This commit is contained in:
2026-09-04 12:18:36 +08:00
parent 566b07be62
commit b697e4c9f0
51 changed files with 1898 additions and 623 deletions
+132 -10
View File
@@ -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
View File
@@ -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` 只作为当前结构来源,不为历史开发数据再新增兼容迁移。
## 全量验收导航
@@ -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) {}
@@ -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
) {
@@ -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;
@@ -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)
@@ -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();
@@ -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:
@@ -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())
@@ -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:");
}
}
@@ -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));
}
@@ -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() {
@@ -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);
@@ -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',
@@ -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] }
@@ -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)));
@@ -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;
}
}