优化
This commit is contained in:
+132
-10
@@ -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
|
||||
|
||||
+4
-3
@@ -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` 只作为当前结构来源,不为历史开发数据再新增兼容迁移。
|
||||
|
||||
## 全量验收导航
|
||||
|
||||
+24
-64
@@ -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<BatchResult> reserve(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody ReserveRequest request);
|
||||
@PostMapping("/pallet-locations/blocks/reservations")
|
||||
ApiResponse<BlockResult> reserveBlock(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody BlockReserveRequest request);
|
||||
|
||||
@PostMapping("/pallet-locations/confirm")
|
||||
ApiResponse<BatchResult> confirm(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody ConfirmRequest request);
|
||||
@PostMapping("/pallet-locations/blocks/confirm")
|
||||
ApiResponse<BlockResult> confirmBlock(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody BlockConfirmRequest request);
|
||||
|
||||
@PostMapping("/pallet-locations/release")
|
||||
ApiResponse<List<ActiveResource>> release(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody ReleaseRequest request);
|
||||
@PostMapping("/pallet-locations/blocks/transfer")
|
||||
ApiResponse<BlockResult> transferBlock(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody BlockTransferRequest request);
|
||||
|
||||
@PostMapping("/pallet-locations/release-selected")
|
||||
ApiResponse<List<SelectiveReleaseResult>> releaseSelected(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody SelectiveReleaseRequest request);
|
||||
|
||||
@PostMapping("/pallet-locations/transfer")
|
||||
ApiResponse<List<TransferResult>> transfer(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody TransferRequest request);
|
||||
|
||||
@PostMapping("/pallet-locations/settlements")
|
||||
ApiResponse<SettlementResult> settle(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody SettlementRequest request);
|
||||
|
||||
@GetMapping("/pallet-locations/settlements/target")
|
||||
ApiResponse<Long> settlementTarget(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestParam("sourceBusinessId") long sourceBusinessId,
|
||||
@RequestParam("idempotencyKey") String idempotencyKey);
|
||||
@PostMapping("/pallet-locations/blocks/release")
|
||||
ApiResponse<BlockResult> releaseBlock(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody BlockReleaseRequest request);
|
||||
|
||||
@GetMapping("/pallet-locations/occupied-resources")
|
||||
ApiResponse<List<OccupiedResource>> 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<PalletReference> 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<Long> 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<AllocationResult> 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<TransferItem> 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<Long> releasedReservationIds,
|
||||
List<SettlementTransferItem> remainingItems) {}
|
||||
record SettlementResult(List<SelectiveReleaseResult> released, List<TransferResult> 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<BlockResource> resources) {}
|
||||
record OccupiedResource(long reservationId, long occupancyId, int allocationNo, long warehouseId,
|
||||
long palletId, long locationId, String locationCode, boolean sharedOverflow,
|
||||
int allocatedPackageCount) {}
|
||||
|
||||
+2
-1
@@ -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<Void> deleteWeightResult(
|
||||
|
||||
@@ -47,7 +47,8 @@ public final class InboundMeasurementResponses {
|
||||
@Schema(description = "Order 服务中的最新客户申报,只用于和既有实测事实对比;不得回写测量、箱码或审核结果")
|
||||
DeclarationSnapshot latestDeclaration,
|
||||
List<Item> items,
|
||||
List<WeightResult> weightResults
|
||||
List<WeightResult> 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<PalletLocationSlot> 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,
|
||||
|
||||
@@ -14,9 +14,11 @@ public record InboundOrderDetail(
|
||||
List<StatusHistorySummary> 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(
|
||||
|
||||
@@ -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<Long> 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<Long> 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
|
||||
) {
|
||||
|
||||
+12
-1
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+55
-12
@@ -9,6 +9,41 @@ import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface InboundPalletAllocationMapper extends BaseMapper<InboundPalletAllocationEntity> {
|
||||
@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<InboundPalletA
|
||||
int consume(@Param("id") long id, @Param("tenantId") long tenantId,
|
||||
@Param("inboundId") long inboundId, @Param("count") int count);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_inbound_pallet_allocation
|
||||
SET allocation_status = 'TRANSFERRED', updated_at = CURRENT_TIMESTAMP(3)
|
||||
WHERE id = #{id} AND tenant_id = #{tenantId} AND inbound_id = #{inboundId}
|
||||
AND allocation_status = 'CONFIRMED' AND picked_package_count < allocated_package_count
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM fl_inbound_pallet_allocation
|
||||
WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND block_id=#{blockId}
|
||||
AND allocation_status='CONFIRMED'
|
||||
""")
|
||||
int markTransferred(@Param("id") long id, @Param("tenantId") long tenantId,
|
||||
@Param("inboundId") long inboundId);
|
||||
int countBlock(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("blockId") long blockId);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_inbound_pallet_allocation
|
||||
SET allocation_status = 'RELEASED', updated_at = CURRENT_TIMESTAMP(3)
|
||||
WHERE tenant_id = #{tenantId} AND inbound_id = #{inboundId}
|
||||
AND reservation_id = #{reservationId} AND allocation_status = 'CONFIRMED'
|
||||
SET allocation_status='TRANSFERRED', slot_status='TRANSFERRED', updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND block_id=#{blockId}
|
||||
AND allocation_status='CONFIRMED'
|
||||
""")
|
||||
int markReleased(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("reservationId") long reservationId);
|
||||
int markBlockTransferred(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("blockId") long blockId);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_inbound_pallet_allocation
|
||||
SET allocation_status='RELEASED', slot_status='RELEASED', updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND block_id=#{blockId}
|
||||
AND allocation_status='CONFIRMED'
|
||||
""")
|
||||
int markBlockReleased(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("blockId") long blockId);
|
||||
}
|
||||
|
||||
@@ -171,7 +171,6 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
|
||||
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<LoadingPickItemEntity>
|
||||
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<LoadingPickItemEntity>
|
||||
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);
|
||||
|
||||
|
||||
@@ -212,9 +212,11 @@ public class InboundOrderRepository {
|
||||
.orderByAsc(InboundPalletAllocationEntity::getAllocationNo));
|
||||
List<InboundOrderDetail.PalletLocationSummary> 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<InboundOrderDetail.FileSummary> fileSummaries = files.selectList(
|
||||
owned(InboundFileEntity::getTenantId, InboundFileEntity::getInboundId, tenantId, inboundId)
|
||||
|
||||
+88
-75
@@ -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<InboundPalletAllocationEntity> list(long tenantId, long inboundId) {
|
||||
return mapper.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
|
||||
.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<InboundPalletAllocationEntity>()
|
||||
.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<Long, String> reservationNos = mapper.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
|
||||
.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<InboundPalletAllocationEntity>()
|
||||
.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<InboundWeightResultEntity> 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<InboundPalletAllocationEntity> 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<InboundPalletAllocationEntity> allocations,
|
||||
List<Long> 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<Long> 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<InboundPalletAllocationEntity>()
|
||||
.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<InboundPalletAllocationEntity>()
|
||||
.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<InboundPalletAllocationEntity> parent) {
|
||||
public void copyBlockToChild(long tenantId, long inboundId, List<InboundPalletAllocationEntity> parent) {
|
||||
mapper.delete(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
|
||||
.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);
|
||||
|
||||
@@ -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<LoadingPickHistoryEntity>()
|
||||
.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<LoadingPickOperationEntity>()
|
||||
.eq(LoadingPickOperationEntity::getOperationId, operationId)
|
||||
.last("FOR UPDATE"));
|
||||
}
|
||||
|
||||
public void insert(LoadingPickOperationEntity operation) {
|
||||
operations.insert(operation);
|
||||
}
|
||||
|
||||
@@ -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<InboundMeasurementItemEntity> items = measurement == null
|
||||
? List.of() : repository.listItems(tenantId, measurement.getId());
|
||||
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inbound.getId());
|
||||
List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationSlots =
|
||||
palletAllocations.list(tenantId, inbound.getId());
|
||||
Map<Long, com.baiyue.firstleg.entity.InboundPalletAllocationEntity> 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<InboundMeasurementResponses.PalletSnapshot> 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<InboundMeasurementItemEntity> items, List<InboundWeightResultEntity> weights,
|
||||
List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationSlots,
|
||||
Map<Long, com.baiyue.firstleg.entity.InboundPalletAllocationEntity> 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<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> 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<InboundMeasurementResponses.PalletLocationSlot> 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<WarehousePalletLocationClient.PalletSnapshot> loadPalletSnapshotPage(
|
||||
|
||||
@@ -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<Long> 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<Long> 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<Long> 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();
|
||||
|
||||
+55
-20
@@ -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<WarehousePalletLocationClient.PalletReference> confirmedPalletReferences(
|
||||
private java.util.List<InboundWeightResultEntity> confirmedWeights(
|
||||
long tenantId, long inboundId) {
|
||||
java.util.List<WarehousePalletLocationClient.PalletReference> references = new java.util.ArrayList<>();
|
||||
java.util.List<InboundWeightResultEntity> 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<Integer> 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 <T> ApiResponse<T> call(RemoteCall<T> call) {
|
||||
try { return call.invoke(); }
|
||||
catch (FeignException exception) {
|
||||
|
||||
@@ -153,18 +153,12 @@ public class PdaPickSettlementService {
|
||||
confirmedAllocations.sort(Comparator.comparing(InboundPalletAllocationEntity::getAllocationNo,
|
||||
Comparator.nullsLast(Integer::compareTo)));
|
||||
consumePickedAllocations(parent.tenantId(), parentInboundId, confirmedAllocations, pickedPackageCount);
|
||||
List<Long> releasedReservationIds = releasedReservationIds(confirmedAllocations);
|
||||
List<WarehousePalletLocationClient.SettlementTransferItem> 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<MeasurementItemRow> 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<Long> releasedReservationIds(List<InboundPalletAllocationEntity> allocations) {
|
||||
return allocations.stream().filter(item -> remaining(item) == 0).map(PdaPickSettlementService::reservationId)
|
||||
.distinct().sorted().toList();
|
||||
}
|
||||
|
||||
private static List<WarehousePalletLocationClient.SettlementTransferItem> remainingTransferItems(
|
||||
List<InboundPalletAllocationEntity> 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<InboundPalletAllocationEntity> 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<InboundPalletAllocationEntity> 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<Long> releasedReservationIds,
|
||||
List<WarehousePalletLocationClient.SettlementTransferItem> remainingItems) {
|
||||
ApiResponse<WarehousePalletLocationClient.SettlementResult> response;
|
||||
private void settleWarehouseBlock(long parentInboundId, long childInboundId, String childCode,
|
||||
long taskId, long operatorId, long blockId) {
|
||||
ApiResponse<WarehousePalletLocationClient.BlockResult> 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<String> settlementTransferFacts(
|
||||
List<WarehousePalletLocationClient.SettlementTransferItem> remainingItems) {
|
||||
return remainingItems.stream().map(item -> item.reservationId() + ":" + item.remainingPackageCount())
|
||||
.sorted().toList();
|
||||
}
|
||||
|
||||
private long existingSettlementTarget(long sourceInboundId, String idempotencyKey) {
|
||||
try {
|
||||
ApiResponse<Long> 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<Long> expected,
|
||||
List<WarehousePalletLocationClient.SelectiveReleaseResult> actual) {
|
||||
return expected.equals(actual.stream().map(WarehousePalletLocationClient.SelectiveReleaseResult::reservationId)
|
||||
.sorted().toList());
|
||||
}
|
||||
|
||||
private static boolean sameTransferredResources(List<WarehousePalletLocationClient.SettlementTransferItem> expected,
|
||||
List<WarehousePalletLocationClient.TransferResult> actual) {
|
||||
Map<Long, Integer> expectedByLocation = expected.stream().collect(Collectors.toMap(
|
||||
WarehousePalletLocationClient.SettlementTransferItem::reservationId,
|
||||
WarehousePalletLocationClient.SettlementTransferItem::remainingPackageCount));
|
||||
Map<Long, Integer> 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("子入库单拆分序号不合法");
|
||||
|
||||
@@ -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<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 服务中的最新客户申报只读视图;订单编辑只能改变本对象,不得覆盖箱码、实测尺寸/重量或审核结果
|
||||
|
||||
@@ -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:
|
||||
|
||||
+12
-2
@@ -24,7 +24,8 @@ class InboundMeasurementEvidenceOpenApiContractTest {
|
||||
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
|
||||
Map<String, Object> 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<String, Object> 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())
|
||||
|
||||
+5
@@ -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:");
|
||||
}
|
||||
}
|
||||
|
||||
+71
-51
@@ -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<InboundPalletAllocationEntity> 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<InboundPalletAllocationEntity> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
+72
-54
@@ -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<WarehousePalletLocationClient.ReserveRequest> 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<WarehousePalletLocationClient.BlockReserveRequest> 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<WarehousePalletLocationClient.ConfirmRequest> 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<WarehousePalletLocationClient.BlockConfirmRequest> 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<WarehousePalletLocationClient.ReserveRequest> 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<WarehousePalletLocationClient.BlockReserveRequest> reserve =
|
||||
ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockReserveRequest.class);
|
||||
verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture());
|
||||
assertThat(reserve.getValue().warehouseId()).isEqualTo(WAREHOUSE);
|
||||
|
||||
ArgumentCaptor<WarehousePalletLocationClient.ConfirmRequest> 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<WarehousePalletLocationClient.BlockConfirmRequest> 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<InboundWeightResultEntity> 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<WarehousePalletLocationClient.ReleaseRequest> 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<WarehousePalletLocationClient.BlockReleaseRequest> 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<InboundWeightResultEntity> confirmedWeights() {
|
||||
|
||||
+31
-58
@@ -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,
|
||||
|
||||
@@ -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<LoadingPlanItemEntity> 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<LoadingPlanItemEntity> updated = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
|
||||
verify(repository).update(updated.capture());
|
||||
assertThat(updated.getValue().getPackageCount()).isEqualTo(60);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unchangedQuantityAndCargoSnapshotDoNotWriteAdjustmentHistory() {
|
||||
LoadingPlanEntity plan = plan("LOADING", 2);
|
||||
|
||||
+6
-4
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'/)
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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',
|
||||
|
||||
+37
@@ -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<LocationBlockDtos.BlockResult> 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<LocationBlockDtos.BlockResult> 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<LocationBlockDtos.BlockResult> 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<LocationBlockDtos.BlockResult> 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<PalletAllocationDtos.BatchResult> confirm(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
|
||||
@@ -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<Resource> resources) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<LocationBlockEntity> {
|
||||
@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);
|
||||
}
|
||||
@@ -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<LocationBlockSlotEntity> {
|
||||
@Select("SELECT * FROM wh_location_block_slot WHERE tenant_id=0 AND block_id=#{blockId} ORDER BY slot_no FOR UPDATE")
|
||||
List<LocationBlockSlotEntity> 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);
|
||||
}
|
||||
@@ -69,6 +69,11 @@ public interface LocationMapper extends BaseMapper<LocationEntity> {
|
||||
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<LocationEntity> {
|
||||
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),
|
||||
|
||||
@@ -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<LocationBlockEntity>()
|
||||
.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<LocationBlockSlotEntity>()
|
||||
.eq(LocationBlockSlotEntity::getTenantId, RESOURCE_TENANT_ID)
|
||||
.eq(LocationBlockSlotEntity::getBlockId, existing.getId())
|
||||
.orderByAsc(LocationBlockSlotEntity::getSlotNo)));
|
||||
}
|
||||
|
||||
List<LocationEntity> available = locations.lockAvailable(RESOURCE_TENANT_ID, request.warehouseId(), CANDIDATE_LIMIT);
|
||||
List<LocationEntity> 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<LocationBlockSlotEntity> 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<LocationBlockSlotEntity> 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<LocationBlockSlotEntity> 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<LocationEntity> selectLocations(List<LocationEntity> available, int required) {
|
||||
if (available.size() < required) {
|
||||
return List.copyOf(available);
|
||||
}
|
||||
for (int start = 0; start <= available.size() - required; start++) {
|
||||
List<LocationEntity> 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<LocationEntity> 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<LocationBlockSlotEntity> 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());
|
||||
}
|
||||
}
|
||||
@@ -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='整票库位块槽位;空槽也由当前入仓码独占';
|
||||
|
||||
-- 头程入库单属于其他数据库,不建立跨库外键。
|
||||
|
||||
@@ -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] }
|
||||
+20
@@ -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)));
|
||||
|
||||
+7
@@ -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"))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user