From b697e4c9f0c0e9fd3878df0bfe93cd9e704bd6ec Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 4 Sep 2026 12:18:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 142 +++++++++- firstleg/AGENTS.md | 7 +- .../client/WarehousePalletLocationClient.java | 88 ++---- .../PlatformInboundMeasurementController.java | 3 +- .../dto/InboundMeasurementResponses.java | 35 ++- .../firstleg/dto/InboundOrderDetail.java | 6 +- .../baiyue/firstleg/dto/LoadingPickDtos.java | 21 +- .../entity/InboundPalletAllocationEntity.java | 13 +- .../entity/LoadingPickItemEntity.java | 4 - .../mapper/InboundPalletAllocationMapper.java | 67 ++++- .../mapper/LoadingPickItemMapper.java | 6 - .../repository/InboundOrderRepository.java | 6 +- .../InboundPalletAllocationRepository.java | 163 ++++++----- .../repository/LoadingPickRepository.java | 27 +- .../service/InboundMeasurementService.java | 65 ++++- .../firstleg/service/LoadingPickService.java | 94 +++--- .../MeasurementLocationOccupationService.java | 75 +++-- .../service/PdaPickSettlementService.java | 137 +++------ .../firstleg/service/SmartLoadingService.java | 9 +- .../resources/db/phase2-inbound-schema.sql | 17 +- .../main/resources/openapi/firstleg-v1.yaml | 41 +++ .../openapi/phase3-pda-convergence-v1.yaml | 4 - ...easurementEvidenceOpenApiContractTest.java | 14 +- .../PdaConvergenceOpenApiContractTest.java | 5 + ...InboundPalletAllocationRepositoryTest.java | 122 ++++---- .../InboundMeasurementServiceTest.java | 69 +++++ .../service/LoadingPickServiceTest.java | 82 +++--- ...surementLocationOccupationServiceTest.java | 126 ++++---- .../service/PdaPickSettlementServiceTest.java | 89 ++---- .../service/SmartLoadingServiceTest.java | 38 +++ .../TaillegRealProducerAcceptanceTest.java | 10 +- .../admin-test-env-modules/coload-groups.mjs | 12 +- .../manual-state-loading-pda.mjs | 2 +- .../phase3-container-loading.mjs | 20 +- .../admin-test-env-modules/phase3-loading.mjs | 8 +- .../phase3-order-splitting-contract.test.mjs | 2 +- .../phase3-order-splitting.mjs | 2 +- .../phase3-producer-chain-contract.test.mjs | 15 +- .../InternalPalletLocationController.java | 37 +++ .../warehouse/dto/LocationBlockDtos.java | 70 +++++ .../warehouse/entity/LocationBlockEntity.java | 31 ++ .../entity/LocationBlockSlotEntity.java | 27 ++ .../warehouse/mapper/LocationBlockMapper.java | 34 +++ .../mapper/LocationBlockSlotMapper.java | 23 ++ .../warehouse/mapper/LocationMapper.java | 10 + .../service/LocationBlockService.java | 268 ++++++++++++++++++ .../db/phase2-pallet-location-schema.sql | 50 ++++ .../resources/openapi/location-block-v1.yaml | 122 ++++++++ .../InternalPalletLocationControllerTest.java | 20 ++ .../WarehouseSchemaContractTest.java | 7 + .../service/LocationBlockServiceTest.java | 176 ++++++++++++ 51 files changed, 1898 insertions(+), 623 deletions(-) create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/dto/LocationBlockDtos.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/entity/LocationBlockEntity.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/entity/LocationBlockSlotEntity.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationBlockMapper.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationBlockSlotMapper.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java create mode 100644 warehouse/src/main/resources/openapi/location-block-v1.yaml create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/service/LocationBlockServiceTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 006089058..f5c8c4c26 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3141,22 +3141,31 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', allocation_no INT UNSIGNED NOT NULL COMMENT '入库单内分配序号', + block_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse整票连续库位块ID', + slot_no INT UNSIGNED NOT NULL COMMENT '连续块内稳定槽位序号', + weight_result_id BIGINT UNSIGNED NULL COMMENT '已绑定称重结果ID;预留空槽为空', + pallet_sequence INT UNSIGNED NULL COMMENT '已绑定称重托盘在整票内的稳定顺序;空槽为空', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务仓库ID', warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键', - pallet_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务托盘ID', - pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', - pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘可用容积快照', + pallet_id BIGINT UNSIGNED NULL COMMENT '仓库服务托盘ID;预留空槽为空', + pallet_code VARCHAR(50) NULL COMMENT '托盘编码快照;预留空槽为空', + pallet_capacity_cbm DECIMAL(18,4) NULL COMMENT '托盘可用容积快照;预留空槽为空', allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本托盘分配体积', allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数', location_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务库位ID', location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照', - reservation_no VARCHAR(64) NOT NULL COMMENT '仓库库位预占单号', + shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '普通连续位不足时是否使用共享A0', + reservation_id BIGINT UNSIGNED NOT NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分槽位', + slot_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED_EMPTY' COMMENT '槽位状态:RESERVED_EMPTY、BOUND、TRANSFERRED、RELEASED', allocation_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED' COMMENT '托盘库位分配状态:RESERVED已预占、CONFIRMED已确认、TRANSFERRED已转移、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 '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_pallet_allocation_no (tenant_id, inbound_id, allocation_no), + UNIQUE KEY uk_pallet_allocation_slot (tenant_id, block_id, slot_no), + UNIQUE KEY uk_pallet_allocation_weight (tenant_id, weight_result_id), KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status), + KEY idx_allocation_reservation (reservation_id), CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB COMMENT='头程侧托盘、库位和仓库预占结果引用'; @@ -10193,11 +10202,58 @@ 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" +); +CALL phase3_pda_add_column( + 'fl_inbound_pallet_allocation', 'slot_no', + "INT UNSIGNED NOT NULL COMMENT '连续块内稳定槽位序号' AFTER block_id" +); +CALL phase3_pda_add_column( + 'fl_inbound_pallet_allocation', 'weight_result_id', + "BIGINT UNSIGNED NULL COMMENT '已绑定称重结果ID;预留空槽为空' AFTER slot_no" +); +CALL phase3_pda_add_column( + 'fl_inbound_pallet_allocation', 'pallet_sequence', + "INT UNSIGNED NULL COMMENT '已绑定称重托盘在整票内的稳定顺序;空槽为空' AFTER weight_result_id" +); +CALL phase3_pda_add_column( + 'fl_inbound_pallet_allocation', 'shared_overflow', + "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '普通连续位不足时是否使用共享A0' AFTER location_code" +); CALL phase3_pda_add_column( 'fl_inbound_pallet_allocation', 'reservation_id', - "BIGINT UNSIGNED NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分托盘' AFTER location_code" + "BIGINT UNSIGNED NOT NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分槽位' AFTER shared_overflow" +); +CALL phase3_pda_add_column( + 'fl_inbound_pallet_allocation', 'slot_status', + "VARCHAR(24) NOT NULL DEFAULT 'RESERVED_EMPTY' COMMENT '槽位状态:RESERVED_EMPTY、BOUND、TRANSFERRED、RELEASED' AFTER reservation_id" ); DROP PROCEDURE IF EXISTS phase3_pda_add_column; + +-- 当前开发基线直接收敛为整票块槽位;旧预占单号不再保留。 +ALTER TABLE fl_inbound_pallet_allocation + MODIFY COLUMN pallet_id BIGINT UNSIGNED NULL COMMENT '仓库服务托盘ID;预留空槽为空', + MODIFY COLUMN pallet_code VARCHAR(50) NULL COMMENT '托盘编码快照;预留空槽为空', + MODIFY COLUMN pallet_capacity_cbm DECIMAL(18,4) NULL COMMENT '托盘可用容积快照;预留空槽为空', + MODIFY COLUMN reservation_id BIGINT UNSIGNED NOT NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分槽位'; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_inbound_pallet_allocation' AND column_name='reservation_no') > 0, + 'ALTER TABLE fl_inbound_pallet_allocation DROP COLUMN reservation_no', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fl_inbound_pallet_allocation' AND index_name='uk_pallet_allocation_slot') = 0, + 'ALTER TABLE fl_inbound_pallet_allocation ADD UNIQUE KEY uk_pallet_allocation_slot (tenant_id,block_id,slot_no)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fl_inbound_pallet_allocation' AND index_name='uk_pallet_allocation_weight') = 0, + 'ALTER TABLE fl_inbound_pallet_allocation ADD UNIQUE KEY uk_pallet_allocation_weight (tenant_id,weight_result_id)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fl_inbound_pallet_allocation' AND index_name='idx_allocation_reservation') = 0, + 'ALTER TABLE fl_inbound_pallet_allocation ADD KEY idx_allocation_reservation (reservation_id)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -- PDA 多人协作:领取粒度由整柜任务下沉为单条拣货明细。 CREATE TABLE IF NOT EXISTS fl_loading_pick_item_assignment ( pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID,同时作为本表主键', @@ -13422,21 +13478,26 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'al CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'allocated_volume_cbm', '本托盘分配体积'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'allocation_no', '入库单内分配序号'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'allocation_status', '托盘库位分配状态:RESERVED已预占、CONFIRMED已确认、TRANSFERRED已转移、RELEASED已释放'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'block_id', 'Warehouse整票连续库位块ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'created_at', '创建时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'id', '头程托盘分配引用ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'inbound_id', '入库单ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'location_code', '库位编码快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'location_id', '仓库服务库位ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'pallet_capacity_cbm', '托盘可用容积快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'pallet_code', '托盘编码快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'pallet_id', '仓库服务托盘ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'pallet_capacity_cbm', '托盘可用容积快照;预留空槽为空'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'pallet_code', '托盘编码快照;预留空槽为空'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'pallet_id', '仓库服务托盘ID;预留空槽为空'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'pallet_sequence', '已绑定称重托盘在整票内的稳定顺序;空槽为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'picked_package_count', '已拣货箱数'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'reservation_id', '仓库预占资源唯一ID,A0共享场景用于区分托盘'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'reservation_no', '仓库库位预占单号'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'reservation_id', '仓库预占资源唯一ID,A0共享场景用于区分槽位'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'shared_overflow', '普通连续位不足时是否使用共享A0'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'slot_no', '连续块内稳定槽位序号'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'slot_status', '槽位状态:RESERVED_EMPTY、BOUND、TRANSFERRED、RELEASED'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'tenant_id', '客户ID和数据隔离键'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'updated_at', '更新时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'warehouse_id', '仓库服务仓库ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id', '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'weight_result_id', '已绑定称重结果ID;预留空槽为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pickup_snapshot', 'address_line', '提货详细地址'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pickup_snapshot', 'city', '城市'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_pickup_snapshot', 'contact_email', '提货联系人邮箱'); @@ -14643,6 +14704,45 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_opera CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', 'source_service', '来源服务'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', 'target_business_id', '目标子入仓单ID'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', 'tenant_id', '仓储公共资源域,固定为0'); + +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, + active_business_block TINYINT GENERATED ALWAYS AS + (CASE WHEN block_status IN ('RESERVED','OCCUPIED') THEN 1 ELSE NULL END) STORED, + 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), + KEY idx_location_block_business (tenant_id, source_service, business_id, block_status), + CONSTRAINT fk_location_block_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), + CONSTRAINT ck_location_block_slot_count CHECK (slot_count BETWEEN 1 AND 200) +) 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, + active_normal_slot TINYINT GENERATED ALWAYS AS + (CASE WHEN resource_status IN ('RESERVED','OCCUPIED') AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED, + 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), + CONSTRAINT fk_location_block_slot_block FOREIGN KEY (block_id) REFERENCES wh_location_block (id), + CONSTRAINT fk_location_block_slot_location FOREIGN KEY (location_id) REFERENCES wh_location (id) +) ENGINE=InnoDB COMMENT='整票库位块槽位;空槽也由当前入仓码独占'; +USE baiyue_firstleg; CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'address_line', '详细地址'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'carrier_code', '船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'city', '城市'); @@ -14876,6 +14976,28 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:同票托盘唯一约束仍阻止托盘主数据复用'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' + AND table_name = 'fl_inbound_pallet_allocation' + AND column_name IN ('block_id','slot_no','weight_result_id','pallet_sequence', + 'shared_overflow','reservation_id','slot_status')) <> 7 + OR (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' + AND table_name = 'fl_inbound_pallet_allocation' + AND column_name = 'reservation_no') <> 0 + OR (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' + AND table_name = 'fl_inbound_pallet_allocation' + AND column_name IN ('pallet_id','pallet_code','pallet_capacity_cbm') + AND is_nullable = 'YES') <> 3 + OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' + AND table_name = 'fl_inbound_pallet_allocation' + AND index_name IN ('uk_pallet_allocation_slot','uk_pallet_allocation_weight') + AND non_unique = 0) <> 2 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:整票库位块槽位字段、空槽语义或唯一索引未正确收敛'; + END IF; IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_carton_label') <> 1 THEN diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index d5e02d165..2217c6aa9 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -81,9 +81,10 @@ - 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间并将该费用版本永久锁定,平台不得再调整;旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 - PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 -- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。当前契约中未传模式且 `locationIds=null` 时按 AUTO 处理。 -- 头程快照必须保存仓库 `reservation_id`。A0 是共享临时库位,多个托盘可具有相同 `location_id`,跨服务结算、转移、释放必须使用 `reservationId`,不得把 `locationId` 当作唯一资源标识。 -- 部分拣货资源结算必须调用 Warehouse 的 `/internal/warehouse/v1/pallet-locations/settlements` 原子接口;禁止拆成独立释放和转移调用。全部释放但子单仍有数量时,由仓库服务重新选择同仓普通库位,不足才落 A0。 +- 测量提交按 `max(已确认称重图片数, ceil(实测体积 / 默认托盘容量))` 预留整票库位块;实际称重图片数与预留槽位数独立。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定块内最小空槽,禁止按列表下标推断。普通位不足时 Warehouse 可将剩余槽位放入共享 A0,不阻断测量提交。 +- 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。 +- 头程快照必须保存 Warehouse `blockId + slotNo + reservationId`;A0 可被多个整票块共享 `locationId`,业务资源身份始终使用 `reservationId`,不得把 `locationId` 当作唯一标识。 +- PDA 部分拣货不得释放块内单槽;存在余量子单时通过 `/internal/warehouse/v1/pallet-locations/blocks/transfer` 将整段转移给子单。整票全部拣完时只通过 `/internal/warehouse/v1/pallet-locations/blocks/release` 一次性释放全部普通位和 A0 预约,禁止恢复逐槽 `settlements/release-selected` 协议。 - 数据库字段变更直接维护当前模块基线与 `../deploy/sql/头程数据库统一增量-MySQL8.sql`;已有 `phase3-pda-release-allocation-migration.sql` 只作为当前结构来源,不为历史开发数据再新增兼容迁移。 ## 全量验收导航 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 4d9c95625..fd92f964c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java @@ -30,35 +30,21 @@ public interface WarehousePalletLocationClient { @org.springframework.web.bind.annotation.PathVariable("palletId") long palletId, @RequestParam("warehouseId") long warehouseId); - @PostMapping("/pallet-locations/reservations") - ApiResponse reserve(@RequestHeader("X-Internal-Service-Token") String token, - @RequestBody ReserveRequest request); + @PostMapping("/pallet-locations/blocks/reservations") + ApiResponse reserveBlock(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody BlockReserveRequest request); - @PostMapping("/pallet-locations/confirm") - ApiResponse confirm(@RequestHeader("X-Internal-Service-Token") String token, - @RequestBody ConfirmRequest request); + @PostMapping("/pallet-locations/blocks/confirm") + ApiResponse confirmBlock(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody BlockConfirmRequest request); - @PostMapping("/pallet-locations/release") - ApiResponse> release(@RequestHeader("X-Internal-Service-Token") String token, - @RequestBody ReleaseRequest request); + @PostMapping("/pallet-locations/blocks/transfer") + ApiResponse transferBlock(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody BlockTransferRequest request); - @PostMapping("/pallet-locations/release-selected") - ApiResponse> releaseSelected( - @RequestHeader("X-Internal-Service-Token") String token, - @RequestBody SelectiveReleaseRequest request); - - @PostMapping("/pallet-locations/transfer") - ApiResponse> transfer(@RequestHeader("X-Internal-Service-Token") String token, - @RequestBody TransferRequest request); - - @PostMapping("/pallet-locations/settlements") - ApiResponse settle(@RequestHeader("X-Internal-Service-Token") String token, - @RequestBody SettlementRequest request); - - @GetMapping("/pallet-locations/settlements/target") - ApiResponse settlementTarget(@RequestHeader("X-Internal-Service-Token") String token, - @RequestParam("sourceBusinessId") long sourceBusinessId, - @RequestParam("idempotencyKey") String idempotencyKey); + @PostMapping("/pallet-locations/blocks/release") + ApiResponse releaseBlock(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody BlockReleaseRequest request); @GetMapping("/pallet-locations/occupied-resources") ApiResponse> occupiedResources( @@ -67,44 +53,18 @@ public interface WarehousePalletLocationClient { @RequestParam("sourceService") String sourceService, @RequestParam("businessId") long businessId); - record ReserveRequest(long tenantId, long warehouseId, String sourceService, long businessId, - String inboundCode, String idempotencyKey, long operatorId, - BigDecimal totalVolumeCbm, List pallets, - LocalDateTime expiresAt, boolean measurementCompleted) {} - record PalletReference(long weightResultId, long palletId) {} - record ConfirmRequest(long tenantId, long warehouseId, String sourceService, long businessId, - long operatorId, BigDecimal totalVolumeCbm, int packageCount) {} - record ReleaseRequest(long tenantId, String sourceService, long businessId, long operatorId, - String reason) {} - record SelectiveReleaseRequest(long tenantId, String sourceService, long businessId, - String idempotencyKey, long operatorId, String reason, - List locationIds) {} - record AllocationResult(long reservationId, String reservationNo, int allocationNo, - long palletId, String palletCode, BigDecimal palletCapacityCbm, - long locationId, String locationCode, - BigDecimal allocatedVolumeCbm, String state) {} - record BatchResult(int palletCount, List allocations) {} - record ActiveResource(long reservationId, long warehouseId, long palletId, String palletCode, - long locationId, String locationCode, String state) {} - record SelectiveReleaseResult(long reservationId, long occupancyId, int allocationNo, - long palletId, long locationId, String locationCode, - int allocatedPackageCount) {} - record TransferItem(long locationId, int remainingPackageCount) {} - record TransferRequest(long tenantId, String sourceService, long sourceBusinessId, - long targetBusinessId, String targetInboundCode, String idempotencyKey, - long operatorId, List items) {} - record TransferResult(long reservationId, String reservationNo, long occupancyId, int allocationNo, - long warehouseResourceTenantId, long warehouseId, long palletId, - String palletCode, BigDecimal palletCapacityCbm, long locationId, - String locationCode, BigDecimal allocatedVolumeCbm, - int remainingPackageCount, String state) {} - record SettlementTransferItem(long reservationId, int remainingPackageCount) {} - record SettlementRequest(long warehouseId, String sourceService, long sourceBusinessId, - long targetBusinessId, int sourcePackageCount, int targetPackageCount, - String targetInboundCode, String idempotencyKey, long operatorId, - String operatorName, String requestId, List releasedReservationIds, - List remainingItems) {} - record SettlementResult(List released, List transferred) {} + record BlockReserveRequest(long warehouseId, String sourceService, long businessId, + String inboundCode, int requiredSlotCount, String idempotencyKey, + long operatorId, LocalDateTime expiresAt) {} + record BlockConfirmRequest(long blockId, long businessId, String idempotencyKey, + long operatorId) {} + record BlockTransferRequest(long blockId, long sourceBusinessId, long targetBusinessId, + String targetInboundCode, String idempotencyKey, long operatorId) {} + record BlockReleaseRequest(long blockId, long businessId, String idempotencyKey, + long operatorId, String reason) {} + 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) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index a517534b3..c83eb68ad 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -216,7 +216,8 @@ public class PlatformInboundMeasurementController { @BusinessAudit(event = "INBOUND_WEIGHT_IMAGE_DELETE", resourceType = "INBOUND_WEIGHT_RESULT", resourceId = "#resultId", description = "删除称重图片及重量结果") - @Operation(summary = "删除称重图片", description = "删除重量结果和文件;已确认图片删除后重新汇总实际总毛重。") + @Operation(summary = "删除称重图片", + description = "先将图片绑定槽恢复为 RESERVED_EMPTY,再删除重量结果和文件;整票库位块继续归当前入仓码。") @DeleteMapping("/weight-results/{resultId}") @PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')") public ApiResponse deleteWeightResult( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java index 6863911ec..8d118da90 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -47,7 +47,8 @@ public final class InboundMeasurementResponses { @Schema(description = "Order 服务中的最新客户申报,只用于和既有实测事实对比;不得回写测量、箱码或审核结果") DeclarationSnapshot latestDeclaration, List items, - List weightResults + List weightResults, + PalletLocationGuide palletLocationGuide ) { } @@ -129,10 +130,42 @@ public final class InboundMeasurementResponses { LocalDateTime adjustedAt, Long confirmedBy, LocalDateTime confirmedAt, + Integer palletSequence, + Long blockId, + Integer slotNo, + Long reservationId, + Long locationId, + String locationCode, + Boolean sharedOverflow, + String slotStatus, Integer version ) { } + /** 服务端权威的整票上架引导;包含尚未绑定称重图片的预留空槽。 */ + public record PalletLocationGuide( + Long blockId, + Integer reservedSlotCount, + Integer actualPalletCount, + Integer reservedEmptySlotCount, + List slots + ) { + } + + public record PalletLocationSlot( + Integer slotNo, + Long reservationId, + Long locationId, + String locationCode, + Boolean sharedOverflow, + String slotStatus, + Long boundWeightResultId, + Integer palletSequence, + Long palletId, + String palletCode + ) { + } + public record PalletSnapshot( Long palletId, String palletCode, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderDetail.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderDetail.java index c511b95e0..0166f7f83 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderDetail.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderDetail.java @@ -14,9 +14,11 @@ public record InboundOrderDetail( List statusHistory ) { public record PalletLocationSummary( - Integer allocationNo, Long warehouseId, Long palletId, String palletCode, + Integer allocationNo, Long blockId, Integer slotNo, Long weightResultId, + Integer palletSequence, Long warehouseId, Long palletId, String palletCode, BigDecimal palletCapacityCbm, BigDecimal allocatedVolumeCbm, Integer allocatedPackageCount, - Long locationId, String locationCode, String reservationNo, String allocationStatus) { + Long reservationId, Long locationId, String locationCode, Boolean sharedOverflow, + String slotStatus, String allocationStatus) { } public record FileSummary( 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 519fa5485..3a68085fa 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -1,6 +1,5 @@ package com.baiyue.firstleg.dto; -import com.fasterxml.jackson.annotation.JsonAlias; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; @@ -20,31 +19,17 @@ public final class LoadingPickDtos { public record PickRequest( @NotNull @Min(1) Long warehouseId, @NotNull @Min(1) Integer pickedPackageCount, - @Pattern(regexp = "AUTO|MANUAL") String releaseMode, - @JsonAlias("reservationIds") List<@Min(1) Long> locationIds, @NotNull @Min(0) Integer version, @NotBlank @Size(max = 128) String operationId - ) { - public PickRequest(Long warehouseId, Integer pickedPackageCount, List locationIds, - Integer version, String operationId) { - this(warehouseId, pickedPackageCount, null, locationIds, version, operationId); - } - } + ) {} /** 部分拣货为该 PDA 明细的最终实际拣出箱数,不是增量。 */ public record PartialPickRequest( @NotNull @Min(1) Long warehouseId, @NotNull @Min(1) Integer finalPickedPackageCount, - @Pattern(regexp = "AUTO|MANUAL") String releaseMode, - @JsonAlias("reservationIds") List<@Min(1) Long> locationIds, @NotNull @Min(0) Integer version, @NotBlank @Size(max = 128) String operationId - ) { - public PartialPickRequest(Long warehouseId, Integer finalPickedPackageCount, List locationIds, - Integer version, String operationId) { - this(warehouseId, finalPickedPackageCount, null, locationIds, version, operationId); - } - } + ) {} /** 跳拣只能发生在尚未拣出任何箱数的 PDA 明细上。 */ public record SkipRequest( @@ -70,8 +55,6 @@ public final class LoadingPickDtos { @NotBlank @Pattern(regexp = "PICK|PARTIAL|SKIP") String action, @Min(1) Integer pickedPackageCount, @Min(1) Integer finalPickedPackageCount, - @Pattern(regexp = "AUTO|MANUAL") String releaseMode, - @JsonAlias("reservationIds") List<@Min(1) Long> locationIds, @NotNull @Min(0) Integer version, @Size(max = 500) String reason ) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java index 58c636deb..d091fdc34 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java @@ -19,6 +19,14 @@ public class InboundPalletAllocationEntity { private Long tenantId; private Long inboundId; private Integer allocationNo; + /** 整票连续库位块标识;同一入库单的活动槽位共享该标识。 */ + private Long blockId; + /** 连续块内从 1 开始的稳定槽位序号。 */ + private Integer slotNo; + /** 称重图片对应的稳定重量结果;空槽允许为空。 */ + private Long weightResultId; + /** 已绑定称重托盘在整票内的稳定展示顺序;空槽允许为空。 */ + private Integer palletSequence; private Long warehouseId; private Long warehouseResourceTenantId; private Long palletId; @@ -29,9 +37,12 @@ public class InboundPalletAllocationEntity { private Integer pickedPackageCount; private Long locationId; private String locationCode; + /** 普通连续位不足时由 Warehouse 分配到共享 A0。 */ + private Boolean sharedOverflow; /** 仓库资源唯一预占标识;A0 多托盘场景不能使用 locationId 作为唯一标识。 */ private Long reservationId; - private String reservationNo; + /** 槽位状态:RESERVED_EMPTY、BOUND、TRANSFERRED、RELEASED。 */ + private String slotStatus; private String allocationStatus; private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java index b271e9f78..1c8349f7b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java @@ -23,10 +23,6 @@ public class LoadingPickItemEntity { private Integer plannedPackageCount; private Integer pickedPackageCount; private String itemStatus; - /** AUTO 按比例释放,MANUAL 按指定库位释放。 */ - private String releaseMode; - /** 人工选择的库位 ID,JSON 数组;空数组表示明确不释放。 */ - private String releaseLocationIds; @Version private Integer version; private LocalDateTime createdAt; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java index e38ecdb2f..4cb90cda4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java @@ -9,6 +9,41 @@ import org.apache.ibatis.annotations.Update; @Mapper public interface InboundPalletAllocationMapper extends BaseMapper { + @Select(""" + SELECT * FROM fl_inbound_pallet_allocation + WHERE tenant_id = #{tenantId} AND inbound_id = #{inboundId} + AND slot_status = 'RESERVED_EMPTY' + AND allocation_status IN ('RESERVED', 'CONFIRMED') + ORDER BY slot_no ASC + LIMIT 1 FOR UPDATE + """) + InboundPalletAllocationEntity selectNextEmptyForUpdate( + @Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + + @Update(""" + UPDATE fl_inbound_pallet_allocation + SET weight_result_id=#{weightResultId}, pallet_sequence=#{palletSequence}, + pallet_id=#{palletId}, pallet_code=#{palletCode}, + pallet_capacity_cbm=#{palletCapacityCbm}, slot_status='BOUND', + updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{id} AND tenant_id=#{tenantId} AND inbound_id=#{inboundId} + AND slot_status='RESERVED_EMPTY' AND weight_result_id IS NULL + """) + int bindWeight(@Param("id") long id, @Param("tenantId") long tenantId, + @Param("inboundId") long inboundId, @Param("weightResultId") long weightResultId, + @Param("palletSequence") int palletSequence, @Param("palletId") long palletId, + @Param("palletCode") String palletCode, + @Param("palletCapacityCbm") java.math.BigDecimal palletCapacityCbm); + + @Update(""" + UPDATE fl_inbound_pallet_allocation + SET weight_result_id=NULL, pallet_sequence=NULL, pallet_id=NULL, pallet_code=NULL, + pallet_capacity_cbm=NULL, slot_status='RESERVED_EMPTY', updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{id} AND tenant_id=#{tenantId} AND inbound_id=#{inboundId} + AND weight_result_id=#{weightResultId} AND slot_status='BOUND' + """) + int unbindWeight(@Param("id") long id, @Param("tenantId") long tenantId, + @Param("inboundId") long inboundId, @Param("weightResultId") long weightResultId); @Select(""" SELECT * FROM fl_inbound_pallet_allocation @@ -39,21 +74,29 @@ public interface InboundPalletAllocationMapper extends BaseMapper SET item_status = CASE WHEN picked_package_count + #{count} = planned_package_count THEN 'PICKED' ELSE 'PICKING' END, picked_package_count = picked_package_count + #{count}, - release_mode = #{releaseMode}, release_location_ids = #{releaseLocationIds}, version = version + 1 WHERE id = #{id} AND version = #{version} AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item @@ -182,15 +181,12 @@ public interface LoadingPickItemMapper extends BaseMapper AND picked_package_count + #{count} <= planned_package_count """) int pick(@Param("id") long id, @Param("count") int count, - @Param("releaseMode") String releaseMode, - @Param("releaseLocationIds") String releaseLocationIds, @Param("version") int version, @Param("warehouseId") long warehouseId); @Update(""" UPDATE fl_loading_pick_item SET picked_package_count = #{finalCount}, item_status = 'PARTIAL', - release_mode = #{releaseMode}, release_location_ids = #{releaseLocationIds}, version = version + 1 WHERE id = #{id} AND version = #{version} AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item @@ -203,8 +199,6 @@ public interface LoadingPickItemMapper extends BaseMapper AND #{finalCount} < planned_package_count """) int partial(@Param("id") long id, @Param("finalCount") int finalCount, - @Param("releaseMode") String releaseMode, - @Param("releaseLocationIds") String releaseLocationIds, @Param("version") int version, @Param("warehouseId") long warehouseId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java index e11916d90..0ab929533 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java @@ -212,9 +212,11 @@ public class InboundOrderRepository { .orderByAsc(InboundPalletAllocationEntity::getAllocationNo)); List palletSummaries = palletAllocations.stream() .map(item -> new InboundOrderDetail.PalletLocationSummary( - item.getAllocationNo(), item.getWarehouseId(), item.getPalletId(), item.getPalletCode(), + item.getAllocationNo(), item.getBlockId(), item.getSlotNo(), item.getWeightResultId(), + item.getPalletSequence(), item.getWarehouseId(), item.getPalletId(), item.getPalletCode(), item.getPalletCapacityCbm(), item.getAllocatedVolumeCbm(), item.getAllocatedPackageCount(), - item.getLocationId(), item.getLocationCode(), item.getReservationNo(), item.getAllocationStatus())) + item.getReservationId(), item.getLocationId(), item.getLocationCode(), item.getSharedOverflow(), + item.getSlotStatus(), item.getAllocationStatus())) .toList(); List fileSummaries = files.selectList( owned(InboundFileEntity::getTenantId, InboundFileEntity::getInboundId, tenantId, inboundId) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java index 0252250f9..5ba3430a3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java @@ -3,66 +3,114 @@ package com.baiyue.firstleg.repository; import jakarta.annotation.Resource; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baiyue.firstleg.client.WarehousePalletLocationClient.BatchResult; +import com.baiyue.firstleg.client.WarehousePalletLocationClient.BlockResult; import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.time.LocalDateTime; -import java.util.Map; import java.util.List; -import java.util.function.Function; -import java.util.stream.Collectors; @Repository public class InboundPalletAllocationRepository { @Resource private InboundPalletAllocationMapper mapper; + public List list(long tenantId, long inboundId) { + return mapper.selectList(new LambdaQueryWrapper() + .eq(InboundPalletAllocationEntity::getTenantId, tenantId) + .eq(InboundPalletAllocationEntity::getInboundId, inboundId) + .orderByAsc(InboundPalletAllocationEntity::getSlotNo) + .orderByAsc(InboundPalletAllocationEntity::getAllocationNo)); + } + + public InboundPalletAllocationEntity findByWeightResult(long tenantId, long inboundId, + long weightResultId) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(InboundPalletAllocationEntity::getTenantId, tenantId) + .eq(InboundPalletAllocationEntity::getInboundId, inboundId) + .eq(InboundPalletAllocationEntity::getWeightResultId, weightResultId)); + } + @Transactional - public void replace(long tenantId, long inboundId, long warehouseId, long warehouseResourceTenantId, - int totalPackageCount, BatchResult result) { - Map reservationNos = mapper.selectList(new LambdaQueryWrapper() - .eq(InboundPalletAllocationEntity::getTenantId, tenantId) - .eq(InboundPalletAllocationEntity::getInboundId, inboundId)) - .stream().collect(Collectors.toMap(InboundPalletAllocationEntity::getReservationId, - InboundPalletAllocationEntity::getReservationNo, (left, right) -> left)); + public void replaceBlock(long tenantId, long inboundId, long warehouseId, + int totalPackageCount, BigDecimal totalVolumeCbm, BlockResult result) { mapper.delete(new LambdaQueryWrapper() .eq(InboundPalletAllocationEntity::getTenantId, tenantId) .eq(InboundPalletAllocationEntity::getInboundId, inboundId)); LocalDateTime now = LocalDateTime.now(); - int allocationCount = result.allocations().size(); + int allocationCount = result.resources().size(); int basePackageCount = allocationCount == 0 ? 0 : totalPackageCount / allocationCount; int remainder = allocationCount == 0 ? 0 : totalPackageCount % allocationCount; - for (int index = 0; index < result.allocations().size(); index++) { - var source = result.allocations().get(index); + for (int index = 0; index < result.resources().size(); index++) { + var source = result.resources().get(index); InboundPalletAllocationEntity value = new InboundPalletAllocationEntity(); value.setTenantId(tenantId); value.setInboundId(inboundId); - value.setAllocationNo(source.allocationNo()); + value.setAllocationNo(source.slotNo()); + value.setBlockId(source.blockId()); + value.setSlotNo(source.slotNo()); value.setWarehouseId(warehouseId); - value.setWarehouseResourceTenantId(warehouseResourceTenantId); - value.setPalletId(source.palletId()); - value.setPalletCode(source.palletCode()); - value.setPalletCapacityCbm(source.palletCapacityCbm()); - value.setAllocatedVolumeCbm(source.allocatedVolumeCbm()); + value.setWarehouseResourceTenantId(0L); + value.setAllocatedVolumeCbm(totalVolumeCbm.divide(BigDecimal.valueOf(allocationCount), 4, + java.math.RoundingMode.HALF_UP)); int allocatedPackages = basePackageCount + (index < remainder ? 1 : 0); value.setAllocatedPackageCount(allocatedPackages); value.setPickedPackageCount(0); value.setLocationId(source.locationId()); value.setLocationCode(source.locationCode()); + value.setSharedOverflow(source.sharedOverflow()); value.setReservationId(source.reservationId()); - // 仓储确认响应只返回 reservationId;沿用预占阶段的预占单号快照。 - value.setReservationNo(source.reservationNo() != null ? source.reservationNo() - : reservationNos.get(source.reservationId())); - value.setAllocationStatus(normalize(source.state())); + value.setSlotStatus("RELEASED".equals(source.resourceStatus()) ? "RELEASED" : "RESERVED_EMPTY"); + value.setAllocationStatus(normalize(source.resourceStatus())); value.setCreatedAt(now); value.setUpdatedAt(now); mapper.insert(value); } } + /** 已确认图片按 confirmedAt、weightResultId 顺序绑定块内下一个空槽,不依赖列表下标。 */ + @Transactional + public void bindConfirmedWeights(long tenantId, long inboundId, List weights) { + weights.stream().filter(weight -> "CONFIRMED".equals(weight.getRecognitionStatus())) + .sorted(java.util.Comparator.comparing(InboundWeightResultEntity::getConfirmedAt, + java.util.Comparator.nullsLast(LocalDateTime::compareTo)) + .thenComparing(InboundWeightResultEntity::getId)) + .forEach(weight -> bindNextEmpty(tenantId, inboundId, weight)); + } + + @Transactional + public InboundPalletAllocationEntity bindNextEmpty(long tenantId, long inboundId, + InboundWeightResultEntity weight) { + InboundPalletAllocationEntity existing = findByWeightResult(tenantId, inboundId, weight.getId()); + if (existing != null) return existing; + InboundPalletAllocationEntity empty = mapper.selectNextEmptyForUpdate(tenantId, inboundId); + if (empty == null) { + throw new IllegalStateException("整票预留库位块没有可绑定的空槽"); + } + if (mapper.bindWeight(empty.getId(), tenantId, inboundId, weight.getId(), empty.getSlotNo(), + weight.getPalletId(), weight.getPalletCode(), weight.getPalletCapacityCbm()) != 1) { + throw new IllegalStateException("整票库位槽已被并发绑定"); + } + empty.setWeightResultId(weight.getId()); + empty.setPalletSequence(empty.getSlotNo()); + empty.setPalletId(weight.getPalletId()); + empty.setPalletCode(weight.getPalletCode()); + empty.setPalletCapacityCbm(weight.getPalletCapacityCbm()); + empty.setSlotStatus("BOUND"); + return empty; + } + + @Transactional + public void unbindWeight(long tenantId, long inboundId, long weightResultId) { + InboundPalletAllocationEntity existing = findByWeightResult(tenantId, inboundId, weightResultId); + if (existing != null && mapper.unbindWeight(existing.getId(), tenantId, inboundId, weightResultId) != 1) { + throw new IllegalStateException("整票库位槽解绑失败"); + } + } + public List lockConfirmed(long tenantId, long inboundId) { return mapper.selectConfirmedForUpdate(tenantId, inboundId); } @@ -73,54 +121,17 @@ public class InboundPalletAllocationRepository { } } - /** 父单快照在仓库资源转移前标记为已转移,后续锁查询不会再将其当作可用资源。 */ - public void markTransferred(long tenantId, long inboundId, List allocations, - List releasedReservationIds) { - for (InboundPalletAllocationEntity allocation : allocations) { - if (releasedReservationIds.contains(allocation.getReservationId())) { - continue; - } - int allocated = allocation.getAllocatedPackageCount() == null ? 0 : allocation.getAllocatedPackageCount(); - int picked = allocation.getPickedPackageCount() == null ? 0 : allocation.getPickedPackageCount(); - if (allocated <= picked) { - continue; - } - if (allocation.getId() == null) { - throw new IllegalStateException("库位资源转移状态已被并发修改"); - } - int changed = mapper.markTransferred(allocation.getId(), tenantId, inboundId); - if (changed != 1 && !isAllocationIdState(allocation.getId(), tenantId, inboundId, "TRANSFERRED")) { - throw new IllegalStateException("库位资源转移状态已被并发修改"); - } - } + /** 余量子单存在时整票块转移,包含已经拣空和预留空槽。 */ + public void markBlockTransferred(long tenantId, long inboundId, long blockId) { + int expected = mapper.countBlock(tenantId, inboundId, blockId); + int changed = mapper.markBlockTransferred(tenantId, inboundId, blockId); + if (expected < 1 || changed != expected) throw new IllegalStateException("整票库位块转移状态已被并发修改"); } - public void markReleased(long tenantId, long inboundId, List reservationIds) { - for (Long reservationId : reservationIds) { - if (reservationId == null) { - throw new IllegalStateException("库位释放状态已被并发修改"); - } - int changed = mapper.markReleased(tenantId, inboundId, reservationId); - if (changed != 1 && !isReservationState(reservationId, tenantId, inboundId, "RELEASED")) { - throw new IllegalStateException("库位释放状态已被并发修改"); - } - } - } - - private boolean isAllocationIdState(long id, long tenantId, long inboundId, String status) { - InboundPalletAllocationEntity current = mapper.selectOne(new LambdaQueryWrapper() - .eq(InboundPalletAllocationEntity::getTenantId, tenantId) - .eq(InboundPalletAllocationEntity::getInboundId, inboundId) - .eq(InboundPalletAllocationEntity::getId, id)); - return current != null && status.equals(current.getAllocationStatus()); - } - - private boolean isReservationState(long reservationId, long tenantId, long inboundId, String status) { - InboundPalletAllocationEntity current = mapper.selectOne(new LambdaQueryWrapper() - .eq(InboundPalletAllocationEntity::getTenantId, tenantId) - .eq(InboundPalletAllocationEntity::getInboundId, inboundId) - .eq(InboundPalletAllocationEntity::getReservationId, reservationId)); - return current != null && status.equals(current.getAllocationStatus()); + public void markBlockReleased(long tenantId, long inboundId, long blockId) { + int expected = mapper.countBlock(tenantId, inboundId, blockId); + int changed = mapper.markBlockReleased(tenantId, inboundId, blockId); + if (expected < 1 || changed != expected) throw new IllegalStateException("整票库位块释放状态已被并发修改"); } /** @@ -128,7 +139,7 @@ public class InboundPalletAllocationRepository { * 仓库 transfer 是本地事务最后一个外部动作,成功后不再执行本地写入,避免跨服务重试时目标子单变化。 */ @Transactional - public void copyRemainingToChild(long tenantId, long inboundId, List parent) { + public void copyBlockToChild(long tenantId, long inboundId, List parent) { mapper.delete(new LambdaQueryWrapper() .eq(InboundPalletAllocationEntity::getTenantId, tenantId) .eq(InboundPalletAllocationEntity::getInboundId, inboundId)); @@ -137,9 +148,6 @@ public class InboundPalletAllocationRepository { int allocated = source.getAllocatedPackageCount() == null ? 0 : source.getAllocatedPackageCount(); int picked = source.getPickedPackageCount() == null ? 0 : source.getPickedPackageCount(); int remaining = allocated - picked; - if (remaining <= 0) { - continue; - } InboundPalletAllocationEntity value = new InboundPalletAllocationEntity(); value.setTenantId(tenantId); value.setInboundId(inboundId); @@ -155,7 +163,12 @@ public class InboundPalletAllocationRepository { value.setLocationId(source.getLocationId()); value.setLocationCode(source.getLocationCode()); value.setReservationId(source.getReservationId()); - value.setReservationNo(source.getReservationNo()); + value.setBlockId(source.getBlockId()); + value.setSlotNo(source.getSlotNo()); + value.setWeightResultId(source.getWeightResultId()); + value.setPalletSequence(source.getPalletSequence()); + value.setSharedOverflow(source.getSharedOverflow()); + value.setSlotStatus(source.getSlotStatus()); value.setAllocationStatus("CONFIRMED"); value.setCreatedAt(now); value.setUpdatedAt(now); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java index d4489ef74..9ce81a08f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java @@ -135,14 +135,12 @@ public class LoadingPickRepository { .eq(LoadingPickItemEntity::getLoadingPlanItemId, planItemId)); } - public int pick(long itemId, int count, String releaseMode, String releaseLocationIds, - int version, long warehouseId) { - return items.pick(itemId, count, releaseMode, releaseLocationIds, version, warehouseId); + public int pick(long itemId, int count, int version, long warehouseId) { + return items.pick(itemId, count, version, warehouseId); } - public int partial(long itemId, int finalCount, String releaseMode, String releaseLocationIds, - int version, long warehouseId) { - return items.partial(itemId, finalCount, releaseMode, releaseLocationIds, version, warehouseId); + public int partial(long itemId, int finalCount, int version, long warehouseId) { + return items.partial(itemId, finalCount, version, warehouseId); } public int skip(long itemId, int version, long warehouseId) { @@ -174,6 +172,12 @@ public class LoadingPickRepository { .eq(LoadingPickHistoryEntity::getOperationId, operationId)); } + public LoadingPickHistoryEntity historyForUpdate(String operationId) { + return histories.selectOne(new LambdaQueryWrapper() + .eq(LoadingPickHistoryEntity::getOperationId, operationId) + .last("FOR UPDATE")); + } + public void insert(LoadingPickHistoryEntity history) { histories.insert(history); } @@ -215,6 +219,17 @@ public class LoadingPickRepository { .eq(LoadingPickOperationEntity::getOperationId, operationId)); } + /** + * Current read used after the pick-task row lock has serialized concurrent commands. + * A normal repeatable-read lookup would keep the transaction's earlier snapshot and + * miss an operation committed while this request was waiting for that row lock. + */ + public LoadingPickOperationEntity operationForUpdate(String operationId) { + return operations.selectOne(new LambdaQueryWrapper() + .eq(LoadingPickOperationEntity::getOperationId, operationId) + .last("FOR UPDATE")); + } + public void insert(LoadingPickOperationEntity operation) { operations.insert(operation); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index 6798fe9fa..313973c47 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -19,6 +19,7 @@ import com.baiyue.firstleg.entity.InboundWeightResultEntity; import com.baiyue.firstleg.entity.InboundWeightRevisionEntity; import com.baiyue.firstleg.entity.MeasureReviewRecordEntity; import com.baiyue.firstleg.repository.InboundMeasurementRepository; +import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -30,6 +31,7 @@ import java.math.RoundingMode; import java.time.LocalDateTime; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; /** 平台端入库测量、OCR 修正、重量确认和提交审核应用服务。 */ @@ -63,6 +65,8 @@ public class InboundMeasurementService { private MeasurementImageClient imageClient; @Resource private WarehousePalletLocationClient warehousePallets; + @Resource + private InboundPalletAllocationRepository palletAllocations; public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); @@ -82,9 +86,18 @@ public class InboundMeasurementService { List items = measurement == null ? List.of() : repository.listItems(tenantId, measurement.getId()); List weights = repository.listWeights(tenantId, inbound.getId()); + List allocationSlots = + palletAllocations.list(tenantId, inbound.getId()); + Map allocationsByWeight = + allocationSlots.stream() + .filter(item -> item.getWeightResultId() != null) + .collect(java.util.stream.Collectors.toMap( + com.baiyue.firstleg.entity.InboundPalletAllocationEntity::getWeightResultId, + java.util.function.Function.identity(), (left, right) -> left)); MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inbound.getId()); InboundMeasurementResponses.DeclarationSnapshot latestDeclaration = loadLatestDeclaration(inbound); - return toDetail(inbound, measurement, items, weights, review, latestDeclaration); + return toDetail(inbound, measurement, items, weights, allocationSlots, allocationsByWeight, + review, latestDeclaration); } public PageResult weightPalletOptions( @@ -311,6 +324,9 @@ public class InboundMeasurementService { weight.setVersion(confirmedVersion); insertRevision(weight, confirmedVersion, "CONFIRM", value, unit, value, unit, grossWeightKg, netWeightKg, null, operatorId, operatorName); + if (!palletAllocations.list(tenantId, inboundId).isEmpty()) { + palletAllocations.bindNextEmpty(tenantId, inboundId, weight); + } recalculateConfirmedWeight(inbound, operatorId); return toWeight(weight); } @@ -322,6 +338,7 @@ public class InboundMeasurementService { requireWeightEditableInbound(inbound); InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId); requireVersion(version, weight.getVersion()); + palletAllocations.unbindWeight(tenantId, inboundId, resultId); if (!repository.deleteWeight(tenantId, inboundId, resultId, version)) { throw concurrentModification(); } @@ -433,6 +450,8 @@ public class InboundMeasurementService { private InboundMeasurementResponses.Detail toDetail( InboundOrderEntity inbound, InboundMeasurementEntity measurement, List items, List weights, + List allocationSlots, + Map allocationsByWeight, MeasureReviewRecordEntity review, InboundMeasurementResponses.DeclarationSnapshot latestDeclaration) { return new InboundMeasurementResponses.Detail( @@ -460,7 +479,32 @@ public class InboundMeasurementService { measurement != null && StringUtils.hasText(measurement.getActualGoodsName()) ? measurement.getActualGoodsName() : latestDeclaration.goodsName(), latestDeclaration, - items.stream().map(this::toItem).toList(), weights.stream().map(this::toWeight).toList()); + items.stream().map(this::toItem).toList(), weights.stream() + .map(weight -> toWeight(weight, allocationsByWeight.get(weight.getId()))).toList(), + toPalletLocationGuide(allocationSlots)); + } + + private static InboundMeasurementResponses.PalletLocationGuide toPalletLocationGuide( + List slots) { + if (slots.isEmpty()) return null; + Long blockId = slots.get(0).getBlockId(); + if (blockId == null || slots.stream().anyMatch(slot -> !java.util.Objects.equals(blockId, slot.getBlockId()))) { + throw new BizException(ResultCode.CONFLICT, "入库单整票库位块快照不一致"); + } + List ordered = slots.stream() + .sorted(java.util.Comparator.comparing( + com.baiyue.firstleg.entity.InboundPalletAllocationEntity::getSlotNo, + java.util.Comparator.nullsLast(Integer::compareTo))) + .map(slot -> new InboundMeasurementResponses.PalletLocationSlot( + slot.getSlotNo(), slot.getReservationId(), slot.getLocationId(), slot.getLocationCode(), + slot.getSharedOverflow(), slot.getSlotStatus(), slot.getWeightResultId(), + slot.getPalletSequence(), slot.getPalletId(), slot.getPalletCode())) + .toList(); + int actualPalletCount = (int) slots.stream().filter(slot -> slot.getWeightResultId() != null).count(); + int reservedEmptySlotCount = (int) slots.stream() + .filter(slot -> "RESERVED_EMPTY".equals(slot.getSlotStatus())).count(); + return new InboundMeasurementResponses.PalletLocationGuide( + blockId, ordered.size(), actualPalletCount, reservedEmptySlotCount, ordered); } /** @@ -576,6 +620,13 @@ public class InboundMeasurementService { } private InboundMeasurementResponses.WeightResult toWeight(InboundWeightResultEntity weight) { + return toWeight(weight, weight.getId() == null ? null + : palletAllocations.findByWeightResult(weight.getTenantId(), weight.getInboundId(), weight.getId())); + } + + private InboundMeasurementResponses.WeightResult toWeight( + InboundWeightResultEntity weight, + com.baiyue.firstleg.entity.InboundPalletAllocationEntity allocation) { return new InboundMeasurementResponses.WeightResult( weight.getId(), weight.getFileId(), weight.getPalletId(), weight.getPalletCode(), weight.getPalletType(), weight.getPalletCapacityCbm(), weight.getPalletTareWeightKg(), @@ -585,7 +636,15 @@ public class InboundMeasurementService { weight.getAdjustedWeightValue(), weight.getAdjustedWeightUnit(), weight.getNetWeightKg(), weight.getRecognitionStatus(), weight.getAdjustmentReason(), weight.getAdjustedBy(), - weight.getAdjustedAt(), weight.getConfirmedBy(), weight.getConfirmedAt(), weight.getVersion()); + weight.getAdjustedAt(), weight.getConfirmedBy(), weight.getConfirmedAt(), + allocation == null ? null : allocation.getPalletSequence(), + allocation == null ? null : allocation.getBlockId(), + allocation == null ? null : allocation.getSlotNo(), + allocation == null ? null : allocation.getReservationId(), + allocation == null ? null : allocation.getLocationId(), + allocation == null ? null : allocation.getLocationCode(), + allocation == null ? null : allocation.getSharedOverflow(), + allocation == null ? null : allocation.getSlotStatus(), weight.getVersion()); } private PageResult loadPalletSnapshotPage( 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 8036e2801..49c0ffcbe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -253,7 +253,7 @@ public class LoadingPickService { Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); if (replay != null) return replay; LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId); - replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); + replay = replayTaskOperationForUpdate(taskId, operationId, requestHash, operatorId); if (replay != null) return replay; LoadingPickAssignmentEntity existing = repository.assignment(taskId); if (existing == null) { @@ -284,7 +284,7 @@ public class LoadingPickService { Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); if (replay != null) return replay; LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId); - replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); + replay = replayTaskOperationForUpdate(taskId, operationId, requestHash, operatorId); if (replay != null) return replay; LoadingPickAssignmentEntity existing = repository.assignment(taskId); int changed = existing == null @@ -300,10 +300,9 @@ public class LoadingPickService { @Transactional public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) { - ReleaseSelection release = releaseSelection(request.releaseMode(), request.locationIds()); String operationId = request.operationId().trim(); String requestHash = hash(taskId + "|" + itemId + "|" + request.warehouseId() - + "|" + request.pickedPackageCount() + "|" + release.mode() + "|" + release.json()); + + "|" + request.pickedPackageCount()); requireNoTaskOperation(operationId); LoadingPickHistoryEntity replay = repository.history(operationId); if (replay != null) { @@ -314,8 +313,8 @@ public class LoadingPickService { return view(requiredTask(taskId), request.warehouseId()); } LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId); - requireNoTaskOperation(operationId); - replay = repository.history(operationId); + requireNoTaskOperationForUpdate(operationId); + replay = repository.historyForUpdate(operationId); if (replay != null) { assertReplay(replay, taskId, itemId, requestHash); return view(task, request.warehouseId()); @@ -324,8 +323,7 @@ public class LoadingPickService { if (item == null) { throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在"); } - if (repository.pick(itemId, request.pickedPackageCount(), release.mode(), release.json(), - request.version(), request.warehouseId()) != 1) { + if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) { throw conflict("拣货数量超过计划箱数或明细版本已变化"); } LoadingPickHistoryEntity history = new LoadingPickHistoryEntity(); @@ -346,10 +344,9 @@ public class LoadingPickService { @Transactional public Task partial(long taskId, long itemId, PartialPickRequest request, long operatorId, String operatorName) { - ReleaseSelection release = releaseSelection(request.releaseMode(), request.locationIds()); String operationId = request.operationId().trim(); String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId() - + "|" + request.finalPickedPackageCount() + "|" + release.mode() + "|" + release.json()); + + "|" + request.finalPickedPackageCount()); requireNoTaskOperation(operationId); LoadingPickHistoryEntity replay = repository.history(operationId); if (replay != null) { @@ -357,15 +354,15 @@ public class LoadingPickService { return view(requiredTask(taskId), request.warehouseId()); } LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId); - requireNoTaskOperation(operationId); - replay = repository.history(operationId); + requireNoTaskOperationForUpdate(operationId); + replay = repository.historyForUpdate(operationId); if (replay != null) { assertReplay(replay, taskId, itemId, requestHash); return view(task, request.warehouseId()); } requireWarehouseItem(taskId, itemId, request.warehouseId()); - if (repository.partial(itemId, request.finalPickedPackageCount(), release.mode(), release.json(), - request.version(), request.warehouseId()) != 1) { + if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(), + request.warehouseId()) != 1) { throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化"); } insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL", @@ -388,8 +385,8 @@ public class LoadingPickService { return view(requiredTask(taskId), request.warehouseId()); } LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId); - requireNoTaskOperation(operationId); - replay = repository.history(operationId); + requireNoTaskOperationForUpdate(operationId); + replay = repository.historyForUpdate(operationId); if (replay != null) { assertReplay(replay, taskId, itemId, requestHash); return view(task, request.warehouseId()); @@ -420,7 +417,7 @@ public class LoadingPickService { if (replay != null) return replay; LoadingPickTaskEntity task = requiredTaskForUpdate(taskId); - replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); + replay = replayTaskOperationForUpdate(taskId, operationId, requestHash, operatorId); if (replay != null) return replay; if (!"LOADING".equals(task.getTaskStatus())) { throw conflict("装柜任务已完成,不能重复提交整批拣货"); @@ -990,10 +987,8 @@ public class LoadingPickService { throw conflict("PDA明细版本已变化,请刷新后重试"); } String action = requestItem.action().trim().toUpperCase(); - ReleaseSelection release = null; if ("PICK".equals(action)) { requireNoUnexpectedBatchFields(requestItem, false, true); - release = releaseSelection(requestItem.releaseMode(), requestItem.locationIds()); int current = count(item.getPickedPackageCount()); int planned = count(item.getPlannedPackageCount()); if (requestItem.pickedPackageCount() == null @@ -1002,7 +997,6 @@ public class LoadingPickService { } } else if ("PARTIAL".equals(action)) { requireNoUnexpectedBatchFields(requestItem, true, false); - release = releaseSelection(requestItem.releaseMode(), requestItem.locationIds()); int current = count(item.getPickedPackageCount()); int planned = count(item.getPlannedPackageCount()); if (requestItem.finalPickedPackageCount() == null @@ -1011,10 +1005,8 @@ public class LoadingPickService { throw conflict("PARTIAL最终箱数必须大于已拣且小于计划箱数"); } } else if ("SKIP".equals(action)) { - if (requestItem.pickedPackageCount() != null || requestItem.finalPickedPackageCount() != null - || StringUtils.hasText(requestItem.releaseMode()) - || requestItem.locationIds() != null && !requestItem.locationIds().isEmpty()) { - throw new BizException(ResultCode.BAD_REQUEST, "SKIP不能携带拣货数量或释放库位"); + if (requestItem.pickedPackageCount() != null || requestItem.finalPickedPackageCount() != null) { + throw new BizException(ResultCode.BAD_REQUEST, "SKIP不能携带拣货数量"); } if (!StringUtils.hasText(requestItem.reason())) { throw new BizException(ResultCode.BAD_REQUEST, "SKIP必须填写异常原因"); @@ -1027,7 +1019,7 @@ public class LoadingPickService { } else { throw new BizException(ResultCode.BAD_REQUEST, "action只能是PICK、PARTIAL或SKIP"); } - result.add(new BatchLine(item, requestItem, action, release, request.warehouseId())); + result.add(new BatchLine(item, requestItem, action, request.warehouseId())); } result.sort(Comparator.comparing(line -> line.item().getId())); return result; @@ -1130,12 +1122,10 @@ public class LoadingPickService { int pickedCount; if ("PICK".equals(line.action())) { pickedCount = request.pickedPackageCount(); - changed = repository.pick(item.getId(), pickedCount, line.release().mode(), - line.release().json(), request.version(), line.warehouseId()); + changed = repository.pick(item.getId(), pickedCount, request.version(), line.warehouseId()); } else if ("PARTIAL".equals(line.action())) { pickedCount = request.finalPickedPackageCount(); - changed = repository.partial(item.getId(), pickedCount, line.release().mode(), - line.release().json(), request.version(), line.warehouseId()); + changed = repository.partial(item.getId(), pickedCount, request.version(), line.warehouseId()); } else { pickedCount = 0; changed = repository.skip(item.getId(), request.version(), line.warehouseId()); @@ -1145,8 +1135,7 @@ public class LoadingPickService { requireNoTaskOperation(itemOperationId); if (repository.history(itemOperationId) != null) throw conflict("整批明细已被重复提交"); String itemHash = hash(line.action() + '|' + item.getId() + '|' + request.version() - + '|' + pickedCount + '|' + (line.release() == null ? "" : line.release().mode()) - + '|' + (line.release() == null ? "" : line.release().json()) + '|' + trim(request.reason())); + + '|' + pickedCount + '|' + trim(request.reason())); insertHistory(taskId, item.getId(), itemOperationId, itemHash, line.action(), pickedCount, trim(request.reason()), operatorId, operatorName); } @@ -1190,6 +1179,18 @@ public class LoadingPickService { private Task replayTaskOperation(long taskId, String operationId, String requestHash, long operatorId) { if (repository.history(operationId) != null) throw conflict("operationId已用于明细拣货操作"); LoadingPickOperationEntity replay = repository.operation(operationId); + return replayTaskOperation(taskId, operationId, requestHash, operatorId, replay); + } + + private Task replayTaskOperationForUpdate(long taskId, String operationId, String requestHash, + long operatorId) { + if (repository.historyForUpdate(operationId) != null) throw conflict("operationId已用于明细拣货操作"); + LoadingPickOperationEntity replay = repository.operationForUpdate(operationId); + return replayTaskOperation(taskId, operationId, requestHash, operatorId, replay); + } + + private Task replayTaskOperation(long taskId, String operationId, String requestHash, long operatorId, + LoadingPickOperationEntity replay) { if (replay == null) return null; if (!Long.valueOf(taskId).equals(replay.getPickTaskId()) || !Long.valueOf(operatorId).equals(replay.getOperatorId()) @@ -1215,6 +1216,10 @@ public class LoadingPickService { if (repository.operation(operationId) != null) throw conflict("operationId已用于PDA任务操作"); } + private void requireNoTaskOperationForUpdate(String operationId) { + if (repository.operationForUpdate(operationId) != null) throw conflict("operationId已用于PDA任务操作"); + } + private static String operation(String value) { String result = trim(value); if (!StringUtils.hasText(result)) throw new BizException(ResultCode.BAD_REQUEST, "operationId不能为空"); @@ -1270,35 +1275,14 @@ public class LoadingPickService { .forEach(item -> { canonical.append('|').append(item.itemId()).append('|').append(trim(item.action())) .append('|').append(item.pickedPackageCount()).append('|') - .append(item.finalPickedPackageCount()).append('|').append(trim(item.releaseMode())) - .append('|').append(item.version()).append('|').append(trim(item.reason())).append('|'); - List locations = item.locationIds() == null ? List.of() - : item.locationIds().stream().sorted().toList(); - canonical.append(locations); + .append(item.finalPickedPackageCount()).append('|') + .append(item.version()).append('|').append(trim(item.reason())); }); return hash(canonical.toString()); } - private static ReleaseSelection releaseSelection(String requestedMode, List requestedLocationIds) { - String mode = requestedMode == null || requestedMode.isBlank() - ? (requestedLocationIds == null ? "AUTO" : "MANUAL") : requestedMode.trim().toUpperCase(); - if (!"AUTO".equals(mode) && !"MANUAL".equals(mode)) { - throw new BizException(ResultCode.BAD_REQUEST, "releaseMode只能是AUTO或MANUAL"); - } - List ids = requestedLocationIds == null ? List.of() : requestedLocationIds.stream().sorted().toList(); - if (new HashSet<>(ids).size() != ids.size()) { - throw new BizException(ResultCode.BAD_REQUEST, "释放库位不能重复"); - } - if ("AUTO".equals(mode) && !ids.isEmpty()) { - throw new BizException(ResultCode.BAD_REQUEST, "AUTO模式不能指定释放库位"); - } - String json = ids.stream().map(String::valueOf).collect(java.util.stream.Collectors.joining(",", "[", "]")); - return new ReleaseSelection(mode, json); - } - private record BatchLine(LoadingPickItemEntity item, BatchItemRequest request, String action, - ReleaseSelection release, long warehouseId) {} - private record ReleaseSelection(String mode, String json) {} + long warehouseId) {} private static String trim(String value) { return value == null ? null : value.trim(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java index a15743ea3..408b9a5ff 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java @@ -41,30 +41,33 @@ public class MeasurementLocationOccupationService { int measurementVersion) { InboundOrderEntity order = requireOrder(tenantId, inboundId); long warehouseId = requireWarehouse(order); - long resourceTenantId = PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID; BigDecimal volume = requireVolume(order); - var palletReferences = confirmedPalletReferences(tenantId, inboundId); - // 补偿释放后的预占记录需要保留审计,失败重试必须使用新的物理预占键; - // 若仍存在活动预占,仓储侧会按业务单据复用,不会创建新的资源占用。 - String key = "measure-" + inboundId + "-" + measurementId + "-" + measurementVersion + var confirmedWeights = confirmedWeights(tenantId, inboundId); + int requiredSlotCount = requiredSlotCount(volume, confirmedWeights.size()); + // Warehouse retains released blocks for audit. A compensated attempt therefore needs a new + // reservation key, otherwise a retry would replay the released block instead of reserving again. + String key = "measure-block-" + inboundId + "-" + measurementId + "-" + measurementVersion + "-" + UUID.randomUUID(); - WarehousePalletLocationClient.BatchResult reserved = requireSuccess(call(() -> warehouse.reserve( - internalToken, new WarehousePalletLocationClient.ReserveRequest(resourceTenantId, warehouseId, - SOURCE_SERVICE, inboundId, order.getInboundCode(), key, operatorId, volume, - palletReferences, - LocalDateTime.now().plus(properties.getReservationTtl()), true))), + WarehousePalletLocationClient.BlockResult reserved = requireSuccess(call(() -> warehouse.reserveBlock( + internalToken, new WarehousePalletLocationClient.BlockReserveRequest(warehouseId, + SOURCE_SERVICE, inboundId, order.getInboundCode(), requiredSlotCount, key, operatorId, + LocalDateTime.now().plus(properties.getReservationTtl())))), "测量完成后库位预占失败"); int packageCount = requirePackageCount(order); - allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId, packageCount, reserved); + requireBlockResult(reserved, requiredSlotCount, "RESERVED"); + allocations.replaceBlock(tenantId, inboundId, warehouseId, packageCount, volume, reserved); + allocations.bindConfirmedWeights(tenantId, inboundId, confirmedWeights); try { - WarehousePalletLocationClient.BatchResult occupied = requireSuccess(call(() -> warehouse.confirm( - internalToken, new WarehousePalletLocationClient.ConfirmRequest(resourceTenantId, warehouseId, - SOURCE_SERVICE, inboundId, operatorId, volume, packageCount))), "测量完成后库位占用失败"); - allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId, packageCount, occupied); + WarehousePalletLocationClient.BlockResult occupied = requireSuccess(call(() -> warehouse.confirmBlock( + internalToken, new WarehousePalletLocationClient.BlockConfirmRequest(reserved.blockId(), inboundId, + key + "-confirm", operatorId))), "测量完成后库位占用失败"); + requireBlockResult(occupied, requiredSlotCount, "OCCUPIED"); + allocations.replaceBlock(tenantId, inboundId, warehouseId, packageCount, volume, occupied); + allocations.bindConfirmedWeights(tenantId, inboundId, confirmedWeights); } catch (RuntimeException failure) { try { - warehouse.release(internalToken, new WarehousePalletLocationClient.ReleaseRequest( - resourceTenantId, SOURCE_SERVICE, inboundId, operatorId, "测量完成库位占用失败")); + warehouse.releaseBlock(internalToken, new WarehousePalletLocationClient.BlockReleaseRequest( + reserved.blockId(), inboundId, key + "-compensate", operatorId, "MANUAL_ADJUSTMENT")); } catch (RuntimeException releaseFailure) { failure.addSuppressed(releaseFailure); } @@ -99,9 +102,9 @@ public class MeasurementLocationOccupationService { return order.getTotalPackageCount(); } - private java.util.List confirmedPalletReferences( + private java.util.List confirmedWeights( long tenantId, long inboundId) { - java.util.List references = new java.util.ArrayList<>(); + java.util.List references = new java.util.ArrayList<>(); for (InboundWeightResultEntity weight : measurements.listWeights(tenantId, inboundId)) { if (!"CONFIRMED".equals(weight.getRecognitionStatus())) { continue; @@ -118,7 +121,7 @@ public class MeasurementLocationOccupationService { if (weight.getId() == null || weight.getId() <= 0) { throw new BizException(ResultCode.CONFLICT, "已确认称重图片缺少结果标识,无法分配库位"); } - references.add(new WarehousePalletLocationClient.PalletReference(weight.getId(), weight.getPalletId())); + references.add(weight); } if (references.isEmpty()) { throw new BizException(ResultCode.CONFLICT, "至少需要一张已确认的称重图片才能提交测量"); @@ -129,6 +132,38 @@ public class MeasurementLocationOccupationService { return references; } + private int requiredSlotCount(BigDecimal volume, int actualPalletCount) { + BigDecimal capacity = properties.getDefaultPalletCapacityCbm(); + if (capacity == null || capacity.signum() <= 0) { + throw new BizException(ResultCode.CONFLICT, "默认托盘容量配置无效,无法计算整票预留库位数"); + } + int byVolume; + try { + byVolume = volume.divide(capacity, 0, java.math.RoundingMode.CEILING).intValueExact(); + } catch (ArithmeticException exception) { + throw new BizException(ResultCode.VALIDATION_FAILED, "整票预留库位数超过系统上限"); + } + int required = Math.max(actualPalletCount, byVolume); + if (required < 1 || required > 200) { + throw new BizException(ResultCode.VALIDATION_FAILED, "整票预留库位数必须在1到200之间"); + } + return required; + } + + private static void requireBlockResult(WarehousePalletLocationClient.BlockResult result, + int requiredSlotCount, String expectedStatus) { + if (result.blockId() <= 0 || !expectedStatus.equals(result.blockStatus()) + || result.resources() == null || result.resources().size() != requiredSlotCount + || result.resources().stream().anyMatch(resource -> resource.blockId() != result.blockId())) { + throw new BizException(ResultCode.CONFLICT, "Warehouse返回的整票库位块不完整"); + } + java.util.List slotNos = result.resources().stream() + .map(WarehousePalletLocationClient.BlockResource::slotNo).sorted().toList(); + if (!slotNos.equals(java.util.stream.IntStream.rangeClosed(1, requiredSlotCount).boxed().toList())) { + throw new BizException(ResultCode.CONFLICT, "Warehouse返回的整票库位槽序不连续"); + } + } + private static ApiResponse call(RemoteCall call) { try { return call.invoke(); } catch (FeignException exception) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java index bf8847085..016d25ad3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java @@ -153,18 +153,12 @@ public class PdaPickSettlementService { confirmedAllocations.sort(Comparator.comparing(InboundPalletAllocationEntity::getAllocationNo, Comparator.nullsLast(Integer::compareTo))); consumePickedAllocations(parent.tenantId(), parentInboundId, confirmedAllocations, pickedPackageCount); - List releasedReservationIds = releasedReservationIds(confirmedAllocations); - List transferItems = - remainingTransferItems(confirmedAllocations); - int allocationRemaining = transferItems.stream() - .mapToInt(WarehousePalletLocationClient.SettlementTransferItem::remainingPackageCount).sum(); + long blockId = blockId(confirmedAllocations); + int allocationRemaining = confirmedAllocations.stream().mapToInt(PdaPickSettlementService::remaining).sum(); if (allocationRemaining != childPackageCount) { throw conflict("父入库单库位剩余箱数与未装柜箱数不一致"); } - long warehouseId = warehouseId(confirmedAllocations); - if (childPackageCount == 0 && (!transferItems.isEmpty() || releasedReservationIds.isEmpty())) { - throw conflict("全量拣货后库位资源结算不完整"); - } + warehouseId(confirmedAllocations); List measurements = splits.lockLatestApprovedItemsForInbound( parent.tenantId(), parentInboundId); @@ -186,19 +180,11 @@ public class PdaPickSettlementService { throw conflict("主入库单测量明细剩余箱数与未装柜箱数不一致"); } - if (childPackageCount > 0 && transferItems.isEmpty()) { - throw conflict("父入库单可转移库位剩余箱数与未装柜箱数不一致"); - } - if (root == null || !"MASTER".equals(root.inboundRole())) throw conflict("根主入库单不存在"); OrderFulfillmentSplitClient.Response orderSplit = createOrderSplit(parent, root, measurementLines, pickedPackageCount, childPackageCount, loadingPlanId, containerBookingId, operationId, "pda-task-" + taskId, operatorId, operatorName); - String settlementKey = "PDA-SETTLE-" + taskId + '-' + parentInboundId; - long remainderChildId = childPackageCount > 0 ? existingSettlementTarget(parentInboundId, settlementKey) : 0L; - if (childPackageCount > 0 && remainderChildId == 0L) { - remainderChildId = IdWorker.getId(); - } + long remainderChildId = childPackageCount > 0 ? IdWorker.getId() : 0L; long batchId = IdWorker.getId(); String idempotencyKey = "pda-task-" + taskId; int rootVersion = root.version() == null ? 0 : root.version(); @@ -269,15 +255,14 @@ public class PdaPickSettlementService { "PDA拣货后来源单承接实装并生成余量子入库单"), "拆分审计"); if (remainderChildId != 0L) { - allocations.copyRemainingToChild(parent.tenantId(), remainderChildId, confirmedAllocations); + allocations.copyBlockToChild(parent.tenantId(), remainderChildId, confirmedAllocations); + allocations.markBlockTransferred(parent.tenantId(), parentInboundId, blockId); + } else { + allocations.markBlockReleased(parent.tenantId(), parentInboundId, blockId); } - allocations.markTransferred(parent.tenantId(), parentInboundId, confirmedAllocations, - releasedReservationIds); - allocations.markReleased(parent.tenantId(), parentInboundId, releasedReservationIds); - settleWarehouseResources(parentInboundId, remainderChildId, + settleWarehouseBlock(parentInboundId, remainderChildId, remainderChildCode == null ? root.inboundCode() : remainderChildCode, - taskId, operatorId, operatorName, - warehouseId, parentAvailable, childPackageCount, releasedReservationIds, transferItems); + taskId, operatorId, blockId); return true; } @@ -409,28 +394,17 @@ public class PdaPickSettlementService { } } - private static List releasedReservationIds(List allocations) { - return allocations.stream().filter(item -> remaining(item) == 0).map(PdaPickSettlementService::reservationId) - .distinct().sorted().toList(); - } - - private static List remainingTransferItems( - List allocations) { - return allocations.stream().filter(item -> remaining(item) > 0) - .map(item -> new WarehousePalletLocationClient.SettlementTransferItem(reservationId(item), remaining(item))) - .toList(); - } - private static int remaining(InboundPalletAllocationEntity allocation) { return count(allocation.getAllocatedPackageCount(), "库位已分配箱数") - (allocation.getPickedPackageCount() == null ? 0 : allocation.getPickedPackageCount()); } - private static long reservationId(InboundPalletAllocationEntity allocation) { - if (allocation.getReservationId() == null || allocation.getReservationId() <= 0) { - throw conflict("库位资源缺少唯一reservationId,请先重新确认托盘库位分配"); + private static long blockId(List allocations) { + if (allocations.isEmpty() || allocations.get(0).getBlockId() == null || allocations.get(0).getBlockId() <= 0 + || allocations.stream().anyMatch(item -> !Objects.equals(allocations.get(0).getBlockId(), item.getBlockId()))) { + throw conflict("PDA结算要求父入库单资源属于同一整票库位块"); } - return allocation.getReservationId(); + return allocations.get(0).getBlockId(); } private static long warehouseId(List allocations) { @@ -441,36 +415,33 @@ public class PdaPickSettlementService { return allocations.get(0).getWarehouseId(); } - private void settleWarehouseResources(long parentInboundId, long childInboundId, String childCode, - long taskId, long operatorId, String operatorName, long warehouseId, - int sourcePackageCount, int targetPackageCount, - List releasedReservationIds, - List remainingItems) { - ApiResponse response; + private void settleWarehouseBlock(long parentInboundId, long childInboundId, String childCode, + long taskId, long operatorId, long blockId) { + ApiResponse response; + String key = "PDA-BLOCK-" + taskId + '-' + parentInboundId; try { - response = warehouse.settle(internalToken, new WarehousePalletLocationClient.SettlementRequest( - warehouseId, SOURCE_SERVICE, parentInboundId, childInboundId, - sourcePackageCount, targetPackageCount, childCode, "PDA-SETTLE-" + taskId + '-' + parentInboundId, - operatorId, operatorName, "PDA-SETTLE-" + taskId + '-' + parentInboundId, - releasedReservationIds, remainingItems)); + response = childInboundId > 0 + ? warehouse.transferBlock(internalToken, new WarehousePalletLocationClient.BlockTransferRequest( + blockId, parentInboundId, childInboundId, childCode, key, operatorId)) + : warehouse.releaseBlock(internalToken, new WarehousePalletLocationClient.BlockReleaseRequest( + blockId, parentInboundId, key, operatorId, "ALL_PACKAGES_PICKED")); } catch (FeignException exception) { - log.warn("PDA warehouse settlement transport failure taskId={} sourceInboundId={} operationId={} " - + "httpStatus={} releasedReservationIds={} remainingReservations={}", - taskId, parentInboundId, "PDA-SETTLE-" + taskId + '-' + parentInboundId, - exception.status(), releasedReservationIds, settlementTransferFacts(remainingItems)); + log.warn("PDA warehouse block settlement transport failure taskId={} sourceInboundId={} " + + "blockId={} operationId={} httpStatus={}", + taskId, parentInboundId, blockId, key, exception.status()); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓储服务不可用,无法结算PDA库位资源"); } if (response == null || !response.successful() || response.data() == null - || response.data().released() == null || response.data().transferred() == null - || !sameReleasedReservations(releasedReservationIds, response.data().released()) - || !sameTransferredResources(remainingItems, response.data().transferred())) { - log.warn("PDA warehouse settlement rejected taskId={} sourceInboundId={} operationId={} " - + "responseCode={} requestId={} traceId={} expectedAction=RELEASE_OR_TRANSFER " - + "releasedReservationIds={} remainingReservations={}", - taskId, parentInboundId, "PDA-SETTLE-" + taskId + '-' + parentInboundId, - response == null ? null : response.code(), response == null ? null : response.requestId(), - response == null ? null : response.traceId(), releasedReservationIds, - settlementTransferFacts(remainingItems)); + || response.data().blockId() != blockId || response.data().resources() == null + || response.data().resources().isEmpty() + || response.data().resources().stream().anyMatch(resource -> resource.blockId() != blockId) + || !(childInboundId > 0 ? "OCCUPIED" : "RELEASED").equals(response.data().blockStatus()) + || response.data().resources().stream().anyMatch(resource -> + !(childInboundId > 0 ? "OCCUPIED" : "RELEASED").equals(resource.resourceStatus()))) { + log.warn("PDA warehouse block settlement rejected taskId={} sourceInboundId={} blockId={} " + + "operationId={} responseCode={} requestId={} traceId={}", + taskId, parentInboundId, blockId, key, response == null ? null : response.code(), + response == null ? null : response.requestId(), response == null ? null : response.traceId()); ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code()); throw new SettlementFailure(code, response == null || response.message() == null ? "仓储服务原子结算PDA库位资源失败" : response.message(), @@ -493,40 +464,6 @@ public class PdaPickSettlementService { public String traceId() { return traceId; } } - private static List settlementTransferFacts( - List remainingItems) { - return remainingItems.stream().map(item -> item.reservationId() + ":" + item.remainingPackageCount()) - .sorted().toList(); - } - - private long existingSettlementTarget(long sourceInboundId, String idempotencyKey) { - try { - ApiResponse response = warehouse.settlementTarget(internalToken, sourceInboundId, idempotencyKey); - return response != null && response.successful() && response.data() != null - ? response.data() : 0L; - } catch (FeignException exception) { - return 0L; - } - } - - private static boolean sameReleasedReservations(List expected, - List actual) { - return expected.equals(actual.stream().map(WarehousePalletLocationClient.SelectiveReleaseResult::reservationId) - .sorted().toList()); - } - - private static boolean sameTransferredResources(List expected, - List actual) { - Map expectedByLocation = expected.stream().collect(Collectors.toMap( - WarehousePalletLocationClient.SettlementTransferItem::reservationId, - WarehousePalletLocationClient.SettlementTransferItem::remainingPackageCount)); - Map actualByLocation = actual.stream().collect(Collectors.toMap( - WarehousePalletLocationClient.TransferResult::reservationId, - WarehousePalletLocationClient.TransferResult::remainingPackageCount, - (left, right) -> -1)); - return expectedByLocation.equals(actualByLocation); - } - static String childInboundCode(String rootInboundCode, int splitSequence) { if (splitSequence <= 0) { throw conflict("子入库单拆分序号不合法"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 3bce6ddee..8f9f0fd11 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -847,7 +847,14 @@ public class SmartLoadingService { ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); Candidate candidate = repository.candidate(planId, booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId()); - if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数"); + if (candidate == null) throw validation("调整箱数超过可用箱数"); + int currentPackageCount = item.getPackageCount() == null ? 0 : item.getPackageCount(); + int remainingPackageCount = Math.max(0, candidate.availablePackageCount() == null + ? 0 : candidate.availablePackageCount()); + if (request.packageCount() > currentPackageCount + && request.packageCount() - currentPackageCount > remainingPackageCount) { + throw validation("调整箱数超过可用箱数"); + } requireSameCustomerScope(booking, candidate); OrderCargoClient.CargoBinding cargo = loadCargo(candidate); List others = new ArrayList<>(repository.items(planId)); diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 2a2591f80..faed9ff13 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -714,22 +714,29 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', allocation_no INT UNSIGNED NOT NULL COMMENT '入库单内分配序号', + block_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse整票连续库位块ID', + slot_no INT UNSIGNED NOT NULL COMMENT '连续块内稳定槽位序号', + weight_result_id BIGINT UNSIGNED NULL COMMENT '已绑定称重结果ID;预留空槽为空', + pallet_sequence INT UNSIGNED NULL COMMENT '已绑定称重托盘在整票内的稳定顺序;空槽为空', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务仓库ID', warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键', - pallet_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务托盘ID', - pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', - pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘可用容积快照', + pallet_id BIGINT UNSIGNED NULL COMMENT '仓库服务托盘ID;预留空槽为空', + pallet_code VARCHAR(50) NULL COMMENT '托盘编码快照;预留空槽为空', + pallet_capacity_cbm DECIMAL(18,4) NULL COMMENT '托盘可用容积快照;预留空槽为空', allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本托盘分配体积', allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数', location_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务库位ID', location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照', - reservation_id BIGINT UNSIGNED NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分托盘', - reservation_no VARCHAR(64) NOT NULL COMMENT '仓库库位预占单号', + shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '普通连续位不足时是否使用共享A0', + reservation_id BIGINT UNSIGNED NOT NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分槽位', + slot_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED_EMPTY' COMMENT '槽位状态:RESERVED_EMPTY、BOUND、TRANSFERRED、RELEASED', allocation_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED' COMMENT '托盘库位分配状态:RESERVED已预占、CONFIRMED已确认、TRANSFERRED已转移、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 '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_pallet_allocation_no (tenant_id, inbound_id, allocation_no), + UNIQUE KEY uk_pallet_allocation_slot (tenant_id, block_id, slot_no), + UNIQUE KEY uk_pallet_allocation_weight (tenant_id, weight_result_id), KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status), KEY idx_allocation_reservation (reservation_id), CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 8c7a2e443..0f0a4ecba 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1047,6 +1047,7 @@ paths: summary: 删除称重图片及重量结果 description: >- 需要 platform:firstleg:inbound:weight:edit 权限;仅允许在可编辑的测量状态删除。 + 已绑定图片先将对应槽恢复为 RESERVED_EMPTY,整票块仍归当前入仓码;解绑或文件删除失败时事务失败。 删除已确认图片后由生产者分别重算剩余确认称重的毛重、净重;消费端不得用净重填充毛重。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -1622,6 +1623,14 @@ components: adjustedAt: {type: string, format: date-time, nullable: true} confirmedBy: {type: string, nullable: true} confirmedAt: {type: string, format: date-time, nullable: true} + palletSequence: {type: integer, minimum: 1, nullable: true, description: 已绑定称重托盘在整票内的稳定顺序;未绑定槽位时为空} + blockId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: Warehouse 整票连续库位块标识} + slotNo: {type: integer, minimum: 1, nullable: true, description: 整票连续块内的稳定槽位序号} + reservationId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: Warehouse 预占资源唯一标识;A0 共享时仍按此字段区分} + locationId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: Warehouse 库位标识} + locationCode: {type: string, nullable: true, description: 库位业务编码,例如 A01 或 A0} + sharedOverflow: {type: boolean, nullable: true, description: 是否因普通连续位不足而使用共享 A0} + slotStatus: {type: string, enum: [RESERVED_EMPTY, BOUND, TRANSFERRED, RELEASED], nullable: true, description: 当前整票槽位状态} version: {type: integer, minimum: 0} InboundMeasurementItem: type: object @@ -1716,6 +1725,38 @@ components: type: array description: WEIGHT 称重结果及其图片、确认状态和服务端重量事实 items: {$ref: '#/components/schemas/InboundWeightResult'} + palletLocationGuide: + allOf: [{$ref: '#/components/schemas/InboundPalletLocationGuide'}] + nullable: true + description: 服务端权威的整票上架引导;尚未预留库位时为空,禁止由 weightResults 数量或数组下标推导 + InboundPalletLocationGuide: + type: object + required: [blockId, reservedSlotCount, actualPalletCount, reservedEmptySlotCount, slots] + properties: + blockId: {type: string, pattern: '^[1-9][0-9]*$', description: Warehouse 整票连续库位块标识} + reservedSlotCount: {type: integer, minimum: 1, description: 整票当前预留槽位总数,等于 slots 长度} + actualPalletCount: {type: integer, minimum: 0, description: 已绑定称重结果的实际托数} + reservedEmptySlotCount: {type: integer, minimum: 0, description: slotStatus=RESERVED_EMPTY 的预留空位数} + slots: + type: array + minItems: 1 + maxItems: 200 + description: 严格按 slotNo 升序返回的整票槽位 + items: {$ref: '#/components/schemas/InboundPalletLocationSlot'} + InboundPalletLocationSlot: + type: object + required: [slotNo, reservationId, locationId, locationCode, sharedOverflow, slotStatus] + properties: + slotNo: {type: integer, minimum: 1} + reservationId: {type: string, pattern: '^[1-9][0-9]*$'} + locationId: {type: string, pattern: '^[1-9][0-9]*$'} + locationCode: {type: string} + sharedOverflow: {type: boolean} + slotStatus: {type: string, enum: [RESERVED_EMPTY, BOUND, TRANSFERRED, RELEASED]} + boundWeightResultId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + palletSequence: {type: integer, minimum: 1, nullable: true} + palletId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + palletCode: {type: string, nullable: true} InboundMeasurementLatestDeclaration: type: object description: Order 服务中的最新客户申报只读视图;订单编辑只能改变本对象,不得覆盖箱码、实测尺寸/重量或审核结果 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 65e63be75..daf52d98f 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -275,10 +275,6 @@ components: action: {type: string, enum: [PICK, PARTIAL, SKIP]} pickedPackageCount: {type: integer, minimum: 1, description: Incremental count required by PICK} finalPickedPackageCount: {type: integer, minimum: 1, description: Final actual count required by PARTIAL} - releaseMode: {type: string, enum: [AUTO, MANUAL]} - reservationIds: - type: array - items: {type: integer, format: int64, minimum: 1} version: {type: integer, minimum: 0} reason: {type: string, maxLength: 500, description: Required by SKIP} ReviewRequest: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java index 93537fe22..e0dcb9c22 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java @@ -24,7 +24,8 @@ class InboundMeasurementEvidenceOpenApiContractTest { Map schemas = stringMap(stringMap(document.get("components")).get("schemas")); Map detailProperties = properties(schemas, "InboundMeasurementDetail"); assertThat(detailProperties).containsKeys( - "actualGrossWeightKg", "actualNetWeightKg", "latestDeclaration", "items", "weightResults"); + "actualGrossWeightKg", "actualNetWeightKg", "latestDeclaration", "items", "weightResults", + "palletLocationGuide"); assertThat(stringMap(detailProperties.get("latestDeclaration"))) .containsEntry("$ref", "#/components/schemas/InboundMeasurementLatestDeclaration"); assertThat(properties(schemas, "InboundMeasurementLatestDeclaration")) @@ -52,7 +53,16 @@ class InboundMeasurementEvidenceOpenApiContractTest { assertThat(itemProperties).containsKeys("id", "intelligentImageFileId"); Map weightProperties = properties(schemas, "InboundWeightResult"); assertThat(weightProperties).containsKeys( - "id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt"); + "id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt", + "palletSequence", "blockId", "slotNo", "reservationId", "locationId", "locationCode", + "sharedOverflow", "slotStatus"); + assertThat(stringMap(weightProperties.get("slotStatus"))).containsEntry( + "enum", java.util.List.of("RESERVED_EMPTY", "BOUND", "TRANSFERRED", "RELEASED")); + assertThat(properties(schemas, "InboundPalletLocationGuide")).containsKeys( + "blockId", "reservedSlotCount", "actualPalletCount", "reservedEmptySlotCount", "slots"); + assertThat(properties(schemas, "InboundPalletLocationSlot")).containsKeys( + "slotNo", "reservationId", "locationId", "locationCode", "sharedOverflow", "slotStatus", + "boundWeightResultId", "palletSequence", "palletId", "palletCode"); assertThat(stringMap(detailProperties.get("actualGrossWeightKg")).get("description").toString()) .contains("CONFIRMED", "grossWeightKg", "含托盘毛重"); assertThat(stringMap(detailProperties.get("actualNetWeightKg")).get("description").toString()) 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 8c12b1d03..b7b9e7141 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java @@ -49,6 +49,9 @@ class PdaConvergenceOpenApiContractTest { String batchRequest = yaml.substring( yaml.indexOf(" BatchRequest:"), yaml.indexOf(" BatchItemRequest:")); + String batchItemRequest = yaml.substring( + yaml.indexOf(" BatchItemRequest:"), + yaml.indexOf(" ReviewRequest:")); assertThat(taskLookup) .contains("name: cabinetNo", "name: loadingPlanId") .doesNotContain("name: containerNo"); @@ -59,5 +62,7 @@ class PdaConvergenceOpenApiContractTest { assertThat(batchRequest) .contains("loadingPlanId:", "containerBookingId:") .doesNotContain("containerNo:"); + assertThat(batchItemRequest) + .doesNotContain("releaseMode:", "reservationIds:", "locationIds:"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepositoryTest.java index b04320e20..b9ac4d490 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepositoryTest.java @@ -1,84 +1,104 @@ package com.baiyue.firstleg.repository; -import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; -import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper; import com.baiyue.firstleg.client.WarehousePalletLocationClient; +import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; +import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.List; import java.math.BigDecimal; -import org.mockito.ArgumentCaptor; +import java.time.LocalDateTime; +import java.util.List; -import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class InboundPalletAllocationRepositoryTest { - @Mock - private InboundPalletAllocationMapper mapper; - @InjectMocks - private InboundPalletAllocationRepository repository; + @Mock private InboundPalletAllocationMapper mapper; + @InjectMocks private InboundPalletAllocationRepository repository; @Test - void acceptsReleasedSnapshotWhenRetryUpdateAffectsNoRows() { - InboundPalletAllocationEntity current = allocation(1L, 91L, "RELEASED", 10, 10); - when(mapper.markReleased(10001L, 31L, 91L)).thenReturn(0); - when(mapper.selectOne(any())).thenReturn(current); + void savesEveryBlockSlotIncludingReservedEmptySlots() { + var result = new WarehousePalletLocationClient.BlockResult(700L, "OCCUPIED", List.of( + resource(700L, 91L, 11L, "A01", 1, false), + resource(700L, 92L, 12L, "A02", 2, false), + resource(700L, 93L, 99L, "A0", 3, true))); - assertThatCode(() -> repository.markReleased(10001L, 31L, List.of(91L))) - .doesNotThrowAnyException(); + repository.replaceBlock(10001L, 31L, 88L, 20, new BigDecimal("2.5000"), result); + + ArgumentCaptor inserted = + ArgumentCaptor.forClass(InboundPalletAllocationEntity.class); + verify(mapper, times(3)).insert(inserted.capture()); + assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getSlotNo) + .containsExactly(1, 2, 3); + assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getWeightResultId) + .containsOnlyNulls(); + assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getSlotStatus) + .containsOnly("RESERVED_EMPTY"); + assertThat(inserted.getAllValues().get(2).getSharedOverflow()).isTrue(); } @Test - void acceptsTransferredSnapshotWhenRetryUpdateAffectsNoRows() { - InboundPalletAllocationEntity source = allocation(1L, 91L, "CONFIRMED", 10, 2); - InboundPalletAllocationEntity current = allocation(1L, 91L, "TRANSFERRED", 10, 2); - when(mapper.markTransferred(1L, 10001L, 31L)).thenReturn(0); - when(mapper.selectOne(any())).thenReturn(current); + void bindsConfirmedWeightsByConfirmationTimeToTheNextEmptySlot() { + InboundPalletAllocationEntity firstEmpty = allocation(1L, 1); + InboundPalletAllocationEntity secondEmpty = allocation(2L, 2); + when(mapper.selectNextEmptyForUpdate(10001L, 31L)).thenReturn(firstEmpty, secondEmpty); + when(mapper.bindWeight(any(Long.class), any(Long.class), any(Long.class), any(Long.class), + any(Integer.class), any(Long.class), any(String.class), any(BigDecimal.class))).thenReturn(1); + InboundWeightResultEntity later = weight(102L, LocalDateTime.of(2026, 9, 4, 10, 2)); + InboundWeightResultEntity earlier = weight(101L, LocalDateTime.of(2026, 9, 4, 10, 1)); - assertThatCode(() -> repository.markTransferred(10001L, 31L, List.of(source), List.of())) - .doesNotThrowAnyException(); + repository.bindConfirmedWeights(10001L, 31L, List.of(later, earlier)); + + org.mockito.InOrder order = org.mockito.Mockito.inOrder(mapper); + order.verify(mapper).bindWeight(1L, 10001L, 31L, 101L, 1, 501L, "P-501", + new BigDecimal("1.2000")); + order.verify(mapper).bindWeight(2L, 10001L, 31L, 102L, 2, 501L, "P-501", + new BigDecimal("1.2000")); } @Test - void restoresReservationNumbersByReservationIdWhenPalletIsReused() { - InboundPalletAllocationEntity first = allocation(1L, 91L, "RESERVED", 1, 0); - first.setPalletId(501L); - first.setReservationNo("LR-91"); - InboundPalletAllocationEntity second = allocation(2L, 92L, "RESERVED", 1, 0); - second.setPalletId(501L); - second.setReservationNo("LR-92"); - when(mapper.selectList(any())).thenReturn(List.of(first, second)); + void unbindRestoresOnlyTheBoundSlotToReservedEmpty() { + InboundPalletAllocationEntity bound = allocation(1L, 1); + bound.setWeightResultId(101L); + when(mapper.selectOne(any())).thenReturn(bound); + when(mapper.unbindWeight(1L, 10001L, 31L, 101L)).thenReturn(1); - var result = new WarehousePalletLocationClient.BatchResult(2, List.of( - new WarehousePalletLocationClient.AllocationResult(91L, null, 1, 501L, "P1", new BigDecimal("1.2"), 11L, "A1", - new BigDecimal("1.2"), "OCCUPIED"), - new WarehousePalletLocationClient.AllocationResult(92L, null, 2, 501L, "P1", new BigDecimal("1.2"), 12L, "A2", - new BigDecimal("1.2"), "OCCUPIED"))); + repository.unbindWeight(10001L, 31L, 101L); - repository.replace(10001L, 31L, 88L, 0L, 2, result); - - ArgumentCaptor inserted = ArgumentCaptor.forClass(InboundPalletAllocationEntity.class); - verify(mapper, org.mockito.Mockito.times(2)).insert(inserted.capture()); - assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getReservationNo) - .containsExactly("LR-91", "LR-92"); + verify(mapper).unbindWeight(1L, 10001L, 31L, 101L); } - private static InboundPalletAllocationEntity allocation(long id, long reservationId, String status, - int allocated, int picked) { - InboundPalletAllocationEntity entity = new InboundPalletAllocationEntity(); - entity.setId(id); - entity.setReservationId(reservationId); - entity.setAllocationStatus(status); - entity.setAllocatedPackageCount(allocated); - entity.setPickedPackageCount(picked); - return entity; + private static WarehousePalletLocationClient.BlockResource resource( + long blockId, long reservationId, long locationId, String code, int slotNo, boolean overflow) { + return new WarehousePalletLocationClient.BlockResource( + blockId, reservationId, locationId, code, slotNo, overflow, "OCCUPIED"); + } + + private static InboundPalletAllocationEntity allocation(long id, int slotNo) { + InboundPalletAllocationEntity value = new InboundPalletAllocationEntity(); + value.setId(id); + value.setSlotNo(slotNo); + return value; + } + + private static InboundWeightResultEntity weight(long id, LocalDateTime confirmedAt) { + InboundWeightResultEntity value = new InboundWeightResultEntity(); + value.setId(id); + value.setRecognitionStatus("CONFIRMED"); + value.setConfirmedAt(confirmedAt); + value.setPalletId(501L); + value.setPalletCode("P-501"); + value.setPalletCapacityCbm(new BigDecimal("1.2000")); + return value; } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index d9abf1beb..f5ce619fd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -12,9 +12,11 @@ import com.baiyue.firstleg.dto.InboundMeasurementResponses; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; import com.baiyue.firstleg.entity.InboundWeightResultEntity; import com.baiyue.firstleg.entity.InboundWeightRevisionEntity; import com.baiyue.firstleg.repository.InboundMeasurementRepository; +import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; import jakarta.annotation.Resource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -58,6 +60,7 @@ class InboundMeasurementServiceTest { @MockBean private OrderCargoClient orderCargoClient; @MockBean private MeasurementImageClient imageClient; @MockBean private WarehousePalletLocationClient warehousePallets; + @MockBean private InboundPalletAllocationRepository palletAllocations; @Resource private InboundMeasurementService service; @BeforeEach @@ -205,6 +208,10 @@ class InboundMeasurementServiceTest { when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement); when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + InboundPalletAllocationEntity bound = locationSlot(1, 801L, "A01", "BOUND", 50L); + InboundPalletAllocationEntity secondBound = locationSlot(2, 802L, "A02", "BOUND", 51L); + InboundPalletAllocationEntity empty = locationSlot(3, 803L, "A03", "RESERVED_EMPTY", null); + when(palletAllocations.list(1L, 10L)).thenReturn(List.of(empty, secondBound, bound)); InboundMeasurementResponses.Detail result = service.detail(1L, 10L); @@ -224,6 +231,16 @@ class InboundMeasurementServiceTest { assertThat(confirmedWeight.grossWeightKg()).isEqualByComparingTo("30.000"); assertThat(confirmedWeight.netWeightKg()).isEqualByComparingTo("11.500"); assertThat(confirmedWeight.confirmedAt()).isNotNull(); + assertThat(confirmedWeight.locationCode()).isEqualTo("A01"); + }); + assertThat(result.palletLocationGuide()).satisfies(guide -> { + assertThat(guide.blockId()).isEqualTo(700L); + assertThat(guide.reservedSlotCount()).isEqualTo(3); + assertThat(guide.actualPalletCount()).isEqualTo(2); + assertThat(guide.reservedEmptySlotCount()).isEqualTo(1); + assertThat(guide.slots()).extracting(InboundMeasurementResponses.PalletLocationSlot::locationCode) + .containsExactly("A01", "A02", "A03"); + assertThat(guide.slots().get(2).boundWeightResultId()).isNull(); }); } @@ -537,6 +554,23 @@ class InboundMeasurementServiceTest { assertThat(revision.getValue().getNetWeightKg()).isEqualByComparingTo("11.500"); } + @Test + void confirmingAWeightBindsItToTheNextEmptySlotWhenTheTicketAlreadyHasABlock() { + InboundOrderEntity inbound = inbound("MEASURE_REJECTED"); + InboundWeightResultEntity weight = weight(20L, "30.000", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(palletAllocations.list(1L, 10L)).thenReturn(List.of( + locationSlot(1, 801L, "A01", "RESERVED_EMPTY", null))); + + service.confirmWeightResult(1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); + + verify(palletAllocations).bindNextEmpty(1L, 10L, weight); + } + @Test void confirmingFifthPalletSeparatelyAggregatesGrossAndCargoNetWeight() { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); @@ -834,6 +868,26 @@ class InboundMeasurementServiceTest { verify(repository).updateInboundWeightSummary(inbound); } + @Test + void deletingBoundWeightUnbindsTheSlotBeforeDeletingTheResultAndFailsOnFileError() { + InboundOrderEntity inbound = inbound("MEASURING"); + InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.deleteWeight(1L, 10L, 20L, 1)).thenReturn(true); + when(imageClient.delete("internal-token", 9L, 1L, 10L, 120L)) + .thenReturn(ApiResponse.failure(com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, + "file unavailable", null, null)); + + assertThatThrownBy(() -> service.deleteWeightResult(1L, 10L, 20L, 1, 9L)) + .isInstanceOf(BizException.class).hasMessageContaining("file unavailable"); + + org.mockito.InOrder order = org.mockito.Mockito.inOrder(palletAllocations, repository, imageClient); + order.verify(palletAllocations).unbindWeight(1L, 10L, 20L); + order.verify(repository).deleteWeight(1L, 10L, 20L, 1); + order.verify(imageClient).delete("internal-token", 9L, 1L, 10L, 120L); + } + @Test void deletingLastConfirmedWeightClearsInboundTotal() { InboundOrderEntity inbound = inbound("MEASURING"); @@ -1051,6 +1105,21 @@ class InboundMeasurementServiceTest { verify(batchService, never()).saveBatch(any()); } + private static InboundPalletAllocationEntity locationSlot( + int slotNo, long locationId, String locationCode, String slotStatus, Long weightResultId) { + InboundPalletAllocationEntity value = new InboundPalletAllocationEntity(); + value.setBlockId(700L); + value.setSlotNo(slotNo); + value.setReservationId(900L + slotNo); + value.setLocationId(locationId); + value.setLocationCode(locationCode); + value.setSharedOverflow(false); + value.setSlotStatus(slotStatus); + value.setWeightResultId(weightResultId); + value.setPalletSequence(weightResultId == null ? null : slotNo); + return value; + } + private InboundOrderEntity inbound(String status) { InboundOrderEntity inbound = new InboundOrderEntity(); inbound.setId(10L); 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 6373316f9..6665d207e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -342,7 +342,7 @@ class LoadingPickServiceTest { preparePick(task, item, "pick-1"); when(repository.allTaskItemsResolved(20L)).thenReturn(false); - service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-1"), 9L, "operator"); + service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-1"), 9L, "operator"); verify(repository).touchTask(20L, 0); verify(repository).syncInboundProjections(20L, 9L, "operator"); @@ -358,7 +358,7 @@ class LoadingPickServiceTest { when(repository.allTaskItemsResolved(20L)).thenReturn(true); when(repository.allPlanItemsPicked(10L)).thenReturn(true); - service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-2"), 9L, "operator"); + service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-2"), 9L, "operator"); verify(repository).touchTask(20L, 0); verify(settlementService, never()).settle(20L, 9L, "operator"); @@ -374,7 +374,7 @@ class LoadingPickServiceTest { preparePick(task, item, "pick-start-loading"); when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1); - service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-start-loading"), + service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-start-loading"), 9L, "operator"); verify(smartLoadingRepository).startPicking(10L, 9L, "operator"); @@ -393,7 +393,7 @@ class LoadingPickServiceTest { new LoadingInboundMapper.Snapshot(50L, 101L, "WAIT_SPLIT", 4)); when(repository.beginInboundLoading(101L, 50L, 9L, 4)).thenReturn(1); - service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-start-inbound"), + service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-start-inbound"), 9L, "operator"); verify(repository).beginInboundLoading(101L, 50L, 9L, 4); @@ -419,7 +419,7 @@ class LoadingPickServiceTest { new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 5)); when(repository.beginInboundLoading(101L, 50L, 9L, 4)).thenReturn(0); - service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-concurrent-inbound"), + service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-concurrent-inbound"), 9L, "operator"); verify(repository).beginInboundLoading(101L, 50L, 9L, 4); @@ -518,7 +518,8 @@ class LoadingPickServiceTest { void claimRechecksSameOperationAfterWaitingForTaskLock() { LoadingPickTaskEntity task = task(); LoadingPickOperationEntity completed = taskOperation("claim-race", "CLAIM"); - when(repository.operation("claim-race")).thenReturn(null, completed); + when(repository.operation("claim-race")).thenReturn(null); + when(repository.operationForUpdate("claim-race")).thenReturn(completed); when(repository.taskForUpdate(20L)).thenReturn(task); when(repository.task(20L)).thenReturn(task); when(repository.items(20L)).thenReturn(List.of()); @@ -535,7 +536,8 @@ class LoadingPickServiceTest { void assignRechecksSameOperationAfterWaitingForTaskLock() { LoadingPickTaskEntity task = task(); LoadingPickOperationEntity completed = taskOperation("assign-race", "ASSIGN"); - when(repository.operation("assign-race")).thenReturn(null, completed); + when(repository.operation("assign-race")).thenReturn(null); + when(repository.operationForUpdate("assign-race")).thenReturn(completed); when(repository.taskForUpdate(20L)).thenReturn(task); when(repository.task(20L)).thenReturn(task); when(repository.items(20L)).thenReturn(List.of()); @@ -554,17 +556,18 @@ class LoadingPickServiceTest { void pickRechecksSameHistoryAfterWaitingForTaskLock() { LoadingPickTaskEntity task = task(); LoadingPickHistoryEntity completed = itemHistory("pick-race", "PICK", - "20|30|101|2|MANUAL|[]"); - when(repository.history("pick-race")).thenReturn(null, completed); + "20|30|101|2"); + when(repository.history("pick-race")).thenReturn(null); + when(repository.historyForUpdate("pick-race")).thenReturn(completed); when(repository.taskForUpdate(20L)).thenReturn(task); when(repository.assignment(20L)).thenReturn(assignment()); when(repository.items(20L, 101L)).thenReturn(List.of(item())); Task replay = service.pick(20L, 30L, - new PickRequest(101L, 2, List.of(), 0, "pick-race"), 9L, "operator"); + new PickRequest(101L, 2, 0, "pick-race"), 9L, "operator"); assertThat(replay.id()).isEqualTo(20L); - verify(repository, never()).pick(anyLong(), anyInt(), anyString(), anyString(), anyInt(), anyLong()); + verify(repository, never()).pick(anyLong(), anyInt(), anyInt(), anyLong()); verify(repository, never()).insert(any(LoadingPickHistoryEntity.class)); verify(repository, never()).touchTask(anyLong(), anyInt()); } @@ -573,17 +576,18 @@ class LoadingPickServiceTest { void partialRechecksSameHistoryAfterWaitingForTaskLock() { LoadingPickTaskEntity task = task(); LoadingPickHistoryEntity completed = itemHistory("partial-race", "PARTIAL", - "PARTIAL|20|30|101|1|MANUAL|[]"); - when(repository.history("partial-race")).thenReturn(null, completed); + "PARTIAL|20|30|101|1"); + when(repository.history("partial-race")).thenReturn(null); + when(repository.historyForUpdate("partial-race")).thenReturn(completed); when(repository.taskForUpdate(20L)).thenReturn(task); when(repository.assignment(20L)).thenReturn(assignment()); when(repository.items(20L, 101L)).thenReturn(List.of(item())); Task replay = service.partial(20L, 30L, - new PartialPickRequest(101L, 1, List.of(), 0, "partial-race"), 9L, "operator"); + new PartialPickRequest(101L, 1, 0, "partial-race"), 9L, "operator"); assertThat(replay.id()).isEqualTo(20L); - verify(repository, never()).partial(anyLong(), anyInt(), anyString(), anyString(), anyInt(), anyLong()); + verify(repository, never()).partial(anyLong(), anyInt(), anyInt(), anyLong()); verify(repository, never()).insert(any(LoadingPickHistoryEntity.class)); verify(repository, never()).touchTask(anyLong(), anyInt()); } @@ -593,7 +597,8 @@ class LoadingPickServiceTest { LoadingPickTaskEntity task = task(); LoadingPickHistoryEntity completed = itemHistory("skip-race", "SKIP", "SKIP|20|30|101|damaged"); - when(repository.history("skip-race")).thenReturn(null, completed); + when(repository.history("skip-race")).thenReturn(null); + when(repository.historyForUpdate("skip-race")).thenReturn(completed); when(repository.taskForUpdate(20L)).thenReturn(task); when(repository.assignment(20L)).thenReturn(assignment()); when(repository.items(20L, 101L)).thenReturn(List.of(item())); @@ -615,7 +620,7 @@ class LoadingPickServiceTest { when(repository.allTaskItemsResolved(20L)).thenReturn(true); when(repository.allPlanItemsPicked(10L)).thenReturn(true); - service.partial(20L, 30L, new PartialPickRequest(101L, 1, List.of(), 0, "partial-1"), + service.partial(20L, 30L, new PartialPickRequest(101L, 1, 0, "partial-1"), 9L, "operator"); verify(repository).touchTask(20L, 0); @@ -1113,7 +1118,7 @@ class LoadingPickServiceTest { when(repository.assignment(20L)).thenReturn(assignment()); when(repository.items(20L)).thenReturn(items); when(repository.items(20L, 101L)).thenReturn(items); - when(repository.pick(30L, 2, "MANUAL", "[]", 0, 101L)).thenReturn(1); + when(repository.pick(30L, 2, 0, 101L)).thenReturn(1); when(repository.skip(31L, 0, 101L)).thenReturn(1); when(repository.allTaskItemsResolved(20L)).thenReturn(true); when(repository.touchTask(20L, 0)).thenReturn(1); @@ -1123,13 +1128,13 @@ class LoadingPickServiceTest { 71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2)))); String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of( - new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null), - new BatchItemRequest(31L, "SKIP", null, null, null, null, 0, "damaged carton"))); + new BatchItemRequest(30L, "PICK", 2, null, 0, null), + new BatchItemRequest(31L, "SKIP", null, null, 0, "damaged carton"))); service.batch(20L, "batch-1", request, 9L, "operator"); service.batch(20L, "batch-1", request, 9L, "operator"); - verify(repository, times(1)).pick(30L, 2, "MANUAL", "[]", 0, 101L); + verify(repository, times(1)).pick(30L, 2, 0, 101L); verify(repository, times(1)).skip(31L, 0, 101L); verify(repository, times(1)).touchTask(20L, 0); verify(settlementService, never()).settle(20L, 9L, "operator"); @@ -1154,7 +1159,7 @@ class LoadingPickServiceTest { when(repository.assignment(20L)).thenReturn(assignment()); when(repository.items(20L)).thenReturn(List.of(item)); when(repository.items(20L, 101L)).thenReturn(List.of(item)); - when(repository.pick(30L, 100, "AUTO", "[]", 0, 101L)).thenReturn(1); + when(repository.pick(30L, 100, 0, 101L)).thenReturn(1); when(repository.allTaskItemsResolved(20L)).thenReturn(true); when(repository.touchTask(20L, 0)).thenReturn(1); when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1); @@ -1164,11 +1169,11 @@ class LoadingPickServiceTest { 71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 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, "AUTO", List.of(), 0, null))); + new BatchItemRequest(30L, "PICK", 100, null, 0, null))); service.batch(20L, "batch-reset-plan", request, 9L, "operator"); - verify(repository).pick(30L, 100, "AUTO", "[]", 0, 101L); + verify(repository).pick(30L, 100, 0, 101L); verify(smartLoadingRepository).startPicking(10L, 9L, "operator"); verify(smartLoadingRepository, never()).insert(any(LoadingPlanHistoryEntity.class)); verify(repository).touchTask(20L, 0); @@ -1190,14 +1195,14 @@ class LoadingPickServiceTest { when(repository.items(20L)).thenReturn(items); when(repository.items(20L, 101L)).thenReturn(items); BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "stale-token", List.of( - new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null), - new BatchItemRequest(31L, "PARTIAL", null, 4, "AUTO", null, 0, null))); + new BatchItemRequest(30L, "PICK", 2, null, 0, null), + new BatchItemRequest(31L, "PARTIAL", null, 4, 0, null))); assertThatThrownBy(() -> service.batch(20L, "batch-invalid", request, 9L, "operator")) .isInstanceOf(BizException.class); - verify(repository, never()).pick(any(Long.class), any(Integer.class), any(), any(), any(Integer.class), any(Long.class)); - verify(repository, never()).partial(any(Long.class), any(Integer.class), any(), any(), any(Integer.class), any(Long.class)); + verify(repository, never()).pick(any(Long.class), any(Integer.class), any(Integer.class), any(Long.class)); + verify(repository, never()).partial(any(Long.class), any(Integer.class), any(Integer.class), any(Long.class)); verify(repository, never()).touchTask(any(Long.class), any(Integer.class)); verify(settlementService, never()).settle(20L, 9L, "operator"); } @@ -1214,13 +1219,13 @@ class LoadingPickServiceTest { ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( 71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2)))); BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "0".repeat(64), List.of( - new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null))); + new BatchItemRequest(30L, "PICK", 2, null, 0, null))); assertThatThrownBy(() -> service.batch(20L, "batch-inventory-stale", request, 9L, "operator")) .isInstanceOf(BizException.class) .hasMessageContaining("库存已变化"); - verify(repository, never()).pick(any(Long.class), any(Integer.class), any(), any(), + verify(repository, never()).pick(any(Long.class), any(Integer.class), any(Integer.class), any(Long.class)); verify(repository, never()).touchTask(any(Long.class), any(Integer.class)); verify(settlementService, never()).settle(20L, 9L, "operator"); @@ -1232,7 +1237,7 @@ class LoadingPickServiceTest { task.setVersion(3); when(repository.taskForUpdate(20L)).thenReturn(task); BatchRequest request = new BatchRequest(101L, 10L, 99L, 2, "stale-token", List.of( - new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null))); + new BatchItemRequest(30L, "PICK", 2, null, 0, null))); assertThatThrownBy(() -> service.batch(20L, "batch-stale", request, 9L, "operator")) .isInstanceOf(BizException.class); @@ -1250,15 +1255,14 @@ class LoadingPickServiceTest { when(repository.assignment(20L)).thenReturn(assignment()); when(repository.items(20L)).thenReturn(List.of(item)); when(repository.items(20L, 101L)).thenReturn(List.of(item)); - when(repository.pick(30L, 2, "MANUAL", "[]", 0, 101L)).thenReturn(1); + 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)))); String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); - BatchItemRequest line = new BatchItemRequest( - 30L, "PICK", 2, null, "MANUAL", List.of(), 0, null); + BatchItemRequest line = new BatchItemRequest(30L, "PICK", 2, null, 0, null); assertThatThrownBy(() -> service.batch(20L, "batch-stale", new BatchRequest( 101L, 10L, 99L, 1, snapshotToken, List.of(line)), 9L, "operator")) @@ -1270,7 +1274,7 @@ class LoadingPickServiceTest { service.batch(20L, "batch-valid", new BatchRequest( 101L, 10L, 99L, 0, snapshotToken, List.of(line)), 9L, "operator"); - verify(repository, times(1)).pick(30L, 2, "MANUAL", "[]", 0, 101L); + verify(repository, times(1)).pick(30L, 2, 0, 101L); verify(repository, times(1)).touchTask(20L, 0); } @@ -1291,7 +1295,7 @@ class LoadingPickServiceTest { BatchRequest staleRequest = new BatchRequest(101L, 10L, 99L, 1, "stale-token", List.of( - new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null))); + new BatchItemRequest(30L, "PICK", 2, null, 0, null))); assertThatThrownBy(() -> service.batch(20L, "batch-stale-after-resync", staleRequest, 9L, "operator")).isInstanceOf(BizException.class); @@ -1299,7 +1303,7 @@ class LoadingPickServiceTest { assertThat(pending.getPickedPackageCount()).isZero(); assertThat(pending.getItemStatus()).isEqualTo("PENDING"); verify(repository, never()).update(any(LoadingPickItemEntity.class)); - verify(repository, never()).pick(any(Long.class), any(Integer.class), any(), any(), + verify(repository, never()).pick(any(Long.class), any(Integer.class), any(Integer.class), any(Long.class)); verify(repository, never()).touchTask(any(Long.class), any(Integer.class)); } @@ -1318,7 +1322,7 @@ class LoadingPickServiceTest { when(repository.taskForUpdate(20L)).thenReturn(task); when(repository.assignment(20L)).thenReturn(assignment()); when(repository.item(20L, 30L, 101L)).thenReturn(item); - when(repository.pick(30L, 2, "MANUAL", "[]", 0, 101L)).thenReturn(1); + when(repository.pick(30L, 2, 0, 101L)).thenReturn(1); when(repository.touchTask(20L, 0)).thenReturn(1); when(repository.items(20L, 101L)).thenReturn(List.of(item)); } @@ -1329,7 +1333,7 @@ class LoadingPickServiceTest { when(repository.taskForUpdate(20L)).thenReturn(task); when(repository.assignment(20L)).thenReturn(assignment()); when(repository.item(20L, 30L, 101L)).thenReturn(item); - when(repository.partial(30L, 1, "MANUAL", "[]", 0, 101L)).thenReturn(1); + when(repository.partial(30L, 1, 0, 101L)).thenReturn(1); when(repository.touchTask(20L, 0)).thenReturn(1); when(repository.items(20L, 101L)).thenReturn(List.of(item)); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java index 7e8d334d8..480394601 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java @@ -52,37 +52,38 @@ class MeasurementLocationOccupationServiceTest { properties.setWarehouseResourceTenantId(90001L); when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights()); - when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"), - org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED"))); - when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"), - org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("OCCUPIED"))); + when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3))); + when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3))); service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3); - ArgumentCaptor reserve = - ArgumentCaptor.forClass(WarehousePalletLocationClient.ReserveRequest.class); - verify(warehouse).reserve(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture()); - assertThat(reserve.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT); + ArgumentCaptor reserve = + ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockReserveRequest.class); + verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture()); assertThat(reserve.getValue().businessId()).isEqualTo(INBOUND); - assertThat(reserve.getValue().idempotencyKey()).startsWith("measure-88-30-3-"); - assertThat(reserve.getValue().pallets()) - .extracting(WarehousePalletLocationClient.PalletReference::palletId) - .containsExactly(601L, 601L, 601L); + assertThat(reserve.getValue().idempotencyKey()).startsWith("measure-block-88-30-3-"); + assertThat(reserve.getValue().requiredSlotCount()).isEqualTo(3); - ArgumentCaptor confirm = - ArgumentCaptor.forClass(WarehousePalletLocationClient.ConfirmRequest.class); - verify(warehouse).confirm(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture()); - assertThat(confirm.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT); + ArgumentCaptor confirm = + ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockConfirmRequest.class); + verify(warehouse).confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture()); assertThat(confirm.getValue().businessId()).isEqualTo(INBOUND); - assertThat(confirm.getValue().packageCount()).isEqualTo(20); + assertThat(confirm.getValue().blockId()).isEqualTo(500L); + assertThat(confirm.getValue().idempotencyKey()) + .isEqualTo(reserve.getValue().idempotencyKey() + "-confirm"); - verify(allocations, times(2)).replace( + verify(allocations, times(2)).replaceBlock( org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT), org.mockito.ArgumentMatchers.eq(INBOUND), org.mockito.ArgumentMatchers.eq(WAREHOUSE), - org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT), org.mockito.ArgumentMatchers.eq(20), + org.mockito.ArgumentMatchers.eq(new BigDecimal("1.0000")), org.mockito.ArgumentMatchers.any()); + verify(allocations, times(2)).bindConfirmedWeights( + org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT), + org.mockito.ArgumentMatchers.eq(INBOUND), org.mockito.ArgumentMatchers.anyList()); } @Test @@ -90,33 +91,51 @@ class MeasurementLocationOccupationServiceTest { properties.setWarehouseResourceTenantId(0L); when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights()); - when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"), - org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED"))); - when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"), - org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("OCCUPIED"))); + when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3))); + when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3))); service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3); - ArgumentCaptor reserve = - ArgumentCaptor.forClass(WarehousePalletLocationClient.ReserveRequest.class); - verify(warehouse).reserve(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture()); - assertThat(reserve.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT); + ArgumentCaptor reserve = + ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockReserveRequest.class); + verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture()); + assertThat(reserve.getValue().warehouseId()).isEqualTo(WAREHOUSE); - ArgumentCaptor confirm = - ArgumentCaptor.forClass(WarehousePalletLocationClient.ConfirmRequest.class); - verify(warehouse).confirm(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture()); - assertThat(confirm.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT); - assertThat(confirm.getValue().packageCount()).isEqualTo(20); + ArgumentCaptor confirm = + ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockConfirmRequest.class); + verify(warehouse).confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture()); + assertThat(confirm.getValue().businessId()).isEqualTo(INBOUND); - verify(allocations, times(2)).replace( + verify(allocations, times(2)).replaceBlock( org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT), org.mockito.ArgumentMatchers.eq(INBOUND), org.mockito.ArgumentMatchers.eq(WAREHOUSE), - org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT), org.mockito.ArgumentMatchers.eq(20), + org.mockito.ArgumentMatchers.eq(new BigDecimal("1.0000")), org.mockito.ArgumentMatchers.any()); } + @Test + void reservesThreeSlotsForVolumeWhileBindingOnlyTwoConfirmedWeightImages() { + InboundOrderEntity inbound = inbound(); + inbound.setActualVolumeCbm(new BigDecimal("2.5000")); + List twoWeights = confirmedWeights().subList(0, 2); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(twoWeights); + when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3))); + when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3))); + + service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3); + + verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.argThat(request -> request.requiredSlotCount() == 3)); + verify(allocations, times(2)).bindConfirmedWeights(BUSINESS_TENANT, INBOUND, twoWeights); + } + @Test void rejectsConfirmedWeightWithoutConfirmationTimeInsteadOfDroppingIt() { InboundWeightResultEntity weight = confirmedWeights().get(0); @@ -128,7 +147,7 @@ class MeasurementLocationOccupationServiceTest { .isInstanceOf(BizException.class) .hasMessageContaining("缺少确认时间"); - verify(warehouse, never()).reserve(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + verify(warehouse, never()).reserveBlock(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); } @Test @@ -142,7 +161,7 @@ class MeasurementLocationOccupationServiceTest { .isInstanceOf(BizException.class) .hasMessageContaining("缺少有效净重"); - verify(warehouse, never()).reserve(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + verify(warehouse, never()).reserveBlock(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); } @Test @@ -156,7 +175,7 @@ class MeasurementLocationOccupationServiceTest { .isInstanceOf(BizException.class) .hasMessageContaining("缺少有效托盘"); - verify(warehouse, never()).reserve(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + verify(warehouse, never()).reserveBlock(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); } @Test @@ -164,24 +183,23 @@ class MeasurementLocationOccupationServiceTest { properties.setWarehouseResourceTenantId(90001L); when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights()); - when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"), - org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED"))); + when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3))); IllegalStateException failure = new IllegalStateException("confirm failed"); - when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"), + when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), org.mockito.ArgumentMatchers.any())).thenThrow(failure); - when(warehouse.release(org.mockito.ArgumentMatchers.eq("internal-token"), - org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(List.of())); + when(warehouse.releaseBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RELEASED", 3))); assertThatThrownBy(() -> service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3)) .isSameAs(failure); - ArgumentCaptor release = - ArgumentCaptor.forClass(WarehousePalletLocationClient.ReleaseRequest.class); - verify(warehouse).release(org.mockito.ArgumentMatchers.eq("internal-token"), release.capture()); - assertThat(release.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT); + ArgumentCaptor release = + ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockReleaseRequest.class); + verify(warehouse).releaseBlock(org.mockito.ArgumentMatchers.eq("internal-token"), release.capture()); assertThat(release.getValue().businessId()).isEqualTo(INBOUND); - verify(allocations).replace(BUSINESS_TENANT, INBOUND, WAREHOUSE, PLATFORM_RESOURCE_TENANT, - 20, batch("RESERVED")); + verify(allocations).replaceBlock(BUSINESS_TENANT, INBOUND, WAREHOUSE, + 20, new BigDecimal("1.0000"), block("RESERVED", 3)); } private static InboundProperties properties() { @@ -209,12 +227,12 @@ class MeasurementLocationOccupationServiceTest { return value; } - private static WarehousePalletLocationClient.BatchResult batch(String state) { - return new WarehousePalletLocationClient.BatchResult(3, java.util.stream.IntStream.rangeClosed(1, 3) - .mapToObj(index -> new WarehousePalletLocationClient.AllocationResult( - 500L + index, "RES-00" + index, index, 601L, "PL-001", new BigDecimal("1.200000"), - 700L + index, "A-00" + index, new BigDecimal("0.333333"), state)) - .toList()); + private static WarehousePalletLocationClient.BlockResult block(String state, int count) { + return new WarehousePalletLocationClient.BlockResult(500L, state, + java.util.stream.IntStream.rangeClosed(1, count) + .mapToObj(index -> new WarehousePalletLocationClient.BlockResource( + 500L, 600L + index, 700L + index, "A-00" + index, index, false, state)) + .toList()); } private static List confirmedWeights() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java index 9bf638824..f4c49566f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java @@ -56,9 +56,6 @@ class PdaPickSettlementServiceTest { .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation -> List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10))); - when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new WarehousePalletLocationClient.SettlementResult(List.of(), List.of( - transfer(1, 501L, 2), transfer(2, 502L, 10))))); writes(12, 8); service.settle(1L, 9L, "operator"); @@ -71,14 +68,13 @@ class PdaPickSettlementServiceTest { verify(splits).normalizeLoadedCartonLabels(10001L, 31L, 8); verify(splits).markSourceLoaded(10001L, 31L, 8, 9L, 3); verify(allocations).consume(10001L, 31L, 1L, 8); - verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request -> - request.warehouseId() == 701L && request.sourceBusinessId() == 31L - && request.idempotencyKey().equals("PDA-SETTLE-1-31") - && request.releasedReservationIds().isEmpty() - && request.remainingItems().equals(List.of(new WarehousePalletLocationClient.SettlementTransferItem(91L, 2), - new WarehousePalletLocationClient.SettlementTransferItem(92L, 10))))); - verify(allocations).copyRemainingToChild(eq(10001L), anyLong(), any()); - verify(allocations).markTransferred(eq(10001L), eq(31L), any(), eq(List.of())); + verify(warehouse).transferBlock(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request -> + request.blockId() == 800L && request.sourceBusinessId() == 31L + && request.targetBusinessId() > 0 + && request.idempotencyKey().equals("PDA-BLOCK-1-31"))); + verify(warehouse, never()).releaseBlock(anyString(), any()); + verify(allocations).copyBlockToChild(eq(10001L), anyLong(), any()); + verify(allocations).markBlockTransferred(10001L, 31L, 800L); verify(splits).convergePartialPlanItem(21L, 9L, "operator"); } @@ -92,8 +88,6 @@ class PdaPickSettlementServiceTest { new MeasurementItemRow(52L, 61L, 41L, 1))); when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation -> List.of(allocation(1L, 1, 501L, 2))); - when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(transfer(1, 501L, 1))))); writes(1, 1); when(splits.insertSplitItem(anyLong(), eq(10001L), anyLong(), eq(31L), anyLong(), anyInt(), eq(41L), eq(61L), eq(52L), eq(1))).thenReturn(1); @@ -114,7 +108,7 @@ class PdaPickSettlementServiceTest { } @Test - void reusesWarehouseSettlementTargetWhenRetryingAfterLocalRollback() { + void transfersTheWholeBlockToTheGeneratedRemainderChild() { LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 3, 1, "PARTIAL"); resolved(List.of(item)); when(splits.lockParent(31L)).thenReturn(parent(3)); @@ -122,19 +116,16 @@ class PdaPickSettlementServiceTest { .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 3))); when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation -> List.of(allocation(1L, 1, 501L, 3))); - when(warehouse.settlementTarget("internal-token", 31L, "PDA-SETTLE-1-31")) - .thenReturn(ApiResponse.success(999L)); - when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(transfer(1, 501L, 2))))); writes(2, 1); service.settle(1L, 9L, "operator"); - verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), eq(999L), + verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), eq(1002L), eq("ORDER-REMAINDER"), eq("IN-001-1"), eq(2), eq(1), eq("REMAINDER"), eq(9L), eq("operator")); - verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request -> - request.targetBusinessId() == 999L && request.targetPackageCount() == 2)); + verify(warehouse).transferBlock(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request -> + request.blockId() == 800L && request.sourceBusinessId() == 31L + && request.targetBusinessId() > 0)); } @Test @@ -146,12 +137,13 @@ class PdaPickSettlementServiceTest { service.settle(1L, 9L, "operator"); - verify(warehouse, never()).settle(anyString(), any()); + verify(warehouse, never()).transferBlock(anyString(), any()); + verify(warehouse, never()).releaseBlock(anyString(), any()); verify(splits, never()).convergePartialPlanItem(anyLong(), anyLong(), anyString()); } @Test - void releasesFullyPickedAllocationBeforeTransferringChildResources() { + void partialPickTransfersTheWholeBlockWithoutReleasingPickedEmptySlots() { LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 10, "PICKED"); resolved(List.of(item)); when(splits.lockParent(31L)).thenReturn(parent(20)); @@ -159,17 +151,13 @@ class PdaPickSettlementServiceTest { .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation -> List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10))); - when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L)), - List.of(transfer(2, 502L, 10))))); writes(10, 10); service.settle(1L, 9L, "operator"); - verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request -> - request.idempotencyKey().equals("PDA-SETTLE-1-31") - && request.releasedReservationIds().equals(List.of(91L)) - && request.remainingItems().equals(List.of(new WarehousePalletLocationClient.SettlementTransferItem(92L, 10))))); + verify(warehouse).transferBlock(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request -> + request.blockId() == 800L && request.sourceBusinessId() == 31L)); + verify(warehouse, never()).releaseBlock(anyString(), any()); } @Test @@ -184,9 +172,6 @@ class PdaPickSettlementServiceTest { first.setPalletId(801L); second.setPalletId(801L); when(allocations.lockConfirmed(10001L, 31L)).thenReturn(List.of(first, second)); - when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L), released(92L, 501L)), - List.of()))); writes(0, 20); service.settle(1L, 9L, "operator"); @@ -196,10 +181,10 @@ class PdaPickSettlementServiceTest { verify(splits, never()).cloneChildWithPurpose(anyLong(), anyLong(), anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyInt(), anyInt(), anyString(), anyLong(), anyString()); verify(splits).markSourceLoaded(10001L, 31L, 20, 9L, 3); - verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request -> - request.targetBusinessId() == 0L && request.targetPackageCount() == 0 - && request.releasedReservationIds().equals(List.of(91L, 92L)) - && request.remainingItems().isEmpty())); + verify(warehouse).releaseBlock(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request -> + request.blockId() == 800L && request.businessId() == 31L + && request.reason().equals("ALL_PACKAGES_PICKED"))); + verify(warehouse, never()).transferBlock(anyString(), any()); } @Test @@ -222,9 +207,6 @@ class PdaPickSettlementServiceTest { .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); when(allocations.lockConfirmed(10001L, 31L)) .thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10))); - when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new WarehousePalletLocationClient.SettlementResult( - List.of(released(91L, 501L), released(92L, 502L)), List.of()))); writes(0, 20); service.settle(1L, 9L, "operator"); @@ -243,9 +225,6 @@ class PdaPickSettlementServiceTest { .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); when(allocations.lockConfirmed(10001L, 31L)) .thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10))); - when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new WarehousePalletLocationClient.SettlementResult( - List.of(released(91L, 501L), released(92L, 502L)), List.of()))); writes(0, 20); service.settle(1L, 9L, "operator"); @@ -294,9 +273,6 @@ class PdaPickSettlementServiceTest { stubOrderSplitSuccess(); writes(0, 20); - when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new WarehousePalletLocationClient.SettlementResult( - List.of(released(91L, 501L), released(92L, 502L)), List.of()))); service.settle(1L, 9L, "operator"); @@ -312,8 +288,6 @@ class PdaPickSettlementServiceTest { resolvedChild(item, 32L, 42L, 62L, 52L, 12, 2); when(splits.nextSplitSequence(10001L, 31L)).thenReturn(2); childWrites(32L, 42L, 62L, 52L, 5, 2); - when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(transfer(1, 501L, 7))))); service.settle(1L, 9L, "operator"); @@ -336,8 +310,6 @@ class PdaPickSettlementServiceTest { when(splits.nextSplitSequence(10001L, 31L)).thenReturn(3); childWrites(33L, 43L, 63L, 53L, 4, 3); when(splits.markSourceLoaded(10001L, 33L, 4, 9L, 3)).thenReturn(0, 1); - when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(transfer(1, 501L, 5))))); assertThatThrownBy(() -> service.settle(1L, 9L, "operator")) .hasMessageContaining("来源分片状态已被并发修改"); @@ -360,6 +332,10 @@ class PdaPickSettlementServiceTest { task.setId(1L); task.setLoadingPlanId(701L); when(picks.task(1L)).thenReturn(task); when(splits.containerBookingId(701L)).thenReturn(801L); + when(warehouse.transferBlock(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(block("OCCUPIED"))); + when(warehouse.releaseBlock(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(block("RELEASED"))); stubOrderSplitSuccess(); } @@ -456,19 +432,16 @@ class PdaPickSettlementServiceTest { private static InboundPalletAllocationEntity allocation(long id, int no, long locationId, int allocated) { InboundPalletAllocationEntity value = new InboundPalletAllocationEntity(); value.setId(id); value.setAllocationNo(no); value.setLocationId(locationId); value.setWarehouseId(701L); + value.setBlockId(800L); value.setSlotNo(no); value.setAllocationStatus("CONFIRMED"); value.setReservationId(90L + no); value.setAllocatedPackageCount(allocated); value.setPickedPackageCount(0); return value; } - private static WarehousePalletLocationClient.TransferResult transfer(int no, long locationId, int remaining) { - return new WarehousePalletLocationClient.TransferResult(90L + no, "RES-" + no, 92L + no, no, - 0L, 701L, 801L + no, "PALLET-" + no, null, locationId, "L-" + no, null, remaining, "OCCUPIED"); - } - - private static WarehousePalletLocationClient.SelectiveReleaseResult released(long reservationId, long locationId) { - return new WarehousePalletLocationClient.SelectiveReleaseResult(reservationId, 92L, 1, 801L, - locationId, "L-1", 10); + private static WarehousePalletLocationClient.BlockResult block(String status) { + return new WarehousePalletLocationClient.BlockResult(800L, status, List.of( + new WarehousePalletLocationClient.BlockResource( + 800L, 91L, 501L, "L-1", 1, false, status))); } private static LoadingPickItemEntity item(long id, long planItemId, long inboundId, long detailId, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index d96b3e9b7..3cc3cf78d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -1413,6 +1413,44 @@ class SmartLoadingServiceTest { assertThat(updated.getValue().getWarehouseRemark()).isEqualTo("装柜时注意外箱"); } + @Test + void quantityAdjustmentCanDecreaseWhenCalculatedRemainingQuantityIsNegative() { + LoadingPlanEntity plan = plan("LOADING", 93); + LoadingPlanItemEntity line = item(8, 9, 20, 50); + when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 94)); + when(repository.item(1L, 8L)).thenReturn(line); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(-25)); + when(repository.items(1L)).thenReturn(List.of(line)); + when(repository.touch(1L, 93, 7L, "u")).thenReturn(1); + when(repository.update(line)).thenReturn(1); + + service.adjust(1L, 8L, new AdjustRequest(30, 93, 0, "adjust-down"), 7L, "u"); + + ArgumentCaptor updated = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); + verify(repository).update(updated.capture()); + assertThat(updated.getValue().getPackageCount()).isEqualTo(30); + } + + @Test + void quantityAdjustmentOnlyAppliesRemainingQuantityLimitToTheIncrease() { + LoadingPlanEntity plan = plan("LOADING", 2); + LoadingPlanItemEntity line = item(8, 9, 20, 50); + when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3)); + when(repository.item(1L, 8L)).thenReturn(line); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(10)); + when(repository.items(1L)).thenReturn(List.of(line)); + when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); + when(repository.update(line)).thenReturn(1); + + service.adjust(1L, 8L, new AdjustRequest(60, 2, 0, "adjust-up"), 7L, "u"); + + ArgumentCaptor updated = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); + verify(repository).update(updated.capture()); + assertThat(updated.getValue().getPackageCount()).isEqualTo(60); + } + @Test void unchangedQuantityAndCargoSnapshotDoNotWriteAdjustmentHistory() { LoadingPlanEntity plan = plan("LOADING", 2); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java index ef55c558c..67cb9964c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java @@ -97,11 +97,13 @@ class TaillegRealProducerAcceptanceTest { private void seedAllocation(long id, long inboundId, long palletId, long locationId, int packages, String suffix) { jdbc.update("INSERT IGNORE INTO fl_inbound_pallet_allocation(id,tenant_id,inbound_id,allocation_no," + - "warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," + + "block_id,slot_no,weight_result_id,pallet_sequence,warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," + "allocated_volume_cbm,allocated_package_count,picked_package_count,location_id,location_code," + - "reservation_id,reservation_no,allocation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED')", - id, CUSTOMER, inboundId, 1, 92L, 0L, palletId, MARKER + "-PALLET-" + suffix, - 1.2, 0.4, packages, packages, locationId, "A-01", id + 1000, MARKER + "-RES-" + suffix); + "shared_overflow,reservation_id,slot_status,allocation_status) " + + "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'BOUND','CONFIRMED')", + id, CUSTOMER, inboundId, 1, id + 3000, 1, id + 2000, 1, 92L, 0L, + palletId, MARKER + "-PALLET-" + suffix, 1.2, 0.4, packages, packages, + locationId, "A-01", false, id + 1000); } private void seedPickItem(long id, long planItemId, long inboundId, long detailId, int packages) { diff --git a/scripts/admin-test-env-modules/coload-groups.mjs b/scripts/admin-test-env-modules/coload-groups.mjs index 0b06a21d6..606922dca 100644 --- a/scripts/admin-test-env-modules/coload-groups.mjs +++ b/scripts/admin-test-env-modules/coload-groups.mjs @@ -819,11 +819,9 @@ export async function run(context) { const skippedInboundId = id(partialTarget.inboundId) const partialBatchItems = (partialPda.task.items || []).map((item) => id(item.inboundId) === skippedInboundId ? { itemId: id(item.id), action: 'SKIP', pickedPackageCount: null, - finalPickedPackageCount: null, releaseMode: null, - locationIds: [], version: Number(item.version), reason: '同柜不可拆组短拣验收' } + finalPickedPackageCount: null, version: Number(item.version), reason: '同柜不可拆组短拣验收' } : { itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount), - finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [], - version: Number(item.version), reason: null }) + finalPickedPackageCount: null, version: Number(item.version), reason: null }) const partialBatchResponse = await context.request('POST', `${PDA}/tasks/${partialPda.taskId}/batch`, { warehouseId: first.warehouseId, loadingPlanId: partialChain.planId, containerBookingId: partialChain.containerBookingId, expectedVersion: Number(partialPda.task.version), @@ -863,8 +861,7 @@ export async function run(context) { const fullTreesBefore = await fulfillmentTrees(context, fullChain.fixtures, '足量完成前') const fullBatchItems = (fullPda.task?.items || []).map((item) => ({ itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount), - finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [], - version: Number(item.version), reason: null, + finalPickedPackageCount: null, version: Number(item.version), reason: null, })) const fullBatchResponse = await context.request('POST', `${PDA}/tasks/${fullPda.taskId}/batch`, { warehouseId: third.warehouseId, loadingPlanId: fullChain.planId, @@ -910,8 +907,7 @@ export async function run(context) { const normalTreeBefore = await fulfillmentTrees(context, normalChain.fixtures, '普通单完成前') const normalBatchItems = (normalPda.task?.items || []).map((item) => ({ itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount), - finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [], - version: Number(item.version), reason: null, + finalPickedPackageCount: null, version: Number(item.version), reason: null, })) const normalBatchResponse = await context.request('POST', `${PDA}/tasks/${normalPda.taskId}/batch`, { warehouseId: seventh.warehouseId, loadingPlanId: normalChain.planId, diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index 6390897a6..a8b183d26 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -291,7 +291,7 @@ async function pickAll(context, chain, label) { expectedVersion: Number(chain.task.version), inventorySnapshotToken: snapshot.snapshotToken, items: chain.task.items.map((item) => ({ itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount), finalPickedPackageCount: null, - releaseMode: 'MANUAL', locationIds: [], version: Number(item.version), reason: null })), + version: Number(item.version), reason: null })), }, `${label}-batch`) chain.task = context.apiSuccess(MODULE, `${label} 完成 PDA 拣货`, response, '', OPTIONS) return chain diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index 367b78a19..f2b2e522e 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -243,9 +243,22 @@ export async function run(context) { inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, expectedVersion: Number(foreignPlan?.version), operationId: `${context.marker}-other-channel-assign`.slice(0, 128), }) - context.addStep(MODULE, '两个真实有效渠道之间拒绝跨渠道装柜', foreignAssignResponse, - foreignAssignResponse.http === 200 && ['A0422', '422'].includes(codeOf(foreignAssignResponse)), + const foreignAssigned = foreignAssignResponse.body?.data + const foreignItem = Array.isArray(foreignAssigned?.items) + ? foreignAssigned.items.find((item) => id(item.inboundId) === inboundId + && id(item.inboundDetailId) === id(candidate.inboundDetailId)) + : null + context.addStep(MODULE, '手工装柜允许两个真实有效渠道之间跨渠道分配', foreignAssignResponse, + foreignAssignResponse.http === 200 && codeOf(foreignAssignResponse) === '200' && Boolean(foreignItem?.id), `primary=${channelCode}, foreign=${foreignChannel.channelCode}, HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS) + if (foreignItem?.id) { + const foreignRemoveResponse = await request(context, 'DELETE', + `${SMART}/plans/${foreignPlan?.id}/items/${foreignItem.id}`, { + expectedPlanVersion: Number(foreignAssigned.version), expectedItemVersion: Number(foreignItem.version), + operationId: `${context.marker}-other-channel-remove`.slice(0, 128), + }) + context.apiSuccess(MODULE, '跨渠道分配验收后释放入仓码供主流程继续', foreignRemoveResponse, '', OPTIONS) + } } const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`) @@ -681,8 +694,7 @@ export async function run(context) { const batchPath = `${PDA}/tasks/${taskId}/batch` const batchItems = [{ itemId: id(pickItem.id), action: 'PICK', pickedPackageCount: 1, - finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [], - version: Number(pickItem.version), reason: null }] + finalPickedPackageCount: null, version: Number(pickItem.version), reason: null }] const staleBatchResponse = await request(context, 'POST', batchPath, { warehouseId, loadingPlanId: planId, containerBookingId: containerId, expectedVersion: Number(task.version) + 1, diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index c4dd150db..2220b49f9 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -655,7 +655,7 @@ export async function run(context) { inventorySnapshotToken: inventory.snapshotToken, items: [{ itemId: id(canonicalItem.id), action: 'PICK', pickedPackageCount: 1, - releaseMode: 'MANUAL', locationIds: [], version: Number(canonicalItem.version), + version: Number(canonicalItem.version), }], } @@ -1279,7 +1279,7 @@ export async function run(context) { expectedVersion: Number(afterUnresolvedCompleteTask?.version), inventorySnapshotToken: partialInventory?.snapshotToken, items: [{ itemId: id(unresolvedTaskItem?.id), action: 'PARTIAL', finalPickedPackageCount: 1, - releaseMode: 'MANUAL', locationIds: [], version: Number(unresolvedTaskItem?.version) }], + version: Number(unresolvedTaskItem?.version) }], }, { 'Idempotency-Key': partialBatchKey }) const partialTask = context.apiSuccess(MODULE, '合法 PARTIAL 解决未完成明细', partialBatchResponse, '', OPTIONS) const partialTaskItem = partialTask?.items?.find?.((item) => id(item?.id) === id(unresolvedTaskItem?.id)) @@ -1349,7 +1349,7 @@ export async function run(context) { expectedVersion: Number(incrementedTask?.version), inventorySnapshotToken: effectiveBatchPayload.inventorySnapshotToken, items: [{ itemId: id(incrementalTaskItem.id), action: 'PICK', pickedPackageCount: 1, - releaseMode: 'MANUAL', locationIds: [], version: Number(incrementalTaskItem.version) }], + version: Number(incrementalTaskItem.version) }], }, { 'Idempotency-Key': `${context.marker}-dynamic-stale-inventory`.slice(0, 128) }) context.addStep(MODULE, '增量后旧 inventory token 明确 A0409 并要求刷新', staleIncrementBatch, staleIncrementBatch.http === 200 && codeOf(staleIncrementBatch) === 'A0409' @@ -1426,7 +1426,7 @@ export async function run(context) { expectedVersion: Number(refreshedDynamicTask?.version), inventorySnapshotToken: refreshedDynamicInventory?.snapshotToken, items: [{ itemId: id(refreshedDynamicTaskItem?.id), action: 'PICK', pickedPackageCount: Number(reassignedPlanItem?.packageCount), - releaseMode: 'MANUAL', locationIds: [], version: Number(refreshedDynamicTaskItem?.version) }], + version: Number(refreshedDynamicTaskItem?.version) }], }, { 'Idempotency-Key': dynamicBatchKey }) const dynamicPickedTask = context.apiSuccess(MODULE, '刷新后完成动态新增项拣货', dynamicBatchResponse, '', OPTIONS) context.assert(MODULE, '动态新增项拣完后同一任务重新进入 AWAITING_REVIEW', dynamicBatchResponse, diff --git a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs index 89430670a..65e5c3560 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs @@ -75,7 +75,7 @@ test('each remainder settles only through inventory snapshot, atomic batch and e assert.match(source, /expectedVersion: Number\(inventory\.taskVersion\)/) assert.match(source, /inventorySnapshotToken: inventory\.snapshotToken/) assert.match(source, /action: 'PICK', pickedPackageCount: 1/) - assert.match(source, /version: Number\(claimedItem\.version\), releaseMode: 'MANUAL', locationIds: \[\]/) + assert.doesNotMatch(source, /releaseMode|locationIds|reservationIds/) assert.match(source, /'Idempotency-Key': batchKey/) assert.match(source, /differentReplay/) assert.match(source, /workflowStatus === 'AWAITING_REVIEW'/) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index 6a4eff852..fce5b7f95 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -392,7 +392,7 @@ async function loadOneFragment(context, state) { expectedVersion: Number(inventory.taskVersion), inventorySnapshotToken: inventory.snapshotToken, items: [{ itemId: id(claimedItem.id), action: 'PICK', pickedPackageCount: 1, - version: Number(claimedItem.version), releaseMode: 'MANUAL', locationIds: [], + version: Number(claimedItem.version), }], } const staleBatch = await request(context, 'POST', batchPath, { diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 0352d3a6e..6b2d07802 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -383,12 +383,23 @@ test('channel isolation reports BLOCKED_FIXTURE instead of inventing a second ch assert.match(value, /context\.createdResources\?\.platformLogisticsChannels/) }) -test('container loading enforces channel isolation and stable PDA identity across container number changes', () => { +test('manual cross-channel loading fixture releases the inbound before the primary flow', () => { + const value = source('./phase3-container-loading.mjs') + assertOrder(value, [ + '新增真实异渠道模拟柜', + '手工装柜允许两个真实有效渠道之间跨渠道分配', + '跨渠道分配验收后释放入仓码供主流程继续', + '人工分配本轮候选货物', + ]) +}) + +test('container loading permits manual cross-channel allocation and keeps stable PDA identity across container number changes', () => { const value = source('./phase3-container-loading.mjs') assertOrder(value, [ '新增内部模拟柜', '新增真实异渠道模拟柜', - '两个真实有效渠道之间拒绝跨渠道装柜', + '手工装柜允许两个真实有效渠道之间跨渠道分配', + '跨渠道分配验收后释放入仓码供主流程继续', '人工分配本轮候选货物', '无真实柜号推送预装计划成功', '无号 push 成功后 PDA 查询 A0404', diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java index 327a8813e..fe3c39602 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java @@ -4,8 +4,10 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.LocationDtos; +import com.baiyue.warehouse.dto.LocationBlockDtos; import com.baiyue.warehouse.dto.PalletAllocationDtos; import com.baiyue.warehouse.service.PalletLocationAllocationService; +import com.baiyue.warehouse.service.LocationBlockService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -31,6 +33,8 @@ import java.util.List; public class InternalPalletLocationController { @Resource private PalletLocationAllocationService service; + @Resource + private LocationBlockService blockService; @Value("${baiyue.security.internal-service-token}") private String internalToken; private byte[] expectedToken; @@ -49,6 +53,39 @@ public class InternalPalletLocationController { return ApiResponse.success(service.reserve(request)); } + @PostMapping("/blocks/reservations") + @Operation(summary = "原子预占整票库位块", description = "优先同库区同前缀连续编号,普通位不足的槽位共享A0") + public ApiResponse reserveBlock( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody LocationBlockDtos.ReserveRequest request) { + verify(token); + return ApiResponse.success(blockService.reserve(request)); + } + + @PostMapping("/blocks/confirm") + public ApiResponse confirmBlock( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody LocationBlockDtos.ConfirmRequest request) { + verify(token); + return ApiResponse.success(blockService.confirm(request)); + } + + @PostMapping("/blocks/transfer") + public ApiResponse transferBlock( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody LocationBlockDtos.TransferRequest request) { + verify(token); + return ApiResponse.success(blockService.transfer(request)); + } + + @PostMapping("/blocks/release") + public ApiResponse releaseBlock( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody LocationBlockDtos.ReleaseRequest request) { + verify(token); + return ApiResponse.success(blockService.release(request)); + } + @PostMapping("/confirm") @Operation(summary = "确认库位占用", description = "托盘只保存主数据快照;本接口不推进头程入库单状态") public ApiResponse confirm(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationBlockDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationBlockDtos.java new file mode 100644 index 000000000..8ad264454 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationBlockDtos.java @@ -0,0 +1,70 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +/** Firstleg 可消费的整票连续库位块内部契约。 */ +public final class LocationBlockDtos { + private LocationBlockDtos() { + } + + public record ReserveRequest( + @Min(1) long warehouseId, + @NotBlank @Size(max = 40) String sourceService, + @Min(1) long businessId, + @NotBlank @Size(max = 40) String inboundCode, + @Min(1) @Max(200) int requiredSlotCount, + @NotBlank @Size(max = 128) String idempotencyKey, + @Min(1) long operatorId, + @NotNull LocalDateTime expiresAt + ) { + } + + public record ConfirmRequest( + @Min(1) long blockId, + @Min(1) long businessId, + @NotBlank @Size(max = 128) String idempotencyKey, + @Min(1) long operatorId + ) { + } + + public record TransferRequest( + @Min(1) long blockId, + @Min(1) long sourceBusinessId, + @Min(1) long targetBusinessId, + @NotBlank @Size(max = 40) String targetInboundCode, + @NotBlank @Size(max = 128) String idempotencyKey, + @Min(1) long operatorId + ) { + } + + public record ReleaseRequest( + @Min(1) long blockId, + @Min(1) long businessId, + @NotBlank @Size(max = 128) String idempotencyKey, + @Min(1) long operatorId, + @NotBlank @Pattern(regexp = "^(ALL_PACKAGES_PICKED|TICKET_VOID|MANUAL_ADJUSTMENT)$") String reason + ) { + } + + public record Resource( + long blockId, + long reservationId, + long locationId, + String locationCode, + int slotNo, + boolean sharedOverflow, + String resourceStatus + ) { + } + + public record BlockResult(long blockId, String blockStatus, List resources) { + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/LocationBlockEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/LocationBlockEntity.java new file mode 100644 index 000000000..e41f60280 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/LocationBlockEntity.java @@ -0,0 +1,31 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("wh_location_block") +public class LocationBlockEntity { + @TableId(type = IdType.INPUT) + private Long id; + private Long tenantId; + private Long warehouseId; + private String sourceService; + private Long businessId; + private String inboundCode; + private Integer slotCount; + private String idempotencyKey; + private String blockStatus; + private LocalDateTime expiresAt; + private String lastOperationKey; + private Long createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Integer version; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/LocationBlockSlotEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/LocationBlockSlotEntity.java new file mode 100644 index 000000000..5f3496634 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/LocationBlockSlotEntity.java @@ -0,0 +1,27 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("wh_location_block_slot") +public class LocationBlockSlotEntity { + @TableId(type = IdType.INPUT) + private Long id; + private Long tenantId; + private Long blockId; + private Integer slotNo; + private Long locationId; + private String locationCode; + private Boolean sharedOverflow; + private String resourceStatus; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Integer version; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationBlockMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationBlockMapper.java new file mode 100644 index 000000000..ab2dd940a --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationBlockMapper.java @@ -0,0 +1,34 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.LocationBlockEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface LocationBlockMapper extends BaseMapper { + @Select("SELECT * FROM wh_location_block WHERE id=#{id} AND tenant_id=0 FOR UPDATE") + LocationBlockEntity lockById(@Param("id") long id); + + @Update(""" + UPDATE wh_location_block SET block_status=#{targetStatus}, last_operation_key=#{operationKey}, + updated_at=CURRENT_TIMESTAMP(3), version=version+1 + WHERE id=#{id} AND tenant_id=0 AND business_id=#{businessId} + AND block_status=#{expectedStatus} AND version=#{version} + """) + int changeStatus(@Param("id") long id, @Param("businessId") long businessId, + @Param("expectedStatus") String expectedStatus, @Param("targetStatus") String targetStatus, + @Param("operationKey") String operationKey, @Param("version") int version); + + @Update(""" + UPDATE wh_location_block SET business_id=#{targetBusinessId}, inbound_code=#{targetInboundCode}, + last_operation_key=#{operationKey}, updated_at=CURRENT_TIMESTAMP(3), version=version+1 + WHERE id=#{id} AND tenant_id=0 AND business_id=#{sourceBusinessId} + AND block_status IN ('RESERVED','OCCUPIED') AND version=#{version} + """) + int transfer(@Param("id") long id, @Param("sourceBusinessId") long sourceBusinessId, + @Param("targetBusinessId") long targetBusinessId, @Param("targetInboundCode") String targetInboundCode, + @Param("operationKey") String operationKey, @Param("version") int version); +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationBlockSlotMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationBlockSlotMapper.java new file mode 100644 index 000000000..bb2ab7bd2 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationBlockSlotMapper.java @@ -0,0 +1,23 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.LocationBlockSlotEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface LocationBlockSlotMapper extends BaseMapper { + @Select("SELECT * FROM wh_location_block_slot WHERE tenant_id=0 AND block_id=#{blockId} ORDER BY slot_no FOR UPDATE") + List lockByBlockId(@Param("blockId") long blockId); + + @Update(""" + UPDATE wh_location_block_slot SET resource_status=#{targetStatus}, updated_at=CURRENT_TIMESTAMP(3), version=version+1 + WHERE tenant_id=0 AND block_id=#{blockId} AND resource_status=#{expectedStatus} + """) + int changeBlockStatus(@Param("blockId") long blockId, @Param("expectedStatus") String expectedStatus, + @Param("targetStatus") String targetStatus); +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationMapper.java index 2a1156f43..3a9c33d6f 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationMapper.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationMapper.java @@ -69,6 +69,11 @@ public interface LocationMapper extends BaseMapper { WHERE o.tenant_id = l.tenant_id AND o.location_id = l.id AND o.occupancy_status = 'OCCUPIED' ) + AND NOT EXISTS ( + SELECT 1 FROM wh_location_block_slot bs + WHERE bs.tenant_id = l.tenant_id AND bs.location_id = l.id + AND bs.resource_status IN ('RESERVED','OCCUPIED') AND bs.shared_overflow = 0 + ) ORDER BY l.zone_code, REGEXP_REPLACE(l.location_code, '[0-9]+$', ''), CAST(COALESCE(REGEXP_SUBSTR(l.location_code, '[0-9]+$'), '0') AS UNSIGNED), @@ -98,6 +103,11 @@ public interface LocationMapper extends BaseMapper { WHERE o.tenant_id = l.tenant_id AND o.location_id = l.id AND o.occupancy_status = 'OCCUPIED' ) + AND NOT EXISTS ( + SELECT 1 FROM wh_location_block_slot bs + WHERE bs.tenant_id = l.tenant_id AND bs.location_id = l.id + AND bs.resource_status IN ('RESERVED','OCCUPIED') AND bs.shared_overflow = 0 + ) ORDER BY l.zone_code, REGEXP_REPLACE(l.location_code, '[0-9]+$', ''), CAST(COALESCE(REGEXP_SUBSTR(l.location_code, '[0-9]+$'), '0') AS UNSIGNED), diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java new file mode 100644 index 000000000..e6d9a587f --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java @@ -0,0 +1,268 @@ +package com.baiyue.warehouse.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.LocationBlockDtos; +import com.baiyue.warehouse.entity.LocationBlockEntity; +import com.baiyue.warehouse.entity.LocationBlockSlotEntity; +import com.baiyue.warehouse.entity.LocationEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.mapper.LocationBlockMapper; +import com.baiyue.warehouse.mapper.LocationBlockSlotMapper; +import com.baiyue.warehouse.repository.LocationRepository; +import jakarta.annotation.Resource; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** 整票库位块的唯一写入边界;块内槽位不能单独释放或转移。 */ +@Service +public class LocationBlockService { + private static final long RESOURCE_TENANT_ID = 0L; + private static final int CANDIDATE_LIMIT = 1000; + private static final Pattern NUMBERED_CODE = Pattern.compile("^(.*?)(\\d+)$"); + + @Resource + private LocationRepository locations; + @Resource + private LocationBlockMapper blocks; + @Resource + private LocationBlockSlotMapper slots; + + @Transactional + public LocationBlockDtos.BlockResult reserve(LocationBlockDtos.ReserveRequest request) { + requireActiveWarehouse(request.warehouseId()); + if (!request.expiresAt().isAfter(LocalDateTime.now())) { + throw new BizException(ResultCode.BAD_REQUEST, "整票库位块过期时间必须晚于当前时间"); + } + LocationBlockEntity existing = blocks.selectOne(new LambdaQueryWrapper() + .eq(LocationBlockEntity::getTenantId, RESOURCE_TENANT_ID) + .eq(LocationBlockEntity::getSourceService, request.sourceService()) + .eq(LocationBlockEntity::getIdempotencyKey, request.idempotencyKey())); + if (existing != null) { + requireSameReservation(existing, request); + return result(existing, slots.selectList(new LambdaQueryWrapper() + .eq(LocationBlockSlotEntity::getTenantId, RESOURCE_TENANT_ID) + .eq(LocationBlockSlotEntity::getBlockId, existing.getId()) + .orderByAsc(LocationBlockSlotEntity::getSlotNo))); + } + + List available = locations.lockAvailable(RESOURCE_TENANT_ID, request.warehouseId(), CANDIDATE_LIMIT); + List selected = selectLocations(available, request.requiredSlotCount()); + LocationEntity overflow = selected.size() < request.requiredSlotCount() + ? locations.lockOrCreateSharedOverflow(RESOURCE_TENANT_ID, request.warehouseId(), request.operatorId()) : null; + if (selected.size() < request.requiredSlotCount() && overflow == null) { + throw new BizException(ResultCode.CONFLICT, "系统共享溢出位 A0 创建或读取失败"); + } + + long blockId = IdWorker.getId(); + LocationBlockEntity block = block(blockId, request); + try { + blocks.insert(block); + List allocated = new ArrayList<>(request.requiredSlotCount()); + for (int index = 0; index < request.requiredSlotCount(); index++) { + LocationEntity location = index < selected.size() ? selected.get(index) : overflow; + if (!Boolean.TRUE.equals(location.getSharedOverflow()) + && locations.changeStatus(RESOURCE_TENANT_ID, request.warehouseId(), location.getId(), + "AVAILABLE", "RESERVED", location.getVersion(), request.operatorId()) != 1) { + throw new BizException(ResultCode.CONFLICT, "整票库位块候选被并发占用"); + } + LocationBlockSlotEntity slot = slot(blockId, index + 1, location); + slots.insert(slot); + allocated.add(slot); + } + return result(block, allocated); + } catch (DataIntegrityViolationException exception) { + throw new BizException(ResultCode.CONFLICT, "整票库位块幂等键重复或普通库位已被占用"); + } + } + + @Transactional + public LocationBlockDtos.BlockResult confirm(LocationBlockDtos.ConfirmRequest request) { + LocationBlockEntity block = lockOwned(request.blockId(), request.businessId()); + List allocated = slots.lockByBlockId(block.getId()); + if ("OCCUPIED".equals(block.getBlockStatus()) && request.idempotencyKey().equals(block.getLastOperationKey())) { + return result(block, allocated); + } + if (!"RESERVED".equals(block.getBlockStatus()) || allocated.size() != block.getSlotCount()) { + throw new BizException(ResultCode.CONFLICT, "整票库位块不是可确认的完整预占状态"); + } + for (LocationBlockSlotEntity slot : allocated) { + if (!Boolean.TRUE.equals(slot.getSharedOverflow())) { + LocationEntity location = locations.lockOwned(RESOURCE_TENANT_ID, block.getWarehouseId(), slot.getLocationId()); + if (location == null || locations.changeStatus(RESOURCE_TENANT_ID, block.getWarehouseId(), location.getId(), + "RESERVED", "OCCUPIED", location.getVersion(), request.operatorId()) != 1) { + throw new BizException(ResultCode.CONFLICT, "整票库位块确认时库位状态发生并发变更"); + } + } + } + if (slots.changeBlockStatus(block.getId(), "RESERVED", "OCCUPIED") != allocated.size() + || blocks.changeStatus(block.getId(), request.businessId(), "RESERVED", "OCCUPIED", + request.idempotencyKey(), block.getVersion()) != 1) { + throw new BizException(ResultCode.CONFLICT, "整票库位块确认时状态发生并发变更"); + } + block.setBlockStatus("OCCUPIED"); + block.setLastOperationKey(request.idempotencyKey()); + allocated.forEach(item -> item.setResourceStatus("OCCUPIED")); + return result(block, allocated); + } + + @Transactional + public LocationBlockDtos.BlockResult transfer(LocationBlockDtos.TransferRequest request) { + if (request.sourceBusinessId() == request.targetBusinessId()) { + throw new BizException(ResultCode.BAD_REQUEST, "来源与余量子入库单不能相同"); + } + LocationBlockEntity block = blocks.lockById(request.blockId()); + if (block != null && request.idempotencyKey().equals(block.getLastOperationKey()) + && block.getBusinessId() == request.targetBusinessId() + && request.targetInboundCode().equals(block.getInboundCode())) { + return result(block, slots.lockByBlockId(block.getId())); + } + if (block == null || block.getBusinessId() != request.sourceBusinessId() + || "RELEASED".equals(block.getBlockStatus())) { + throw new BizException(ResultCode.FORBIDDEN, "整票库位块不属于来源入库单或已释放"); + } + if (blocks.transfer(block.getId(), request.sourceBusinessId(), request.targetBusinessId(), + request.targetInboundCode(), request.idempotencyKey(), block.getVersion()) != 1) { + throw new BizException(ResultCode.CONFLICT, "整票库位块归属发生并发变更"); + } + block.setBusinessId(request.targetBusinessId()); + block.setInboundCode(request.targetInboundCode()); + block.setLastOperationKey(request.idempotencyKey()); + return result(block, slots.lockByBlockId(block.getId())); + } + + @Transactional + public LocationBlockDtos.BlockResult release(LocationBlockDtos.ReleaseRequest request) { + LocationBlockEntity block = lockOwned(request.blockId(), request.businessId()); + List allocated = slots.lockByBlockId(block.getId()); + if ("RELEASED".equals(block.getBlockStatus()) && request.idempotencyKey().equals(block.getLastOperationKey())) { + return result(block, allocated); + } + if (!("RESERVED".equals(block.getBlockStatus()) || "OCCUPIED".equals(block.getBlockStatus()))) { + throw new BizException(ResultCode.CONFLICT, "整票库位块已释放或状态非法"); + } + String previous = block.getBlockStatus(); + for (LocationBlockSlotEntity slot : allocated) { + if (!Boolean.TRUE.equals(slot.getSharedOverflow())) { + LocationEntity location = locations.lockOwned(RESOURCE_TENANT_ID, block.getWarehouseId(), slot.getLocationId()); + if (location == null || locations.changeStatus(RESOURCE_TENANT_ID, block.getWarehouseId(), location.getId(), + previous, "AVAILABLE", location.getVersion(), request.operatorId()) != 1) { + throw new BizException(ResultCode.CONFLICT, "整票库位块释放时库位状态发生并发变更"); + } + } + } + if (slots.changeBlockStatus(block.getId(), previous, "RELEASED") != allocated.size() + || blocks.changeStatus(block.getId(), request.businessId(), previous, "RELEASED", + request.idempotencyKey(), block.getVersion()) != 1) { + throw new BizException(ResultCode.CONFLICT, "整票库位块释放时状态发生并发变更"); + } + block.setBlockStatus("RELEASED"); + block.setLastOperationKey(request.idempotencyKey()); + allocated.forEach(item -> item.setResourceStatus("RELEASED")); + return result(block, allocated); + } + + private void requireActiveWarehouse(long warehouseId) { + WarehouseEntity warehouse = locations.findWarehouse(RESOURCE_TENANT_ID, warehouseId); + if (warehouse == null || !"ACTIVE".equals(warehouse.getStatus())) { + throw new BizException(ResultCode.NOT_FOUND, "可用平台公共仓库不存在"); + } + } + + private LocationBlockEntity lockOwned(long blockId, long businessId) { + LocationBlockEntity block = blocks.lockById(blockId); + if (block == null || block.getBusinessId() != businessId) { + throw new BizException(ResultCode.FORBIDDEN, "整票库位块不属于指定入库单"); + } + return block; + } + + private static List selectLocations(List available, int required) { + if (available.size() < required) { + return List.copyOf(available); + } + for (int start = 0; start <= available.size() - required; start++) { + List run = available.subList(start, start + required); + if (isContinuous(run)) { + return List.copyOf(run); + } + } + return List.copyOf(available.subList(0, required)); + } + + private static boolean isContinuous(List run) { + Matcher first = NUMBERED_CODE.matcher(run.get(0).getLocationCode()); + if (!first.matches()) { + return false; + } + String zone = run.get(0).getZoneCode(); + String prefix = first.group(1); + int previous = Integer.parseInt(first.group(2)); + for (int index = 1; index < run.size(); index++) { + LocationEntity item = run.get(index); + Matcher current = NUMBERED_CODE.matcher(item.getLocationCode()); + if (!current.matches() || !Objects.equals(zone, item.getZoneCode()) || !prefix.equals(current.group(1)) + || Integer.parseInt(current.group(2)) != previous + 1) { + return false; + } + previous++; + } + return true; + } + + private static LocationBlockEntity block(long id, LocationBlockDtos.ReserveRequest request) { + LocationBlockEntity entity = new LocationBlockEntity(); + entity.setId(id); + entity.setTenantId(RESOURCE_TENANT_ID); + entity.setWarehouseId(request.warehouseId()); + entity.setSourceService(request.sourceService()); + entity.setBusinessId(request.businessId()); + entity.setInboundCode(request.inboundCode()); + entity.setSlotCount(request.requiredSlotCount()); + entity.setIdempotencyKey(request.idempotencyKey()); + entity.setBlockStatus("RESERVED"); + entity.setExpiresAt(request.expiresAt()); + entity.setCreatedBy(request.operatorId()); + entity.setVersion(0); + return entity; + } + + private static LocationBlockSlotEntity slot(long blockId, int slotNo, LocationEntity location) { + LocationBlockSlotEntity entity = new LocationBlockSlotEntity(); + entity.setId(IdWorker.getId()); + entity.setTenantId(RESOURCE_TENANT_ID); + entity.setBlockId(blockId); + entity.setSlotNo(slotNo); + entity.setLocationId(location.getId()); + entity.setLocationCode(location.getLocationCode()); + entity.setSharedOverflow(Boolean.TRUE.equals(location.getSharedOverflow())); + entity.setResourceStatus("RESERVED"); + entity.setVersion(0); + return entity; + } + + private static void requireSameReservation(LocationBlockEntity block, LocationBlockDtos.ReserveRequest request) { + if (block.getWarehouseId() != request.warehouseId() || block.getBusinessId() != request.businessId() + || block.getSlotCount() != request.requiredSlotCount() || !block.getInboundCode().equals(request.inboundCode())) { + throw new BizException(ResultCode.CONFLICT, "整票库位块幂等键已用于不同请求"); + } + } + + private static LocationBlockDtos.BlockResult result(LocationBlockEntity block, List slots) { + return new LocationBlockDtos.BlockResult(block.getId(), block.getBlockStatus(), slots.stream() + .map(item -> new LocationBlockDtos.Resource(block.getId(), item.getId(), item.getLocationId(), + item.getLocationCode(), item.getSlotNo(), Boolean.TRUE.equals(item.getSharedOverflow()), + item.getResourceStatus())) + .toList()); + } +} diff --git a/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql b/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql index 60eb7796c..ed080a11e 100644 --- a/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql +++ b/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql @@ -133,4 +133,54 @@ CREATE TABLE IF NOT EXISTS wh_location_occupancy ( CONSTRAINT ck_occupancy_volume CHECK (allocated_volume_cbm > 0) ) ENGINE=InnoDB COMMENT='已确认的库位占用事实记录,托盘字段仅为主数据快照'; +CREATE TABLE IF NOT EXISTS wh_location_block ( + 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 + 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), + KEY idx_location_block_business (tenant_id, source_service, business_id, block_status), + CONSTRAINT fk_location_block_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), + CONSTRAINT ck_location_block_slot_count CHECK (slot_count BETWEEN 1 AND 200) +) 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 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 + 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), + CONSTRAINT fk_location_block_slot_block FOREIGN KEY (block_id) REFERENCES wh_location_block (id), + CONSTRAINT fk_location_block_slot_location FOREIGN KEY (location_id) REFERENCES wh_location (id) +) ENGINE=InnoDB COMMENT='整票库位块槽位;空槽也由当前入仓码独占'; + -- 头程入库单属于其他数据库,不建立跨库外键。 diff --git a/warehouse/src/main/resources/openapi/location-block-v1.yaml b/warehouse/src/main/resources/openapi/location-block-v1.yaml new file mode 100644 index 000000000..0f307d5d3 --- /dev/null +++ b/warehouse/src/main/resources/openapi/location-block-v1.yaml @@ -0,0 +1,122 @@ +openapi: 3.0.3 +info: + title: Warehouse Internal Location Block API + version: 1.0.0 +paths: + /internal/warehouse/v1/pallet-locations/blocks/reservations: + post: + summary: 原子预占整票库位块 + security: [{ internalServiceToken: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ReserveRequest' } + responses: + '200': { $ref: '#/components/responses/BlockResponse' } + /internal/warehouse/v1/pallet-locations/blocks/confirm: + post: + summary: 整段确认占用 + security: [{ internalServiceToken: [] }] + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/ConfirmRequest' } } } + responses: + '200': { $ref: '#/components/responses/BlockResponse' } + /internal/warehouse/v1/pallet-locations/blocks/transfer: + post: + summary: 将整段资源归属转移给余量子入库单 + security: [{ internalServiceToken: [] }] + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/TransferRequest' } } } + responses: + '200': { $ref: '#/components/responses/BlockResponse' } + /internal/warehouse/v1/pallet-locations/blocks/release: + post: + summary: 整段释放 + description: 仅整票剩余箱数为0、整票作废或显式人工调整可调用;不提供单槽释放接口。 + security: [{ internalServiceToken: [] }] + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/ReleaseRequest' } } } + responses: + '200': { $ref: '#/components/responses/BlockResponse' } +components: + securitySchemes: + internalServiceToken: + type: apiKey + in: header + name: X-Internal-Service-Token + responses: + BlockResponse: + description: 统一 ApiResponse,data 为整票块快照 + content: + application/json: + schema: + type: object + required: [code, message, data] + properties: + code: { type: string } + message: { type: string } + data: { $ref: '#/components/schemas/BlockResult' } + schemas: + ReserveRequest: + type: object + required: [warehouseId, sourceService, businessId, inboundCode, requiredSlotCount, idempotencyKey, operatorId, expiresAt] + properties: + warehouseId: { type: integer, format: int64, minimum: 1 } + sourceService: { type: string, maxLength: 40 } + businessId: { type: integer, format: int64, minimum: 1 } + inboundCode: { type: string, maxLength: 40 } + requiredSlotCount: { type: integer, minimum: 1, maximum: 200 } + idempotencyKey: { type: string, maxLength: 128 } + operatorId: { type: integer, format: int64, minimum: 1 } + expiresAt: { type: string, format: date-time } + ConfirmRequest: + type: object + required: [blockId, businessId, idempotencyKey, operatorId] + properties: + blockId: { type: integer, format: int64, minimum: 1 } + businessId: { type: integer, format: int64, minimum: 1 } + idempotencyKey: { type: string, maxLength: 128 } + operatorId: { type: integer, format: int64, minimum: 1 } + TransferRequest: + type: object + required: [blockId, sourceBusinessId, targetBusinessId, targetInboundCode, idempotencyKey, operatorId] + properties: + blockId: { type: integer, format: int64, minimum: 1 } + sourceBusinessId: { type: integer, format: int64, minimum: 1 } + targetBusinessId: { type: integer, format: int64, minimum: 1 } + targetInboundCode: { type: string, maxLength: 40 } + idempotencyKey: { type: string, maxLength: 128 } + operatorId: { type: integer, format: int64, minimum: 1 } + ReleaseRequest: + type: object + required: [blockId, businessId, idempotencyKey, operatorId, reason] + properties: + blockId: { type: integer, format: int64, minimum: 1 } + businessId: { type: integer, format: int64, minimum: 1 } + idempotencyKey: { type: string, maxLength: 128 } + operatorId: { type: integer, format: int64, minimum: 1 } + reason: { type: string, enum: [ALL_PACKAGES_PICKED, TICKET_VOID, MANUAL_ADJUSTMENT] } + BlockResult: + type: object + required: [blockId, blockStatus, resources] + properties: + blockId: { type: integer, format: int64 } + blockStatus: { type: string, enum: [RESERVED, OCCUPIED, RELEASED] } + resources: + type: array + items: { $ref: '#/components/schemas/Resource' } + Resource: + type: object + required: [blockId, reservationId, locationId, locationCode, slotNo, sharedOverflow, resourceStatus] + properties: + blockId: { type: integer, format: int64 } + reservationId: { type: integer, format: int64 } + locationId: { type: integer, format: int64 } + locationCode: { type: string } + slotNo: { type: integer, minimum: 1 } + sharedOverflow: { type: boolean } + resourceStatus: { type: string, enum: [RESERVED, OCCUPIED, RELEASED] } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletLocationControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletLocationControllerTest.java index a3a2be72f..d643bbb0a 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletLocationControllerTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletLocationControllerTest.java @@ -4,7 +4,9 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.PalletAllocationDtos; import com.baiyue.warehouse.dto.LocationDtos; +import com.baiyue.warehouse.dto.LocationBlockDtos; import com.baiyue.warehouse.service.PalletLocationAllocationService; +import com.baiyue.warehouse.service.LocationBlockService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -22,11 +24,13 @@ class InternalPalletLocationControllerTest { private static final String INTERNAL_TOKEN = "test-internal-service-token"; private final PalletLocationAllocationService service = mock(PalletLocationAllocationService.class); + private final LocationBlockService blockService = mock(LocationBlockService.class); private final InternalPalletLocationController controller = new InternalPalletLocationController(); @BeforeEach void setUp() { ReflectionTestUtils.setField(controller, "service", service); + ReflectionTestUtils.setField(controller, "blockService", blockService); ReflectionTestUtils.setField(controller, "internalToken", INTERNAL_TOKEN); controller.initializeToken(); } @@ -78,6 +82,22 @@ class InternalPalletLocationControllerTest { verify(service).occupiedResources(0L, "firstleg-service", 88L); } + @Test + void reservesWholeBlockOnlyForInternalCaller() { + LocationBlockDtos.ReserveRequest request = new LocationBlockDtos.ReserveRequest(20L, "firstleg-service", + 88L, "RK-001", 3, "reserve-block-1", 9001L, java.time.LocalDateTime.now().plusHours(1)); + LocationBlockDtos.BlockResult expected = new LocationBlockDtos.BlockResult(700L, "RESERVED", List.of( + new LocationBlockDtos.Resource(700L, 701L, 40L, "A01", 1, false, "RESERVED"))); + when(blockService.reserve(request)).thenReturn(expected); + + assertThat(controller.reserveBlock(INTERNAL_TOKEN, request).data()).isEqualTo(expected); + verify(blockService).reserve(request); + assertThatThrownBy(() -> controller.reserveBlock("invalid", request)) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.FORBIDDEN); + } + private static PalletAllocationDtos.TransferRequest request() { return new PalletAllocationDtos.TransferRequest(10001L, "FIRSTLEG", 88L, 89L, "RK-001-S1", "transfer-88-89", 9001L, List.of(new PalletAllocationDtos.TransferItem(40L, 5))); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java index 6c7c213f2..340ed1ccf 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java @@ -47,6 +47,13 @@ class WarehouseSchemaContractTest { @Test void shouldHandleCase04() throws IOException { String sql = read("phase2-pallet-location-schema.sql"); + assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_location_block (") + .contains("CREATE TABLE IF NOT EXISTS wh_location_block_slot (") + .contains("UNIQUE KEY uk_location_block_active_normal") + .contains("UNIQUE KEY uk_location_block_active_business") + .contains("block_status IN ('RESERVED','OCCUPIED')") + .contains("resource_status IN ('RESERVED','OCCUPIED')") + .contains("COMMENT='整票库位块槽位;空槽也由当前入仓码独占'"); assertColumns(sql, "wh_pallet", "tenant_id", "warehouse_id", "pallet_code", "pallet_type", "capacity_cbm", "weight_kg", "color", "remark", "deleted", "version"); assertThat(tableDefinition(sql, "wh_pallet")) diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationBlockServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationBlockServiceTest.java new file mode 100644 index 000000000..f7986e7c9 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationBlockServiceTest.java @@ -0,0 +1,176 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.warehouse.dto.LocationBlockDtos; +import com.baiyue.warehouse.entity.LocationBlockEntity; +import com.baiyue.warehouse.entity.LocationBlockSlotEntity; +import com.baiyue.warehouse.entity.LocationEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.mapper.LocationBlockMapper; +import com.baiyue.warehouse.mapper.LocationBlockSlotMapper; +import com.baiyue.warehouse.repository.LocationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LocationBlockServiceTest { + private final LocationRepository locations = mock(LocationRepository.class); + private final LocationBlockMapper blocks = mock(LocationBlockMapper.class); + private final LocationBlockSlotMapper slots = mock(LocationBlockSlotMapper.class); + private final LocationBlockService service = new LocationBlockService(); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "locations", locations); + ReflectionTestUtils.setField(service, "blocks", blocks); + ReflectionTestUtils.setField(service, "slots", slots); + WarehouseEntity warehouse = new WarehouseEntity(); + warehouse.setId(20L); + warehouse.setTenantId(0L); + warehouse.setStatus("ACTIVE"); + when(locations.findWarehouse(0L, 20L)).thenReturn(warehouse); + when(blocks.selectOne(any())).thenReturn(null); + when(blocks.insert(any(LocationBlockEntity.class))).thenReturn(1); + when(slots.insert(any(LocationBlockSlotEntity.class))).thenReturn(1); + when(locations.changeStatus(anyLong(), anyLong(), anyLong(), any(), any(), anyInt(), anyLong())).thenReturn(1); + } + + @Test + void reservesSameZoneAndPrefixContinuousRunAheadOfEarlierGap() { + when(locations.lockAvailable(0L, 20L, 1000)).thenReturn(List.of( + location(1L, "A", "A01"), location(2L, "A", "A03"), + location(3L, "B", "B01"), location(4L, "B", "B02"), location(5L, "B", "B03"))); + + LocationBlockDtos.BlockResult result = service.reserve(request(3)); + + assertThat(result.blockStatus()).isEqualTo("RESERVED"); + assertThat(result.resources()).extracting(LocationBlockDtos.Resource::locationCode) + .containsExactly("B01", "B02", "B03"); + assertThat(result.resources()).extracting(LocationBlockDtos.Resource::slotNo).containsExactly(1, 2, 3); + assertThat(result.resources()).allSatisfy(item -> { + assertThat(item.blockId()).isEqualTo(result.blockId()); + assertThat(item.sharedOverflow()).isFalse(); + assertThat(item.resourceStatus()).isEqualTo("RESERVED"); + }); + verify(locations, never()).lockOrCreateSharedOverflow(anyLong(), anyLong(), anyLong()); + } + + @Test + void keepsAllOrdinaryLocationsAndUsesSharedA0ForOnlyTheMissingSlots() { + LocationEntity ordinary = location(1L, "A", "A01"); + LocationEntity overflow = location(99L, "OVERFLOW", "A0"); + overflow.setSharedOverflow(true); + when(locations.lockAvailable(0L, 20L, 1000)).thenReturn(List.of(ordinary)); + when(locations.lockOrCreateSharedOverflow(0L, 20L, 9001L)).thenReturn(overflow); + + LocationBlockDtos.BlockResult result = service.reserve(request(3)); + + assertThat(result.resources()).extracting(LocationBlockDtos.Resource::locationCode) + .containsExactly("A01", "A0", "A0"); + assertThat(result.resources()).extracting(LocationBlockDtos.Resource::sharedOverflow) + .containsExactly(false, true, true); + verify(locations).changeStatus(0L, 20L, 1L, "AVAILABLE", "RESERVED", 0, 9001L); + verify(locations, never()).changeStatus(eq(0L), eq(20L), eq(99L), any(), any(), anyInt(), anyLong()); + } + + @Test + void transfersOwnershipWithoutReleasingAnySlot() { + LocationBlockEntity block = block("OCCUPIED", 88L, null); + when(blocks.lockById(700L)).thenReturn(block); + when(blocks.transfer(700L, 88L, 89L, "RK-S1", "transfer-1", 0)).thenReturn(1); + when(slots.lockByBlockId(700L)).thenReturn(List.of(slot(701L, 1, 1L, "A01", false, "OCCUPIED"))); + + LocationBlockDtos.BlockResult result = service.transfer( + new LocationBlockDtos.TransferRequest(700L, 88L, 89L, "RK-S1", "transfer-1", 9001L)); + + assertThat(result.blockStatus()).isEqualTo("OCCUPIED"); + verify(blocks).transfer(700L, 88L, 89L, "RK-S1", "transfer-1", 0); + verify(slots, never()).changeBlockStatus(anyLong(), any(), any()); + verify(locations, never()).changeStatus(anyLong(), anyLong(), anyLong(), any(), any(), anyInt(), anyLong()); + } + + @Test + void releasesEveryNormalSlotTogetherAndLeavesSharedA0Available() { + LocationBlockEntity block = block("OCCUPIED", 88L, null); + LocationBlockSlotEntity first = slot(701L, 1, 1L, "A01", false, "OCCUPIED"); + LocationBlockSlotEntity overflow = slot(702L, 2, 99L, "A0", true, "OCCUPIED"); + when(blocks.lockById(700L)).thenReturn(block); + when(slots.lockByBlockId(700L)).thenReturn(List.of(first, overflow)); + when(locations.lockOwned(0L, 20L, 1L)).thenReturn(location(1L, "A", "A01", "OCCUPIED")); + when(slots.changeBlockStatus(700L, "OCCUPIED", "RELEASED")).thenReturn(2); + when(blocks.changeStatus(700L, 88L, "OCCUPIED", "RELEASED", "release-1", 0)).thenReturn(1); + + LocationBlockDtos.BlockResult result = service.release(new LocationBlockDtos.ReleaseRequest( + 700L, 88L, "release-1", 9001L, "ALL_PACKAGES_PICKED")); + + assertThat(result.resources()).extracting(LocationBlockDtos.Resource::resourceStatus) + .containsOnly("RELEASED"); + verify(locations).changeStatus(0L, 20L, 1L, "OCCUPIED", "AVAILABLE", 0, 9001L); + verify(locations, never()).lockOwned(0L, 20L, 99L); + } + + private static LocationBlockDtos.ReserveRequest request(int slots) { + return new LocationBlockDtos.ReserveRequest(20L, "firstleg-service", 88L, "RK-001", slots, + "reserve-1", 9001L, LocalDateTime.now().plusHours(1)); + } + + private static LocationEntity location(long id, String zone, String code) { + return location(id, zone, code, "AVAILABLE"); + } + + private static LocationEntity location(long id, String zone, String code, String status) { + LocationEntity entity = new LocationEntity(); + entity.setId(id); + entity.setTenantId(0L); + entity.setWarehouseId(20L); + entity.setZoneCode(zone); + entity.setLocationCode(code); + entity.setSharedOverflow(false); + entity.setStatus(status); + entity.setVersion(0); + return entity; + } + + private static LocationBlockEntity block(String status, long businessId, String operationKey) { + LocationBlockEntity entity = new LocationBlockEntity(); + entity.setId(700L); + entity.setTenantId(0L); + entity.setWarehouseId(20L); + entity.setSourceService("firstleg-service"); + entity.setBusinessId(businessId); + entity.setInboundCode("RK-001"); + entity.setSlotCount(2); + entity.setBlockStatus(status); + entity.setLastOperationKey(operationKey); + entity.setVersion(0); + return entity; + } + + private static LocationBlockSlotEntity slot(long id, int slotNo, long locationId, String code, + boolean overflow, String status) { + LocationBlockSlotEntity entity = new LocationBlockSlotEntity(); + entity.setId(id); + entity.setTenantId(0L); + entity.setBlockId(700L); + entity.setSlotNo(slotNo); + entity.setLocationId(locationId); + entity.setLocationCode(code); + entity.setSharedOverflow(overflow); + entity.setResourceStatus(status); + entity.setVersion(0); + return entity; + } +}