优化
This commit is contained in:
+159
-52
@@ -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
|
||||
|
||||
+18
-11
@@ -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) {}
|
||||
}
|
||||
|
||||
+19
-2
@@ -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) {
|
||||
|
||||
+5
-3
@@ -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}
|
||||
|
||||
+37
@@ -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"));
|
||||
}
|
||||
}
|
||||
+4
@@ -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);
|
||||
|
||||
+34
@@ -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"));
|
||||
}
|
||||
}
|
||||
+22
@@ -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("同票托盘唯一约束仍阻止托盘主数据复用");
|
||||
}
|
||||
}
|
||||
|
||||
+38
-1
@@ -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"),
|
||||
|
||||
+120
-14
@@ -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
@@ -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, "无效的内部服务凭据");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+15
-40
@@ -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(),
|
||||
|
||||
+2
-1
@@ -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' }
|
||||
+59
@@ -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"));
|
||||
}
|
||||
}
|
||||
+25
@@ -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:");
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+38
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-4
@@ -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;
|
||||
}
|
||||
|
||||
+26
-24
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user