diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 24dbaba03..4cd343579 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -2000,9 +2000,8 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', pallet_name VARCHAR(100) NULL COMMENT '托盘名称', capacity_cbm DECIMAL(18,6) NOT NULL COMMENT '托盘可用容积CBM', - weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '托盘重量kg', + weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '托盘皮重kg', color VARCHAR(32) NOT NULL DEFAULT '' COMMENT '托盘颜色', - status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT '内部状态:AVAILABLE、RESERVED或OCCUPIED', remark VARCHAR(500) NULL COMMENT '备注', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -2012,11 +2011,11 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_pallet_tenant_code (tenant_id, pallet_code), - KEY idx_pallet_available (tenant_id, warehouse_id, status, deleted), - KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, status, deleted), + KEY idx_pallet_warehouse (tenant_id, warehouse_id, deleted, pallet_code), + KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, capacity_cbm, deleted), CONSTRAINT fk_pallet_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), CONSTRAINT ck_pallet_capacity CHECK (capacity_cbm > 0) -) ENGINE=InnoDB COMMENT='二期托盘基础资料和物理托盘实例'; +) ENGINE=InnoDB COMMENT='可复用托盘标识和称重主数据,不承载动态资源状态'; -- 存量库补齐托盘重量、颜色字段(可重复执行)。 SET @pallet_weight_sql = IF((SELECT COUNT(*) FROM information_schema.columns @@ -2028,6 +2027,28 @@ SET @pallet_color_sql = IF((SELECT COUNT(*) FROM information_schema.columns 'ALTER TABLE wh_pallet ADD COLUMN color VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''托盘颜色'' AFTER weight_kg', 'SELECT 1'); PREPARE pallet_color_stmt FROM @pallet_color_sql; EXECUTE pallet_color_stmt; DEALLOCATE PREPARE pallet_color_stmt; +-- 托盘是可复用主数据,不再用状态或活动唯一约束模拟库存资源。 +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND index_name = 'idx_pallet_available') > 0, + 'ALTER TABLE wh_pallet DROP INDEX idx_pallet_available', '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 = 'wh_pallet' AND index_name = 'idx_pallet_type') > 0, + 'ALTER TABLE wh_pallet DROP INDEX idx_pallet_type', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND column_name = 'status') > 0, + 'ALTER TABLE wh_pallet DROP COLUMN status', '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 = 'wh_pallet' AND index_name = 'idx_pallet_warehouse') = 0, + 'ALTER TABLE wh_pallet ADD KEY idx_pallet_warehouse (tenant_id, warehouse_id, deleted, pallet_code)', '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 = 'wh_pallet' AND index_name = 'idx_pallet_type') = 0, + 'ALTER TABLE wh_pallet ADD KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, capacity_cbm, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + CREATE TABLE IF NOT EXISTS wh_location ( id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共库位固定使用 tenant_id=0', @@ -2079,19 +2100,17 @@ CREATE TABLE IF NOT EXISTS wh_location_reservation ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', active_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动预占唯一标记', - active_pallet_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动预占唯一标记(包含A0)', PRIMARY KEY (id), UNIQUE KEY uk_location_reservation_no (tenant_id, reservation_no), UNIQUE KEY uk_location_reservation_idempotency (tenant_id, source_service, idempotency_key), UNIQUE KEY uk_location_active_reservation (tenant_id, location_id, active_reservation), - UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation), KEY idx_location_reservation_business (tenant_id, source_service, business_id, reservation_status), KEY idx_location_reservation_expire (reservation_status, expires_at), KEY idx_location_reservation_target (tenant_id, warehouse_id, location_id, reservation_status), CONSTRAINT fk_reservation_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), CONSTRAINT fk_reservation_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), CONSTRAINT fk_reservation_location FOREIGN KEY (location_id) REFERENCES wh_location (id) -) ENGINE=InnoDB COMMENT='跨服务入库Saga的托盘和库位临时预占'; +) ENGINE=InnoDB COMMENT='跨服务入库Saga的库位临时预占,托盘字段仅为主数据快照'; CREATE TABLE IF NOT EXISTS wh_location_occupancy ( id BIGINT UNSIGNED NOT NULL COMMENT '库位占用ID', @@ -2118,12 +2137,10 @@ CREATE TABLE IF NOT EXISTS wh_location_occupancy ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', active_location_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动占用唯一标记', - active_pallet_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动占用唯一标记(包含A0)', PRIMARY KEY (id), UNIQUE KEY uk_location_occupancy_no (tenant_id, occupancy_no), UNIQUE KEY uk_occupancy_reservation (tenant_id, reservation_id), UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy), - UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy), KEY idx_occupancy_business (tenant_id, source_service, business_id, occupancy_status), KEY idx_occupancy_warehouse (tenant_id, warehouse_id, occupancy_status, occupied_at), CONSTRAINT fk_occupancy_reservation FOREIGN KEY (reservation_id) REFERENCES wh_location_reservation (id), @@ -2131,7 +2148,7 @@ CREATE TABLE IF NOT EXISTS wh_location_occupancy ( CONSTRAINT fk_occupancy_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), CONSTRAINT fk_occupancy_location FOREIGN KEY (location_id) REFERENCES wh_location (id), CONSTRAINT ck_occupancy_volume CHECK (allocated_volume_cbm > 0) -) ENGINE=InnoDB COMMENT='已确认的一托一库位占用事实记录'; +) ENGINE=InnoDB COMMENT='已确认的库位占用事实记录,托盘字段仅为主数据快照'; -- 头程入库单属于其他数据库,不建立跨库外键。 @@ -2211,10 +2228,6 @@ SET @reservation_needs_upgrade = IFNULL(( SET @sql = IF(@reservation_needs_upgrade = 1 AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_location_active_reservation') > 0, 'ALTER TABLE wh_location_reservation DROP INDEX uk_location_active_reservation', 'SELECT 1'); PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -SET @sql = IF(@reservation_needs_upgrade = 1 AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') > 0, - 'ALTER TABLE wh_location_reservation DROP INDEX uk_pallet_active_reservation', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - SET @sql = IF(@reservation_needs_upgrade = 1, 'ALTER TABLE wh_location_reservation MODIFY COLUMN active_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = ''RESERVED'' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT ''普通库位活动预占唯一标记,A0 返回 NULL''', 'SELECT 1'); @@ -2228,18 +2241,11 @@ SET @occupancy_needs_upgrade = IF( EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND column_name = 'active_location_occupancy' - AND LOCATE('shared_overflow', generation_expression) > 0) - AND EXISTS(SELECT 1 FROM information_schema.columns - WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' - AND column_name = 'active_pallet_occupancy'), + AND LOCATE('shared_overflow', generation_expression) > 0), 0, 1); SET @sql = IF(@occupancy_needs_upgrade = 1 AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy') > 0, 'ALTER TABLE wh_location_occupancy DROP INDEX uk_location_active_occupancy', 'SELECT 1'); PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -SET @sql = IF(@occupancy_needs_upgrade = 1 AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') > 0, - 'ALTER TABLE wh_location_occupancy DROP INDEX uk_pallet_active_occupancy', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; DELIMITER $$ CREATE PROCEDURE add_location_overflow_column_if_missing( @@ -2265,29 +2271,28 @@ BEGIN END$$ DELIMITER ; -CALL add_location_overflow_column_if_missing( - 'wh_location_reservation', 'active_pallet_reservation', - 'TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = ''RESERVED'' THEN 1 ELSE NULL END) STORED COMMENT ''托盘活动预占唯一标记(包含 A0)''' -); CALL add_location_overflow_column_if_missing( 'wh_location_occupancy', 'active_location_occupancy', 'TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = ''OCCUPIED'' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT ''普通库位活动占用唯一标记''' ); -CALL add_location_overflow_column_if_missing( - 'wh_location_occupancy', 'active_pallet_occupancy', - 'TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = ''OCCUPIED'' THEN 1 ELSE NULL END) STORED COMMENT ''托盘活动占用唯一标记(包含 A0)''' -); DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') = 0, - 'ALTER TABLE wh_location_reservation ADD UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation)', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy') = 0, 'ALTER TABLE wh_location_occupancy ADD UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy)', 'SELECT 1'); PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') = 0, - 'ALTER TABLE wh_location_occupancy ADD UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy)', 'SELECT 1'); + +-- 清理旧环境中的托盘活动资源约束;库位活动约束继续保留。 +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') > 0, + 'ALTER TABLE wh_location_reservation DROP INDEX uk_pallet_active_reservation', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') > 0, + 'ALTER TABLE wh_location_occupancy DROP INDEX uk_pallet_active_occupancy', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND column_name = 'active_pallet_reservation') > 0, + 'ALTER TABLE wh_location_reservation DROP COLUMN active_pallet_reservation', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND column_name = 'active_pallet_occupancy') > 0, + 'ALTER TABLE wh_location_occupancy DROP COLUMN active_pallet_occupancy', 'SELECT 1'); PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -- 托盘、库位、预占和占用均为平台公共仓储资源,存量测试数据统一迁移到平台作用域 0。 @@ -2758,6 +2763,11 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', file_id BIGINT UNSIGNED NOT NULL COMMENT '称重图片文件ID', + pallet_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse托盘ID', + pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', + pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型快照', + pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘容量CBM快照', + pallet_tare_weight_kg DECIMAL(18,3) NOT NULL COMMENT '托盘皮重KG快照', ocr_provider VARCHAR(64) NULL COMMENT 'OCR服务商编码', ocr_raw_text VARCHAR(1000) NULL COMMENT 'OCR原始文本', ocr_weight_value DECIMAL(18,3) NULL COMMENT 'OCR识别原始重量值', @@ -2765,7 +2775,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( confidence DECIMAL(6,5) NULL COMMENT '识别置信度', adjusted_weight_value DECIMAL(18,3) NULL COMMENT '人工修正后的原单位重量', adjusted_weight_unit VARCHAR(8) NULL COMMENT '人工修正后的单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认并统一换算后的重量kg', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', recognition_status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '重量识别状态:PENDING待识别、PENDING_CONFIRM待确认、NEEDS_MANUAL需人工录入、CONFIRMED已确认', adjustment_reason VARCHAR(500) NULL COMMENT '人工修正原因', adjusted_by BIGINT UNSIGNED NULL COMMENT '修正人ID', @@ -2781,8 +2792,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( PRIMARY KEY (id), UNIQUE KEY uk_weight_inbound_file (tenant_id, inbound_id, file_id), KEY idx_weight_inbound_status (tenant_id, inbound_id, recognition_status), + KEY idx_weight_inbound_pallet (tenant_id, inbound_id, pallet_id), CONSTRAINT fk_weight_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='称重图片OCR原值、人工修正值和确认重量'; +) ENGINE=InnoDB COMMENT='称重图片、托盘皮重快照、OCR毛重和确认净重'; CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID', @@ -2795,7 +2807,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', reason VARCHAR(500) NULL COMMENT '人工修正原因', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', @@ -3014,11 +3027,16 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation ( 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_pallet (tenant_id, inbound_id, pallet_id), KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status), CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB COMMENT='头程侧托盘、库位和仓库预占结果引用'; +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_pallet') > 0, + 'ALTER TABLE fl_inbound_pallet_allocation DROP INDEX uk_pallet_allocation_pallet', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + CREATE TABLE IF NOT EXISTS fl_inbound_file ( id BIGINT UNSIGNED NOT NULL COMMENT '入库单文件关系ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', @@ -3197,6 +3215,25 @@ CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_height_cm', CALL phase2_measure_add_column('fl_inbound_measurement_item', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''最后更新时间'' AFTER created_at'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_id', + 'BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''Warehouse托盘ID'' AFTER file_id'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_code', + 'VARCHAR(50) NOT NULL DEFAULT '''' COMMENT ''托盘编码快照'' AFTER pallet_id'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_type', + 'VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''托盘类型快照'' AFTER pallet_code'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_capacity_cbm', + 'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''托盘容量CBM快照'' AFTER pallet_type'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_tare_weight_kg', + 'DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''托盘皮重KG快照'' AFTER pallet_capacity_cbm'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'gross_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''确认时统一换算后的含托盘毛重kg'' AFTER adjusted_weight_unit'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'net_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''确认毛重扣除托盘皮重后的货物净重kg'' AFTER gross_weight_kg'); +CALL phase2_measure_add_column('fl_inbound_weight_revision', 'gross_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''确认时统一换算后的含托盘毛重kg'' AFTER after_weight_unit'); +CALL phase2_measure_add_column('fl_inbound_weight_revision', 'net_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''确认毛重扣除托盘皮重后的货物净重kg'' AFTER gross_weight_kg'); + SET @has_measure_box_code_index = ( SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() @@ -3217,6 +3254,30 @@ ALTER TABLE fl_inbound_measurement_item DROP PROCEDURE IF EXISTS phase2_measure_add_column; +SET @phase2_measure_sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_weight_result' + AND index_name = 'idx_weight_inbound_pallet') = 0, + 'ALTER TABLE fl_inbound_weight_result ADD KEY idx_weight_inbound_pallet (tenant_id, inbound_id, pallet_id)', + 'SELECT 1'); +PREPARE phase2_measure_statement FROM @phase2_measure_sql; +EXECUTE phase2_measure_statement; +DEALLOCATE PREPARE phase2_measure_statement; + +SET @phase2_measure_sql = IF((SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_weight_result' + AND column_name = 'final_weight_kg') > 0, + 'ALTER TABLE fl_inbound_weight_result DROP COLUMN final_weight_kg', 'SELECT 1'); +PREPARE phase2_measure_statement FROM @phase2_measure_sql; +EXECUTE phase2_measure_statement; +DEALLOCATE PREPARE phase2_measure_statement; +SET @phase2_measure_sql = IF((SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_weight_revision' + AND column_name = 'final_weight_kg') > 0, + 'ALTER TABLE fl_inbound_weight_revision DROP COLUMN final_weight_kg', 'SELECT 1'); +PREPARE phase2_measure_statement FROM @phase2_measure_sql; +EXECUTE phase2_measure_statement; +DEALLOCATE PREPARE phase2_measure_statement; + CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', @@ -3228,7 +3289,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', reason VARCHAR(500) NULL COMMENT '人工修正原因', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', @@ -11190,7 +11252,7 @@ CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_split_history', '不可 CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_split_item', '主子入仓单拆分数量台账'); CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_status_history', '入库单流程状态不可变历史'); CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_transport_snapshot', '入库单关联运输信息只读快照'); -CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_weight_result', '称重图片OCR原值、人工修正值和确认重量'); +CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_weight_result', '称重图片、托盘皮重快照、OCR毛重和确认净重'); CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'OCR重量人工修正和确认的不可变版本历史'); CALL queue_table_comment('baiyue_firstleg', 'fl_loading_evidence', '订单装柜空仓/半仓/满仓/锁仓/带锁码照证据'); CALL queue_table_comment('baiyue_firstleg', 'fl_loading_operation', '装柜与派送写操作幂等记录'); @@ -11256,11 +11318,11 @@ CALL queue_table_comment('baiyue_order', 'sys_audit_outbox', '可靠审计事件 CALL queue_table_comment('baiyue_warehouse', 'sys_audit_outbox', '可靠审计事件本地发送箱'); CALL queue_table_comment('baiyue_warehouse', 'wh_express_company', '平台公共快递公司主数据,固定归属 tenant_id=0'); CALL queue_table_comment('baiyue_warehouse', 'wh_location', '二期库位基础资料,默认一托盘对应一个库位且不作容量校验'); -CALL queue_table_comment('baiyue_warehouse', 'wh_location_occupancy', '已确认的一托一库位占用事实记录'); -CALL queue_table_comment('baiyue_warehouse', 'wh_location_reservation', '跨服务入库Saga的托盘和库位临时预占'); +CALL queue_table_comment('baiyue_warehouse', 'wh_location_occupancy', '已确认的库位占用事实记录,托盘字段仅为主数据快照'); +CALL queue_table_comment('baiyue_warehouse', 'wh_location_reservation', '跨服务入库Saga的库位临时预占,托盘字段仅为主数据快照'); CALL queue_table_comment('baiyue_warehouse', 'wh_overseas_storage_fee_rule', '客户海外仓费用规则'); CALL queue_table_comment('baiyue_warehouse', 'wh_overseas_storage_fee_tier', '客户海外仓费用阶梯'); -CALL queue_table_comment('baiyue_warehouse', 'wh_pallet', '二期托盘基础资料和物理托盘实例'); +CALL queue_table_comment('baiyue_warehouse', 'wh_pallet', '可复用托盘标识和称重主数据,不承载动态资源状态'); CALL queue_table_comment('baiyue_warehouse', 'wh_pallet_location_release_operation', 'PDA已拣满托盘库位精确释放的持久化幂等操作记录'); CALL queue_table_comment('baiyue_warehouse', 'wh_pallet_location_settlement_operation', 'PDA拣货资源原子结算的持久化幂等操作记录'); CALL queue_table_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', '托盘库位归属转移的持久化幂等操作记录'); @@ -12536,13 +12598,19 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'create CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'created_by', '创建人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'deleted', '逻辑删除标记'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'file_id', '称重图片文件ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'final_weight_kg', '确认并统一换算后的重量kg'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'gross_weight_kg', '确认时统一换算后的含托盘毛重kg'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'id', 'OCR重量结果ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'inbound_id', '入库单ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'net_weight_kg', '确认毛重扣除托盘皮重后的货物净重kg'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'ocr_provider', 'OCR服务商编码'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'ocr_raw_text', 'OCR原始文本'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'ocr_weight_unit', 'OCR识别原始单位'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'ocr_weight_value', 'OCR识别原始重量值'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'pallet_capacity_cbm', '托盘容量CBM快照'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'pallet_code', '托盘编码快照'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'pallet_id', 'Warehouse托盘ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'pallet_tare_weight_kg', '托盘皮重KG快照'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'pallet_type', '托盘类型快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'recognition_status', '重量识别状态:PENDING待识别、PENDING_CONFIRM待确认、NEEDS_MANUAL需人工录入、CONFIRMED已确认'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'tenant_id', '客户ID和数据隔离键'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'updated_at', '更新时间'); @@ -12553,9 +12621,10 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'afte CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'after_weight_value', '变更后原单位重量'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'before_weight_unit', '变更前单位'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'before_weight_value', '变更前原单位重量'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'final_weight_kg', '确认时统一换算后的重量kg'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'gross_weight_kg', '确认时统一换算后的含托盘毛重kg'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'id', 'OCR重量修订记录ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'inbound_id', '入库单ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'net_weight_kg', '确认毛重扣除托盘皮重后的货物净重kg'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'operated_at', '操作时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'operator_id', '操作人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'operator_name', '操作人名称快照'); @@ -13527,7 +13596,6 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_location', 'warehouse_id', ' CALL queue_column_comment('baiyue_warehouse', 'wh_location', 'zone_code', '库区编码'); CALL queue_column_comment('baiyue_warehouse', 'wh_location', 'zone_name', '库区名称'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'active_location_occupancy', '普通库位活动占用唯一标记'); -CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'active_pallet_occupancy', '托盘活动占用唯一标记(包含A0)'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'allocated_package_count', '本托盘分配箱数'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'allocated_volume_cbm', '本托盘分配体积CBM'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'business_id', '来源服务入库单ID'); @@ -13551,7 +13619,6 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'tenant_i CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'updated_at', '更新时间'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'warehouse_id', '仓库ID'); -CALL queue_column_comment('baiyue_warehouse', 'wh_location_reservation', 'active_pallet_reservation', '托盘活动预占唯一标记(包含A0)'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_reservation', 'active_reservation', '普通库位活动预占唯一标记,A0 返回 NULL'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_reservation', 'business_id', '来源服务入库单ID'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_reservation', 'confirmed_at', '确认占用时间'); @@ -13613,13 +13680,12 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'pallet_code', '平 CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'pallet_name', '托盘名称'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'pallet_type', '托盘类型'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'remark', '备注'); -CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'status', '内部状态:AVAILABLE、RESERVED或OCCUPIED'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'tenant_id', '平台公共托盘固定使用 tenant_id=0'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'updated_at', '更新时间'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'updated_by', '最后修改人ID'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'warehouse_id', '所属仓库ID'); -CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'weight_kg', '托盘重量kg'); +CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'weight_kg', '托盘皮重kg'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_release_operation', 'business_id', '入库单ID'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_release_operation', 'completed_at', '完成时间'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_release_operation', 'created_at', '创建时间'); @@ -13815,6 +13881,47 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:Finance 来源事件或人工写操作幂等约束不完整'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_warehouse' + AND ((table_name = 'wh_pallet' AND column_name = 'status') + OR (table_name = 'wh_location_reservation' AND column_name = 'active_pallet_reservation') + OR (table_name = 'wh_location_occupancy' AND column_name = 'active_pallet_occupancy'))) <> 0 + OR (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_warehouse' + AND index_name IN ('uk_pallet_active_reservation', 'uk_pallet_active_occupancy')) <> 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:托盘动态资源状态或活动唯一约束仍存在'; + END IF; + IF (SELECT COUNT(DISTINCT CONCAT(table_name, '.', index_name)) + FROM information_schema.statistics + WHERE table_schema = 'baiyue_warehouse' + AND ((table_name = 'wh_location_reservation' AND index_name = 'uk_location_active_reservation') + OR (table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy')) + AND non_unique = 0) <> 2 THEN + 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_weight_result' AND column_name IN ( + 'pallet_id', 'pallet_code', 'pallet_type', 'pallet_capacity_cbm', + 'pallet_tare_weight_kg', 'gross_weight_kg', 'net_weight_kg')) + OR (table_name = 'fl_inbound_weight_revision' + AND column_name IN ('gross_weight_kg', 'net_weight_kg')))) <> 9 + OR (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' + AND table_name IN ('fl_inbound_weight_result', 'fl_inbound_weight_revision') + AND column_name = 'final_weight_kg') <> 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:称重托盘快照或毛重净重字段未正确收敛'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' + AND table_name = 'fl_inbound_pallet_allocation' + AND index_name = 'uk_pallet_allocation_pallet') <> 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:同票托盘唯一约束仍阻止托盘主数据复用'; + END IF; IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_carton_label') <> 1 THEN diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java index 86d0e8481..d5ccc7dc4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java @@ -13,39 +13,44 @@ import java.time.LocalDateTime; import java.util.List; @FeignClient(name = "warehouse-service", contextId = "warehousePalletLocationClient", - url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/pallet-locations") + url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1") public interface WarehousePalletLocationClient { - @PostMapping("/reservations") + @GetMapping("/pallets/snapshots") + ApiResponse> palletSnapshots( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestParam("warehouseId") long warehouseId); + + @PostMapping("/pallet-locations/reservations") ApiResponse reserve(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody ReserveRequest request); - @PostMapping("/confirm") + @PostMapping("/pallet-locations/confirm") ApiResponse confirm(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody ConfirmRequest request); - @PostMapping("/release") + @PostMapping("/pallet-locations/release") ApiResponse> release(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody ReleaseRequest request); - @PostMapping("/release-selected") + @PostMapping("/pallet-locations/release-selected") ApiResponse> releaseSelected( @RequestHeader("X-Internal-Service-Token") String token, @RequestBody SelectiveReleaseRequest request); - @PostMapping("/transfer") + @PostMapping("/pallet-locations/transfer") ApiResponse> transfer(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody TransferRequest request); - @PostMapping("/settlements") + @PostMapping("/pallet-locations/settlements") ApiResponse settle(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody SettlementRequest request); - @GetMapping("/settlements/target") + @GetMapping("/pallet-locations/settlements/target") ApiResponse settlementTarget(@RequestHeader("X-Internal-Service-Token") String token, @RequestParam("sourceBusinessId") long sourceBusinessId, @RequestParam("idempotencyKey") String idempotencyKey); - @GetMapping("/occupied-resources") + @GetMapping("/pallet-locations/occupied-resources") ApiResponse> occupiedResources( @RequestHeader("X-Internal-Service-Token") String token, @RequestParam("tenantId") long tenantId, @@ -90,6 +95,8 @@ public interface WarehousePalletLocationClient { List remainingItems) {} record SettlementResult(List released, List transferred) {} record OccupiedResource(long reservationId, long occupancyId, int allocationNo, long warehouseId, - long palletId, long locationId, String locationCode, boolean sharedOverflow, - int allocatedPackageCount) {} + long palletId, long locationId, String locationCode, boolean sharedOverflow, + int allocatedPackageCount) {} + record PalletSnapshot(long palletId, String palletCode, String palletType, + BigDecimal capacityCbm, BigDecimal tareWeightKg) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index 058850f70..f95135f66 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -33,6 +33,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.http.MediaType; import org.springframework.web.multipart.MultipartFile; +import java.util.List; + /** 平台后台入库测量录入、提交和 OCR 重量结果维护接口。 */ @Tag(name = "平台入库测量", description = "维护测量草稿、箱规明细和可追溯 OCR 重量结果。") @RestController @@ -53,6 +55,19 @@ public class PlatformInboundMeasurementController { return ApiResponse.success(service.detail(tenantId, inboundId)); } + @Operation(summary = "查询称重可选托盘快照", + description = "按入库单所属仓库返回未删除的托盘只读快照,不校验托盘状态或占用资格。") + @GetMapping("/weight-pallet-options") + @PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:measure:view'," + + " 'platform:firstleg:inbound:weight:edit')") + public ApiResponse> weightPalletOptions( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @PathVariable long inboundId) { + requirePlatformTenant(user, tenantId); + return ApiResponse.success(service.weightPalletOptions(tenantId, inboundId)); + } + @BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND", resourceId = "#inboundId", description = "保存入库测量草稿") @Operation(summary = "保存测量草稿", description = "保存人工测量批次和服务端计算明细,不推进入库状态。") @@ -142,7 +157,8 @@ public class PlatformInboundMeasurementController { @BusinessAudit(event = "INBOUND_WEIGHT_OCR_RECORD", resourceType = "INBOUND", resourceId = "#inboundId", description = "登记称重图片 OCR 原值") - @Operation(summary = "登记 OCR 重量结果", description = "按图片文件 ID 保存不可覆盖的 OCR 原值。") + @Operation(summary = "登记 OCR 重量结果", + description = "按图片文件 ID 和托盘 ID 保存 OCR 毛重原值及同仓托盘快照。") @PostMapping("/weight-results") @PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')") public ApiResponse createWeightResult( @@ -173,7 +189,8 @@ public class PlatformInboundMeasurementController { @BusinessAudit(event = "INBOUND_WEIGHT_OCR_CONFIRM", resourceType = "INBOUND_WEIGHT_RESULT", resourceId = "#resultId", description = "确认 OCR 重量并累加实际总毛重") @Operation(summary = "确认 OCR 重量", - description = "可在同一事务中携带人工修改值并确认;统一换算为 KG 后重算所有已确认图片的总重量。") + description = "可在同一事务中携带人工毛重修改值并确认;统一换算 KG、扣除托盘皮重后," + + "按所有已确认净重重算入库单 actualGrossWeightKg。") @PostMapping("/weight-results/{resultId}/confirm") @PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')" + " and (!#request.hasAdjustment() or hasAuthority('platform:firstleg:inbound:weight:edit'))") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java index 38bebfe18..b6ab938c4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java @@ -79,6 +79,7 @@ public final class InboundMeasurementRequests { public record CreateWeightResultRequest( @NotNull @Positive Long fileId, + @NotNull @Positive Long palletId, @Size(max = 64) String ocrProvider, @Size(max = 1000) String ocrRawText, @DecimalMin("0.000") @Digits(integer = 15, fraction = 3) BigDecimal ocrWeightValue, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java index 67744b939..0f74b7382 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -81,14 +81,20 @@ public final class InboundMeasurementResponses { public record WeightResult( Long id, Long fileId, + Long palletId, + String palletCode, + String palletType, + BigDecimal palletCapacityCbm, + BigDecimal palletTareWeightKg, String ocrProvider, String ocrRawText, BigDecimal ocrWeightValue, String ocrWeightUnit, BigDecimal confidence, + BigDecimal grossWeightKg, BigDecimal adjustedWeightValue, String adjustedWeightUnit, - BigDecimal finalWeightKg, + BigDecimal netWeightKg, String recognitionStatus, String adjustmentReason, Long adjustedBy, @@ -99,6 +105,15 @@ public final class InboundMeasurementResponses { ) { } + public record PalletSnapshot( + Long palletId, + String palletCode, + String palletType, + BigDecimal capacityCbm, + BigDecimal tareWeightKg + ) { + } + public record DraftSaved(Long measurementId, Integer version, String status) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java index 39544286f..22dd6a734 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java @@ -37,9 +37,11 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound, } public record WeightResult( - Long id, Long fileId, BigDecimal recognizedWeightValue, String recognizedWeightUnit, - BigDecimal adjustedWeightValue, String adjustedWeightUnit, - BigDecimal finalWeightKg, String recognitionStatus) { + Long id, Long fileId, Long palletId, String palletCode, String palletType, + BigDecimal palletCapacityCbm, BigDecimal palletTareWeightKg, + BigDecimal recognizedWeightValue, String recognizedWeightUnit, + BigDecimal grossWeightKg, BigDecimal adjustedWeightValue, String adjustedWeightUnit, + BigDecimal netWeightKg, String recognitionStatus) { } public record BillingBasis( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightResultEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightResultEntity.java index 6e00d2919..e981ba446 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightResultEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightResultEntity.java @@ -10,7 +10,7 @@ import lombok.Setter; import java.math.BigDecimal; import java.time.LocalDateTime; -/** 称重图片 OCR 原值、人工修正值和最终确认重量。 */ +/** 称重图片、托盘皮重快照、OCR 原值和最终确认净重。 */ @Getter @Setter @TableName("fl_inbound_weight_result") @@ -20,6 +20,11 @@ public class InboundWeightResultEntity extends BaseEntity { private Long tenantId; private Long inboundId; private Long fileId; + private Long palletId; + private String palletCode; + private String palletType; + private BigDecimal palletCapacityCbm; + private BigDecimal palletTareWeightKg; private String ocrProvider; private String ocrRawText; private BigDecimal ocrWeightValue; @@ -27,7 +32,8 @@ public class InboundWeightResultEntity extends BaseEntity { private BigDecimal confidence; private BigDecimal adjustedWeightValue; private String adjustedWeightUnit; - private BigDecimal finalWeightKg; + private BigDecimal grossWeightKg; + private BigDecimal netWeightKg; private String recognitionStatus; private String adjustmentReason; private Long adjustedBy; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightRevisionEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightRevisionEntity.java index 7882ebdef..ef6eb203b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightRevisionEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightRevisionEntity.java @@ -25,7 +25,8 @@ public class InboundWeightRevisionEntity { private String beforeWeightUnit; private BigDecimal afterWeightValue; private String afterWeightUnit; - private BigDecimal finalWeightKg; + private BigDecimal grossWeightKg; + private BigDecimal netWeightKg; private String reason; private Long operatorId; private String operatorName; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java index 1e67be5d1..b82ca598c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java @@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.CartonMeasurementResult; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; import com.baiyue.firstleg.repository.InboundMeasurementRepository; import feign.FeignException; import org.slf4j.Logger; @@ -143,13 +144,26 @@ public class CartonMeasurementService { } // 提交审核后仍允许平台人工修正代表箱数,但订单原始总箱数不可被测量汇总覆盖。 if ("MEASURE_PENDING".equals(inbound.getProcessStatus())) { - BigDecimal totalWeight = items.stream() - .map(InboundMeasurementItemEntity::getTotalWeightKg) - .filter(java.util.Objects::nonNull) - .reduce(BigDecimal.ZERO, BigDecimal::add) - .setScale(3, RoundingMode.CEILING); - if (positive(totalWeight)) { - inbound.setActualGrossWeightKg(totalWeight); + List confirmedWeights = repository.listWeights(tenantId, inboundId) + .stream() + .filter(weight -> "CONFIRMED".equals(weight.getRecognitionStatus())) + .toList(); + if (!confirmedWeights.isEmpty()) { + BigDecimal confirmedNetWeight = confirmedWeights.stream() + .map(InboundWeightResultEntity::getNetWeightKg) + .filter(java.util.Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add) + .setScale(3, RoundingMode.CEILING); + inbound.setActualGrossWeightKg(confirmedNetWeight); + } else { + BigDecimal totalWeight = items.stream() + .map(InboundMeasurementItemEntity::getTotalWeightKg) + .filter(java.util.Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add) + .setScale(3, RoundingMode.CEILING); + if (positive(totalWeight)) { + inbound.setActualGrossWeightKg(totalWeight); + } } inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm()); inbound.setVolumeWeightKg(measurement.getVolumeWeightKg()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index 199f96517..f61879683 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.client.MeasurementImageClient; +import com.baiyue.firstleg.client.WarehousePalletLocationClient; import com.baiyue.firstleg.dto.InboundMeasurementRequests; import com.baiyue.firstleg.dto.InboundMeasurementResponses; import com.baiyue.firstleg.dto.MeasurementBatchCommand; @@ -57,6 +58,8 @@ public class InboundMeasurementService { private String internalToken; @Resource private MeasurementImageClient imageClient; + @Resource + private WarehousePalletLocationClient warehousePallets; public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); @@ -68,6 +71,16 @@ public class InboundMeasurementService { return toDetail(inbound, measurement, items, weights, review); } + public List weightPalletOptions( + long tenantId, long inboundId) { + InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + return loadPalletSnapshots(inbound).stream() + .map(snapshot -> new InboundMeasurementResponses.PalletSnapshot( + snapshot.palletId(), snapshot.palletCode(), snapshot.palletType(), + snapshot.capacityCbm(), snapshot.tareWeightKg())) + .toList(); + } + @Transactional public InboundMeasurementResponses.DraftSaved saveDraft( long tenantId, long inboundId, long operatorId, @@ -147,11 +160,22 @@ public class InboundMeasurementService { if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) { throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重"); } + WarehousePalletLocationClient.PalletSnapshot pallet = loadPalletSnapshots(inbound).stream() + .filter(snapshot -> snapshot.palletId() == request.palletId()) + .findFirst() + .orElseThrow(() -> new BizException(ResultCode.VALIDATION_FAILED, + "所选托盘不属于当前入库仓库或已被删除")); + validatePalletSnapshot(pallet); String unit = rawOcrUnit(request.ocrWeightUnit()); InboundWeightResultEntity weight = new InboundWeightResultEntity(); weight.setTenantId(tenantId); weight.setInboundId(inboundId); weight.setFileId(request.fileId()); + weight.setPalletId(pallet.palletId()); + weight.setPalletCode(pallet.palletCode().trim()); + weight.setPalletType(pallet.palletType().trim()); + weight.setPalletCapacityCbm(pallet.capacityCbm().setScale(4, RoundingMode.HALF_UP)); + weight.setPalletTareWeightKg(pallet.tareWeightKg().setScale(3, RoundingMode.HALF_UP)); weight.setOcrProvider(request.ocrProvider()); weight.setOcrRawText(request.ocrRawText()); weight.setOcrWeightValue(request.ocrWeightValue()); @@ -196,7 +220,8 @@ public class InboundMeasurementService { weight.setAdjustmentReason(adjustmentReason.trim()); weight.setAdjustedBy(operatorId); weight.setAdjustedAt(LocalDateTime.now()); - weight.setFinalWeightKg(null); + weight.setGrossWeightKg(null); + weight.setNetWeightKg(null); weight.setConfirmedBy(null); weight.setConfirmedAt(null); weight.setRecognitionStatus(WEIGHT_PENDING_CONFIRM); @@ -207,7 +232,7 @@ public class InboundMeasurementService { int correctedVersion = expectedVersion + 1; weight.setVersion(correctedVersion); insertRevision(weight, correctedVersion, "CORRECT", beforeValue, beforeUnit, - adjustedWeightValue, adjustedUnit, null, adjustmentReason.trim(), + adjustedWeightValue, adjustedUnit, null, null, adjustmentReason.trim(), operatorId, operatorName); return correctedVersion; } @@ -236,8 +261,15 @@ public class InboundMeasurementService { throw new BizException(ResultCode.VALIDATION_FAILED, "OCR 重量无法确认,请先人工修正数值和单位"); } String unit = normalizeUnit(rawUnit); - BigDecimal finalKg = requireConfirmableKg(value, unit); - weight.setFinalWeightKg(finalKg); + BigDecimal grossWeightKg = requireConfirmableKg(value, unit); + BigDecimal netWeightKg = grossWeightKg.subtract(weight.getPalletTareWeightKg()) + .setScale(3, RoundingMode.HALF_UP); + if (!isPositive(netWeightKg)) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "称重毛重必须大于托盘皮重"); + } + weight.setGrossWeightKg(grossWeightKg); + weight.setNetWeightKg(netWeightKg); weight.setRecognitionStatus(WEIGHT_CONFIRMED); weight.setConfirmedBy(operatorId); weight.setConfirmedAt(LocalDateTime.now()); @@ -248,7 +280,7 @@ public class InboundMeasurementService { int confirmedVersion = currentVersion + 1; weight.setVersion(confirmedVersion); insertRevision(weight, confirmedVersion, "CONFIRM", value, unit, value, unit, - finalKg, null, operatorId, operatorName); + grossWeightKg, netWeightKg, null, operatorId, operatorName); recalculateConfirmedWeight(inbound, operatorId); return toWeight(weight); } @@ -288,7 +320,7 @@ public class InboundMeasurementService { .filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) .toList(); BigDecimal total = confirmedWeights.stream() - .map(InboundWeightResultEntity::getFinalWeightKg) + .map(InboundWeightResultEntity::getNetWeightKg) .reduce(BigDecimal.ZERO, BigDecimal::add) .setScale(3, RoundingMode.HALF_UP); inbound.setActualGrossWeightKg(confirmedWeights.isEmpty() ? null : total); @@ -304,7 +336,7 @@ public class InboundMeasurementService { private BigDecimal confirmedWeightForSubmission(List weights) { BigDecimal total = weights.stream() .filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) - .map(InboundWeightResultEntity::getFinalWeightKg) + .map(InboundWeightResultEntity::getNetWeightKg) .filter(java.util.Objects::nonNull) .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, RoundingMode.HALF_UP); return isPositive(total) ? total : null; @@ -319,7 +351,8 @@ public class InboundMeasurementService { private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action, BigDecimal beforeValue, String beforeUnit, - BigDecimal afterValue, String afterUnit, BigDecimal finalWeightKg, + BigDecimal afterValue, String afterUnit, + BigDecimal grossWeightKg, BigDecimal netWeightKg, String reason, long operatorId, String operatorName) { InboundWeightRevisionEntity revision = new InboundWeightRevisionEntity(); revision.setTenantId(weight.getTenantId()); @@ -331,7 +364,8 @@ public class InboundMeasurementService { revision.setBeforeWeightUnit(beforeUnit); revision.setAfterWeightValue(afterValue); revision.setAfterWeightUnit(afterUnit); - revision.setFinalWeightKg(finalWeightKg); + revision.setGrossWeightKg(grossWeightKg); + revision.setNetWeightKg(netWeightKg); revision.setReason(reason); revision.setOperatorId(operatorId); revision.setOperatorName(operatorName); @@ -396,13 +430,46 @@ public class InboundMeasurementService { private InboundMeasurementResponses.WeightResult toWeight(InboundWeightResultEntity weight) { return new InboundMeasurementResponses.WeightResult( - weight.getId(), weight.getFileId(), weight.getOcrProvider(), weight.getOcrRawText(), + weight.getId(), weight.getFileId(), weight.getPalletId(), weight.getPalletCode(), + weight.getPalletType(), weight.getPalletCapacityCbm(), weight.getPalletTareWeightKg(), + weight.getOcrProvider(), weight.getOcrRawText(), weight.getOcrWeightValue(), weight.getOcrWeightUnit(), weight.getConfidence(), - weight.getAdjustedWeightValue(), weight.getAdjustedWeightUnit(), weight.getFinalWeightKg(), + weight.getGrossWeightKg(), + weight.getAdjustedWeightValue(), weight.getAdjustedWeightUnit(), + weight.getNetWeightKg(), weight.getRecognitionStatus(), weight.getAdjustmentReason(), weight.getAdjustedBy(), weight.getAdjustedAt(), weight.getConfirmedBy(), weight.getConfirmedAt(), weight.getVersion()); } + private List loadPalletSnapshots( + InboundOrderEntity inbound) { + if (inbound.getWarehouseId() == null || inbound.getWarehouseId() <= 0) { + throw new BizException(ResultCode.CONFLICT, "入库单未绑定有效仓库,无法选择称重托盘"); + } + try { + ApiResponse> response = + warehousePallets.palletSnapshots(internalToken, inbound.getWarehouseId()); + if (response == null || !response.successful() || response.data() == null) { + String message = response == null || !StringUtils.hasText(response.message()) + ? "托盘快照查询失败" : response.message(); + throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE + : ResultCode.of(response.code()), message); + } + return response.data(); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库服务暂不可用,无法查询托盘快照"); + } + } + + private void validatePalletSnapshot(WarehousePalletLocationClient.PalletSnapshot snapshot) { + if (snapshot.palletId() <= 0 || !StringUtils.hasText(snapshot.palletCode()) + || !StringUtils.hasText(snapshot.palletType()) || !isPositive(snapshot.capacityCbm()) + || snapshot.tareWeightKg() == null + || snapshot.tareWeightKg().compareTo(BigDecimal.ZERO) < 0) { + throw new BizException(ResultCode.CONFLICT, "仓库服务返回的托盘快照不完整"); + } + } + private InboundOrderEntity requireInbound(long tenantId, long inboundId) { InboundOrderEntity inbound = repository.findInbound(tenantId, inboundId); if (inbound == null) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java index 931065259..30af43bd4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java @@ -73,9 +73,11 @@ public class MerchantInboundBillingService { .toList(); var weightResults = measurement.weightResults().stream() .map(weight -> new MerchantInboundBillingResponse.WeightResult( - weight.id(), weight.fileId(), weight.ocrWeightValue(), weight.ocrWeightUnit(), - weight.adjustedWeightValue(), weight.adjustedWeightUnit(), - weight.finalWeightKg(), weight.recognitionStatus())) + weight.id(), weight.fileId(), weight.palletId(), weight.palletCode(), weight.palletType(), + weight.palletCapacityCbm(), weight.palletTareWeightKg(), + weight.ocrWeightValue(), weight.ocrWeightUnit(), + weight.grossWeightKg(), weight.adjustedWeightValue(), weight.adjustedWeightUnit(), + weight.netWeightKg(), weight.recognitionStatus())) .toList(); var basis = new MerchantInboundBillingResponse.BillingBasis( fee.chargeMode(), fee.chargeUnit(), chargeableBasis(fee), fee.actualWeightKg(), diff --git a/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql b/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql index 11869f9a4..a2cd23498 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql @@ -77,7 +77,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', reason VARCHAR(500) NULL COMMENT '人工修正原因', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 3fc7fa9cb..7962a0d2f 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -42,7 +42,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货', current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照', total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数', - actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认后的实际总毛重kg', + actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '下游货物实际毛重kg,汇总已确认称重净重', chargeable_weight_kg DECIMAL(18,3) NULL COMMENT '当前费用快照的计费重量kg', actual_volume_cbm DECIMAL(18,4) NULL COMMENT '确认后的实际总体积m3', chargeable_volume_cbm DECIMAL(18,4) NULL COMMENT '当前费用快照的计费体积m3', @@ -270,6 +270,11 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', file_id BIGINT UNSIGNED NOT NULL COMMENT '称重图片文件ID', + pallet_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse托盘ID', + pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', + pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型快照', + pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘容量CBM快照', + pallet_tare_weight_kg DECIMAL(18,3) NOT NULL COMMENT '托盘皮重KG快照', ocr_provider VARCHAR(64) NULL COMMENT 'OCR服务商编码', ocr_raw_text VARCHAR(1000) NULL COMMENT 'OCR原始文本', ocr_weight_value DECIMAL(18,3) NULL COMMENT 'OCR识别原始重量值', @@ -277,7 +282,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( confidence DECIMAL(6,5) NULL COMMENT '识别置信度', adjusted_weight_value DECIMAL(18,3) NULL COMMENT '人工修正后的原单位重量', adjusted_weight_unit VARCHAR(8) NULL COMMENT '人工修正后的单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认并统一换算后的重量kg', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', recognition_status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '重量识别状态:PENDING待识别、PENDING_CONFIRM待确认、NEEDS_MANUAL需人工录入、CONFIRMED已确认', adjustment_reason VARCHAR(500) NULL COMMENT '人工修正原因', adjusted_by BIGINT UNSIGNED NULL COMMENT '修正人ID', @@ -293,8 +299,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( PRIMARY KEY (id), UNIQUE KEY uk_weight_inbound_file (tenant_id, inbound_id, file_id), KEY idx_weight_inbound_status (tenant_id, inbound_id, recognition_status), + KEY idx_weight_inbound_pallet (tenant_id, inbound_id, pallet_id), CONSTRAINT fk_weight_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='称重图片OCR原值、人工修正值和确认重量'; +) ENGINE=InnoDB COMMENT='称重图片、托盘皮重快照、OCR毛重和确认净重'; CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID', @@ -307,7 +314,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', reason VARCHAR(500) NULL COMMENT '人工修正原因', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', @@ -664,7 +672,6 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation ( 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_pallet (tenant_id, inbound_id, pallet_id), KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status), KEY idx_allocation_reservation (reservation_id), CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index b7054cf21..0b12768b7 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -542,13 +542,62 @@ paths: responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 无测量编辑权限} + /api/platform/v1/first-leg/inbounds/{inboundId}/weight-pallet-options: + get: + summary: 查询称重可选托盘快照 + description: >- + 需要入库测量查看或称重编辑权限。Firstleg 按入库单 warehouseId 查询 Warehouse 公共托盘快照; + 仅限定同仓且未删除,不校验托盘状态或占用资格,Admin 不需要 platform:pallet:list。 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': + description: 成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {type: array, items: {$ref: '#/components/schemas/WeightPalletSnapshot'}} + '403': {description: 无入库测量查看或称重编辑权限} + /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results: + post: + summary: 登记称重图片 OCR 毛重结果 + description: >- + 需要称重编辑权限。请求必须绑定 palletId;Firstleg 按入库单 warehouseId 读取 Warehouse + 权威快照并固化托盘编码、类型、容量和皮重,不信任前端托盘快照。 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CreateWeightResultRequest'} + responses: + '200': + description: 成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/InboundWeightResult'} + '403': {description: 无称重编辑权限} /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results/{resultId}/confirm: post: summary: 确认 OCR 重量 description: >- 需要 platform:firstleg:inbound:weight:confirm 权限;携带人工修改字段时还需要 platform:firstleg:inbound:weight:edit 权限。修改与确认在同一事务内完成,保留 OCR 原值, - 分别写入 CORRECT 和 CONFIRM 修订记录,并重算所有已确认图片的实际总毛重。 + 分别写入 CORRECT 和 CONFIRM 修订记录。输入表示含托盘毛重;服务端统一换算为 KG, + 使用创建时固化的托盘皮重计算 netWeightKg,且毛重必须大于皮重。 + 入库单 actualGrossWeightKg 明确保留为下游货物实际毛重字段,值汇总所有已确认 netWeightKg。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -559,14 +608,48 @@ paths: application/json: schema: {$ref: '#/components/schemas/ConfirmWeightResultRequest'} responses: - '200': {description: 成功或业务失败,按响应体 code 判断} + '200': + description: 成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/InboundWeightResult'} '403': {description: 无确认权限,或修改重量时无编辑权限} /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results/{resultId}: + put: + summary: 人工修正 OCR 毛重 + description: >- + 需要称重编辑权限。修正值仍表示含托盘毛重;保留 OCR 原值,已确认结果修正后立即移出净重汇总并回到待确认。 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: resultId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CorrectWeightResultRequest'} + responses: + '200': + description: 成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/InboundWeightResult'} + '403': {description: 无称重编辑权限} delete: summary: 删除称重图片及重量结果 description: >- 需要 platform:firstleg:inbound:weight:edit 权限;仅允许在可编辑的测量状态删除。 - 删除已确认图片后重新汇总剩余已确认图片的实际总毛重,删除最后一张时总毛重恢复为空。 + 删除已确认图片后重新汇总剩余已确认图片的净重,删除最后一张时 actualGrossWeightKg 恢复为空。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: version, in: query, required: true, schema: {type: integer, format: int32, minimum: 0}} @@ -986,10 +1069,65 @@ components: properties: configId: {type: string, pattern: '^[1-9][0-9]*$'} createdByThisInitialization: {type: boolean} + WeightPalletSnapshot: + type: object + required: [palletId, palletCode, palletType, capacityCbm, tareWeightKg] + properties: + palletId: {type: string, pattern: '^[1-9][0-9]*$'} + palletCode: {type: string} + palletType: {type: string} + capacityCbm: {type: number, format: double, minimum: 0.0001} + tareWeightKg: {type: number, format: double, minimum: 0, multipleOf: 0.001} + CreateWeightResultRequest: + type: object + required: [fileId, palletId] + properties: + fileId: {type: string, pattern: '^[1-9][0-9]*$'} + palletId: {type: string, pattern: '^[1-9][0-9]*$'} + ocrProvider: {type: string, maxLength: 64, nullable: true} + ocrRawText: {type: string, maxLength: 1000, nullable: true} + ocrWeightValue: {type: number, format: double, minimum: 0, nullable: true, description: OCR 原始含托盘毛重} + ocrWeightUnit: {type: string, maxLength: 8, nullable: true} + confidence: {type: number, format: double, minimum: 0, maximum: 1, nullable: true} + CorrectWeightResultRequest: + type: object + required: [version, adjustedWeightValue, adjustedWeightUnit, adjustmentReason] + properties: + version: {type: integer, minimum: 0} + adjustedWeightValue: {type: number, format: double, minimum: 0.001, description: 人工修正后的含托盘毛重} + adjustedWeightUnit: {type: string, enum: [KG, LB]} + adjustmentReason: {type: string, minLength: 1, maxLength: 500} + InboundWeightResult: + type: object + required: [id, fileId, palletId, palletCode, palletType, palletCapacityCbm, palletTareWeightKg, recognitionStatus, version] + properties: + id: {type: string, pattern: '^[1-9][0-9]*$'} + fileId: {type: string, pattern: '^[1-9][0-9]*$'} + palletId: {type: string, pattern: '^[1-9][0-9]*$'} + palletCode: {type: string} + palletType: {type: string} + palletCapacityCbm: {type: number, format: double, minimum: 0.0001} + palletTareWeightKg: {type: number, format: double, minimum: 0, multipleOf: 0.001} + ocrProvider: {type: string, nullable: true} + ocrRawText: {type: string, nullable: true} + ocrWeightValue: {type: number, format: double, nullable: true, description: OCR 原始含托盘毛重} + ocrWeightUnit: {type: string, nullable: true} + confidence: {type: number, format: double, nullable: true} + grossWeightKg: {type: number, format: double, nullable: true, multipleOf: 0.001, description: 确认时标准化的含托盘毛重 KG} + adjustedWeightValue: {type: number, format: double, nullable: true, description: 人工修正后的含托盘毛重} + adjustedWeightUnit: {type: string, nullable: true} + netWeightKg: {type: number, format: double, nullable: true, multipleOf: 0.001, description: grossWeightKg - palletTareWeightKg} + recognitionStatus: {type: string, enum: [PENDING, PENDING_CONFIRM, NEEDS_MANUAL, CONFIRMED]} + adjustmentReason: {type: string, nullable: true} + adjustedBy: {type: string, nullable: true} + adjustedAt: {type: string, format: date-time, nullable: true} + confirmedBy: {type: string, nullable: true} + confirmedAt: {type: string, format: date-time, nullable: true} + version: {type: integer, minimum: 0} ConfirmWeightResultRequest: type: object required: [version] - description: 人工修改字段必须同时提供;全部省略时直接确认现有 OCR 或已修正重量。 + description: 人工修改字段表示含托盘毛重且必须同时提供;全部省略时直接确认现有 OCR 或已修正毛重。 properties: version: {type: integer, minimum: 0} adjustedWeightValue: {type: number, format: double, minimum: 0.001, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/client/WarehousePalletLocationClientContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/client/WarehousePalletLocationClientContractTest.java new file mode 100644 index 000000000..1bcd9995c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/client/WarehousePalletLocationClientContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class WarehousePalletLocationClientContractTest { + @Test + void consumesTheWarehousePalletSnapshotContractExactly() throws Exception { + FeignClient client = WarehousePalletLocationClient.class.getAnnotation(FeignClient.class); + assertThat(client.path()).isEqualTo("/internal/warehouse/v1"); + + Method method = WarehousePalletLocationClient.class.getDeclaredMethod( + "palletSnapshots", String.class, long.class); + assertThat(method.getAnnotation(GetMapping.class).value()) + .containsExactly("/pallets/snapshots"); + assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).value()) + .isEqualTo("warehouseId"); + + var snapshot = new WarehousePalletLocationClient.PalletSnapshot( + 101L, "PLT-101", "STANDARD", new java.math.BigDecimal("2.0000"), + new java.math.BigDecimal("18.500")); + assertThat(snapshot).extracting( + WarehousePalletLocationClient.PalletSnapshot::palletId, + WarehousePalletLocationClient.PalletSnapshot::palletCode, + WarehousePalletLocationClient.PalletSnapshot::palletType, + WarehousePalletLocationClient.PalletSnapshot::capacityCbm, + WarehousePalletLocationClient.PalletSnapshot::tareWeightKg) + .containsExactly(101L, "PLT-101", "STANDARD", + new java.math.BigDecimal("2.0000"), new java.math.BigDecimal("18.500")); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java index 84a4d6774..2a1d81c82 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java @@ -17,6 +17,10 @@ class PlatformInboundMeasurementControllerContractTest { void everyMeasurementOperationUsesDedicatedButtonPermission() throws Exception { assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:measure:view')", AuthenticatedUser.class, long.class, long.class); + assertAuthority("weightPalletOptions", + "hasAnyAuthority('platform:firstleg:inbound:measure:view'," + + " 'platform:firstleg:inbound:weight:edit')", + AuthenticatedUser.class, long.class, long.class); assertAuthority("saveDraft", "hasAuthority('platform:firstleg:inbound:measure:edit')", AuthenticatedUser.class, long.class, long.class, InboundMeasurementRequests.SaveDraftRequest.class); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/dto/CreateWeightResultRequestValidationTest.java b/firstleg/src/test/java/com/baiyue/firstleg/dto/CreateWeightResultRequestValidationTest.java new file mode 100644 index 000000000..5937a6f8c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/dto/CreateWeightResultRequestValidationTest.java @@ -0,0 +1,34 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +class CreateWeightResultRequestValidationTest { + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + void requiresAValidFileAndPalletBinding() { + var valid = request(120L, 501L); + var missingPallet = request(120L, null); + var invalidPallet = request(120L, 0L); + + assertThat(validator.validate(valid)).isEmpty(); + assertThat(validator.validate(missingPallet)) + .extracting(violation -> violation.getPropertyPath().toString()) + .contains("palletId"); + assertThat(validator.validate(invalidPallet)) + .extracting(violation -> violation.getPropertyPath().toString()) + .contains("palletId"); + } + + private InboundMeasurementRequests.CreateWeightResultRequest request(Long fileId, Long palletId) { + return new InboundMeasurementRequests.CreateWeightResultRequest( + fileId, palletId, "FILE_WEIGHT_OCR", "30.000 KG", new BigDecimal("30.000"), + "KG", new BigDecimal("0.99000")); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java index 637251088..3d06d0412 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java @@ -63,4 +63,26 @@ class InboundWorkflowSqlContractTest { .contains("uk_measure_box_code") .contains("单箱当前测量字段未完整创建"); } + + @Test + @DisplayName("称重结果固化托盘快照、毛重和净重并允许托盘主数据复用") + void shouldPersistWeightPalletSnapshotsAndNetWeight() throws Exception { + String schema = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); + + assertThat(schema) + .contains("pallet_tare_weight_kg DECIMAL(18,3) NOT NULL") + .contains("gross_weight_kg DECIMAL(18,3) NULL") + .contains("net_weight_kg DECIMAL(18,3) NULL") + .contains("idx_weight_inbound_pallet") + .doesNotContain("final_weight_kg") + .doesNotContain("uk_pallet_allocation_pallet"); + assertThat(unified) + .contains("CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_id'") + .contains("ALTER TABLE fl_inbound_weight_result DROP COLUMN final_weight_kg") + .contains("ALTER TABLE fl_inbound_pallet_allocation DROP INDEX uk_pallet_allocation_pallet") + .contains("称重托盘快照或毛重净重字段未正确收敛") + .contains("同票托盘唯一约束仍阻止托盘主数据复用"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java index 189441602..3ab81dcce 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java @@ -10,6 +10,7 @@ import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; import com.baiyue.firstleg.repository.InboundMeasurementRepository; import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; import org.junit.jupiter.api.BeforeEach; @@ -248,7 +249,7 @@ class CartonMeasurementServiceTest { } @Test - void confirmedPackageCountSynchronizesActualWeightAndVolumeForPricingAfterSubmission() { + void confirmedPackageCountUsesCartonWeightAsFallbackWhenNoConfirmedImageExists() { InboundOrderEntity inbound = inbound("MEASURE_PENDING"); InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2); measurement.setTotalPackageCount(1); @@ -266,6 +267,7 @@ class CartonMeasurementServiceTest { when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement); when(repository.updateItem(item)).thenReturn(true); when(repository.listItems(10001L, 30001L)).thenReturn(List.of(item)); + when(repository.listWeights(10001L, 70001L)).thenReturn(List.of()); when(repository.updateMeasurement(measurement)).thenReturn(true); when(repository.updateInbound(inbound)).thenReturn(true); @@ -278,6 +280,41 @@ class CartonMeasurementServiceTest { verify(repository).updateInbound(inbound); } + @Test + void cartonMeasurementAfterConfirmedWeightKeepsNetWeightAndUpdatesVolume() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + inbound.setActualGrossWeightKg(new BigDecimal("11.500")); + InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2); + measurement.setTotalPackageCount(1); + measurement.setTotalVolumeCbm(new BigDecimal("0.0600")); + measurement.setVolumeWeightKg(new BigDecimal("10.020")); + InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L); + item.setUnitWeightKg(new BigDecimal("10.880")); + item.setOuterLengthCm(new BigDecimal("50")); + item.setOuterWidthCm(new BigDecimal("40")); + item.setOuterHeightCm(new BigDecimal("30")); + item.setUnitVolumeCbm(new BigDecimal("0.1496")); + item.setCalculationRuleVersion("ACTUAL_DIMENSION_V1"); + InboundWeightResultEntity confirmedWeight = new InboundWeightResultEntity(); + confirmedWeight.setRecognitionStatus("CONFIRMED"); + confirmedWeight.setNetWeightKg(new BigDecimal("11.500")); + when(repository.findInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findItem(10001L, 40001L)).thenReturn(item); + when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement); + when(repository.updateItem(item)).thenReturn(true); + when(repository.listItems(10001L, 30001L)).thenReturn(List.of(item)); + when(repository.listWeights(10001L, 70001L)).thenReturn(List.of(confirmedWeight)); + when(repository.updateMeasurement(measurement)).thenReturn(true); + when(repository.updateInbound(inbound)).thenReturn(true); + + service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5); + + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("11.500"); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.7480"); + assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("124.916"); + verify(repository).updateInbound(inbound); + } + private CartonMeasurementCommand command() { return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("59.100"), new BigDecimal("40.200"), new BigDecimal("30.300"), new BigDecimal("12.500"), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index 009fab68a..d3f975717 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.client.MeasurementImageClient; +import com.baiyue.firstleg.client.WarehousePalletLocationClient; import com.baiyue.firstleg.dto.InboundMeasurementRequests; import com.baiyue.firstleg.dto.MeasurementBatchCommand; import com.baiyue.firstleg.dto.InboundMeasurementResponses; @@ -49,8 +50,15 @@ class InboundMeasurementServiceTest { @MockBean private MeasurementBatchService batchService; @MockBean private OrderCargoClient orderCargoClient; @MockBean private MeasurementImageClient imageClient; + @MockBean private WarehousePalletLocationClient warehousePallets; @Resource private InboundMeasurementService service; + @BeforeEach + void stubWarehousePalletSnapshots() { + when(warehousePallets.palletSnapshots("internal-token", 100L)).thenReturn( + ApiResponse.success(List.of(palletSnapshot()))); + } + @Test void customerCannotReadAnotherCustomersMeasurement() { assertThatThrownBy(() -> service.detail(2L, 10L)) @@ -90,7 +98,8 @@ class InboundMeasurementServiceTest { InboundOrderEntity inbound = inbound("MEASURING"); inbound.setActualGrossWeightKg(new BigDecimal("12.500")); InboundWeightResultEntity weight = weight(20L, "12.500", "KG", "CONFIRMED", 4); - weight.setFinalWeightKg(new BigDecimal("12.500")); + weight.setGrossWeightKg(new BigDecimal("12.500")); + weight.setNetWeightKg(new BigDecimal("12.500")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); when(repository.updateWeight(weight)).thenReturn(true); @@ -102,7 +111,8 @@ class InboundMeasurementServiceTest { 4, new BigDecimal("13.200"), "KG", "秤面显示修正")); assertThat(weight.getRecognitionStatus()).isEqualTo("PENDING_CONFIRM"); - assertThat(weight.getFinalWeightKg()).isNull(); + assertThat(weight.getGrossWeightKg()).isNull(); + assertThat(weight.getNetWeightKg()).isNull(); assertThat(inbound.getActualGrossWeightKg()).isNull(); verify(repository).updateInboundWeightSummary(inbound); } @@ -134,22 +144,55 @@ class InboundMeasurementServiceTest { InboundMeasurementResponses.WeightResult result = service.createWeightResult( 1L, 10L, 9L, new InboundMeasurementRequests.CreateWeightResultRequest( - 120L, "FILE_WEIGHT_OCR", "285.5", new BigDecimal("285.500"), "KG", + 120L, 501L, "FILE_WEIGHT_OCR", "285.5", new BigDecimal("285.500"), "KG", new BigDecimal("0.5097191334"))); ArgumentCaptor saved = ArgumentCaptor.forClass(InboundWeightResultEntity.class); verify(repository).insertWeight(saved.capture()); assertThat(saved.getValue().getConfidence()).isEqualByComparingTo("0.50972"); + assertThat(saved.getValue().getPalletId()).isEqualTo(501L); + assertThat(saved.getValue().getPalletCode()).isEqualTo("PLT-501"); + assertThat(saved.getValue().getPalletTareWeightKg()).isEqualByComparingTo("18.500"); assertThat(result.confidence()).isEqualByComparingTo("0.50972"); } + @Test + void weightPalletOptionsUsesInboundWarehouseWithoutCustomerScope() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + + List result = + service.weightPalletOptions(1L, 10L); + + assertThat(result).singleElement().satisfies(snapshot -> { + assertThat(snapshot.palletId()).isEqualTo(501L); + assertThat(snapshot.tareWeightKg()).isEqualByComparingTo("18.500"); + }); + verify(warehousePallets).palletSnapshots("internal-token", 100L); + } + + @Test + void creatingWeightResultRejectsPalletOutsideTheInboundWarehouse() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + + assertThatThrownBy(() -> service.createWeightResult(1L, 10L, 9L, + new InboundMeasurementRequests.CreateWeightResultRequest( + 120L, 999L, "FILE_WEIGHT_OCR", "20.0 KG", new BigDecimal("20.000"), + "KG", new BigDecimal("0.99")))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不属于当前入库仓库"); + + verify(repository, never()).insertWeight(any()); + } + @Test void confirmingLbWeightConvertsToKgAndRecalculatesOnlyConfirmedImages() { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); InboundWeightResultEntity current = weight(20L, "10.000", "LB", "PENDING_CONFIRM", 0); InboundWeightResultEntity previous = weight(21L, "2.000", "KG", "CONFIRMED", 1); - previous.setFinalWeightKg(new BigDecimal("2.000")); + previous.setNetWeightKg(new BigDecimal("2.000")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(current); when(repository.updateWeight(current)).thenReturn(true); @@ -160,12 +203,58 @@ class InboundMeasurementServiceTest { 1L, 10L, 20L, 9L, "操作员", new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); - assertThat(response.finalWeightKg()).isEqualByComparingTo("4.536"); + assertThat(response.grossWeightKg()).isEqualByComparingTo("4.536"); + assertThat(response.netWeightKg()).isEqualByComparingTo("4.536"); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("6.536"); assertThat(inbound.getProcessStatus()).isEqualTo("MEASURING"); verify(repository).insertRevision(any(InboundWeightRevisionEntity.class)); } + @Test + void confirmingWeightAfterCartonMeasurementReplacesFallbackWithNetWeight() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + inbound.setActualGrossWeightKg(new BigDecimal("54.400")); + InboundWeightResultEntity weight = weight(20L, "30.000", "KG", "PENDING_CONFIRM", 0); + weight.setPalletTareWeightKg(new BigDecimal("18.500")); + 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(repository.updateInboundWeightSummary(inbound)).thenReturn(true); + + InboundMeasurementResponses.WeightResult response = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); + + assertThat(response.grossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(response.netWeightKg()).isEqualByComparingTo("11.500"); + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("11.500"); + ArgumentCaptor revision = + ArgumentCaptor.forClass(InboundWeightRevisionEntity.class); + verify(repository).insertRevision(revision.capture()); + assertThat(revision.getValue().getGrossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(revision.getValue().getNetWeightKg()).isEqualByComparingTo("11.500"); + } + + @Test + void confirmingWeightRejectsGrossWeightNotAboveFrozenTare() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "18.500", "KG", "PENDING_CONFIRM", 0); + weight.setPalletTareWeightKg(new BigDecimal("18.500")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + + assertThatThrownBy(() -> service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("毛重必须大于托盘皮重"); + + verify(repository, never()).updateWeight(any()); + verify(repository, never()).insertRevision(any()); + verify(repository, never()).updateInboundWeightSummary(any()); + } + @ParameterizedTest @CsvSource({ "kg,2.000", @@ -190,7 +279,8 @@ class InboundMeasurementServiceTest { assertThat(response.adjustedWeightUnit()).isEqualTo( unit.trim().toUpperCase().startsWith("LB") ? "LB" : "KG"); - assertThat(response.finalWeightKg()).isEqualByComparingTo(expectedKg); + assertThat(response.grossWeightKg()).isEqualByComparingTo(expectedKg); + assertThat(response.netWeightKg()).isEqualByComparingTo(expectedKg); assertThat(response.version()).isEqualTo(2); } @@ -208,7 +298,7 @@ class InboundMeasurementServiceTest { 1L, 10L, 20L, 9L, "操作员", new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); - assertThat(response.finalWeightKg()).isEqualByComparingTo("0.001"); + assertThat(response.netWeightKg()).isEqualByComparingTo("0.001"); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("0.001"); } @@ -283,7 +373,8 @@ class InboundMeasurementServiceTest { 0, new BigDecimal("286.125"), "kg", "OCR 原值 285.500 KG,人工确认 286.125 KG")); assertThat(response.adjustedWeightValue()).isEqualByComparingTo("286.125"); - assertThat(response.finalWeightKg()).isEqualByComparingTo("286.125"); + assertThat(response.grossWeightKg()).isEqualByComparingTo("286.125"); + assertThat(response.netWeightKg()).isEqualByComparingTo("286.125"); assertThat(response.recognitionStatus()).isEqualTo("CONFIRMED"); assertThat(response.version()).isEqualTo(2); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("286.125"); @@ -344,7 +435,8 @@ class InboundMeasurementServiceTest { void confirmingAnAlreadyConfirmedWeightWithCurrentVersionIsIdempotent() { InboundOrderEntity inbound = inbound("MEASURING"); InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2); - weight.setFinalWeightKg(new BigDecimal("285.500")); + weight.setGrossWeightKg(new BigDecimal("285.500")); + weight.setNetWeightKg(new BigDecimal("285.500")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); @@ -363,7 +455,8 @@ class InboundMeasurementServiceTest { void confirmingWithStaleVersionDoesNotTreatRequestAsIdempotent() { InboundOrderEntity inbound = inbound("MEASURING"); InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2); - weight.setFinalWeightKg(new BigDecimal("285.500")); + weight.setGrossWeightKg(new BigDecimal("285.500")); + weight.setNetWeightKg(new BigDecimal("285.500")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); @@ -420,7 +513,8 @@ class InboundMeasurementServiceTest { InboundOrderEntity inbound = inbound("MEASURING"); inbound.setActualGrossWeightKg(new BigDecimal("285.500")); InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2); - weight.setFinalWeightKg(new BigDecimal("285.500")); + weight.setGrossWeightKg(new BigDecimal("285.500")); + weight.setNetWeightKg(new BigDecimal("285.500")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); when(repository.deleteWeight(1L, 10L, 20L, 2)).thenReturn(true); @@ -440,9 +534,9 @@ class InboundMeasurementServiceTest { InboundOrderEntity inbound = inbound("MEASURING"); inbound.setActualGrossWeightKg(new BigDecimal("15.000")); InboundWeightResultEntity deleted = weight(20L, "10.000", "KG", "CONFIRMED", 2); - deleted.setFinalWeightKg(new BigDecimal("10.000")); + deleted.setNetWeightKg(new BigDecimal("10.000")); InboundWeightResultEntity remaining = weight(21L, "5.000", "KG", "CONFIRMED", 1); - remaining.setFinalWeightKg(new BigDecimal("5.000")); + remaining.setNetWeightKg(new BigDecimal("5.000")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(deleted); when(repository.deleteWeight(1L, 10L, 20L, 2)).thenReturn(true); @@ -488,7 +582,7 @@ class InboundMeasurementServiceTest { item.setPackageCount(3); item.setSourceImageFileId(500L); InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1); - weight.setFinalWeightKg(new BigDecimal("20.000")); + weight.setNetWeightKg(new BigDecimal("20.000")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement); when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); @@ -592,6 +686,7 @@ class InboundMeasurementServiceTest { InboundOrderEntity inbound = new InboundOrderEntity(); inbound.setId(10L); inbound.setTenantId(1L); + inbound.setWarehouseId(100L); inbound.setProcessStatus(status); inbound.setTotalPackageCount(3); inbound.setVersion(0); @@ -605,10 +700,21 @@ class InboundMeasurementServiceTest { weight.setTenantId(1L); weight.setInboundId(10L); weight.setFileId(id + 100L); + weight.setPalletId(501L); + weight.setPalletCode("PLT-501"); + weight.setPalletType("STANDARD"); + weight.setPalletCapacityCbm(new BigDecimal("2.0000")); + weight.setPalletTareWeightKg(BigDecimal.ZERO.setScale(3)); weight.setOcrWeightValue(new BigDecimal(value)); weight.setOcrWeightUnit(unit); weight.setRecognitionStatus(status); weight.setVersion(version); return weight; } + + private WarehousePalletLocationClient.PalletSnapshot palletSnapshot() { + return new WarehousePalletLocationClient.PalletSnapshot( + 501L, "PLT-501", "STANDARD", new BigDecimal("2.0000"), + new BigDecimal("18.500")); + } } diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index d26e917c2..d20a1b119 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -2,7 +2,7 @@ ## 职责与边界 -- 负责国内收货仓、地址/能力、往来单位、快递公司、平台公共供应商、国内整柜库存/出入库,以及托盘、库位、预占和占用。 +- 负责国内收货仓、地址/能力、往来单位、快递公司、平台公共供应商、国内整柜库存/出入库,以及托盘主数据、库位预占和占用。 - 入库单、测量和入库状态归 `firstleg/`;订单只通过内部接口校验并保存快照。 - 公共资源统一 `tenant_id=0`,不得绑定客户或要求客户筛选。 @@ -28,10 +28,11 @@ ## 三期托盘、库位与 A0 规则 -- 普通库位保持一托一位;A0 是系统创建的共享临时溢出位,可被多个托盘占用,但每个托盘占用仍必须通过 `reservation_id`、`occupancy_id` 独立识别。 +- 托盘是可复用标识和称重主数据,不参与 `AVAILABLE → RESERVED → OCCUPIED → AVAILABLE`;同一托盘主数据可被多条分配复用。 +- 普通库位保持单活动预占/占用;A0 是系统创建的共享临时溢出位,可承载多条占用,但每条库位占用仍必须通过 `reservation_id`、`occupancy_id` 独立识别。 - 头程 PDA 结算接口 `/internal/warehouse/v1/pallet-locations/settlements` 是释放、子单转移和全部释放后重新分配的原子边界。不得将其拆成多个可独立提交的远程操作。 - 部分拣货的资源操作必须按 `reservationId` 定位,不能按 `locationId` 定位;A0 多托盘共享时,按 `locationId` 会误释放或合并资源。 -- 当父单全部资源释放但子单仍有剩余数量时,在同一仓库事务内优先分配自然 A-Z 顺序的普通空闲库位;普通库位不足时使用 A0,并保持托盘状态、预占、占用和子单归属一致。 +- 当父单全部库位释放但子单仍有剩余数量时,在同一仓库事务内优先分配自然 A-Z 顺序的普通空闲库位;普通库位不足时使用 A0,并保持预占、占用和子单归属一致。 - A0 换绑入口包括内部接口和平台接口:平台人员使用 `/api/platform/v1/locations/temporary-occupancies/{occupancyId}/rebind`,需要 `platform:location:edit`。目标必须为同仓 `AVAILABLE` 的普通库位;A0 不允许人工编辑、停用或删除。 - 资源结算、释放、转移和换绑必须保存可重放的幂等记录或具有等价的状态幂等语义,并在测试中覆盖并发状态变更。 diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletController.java new file mode 100644 index 000000000..921086bec --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletController.java @@ -0,0 +1,67 @@ +package com.baiyue.warehouse.controller; + +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.PalletRequests; +import com.baiyue.warehouse.service.PalletApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.Min; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; + +/** 内部托盘主数据只读接口。 */ +@Validated +@RestController +@RequestMapping("/internal/warehouse/v1/pallets") +@Tag(name = "托盘内部接口", description = "内部服务令牌保护的托盘主数据快照") +public class InternalPalletController { + @Resource + private PalletApplicationService service; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; + private byte[] expectedToken; + + @PostConstruct + void initializeToken() { + expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); + } + + @GetMapping("/{palletId}/snapshot") + @Operation(summary = "查询托盘只读快照", description = "固定按平台公共作用域校验仓库归属,不返回动态状态或可用性") + public ApiResponse snapshot( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable @Min(1) long palletId, @RequestParam @Min(1) long warehouseId) { + verify(token); + return ApiResponse.success(service.snapshot(0L, warehouseId, palletId)); + } + + @GetMapping("/snapshots") + @Operation(summary = "查询仓库托盘快照列表", description = "返回该仓全部未删除托盘主数据,不按动态状态或占用过滤") + public ApiResponse> snapshots( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestParam @Min(1) long warehouseId) { + verify(token); + return ApiResponse.success(service.snapshots(0L, warehouseId)); + } + + private void verify(String token) { + byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(expectedToken, supplied)) { + throw new BizException(ResultCode.FORBIDDEN, "无效的内部服务凭据"); + } + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java index c50811308..84ce49aed 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java @@ -41,7 +41,7 @@ public class InternalPalletLocationController { } @PostMapping("/reservations") - @Operation(summary = "拆分托盘并预占一托一位") + @Operation(summary = "按托盘主数据拆分并预占库位") public ApiResponse reserve(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @Valid @RequestBody PalletAllocationDtos.ReserveRequest request) { verify(token); @@ -49,7 +49,7 @@ public class InternalPalletLocationController { } @PostMapping("/confirm") - @Operation(summary = "确认托盘和库位占用", description = "只确认仓库资源,不推进头程入库单状态") + @Operation(summary = "确认库位占用", description = "托盘只保存主数据快照;本接口不推进头程入库单状态") public ApiResponse confirm(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @Valid @RequestBody PalletAllocationDtos.ConfirmRequest request) { verify(token); @@ -111,7 +111,7 @@ public class InternalPalletLocationController { } @PostMapping("/transfer") - @Operation(summary = "将未拣货库位转给子入库单", description = "仅更新业务归属,托盘和库位继续保持已占用") + @Operation(summary = "将未拣货库位转给子入库单", description = "仅更新库位业务归属,托盘主数据不变") public ApiResponse> transfer( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @Valid @RequestBody PalletAllocationDtos.TransferRequest request) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java index 41970d73a..2296589e1 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java @@ -89,7 +89,7 @@ public class PlatformPalletController { @DeleteMapping("/{id}") @PreAuthorize("hasAuthority('platform:pallet:delete')") - @Operation(summary = "删除托盘", description = "仅可删除未预占、未占用的托盘") + @Operation(summary = "删除托盘", description = "托盘是可复用主数据,删除不受库位预占或占用状态限制") public ApiResponse delete(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Min(0) long tenantId, @RequestParam @Min(1) long warehouseId, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java index 65397b801..bdc93b4e5 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java @@ -74,4 +74,14 @@ public final class PalletRequests { LocalDateTime updatedAt ) { } + + @Schema(name = "InternalPalletSnapshot", description = "内部只读托盘主数据快照;不包含动态资源状态") + public record Snapshot( + @Schema(description = "托盘ID") long palletId, + @Schema(description = "托盘编码") String palletCode, + @Schema(description = "托盘类型") String palletType, + @Schema(description = "容量,单位m³") BigDecimal capacityCbm, + @Schema(description = "皮重,单位kg") BigDecimal tareWeightKg + ) { + } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/PalletEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/PalletEntity.java index 1bfb73b41..08c7bc22b 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/entity/PalletEntity.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/PalletEntity.java @@ -10,9 +10,7 @@ import lombok.Setter; import java.math.BigDecimal; -/** - * 托盘主数据实体。状态仅供内部预占、占用流程使用,不属于平台维护字段。 - */ +/** 托盘可复用主数据实体,不承载预占或占用状态。 */ @Getter @Setter @TableName("wh_pallet") @@ -37,15 +35,12 @@ public class PalletEntity extends BaseEntity { @Schema(description = "托盘容积,单位m³", example = "1.200000") private BigDecimal capacityCbm; - @Schema(description = "托盘重量,单位kg", example = "8.000") + @Schema(description = "托盘皮重,单位kg", example = "8.000") private BigDecimal weightKg; @Schema(description = "托盘颜色", example = "黄色") private String color; - @Schema(description = "资源状态", allowableValues = {"AVAILABLE", "RESERVED", "OCCUPIED", "DISABLED"}) - private String status; - @Schema(description = "备注", example = "标准川字托盘") private String remark; } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletMapper.java index 9497d69b8..588a75b4c 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletMapper.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletMapper.java @@ -12,4 +12,13 @@ public interface PalletMapper extends BaseMapper { /** 编码序列必须包含逻辑删除记录,避免历史编码被复用。 */ @Select("SELECT pallet_code FROM wh_pallet WHERE tenant_id = #{tenantId} AND pallet_code LIKE 'TP-%'") List selectAllCodes(@Param("tenantId") long tenantId); + + /** 既有库位分配需在主数据逻辑删除后继续读取其不可变属性。 */ + @Select(""" + SELECT * FROM wh_pallet + WHERE tenant_id = #{tenantId} AND warehouse_id = #{warehouseId} AND id = #{id} + """) + PalletEntity selectIncludingDeleted(@Param("tenantId") long tenantId, + @Param("warehouseId") long warehouseId, + @Param("id") long id); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java index d34bf08de..9f44088dd 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java @@ -11,12 +11,11 @@ import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; -import java.util.Collection; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; -/** 托盘持久化仓储;平台分页支持跨租户筛选,资源操作始终包含租户和仓库边界。 */ +/** 托盘主数据持久化仓储;平台分页支持跨作用域筛选。 */ @Repository public class PalletRepository { private static final Pattern PALLET_CODE = Pattern.compile("TP-(\\d+)"); @@ -43,6 +42,18 @@ public class PalletRepository { .eq(PalletEntity::getId, id)); } + public PalletEntity findOwnedIncludingDeleted(long tenantId, long warehouseId, long id) { + return mapper.selectIncludingDeleted(tenantId, warehouseId, id); + } + + public List findAllByWarehouse(long tenantId, long warehouseId) { + return mapper.selectList(new LambdaQueryWrapper() + .eq(PalletEntity::getTenantId, tenantId) + .eq(PalletEntity::getWarehouseId, warehouseId) + .orderByAsc(PalletEntity::getPalletCode) + .orderByAsc(PalletEntity::getId)); + } + public boolean existsCode(long tenantId, long warehouseId, String code, Long excludedId) { return mapper.selectCount(new LambdaQueryWrapper() .eq(PalletEntity::getTenantId, tenantId) @@ -51,16 +62,14 @@ public class PalletRepository { .ne(excludedId != null, PalletEntity::getId, excludedId)) > 0; } - public List findAvailable(long tenantId, long warehouseId, String palletType, - java.math.BigDecimal capacityCbm, int limit) { + public List findReusable(long tenantId, long warehouseId, String palletType, + java.math.BigDecimal capacityCbm) { return mapper.selectList(new LambdaQueryWrapper() .eq(PalletEntity::getTenantId, tenantId) .eq(PalletEntity::getWarehouseId, warehouseId) - .eq(PalletEntity::getStatus, "AVAILABLE") .eq(StringUtils.hasText(palletType), PalletEntity::getPalletType, palletType) .eq(capacityCbm != null, PalletEntity::getCapacityCbm, capacityCbm) - .orderByAsc(PalletEntity::getPalletCode) - .last("LIMIT " + limit)); + .orderByAsc(PalletEntity::getPalletCode)); } /** @@ -92,19 +101,7 @@ public class PalletRepository { .eq(PalletEntity::getWarehouseId, warehouseId) .eq(PalletEntity::getId, id) .eq(PalletEntity::getVersion, version) - .in(PalletEntity::getStatus, List.of("AVAILABLE", "DISABLED")) .set(PalletEntity::getDeleted, true) .set(PalletEntity::getVersion, version + 1)); } - - public int transitionStatus(long tenantId, long warehouseId, long id, - Collection expectedStatuses, String targetStatus) { - return mapper.update(null, new LambdaUpdateWrapper() - .eq(PalletEntity::getTenantId, tenantId) - .eq(PalletEntity::getWarehouseId, warehouseId) - .eq(PalletEntity::getId, id) - .in(PalletEntity::getStatus, expectedStatuses) - .set(PalletEntity::getStatus, targetStatus) - .setSql("version = version + 1")); - } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java index 7d5fff487..2ac4c7cec 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java @@ -13,6 +13,8 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + /** 托盘主数据应用服务。 */ @Service public class PalletApplicationService { @@ -41,7 +43,6 @@ public class PalletApplicationService { entity.setTenantId(tenantId); entity.setWarehouseId(request.warehouseId()); copy(entity, request); - entity.setStatus("AVAILABLE"); String code = repository.nextCode(tenantId); for (int attempt = 0; attempt < CODE_INSERT_ATTEMPTS; attempt++) { entity.setPalletCode(code); @@ -78,7 +79,7 @@ public class PalletApplicationService { public void delete(long tenantId, long warehouseId, long id) { PalletEntity entity = owned(tenantId, warehouseId, id); if (repository.logicalDelete(tenantId, warehouseId, id, entity.getVersion()) != 1) { - throw new BizException(ResultCode.CONFLICT, "预占、占用或已被并发修改的托盘不能删除"); + throw new BizException(ResultCode.CONFLICT, "托盘已被并发修改,请刷新后重试"); } } @@ -122,4 +123,18 @@ public class PalletApplicationService { entity.getColor(), entity.getRemark(), entity.getCreatedAt(), entity.getUpdatedAt()); } + + public PalletRequests.Snapshot snapshot(long tenantId, long warehouseId, long palletId) { + PalletEntity entity = owned(tenantId, warehouseId, palletId); + return new PalletRequests.Snapshot(entity.getId(), entity.getPalletCode(), entity.getPalletType(), + entity.getCapacityCbm(), entity.getWeightKg()); + } + + public List snapshots(long tenantId, long warehouseId) { + requireWarehouse(tenantId, warehouseId, false); + return repository.findAllByWarehouse(tenantId, warehouseId).stream() + .map(entity -> new PalletRequests.Snapshot(entity.getId(), entity.getPalletCode(), entity.getPalletType(), + entity.getCapacityCbm(), entity.getWeightKg())) + .toList(); + } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java index 2cf48e72c..e8133e04c 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java @@ -28,7 +28,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; /** - * 托盘拆分和一托一位编排。库位资源操作全部委托给任务6的 {@link LocationResourceService}。 + * 托盘主数据复用和库位分配编排。库位资源操作全部委托给 {@link LocationResourceService}。 */ @Service public class PalletLocationAllocationService { @@ -54,7 +54,7 @@ public class PalletLocationAllocationService { private ObjectMapper objectMapper; @Transactional - @BusinessAudit(event = "PALLET_LOCATION_RESERVE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "预占托盘和库位", captureBefore = false) + @BusinessAudit(event = "PALLET_LOCATION_RESERVE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "复用托盘主数据并预占库位", captureBefore = false) public PalletAllocationDtos.BatchResult reserve(PalletAllocationDtos.ReserveRequest request) { if (!Boolean.TRUE.equals(request.measurementCompleted())) { throw new BizException(ResultCode.CONFLICT, "只能使用已完成测量的总体积拆分托盘并占用库位"); @@ -67,19 +67,15 @@ public class PalletLocationAllocationService { String palletType = org.springframework.util.StringUtils.hasText(request.palletType()) ? request.palletType() : DEFAULT_PALLET_TYPE; - List pallets = palletRepository.findAvailable(request.tenantId(), request.warehouseId(), - palletType, request.palletCapacityCbm(), split.palletCount()); - if (pallets.size() != split.palletCount()) { - throw new BizException(ResultCode.CONFLICT, "可用托盘数量不足"); + List pallets = palletRepository.findReusable(request.tenantId(), request.warehouseId(), + palletType, request.palletCapacityCbm()); + if (pallets.isEmpty()) { + throw new BizException(ResultCode.NOT_FOUND, "未配置匹配类型和容量的托盘主数据"); } - List targets = new ArrayList<>(pallets.size()); - for (int index = 0; index < pallets.size(); index++) { - PalletEntity pallet = pallets.get(index); - if (palletRepository.transitionStatus(request.tenantId(), request.warehouseId(), pallet.getId(), - List.of("AVAILABLE"), "RESERVED") != 1) { - throw new BizException(ResultCode.CONFLICT, "托盘被并发预占,请重试"); - } + List targets = new ArrayList<>(split.palletCount()); + for (int index = 0; index < split.palletCount(); index++) { + PalletEntity pallet = pallets.get(index % pallets.size()); targets.add(new LocationDtos.ReservationTarget(pallet.getId(), pallet.getPalletCode(), request.idempotencyKey() + ":" + (index + 1))); } @@ -101,7 +97,7 @@ public class PalletLocationAllocationService { } @Transactional - @BusinessAudit(event = "PALLET_LOCATION_CONFIRM", resourceType = "INBOUND", resourceId = "#request.businessId", description = "确认托盘和库位占用") + @BusinessAudit(event = "PALLET_LOCATION_CONFIRM", resourceType = "INBOUND", resourceId = "#request.businessId", description = "确认库位占用") public PalletAllocationDtos.BatchResult confirm(PalletAllocationDtos.ConfirmRequest request) { var split = splitter.split(request.totalVolumeCbm(), request.palletCapacityCbm()); List active = active(request.tenantId(), request.sourceService(), request.businessId()); @@ -123,13 +119,6 @@ public class PalletLocationAllocationService { List occupied = locationResourceService.confirmOccupied( new LocationDtos.ConfirmCommand(request.tenantId(), request.warehouseId(), request.sourceService(), request.businessId(), request.operatorId(), targets)); - for (LocationDtos.ActiveResource resource : active) { - if ("RESERVED".equals(resource.state()) - && palletRepository.transitionStatus(request.tenantId(), request.warehouseId(), resource.palletId(), - List.of("RESERVED"), "OCCUPIED") != 1) { - throw new BizException(ResultCode.CONFLICT, "托盘确认占用发生并发冲突"); - } - } List results = new ArrayList<>(occupied.size()); for (int index = 0; index < occupied.size(); index++) { var item = occupied.get(index); @@ -141,7 +130,7 @@ public class PalletLocationAllocationService { } @Transactional - @BusinessAudit(event = "PALLET_LOCATION_RELEASE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "补偿释放托盘和库位预占") + @BusinessAudit(event = "PALLET_LOCATION_RELEASE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "补偿释放库位预占") public List release(PalletAllocationDtos.ReleaseRequest request) { List active = active(request.tenantId(), request.sourceService(), request.businessId()); if (active.stream().anyMatch(item -> "OCCUPIED".equals(item.state()))) { @@ -150,12 +139,6 @@ public class PalletLocationAllocationService { List released = locationResourceService.release( new LocationDtos.ReleaseCommand(request.tenantId(), request.sourceService(), request.businessId(), request.operatorId(), request.reason())); - for (LocationDtos.ActiveResource resource : released) { - if (palletRepository.transitionStatus(request.tenantId(), resource.warehouseId(), resource.palletId(), - List.of("RESERVED"), "AVAILABLE") != 1) { - throw new BizException(ResultCode.CONFLICT, "托盘释放发生并发冲突"); - } - } return released; } @@ -187,10 +170,6 @@ public class PalletLocationAllocationService { request.operatorId(), request.reason(), request.locationIds())); List result = new ArrayList<>(released.size()); for (LocationDtos.ExactSelectiveReleaseResult resource : released) { - if (palletRepository.transitionStatus(request.tenantId(), resource.warehouseId(), resource.palletId(), - List.of("OCCUPIED"), "AVAILABLE") != 1) { - throw new BizException(ResultCode.CONFLICT, "Pallet changed concurrently during selected release"); - } result.add(new PalletAllocationDtos.SelectiveReleaseResult(resource.reservationId(), resource.occupancyId(), resource.allocationNo(), resource.palletId(), resource.locationId(), resource.locationCode(), resource.allocatedPackageCount())); @@ -269,10 +248,6 @@ public class PalletLocationAllocationService { request.sourceBusinessId(), request.operatorId(), settlementReason(request), request.releasedReservationIds())); List result = new ArrayList<>(released.size()); for (LocationDtos.ExactSelectiveReleaseResult resource : released) { - if (palletRepository.transitionStatus(PLATFORM_RESOURCE_TENANT_ID, resource.warehouseId(), resource.palletId(), - List.of("OCCUPIED"), "AVAILABLE") != 1) { - throw new BizException(ResultCode.CONFLICT, "Pallet changed concurrently during PDA settlement"); - } result.add(new PalletAllocationDtos.SelectiveReleaseResult(resource.reservationId(), resource.occupancyId(), resource.allocationNo(), resource.palletId(), resource.locationId(), resource.locationCode(), resource.allocatedPackageCount())); @@ -307,7 +282,7 @@ public class PalletLocationAllocationService { return sha256(canonical); } - /** 将未拣货的库位和托盘分配转给二级子入库单,物理资源继续保持占用。 */ + /** 将未拣货的库位分配转给二级子入库单,库位继续保持占用。 */ @Transactional @BusinessAudit(event = "PALLET_LOCATION_TRANSFER", resourceType = "INBOUND", resourceId = "#request.sourceBusinessId", description = "将未拣货库位转给子入库单") @@ -352,9 +327,9 @@ public class PalletLocationAllocationService { List transferred) { List result = new ArrayList<>(transferred.size()); for (LocationDtos.TransferResult item : transferred) { - PalletEntity pallet = palletRepository.findOwned(tenantId, item.warehouseId(), item.palletId()); - if (pallet == null || !"OCCUPIED".equals(pallet.getStatus())) { - throw new BizException(ResultCode.CONFLICT, "转移托盘不存在或未处于占用状态"); + PalletEntity pallet = palletRepository.findOwnedIncludingDeleted(tenantId, item.warehouseId(), item.palletId()); + if (pallet == null) { + throw new BizException(ResultCode.NOT_FOUND, "转移记录引用的托盘主数据不存在"); } result.add(new PalletAllocationDtos.TransferResult(item.reservationId(), item.reservationNo(), item.occupancyId(), item.allocationNo(), tenantId, item.warehouseId(), item.palletId(), diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java index d1aaa60a8..d6d41a8bf 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java @@ -20,9 +20,10 @@ public class WarehouseSchemaReadinessService { "SELECT id, tenant_id, data_scope_id, partner_code, partner_name, partner_type, status, remark, deleted FROM wh_partner LIMIT 0", "SELECT partner_id, tenant_id, data_scope_id, partner_type FROM wh_partner_type LIMIT 0", "SELECT id, tenant_id, company_code, company_name, status, deleted FROM wh_express_company LIMIT 0", + "SELECT id, tenant_id, warehouse_id, pallet_code, pallet_type, capacity_cbm, weight_kg, deleted, version FROM wh_pallet LIMIT 0", "SELECT id, tenant_id, warehouse_id, zone_code, zone_name, location_code, location_type, max_weight_kg, shared_overflow, status, deleted, version FROM wh_location LIMIT 0", "SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, reservation_status, expires_at, version FROM wh_location_reservation LIMIT 0", - "SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, occupancy_status, active_location_occupancy, active_pallet_occupancy, version FROM wh_location_occupancy LIMIT 0", + "SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, occupancy_status, active_location_occupancy, version FROM wh_location_occupancy LIMIT 0", "SELECT id, tenant_id, customer_name, currency, version FROM wh_overseas_storage_fee_rule LIMIT 0", "SELECT id, tenant_id, rule_id, start_day, end_day, unit_price, sort_no, version FROM wh_overseas_storage_fee_tier LIMIT 0", "SELECT id, tenant_id, business_type, order_no, warehouse_id, container_no, box_count, status, version FROM wh_domestic_container_order LIMIT 0", diff --git a/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql b/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql index fe7ec30de..60eb7796c 100644 --- a/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql +++ b/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql @@ -15,9 +15,8 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', pallet_name VARCHAR(100) NULL COMMENT '托盘名称', capacity_cbm DECIMAL(18,6) NOT NULL COMMENT '托盘可用容积CBM', - weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '托盘重量kg', + weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '托盘皮重kg', color VARCHAR(32) NOT NULL DEFAULT '' COMMENT '托盘颜色', - status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT '内部状态:AVAILABLE、RESERVED或OCCUPIED', remark VARCHAR(500) NULL COMMENT '备注', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -27,11 +26,11 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_pallet_tenant_code (tenant_id, pallet_code), - KEY idx_pallet_available (tenant_id, warehouse_id, status, deleted), - KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, status, deleted), + KEY idx_pallet_warehouse (tenant_id, warehouse_id, deleted, pallet_code), + KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, capacity_cbm, deleted), CONSTRAINT fk_pallet_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), CONSTRAINT ck_pallet_capacity CHECK (capacity_cbm > 0) -) ENGINE=InnoDB COMMENT='二期托盘基础资料和物理托盘实例'; +) ENGINE=InnoDB COMMENT='可复用托盘标识和称重主数据,不承载动态资源状态'; CREATE TABLE IF NOT EXISTS wh_location ( id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', @@ -84,19 +83,17 @@ CREATE TABLE IF NOT EXISTS wh_location_reservation ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', active_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动预占唯一标记,A0 返回 NULL', - active_pallet_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动预占唯一标记(包含A0)', PRIMARY KEY (id), UNIQUE KEY uk_location_reservation_no (tenant_id, reservation_no), UNIQUE KEY uk_location_reservation_idempotency (tenant_id, source_service, idempotency_key), UNIQUE KEY uk_location_active_reservation (tenant_id, location_id, active_reservation), - UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation), KEY idx_location_reservation_business (tenant_id, source_service, business_id, reservation_status), KEY idx_location_reservation_expire (reservation_status, expires_at), KEY idx_location_reservation_target (tenant_id, warehouse_id, location_id, reservation_status), CONSTRAINT fk_reservation_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), CONSTRAINT fk_reservation_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), CONSTRAINT fk_reservation_location FOREIGN KEY (location_id) REFERENCES wh_location (id) -) ENGINE=InnoDB COMMENT='跨服务入库Saga的托盘和库位临时预占'; +) ENGINE=InnoDB COMMENT='跨服务入库Saga的库位临时预占,托盘字段仅为主数据快照'; CREATE TABLE IF NOT EXISTS wh_location_occupancy ( id BIGINT UNSIGNED NOT NULL COMMENT '库位占用ID', @@ -123,12 +120,10 @@ CREATE TABLE IF NOT EXISTS wh_location_occupancy ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', active_location_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动占用唯一标记', - active_pallet_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动占用唯一标记(包含A0)', PRIMARY KEY (id), UNIQUE KEY uk_location_occupancy_no (tenant_id, occupancy_no), UNIQUE KEY uk_occupancy_reservation (tenant_id, reservation_id), UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy), - UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy), KEY idx_occupancy_business (tenant_id, source_service, business_id, occupancy_status), KEY idx_occupancy_warehouse (tenant_id, warehouse_id, occupancy_status, occupied_at), CONSTRAINT fk_occupancy_reservation FOREIGN KEY (reservation_id) REFERENCES wh_location_reservation (id), @@ -136,6 +131,6 @@ CREATE TABLE IF NOT EXISTS wh_location_occupancy ( CONSTRAINT fk_occupancy_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), CONSTRAINT fk_occupancy_location FOREIGN KEY (location_id) REFERENCES wh_location (id), CONSTRAINT ck_occupancy_volume CHECK (allocated_volume_cbm > 0) -) ENGINE=InnoDB COMMENT='已确认的一托一库位占用事实记录'; +) ENGINE=InnoDB COMMENT='已确认的库位占用事实记录,托盘字段仅为主数据快照'; -- 头程入库单属于其他数据库,不建立跨库外键。 diff --git a/warehouse/src/main/resources/db/phase2-pallet-seed.sql b/warehouse/src/main/resources/db/phase2-pallet-seed.sql index 67373e44f..507565fb5 100644 --- a/warehouse/src/main/resources/db/phase2-pallet-seed.sql +++ b/warehouse/src/main/resources/db/phase2-pallet-seed.sql @@ -2,11 +2,11 @@ SET NAMES utf8mb4; USE baiyue_warehouse; --- 编码由平台维护接口自动生成;两个国内作业仓各初始化一个可用托盘。 -INSERT INTO wh_pallet(id,tenant_id,warehouse_id,pallet_code,pallet_type,capacity_cbm,weight_kg,color,status,remark,created_by,updated_by) +-- 编码由平台维护接口自动生成;两个国内作业仓各初始化一条可复用托盘主数据。 +INSERT INTO wh_pallet(id,tenant_id,warehouse_id,pallet_code,pallet_type,capacity_cbm,weight_kg,color,remark,created_by,updated_by) VALUES - (190000000000000101,0,190000000000000001,'TP-001','木托盘',1.200000,8.000,'黄色','AVAILABLE',NULL,0,0), - (190000000000000102,0,190000000000000002,'TP-002','胶托盘',2.400000,12.000,'黑色','AVAILABLE',NULL,0,0) + (190000000000000101,0,190000000000000001,'TP-001','木托盘',1.200000,8.000,'黄色',NULL,0,0), + (190000000000000102,0,190000000000000002,'TP-002','胶托盘',2.400000,12.000,'黑色',NULL,0,0) ON DUPLICATE KEY UPDATE tenant_id=0, warehouse_id=VALUES(warehouse_id), diff --git a/warehouse/src/main/resources/openapi/pallet-v1.yaml b/warehouse/src/main/resources/openapi/pallet-v1.yaml new file mode 100644 index 000000000..a1db02419 --- /dev/null +++ b/warehouse/src/main/resources/openapi/pallet-v1.yaml @@ -0,0 +1,72 @@ +openapi: 3.0.3 +info: + title: Warehouse Pallet API + version: 1.0.0 + description: 托盘是可复用标识和称重主数据,不承载 AVAILABLE、RESERVED 或 OCCUPIED 动态状态。 +paths: + /internal/warehouse/v1/pallets/{palletId}/snapshot: + get: + summary: 查询单条托盘主数据快照 + description: 固定使用平台公共作用域 tenant_id=0,并按 warehouseId 校验归属;不返回状态或可用性。 + parameters: + - $ref: '#/components/parameters/InternalToken' + - { in: path, name: palletId, required: true, schema: { type: integer, format: int64, minimum: 1 } } + - { in: query, name: warehouseId, required: true, schema: { type: integer, format: int64, minimum: 1 } } + responses: + '200': + description: 业务响应;不存在或归属不符时 body code 为 A0404 + content: { application/json: { schema: { $ref: '#/components/schemas/PalletSnapshotResponse' } } } + /internal/warehouse/v1/pallets/snapshots: + get: + summary: 查询仓库全部托盘主数据快照 + description: 固定使用平台公共作用域 tenant_id=0,返回该仓全部未删除主数据,不按状态或占用过滤。 + parameters: + - $ref: '#/components/parameters/InternalToken' + - { in: query, name: warehouseId, required: true, schema: { type: integer, format: int64, minimum: 1 } } + responses: + '200': + description: 业务响应 + content: { application/json: { schema: { $ref: '#/components/schemas/PalletSnapshotListResponse' } } } +components: + parameters: + InternalToken: + in: header + name: X-Internal-Service-Token + required: true + schema: { type: string } + schemas: + PalletSnapshot: + type: object + additionalProperties: false + required: [palletId, palletCode, palletType, capacityCbm, tareWeightKg] + properties: + palletId: { type: integer, format: int64 } + palletCode: { type: string } + palletType: { type: string } + capacityCbm: { type: number } + tareWeightKg: { type: number } + ApiEnvelope: + type: object + required: [code, message, timestamp] + properties: + code: { type: string, example: '200' } + message: { type: string } + requestId: { type: string, nullable: true } + traceId: { type: string, nullable: true } + timestamp: { type: string, format: date-time } + PalletSnapshotResponse: + allOf: + - { $ref: '#/components/schemas/ApiEnvelope' } + - type: object + required: [data] + properties: + data: { $ref: '#/components/schemas/PalletSnapshot' } + PalletSnapshotListResponse: + allOf: + - { $ref: '#/components/schemas/ApiEnvelope' } + - type: object + required: [data] + properties: + data: + type: array + items: { $ref: '#/components/schemas/PalletSnapshot' } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletControllerTest.java new file mode 100644 index 000000000..c67e122ce --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletControllerTest.java @@ -0,0 +1,59 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.service.PalletApplicationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class InternalPalletControllerTest { + private static final String TOKEN = "test-internal-token"; + private final PalletApplicationService service = mock(PalletApplicationService.class); + private final InternalPalletController controller = new InternalPalletController(); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(controller, "service", service); + ReflectionTestUtils.setField(controller, "internalToken", TOKEN); + controller.initializeToken(); + } + + @Test + void readsSnapshotFromPlatformScope() { + PalletRequests.Snapshot expected = snapshot(30L, "TP-001"); + when(service.snapshot(0L, 20L, 30L)).thenReturn(expected); + + assertThat(controller.snapshot(TOKEN, 30L, 20L).data()).isEqualTo(expected); + verify(service).snapshot(0L, 20L, 30L); + } + + @Test + void listsAllWarehouseSnapshotsFromPlatformScope() { + List expected = List.of(snapshot(30L, "TP-001"), snapshot(31L, "TP-002")); + when(service.snapshots(0L, 20L)).thenReturn(expected); + + assertThat(controller.snapshots(TOKEN, 20L).data()).isEqualTo(expected); + verify(service).snapshots(0L, 20L); + } + + @Test + void rejectsInvalidInternalToken() { + assertThatThrownBy(() -> controller.snapshots("invalid", 20L)).isInstanceOf(BizException.class); + verifyNoInteractions(service); + } + + private static PalletRequests.Snapshot snapshot(long id, String code) { + return new PalletRequests.Snapshot(id, code, "STANDARD", new BigDecimal("1.2"), new BigDecimal("8")); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PalletOpenApiContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PalletOpenApiContractTest.java new file mode 100644 index 000000000..bb6b61e2d --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PalletOpenApiContractTest.java @@ -0,0 +1,25 @@ +package com.baiyue.warehouse.controller; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class PalletOpenApiContractTest { + @Test + void freezesInternalSnapshotPathsAndFields() throws IOException { + String yaml = Files.readString(Path.of("src/main/resources/openapi/pallet-v1.yaml")); + + assertThat(yaml) + .contains("/internal/warehouse/v1/pallets/{palletId}/snapshot:") + .contains("/internal/warehouse/v1/pallets/snapshots:") + .contains("required: [palletId, palletCode, palletType, capacityCbm, tareWeightKg]") + .contains("additionalProperties: false") + .doesNotContain("tenantId:") + .doesNotContain("available:") + .doesNotContain("status:"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java index 7a4c50d72..d6f2b911d 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java @@ -10,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat; class InitialWarehouseResourceSeedSqlContractTest { @Test - void initializesAvailablePalletAndLocationsForBothDomesticWarehouses() throws Exception { + void initializesReusablePalletMasterDataAndAvailableLocationsForBothDomesticWarehouses() throws Exception { assertModuleSeed(Files.readString(Path.of("src", "main", "resources", "db", "phase2-pallet-seed.sql"), StandardCharsets.UTF_8)); assertUnifiedSeed(Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), @@ -19,6 +19,7 @@ class InitialWarehouseResourceSeedSqlContractTest { private static void assertModuleSeed(String sql) { assertSeed(sql, "190000000000000001", "190000000000000002", "190000000000000"); + assertThat(sql).doesNotContain("color,status,remark"); } private static void assertUnifiedSeed(String sql) { diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java index 0a90de248..c319729b3 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java @@ -44,21 +44,29 @@ class WarehouseSchemaContractTest { void shouldHandleCase04() throws IOException { String sql = read("phase2-pallet-location-schema.sql"); assertColumns(sql, "wh_pallet", "tenant_id", "warehouse_id", "pallet_code", "pallet_type", - "capacity_cbm", "weight_kg", "color", "status", "remark", "deleted", "version"); - assertThat(sql).contains("uk_pallet_tenant_code (tenant_id, pallet_code)"); + "capacity_cbm", "weight_kg", "color", "remark", "deleted", "version"); + assertThat(tableDefinition(sql, "wh_pallet")) + .contains("uk_pallet_tenant_code (tenant_id, pallet_code)") + .contains("idx_pallet_warehouse (tenant_id, warehouse_id, deleted, pallet_code)") + .doesNotContain("status VARCHAR") + .doesNotContain("AVAILABLE") + .doesNotContain("RESERVED") + .doesNotContain("OCCUPIED"); assertColumns(sql, "wh_location", "tenant_id", "warehouse_id", "zone_code", "zone_name", "location_code", "location_type", "max_weight_kg", "shared_overflow", "status", "remark", "deleted", "version"); assertColumns(sql, "wh_location_reservation", "idempotency_key", "business_id", "pallet_id", "location_id", "shared_overflow", "reservation_status", "expires_at", - "active_reservation", "active_pallet_reservation", "version"); + "active_reservation", "version"); assertColumns(sql, "wh_location_occupancy", "reservation_id", "business_id", "pallet_id", "location_id", "shared_overflow", "occupancy_status", - "active_location_occupancy", "active_pallet_occupancy", "version"); + "active_location_occupancy", "version"); assertThat(sql).contains("uk_location_tenant_warehouse_code") .contains("uk_location_active_reservation") - .contains("uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation)") .contains("uk_location_active_occupancy") - .contains("uk_pallet_active_occupancy"); + .doesNotContain("active_pallet_reservation") + .doesNotContain("active_pallet_occupancy") + .doesNotContain("uk_pallet_active_reservation") + .doesNotContain("uk_pallet_active_occupancy"); } @Test @@ -112,6 +120,24 @@ class WarehouseSchemaContractTest { } } + @Test + void unifiedSqlShouldConvergePalletsToReusableMasterData() throws IOException { + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); + String palletDefinition = tableDefinition(unified, "wh_pallet"); + + assertThat(palletDefinition) + .contains("idx_pallet_warehouse (tenant_id, warehouse_id, deleted, pallet_code)") + .contains("idx_pallet_type (tenant_id, warehouse_id, pallet_type, capacity_cbm, deleted)") + .doesNotContain("status VARCHAR") + .doesNotContain("idx_pallet_available"); + assertThat(unified) + .contains("ALTER TABLE wh_pallet DROP COLUMN status") + .contains("ALTER TABLE wh_location_reservation DROP COLUMN active_pallet_reservation") + .contains("ALTER TABLE wh_location_occupancy DROP COLUMN active_pallet_occupancy") + .contains("托盘动态资源状态或活动唯一约束仍存在") + .contains("库位活动预占或占用唯一约束不完整"); + } + private static String read(String file) throws IOException { return Files.readString(Path.of("src/main/resources/db", file)); } @@ -133,4 +159,10 @@ class WarehouseSchemaContractTest { int end = sql.indexOf(' ', start); return sql.substring(start, end); } + + private static String tableDefinition(String sql, String table) { + int start = sql.indexOf("CREATE TABLE IF NOT EXISTS " + table + " ("); + int end = sql.indexOf("ENGINE=InnoDB", start); + return sql.substring(start, end); + } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java index be0252b42..0761dbb33 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java @@ -27,7 +27,7 @@ class PalletApplicationServiceTest { @Test void shouldHandleCase01() { - PalletEntity entity = pallet("OCCUPIED"); + PalletEntity entity = pallet(); when(repository.findOwned(10001, 20, 30)).thenReturn(entity); org.assertj.core.api.Assertions.assertThat(service.detail(10001, 20, 30)) .extracting("id", "palletCode").containsExactly(30L, null); @@ -41,7 +41,7 @@ class PalletApplicationServiceTest { @Test void pagesWithoutTenantFilter() { PalletRequests.PageQuery query = new PalletRequests.PageQuery(1, 20, null, null, null); - when(repository.page(null, query)).thenReturn(new PageResult<>(java.util.List.of(pallet("AVAILABLE")), + when(repository.page(null, query)).thenReturn(new PageResult<>(java.util.List.of(pallet()), 1, 20, 1, 1)); org.assertj.core.api.Assertions.assertThat(service.page(null, query).records()) @@ -63,12 +63,29 @@ class PalletApplicationServiceTest { .hasMessageContaining("仅支持国内仓"); } - private static PalletEntity pallet(String status) { + @Test + void returnsReusablePalletSnapshotsWithoutResourceStatus() { + WarehouseEntity warehouse = new WarehouseEntity(); + warehouse.setWarehouseType("DOMESTIC"); + when(warehouseRepository.findOwned(0L, 20L)).thenReturn(warehouse); + PalletEntity entity = pallet(); + entity.setPalletCode("TP-001"); + entity.setPalletType("STANDARD"); + entity.setCapacityCbm(new BigDecimal("1.200000")); + entity.setWeightKg(new BigDecimal("8.000")); + when(repository.findAllByWarehouse(0L, 20L)).thenReturn(java.util.List.of(entity)); + + org.assertj.core.api.Assertions.assertThat(service.snapshots(0L, 20L)).singleElement().satisfies(snapshot -> { + org.assertj.core.api.Assertions.assertThat(snapshot.palletId()).isEqualTo(30L); + org.assertj.core.api.Assertions.assertThat(snapshot.tareWeightKg()).isEqualByComparingTo("8.000"); + }); + } + + private static PalletEntity pallet() { PalletEntity entity = new PalletEntity(); entity.setId(30L); entity.setTenantId(10001L); entity.setWarehouseId(20L); - entity.setStatus(status); entity.setVersion(0); return entity; } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java index 81ebd4c76..042cfbc12 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java @@ -52,22 +52,35 @@ class PalletLocationAllocationServiceTest { } @Test - void shouldHandleCase01() { + void reusesOnePalletMasterForMultipleAllocations() { when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of()); - when(pallets.findAvailable(10001, 20, "木托盘", new BigDecimal("2"), 1)) + when(pallets.findReusable(10001, 20, "木托盘", new BigDecimal("2"))) .thenReturn(List.of(pallet(30, "TP-001"))); - when(pallets.transitionStatus(10001, 20, 30, List.of("AVAILABLE"), "RESERVED")).thenReturn(0); + when(locations.reserveAvailable(any())).thenReturn(List.of( + new LocationDtos.ReservationResult(101, "LR101", 30, "TP-001", 40, "A-01", false, + LocalDateTime.now().plusMinutes(30)), + new LocationDtos.ReservationResult(102, "LR102", 30, "TP-001", 41, "A-02", false, + LocalDateTime.now().plusMinutes(30)))); - assertThatThrownBy(() -> service.reserve(reserve(88))).isInstanceOf(BizException.class); - verify(locations, never()).reserveAvailable(any()); + var request = new PalletAllocationDtos.ReserveRequest(10001, 20, "FIRSTLEG", 88, "RK-001", "measure-001", + 9001, new BigDecimal("4"), new BigDecimal("2"), "木托盘", LocalDateTime.now().plusMinutes(30), true); + assertThat(service.reserve(request).allocations()).hasSize(2) + .extracting(PalletAllocationDtos.AllocationResult::palletId) + .containsOnly(30L); + + org.mockito.ArgumentCaptor command = + org.mockito.ArgumentCaptor.forClass(LocationDtos.ReserveCommand.class); + verify(locations).reserveAvailable(command.capture()); + assertThat(command.getValue().targets()).hasSize(2) + .extracting(LocationDtos.ReservationTarget::palletId) + .containsOnly(30L); } @Test void shouldHandleCase02() { when(locations.findActiveResources(10001, "FIRSTLEG", 99)).thenReturn(List.of()); - when(pallets.findAvailable(10001, 20, "STANDARD", new BigDecimal("2"), 1)) + when(pallets.findReusable(10001, 20, "STANDARD", new BigDecimal("2"))) .thenReturn(List.of(pallet(30, "TP-001"))); - when(pallets.transitionStatus(10001, 20, 30, List.of("AVAILABLE"), "RESERVED")).thenReturn(1); when(locations.reserveAvailable(any())).thenReturn(List.of( new LocationDtos.ReservationResult(101, "LR101", 30, "TP-001", 40, "A0", true, LocalDateTime.now().plusMinutes(30)))); @@ -77,7 +90,7 @@ class PalletLocationAllocationServiceTest { var result = service.reserve(request); assertThat(result.allocations().get(0).locationCode()).isEqualTo("A0"); - verify(pallets).findAvailable(10001, 20, "STANDARD", new BigDecimal("2"), 1); + verify(pallets).findReusable(10001, 20, "STANDARD", new BigDecimal("2")); } @Test @@ -86,13 +99,12 @@ class PalletLocationAllocationServiceTest { 101, null, 20, 30, "TP-001", 40, "A-01", false, "RESERVED"); when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of(resource)); when(locations.release(any())).thenReturn(List.of(resource)); - when(pallets.transitionStatus(10001, 20, 30, List.of("RESERVED"), "AVAILABLE")).thenReturn(1); List released = service.release( new PalletAllocationDtos.ReleaseRequest(10001, "FIRSTLEG", 88, 9001, "入库补偿")); assertThat(released).containsExactly(resource); - verify(pallets).transitionStatus(10001, 20, 30, List.of("RESERVED"), "AVAILABLE"); + verifyNoInteractions(pallets); } @Test @@ -117,7 +129,7 @@ class PalletLocationAllocationServiceTest { assertThat(result.palletCount()).isEqualTo(1); assertThat(result.allocations().get(0).state()).isEqualTo("RESERVED"); - verify(pallets, never()).findAvailable(any(Long.class), any(Long.class), any(), any(), any(Integer.class)); + verify(pallets, never()).findReusable(any(Long.class), any(Long.class), any(), any()); } @Test @@ -127,8 +139,6 @@ class PalletLocationAllocationServiceTest { when(locations.findActiveResources(10001L, "FIRSTLEG", 88L)).thenReturn(List.of(resource)); when(locations.confirmOccupied(any())).thenReturn(List.of(new LocationDtos.OccupancyResult( 301L, "OC-301", 101L, 30L, 40L, "A-01", false))); - when(pallets.transitionStatus(10001L, 20L, 30L, List.of("RESERVED"), "OCCUPIED")) - .thenReturn(1); service.confirm(new PalletAllocationDtos.ConfirmRequest(10001L, 20L, "FIRSTLEG", 88L, 9001L, new BigDecimal("1.0"), new BigDecimal("2.0"), 20)); @@ -153,8 +163,6 @@ class PalletLocationAllocationServiceTest { "TP-" + index, locationId, "A-" + index, false, "RESERVED")); occupancyResults.add(new LocationDtos.OccupancyResult(400L + index, "OC-" + index, reservationId, palletId, locationId, "A-" + index, false)); - when(pallets.transitionStatus(10001L, 20L, palletId, List.of("RESERVED"), "OCCUPIED")) - .thenReturn(1); } when(locations.findActiveResources(10001L, "FIRSTLEG", 89L)).thenReturn(resources); when(locations.confirmOccupied(any())).thenReturn(occupancyResults); @@ -198,8 +206,6 @@ class PalletLocationAllocationServiceTest { }); when(releaseOperations.lockByKey(anyLong(), anyString(), anyLong(), anyString())).thenReturn(operation); when(locations.releaseSelectedExactly(any())).thenReturn(List.of(resource)); - when(pallets.transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE")) - .thenReturn(1); var result = service.releaseSelected(new PalletAllocationDtos.SelectiveReleaseRequest( 10001L, "FIRSTLEG", 88L, "pda-release-88-202", 9001L, "PDA partial pick", List.of(40L))); @@ -209,7 +215,7 @@ class PalletLocationAllocationServiceTest { assertThat(item.locationId()).isEqualTo(40L); assertThat(item.allocatedPackageCount()).isEqualTo(4); }); - verify(pallets).transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE"); + verifyNoInteractions(pallets); verify(releaseOperations).complete(org.mockito.ArgumentMatchers.eq(701L), anyString()); } @@ -265,14 +271,12 @@ class PalletLocationAllocationServiceTest { .thenReturn(operation); when(locations.releaseSelectedReservations(any())).thenReturn(List.of(new LocationDtos.ExactSelectiveReleaseResult( 101L, 201L, 1, 20L, 30L, 40L, "A-01", 8))); - when(pallets.transitionStatus(0L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE")).thenReturn(1); when(locations.transferToChildByReservation(any())).thenReturn(List.of(new LocationDtos.TransferResult( 102L, "LR102", 202L, 2, 20L, 31L, "TP-002", 41L, "A-02", new BigDecimal("1.200000"), 12, "OCCUPIED"))); PalletEntity pallet = pallet(31L, "TP-002"); - pallet.setStatus("OCCUPIED"); pallet.setCapacityCbm(new BigDecimal("1.500000")); - when(pallets.findOwned(0L, 20L, 31L)).thenReturn(pallet); + when(pallets.findOwnedIncludingDeleted(0L, 20L, 31L)).thenReturn(pallet); var result = service.settle(settlementRequest()); @@ -407,9 +411,8 @@ class PalletLocationAllocationServiceTest { 101L, "LR101", 201L, 1, 20L, 30L, "TP-001", 40L, "A-01", new BigDecimal("1.200000"), 5, "OCCUPIED"))); PalletEntity pallet = pallet(30L, "TP-001"); - pallet.setStatus("OCCUPIED"); pallet.setCapacityCbm(new BigDecimal("1.500000")); - when(pallets.findOwned(10001L, 20L, 30L)).thenReturn(pallet); + when(pallets.findOwnedIncludingDeleted(10001L, 20L, 30L)).thenReturn(pallet); var result = service.transfer(new PalletAllocationDtos.TransferRequest( 10001L, "FIRSTLEG", 88L, 89L, "RK-001-S1", "transfer-88-89", 9001L, @@ -421,7 +424,6 @@ class PalletLocationAllocationServiceTest { assertThat(item.remainingPackageCount()).isEqualTo(5); assertThat(item.state()).isEqualTo("OCCUPIED"); }); - verify(pallets, never()).transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE"); verify(transferOperations).complete(org.mockito.ArgumentMatchers.eq(501L), anyString()); }