diff --git a/config/nacos/cn/file-service.yml b/config/nacos/cn/file-service.yml index 1f230df04..2d1ff9358 100644 --- a/config/nacos/cn/file-service.yml +++ b/config/nacos/cn/file-service.yml @@ -30,7 +30,7 @@ baiyue: enabled: true max-rows: 200000 max-attempts: 3 - batch-size: 500 + batch-size: 200 LOADING_LIST_BUY_DECLARATION_ITEMS: provider-service: firstleg-service diff --git a/config/nacos/local/file-service.yml b/config/nacos/local/file-service.yml index 1ecfc0ed3..31fcdb590 100644 --- a/config/nacos/local/file-service.yml +++ b/config/nacos/local/file-service.yml @@ -26,7 +26,7 @@ baiyue: enabled: true max-rows: 200000 max-attempts: 3 - batch-size: 500 + batch-size: 200 LOADING_LIST_BUY_DECLARATION_ITEMS: provider-service: firstleg-service diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 024627b59..023f7bcdf 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1321,24 +1321,25 @@ ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), route_path = VALUES(route_path), http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; --- 平台代客创建、编辑草稿权限;可重复执行并纳入已有平台管理员和订单运营角色授权。 +-- 平台订单写入与导出权限;可重复执行并纳入已有平台管理员和订单运营角色授权。 INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) VALUES (5102019, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:create', '平台代客创建订单', NULL, NULL, 90, 'ACTIVE', 0), (5102024, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:edit', '平台编辑订单草稿', NULL, NULL, 100, 'ACTIVE', 0), - (5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0) + (5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0), + (5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表', NULL, NULL, 120, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), status = 'ACTIVE', deleted = 0; INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000001, id, 0 FROM iam_permission -WHERE id IN (5102019, 5102024, 5102025) +WHERE id IN (5102019, 5102024, 5102025, 5102026) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000003, id, 0 FROM iam_permission -WHERE id IN (5102019, 5102024, 5102025) +WHERE id IN (5102019, 5102024, 5102025, 5102026) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES @@ -1363,7 +1364,9 @@ ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000003, id, 0 FROM iam_permission -WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%' +WHERE permission_code LIKE 'platform:order%' + OR permission_code LIKE 'platform:firstleg%' + OR permission_code = 'platform:download:list' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES @@ -2794,6 +2797,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item ( total_weight_kg DECIMAL(18,3) NULL COMMENT '本箱规总重量kg', total_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本箱规总体积m3', volume_weight_kg DECIMAL(18,3) NOT NULL COMMENT '本箱规材积重kg', + charge_rule_id BIGINT UNSIGNED NULL COMMENT '该测量明细绑定的客户计费规则ID', + coefficient_mode VARCHAR(16) NULL COMMENT '系数模式快照:ADD或MULTIPLY', calculation_rule_version VARCHAR(64) NOT NULL COMMENT '体积计算和舍入规则版本', source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID', machine_no VARCHAR(64) NULL COMMENT '本次测量设备编号', @@ -3366,6 +3371,10 @@ CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_volume_cbm' 'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''本箱规总体积m3'' AFTER total_weight_kg'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'volume_weight_kg', 'DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''本箱规材积重kg'' AFTER total_volume_cbm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'charge_rule_id', + 'BIGINT UNSIGNED NULL COMMENT ''该测量明细绑定的客户计费规则ID'' AFTER volume_weight_kg'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'coefficient_mode', + 'VARCHAR(16) NULL COMMENT ''系数模式快照:ADD或MULTIPLY'' AFTER charge_rule_id'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'over_limit_warning', 'VARCHAR(500) NULL COMMENT ''超线提醒快照'' AFTER source_image_file_id'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'machine_no', @@ -3632,6 +3641,10 @@ CALL phase2_fba_add_column('fl_inbound_order', 'carrier_id', "BIGINT UNSIGNED NU CALL phase2_fba_add_column('fl_inbound_order', 'carrier_name', "VARCHAR(128) NULL COMMENT '计费船司名称快照' AFTER carrier_id"); CALL phase2_fba_add_column('fl_carrier_fba_rate', 'business_type', "VARCHAR(32) NOT NULL DEFAULT 'FBA' COMMENT '业务或计费类型' AFTER origin_warehouse_name"); CALL phase2_fba_add_column('fl_customer_charge_rule', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted"); +CALL phase2_fba_add_column('fl_customer_charge_rule', 'length_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '长度计算系数' AFTER conversion_factor"); +CALL phase2_fba_add_column('fl_customer_charge_rule', 'width_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '宽度计算系数' AFTER length_factor"); +CALL phase2_fba_add_column('fl_customer_charge_rule', 'height_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '高度计算系数' AFTER width_factor"); +CALL phase2_fba_add_column('fl_customer_charge_rule', 'default_rule', "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '客户默认生效规则' AFTER status"); CALL phase2_fba_add_column('fl_carrier_fba_rate', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted"); CALL phase2_fba_add_column('fl_inbound_fee', 'rule_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '客户规则编码快照' AFTER carrier_rate_version"); CALL phase2_fba_add_column('fl_inbound_fee', 'customer_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '客户ID快照' AFTER rule_code"); @@ -3887,7 +3900,9 @@ VALUES (5102045, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:weight:confirm', '确认OCR重量', NULL, NULL, 40, 'ACTIVE', 0), (5102194, 0, 'PLATFORM', 'BUTTON', 5102030, - 'platform:firstleg:inbound:measure:manual-entry', '手动录入箱规', NULL, NULL, 25, 'ACTIVE', 0) + 'platform:firstleg:inbound:measure:manual-entry', '手动录入箱规', NULL, NULL, 25, 'ACTIVE', 0), + (5102195, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:warehouse-suggestion:edit', '维护仓库建议测量数据', NULL, NULL, 27, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_name = VALUES(permission_name), route_path = VALUES(route_path), @@ -3895,12 +3910,21 @@ ON DUPLICATE KEY UPDATE INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000001, id, 0 FROM iam_permission -WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045, 5102194) +WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045, 5102194, 5102195) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000003, id, 0 FROM iam_permission -WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045, 5102194) +WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045, 5102194, 5102195) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, 0 +FROM iam_role role +JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.permission_code = 'platform:firstleg:inbound:warehouse-suggestion:edit' +WHERE role.tenant_id = 0 AND UPPER(role.role_code) = 'PLATFORM_CUSTOM_ADMIN' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) @@ -4031,19 +4055,29 @@ VALUES (5102065, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:calculate', '计算入库费用', NULL, NULL, 50, 'ACTIVE', 1, 0), (5102066, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:confirm', '内部确认入库费用', NULL, NULL, 60, 'ACTIVE', 1, 0), (5102067, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:view', '查看入库费用', NULL, NULL, 70, 'ACTIVE', 1, 0), - (5102068, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:adjust', '调整已发布费用', NULL, NULL, 80, 'ACTIVE', 1, 0) + (5102068, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:adjust', '调整已发布费用', NULL, NULL, 80, 'ACTIVE', 1, 0), + (5102069, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:confirm-send-fee', '确认并推送客户费用', NULL, NULL, 90, 'ACTIVE', 1, 0) ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), status = 'ACTIVE', built_in = 1, deleted = 0; INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000001, id, 0 FROM iam_permission -WHERE id BETWEEN 5102061 AND 5102068 +WHERE id BETWEEN 5102061 AND 5102069 ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000003, id, 0 FROM iam_permission -WHERE id IN (5102061, 5102063, 5102065, 5102066, 5102067, 5102068) +WHERE id IN (5102061, 5102063, 5102065, 5102066, 5102067, 5102068, 5102069) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, 0 +FROM iam_role role +JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.permission_code = 'firstleg:inbound:confirm-send-fee' +WHERE role.tenant_id = 0 AND UPPER(role.role_code) = 'PLATFORM_CUSTOM_ADMIN' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -- 客户尺寸计算配置权限;与 FBA 计费规则相互独立。 @@ -4351,6 +4385,7 @@ CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'w CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'pricing_tax_mode'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'fulfillment_type'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_measurement_item', 'external_detail_no'); +CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_measurement_item', 'coefficient_mode'); CALL unified_assert_table('baiyue_firstleg', 'fl_price_channel_catalog'); CALL unified_assert_table('baiyue_firstleg', 'fl_price_origin_alias'); CALL unified_assert_table('baiyue_firstleg', 'fl_price_warehouse_mapping'); @@ -4376,10 +4411,11 @@ SET @phase2_permission_count = ( AND permission_code IN ( 'platform:firstleg:inbound:list', 'platform:firstleg:inbound:view', 'platform:firstleg:inbound:measure:view', 'platform:firstleg:inbound:measure:edit', - 'platform:firstleg:inbound:measure:submit', 'platform:firstleg:inbound:weight:edit', + 'platform:firstleg:inbound:measure:submit', 'platform:firstleg:inbound:warehouse-suggestion:edit', + 'platform:firstleg:inbound:weight:edit', 'platform:firstleg:inbound:weight:confirm', 'firstleg:inbound:measurement-review', 'firstleg:inbound:measurement-review-history', 'firstleg:inbound:exception-release', - 'firstleg:inbound:send-fee', 'firstleg:fba-pricing:rule:view', + 'firstleg:inbound:send-fee', 'firstleg:inbound:confirm-send-fee', 'firstleg:fba-pricing:rule:view', 'firstleg:fba-pricing:rule:edit', 'firstleg:fba-pricing:rate:view', 'firstleg:fba-pricing:rate:edit', 'firstleg:fba-pricing:calculate', 'firstleg:fba-pricing:confirm', 'firstleg:fba-pricing:view', @@ -4395,9 +4431,9 @@ DROP PROCEDURE IF EXISTS unified_assert_permission_count; DELIMITER $$ CREATE PROCEDURE unified_assert_permission_count() BEGIN - IF @phase2_permission_count <> 30 THEN + IF @phase2_permission_count <> 32 THEN SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:二期 IAM 权限资源未达到 30 项'; + SET MESSAGE_TEXT = '统一迁移失败:二期 IAM 权限资源未达到 32 项'; END IF; END$$ DELIMITER ; @@ -8024,6 +8060,7 @@ CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config ( inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)', inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)', edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数', + default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ADD' COMMENT '订单外箱规默认计算方式:ADD或MULTIPLY', remark VARCHAR(500) NULL COMMENT '备注', initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', @@ -8043,6 +8080,25 @@ CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config ( CHECK (edge_addition_cm >= 0.001 AND edge_addition_cm <= 100.000) ) ENGINE=InnoDB COMMENT='客户固定四箱规尺寸配置'; +DROP PROCEDURE IF EXISTS unified_dimension_config_add_default_mode; +DELIMITER $$ +CREATE PROCEDURE unified_dimension_config_add_default_mode() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' + AND table_name = 'fl_customer_dimension_config' + AND column_name = 'default_calculation_mode' + ) THEN + ALTER TABLE baiyue_firstleg.fl_customer_dimension_config + ADD COLUMN default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ADD' + COMMENT '订单外箱规默认计算方式:ADD或MULTIPLY' AFTER edge_addition_cm; + END IF; +END$$ +DELIMITER ; +CALL unified_dimension_config_add_default_mode(); +DROP PROCEDURE IF EXISTS unified_dimension_config_add_default_mode; + -- 新结构首次部署或旧结构受控重建后,为所有现有企业客户补齐缺失箱规。 -- 复跑命中 (tenant_id, box_type) 唯一键时只做空更新,不覆盖尺寸、备注、版本或审计字段。 INSERT INTO baiyue_firstleg.fl_customer_dimension_config ( @@ -13213,6 +13269,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'cr CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'created_by', '创建人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'deleted', '逻辑删除'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'edge_addition_cm', '每个尺寸方向的单侧加宽厘米数'); +CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'default_calculation_mode', '订单外箱规默认计算方式:ADD或MULTIPLY'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'id', '配置ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'initialization_key', '创建流程幂等键及补偿归属'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'inner_height_cm', '箱规内高(厘米)'); @@ -13494,6 +13551,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement', 'warehous CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'box_code', '柏悦唛头箱号;设备单箱测量时同一客户内唯一'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'box_specification', '箱规标识'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'calculation_rule_version', '体积计算和舍入规则版本'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'coefficient_mode', '系数模式快照:ADD或MULTIPLY'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'created_at', '创建时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'device_measured_at', '本次设备测量时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'external_detail_no', '第三方测量明细唯一号,平台手工明细可为空'); @@ -15156,8 +15214,9 @@ BEGIN IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_customer_dimension_config' AND column_name IN ('id', 'tenant_id', 'box_type', 'inner_length_cm', 'inner_width_cm', - 'inner_height_cm', 'edge_addition_cm', 'remark', 'initialization_key', - 'created_by', 'created_at', 'updated_by', 'updated_at', 'deleted', 'version')) <> 15 THEN + 'inner_height_cm', 'edge_addition_cm', 'default_calculation_mode', 'remark', + 'initialization_key', 'created_by', 'created_at', 'updated_by', 'updated_at', + 'deleted', 'version')) <> 16 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:客户四箱规尺寸配置字段不完整'; END IF; @@ -15261,9 +15320,11 @@ BEGIN END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 0 AND app_code = 'PLATFORM' - AND ((id = 5103040 AND permission_code = 'platform:download:list' + AND ((id = 5102026 AND permission_code = 'platform:order:export' + AND permission_name = '导出订单列表' AND parent_id = 5102010) + OR (id = 5103040 AND permission_code = 'platform:download:list' AND permission_name = '下载中心' AND route_path = '/downloads') - OR (id = 5103041 AND permission_code = 'platform:download:all' AND parent_id = 5103040))) <> 2 + OR (id = 5103041 AND permission_code = 'platform:download:all' AND parent_id = 5103040))) <> 3 OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND ((id = 5103060 AND permission_code = 'platform:location:list' AND parent_id = 5103000) @@ -15272,7 +15333,18 @@ BEGIN OR (id = 5103063 AND permission_code = 'platform:location:status' AND parent_id = 5103060) OR (id = 5103064 AND permission_code = 'platform:location:delete' AND parent_id = 5103060))) <> 5 THEN SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:下载中心或库位权限语义未修复'; + SET MESSAGE_TEXT = '统一迁移失败:订单导出、下载中心或库位权限语义未修复'; + END IF; + IF (SELECT COUNT(DISTINCT CONCAT(r.role_code, ':', p.permission_code)) + FROM baiyue_iam.iam_role_permission rp + JOIN baiyue_iam.iam_role r ON r.id = rp.role_id AND r.tenant_id = rp.tenant_id + JOIN baiyue_iam.iam_permission p ON p.id = rp.permission_id AND p.tenant_id = rp.tenant_id + WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' + AND r.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR') + AND p.permission_code IN ('platform:order:export', 'platform:download:list') + AND p.status = 'ACTIVE' AND p.deleted = 0) <> 4 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:管理员或订单运营角色缺少订单导出与下载中心权限'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 0 AND app_code = 'PLATFORM' diff --git a/file/src/main/java/com/baiyue/file/dto/ExportTaskDtos.java b/file/src/main/java/com/baiyue/file/dto/ExportTaskDtos.java index d1079c543..beb48161b 100644 --- a/file/src/main/java/com/baiyue/file/dto/ExportTaskDtos.java +++ b/file/src/main/java/com/baiyue/file/dto/ExportTaskDtos.java @@ -19,6 +19,7 @@ public final class ExportTaskDtos { String id, String taskNo, String exportType, String format, String taskName, String status, Long totalRows, long processedRows, String fileName, String contentType, Long sizeBytes, String failureMessage, + int attemptCount, Integer maxAttempts, LocalDateTime nextRetryAt, String requestedBy, String requestedUsername, LocalDateTime requestedAt, LocalDateTime startedAt, LocalDateTime completedAt, LocalDateTime expiresAt) { } } diff --git a/file/src/main/java/com/baiyue/file/mapper/FileExportOutboxMapper.java b/file/src/main/java/com/baiyue/file/mapper/FileExportOutboxMapper.java index 5ed9e1503..eaffd1079 100644 --- a/file/src/main/java/com/baiyue/file/mapper/FileExportOutboxMapper.java +++ b/file/src/main/java/com/baiyue/file/mapper/FileExportOutboxMapper.java @@ -33,6 +33,13 @@ public interface FileExportOutboxMapper extends BaseMapper { int sendFailed(@Param("id") long id, @Param("owner") String owner, @Param("backoffSeconds") int backoffSeconds, @Param("error") String error); + @Update(""" + UPDATE file_export_outbox SET next_retry_at=CURRENT_TIMESTAMP(3) + WHERE task_id=#{taskId} AND dispatch_version=#{dispatchVersion} + AND status='PENDING' AND next_retry_at>CURRENT_TIMESTAMP(3) + """) + int expeditePendingRetry(@Param("taskId") long taskId, @Param("dispatchVersion") int dispatchVersion); + @Update("UPDATE file_export_outbox SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL WHERE status='CLAIMED' AND lease_until<=CURRENT_TIMESTAMP(3) LIMIT 50") int releaseExpiredClaims(); diff --git a/file/src/main/java/com/baiyue/file/mapper/FileExportTaskMapper.java b/file/src/main/java/com/baiyue/file/mapper/FileExportTaskMapper.java index c1568adf4..25447db04 100644 --- a/file/src/main/java/com/baiyue/file/mapper/FileExportTaskMapper.java +++ b/file/src/main/java/com/baiyue/file/mapper/FileExportTaskMapper.java @@ -88,6 +88,13 @@ public interface FileExportTaskMapper extends BaseMapper { """) int manualRetry(@Param("id") long id); + @Update(""" + UPDATE file_export_task SET next_retry_at=CURRENT_TIMESTAMP(3) + WHERE id=#{id} AND status='PENDING' AND dispatch_version=#{dispatchVersion} + AND attempt_count>0 AND next_retry_at>CURRENT_TIMESTAMP(3) + """) + int expeditePendingRetry(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion); + @Update("UPDATE file_export_task SET status='FAILED',completed_at=CURRENT_TIMESTAMP(3),failure_message=#{reason},lease_owner=NULL,lease_until=NULL WHERE id=#{id} AND dispatch_version=#{dispatchVersion} AND status='PENDING'") int failQuarantined(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion, @Param("reason") String reason); diff --git a/file/src/main/java/com/baiyue/file/repository/ExportOutboxRepository.java b/file/src/main/java/com/baiyue/file/repository/ExportOutboxRepository.java index 9eac22349..81fa1cc52 100644 --- a/file/src/main/java/com/baiyue/file/repository/ExportOutboxRepository.java +++ b/file/src/main/java/com/baiyue/file/repository/ExportOutboxRepository.java @@ -22,6 +22,9 @@ public class ExportOutboxRepository { public boolean claim(long id, String owner, int leaseSeconds) { return mapper.claim(id, owner, leaseSeconds) == 1; } public boolean sent(long id, String owner) { return mapper.sent(id, owner) == 1; } public boolean sendFailed(long id, String owner, int seconds, String error) { return mapper.sendFailed(id, owner, seconds, error) == 1; } + public boolean expeditePendingRetry(long taskId, int dispatchVersion) { + return mapper.expeditePendingRetry(taskId, dispatchVersion) == 1; + } public int releaseExpiredClaims() { return mapper.releaseExpiredClaims(); } public int redispatchSent(int staleSeconds, int graceSeconds) { return mapper.redispatchSent(staleSeconds, graceSeconds); } public boolean quarantine(String eventId, long taskId, int version, String reason) { return mapper.quarantine(eventId, taskId, version, reason) == 1; } diff --git a/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java b/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java index be4797a05..7fe987e8f 100644 --- a/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java +++ b/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java @@ -63,6 +63,9 @@ public class ExportTaskRepository { return mapper.fail(id, dispatchVersion, owner, message) == 1; } public boolean manualRetry(long id) { return mapper.manualRetry(id) == 1; } + public boolean expeditePendingRetry(long id, int dispatchVersion) { + return mapper.expeditePendingRetry(id, dispatchVersion) == 1; + } public boolean failQuarantined(long id, int dispatchVersion, String reason) { return mapper.failQuarantined(id, dispatchVersion, reason) == 1; } public boolean recoverStale(long id, int dispatchVersion) { return mapper.recoverStale(id, dispatchVersion) == 1; } public boolean failStale(long id, int dispatchVersion) { return mapper.failStale(id, dispatchVersion) == 1; } diff --git a/file/src/main/java/com/baiyue/file/service/ExportStateService.java b/file/src/main/java/com/baiyue/file/service/ExportStateService.java index d03ed3856..62bfb5ae5 100644 --- a/file/src/main/java/com/baiyue/file/service/ExportStateService.java +++ b/file/src/main/java/com/baiyue/file/service/ExportStateService.java @@ -65,6 +65,14 @@ public class ExportStateService { return true; } @Transactional + public boolean expeditePendingRetry(FileExportTask task) { + if (!outbox.expeditePendingRetry(task.getId(), task.getDispatchVersion())) return false; + if (!tasks.expeditePendingRetry(task.getId(), task.getDispatchVersion())) { + throw new IllegalStateException("Unable to expedite pending export task"); + } + return true; + } + @Transactional public void quarantine(String eventId, long taskId, int version, String reason) { FileExportTask current = tasks.find(taskId); if (current == null || current.getDispatchVersion() != version || !"PENDING".equals(current.getStatus())) return; diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java index 22071a29a..a25401d95 100644 --- a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java +++ b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java @@ -103,21 +103,27 @@ public class ExportTaskService { public ExportTaskResponse retry(AuthenticatedUser user, long id) { FileExportTask task = ownedTask(user, id); - if (!"FAILED".equals(task.getStatus())) { - throw new BizException(ResultCode.CONFLICT, "只有失败的导出任务可以重试"); - } var definition = registry.require(task.getExportType()); if (!definition.isEnabled()) throw new BizException(ResultCode.CONFLICT, "该导出类型已停用"); String permission = definition.getRequiredPermission(); if (!user.permissions().contains(permission)) { throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限"); } + if ("PENDING".equals(task.getStatus()) && task.getAttemptCount() != null && task.getAttemptCount() > 0) { + if (!stateService.expeditePendingRetry(task)) { + throw new BizException(ResultCode.CONFLICT, "任务已进入执行队列,无需重复重试"); + } + return response(repository.find(id)); + } + if (!"FAILED".equals(task.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "只有等待重试或失败的导出任务可以重试"); + } if (!storage.delete(task.getObjectKey())) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "上次导出文件清理失败,请稍后重试"); } boolean retried = stateService.manualRetry(task); if (!retried) { - throw new BizException(ResultCode.CONFLICT, "只有失败的导出任务可以重试"); + throw new BizException(ResultCode.CONFLICT, "只有等待重试或失败的导出任务可以重试"); } return response(repository.find(id)); } @@ -205,6 +211,7 @@ public class ExportTaskService { task.getFormat(), task.getTaskName(), task.getStatus(), task.getTotalRows(), task.getProcessedRows() == null ? 0 : task.getProcessedRows(), task.getFileName(), task.getContentType(), task.getSizeBytes(), task.getFailureMessage(), + task.getAttemptCount() == null ? 0 : task.getAttemptCount(), task.getMaxAttempts(), task.getNextRetryAt(), String.valueOf(task.getRequestedBy()), task.getRequestedUsername(), task.getRequestedAt(), task.getStartedAt(), task.getCompletedAt(), task.getExpiresAt()); } diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index 2d9fa2132..ef4179c70 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -109,7 +109,7 @@ baiyue: enabled: true max-rows: 200000 max-attempts: 3 - batch-size: 500 + batch-size: 200 LOADING_LIST_BUY_DECLARATION_ITEMS: provider-service: firstleg-service provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches diff --git a/file/src/main/resources/openapi/domestic-order-export-v1.yaml b/file/src/main/resources/openapi/domestic-order-export-v1.yaml index b29e1b41b..e2b788aad 100644 --- a/file/src/main/resources/openapi/domestic-order-export-v1.yaml +++ b/file/src/main/resources/openapi/domestic-order-export-v1.yaml @@ -19,8 +19,27 @@ paths: application/json: schema: {$ref: '#/components/schemas/CreateDomesticOrderExportTask'} responses: - '200': {description: 返回异步任务,初始状态 PENDING} + '200': + description: 返回异步任务,初始状态 PENDING + content: + application/json: + schema: {$ref: '#/components/schemas/ExportTaskRetryState'} '403': {description: 缺少 platform:order:export} + /api/platform/v1/files/export-tasks/{taskId}/retry: + post: + summary: 人工重试订单导出任务 + description: >- + FAILED 任务重新开始一次执行;PENDING 且已自动失败过的退避任务立即到期并复用当前投递消息, + 不新增并发任务。RUNNING、初次等待消费及已完成任务不允许重复重试。 + parameters: + - {name: taskId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': + description: 返回已重新进入立即执行队列的任务 + content: + application/json: + schema: {$ref: '#/components/schemas/ExportTaskRetryState'} + '409': {description: 任务当前状态不允许重试或已进入执行队列} /api/platform/v1/files/export-tasks/{taskId}/download: get: summary: 下载本人已完成的订单导出文件 @@ -37,6 +56,16 @@ paths: schema: {type: string, format: binary} components: schemas: + ExportTaskRetryState: + type: object + required: [id, status, attemptCount, maxAttempts] + properties: + id: {type: string, pattern: '^[1-9][0-9]*$'} + status: {type: string, enum: [PENDING, RUNNING, SUCCEEDED, FAILED, EXPIRED]} + failureMessage: {type: string, nullable: true} + attemptCount: {type: integer, minimum: 0} + maxAttempts: {type: integer, minimum: 1} + nextRetryAt: {type: string, format: date-time, nullable: true} CreateDomesticOrderExportTask: type: object required: [exportType, format, taskName, parameters] diff --git a/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java b/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java index 38fb236eb..fbe7daa45 100644 --- a/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java @@ -22,6 +22,15 @@ class DomesticWarehouseOrderExportContractTest { .contains("provider-path: /internal/exports/v1/" + TYPE + "/batches") .contains("required-permission: platform:order:export") .contains("app-scope: PLATFORM"); + Map root = map(new Yaml().load(source)); + Map baiyue = map(root.get("baiyue")); + Map fileConfig = map(baiyue.get("file")); + Map export = map(fileConfig.get("export")); + Map definitions = map(export.get("definitions")); + Map orderDefinition = map(definitions.get(TYPE)); + assertThat(orderDefinition.get("batch-size")) + .as("%s must stay within Firstleg's 200-order summary limit", file) + .isEqualTo(200); } } @@ -30,6 +39,7 @@ class DomesticWarehouseOrderExportContractTest { "file/src/main/resources/openapi/domestic-order-export-v1.yaml")); Map document = map(new Yaml().load(source)); Map schemas = map(map(document.get("components")).get("schemas")); + Map paths = map(document.get("paths")); Map parameters = map(map(schemas.get("DomesticOrderExportParameters")).get("properties")); Map columns = map(map(schemas.get("DomesticOrderExportColumns")).get("properties")); @@ -48,6 +58,10 @@ class DomesticWarehouseOrderExportContractTest { .doesNotContainKeys("orderId", "tenantId", "customerId", "inboundId", "receivingWarehouseId", "destinationWarehouseId"); assertThat(source).contains("platform:order:export", "selectedOrderNos 非空时优先", "no-store"); + assertThat(paths).containsKey("/api/platform/v1/files/export-tasks/{taskId}/retry"); + assertThat(map(map(schemas.get("ExportTaskRetryState")).get("properties"))) + .containsKeys("attemptCount", "maxAttempts", "nextRetryAt", "failureMessage"); + assertThat(source).contains("PENDING 且已自动失败过的退避任务立即到期并复用当前投递消息"); } private static Path repositoryRoot() { diff --git a/file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java b/file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java index af8f29d64..3583f87d3 100644 --- a/file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java +++ b/file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java @@ -43,6 +43,19 @@ class ExportMapperContractTest { assertFalse(sql.contains("CLAIMED")); } + @Test void manualExpediteOnlyAdvancesTheCurrentPendingBackoffMessage() throws Exception { + String taskSql=update(FileExportTaskMapper.class,"expeditePendingRetry"); + String outboxSql=update(FileExportOutboxMapper.class,"expeditePendingRetry"); + for (String sql : java.util.List.of(taskSql,outboxSql)) { + assertTrue(sql.contains("dispatch_version=#{dispatchVersion}")); + assertTrue(sql.contains("status='PENDING'")); + assertTrue(sql.contains("next_retry_at>CURRENT_TIMESTAMP(3)")); + assertFalse(sql.contains("dispatch_version=dispatch_version+1")); + } + assertTrue(taskSql.contains("attempt_count>0")); + assertTrue(outboxSql.contains("task_id=#{taskId}")); + } + private String update(Class type,String method) throws Exception { Method target=java.util.Arrays.stream(type.getDeclaredMethods()).filter(candidate -> candidate.getName().equals(method)).findFirst().orElseThrow(); return String.join(" ",target.getAnnotation(Update.class).value()).replaceAll("\\s+"," "); diff --git a/file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java index e6dd2aa36..7237343eb 100644 --- a/file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java @@ -95,6 +95,19 @@ class ExportStateServiceTest { verify(tasks,never()).failQuarantined(anyLong(),anyInt(),anyString()); } + @Test void pendingBackoffIsExpeditedWithoutCreatingADuplicateCommand() { + FileExportTask task=task(9L,3,"PENDING"); + when(outbox.expeditePendingRetry(9L,3)).thenReturn(true); + when(tasks.expeditePendingRetry(9L,3)).thenReturn(true); + + org.junit.jupiter.api.Assertions.assertTrue(service.expeditePendingRetry(task)); + + InOrder order=inOrder(outbox,tasks); + order.verify(outbox).expeditePendingRetry(9L,3); + order.verify(tasks).expeditePendingRetry(9L,3); + verifyNoInteractions(commands); + } + private FileExportTask task(long id,int version,String status){ FileExportTask task=new FileExportTask();task.setId(id);task.setDispatchVersion(version);task.setStatus(status);return task; } diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java index b9347a7ba..d715efdae 100644 --- a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java @@ -171,6 +171,23 @@ class ExportTaskServiceTest { assertTrue(denied.getMessage().contains("权限")); } + @Test + void pendingTaskInAutomaticBackoffCanBeRetriedImmediately() { + FileExportTask task = new FileExportTask(); + task.setId(9L); task.setTenantId(0L); task.setAppCode("PLATFORM"); task.setRequestedBy(1L); + task.setExportType("CUSTOMER_LIST"); task.setStatus("PENDING"); task.setAttemptCount(2); + when(repository.find(9L)).thenReturn(task); + when(registry.require("CUSTOMER_LIST")).thenReturn(definition()); + when(states.expeditePendingRetry(task)).thenReturn(true); + + var response = service.retry(user(1L, Set.of("platform:customer:export")), 9L); + + assertEquals("PENDING", response.status()); + assertEquals(2, response.attemptCount()); + verify(states).expeditePendingRetry(task); + verify(storage, org.mockito.Mockito.never()).delete(any()); + } + private AuthenticatedUser user(long id, Set permissions) { return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformCustomerDimensionConfigController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformCustomerDimensionConfigController.java index 934413b7e..82c1312e3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformCustomerDimensionConfigController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformCustomerDimensionConfigController.java @@ -4,6 +4,7 @@ import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.GroupResponse; +import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.DefaultCalculationModeUpdateRequest; import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest; import com.baiyue.firstleg.service.CustomerDimensionConfigService; import com.baiyue.firstleg.service.FirstlegScopeGuard; @@ -47,4 +48,19 @@ public class PlatformCustomerDimensionConfigController { long scope = FirstlegScopeGuard.platform(user, tenantId); return ApiResponse.success(service.update(scope, user.userId(), request)); } + + @PutMapping("/default-calculation-mode") + @PreAuthorize("hasAuthority('firstleg:dimension-config:edit')") + @BusinessAudit(event = "CUSTOMER_DIMENSION_DEFAULT_MODE_UPDATE", + resourceType = "DIMENSION_CONFIG", resourceId = "#tenantId", + description = "平台设置客户默认箱规计算方式") + @Operation(summary = "设置客户默认箱规计算方式") + public ApiResponse updateDefaultCalculationMode( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @Valid @RequestBody DefaultCalculationModeUpdateRequest request) { + long scope = FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.updateDefaultCalculationMode( + scope, user.userId(), request.defaultCalculationMode())); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java index 80d64f61b..1b6458ead 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java @@ -20,6 +20,7 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -41,8 +42,9 @@ public class PlatformFbaMasterDataController { public ApiResponse> rules( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) Long tenantId, + @RequestParam(required = false) Long customerId, @RequestParam(required = false) String ruleCode) { - return ApiResponse.success(service.listRules(optionalPlatformTenant(user, tenantId), ruleCode)); + return ApiResponse.success(service.listRules(optionalPlatformTenant(user, tenantId), customerId, ruleCode)); } @GetMapping("/customer-rules/{id}") @@ -66,13 +68,24 @@ public class PlatformFbaMasterDataController { @DeleteMapping("/customer-rules/{id}") @PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:edit')") @BusinessAudit(event = "FBA_RULE_RETIRE", resourceType = "CUSTOMER_CHARGE_RULE", resourceId = "#id", - description = "停用客户FBA计费规则版本") + description = "删除客户系数配置") public ApiResponse retireRule(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long id) { service.retireRule(FirstlegScopeGuard.platform(user, tenantId), id, user.userId()); return ApiResponse.success(null); } + @PutMapping("/customer-rules/{id}/default") + @PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:edit')") + @BusinessAudit(event = "FBA_RULE_DEFAULT_SET", resourceType = "CUSTOMER_CHARGE_RULE", resourceId = "#id", + description = "设置客户默认FBA计费规则") + public ApiResponse setDefaultRule(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, @PathVariable long id) { + long scope = FirstlegScopeGuard.platform(user, tenantId); + service.setDefaultRule(scope, id, user.userId()); + return ApiResponse.success(null); + } + @GetMapping("/carrier-rates") @PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')") @Operation(summary = "查询船司 FBA 价格版本", description = "查询平台公共价格;所有客户共享") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java index 57813a240..b5c07df49 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java @@ -43,11 +43,14 @@ public class PlatformFbaPricingController { @Valid @RequestBody(required = false) CalculateRequest request) { long scope = FirstlegScopeGuard.platform(user, tenantId); return ApiResponse.success(service.calculate(scope, user.userId(), inboundId, - request == null ? null : request.pricingAt(), idempotencyKey)); + request == null ? null : request.pricingAt(), + request == null ? null : request.selectedWeightKg(), + request == null ? null : request.selectedVolumeCbm(), idempotencyKey)); } @PostMapping("/confirm") - @PreAuthorize("hasAuthority('firstleg:fba-pricing:confirm')") + @PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee'," + + " 'firstleg:fba-pricing:confirm')") @BusinessAudit(event = "FBA_FEE_CONFIRM", resourceType = "INBOUND", resourceId = "#inboundId", description = "确认FBA费用快照") public ApiResponse confirm( @@ -58,7 +61,8 @@ public class PlatformFbaPricingController { } @PostMapping("/send") - @PreAuthorize("hasAuthority('firstleg:inbound:send-fee')") + @PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee'," + + " 'firstleg:inbound:send-fee')") @BusinessAudit(event = "FBA_FEE_SEND", resourceType = "INBOUND", resourceId = "#inboundId", description = "发送FBA费用快照并进入待托盘状态") public ApiResponse send( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index 657cdf58f..a517534b3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -80,7 +80,8 @@ public class PlatformInboundMeasurementController { resourceId = "#inboundId", description = "保存入库测量草稿") @Operation(summary = "保存测量草稿", description = "保存人工测量批次和服务端计算明细,不推进入库状态。") @PutMapping("/measurement/draft") - @PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')") + @PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:warehouse-suggestion:edit'," + + " 'platform:firstleg:inbound:measure:edit')") public ApiResponse saveDraft( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java index a75c4b6b3..99e332c43 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java @@ -8,6 +8,7 @@ import jakarta.validation.constraints.Digits; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import java.math.BigDecimal; @@ -21,6 +22,10 @@ public final class CustomerDimensionConfigDtos { @NotNull @Size(min = 4, max = 4) List<@Valid RuleUpdateRequest> rules ) { } + public record DefaultCalculationModeUpdateRequest( + @NotBlank @Pattern(regexp = "ADD|MULTIPLY") String defaultCalculationMode + ) { } + public record RuleUpdateRequest( @NotNull CustomerDimensionBoxType boxType, @NotNull @DecimalMin("0.001") @Digits(integer = 6, fraction = 3) BigDecimal innerLengthCm, @@ -32,7 +37,7 @@ public final class CustomerDimensionConfigDtos { @NotNull @Min(0) Integer version ) { } - public record GroupResponse(Long tenantId, List rules) { } + public record GroupResponse(Long tenantId, String defaultCalculationMode, List rules) { } public record RuleResponse( Long id, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java index 07ca2ac16..42de4cfb5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java @@ -29,8 +29,21 @@ public final class FbaPricingDtos { @NotBlank @Pattern(regexp = "[A-Z]{3}") String currency, @NotNull LocalDateTime effectiveFrom, LocalDateTime effectiveTo, - @Size(max = 500) String remark - ) {} + @Size(max = 500) String remark, + @DecimalMin(value = "0", inclusive = false) BigDecimal lengthFactor, + @DecimalMin(value = "0", inclusive = false) BigDecimal widthFactor, + @DecimalMin(value = "0", inclusive = false) BigDecimal heightFactor, + Boolean defaultRule + ) { + public CustomerRuleVersionRequest(String ruleCode, Long customerId, String customerName, + String businessType, String chargeMode, String taxMode, + BigDecimal conversionFactor, Integer roundingScale, + String roundingMode, String currency, LocalDateTime effectiveFrom, + LocalDateTime effectiveTo, String remark) { + this(ruleCode, customerId, customerName, businessType, chargeMode, taxMode, conversionFactor, + roundingScale, roundingMode, currency, effectiveFrom, effectiveTo, remark, null, null, null, null); + } + } public record CarrierRateVersionRequest( @NotBlank @Size(max = 50) String rateCode, @@ -50,7 +63,17 @@ public final class FbaPricingDtos { @Size(max = 500) String remark ) {} - public record CalculateRequest(LocalDateTime pricingAt) {} + public record CalculateRequest( + LocalDateTime pricingAt, + @DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg, + @DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm, + @Size(max = 32) String weightSource, + @Size(max = 32) String volumeSource + ) { + public CalculateRequest(LocalDateTime pricingAt) { + this(pricingAt, null, null, null, null); + } + } public record AdjustFeeRequest( @NotNull @Positive Integer expectedFeeVersion, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java index 6f899c510..b1d60ed3f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java @@ -55,8 +55,29 @@ public final class InboundMeasurementRequests { @DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerLengthCm, @DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerWidthCm, @DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerHeightCm, + @Positive Long chargeRuleId, + @jakarta.validation.constraints.Pattern(regexp = "ADD|MULTIPLY") String coefficientMode, @Size(max = 500) String remark ) { + public ItemRequest(Long inboundDetailId, String itemNo, String boxSpecification, Integer packageCount, + BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, + Long intelligentImageFileId, String overLimitWarning, + BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm, + Long chargeRuleId, String remark) { + this(inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm, + intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm, + chargeRuleId, null, remark); + } + + public ItemRequest(Long inboundDetailId, String itemNo, String boxSpecification, Integer packageCount, + BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, + Long intelligentImageFileId, String overLimitWarning, + BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm, + String remark) { + this(inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm, + intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm, + null, null, remark); + } } public record SubmitRequest( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java index a4893f879..74c2b5956 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -90,6 +90,8 @@ public final class InboundMeasurementResponses { BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm, + Long chargeRuleId, + String coefficientMode, String remark, String boxCode, String scannedShipmentId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java index 653d4c315..8e7ecfda9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java @@ -40,7 +40,28 @@ public record MeasurementBatchCommand( BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm, + Long chargeRuleId, + String coefficientMode, String remark ) { + public Item(Long inboundDetailId, String itemNo, String boxSpecification, int packageCount, + BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, + Long intelligentImageFileId, String overLimitWarning, + BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm, + Long chargeRuleId, String remark) { + this(inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm, + intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm, + chargeRuleId, null, remark); + } + + public Item(Long inboundDetailId, String itemNo, String boxSpecification, int packageCount, + BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, + Long intelligentImageFileId, String overLimitWarning, + BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm, + String remark) { + this(inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm, + intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm, + null, null, remark); + } } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerChargeRuleEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerChargeRuleEntity.java index 681238e2e..45a795675 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerChargeRuleEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerChargeRuleEntity.java @@ -26,6 +26,11 @@ public class CustomerChargeRuleEntity extends BaseEntity { private String chargeMode; private String taxMode; private BigDecimal conversionFactor; + /** 体积计算各方向系数;历史数据为空时按 1 处理。 */ + private BigDecimal lengthFactor; + private BigDecimal widthFactor; + private BigDecimal heightFactor; + private Boolean defaultRule; private Integer roundingScale; private String roundingMode; private String currency; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java index e19926031..4ed2769d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java @@ -23,6 +23,7 @@ public class CustomerDimensionConfigEntity extends BaseEntity { private BigDecimal innerWidthCm; private BigDecimal innerHeightCm; private BigDecimal edgeAdditionCm; + private String defaultCalculationMode; private String remark; private String initializationKey; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java index c012a9f85..9342d5412 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java @@ -34,6 +34,8 @@ public class InboundMeasurementItemEntity { private BigDecimal volumeCbm; private BigDecimal totalVolumeCbm; private BigDecimal volumeWeightKg; + private Long chargeRuleId; + private String coefficientMode; private String calculationRuleVersion; private Long sourceImageFileId; private String machineNo; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/CustomerDimensionConfigMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/CustomerDimensionConfigMapper.java index 92fdfb98b..55fb0ebfa 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/CustomerDimensionConfigMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/CustomerDimensionConfigMapper.java @@ -8,6 +8,7 @@ import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import java.util.List; @@ -17,12 +18,12 @@ public interface CustomerDimensionConfigMapper extends BaseMapper INSERT IGNORE INTO fl_customer_dimension_config (id, tenant_id, box_type, inner_length_cm, inner_width_cm, inner_height_cm, - edge_addition_cm, remark, initialization_key, created_by, created_at, + edge_addition_cm, default_calculation_mode, remark, initialization_key, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (#{row.id}, #{row.tenantId}, #{row.boxType}, #{row.innerLengthCm}, #{row.innerWidthCm}, - #{row.innerHeightCm}, #{row.edgeAdditionCm}, #{row.remark}, #{row.initializationKey}, + #{row.innerHeightCm}, #{row.edgeAdditionCm}, #{row.defaultCalculationMode}, #{row.remark}, #{row.initializationKey}, #{operatorId}, CURRENT_TIMESTAMP(3), #{operatorId}, CURRENT_TIMESTAMP(3), 0, 0) @@ -32,7 +33,7 @@ public interface CustomerDimensionConfigMapper extends BaseMapper selectDefaultsForCompensation( @Param("tenantId") long tenantId, @Param("initializationKey") String initializationKey); + @Update(""" + UPDATE fl_customer_dimension_config + SET default_calculation_mode = #{mode}, updated_by = #{operatorId}, + updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE tenant_id = #{tenantId} AND deleted = 0 + """) + int updateDefaultCalculationMode(@Param("tenantId") long tenantId, + @Param("mode") String mode, + @Param("operatorId") long operatorId); + @Delete(""" DELETE FROM fl_customer_dimension_config WHERE tenant_id = #{tenantId} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/CustomerDimensionConfigRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/CustomerDimensionConfigRepository.java index 2bebaf7fd..abe5d48de 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/CustomerDimensionConfigRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/CustomerDimensionConfigRepository.java @@ -26,6 +26,10 @@ public class CustomerDimensionConfigRepository { return mapper.updateById(value); } + public int updateDefaultCalculationMode(long tenantId, String mode, long operatorId) { + return mapper.updateDefaultCalculationMode(tenantId, mode, operatorId); + } + public List findDefaultsForCompensation(long tenantId, String initializationKey) { return mapper.selectDefaultsForCompensation(tenantId, initializationKey); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index 1f8929b1c..f2534b195 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -42,6 +42,24 @@ public class FbaPricingRepository { private InboundOutboxMapper outbox; public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); } + + public int clearDefaultRule(long tenantId, long customerId, long operatorId) { + return rules.update(null, new LambdaUpdateWrapper() + .eq(CustomerChargeRuleEntity::getTenantId, tenantId) + .eq(CustomerChargeRuleEntity::getCustomerId, customerId) + .eq(CustomerChargeRuleEntity::getDefaultRule, true) + .set(CustomerChargeRuleEntity::getDefaultRule, false) + .set(CustomerChargeRuleEntity::getUpdatedBy, operatorId)); + } + + public int setDefaultRule(long tenantId, long id, long operatorId) { + return rules.update(null, new LambdaUpdateWrapper() + .eq(CustomerChargeRuleEntity::getTenantId, tenantId) + .eq(CustomerChargeRuleEntity::getId, id) + .eq(CustomerChargeRuleEntity::getStatus, "ACTIVE") + .set(CustomerChargeRuleEntity::getDefaultRule, true) + .set(CustomerChargeRuleEntity::getUpdatedBy, operatorId)); + } public void insertRate(CarrierFbaRateEntity rate) { rates.insert(rate); } public CustomerChargeRuleEntity findRule(long tenantId, long id) { @@ -50,6 +68,15 @@ public class FbaPricingRepository { .eq(CustomerChargeRuleEntity::getId, id)); } + public CustomerChargeRuleEntity findDefaultRule(long tenantId, long customerId) { + return rules.selectOne(new LambdaQueryWrapper() + .eq(CustomerChargeRuleEntity::getTenantId, tenantId) + .eq(CustomerChargeRuleEntity::getCustomerId, customerId) + .eq(CustomerChargeRuleEntity::getStatus, "ACTIVE") + .eq(CustomerChargeRuleEntity::getDefaultRule, true) + .last("LIMIT 1")); + } + public CarrierFbaRateEntity findRate(long id) { return rates.selectOne(new LambdaQueryWrapper() .eq(CarrierFbaRateEntity::getTenantId, CarrierFbaRateEntity.PLATFORM_SCOPE) @@ -57,10 +84,16 @@ public class FbaPricingRepository { } public List listRules(Long tenantId, String ruleCode) { + return listRules(tenantId, null, ruleCode); + } + + public List listRules(Long tenantId, Long customerId, String ruleCode) { return rules.selectList(new LambdaQueryWrapper() .eq(tenantId != null, CustomerChargeRuleEntity::getTenantId, tenantId) + .eq(customerId != null, CustomerChargeRuleEntity::getCustomerId, customerId) .eq(ruleCode != null && !ruleCode.isBlank(), CustomerChargeRuleEntity::getRuleCode, ruleCode) .orderByAsc(CustomerChargeRuleEntity::getRuleCode) + .orderByDesc(CustomerChargeRuleEntity::getDefaultRule) .orderByDesc(CustomerChargeRuleEntity::getRuleVersion)); } @@ -101,6 +134,7 @@ public class FbaPricingRepository { .eq(CustomerChargeRuleEntity::getId, id) .eq(CustomerChargeRuleEntity::getStatus, "ACTIVE") .set(CustomerChargeRuleEntity::getStatus, "INACTIVE") + .set(CustomerChargeRuleEntity::getDefaultRule, false) .set(CustomerChargeRuleEntity::getUpdatedBy, operatorId)); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java index f91de8523..6dd1b7f84 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java @@ -26,6 +26,7 @@ import java.util.Map; @Service public class CustomerDimensionConfigService { public static final BigDecimal DEFAULT_EDGE_ADDITION_CM = new BigDecimal("2.000"); + public static final String DEFAULT_CALCULATION_MODE = "ADD"; private static final BigDecimal TWO = new BigDecimal("2"); private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000"); @@ -65,6 +66,17 @@ public class CustomerDimensionConfigService { return response(tenantId, existing); } + @Transactional + public GroupResponse updateDefaultCalculationMode(long tenantId, long operatorId, String mode) { + String normalizedMode = normalizeCalculationMode(mode); + requireCompleteRules(repository.findAll(tenantId)); + if (repository.updateDefaultCalculationMode(tenantId, normalizedMode, operatorId) + != CustomerDimensionBoxType.values().length) { + throw staleConfiguration(); + } + return response(tenantId, requireCompleteRules(repository.findAll(tenantId))); + } + @Transactional public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey) { requireTenant(tenantId); @@ -159,6 +171,7 @@ public class CustomerDimensionConfigService { value.setInnerWidthCm(type.defaultWidthCm()); value.setInnerHeightCm(type.defaultHeightCm()); value.setEdgeAdditionCm(DEFAULT_EDGE_ADDITION_CM); + value.setDefaultCalculationMode(DEFAULT_CALCULATION_MODE); value.setRemark(type.defaultRemark()); value.setInitializationKey(initializationKey); rows.add(value); @@ -181,6 +194,7 @@ public class CustomerDimensionConfigService { || value.getVersion() == null || value.getVersion() != 0 || value.getInnerLengthCm() == null || value.getInnerWidthCm() == null || value.getInnerHeightCm() == null || value.getEdgeAdditionCm() == null + || !DEFAULT_CALCULATION_MODE.equals(normalizeCalculationMode(value.getDefaultCalculationMode())) || value.getInnerLengthCm().compareTo(type.defaultLengthCm()) != 0 || value.getInnerWidthCm().compareTo(type.defaultWidthCm()) != 0 || value.getInnerHeightCm().compareTo(type.defaultHeightCm()) != 0 @@ -200,7 +214,12 @@ public class CustomerDimensionConfigService { } private static GroupResponse response(long tenantId, List values) { - return new GroupResponse(tenantId, values.stream().map(CustomerDimensionConfigService::ruleResponse).toList()); + String mode = normalizeCalculationMode(values.get(0).getDefaultCalculationMode()); + if (values.stream().anyMatch(value -> !mode.equals(normalizeCalculationMode(value.getDefaultCalculationMode())))) { + throw new BizException(ResultCode.INTERNAL_ERROR, "客户默认箱规计算方式配置不一致"); + } + return new GroupResponse(tenantId, mode, + values.stream().map(CustomerDimensionConfigService::ruleResponse).toList()); } private static RuleResponse ruleResponse(CustomerDimensionConfigEntity value) { @@ -232,6 +251,15 @@ public class CustomerDimensionConfigService { return value.trim(); } + private static String normalizeCalculationMode(String value) { + if (value == null || value.isBlank()) return DEFAULT_CALCULATION_MODE; + String normalized = value.trim().toUpperCase(); + if (!"ADD".equals(normalized) && !"MULTIPLY".equals(normalized)) { + throw new BizException(ResultCode.BAD_REQUEST, "默认箱规计算方式无效"); + } + return normalized; + } + private static void requireTenant(long tenantId) { if (tenantId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "客户ID无效"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java index 4e76a9ede..d829a013e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java @@ -69,7 +69,7 @@ public class CustomerDimensionWeightingService { BigDecimal heightCm) { BigDecimal rawVolume = lengthCm.multiply(widthCm).multiply(heightCm) .divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP); - return new WeightedDimensions(null, null, lengthCm, widthCm, heightCm, + return new WeightedDimensions(null, null, null, lengthCm, widthCm, heightCm, rawVolume, rawVolume.setScale(3, RoundingMode.HALF_UP), "ACTUAL_DIMENSION_V1"); } @@ -78,6 +78,7 @@ public class CustomerDimensionWeightingService { public record WeightedDimensions( CustomerDimensionBoxType boxType, Integer ruleVersion, + BigDecimal edgeAdditionCm, BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm, @@ -119,7 +120,7 @@ public class CustomerDimensionWeightingService { BigDecimal rawVolume = outerLength.multiply(outerWidth).multiply(outerHeight) .divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP); BigDecimal unitVolume = rawVolume.setScale(3, RoundingMode.HALF_UP); - return new WeightedDimensions(boxType, version, outerLength, outerWidth, outerHeight, + return new WeightedDimensions(boxType, version, edgeAdditionCm, outerLength, outerWidth, outerHeight, rawVolume, unitVolume, "CUSTOMER_DIMENSION_" + boxType.name() + "_V" + version); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java index 4a9afefe0..c051e6e57 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java @@ -26,15 +26,6 @@ public class FbaMasterDataService { public CustomerChargeRuleEntity createRuleVersion(long tenantId, long operatorId, CustomerRuleVersionRequest request) { validatePeriod(request.effectiveFrom(), request.effectiveTo()); - boolean overlaps = repository.listRules(tenantId, null).stream() - .filter(v -> "ACTIVE".equals(v.getStatus())) - .filter(v -> Objects.equals(v.getCustomerId(), request.customerId())) - .filter(v -> Objects.equals(v.getBusinessType(), request.businessType())) - .filter(v -> Objects.equals(v.getTaxMode(), request.taxMode())) - .anyMatch(v -> overlaps(v.getEffectiveFrom(), v.getEffectiveTo(), - request.effectiveFrom(), request.effectiveTo())); - if (overlaps) throw conflict("客户计费规则有效期重叠,无法保证唯一匹配"); - CustomerChargeRuleEntity value = new CustomerChargeRuleEntity(); value.setTenantId(tenantId); value.setRuleCode(request.ruleCode()); @@ -45,6 +36,10 @@ public class FbaMasterDataService { value.setChargeMode(request.chargeMode()); value.setTaxMode(request.taxMode()); value.setConversionFactor(request.conversionFactor()); + value.setLengthFactor(defaultFactor(request.lengthFactor())); + value.setWidthFactor(defaultFactor(request.widthFactor())); + value.setHeightFactor(defaultFactor(request.heightFactor())); + value.setDefaultRule(false); value.setRoundingScale(request.roundingScale()); value.setRoundingMode(request.roundingMode()); value.setCurrency(request.currency()); @@ -58,6 +53,15 @@ public class FbaMasterDataService { return value; } + @Transactional + public void setDefaultRule(long tenantId, long id, long operatorId) { + CustomerChargeRuleEntity value = rule(tenantId, id); + repository.clearDefaultRule(tenantId, value.getCustomerId(), operatorId); + if (repository.setDefaultRule(tenantId, id, operatorId) != 1) { + throw conflict("规则已停用或不存在"); + } + } + @Transactional public CarrierFbaRateEntity createRateVersion(long operatorId, CarrierRateVersionRequest request) { validatePeriod(request.effectiveFrom(), request.effectiveTo()); @@ -102,6 +106,10 @@ public class FbaMasterDataService { return repository.listRules(tenantId, code); } + public List listRules(Long tenantId, Long customerId, String code) { + return repository.listRules(tenantId, customerId, code); + } + public List listRates(String code) { return repository.listRates(code); } @@ -142,4 +150,8 @@ public class FbaMasterDataService { private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } + + private static java.math.BigDecimal defaultFactor(java.math.BigDecimal value) { + return value == null ? java.math.BigDecimal.ONE : value; + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index ecd089459..7d0c17d18 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -49,7 +49,17 @@ public class FbaPricingService { @Transactional public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId, LocalDateTime requestedAt, String idempotencyKey) { - String hash = hash("CALCULATE", inboundId, requestedAt == null ? "CURRENT" : requestedAt.toString()); + return calculate(tenantId, operatorId, inboundId, requestedAt, null, null, idempotencyKey); + } + + @Transactional + public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId, + LocalDateTime requestedAt, BigDecimal selectedWeightKg, + BigDecimal selectedVolumeCbm, String idempotencyKey) { + String discriminator = (requestedAt == null ? "CURRENT" : requestedAt.toString()) + ":" + + (selectedWeightKg == null ? "ACTUAL_WEIGHT" : selectedWeightKg) + ":" + + (selectedVolumeCbm == null ? "ACTUAL_VOLUME" : selectedVolumeCbm); + String hash = hash("CALCULATE", inboundId, discriminator); InboundPricingIdempotencyEntity token = beginIdempotency( tenantId, operatorId, inboundId, "FEE_CALCULATE", idempotencyKey, hash); if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null); @@ -58,10 +68,11 @@ public class FbaPricingService { ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest( inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null, - pricingTaxMode(inbound), inbound.getActualNetWeightKg(), inbound.getActualVolumeCbm())); + pricingTaxMode(inbound), selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg, + selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm)); RateRow rate = estimate.rate(); - BigDecimal weight = inbound.getActualNetWeightKg().setScale(3, RoundingMode.HALF_UP); - BigDecimal volume = inbound.getActualVolumeCbm().setScale(4, RoundingMode.HALF_UP); + BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, RoundingMode.HALF_UP); + BigDecimal volume = (selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm).setScale(4, RoundingMode.HALF_UP); BigDecimal chargeable = estimate.chargeableQuantity(); BigDecimal converted = chargeable; BigDecimal subtotal = estimate.amount().setScale(MONEY_SCALE, RoundingMode.HALF_UP); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index 079ab913f..9b9cad116 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -54,6 +54,8 @@ public class InboundMeasurementService { @Resource private MeasurementBatchService batchService; @Resource + private FbaMasterDataService fbaMasterDataService; + @Resource private OrderCargoClient orderCargoClient; @Value("${baiyue.security.internal-service-token:}") private String internalToken; @@ -91,6 +93,13 @@ public class InboundMeasurementService { InboundMeasurementRequests.SaveDraftRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); validateInboundDetails(tenantId, inbound, request.items()); + request.items().stream().map(InboundMeasurementRequests.ItemRequest::chargeRuleId) + .filter(java.util.Objects::nonNull).distinct().forEach(ruleId -> { + var rule = fbaMasterDataService.rule(tenantId, ruleId); + if (!"ACTIVE".equals(rule.getStatus()) || !java.util.Objects.equals(rule.getCustomerId(), inbound.getCustomerId())) { + throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细绑定的计费规则不属于当前客户或已停用"); + } + }); InboundMeasurementEntity existing = request.measurementId() == null ? null : repository.findMeasurement(tenantId, inboundId, request.measurementId()); OrderCargoClient.MeasurementDeclarationSnapshot declaration = loadDeclaration(inbound); @@ -112,7 +121,7 @@ public class InboundMeasurementService { item.inboundDetailId(), item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(), item.widthCm(), item.heightCm(), item.intelligentImageFileId(), item.overLimitWarning(), item.outerLengthCm(), item.outerWidthCm(), item.outerHeightCm(), - item.remark())).toList()); + item.chargeRuleId(), item.coefficientMode(), item.remark())).toList()); InboundMeasurementEntity saved = batchService.saveBatch(command); return new InboundMeasurementResponses.DraftSaved(saved.getId(), saved.getVersion(), saved.getStatus()); } @@ -531,7 +540,8 @@ public class InboundMeasurementService { item.getUnitVolumeCbm(), item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(), item.getOverLimitWarning(), item.getOuterLengthCm(), item.getOuterWidthCm(), - item.getOuterHeightCm(), item.getRemark(), item.getBoxCode(), item.getScannedShipmentId(), + item.getOuterHeightCm(), item.getChargeRuleId(), item.getCoefficientMode(), + item.getRemark(), item.getBoxCode(), item.getScannedShipmentId(), item.getPackageCount(), item.getMachineNo(), item.getDeviceMeasuredAt(), item.getUpdatedAt()); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index f9b6099e3..1a4543aca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -7,6 +7,8 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.MeasurementBatchCommand; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; +import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; +import com.baiyue.firstleg.repository.FbaPricingRepository; import com.baiyue.firstleg.repository.InboundMeasurementRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,10 +17,14 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDateTime; import java.util.HashMap; +import java.util.ArrayList; +import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * 后台录入和第三方回传共用的测量批次持久化服务。 @@ -39,10 +45,14 @@ public class MeasurementBatchService { static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); private static final BigDecimal MAX_RAW_VOLUME_CBM = new BigDecimal("999999999999.999999"); private static final BigDecimal MAX_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999"); + private static final Pattern SPECIFICATION_COEFFICIENT_PATTERN = Pattern.compile( + "(SMALL|MEDIUM|LARGE|EXTRA_LARGE):(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?);系数:(\\d+(?:\\.\\d+)?)"); @Resource private InboundMeasurementRepository repository; @Resource private CustomerDimensionWeightingService dimensionWeighting; + @Resource + private FbaPricingRepository pricingRepository; @Transactional public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) { @@ -52,7 +62,7 @@ public class MeasurementBatchService { } validateSource(command); List calculatedItems = calculateItems( - command, dimensionWeighting.load(command.tenantId())); + command, dimensionWeighting.load(command.tenantId()), inbound.getCustomerId()); InboundMeasurementEntity measurement; if (command.measurementId() == null) { @@ -166,7 +176,8 @@ public class MeasurementBatchService { } private List calculateItems( - MeasurementBatchCommand command, CustomerDimensionWeightingService.RuleSet dimensionRules) { + MeasurementBatchCommand command, CustomerDimensionWeightingService.RuleSet dimensionRules, + Long customerId) { Set itemNumbers = new HashSet<>(); return command.items().stream().map(source -> { if (!itemNumbers.add(source.itemNo())) { @@ -174,8 +185,49 @@ public class MeasurementBatchService { } CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolve( source.lengthCm(), source.widthCm(), source.heightCm()); - BigDecimal unitVolumeRaw = weighted.rawVolumeCbm(); - BigDecimal unitVolume = weighted.unitVolumeCbm(); + String requestedMode = normalizeMode(source.coefficientMode()); + CustomerChargeRuleEntity coefficientRule = "MULTIPLY".equals(requestedMode) + ? requireCoefficientRule(command.tenantId(), customerId, source.chargeRuleId()) + : requestedMode == null ? findDefaultCoefficientRule(command.tenantId(), customerId) : null; + String coefficientMode = coefficientRule != null ? "MULTIPLY" : requestedMode; + boolean useDimensionRule = "ADD".equals(coefficientMode); + // The box-dimension strategy bills against the matched carton tier. + // This is important when a measured carton is smaller than a standard + // carton: use the configured outer dimensions, rather than adding the + // edge allowance to each measured side. + BigDecimal addition = weighted.edgeAdditionCm(); + boolean hasMatchedAddition = useDimensionRule && weighted.boxType() != null; + BigDecimal outerLength = hasMatchedAddition ? weighted.outerLengthCm() : source.lengthCm(); + BigDecimal outerWidth = hasMatchedAddition ? weighted.outerWidthCm() : source.widthCm(); + BigDecimal outerHeight = hasMatchedAddition ? weighted.outerHeightCm() : source.heightCm(); + boolean hasOuterOverride = source.outerLengthCm() != null + || source.outerWidthCm() != null + || source.outerHeightCm() != null; + if (hasOuterOverride && (source.outerLengthCm() == null + || source.outerWidthCm() == null || source.outerHeightCm() == null + || source.outerLengthCm().signum() <= 0 || source.outerWidthCm().signum() <= 0 + || source.outerHeightCm().signum() <= 0)) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "仓库修改外箱长、宽、高必须同时填写且大于0,明细 " + source.itemNo()); + } + if (hasOuterOverride) { + // Warehouse-confirmed outer dimensions are authoritative. Derived + // values below (girth, volume and volumetric weight) use them directly. + outerLength = source.outerLengthCm(); + outerWidth = source.outerWidthCm(); + outerHeight = source.outerHeightCm(); + } else if (coefficientRule != null) { + BigDecimal specificationCoefficient = resolveSpecificationCoefficient( + coefficientRule, source.lengthCm(), source.widthCm(), source.heightCm()); + outerLength = applyCoefficient(outerLength, specificationCoefficient); + outerWidth = applyCoefficient(outerWidth, specificationCoefficient); + outerHeight = applyCoefficient(outerHeight, specificationCoefficient); + } + BigDecimal unitVolumeRaw = outerLength.multiply(outerWidth).multiply(outerHeight) + .divide(new BigDecimal("1000000"), 6, RoundingMode.HALF_UP); + BigDecimal unitVolume = hasMatchedAddition && !hasOuterOverride + ? unitVolumeRaw.setScale(3, RoundingMode.HALF_UP) + : unitVolumeRaw.setScale(4, RoundingMode.CEILING); BigDecimal packageCount = BigDecimal.valueOf(source.packageCount()); BigDecimal totalVolumeRaw = unitVolumeRaw.multiply(packageCount).setScale(6, RoundingMode.HALF_UP); BigDecimal totalVolume; @@ -197,25 +249,115 @@ public class MeasurementBatchService { item.setLengthCm(source.lengthCm()); item.setWidthCm(source.widthCm()); item.setHeightCm(source.heightCm()); - item.setGirthCm(source.lengthCm().add(source.widthCm().add(source.heightCm()) - .multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING)); + BigDecimal girth = outerLength.add(outerWidth.add(outerHeight).multiply(BigDecimal.valueOf(2))); + item.setGirthCm(girth.setScale(3, RoundingMode.CEILING)); item.setUnitVolumeCbm(unitVolume); item.setVolumeRawCbm(totalVolumeRaw); item.setVolumeCbm(totalVolume); item.setTotalVolumeCbm(totalVolume); item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING)); - item.setCalculationRuleVersion(weighted.calculationRuleVersion()); + item.setChargeRuleId(coefficientRule == null ? null : coefficientRule.getId()); + item.setCoefficientMode(coefficientMode); + item.setCalculationRuleVersion(hasOuterOverride ? "WAREHOUSE_OUTER_OVERRIDE_V1" + : coefficientRule != null + ? "CUSTOMER_COEFFICIENT_V" + coefficientRule.getRuleVersion() + "_MULTIPLY" + : useDimensionRule ? weighted.calculationRuleVersion() : "ACTUAL_DIMENSION_V1"); item.setSourceImageFileId(source.intelligentImageFileId()); item.setOverLimitWarning(source.overLimitWarning()); - item.setOuterLengthCm(weighted.outerLengthCm()); - item.setOuterWidthCm(weighted.outerWidthCm()); - item.setOuterHeightCm(weighted.outerHeightCm()); + item.setOuterLengthCm(outerLength.setScale(3, RoundingMode.HALF_UP)); + item.setOuterWidthCm(outerWidth.setScale(3, RoundingMode.HALF_UP)); + item.setOuterHeightCm(outerHeight.setScale(3, RoundingMode.HALF_UP)); item.setRemark(source.remark()); item.setCreatedAt(LocalDateTime.now()); return item; }).toList(); } + private String normalizeMode(String mode) { + if (mode == null || mode.isBlank()) return null; + String normalized = mode.trim().toUpperCase(java.util.Locale.ROOT); + if (!Set.of("ADD", "MULTIPLY").contains(normalized)) { + throw new BizException(ResultCode.VALIDATION_FAILED, "系数模式仅支持 ADD 或 MULTIPLY"); + } + return normalized; + } + + private CustomerChargeRuleEntity findDefaultCoefficientRule(long tenantId, Long customerId) { + if (customerId == null || customerId <= 0) return null; + return pricingRepository.findDefaultRule(tenantId, customerId); + } + + private CustomerChargeRuleEntity requireCoefficientRule(long tenantId, Long customerId, Long ruleId) { + if (customerId == null || customerId <= 0) { + if (ruleId == null) return null; + throw new BizException(ResultCode.VALIDATION_FAILED, "入库单客户无效,不能应用计费系数"); + } + CustomerChargeRuleEntity rule = ruleId == null + ? pricingRepository.findDefaultRule(tenantId, customerId) + : pricingRepository.findRule(tenantId, ruleId); + if (rule == null || !"ACTIVE".equals(rule.getStatus()) + || !Boolean.TRUE.equals(rule.getDefaultRule()) || !customerId.equals(rule.getCustomerId())) { + throw new BizException(ResultCode.VALIDATION_FAILED, "计费规则不属于当前客户、未启用或未设置默认规则"); + } + return rule; + } + + private BigDecimal applyCoefficient(BigDecimal dimension, BigDecimal coefficient) { + if (coefficient == null) coefficient = BigDecimal.ONE; + BigDecimal result = dimension.multiply(coefficient); + if (result.compareTo(BigDecimal.ZERO) <= 0 || result.compareTo(new BigDecimal("999999999.999")) > 0) { + throw new BizException(ResultCode.VALIDATION_FAILED, "应用计费系数后的箱规尺寸超出允许范围"); + } + return result; + } + + private BigDecimal resolveSpecificationCoefficient(CustomerChargeRuleEntity rule, + BigDecimal length, BigDecimal width, + BigDecimal height) { + List tiers = parseSpecificationCoefficients(rule.getRemark()); + if (tiers.isEmpty()) { + return rule.getLengthFactor() == null ? BigDecimal.ONE : rule.getLengthFactor(); + } + List measured = sortedDimensions(length, width, height); + return tiers.stream() + .filter(tier -> tier.contains(measured)) + .findFirst() + .orElse(tiers.get(tiers.size() - 1)) + .coefficient(); + } + + private List parseSpecificationCoefficients(String remark) { + if (remark == null || remark.isBlank()) return List.of(); + Matcher matcher = SPECIFICATION_COEFFICIENT_PATTERN.matcher(remark); + List tiers = new ArrayList<>(); + while (matcher.find()) { + List dimensions = sortedDimensions(new BigDecimal(matcher.group(2)), + new BigDecimal(matcher.group(3)), new BigDecimal(matcher.group(4))); + BigDecimal coefficient = new BigDecimal(matcher.group(5)); + if (coefficient.signum() > 0) tiers.add(new SpecificationCoefficient(dimensions, coefficient)); + } + tiers.sort(Comparator.comparing(SpecificationCoefficient::volume)); + return tiers; + } + + private List sortedDimensions(BigDecimal length, BigDecimal width, BigDecimal height) { + List dimensions = new ArrayList<>(List.of(length, width, height)); + dimensions.sort(Comparator.reverseOrder()); + return dimensions; + } + + private record SpecificationCoefficient(List dimensions, BigDecimal coefficient) { + private BigDecimal volume() { + return dimensions.get(0).multiply(dimensions.get(1)).multiply(dimensions.get(2)); + } + + private boolean contains(List measured) { + return measured.get(0).compareTo(dimensions.get(0)) <= 0 + && measured.get(1).compareTo(dimensions.get(1)) <= 0 + && measured.get(2).compareTo(dimensions.get(2)) <= 0; + } + } + private static BizException measurementRangeExceeded(MeasurementBatchCommand.Item source) { return new BizException(ResultCode.VALIDATION_FAILED, "箱规尺寸或箱数超出系统允许范围,明细 " + source.itemNo() + ":" diff --git a/firstleg/src/main/resources/db/customer-dimension-config.sql b/firstleg/src/main/resources/db/customer-dimension-config.sql index 30929988f..6b6edc7d4 100644 --- a/firstleg/src/main/resources/db/customer-dimension-config.sql +++ b/firstleg/src/main/resources/db/customer-dimension-config.sql @@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_dimension_config ( inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)', inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)', edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数', + default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ADD' COMMENT '订单外箱规默认计算方式:ADD或MULTIPLY', remark VARCHAR(500) NULL COMMENT '备注', initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', diff --git a/firstleg/src/main/resources/db/firstleg.sql b/firstleg/src/main/resources/db/firstleg.sql index 762d0a30e..e21360d74 100644 --- a/firstleg/src/main/resources/db/firstleg.sql +++ b/firstleg/src/main/resources/db/firstleg.sql @@ -57,6 +57,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_dimension_config ( inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)', inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)', edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数', + default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ADD' COMMENT '订单外箱规默认计算方式:ADD或MULTIPLY', remark VARCHAR(500) NULL COMMENT '备注', initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', diff --git a/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql b/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql index 5024450c5..5a99b447b 100644 --- a/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql +++ b/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql @@ -20,6 +20,10 @@ CALL phase2_fba_add_column('fl_inbound_order', 'carrier_id', "BIGINT UNSIGNED NU CALL phase2_fba_add_column('fl_inbound_order', 'carrier_name', "VARCHAR(128) NULL COMMENT '计费船司名称快照' AFTER carrier_id"); CALL phase2_fba_add_column('fl_carrier_fba_rate', 'business_type', "VARCHAR(32) NOT NULL DEFAULT 'FBA' COMMENT '业务或计费类型' AFTER origin_warehouse_name"); CALL phase2_fba_add_column('fl_customer_charge_rule', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted"); +CALL phase2_fba_add_column('fl_customer_charge_rule', 'length_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '长度计算系数' AFTER conversion_factor"); +CALL phase2_fba_add_column('fl_customer_charge_rule', 'width_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '宽度计算系数' AFTER length_factor"); +CALL phase2_fba_add_column('fl_customer_charge_rule', 'height_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '高度计算系数' AFTER width_factor"); +CALL phase2_fba_add_column('fl_customer_charge_rule', 'default_rule', "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '客户默认生效规则' AFTER status"); CALL phase2_fba_add_column('fl_carrier_fba_rate', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted"); CALL phase2_fba_add_column('fl_inbound_fee', 'rule_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '客户规则编码快照' AFTER carrier_rate_version"); CALL phase2_fba_add_column('fl_inbound_fee', 'customer_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '客户ID快照' AFTER rule_code"); diff --git a/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql b/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql index 69cfd6e26..142a7d045 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql @@ -59,6 +59,10 @@ CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_width_cm', 'DECIMAL(12,3) NULL COMMENT ''外箱规宽度cm'' AFTER outer_length_cm'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_height_cm', 'DECIMAL(12,3) NULL COMMENT ''外箱规高度cm'' AFTER outer_width_cm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'charge_rule_id', + 'BIGINT UNSIGNED NULL COMMENT ''该测量明细绑定的客户计费规则ID'' AFTER volume_weight_kg'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'coefficient_mode', + 'VARCHAR(16) NULL COMMENT ''系数模式快照:ADD或MULTIPLY'' AFTER charge_rule_id'); DROP PROCEDURE IF EXISTS phase2_measure_add_column; diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 23d6903db..f0f17a054 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -270,6 +270,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item ( volume_cbm DECIMAL(18,4) NOT NULL COMMENT '业务体积m3', total_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本箱规总体积m3', volume_weight_kg DECIMAL(18,3) NOT NULL COMMENT '本箱规材积重kg', + charge_rule_id BIGINT UNSIGNED NULL COMMENT '该测量明细绑定的客户计费规则ID', + coefficient_mode VARCHAR(16) NULL COMMENT '系数模式快照:ADD或MULTIPLY', calculation_rule_version VARCHAR(64) NOT NULL COMMENT '体积计算和舍入规则版本', source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID', machine_no VARCHAR(64) NULL COMMENT '本次测量设备编号', @@ -860,6 +862,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_dimension_config ( inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)', inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)', edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数', + default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ADD' COMMENT '订单外箱规默认计算方式:ADD或MULTIPLY', remark VARCHAR(500) NULL COMMENT '备注', initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index ea26300ee..1b8cfd611 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -12,6 +12,10 @@ paths: description: 需要 firstleg:fba-pricing:rule:view 权限 parameters: - {$ref: '#/components/parameters/OptionalTenantId'} + - name: customerId + in: query + required: false + schema: {type: integer, format: int64, minimum: 1} - {name: ruleCode, in: query, schema: {type: string, maxLength: 50}} responses: '200': @@ -23,10 +27,16 @@ paths: /api/platform/v1/fba-pricing/customer-rules/versions: post: summary: 新建客户计费规则版本 - description: 版本内容不可原地修改;有效期使用 [effectiveFrom,effectiveTo) 半开区间。 + description: 新增配置固定为未启用,可保存有效期重叠的多条配置;保存后通过设置默认规则接口启用其中一条。 parameters: [{$ref: '#/components/parameters/TenantId'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CustomerRuleVersionRequest'}}}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + /api/platform/v1/fba-pricing/customer-rules/{id}/default: + put: + summary: 启用客户计费规则 + description: 将指定配置设为当前客户唯一默认生效规则,并取消原默认规则。 + parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/Id'}] + responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} /api/platform/v1/fba-pricing/customer-rules/{id}: get: summary: 查询客户计费规则版本详情 @@ -137,13 +147,13 @@ paths: /api/platform/v1/inbounds/{inboundId}/fees/confirm: post: summary: 确认最新费用快照 - description: 确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。 + description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:fba-pricing:confirm。确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/send: post: summary: 发送已确认费用快照 - description: 人工发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox。入库单已到 WAIT_SPLIT 时不得回退或阻塞主流程。 + description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:inbound:send-fee。人工发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox。入库单已到 WAIT_SPLIT 时不得回退或阻塞主流程。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/latest: diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 041ba50b4..2393867ff 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -168,6 +168,20 @@ paths: responses: '200': {description: 成功时 data 为更新后的 CustomerDimensionConfigGroupResponse} '403': {description: 无编辑权限} + /api/platform/v1/first-leg/customer-dimension-config/default-calculation-mode: + put: + summary: 设置客户订单外箱规默认计算方式 + description: 需要 firstleg:dimension-config:edit 权限;ADD 表示按箱规尺寸加宽,MULTIPLY 表示按默认权重系数计算。 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CustomerDimensionDefaultCalculationModeUpdateRequest'} + responses: + '200': {description: 成功时 data 为更新后的 CustomerDimensionConfigGroupResponse} + '403': {description: 无编辑权限} /internal/firstleg/v1/inbounds/order-approved: post: summary: 订单审核通过后幂等创建入库单 @@ -762,7 +776,8 @@ paths: post: summary: 平台模拟单箱设备扫描 description: >- - 需要 platform:firstleg:inbound:measure:edit 权限;仅用于平台联调和代设备录入, + 需要 platform:firstleg:inbound:warehouse-suggestion:edit 或兼容权限 + platform:firstleg:inbound:measure:edit;仅用于平台联调和代设备录入, 复用正式单箱覆盖规则,不代表最终第三方设备协议或鉴权方式。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -1354,6 +1369,11 @@ components: maxItems: 4 description: 必须恰好包含 SMALL、MEDIUM、LARGE、EXTRA_LARGE 各一行 items: {$ref: '#/components/schemas/CustomerDimensionConfigRuleUpdateRequest'} + CustomerDimensionDefaultCalculationModeUpdateRequest: + type: object + required: [defaultCalculationMode] + properties: + defaultCalculationMode: {type: string, enum: [ADD, MULTIPLY]} CustomerDimensionConfigRuleUpdateRequest: type: object required: [boxType, innerLengthCm, innerWidthCm, innerHeightCm, edgeAdditionCm, version] @@ -1373,9 +1393,10 @@ components: version: {type: integer, minimum: 0, description: 乐观锁版本号} CustomerDimensionConfigGroupResponse: type: object - required: [tenantId, rules] + required: [tenantId, defaultCalculationMode, rules] properties: tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + defaultCalculationMode: {type: string, enum: [ADD, MULTIPLY]} rules: type: array minItems: 4 @@ -1508,6 +1529,8 @@ components: outerLengthCm: {type: number, format: double, nullable: true} outerWidthCm: {type: number, format: double, nullable: true} outerHeightCm: {type: number, format: double, nullable: true} + chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + coefficientMode: {type: string, enum: [ADD, MULTIPLY], nullable: true} remark: {type: string, nullable: true} boxCode: {type: string, nullable: true} scannedShipmentId: {type: string, nullable: true} @@ -1622,6 +1645,8 @@ components: outerLengthCm: {type: number, minimum: 0, nullable: true} outerWidthCm: {type: number, minimum: 0, nullable: true} outerHeightCm: {type: number, minimum: 0, nullable: true} + chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + coefficientMode: {type: string, enum: [ADD, MULTIPLY], nullable: true} remark: {type: string, maxLength: 500, nullable: true} InboundMeasurementDetailEnvelope: type: object diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/CustomerDimensionConfigControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/CustomerDimensionConfigControllerContractTest.java index d6bb1c5fc..5bae64fbb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/CustomerDimensionConfigControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/CustomerDimensionConfigControllerContractTest.java @@ -58,5 +58,11 @@ class CustomerDimensionConfigControllerContractTest { long.class, com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest.class) .getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('firstleg:dimension-config:edit')"); + assertThat(PlatformCustomerDimensionConfigController.class + .getDeclaredMethod("updateDefaultCalculationMode", + com.baiyue.common.security.AuthenticatedUser.class, long.class, + com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.DefaultCalculationModeUpdateRequest.class) + .getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('firstleg:dimension-config:edit')"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java index 893e6c381..10aa5422a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.controller; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RequestParam; import org.yaml.snakeyaml.Yaml; @@ -15,6 +16,15 @@ import static org.assertj.core.api.Assertions.assertThat; @DisplayName("FBA计费OpenAPI契约") class FbaPricingContractTest { + @Test + @DisplayName("确认和发送费用兼容新的组合权限与原有权限") + void allowsCombinedConfirmSendPermissionAndLegacyPermissions() { + assertPricingAuthority("confirm", "hasAnyAuthority('firstleg:inbound:confirm-send-fee'," + + " 'firstleg:fba-pricing:confirm')"); + assertPricingAuthority("send", "hasAnyAuthority('firstleg:inbound:confirm-send-fee'," + + " 'firstleg:inbound:send-fee')"); + } + @Test @DisplayName("客户规则保留客户参数,船司价格使用平台公共作用域") void keepsCustomerRuleScopeAndRemovesCarrierRateTenantScope() { @@ -25,7 +35,7 @@ class FbaPricingContractTest { Method rates = method("rates"); assertThat(rates.getParameterTypes()).doesNotContain(Long.class); - for (String scopedMethod : new String[] {"rule", "createRule", "retireRule"}) { + for (String scopedMethod : new String[] {"rule", "createRule", "retireRule", "setDefaultRule"}) { Method method = method(scopedMethod); assertThat(method.getParameterTypes()[1]).isEqualTo(long.class); assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).required()).isTrue(); @@ -45,6 +55,7 @@ class FbaPricingContractTest { .getContentAsString(StandardCharsets.UTF_8); assertThat(openapi) .contains("/api/platform/v1/fba-pricing/customer-rules/versions:") + .contains("/api/platform/v1/fba-pricing/customer-rules/{id}/default:") .contains("/api/platform/v1/fba-pricing/carrier-rates/versions:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/calculate:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/confirm:") @@ -146,6 +157,13 @@ class FbaPricingContractTest { .findFirst().orElseThrow(); } + private static void assertPricingAuthority(String name, String expected) { + Method method = java.util.Arrays.stream(PlatformFbaPricingController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(name)) + .findFirst().orElseThrow(); + assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected); + } + @SuppressWarnings("unchecked") private static Map map(Object value) { return (Map) value; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java index 747a962f5..b70b2dd77 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java @@ -58,7 +58,8 @@ class PlatformInboundMeasurementControllerContractTest { "hasAnyAuthority('platform:firstleg:inbound:measure:view'," + " 'platform:firstleg:inbound:weight:edit')", AuthenticatedUser.class, long.class, long.class, long.class, long.class, String.class); - assertAuthority("saveDraft", "hasAuthority('platform:firstleg:inbound:measure:edit')", + assertAuthority("saveDraft", "hasAnyAuthority('platform:firstleg:inbound:warehouse-suggestion:edit'," + + " 'platform:firstleg:inbound:measure:edit')", AuthenticatedUser.class, long.class, long.class, InboundMeasurementRequests.SaveDraftRequest.class); assertAuthority("submit", "hasAuthority('platform:firstleg:inbound:measure:submit')", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java index b3dea1243..5811575cd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java @@ -5,6 +5,7 @@ import com.baiyue.firstleg.mapper.CustomerDimensionConfigMapper; import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; @@ -34,6 +35,7 @@ class CustomerDimensionConfigSqlContractTest { .contains("inner_width_cm DECIMAL(9,3) NOT NULL") .contains("inner_height_cm DECIMAL(9,3) NOT NULL") .contains("edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000") + .contains("default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ADD'") .contains("initialization_key VARCHAR(128) NOT NULL") .contains("created_by BIGINT UNSIGNED NOT NULL") .contains("updated_by BIGINT UNSIGNED NOT NULL") @@ -88,6 +90,19 @@ class CustomerDimensionConfigSqlContractTest { .contains("remark = CASE box_type"); } + @Test + void defaultCalculationModeUpdateCoversTheWholeCustomerRuleSet() throws Exception { + Update annotation = CustomerDimensionConfigMapper.class + .getDeclaredMethod("updateDefaultCalculationMode", long.class, String.class, long.class) + .getAnnotation(Update.class); + String sql = String.join(" ", annotation.value()); + + assertThat(sql) + .contains("default_calculation_mode = #{mode}") + .contains("tenant_id = #{tenantId}") + .contains("version = version + 1"); + } + @Test void unifiedMigrationKeepsPermissionGuardsAndAssertsFinalFourRowSchema() throws Exception { String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java index 8f0ee2fec..bd6179c64 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java @@ -140,6 +140,20 @@ class CustomerDimensionConfigServiceTest { verify(repository, times(2)).update(any()); } + @Test + void savesAndReturnsTheCustomerDefaultCalculationMode() { + List existing = defaultConfigs("registration-1"); + List saved = defaultConfigs("registration-1"); + saved.forEach(value -> value.setDefaultCalculationMode("MULTIPLY")); + when(repository.findAll(8L)).thenReturn(existing, saved); + when(repository.updateDefaultCalculationMode(8L, "MULTIPLY", 100L)).thenReturn(4); + + GroupResponse result = service.updateDefaultCalculationMode(8L, 100L, "MULTIPLY"); + + assertThat(result.defaultCalculationMode()).isEqualTo("MULTIPLY"); + verify(repository).updateDefaultCalculationMode(8L, "MULTIPLY", 100L); + } + @Test void compensationLocksAndDeletesExactlyFourUnchangedDefaults() { when(repository.findDefaultsForCompensation(8L, "registration-1")) @@ -192,6 +206,7 @@ class CustomerDimensionConfigServiceTest { value.setInnerWidthCm(type.defaultWidthCm()); value.setInnerHeightCm(type.defaultHeightCm()); value.setEdgeAdditionCm(new BigDecimal("2.000")); + value.setDefaultCalculationMode("ADD"); value.setRemark(type.defaultRemark()); value.setVersion(0); value.setInitializationKey(initializationKey); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java index dec7edf1d..b7d766724 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java @@ -1,6 +1,5 @@ package com.baiyue.firstleg.service; -import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest; import com.baiyue.firstleg.entity.CarrierFbaRateEntity; @@ -17,10 +16,10 @@ import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -47,10 +46,9 @@ class FbaMasterDataServiceTest { } @Test - @DisplayName("半开有效期首尾相接不视为重叠") - void acceptsAdjacentHalfOpenPeriods() { + @DisplayName("有效期合法时创建客户规则") + void createsRuleWithValidPeriod() { CustomerChargeRuleEntity old = activeRule(AUGUST.minusMonths(1), AUGUST); - when(repository.listRules(1L, null)).thenReturn(List.of(old)); when(repository.listRules(1L, "CR-1")).thenReturn(List.of(old)); when(repository.nextRuleVersion(1L, "CR-1")).thenReturn(2); doAnswer(invocation -> { ((CustomerChargeRuleEntity) invocation.getArgument(0)).setId(10L); return null; }) @@ -61,18 +59,25 @@ class FbaMasterDataServiceTest { assertThat(created.getId()).isEqualTo(10L); assertThat(created.getRuleVersion()).isEqualTo(2); + assertThat(created.getEffectiveFrom()).isEqualTo(AUGUST); + assertThat(created.getEffectiveTo()).isEqualTo(AUGUST.plusMonths(1)); } @Test - @DisplayName("任意重叠有效期在保存阶段拒绝避免多规则匹配") - void rejectsOverlappingPeriods() { - when(repository.listRules(1L, null)).thenReturn(List.of( - activeRule(AUGUST, AUGUST.plusMonths(1)))); + @DisplayName("重叠配置允许新增且新增时固定不启用") + void createsOverlappingRuleAsNonDefault() { + when(repository.nextRuleVersion(1L, "CR-1")).thenReturn(2); + doAnswer(invocation -> { ((CustomerChargeRuleEntity) invocation.getArgument(0)).setId(11L); return null; }) + .when(repository).insertRule(any()); - assertThatThrownBy(() -> service.createRuleVersion( - 1L, 2L, request(AUGUST.plusDays(1), AUGUST.plusMonths(2)))) - .isInstanceOf(BizException.class) - .hasMessageContaining("有效期重叠"); + CustomerChargeRuleEntity created = service.createRuleVersion( + 1L, 2L, request(AUGUST.plusDays(1), AUGUST.plusMonths(2), true)); + + assertThat(created.getId()).isEqualTo(11L); + assertThat(created.getDefaultRule()).isFalse(); + verify(repository, never()).clearDefaultRule(anyLong(), anyLong(), anyLong()); + verify(repository, never()).setDefaultRule(anyLong(), anyLong(), anyLong()); + verify(repository, never()).listRules(1L, null); } @Test @@ -92,8 +97,13 @@ class FbaMasterDataServiceTest { } private CustomerRuleVersionRequest request(LocalDateTime from, LocalDateTime to) { + return request(from, to, null); + } + + private CustomerRuleVersionRequest request(LocalDateTime from, LocalDateTime to, Boolean defaultRule) { return new CustomerRuleVersionRequest("CR-1", 100L, "客户A", "FBA_SEA", "KG", - "TAX_INCLUDED", new BigDecimal("167"), 3, "CEILING", "USD", from, to, null); + "TAX_INCLUDED", new BigDecimal("167"), 3, "CEILING", "USD", from, to, null, + BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, defaultRule); } private CarrierRateVersionRequest carrierRateRequest() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java index 22a46434c..b7ebf3d4b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java @@ -7,6 +7,8 @@ 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.CustomerChargeRuleEntity; +import com.baiyue.firstleg.repository.FbaPricingRepository; import com.baiyue.firstleg.repository.InboundMeasurementRepository; import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; import jakarta.annotation.Resource; @@ -34,6 +36,7 @@ import static org.mockito.Mockito.when; class MeasurementBatchServiceTest { @MockBean private InboundMeasurementRepository repository; @MockBean private CustomerDimensionConfigRepository dimensionConfigs; + @MockBean private FbaPricingRepository pricingRepository; @Resource private MeasurementBatchService service; @BeforeEach @@ -62,14 +65,17 @@ class MeasurementBatchServiceTest { ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(100L), items.capture()); - assertThat(items.getValue().get(0).getUnitVolumeCbm()).isEqualByComparingTo("0.0510"); - assertThat(items.getValue().get(0).getTotalVolumeCbm()).isEqualByComparingTo("0.1020"); - assertThat(items.getValue().get(0).getOuterLengthCm()).isEqualByComparingTo("44.000"); - assertThat(items.getValue().get(0).getOuterWidthCm()).isEqualByComparingTo("34.000"); - assertThat(items.getValue().get(0).getOuterHeightCm()).isEqualByComparingTo("34.000"); + assertThat(items.getValue().get(0).getUnitVolumeCbm()).isEqualByComparingTo("0.0267"); + assertThat(items.getValue().get(0).getTotalVolumeCbm()).isEqualByComparingTo("0.0534"); + assertThat(items.getValue().get(0).getOuterLengthCm()).isEqualByComparingTo("41.000"); + assertThat(items.getValue().get(0).getOuterWidthCm()).isEqualByComparingTo("31.000"); + assertThat(items.getValue().get(0).getOuterHeightCm()).isEqualByComparingTo("21.000"); assertThat(items.getValue().get(0).getCalculationRuleVersion()) - .isEqualTo("CUSTOMER_DIMENSION_MEDIUM_V0"); - assertThat(items.getValue().get(0).getGirthCm()).isEqualByComparingTo("140.000"); + .isEqualTo("WAREHOUSE_OUTER_OVERRIDE_V1"); + assertThat(items.getValue().get(0).getCoefficientMode()).isNull(); + assertThat(items.getValue().get(0).getChargeRuleId()).isNull(); + assertThat(items.getValue().get(0).getGirthCm()).isEqualByComparingTo("145.000"); + verify(pricingRepository).findDefaultRule(1L, 1L); verify(repository, never()).updateInbound(any()); } @@ -83,14 +89,163 @@ class MeasurementBatchServiceTest { InboundMeasurementEntity saved = service.saveBatch(command); assertThat(saved.getStatus()).isEqualTo("DRAFT"); - assertThat(saved.getTotalVolumeCbm()).isEqualByComparingTo("0.1020"); - assertThat(saved.getVolumeWeightKg()).isEqualByComparingTo("17.034"); + assertThat(saved.getTotalVolumeCbm()).isEqualByComparingTo("0.0534"); + assertThat(saved.getVolumeWeightKg()).isEqualByComparingTo("8.918"); ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(100L), items.capture()); assertThat(items.getValue().get(0).getInboundDetailId()).isEqualTo(80001L); } + @Test + void automaticallyAppliesCustomerDefaultRuleToActualMeasurement() { + CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); + rule.setId(77L); + rule.setTenantId(1L); + rule.setCustomerId(1L); + rule.setRuleCode("DEFAULT"); + rule.setRuleVersion(2); + rule.setStatus("ACTIVE"); + rule.setDefaultRule(true); + rule.setLengthFactor(new BigDecimal("1.1")); + rule.setWidthFactor(new BigDecimal("1.1")); + rule.setHeightFactor(new BigDecimal("1.1")); + when(pricingRepository.findDefaultRule(1L, 1L)).thenReturn(rule); + MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item( + 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), + new BigDecimal("20"), 500L, null, null, null, null, null, + null, "备注"); + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source)); + + service.saveBatch(command); + + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + InboundMeasurementItemEntity calculated = items.getValue().get(0); + assertThat(calculated.getChargeRuleId()).isEqualTo(77L); + assertThat(calculated.getCoefficientMode()).isEqualTo("MULTIPLY"); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("44.000"); + assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("33.000"); + assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("22.000"); + assertThat(calculated.getGirthCm()).isEqualByComparingTo("154.000"); + assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.0320"); + assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.0640"); + } + + @Test + void usesOneMatchedTierCoefficientForAllDimensionsOfEachSpecification() { + CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); + rule.setId(78L); + rule.setTenantId(1L); + rule.setCustomerId(1L); + rule.setRuleCode("TIERED"); + rule.setRuleVersion(3); + rule.setStatus("ACTIVE"); + rule.setDefaultRule(true); + rule.setLengthFactor(new BigDecimal("1.05")); + rule.setWidthFactor(new BigDecimal("1.25")); + rule.setHeightFactor(new BigDecimal("1.35")); + rule.setRemark("SMALL:30,20,20;系数:1.1;小箱:—;MEDIUM:40,30,30;系数:1.2;中箱:—;" + + "LARGE:50,40,40;系数:1.3;大箱:—;EXTRA_LARGE:60,50,50;系数:1.4;特大箱:—"); + when(pricingRepository.findDefaultRule(1L, 1L)).thenReturn(rule); + MeasurementBatchCommand.Item small = new MeasurementBatchCommand.Item( + 80001L, "001", "A", 1, new BigDecimal("30"), new BigDecimal("20"), + new BigDecimal("20"), 500L, null, null, null, null, null, null, "小箱"); + MeasurementBatchCommand.Item medium = new MeasurementBatchCommand.Item( + 80002L, "002", "B", 1, new BigDecimal("31"), new BigDecimal("20"), + new BigDecimal("20"), 501L, null, null, null, null, null, null, "中箱"); + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, List.of(small, medium)); + + service.saveBatch(command); + + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + assertThat(items.getValue().get(0).getOuterLengthCm()).isEqualByComparingTo("33.000"); + assertThat(items.getValue().get(0).getOuterWidthCm()).isEqualByComparingTo("22.000"); + assertThat(items.getValue().get(0).getOuterHeightCm()).isEqualByComparingTo("22.000"); + assertThat(items.getValue().get(1).getOuterLengthCm()).isEqualByComparingTo("37.200"); + assertThat(items.getValue().get(1).getOuterWidthCm()).isEqualByComparingTo("24.000"); + assertThat(items.getValue().get(1).getOuterHeightCm()).isEqualByComparingTo("24.000"); + } + + @Test + void keepsActualMeasurementUnchangedWhenCustomerHasNoDefaultRule() { + MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item( + 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), + new BigDecimal("20"), 500L, null, null, null, null, null, null, "备注"); + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source)); + + service.saveBatch(command); + + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + InboundMeasurementItemEntity calculated = items.getValue().get(0); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("40.000"); + assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("30.000"); + assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("20.000"); + assertThat(calculated.getChargeRuleId()).isNull(); + assertThat(calculated.getCoefficientMode()).isNull(); + assertThat(calculated.getCalculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_V1"); + } + + @Test + void addsMatchedEdgeValueToMeasuredDimensionsInsteadOfRuleBoundary() { + MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item( + 80001L, "001", "A", 2, new BigDecimal("59.5"), new BigDecimal("41.2"), + new BigDecimal("31.3"), 500L, null, null, null, null, null, "ADD", "备注"); + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source)); + + service.saveBatch(command); + + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + InboundMeasurementItemEntity calculated = items.getValue().get(0); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("61.500"); + assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("43.200"); + assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("33.300"); + assertThat(calculated.getGirthCm()).isEqualByComparingTo("214.500"); + assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.088"); + assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.1760"); + } + + @Test + void warehouseOuterDimensionsOverrideCalculatedCartonAndAllDerivedValues() { + MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item( + 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), + new BigDecimal("20"), 500L, null, new BigDecimal("50"), + new BigDecimal("40"), new BigDecimal("30"), null, "ADD", "仓库修正"); + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source)); + + service.saveBatch(command); + + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + InboundMeasurementItemEntity calculated = items.getValue().get(0); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("50.000"); + assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("40.000"); + assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("30.000"); + assertThat(calculated.getGirthCm()).isEqualByComparingTo("190.000"); + assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.0600"); + assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.1200"); + assertThat(calculated.getVolumeWeightKg()).isEqualByComparingTo("20.040"); + assertThat(calculated.getCalculationRuleVersion()).isEqualTo("WAREHOUSE_OUTER_OVERRIDE_V1"); + } + @Test void staleDraftVersionIsRejectedBeforeReplacingItems() { InboundMeasurementEntity existing = new InboundMeasurementEntity(); @@ -184,6 +339,7 @@ class MeasurementBatchServiceTest { InboundOrderEntity inbound = new InboundOrderEntity(); inbound.setId(10L); inbound.setTenantId(1L); + inbound.setCustomerId(1L); inbound.setProcessStatus("WAIT_MEASURE"); inbound.setVersion(0); return inbound; diff --git a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql index 097bed02a..991c1a490 100644 --- a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql +++ b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql @@ -78,6 +78,7 @@ SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) UPDATE iam_permission p JOIN (SELECT 'platform:firstleg:inbound:measure:edit' code, 5102041 parent_id UNION ALL + SELECT 'platform:firstleg:inbound:warehouse-suggestion:edit', 5102041 UNION ALL SELECT 'platform:firstleg:inbound:measure:submit', 5102041 UNION ALL SELECT 'platform:firstleg:inbound:measure:manual-entry', 5102041 UNION ALL SELECT 'platform:firstleg:inbound:weight:edit', 5102041 UNION ALL diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql index 0f00deb81..a0be9fd0f 100644 --- a/iam/src/main/resources/db/phase1-admin-rbac.sql +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -123,24 +123,25 @@ ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), route_path = VALUES(route_path), http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; --- 平台代客创建、编辑草稿权限;可重复执行并纳入已有平台管理员和订单运营角色授权。 +-- 平台订单写入与导出权限;可重复执行并纳入已有平台管理员和订单运营角色授权。 INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) VALUES (5102019, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:create', '平台代客创建订单', NULL, NULL, 90, 'ACTIVE', 0), (5102024, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:edit', '平台编辑订单草稿', NULL, NULL, 100, 'ACTIVE', 0), - (5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0) + (5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0), + (5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表', NULL, NULL, 120, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), status = 'ACTIVE', deleted = 0; INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000001, id, 0 FROM iam_permission -WHERE id IN (5102019, 5102024, 5102025) +WHERE id IN (5102019, 5102024, 5102025, 5102026) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000003, id, 0 FROM iam_permission -WHERE id IN (5102019, 5102024, 5102025) +WHERE id IN (5102019, 5102024, 5102025, 5102026) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES @@ -165,7 +166,9 @@ ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000003, id, 0 FROM iam_permission -WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%' +WHERE permission_code LIKE 'platform:order%' + OR permission_code LIKE 'platform:firstleg%' + OR permission_code = 'platform:download:list' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES diff --git a/iam/src/main/resources/db/phase2-fba-pricing-permissions.sql b/iam/src/main/resources/db/phase2-fba-pricing-permissions.sql index e64162fbc..21fe1fa4a 100644 --- a/iam/src/main/resources/db/phase2-fba-pricing-permissions.sql +++ b/iam/src/main/resources/db/phase2-fba-pricing-permissions.sql @@ -13,17 +13,27 @@ VALUES (5102065, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:calculate', '计算入库费用', NULL, NULL, 50, 'ACTIVE', 1, 0), (5102066, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:confirm', '内部确认入库费用', NULL, NULL, 60, 'ACTIVE', 1, 0), (5102067, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:view', '查看入库费用', NULL, NULL, 70, 'ACTIVE', 1, 0), - (5102068, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:adjust', '调整已发布费用', NULL, NULL, 80, 'ACTIVE', 1, 0) + (5102068, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:adjust', '调整已发布费用', NULL, NULL, 80, 'ACTIVE', 1, 0), + (5102069, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:confirm-send-fee', '确认并推送客户费用', NULL, NULL, 90, 'ACTIVE', 1, 0) ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), status = 'ACTIVE', built_in = 1, deleted = 0; INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000001, id, 0 FROM iam_permission -WHERE id BETWEEN 5102061 AND 5102068 +WHERE id BETWEEN 5102061 AND 5102069 ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000003, id, 0 FROM iam_permission -WHERE id IN (5102061, 5102063, 5102065, 5102066, 5102067, 5102068) +WHERE id IN (5102061, 5102063, 5102065, 5102066, 5102067, 5102068, 5102069) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, 0 +FROM iam_role role +JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.permission_code = 'firstleg:inbound:confirm-send-fee' +WHERE role.tenant_id = 0 AND UPPER(role.role_code) = 'PLATFORM_CUSTOM_ADMIN' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/iam/src/main/resources/db/phase2-inbound-measurement-permissions.sql b/iam/src/main/resources/db/phase2-inbound-measurement-permissions.sql index f76ab2824..89d66d2cc 100644 --- a/iam/src/main/resources/db/phase2-inbound-measurement-permissions.sql +++ b/iam/src/main/resources/db/phase2-inbound-measurement-permissions.sql @@ -17,7 +17,9 @@ VALUES (5102045, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:weight:confirm', '确认OCR重量', NULL, NULL, 40, 'ACTIVE', 0), (5102194, 0, 'PLATFORM', 'BUTTON', 5102030, - 'platform:firstleg:inbound:measure:manual-entry', '手动录入箱规', NULL, NULL, 25, 'ACTIVE', 0) + 'platform:firstleg:inbound:measure:manual-entry', '手动录入箱规', NULL, NULL, 25, 'ACTIVE', 0), + (5102195, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:warehouse-suggestion:edit', '维护仓库建议测量数据', NULL, NULL, 27, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_name = VALUES(permission_name), route_path = VALUES(route_path), @@ -25,12 +27,21 @@ ON DUPLICATE KEY UPDATE INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000001, id, 0 FROM iam_permission -WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045, 5102194) +WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045, 5102194, 5102195) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000003, id, 0 FROM iam_permission -WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045, 5102194) +WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045, 5102194, 5102195) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, 0 +FROM iam_role role +JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.permission_code = 'platform:firstleg:inbound:warehouse-suggestion:edit' +WHERE role.tenant_id = 0 AND UPPER(role.role_code) = 'PLATFORM_CUSTOM_ADMIN' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) diff --git a/order/src/main/resources/db/platform-order-export-permission.sql b/order/src/main/resources/db/platform-order-export-permission.sql index b273a4af6..bb343b653 100644 --- a/order/src/main/resources/db/platform-order-export-permission.sql +++ b/order/src/main/resources/db/platform-order-export-permission.sql @@ -15,3 +15,12 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT role_id, 5102026, 0 FROM (SELECT 4000001 AS role_id UNION ALL SELECT 4000003 AS role_id) roles ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- 订单运营角色创建导出任务后必须能够进入下载中心获取结果文件。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000003, id, 0 +FROM iam_permission +WHERE tenant_id = 0 AND app_code = 'PLATFORM' + AND permission_code = 'platform:download:list' + AND status = 'ACTIVE' AND deleted = 0 +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportPermissionSqlContractTest.java b/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportPermissionSqlContractTest.java new file mode 100644 index 000000000..f406b3d9e --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportPermissionSqlContractTest.java @@ -0,0 +1,41 @@ +package com.baiyue.order.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class DomesticWarehouseOrderExportPermissionSqlContractTest { + + @Test + void keepsOrderExportPermissionAndDownloadGrantInAllInitializationEntrypoints() throws Exception { + String moduleSql = new ClassPathResource("db/platform-order-export-permission.sql") + .getContentAsString(StandardCharsets.UTF_8); + String iamSeed = Files.readString(resolve("iam/src/main/resources/db/phase1-admin-rbac.sql"), + StandardCharsets.UTF_8); + String unified = Files.readString(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + + for (String sql : new String[] {moduleSql, iamSeed, unified}) { + assertThat(sql) + .contains("5102026") + .contains("platform:order:export") + .contains("导出订单列表") + .contains("platform:download:list") + .contains("4000003"); + } + assertThat(iamSeed).contains("WHERE id IN (5102019, 5102024, 5102025, 5102026)"); + assertThat(unified) + .contains("WHERE id IN (5102019, 5102024, 5102025, 5102026)") + .contains("管理员或订单运营角色缺少订单导出与下载中心权限"); + } + + private static Path resolve(String relative) { + Path direct = Path.of(relative); + return Files.exists(direct) ? direct : Path.of("..").resolve(relative); + } +}