This commit is contained in:
2026-08-14 17:37:59 +08:00
parent 87e919f3c4
commit feced8eb92
40 changed files with 1142 additions and 244 deletions
+159 -52
View File
@@ -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
@@ -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<List<PalletSnapshot>> palletSnapshots(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("warehouseId") long warehouseId);
@PostMapping("/pallet-locations/reservations")
ApiResponse<BatchResult> reserve(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReserveRequest request);
@PostMapping("/confirm")
@PostMapping("/pallet-locations/confirm")
ApiResponse<BatchResult> confirm(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ConfirmRequest request);
@PostMapping("/release")
@PostMapping("/pallet-locations/release")
ApiResponse<List<ActiveResource>> release(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReleaseRequest request);
@PostMapping("/release-selected")
@PostMapping("/pallet-locations/release-selected")
ApiResponse<List<SelectiveReleaseResult>> releaseSelected(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SelectiveReleaseRequest request);
@PostMapping("/transfer")
@PostMapping("/pallet-locations/transfer")
ApiResponse<List<TransferResult>> transfer(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody TransferRequest request);
@PostMapping("/settlements")
@PostMapping("/pallet-locations/settlements")
ApiResponse<SettlementResult> settle(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SettlementRequest request);
@GetMapping("/settlements/target")
@GetMapping("/pallet-locations/settlements/target")
ApiResponse<Long> 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<List<OccupiedResource>> occupiedResources(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("tenantId") long tenantId,
@@ -90,6 +95,8 @@ public interface WarehousePalletLocationClient {
List<SettlementTransferItem> remainingItems) {}
record SettlementResult(List<SelectiveReleaseResult> released, List<TransferResult> 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) {}
}
@@ -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<List<InboundMeasurementResponses.PalletSnapshot>> 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<InboundMeasurementResponses.WeightResult> 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'))")
@@ -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,
@@ -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) {
}
@@ -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(
@@ -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;
@@ -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;
@@ -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<InboundWeightResultEntity> 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());
@@ -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<InboundMeasurementResponses.PalletSnapshot> 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<InboundWeightResultEntity> 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<WarehousePalletLocationClient.PalletSnapshot> loadPalletSnapshots(
InboundOrderEntity inbound) {
if (inbound.getWarehouseId() == null || inbound.getWarehouseId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "入库单未绑定有效仓库,无法选择称重托盘");
}
try {
ApiResponse<List<WarehousePalletLocationClient.PalletSnapshot>> 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) {
@@ -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(),
@@ -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 '操作人名称快照',
@@ -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)
@@ -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}
@@ -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"));
}
}
@@ -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);
@@ -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"));
}
}
@@ -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("同票托盘唯一约束仍阻止托盘主数据复用");
}
}
@@ -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"),
@@ -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<InboundWeightResultEntity> 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<InboundMeasurementResponses.PalletSnapshot> 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<InboundWeightRevisionEntity> 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"));
}
}
+4 -3
View File
@@ -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 不允许人工编辑、停用或删除。
- 资源结算、释放、转移和换绑必须保存可重放的幂等记录或具有等价的状态幂等语义,并在测试中覆盖并发状态变更。
@@ -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<PalletRequests.Snapshot> 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<List<PalletRequests.Snapshot>> 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, "无效的内部服务凭据");
}
}
}
@@ -41,7 +41,7 @@ public class InternalPalletLocationController {
}
@PostMapping("/reservations")
@Operation(summary = "拆分托盘并预占一托一位")
@Operation(summary = "按托盘主数据拆分并预占库位")
public ApiResponse<PalletAllocationDtos.BatchResult> 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<PalletAllocationDtos.BatchResult> 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<List<PalletAllocationDtos.TransferResult>> transfer(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PalletAllocationDtos.TransferRequest request) {
@@ -89,7 +89,7 @@ public class PlatformPalletController {
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('platform:pallet:delete')")
@Operation(summary = "删除托盘", description = "仅可删除未预占、未占用的托盘")
@Operation(summary = "删除托盘", description = "托盘是可复用主数据,删除不受库位预占或占用状态限制")
public ApiResponse<Void> delete(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(0) long tenantId,
@RequestParam @Min(1) long warehouseId,
@@ -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
) {
}
}
@@ -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;
}
@@ -12,4 +12,13 @@ public interface PalletMapper extends BaseMapper<PalletEntity> {
/** 编码序列必须包含逻辑删除记录,避免历史编码被复用。 */
@Select("SELECT pallet_code FROM wh_pallet WHERE tenant_id = #{tenantId} AND pallet_code LIKE 'TP-%'")
List<String> 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);
}
@@ -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<PalletEntity> findAllByWarehouse(long tenantId, long warehouseId) {
return mapper.selectList(new LambdaQueryWrapper<PalletEntity>()
.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<PalletEntity>()
.eq(PalletEntity::getTenantId, tenantId)
@@ -51,16 +62,14 @@ public class PalletRepository {
.ne(excludedId != null, PalletEntity::getId, excludedId)) > 0;
}
public List<PalletEntity> findAvailable(long tenantId, long warehouseId, String palletType,
java.math.BigDecimal capacityCbm, int limit) {
public List<PalletEntity> findReusable(long tenantId, long warehouseId, String palletType,
java.math.BigDecimal capacityCbm) {
return mapper.selectList(new LambdaQueryWrapper<PalletEntity>()
.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<String> expectedStatuses, String targetStatus) {
return mapper.update(null, new LambdaUpdateWrapper<PalletEntity>()
.eq(PalletEntity::getTenantId, tenantId)
.eq(PalletEntity::getWarehouseId, warehouseId)
.eq(PalletEntity::getId, id)
.in(PalletEntity::getStatus, expectedStatuses)
.set(PalletEntity::getStatus, targetStatus)
.setSql("version = version + 1"));
}
}
@@ -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<PalletRequests.Snapshot> 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();
}
}
@@ -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<PalletEntity> pallets = palletRepository.findAvailable(request.tenantId(), request.warehouseId(),
palletType, request.palletCapacityCbm(), split.palletCount());
if (pallets.size() != split.palletCount()) {
throw new BizException(ResultCode.CONFLICT, "可用托盘数量不足");
List<PalletEntity> pallets = palletRepository.findReusable(request.tenantId(), request.warehouseId(),
palletType, request.palletCapacityCbm());
if (pallets.isEmpty()) {
throw new BizException(ResultCode.NOT_FOUND, "未配置匹配类型和容量的托盘主数据");
}
List<LocationDtos.ReservationTarget> 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<LocationDtos.ReservationTarget> 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<LocationDtos.ActiveResource> active = active(request.tenantId(), request.sourceService(), request.businessId());
@@ -123,13 +119,6 @@ public class PalletLocationAllocationService {
List<LocationDtos.OccupancyResult> 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<PalletAllocationDtos.AllocationResult> 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<LocationDtos.ActiveResource> release(PalletAllocationDtos.ReleaseRequest request) {
List<LocationDtos.ActiveResource> 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<LocationDtos.ActiveResource> 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<PalletAllocationDtos.SelectiveReleaseResult> 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<PalletAllocationDtos.SelectiveReleaseResult> 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<LocationDtos.TransferResult> transferred) {
List<PalletAllocationDtos.TransferResult> 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(),
@@ -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",
@@ -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='已确认的库位占用事实记录,托盘字段仅为主数据快照';
-- 头程入库单属于其他数据库,不建立跨库外键。
@@ -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),
@@ -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' }
@@ -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<PalletRequests.Snapshot> 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"));
}
}
@@ -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:");
}
}
@@ -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) {
@@ -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);
}
}
@@ -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;
}
@@ -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<LocationDtos.ReserveCommand> 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<LocationDtos.ActiveResource> 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());
}