新增海外仓阶梯费用

This commit is contained in:
2026-08-05 11:20:28 +08:00
parent 9678ee0f1f
commit cac377de96
20 changed files with 801 additions and 10 deletions
+3 -2
View File
@@ -22,7 +22,7 @@
| `iam/` | 租户、商户成员、平台统一用户管理、企业资料/资质、RBAC 与授权快照 | `PlatformUserManagementController.java`、`PlatformTenantController.java`、`ProfileResourceController.java`、`RbacController.java` |
| `order/` | 订单主数据、租户运单数、持久化幂等、状态编排、快照、货物明细内部查询与 Outbox | `PlatformFirstLegOrderController.java`、`OrderApplicationService.java`、`InternalOrderCargoController.java`、`README-ORDER.md` |
| `firstleg/` | 履约任务与分配;二期入库单、测量/OCR 结果、审核、FBA 计费和自动完成入库 | 履约:`PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java`;入库:`controller/PlatformInbound*.java`、`service/Inbound*.java`、`service/Fba*.java` |
| `warehouse/` | 仓库、地址、服务能力、往来单位、快递公司;二期托盘、库位、预占和占用 | 主数据:`controller/`、`PlatformPartnerQueryService.java`;二期:`PlatformPalletController.java`、`PlatformLocationController.java`、`PalletLocationAllocationService.java` |
| `warehouse/` | 仓库、地址、服务能力、往来单位、快递公司、海外仓阶梯费用;二期托盘、库位、预占和占用 | 主数据:`controller/`、`PlatformPartnerQueryService.java`、`PlatformOverseasStorageFeeController.java`;二期:`PlatformPalletController.java`、`PlatformLocationController.java`、`PalletLocationAllocationService.java` |
| `file/` | 企业资质文件、公共导出/下载中心;入库测量图片与重量 OCR 协作 | 资质:`QualificationFileController.java`;导出:`PlatformExportTaskController.java`、`ExportStateService.java`;测量图片:`PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`MeasurementImageService.java` |
| `print/` | 无数据库的无状态 PDF 渲染;固定入仓标签模板、二维码、字体嵌入和资源保护 | `InternalPdfController.java`、`InboundMarkPdfService.java`、`InboundMarkV1Renderer.java`、`src/main/resources/openapi/print-internal-v1.yaml` |
| `audit-service/` | 审计事件接收与持久化 | `AuditIngestController.java`、`AuditIngestService.java`;当前接收接口未实现内部令牌保护 |
@@ -39,6 +39,7 @@
| 平台代客户上传或替换资质 | `file/.../controller/PlatformQualificationFileController.java` → `client/IamQualificationClient.java` → `iam/.../controller/InternalTenantController.java` 的 `/platform-qualifications` |
| 往来单位多类型 | `warehouse/.../service/PlatformPartnerQueryService.java`、`entity/PartnerTypeEntity.java`、`src/main/resources/db/warehouse-partner.sql`,消费者看 `order/.../client/PartnerClient.java` |
| 往来单位/快递公司批量导入 | `warehouse/.../service/PartnerImportService.java`、`ExpressCompanyImportService.java`、两个平台 Controller 的 `/import-template` 与 `/import` |
| 海外仓阶梯费用 | `warehouse/.../controller/PlatformOverseasStorageFeeController.java` → `service/OverseasStorageFeeService.java` → `repository/OverseasStorageFeeRepository.java`;表为 `wh_overseas_storage_fee_rule`、`wh_overseas_storage_fee_tier` |
| 入仓标签 PDF 生成 | 调用方先读 `print/README.md`;实现为 `print/.../controller/InternalPdfController.java` → `service/InboundMarkPdfService.java` → `renderer/InboundMarkV1Renderer.java`;机器契约见 `print/src/main/resources/openapi/print-internal-v1.yaml`,仅供携带内部令牌的服务调用,不加入 Gateway |
| HTTP 200、body code、成功码 `"200"` | `docs/统一响应约定改造方案-HTTP200.md`、`common-core/ResultCode.java`、`common-web/GlobalExceptionHandler.java` |
| 单服务 Jenkins 构建或部署 | 总控 `Jenkinsfile`、子 Job `Jenkinsfile.service`、模块/JAR 映射 `deploy/service-map.yml` |
@@ -56,7 +57,7 @@
| 托盘、库位与预占/占用 | 平台 CRUD:`warehouse/.../controller/PlatformPalletController.java`、`PlatformLocationController.java`;内部事务接口:`InternalPalletLocationController.java` | `service/PalletApplicationService.java`、`LocationApplicationService.java`、`PalletLocationAllocationService.java`;表与迁移在 `warehouse/src/main/resources/db/phase2-*.sql` |
| 称重图片、OCR 重试与第三方测量证据 | 平台入口 `file/.../controller/PlatformMeasurementImageController.java`;Firstleg 内部入口 `InternalMeasurementImageController.java` | `service/MeasurementImageService.java`、`MeasurementImageStorage.java`、`client/WeightOcrClient.java`;重量识别服务入口 `integration-service/.../controller/WeightOcrController.java` |
| 入库状态、幂等、历史与 Outbox | `firstleg/.../domain/InboundProcessStatus.java`、`service/InboundIdempotencyService.java`、`repository/InboundWorkflowRepository.java` | 表结构与增量在 `firstleg/src/main/resources/db/phase2-*.sql`;SQL 契约测试 `repository/InboundWorkflowSqlContractTest.java` |
| 二期数据库部署 | 测试环境/存量环境统一入口 `deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql` | 覆盖 `baiyue_file`、`baiyue_firstleg`、`baiyue_warehouse`;前置、可重复执行边界和末尾 `PASS` 核验见脚本头及 `deploy/sql/README.md`,不得在未核对目标库和备份的情况下执行 |
| 数据库部署 | 空库/存量环境唯一入口 `deploy/sql/头程数据库统一增量-MySQL8.sql` | 覆盖全部一期、二期及后续增量;可重复执行边界和末尾 `PASS` 核验见脚本头及 `deploy/sql/README.md`,历史日期脚本不得再单独执行 |
- 上表中的 `模块/.../` 从对应模块的 `src/main/java/com/baiyue/<domain>/` 起算;二期链路跨 `order`、`firstleg`、`warehouse`、`file`、`integration-service`,修改 DTO、内部令牌、状态或表结构时必须逐个检查调用方与被调用方。
- 二期入库主状态归 `firstleg`,订单货物主数据仍归 `order`,托盘/库位资源归 `warehouse`,文件对象归 `file`,OCR 供应商适配归 `integration-service`;禁止通过跨库查询绕过内部 API。
@@ -8,6 +8,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.RedisTokenKeys;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.SetOperations;
@@ -28,7 +29,7 @@ import java.util.List;
*/
@Component
public class TokenSessionStore {
@Resource
@Autowired
private StringRedisTemplate redisTemplate;
@Resource
private ObjectMapper objectMapper;
+7
View File
@@ -78,3 +78,10 @@ SELECT COUNT(*) = 8 AS scheduler_columns_ready
- 当前固定入口 `头程数据库统一增量-MySQL8.sql` 已内联该增量以及订单船司字段、二期 28 项 IAM 权限和最新仓储资源租户字段。
- 新增 `external_request_hash`、`inbound_detail_id`、`external_detail_no` 和第三方明细唯一索引,用于一条回传绑定一条订单货物明细及一张文件中心图片。
- 部署 Firstleg 新版本前必须完整执行固定入口,并确认末尾总体验证为 `PASS`。
## 海外仓阶梯费用增量
- `2026-08-05` 增量已追加到固定入口末尾,不创建新的日期部署脚本。
- Warehouse 新增 `wh_overseas_storage_fee_rule` 和 `wh_overseas_storage_fee_tier`,包含客户唯一索引、分页索引、阶梯起始天唯一索引和规则明细查询索引。
- IAM 新增 `platform:overseas-storage-fee:list/create/edit/delete` 四项权限,默认授予平台系统管理员。
- Warehouse Service 启动前必须先执行固定入口;末尾只有两张表、五个索引和四项权限均存在才返回 `PASS`。
@@ -3342,5 +3342,123 @@ DELIMITER ;
CALL unified_assert_permission_count();
DROP PROCEDURE IF EXISTS unified_assert_permission_count;
-- ============================================================================
-- 2026-08-05:海外仓阶梯费用基础资料 CRUD 增量
-- ============================================================================
USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_rule (
id BIGINT UNSIGNED NOT NULL COMMENT '海外仓阶梯费用规则ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户账号ID,沿用tenant_id物理字段',
customer_name VARCHAR(128) NOT NULL COMMENT '客户公司名称快照',
currency CHAR(3) NOT NULL COMMENT 'ISO 4217币种三字码',
created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
active_customer_id BIGINT UNSIGNED
GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN tenant_id ELSE NULL END) STORED
COMMENT '未删除规则的客户唯一键',
PRIMARY KEY (id),
UNIQUE KEY uk_overseas_fee_active_customer (active_customer_id),
KEY idx_overseas_fee_page (deleted, updated_at, id),
KEY idx_overseas_fee_customer_name (customer_name, deleted)
) ENGINE=InnoDB COMMENT='客户维度海外仓阶梯仓储费规则';
CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_tier (
id BIGINT UNSIGNED NOT NULL COMMENT '海外仓阶梯费用明细ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户账号ID,沿用tenant_id物理字段',
rule_id BIGINT UNSIGNED NOT NULL COMMENT '阶梯费用规则ID',
start_day SMALLINT UNSIGNED NOT NULL COMMENT '起始天,含边界',
end_day SMALLINT UNSIGNED NOT NULL COMMENT '结束天,含边界',
unit_price DECIMAL(18,6) NOT NULL COMMENT '仓储费标准',
sort_no SMALLINT UNSIGNED NOT NULL COMMENT '阶梯排序号',
created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
active_start_day SMALLINT UNSIGNED
GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN start_day ELSE NULL END) STORED
COMMENT '未删除阶梯的起始天唯一键',
PRIMARY KEY (id),
UNIQUE KEY uk_overseas_fee_tier_start (rule_id, active_start_day),
KEY idx_overseas_fee_tier_rule (tenant_id, rule_id, deleted, sort_no, id),
CONSTRAINT chk_overseas_fee_tier_days CHECK (start_day >= 1 AND end_day >= start_day),
CONSTRAINT chk_overseas_fee_tier_price CHECK (unit_price > 0)
) ENGINE=InnoDB COMMENT='海外仓阶梯仓储费明细';
USE baiyue_iam;
INSERT INTO iam_permission (
id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted
)
VALUES
(5100030, 0, 'PLATFORM', 'MENU', 5100000,
'platform:overseas-storage-fee:list', '海外仓阶梯费用',
'/customers/overseas-storage-fees', 'GET', 20, 'ACTIVE', 1, 0),
(5100031, 0, 'PLATFORM', 'BUTTON', 5100030,
'platform:overseas-storage-fee:create', '新增海外仓阶梯费用',
NULL, NULL, 10, 'ACTIVE', 1, 0),
(5100032, 0, 'PLATFORM', 'BUTTON', 5100030,
'platform:overseas-storage-fee:edit', '编辑海外仓阶梯费用',
NULL, NULL, 20, 'ACTIVE', 1, 0),
(5100033, 0, 'PLATFORM', 'BUTTON', 5100030,
'platform:overseas-storage-fee:delete', '删除海外仓阶梯费用',
NULL, NULL, 30, 'ACTIVE', 1, 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),
http_method = VALUES(http_method), sort_no = VALUES(sort_no),
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 tenant_id = 0 AND app_code = 'PLATFORM'
AND permission_code IN (
'platform:overseas-storage-fee:list',
'platform:overseas-storage-fee:create',
'platform:overseas-storage-fee:edit',
'platform:overseas-storage-fee:delete'
)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
DROP PROCEDURE IF EXISTS unified_assert_overseas_storage_fee;
DELIMITER $$
CREATE PROCEDURE unified_assert_overseas_storage_fee()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'baiyue_warehouse'
AND table_name IN ('wh_overseas_storage_fee_rule', 'wh_overseas_storage_fee_tier')) <> 2 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:海外仓阶梯费用表未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema = 'baiyue_warehouse'
AND index_name IN ('uk_overseas_fee_active_customer', 'idx_overseas_fee_page',
'idx_overseas_fee_customer_name', 'uk_overseas_fee_tier_start',
'idx_overseas_fee_tier_rule')) <> 5 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:海外仓阶梯费用索引未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0
AND permission_code IN (
'platform:overseas-storage-fee:list', 'platform:overseas-storage-fee:create',
'platform:overseas-storage-fee:edit', 'platform:overseas-storage-fee:delete')) <> 4 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:海外仓阶梯费用权限未完整创建';
END IF;
END$$
DELIMITER ;
CALL unified_assert_overseas_storage_fee();
DROP PROCEDURE IF EXISTS unified_assert_overseas_storage_fee;
SELECT 'PASS' AS migration_status,
'一期与二期统一增量执行完成,关键结构与二期权限校验通过' AS message;
'统一增量执行完成,海外仓阶梯费用表、索引和权限校验通过' AS message;
@@ -1,7 +1,6 @@
package com.baiyue.firstleg.security;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
@@ -11,7 +10,7 @@ import java.time.Duration;
@Component
public class RedisNonceReplayGuard implements NonceReplayGuard {
private static final String KEY_PREFIX = "baiyue:firstleg:measurement-openapi:nonce:";
@Resource
@Autowired
private StringRedisTemplate redisTemplate;
@Override
+1 -1
View File
@@ -57,7 +57,7 @@ spring:
- id: warehouse-service
uri: lb://warehouse-service
predicates:
- Path=/api/merchant/v1/warehouses/**,/api/merchant/v1/partners/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**,/api/platform/v1/partners/**,/api/platform/v1/express-companies/**,/api/platform/v1/pallets/**,/api/platform/v1/locations/**
- Path=/api/merchant/v1/warehouses/**,/api/merchant/v1/partners/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**,/api/platform/v1/partners/**,/api/platform/v1/express-companies/**,/api/platform/v1/pallets/**,/api/platform/v1/locations/**,/api/platform/v1/overseas-storage-fees/**
- id: file-service
uri: lb://file-service
predicates:
@@ -48,7 +48,8 @@ class GatewayFileRouteConfigurationTest {
String paths = String.join(",", predicate.getArgs().values());
assertThat(paths)
.contains("/api/platform/v1/pallets/**")
.contains("/api/platform/v1/locations/**");
.contains("/api/platform/v1/locations/**")
.contains("/api/platform/v1/overseas-storage-fees/**");
});
}
}
+1
View File
@@ -19,6 +19,7 @@
| 平台快递公司增改查、启停与 XLSX 导入 | `controller/PlatformExpressCompanyController.java`、`service/ExpressCompanyQueryService.java`、`ExpressCompanyImportService.java`;模板 `/import-template`,导入 `/import` |
| 平台托盘增改查 | `controller/PlatformPalletController.java` → `service/PalletApplicationService.java` → `repository/PalletRepository.java`;体积分托盘规则在 `service/PalletVolumeSplitter.java` |
| 平台库位增改查、可用库位与容量 | `controller/PlatformLocationController.java` → `service/LocationApplicationService.java`、`LocationResourceService.java` → `repository/LocationRepository.java` |
| 海外仓阶梯费用 CRUD | `controller/PlatformOverseasStorageFeeController.java` → `service/OverseasStorageFeeService.java` → `repository/OverseasStorageFeeRepository.java`;客户主记录与阶梯明细分别落 `wh_overseas_storage_fee_rule`、`wh_overseas_storage_fee_tier` |
| Firstleg 托盘库位预占、确认和释放 | `controller/InternalPalletLocationController.java`(`/reservations`、`/confirm`、`/release`)→ `service/PalletLocationAllocationService.java`;只允许内部令牌调用 |
| 服务与数据库结构状态 | `controller/WarehouseHealthController.java`、`service/WarehouseSchemaReadinessService.java` |
| 数据访问异常映射 | `controller/WarehouseDataAccessExceptionHandler.java`;数据库不可用或结构漂移统一返回 `S0503/503` |
@@ -0,0 +1,93 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.warehouse.dto.OverseasStorageFeeDtos;
import com.baiyue.warehouse.service.OverseasStorageFeeService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
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;
import org.springframework.web.bind.annotation.RestController;
/** 平台海外仓阶梯费用管理接口。 */
@Validated
@RestController
@RequestMapping("/api/platform/v1/overseas-storage-fees")
@Tag(name = "海外仓阶梯费用", description = "按客户维护海外仓仓储费阶梯")
public class PlatformOverseasStorageFeeController {
@Resource
private OverseasStorageFeeService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:overseas-storage-fee:list')")
@Operation(summary = "分页查询海外仓阶梯费用")
public ApiResponse<PageResult<OverseasStorageFeeDtos.View>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @ParameterObject OverseasStorageFeeDtos.PageQuery query) {
requirePlatform(user);
return ApiResponse.success(service.page(query));
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:overseas-storage-fee:list')")
@Operation(summary = "查询海外仓阶梯费用详情")
public ApiResponse<OverseasStorageFeeDtos.View> detail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long id) {
requirePlatform(user);
return ApiResponse.success(service.detail(id));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:overseas-storage-fee:create')")
@Operation(summary = "新增海外仓阶梯费用")
public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody OverseasStorageFeeDtos.CreateRequest request) {
requirePlatform(user);
return ApiResponse.success(service.create(request));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('platform:overseas-storage-fee:edit')")
@Operation(summary = "编辑海外仓阶梯费用")
public ApiResponse<Void> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Min(1) long id,
@Valid @RequestBody OverseasStorageFeeDtos.UpdateRequest request) {
requirePlatform(user);
service.update(id, request);
return ApiResponse.success(null);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('platform:overseas-storage-fee:delete')")
@Operation(summary = "删除海外仓阶梯费用")
public ApiResponse<Void> delete(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Min(1) long id,
@RequestParam @Min(0) int version) {
requirePlatform(user);
service.delete(id, version);
return ApiResponse.success(null);
}
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
}
}
@@ -0,0 +1,86 @@
package com.baiyue.warehouse.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 海外仓阶梯费用接口契约。 */
public final class OverseasStorageFeeDtos {
private OverseasStorageFeeDtos() {
}
@Schema(name = "OverseasStorageFeePageQuery", description = "海外仓阶梯费用分页条件")
public record PageQuery(
@Min(1) long page,
@Min(1) @Max(200) long size,
@Schema(description = "客户ID,不传时查询全部客户") @Positive Long customerId,
@Schema(description = "客户公司名称关键字") @Size(max = 128) String customerName
) {
public PageQuery {
if (page == 0) {
page = 1;
}
if (size == 0) {
size = 20;
}
}
}
@Schema(name = "OverseasStorageFeeTierRequest", description = "仓储费阶梯")
public record TierRequest(
@NotNull @Min(1) @Max(36500) Integer startDay,
@NotNull @Min(1) @Max(36500) Integer endDay,
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6)
BigDecimal unitPrice
) {
}
@Schema(name = "OverseasStorageFeeCreateRequest", description = "新增海外仓阶梯费用")
public record CreateRequest(
@NotNull @Positive Long customerId,
@NotBlank @Size(max = 128) String customerName,
@NotBlank @Pattern(regexp = "^[A-Z]{3}$") String currency,
@NotEmpty @Size(max = 100) List<@Valid TierRequest> tiers
) {
}
@Schema(name = "OverseasStorageFeeUpdateRequest", description = "编辑海外仓阶梯费用")
public record UpdateRequest(
@NotNull @Positive Long customerId,
@NotBlank @Size(max = 128) String customerName,
@NotBlank @Pattern(regexp = "^[A-Z]{3}$") String currency,
@NotEmpty @Size(max = 100) List<@Valid TierRequest> tiers,
@NotNull @Min(0) Integer version
) {
}
@Schema(name = "OverseasStorageFeeTierView", description = "仓储费阶梯明细")
public record TierView(Long id, Integer startDay, Integer endDay, BigDecimal unitPrice, Integer sortNo) {
}
@Schema(name = "OverseasStorageFeeView", description = "海外仓阶梯费用详情")
public record View(
Long id,
Long customerId,
String customerName,
String currency,
List<TierView> tiers,
Integer version,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}
}
@@ -0,0 +1,20 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
/** 客户维度的海外仓阶梯仓储费主记录。 */
@Getter
@Setter
@TableName("wh_overseas_storage_fee_rule")
public class OverseasStorageFeeRuleEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String customerName;
private String currency;
}
@@ -0,0 +1,25 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/** 海外仓仓储费阶梯明细。 */
@Getter
@Setter
@TableName("wh_overseas_storage_fee_tier")
public class OverseasStorageFeeTierEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long ruleId;
private Integer startDay;
private Integer endDay;
private BigDecimal unitPrice;
private Integer sortNo;
}
@@ -0,0 +1,8 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.OverseasStorageFeeRuleEntity;
/** 海外仓阶梯费用主记录 Mapper。 */
public interface OverseasStorageFeeRuleMapper extends BaseMapper<OverseasStorageFeeRuleEntity> {
}
@@ -0,0 +1,8 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.OverseasStorageFeeTierEntity;
/** 海外仓阶梯费用明细 Mapper。 */
public interface OverseasStorageFeeTierMapper extends BaseMapper<OverseasStorageFeeTierEntity> {
}
@@ -0,0 +1,83 @@
package com.baiyue.warehouse.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.warehouse.dto.OverseasStorageFeeDtos;
import com.baiyue.warehouse.entity.OverseasStorageFeeRuleEntity;
import com.baiyue.warehouse.entity.OverseasStorageFeeTierEntity;
import com.baiyue.warehouse.mapper.OverseasStorageFeeRuleMapper;
import com.baiyue.warehouse.mapper.OverseasStorageFeeTierMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.Collection;
import java.util.List;
/** 海外仓阶梯费用持久化仓储。 */
@Repository
public class OverseasStorageFeeRepository {
@Resource
private OverseasStorageFeeRuleMapper ruleMapper;
@Resource
private OverseasStorageFeeTierMapper tierMapper;
public PageResult<OverseasStorageFeeRuleEntity> page(OverseasStorageFeeDtos.PageQuery query) {
LambdaQueryWrapper<OverseasStorageFeeRuleEntity> wrapper =
new LambdaQueryWrapper<OverseasStorageFeeRuleEntity>()
.eq(query.customerId() != null, OverseasStorageFeeRuleEntity::getTenantId, query.customerId())
.like(StringUtils.hasText(query.customerName()),
OverseasStorageFeeRuleEntity::getCustomerName, query.customerName())
.orderByDesc(OverseasStorageFeeRuleEntity::getUpdatedAt)
.orderByDesc(OverseasStorageFeeRuleEntity::getId);
return PageResult.from(ruleMapper.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public OverseasStorageFeeRuleEntity find(long id) {
return ruleMapper.selectById(id);
}
public boolean existsCustomer(long customerId, Long excludedId) {
return ruleMapper.selectCount(new LambdaQueryWrapper<OverseasStorageFeeRuleEntity>()
.eq(OverseasStorageFeeRuleEntity::getTenantId, customerId)
.ne(excludedId != null, OverseasStorageFeeRuleEntity::getId, excludedId)) > 0;
}
public List<OverseasStorageFeeTierEntity> findTiers(Collection<Long> ruleIds) {
if (ruleIds == null || ruleIds.isEmpty()) {
return List.of();
}
return tierMapper.selectList(new LambdaQueryWrapper<OverseasStorageFeeTierEntity>()
.in(OverseasStorageFeeTierEntity::getRuleId, ruleIds)
.orderByAsc(OverseasStorageFeeTierEntity::getRuleId)
.orderByAsc(OverseasStorageFeeTierEntity::getSortNo)
.orderByAsc(OverseasStorageFeeTierEntity::getId));
}
public void insertRule(OverseasStorageFeeRuleEntity entity) {
ruleMapper.insert(entity);
}
public int updateRule(OverseasStorageFeeRuleEntity entity) {
return ruleMapper.updateById(entity);
}
public void insertTiers(List<OverseasStorageFeeTierEntity> tiers) {
tiers.forEach(tierMapper::insert);
}
public void deleteTiers(long ruleId) {
tierMapper.delete(new LambdaQueryWrapper<OverseasStorageFeeTierEntity>()
.eq(OverseasStorageFeeTierEntity::getRuleId, ruleId));
}
public int deleteRule(long id, int version) {
return ruleMapper.update(null, new LambdaUpdateWrapper<OverseasStorageFeeRuleEntity>()
.eq(OverseasStorageFeeRuleEntity::getId, id)
.eq(OverseasStorageFeeRuleEntity::getVersion, version)
.set(OverseasStorageFeeRuleEntity::getDeleted, true)
.set(OverseasStorageFeeRuleEntity::getVersion, version + 1));
}
}
@@ -0,0 +1,161 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.OverseasStorageFeeDtos;
import com.baiyue.warehouse.entity.OverseasStorageFeeRuleEntity;
import com.baiyue.warehouse.entity.OverseasStorageFeeTierEntity;
import com.baiyue.warehouse.repository.OverseasStorageFeeRepository;
import jakarta.annotation.Resource;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** 海外仓阶梯费用管理服务。 */
@Service
public class OverseasStorageFeeService {
@Resource
private OverseasStorageFeeRepository repository;
public PageResult<OverseasStorageFeeDtos.View> page(OverseasStorageFeeDtos.PageQuery query) {
PageResult<OverseasStorageFeeRuleEntity> source = repository.page(query);
Map<Long, List<OverseasStorageFeeTierEntity>> tiers = repository.findTiers(
source.records().stream().map(OverseasStorageFeeRuleEntity::getId).toList())
.stream().collect(Collectors.groupingBy(OverseasStorageFeeTierEntity::getRuleId));
return new PageResult<>(source.records().stream()
.map(rule -> view(rule, tiers.getOrDefault(rule.getId(), List.of())))
.toList(), source.page(), source.size(), source.total(), source.pages());
}
public OverseasStorageFeeDtos.View detail(long id) {
OverseasStorageFeeRuleEntity rule = required(id);
return view(rule, repository.findTiers(List.of(id)));
}
@Transactional
@BusinessAudit(event = "OVERSEAS_STORAGE_FEE_CREATE", resourceType = "OVERSEAS_STORAGE_FEE",
description = "新增海外仓阶梯费用", captureBefore = false)
public Long create(OverseasStorageFeeDtos.CreateRequest request) {
ensureCustomerUnique(request.customerId(), null);
List<OverseasStorageFeeDtos.TierRequest> tiers = normalize(request.tiers());
OverseasStorageFeeRuleEntity rule = new OverseasStorageFeeRuleEntity();
copy(rule, request.customerId(), request.customerName(), request.currency());
try {
repository.insertRule(rule);
repository.insertTiers(tierEntities(rule.getId(), request.customerId(), tiers));
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "该客户已配置海外仓阶梯费用或阶梯区间重复");
}
return rule.getId();
}
@Transactional
@BusinessAudit(event = "OVERSEAS_STORAGE_FEE_UPDATE", resourceType = "OVERSEAS_STORAGE_FEE",
resourceId = "#id", description = "编辑海外仓阶梯费用")
public void update(long id, OverseasStorageFeeDtos.UpdateRequest request) {
OverseasStorageFeeRuleEntity rule = required(id);
if (!rule.getVersion().equals(request.version())) {
throw new BizException(ResultCode.CONFLICT, "费用配置已被修改,请刷新后重试");
}
ensureCustomerUnique(request.customerId(), id);
List<OverseasStorageFeeDtos.TierRequest> tiers = normalize(request.tiers());
copy(rule, request.customerId(), request.customerName(), request.currency());
try {
if (repository.updateRule(rule) != 1) {
throw new BizException(ResultCode.CONFLICT, "费用配置已被并发修改,请刷新后重试");
}
repository.deleteTiers(id);
repository.insertTiers(tierEntities(id, request.customerId(), tiers));
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "该客户已配置海外仓阶梯费用或阶梯区间重复");
}
}
@Transactional
@BusinessAudit(event = "OVERSEAS_STORAGE_FEE_DELETE", resourceType = "OVERSEAS_STORAGE_FEE",
resourceId = "#id", description = "删除海外仓阶梯费用")
public void delete(long id, int version) {
required(id);
repository.deleteTiers(id);
if (repository.deleteRule(id, version) != 1) {
throw new BizException(ResultCode.CONFLICT, "费用配置已被并发修改,请刷新后重试");
}
}
private OverseasStorageFeeRuleEntity required(long id) {
OverseasStorageFeeRuleEntity rule = repository.find(id);
if (rule == null) {
throw new BizException(ResultCode.NOT_FOUND, "海外仓阶梯费用不存在");
}
return rule;
}
private void ensureCustomerUnique(long customerId, Long excludedId) {
if (repository.existsCustomer(customerId, excludedId)) {
throw new BizException(ResultCode.CONFLICT, "该客户已配置海外仓阶梯费用");
}
}
private static List<OverseasStorageFeeDtos.TierRequest> normalize(
List<OverseasStorageFeeDtos.TierRequest> source) {
if (source == null || source.isEmpty() || source.size() > 100) {
throw new BizException(ResultCode.VALIDATION_FAILED, "阶梯费用明细必须为1至100组");
}
if (source.stream().anyMatch(tier -> tier == null || tier.startDay() == null
|| tier.endDay() == null || tier.unitPrice() == null
|| tier.unitPrice().signum() <= 0)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "阶梯天数和仓储费标准必须为有效正数");
}
List<OverseasStorageFeeDtos.TierRequest> tiers = new ArrayList<>(source);
tiers.sort(Comparator.comparingInt(OverseasStorageFeeDtos.TierRequest::startDay));
int expectedStart = 1;
for (OverseasStorageFeeDtos.TierRequest tier : tiers) {
if (tier.startDay() != expectedStart || tier.endDay() < tier.startDay()) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"阶梯必须从第1天开始,且区间连续、不能重叠或倒置");
}
expectedStart = tier.endDay() + 1;
}
return List.copyOf(tiers);
}
private static void copy(OverseasStorageFeeRuleEntity rule, long customerId,
String customerName, String currency) {
rule.setTenantId(customerId);
rule.setCustomerName(customerName.trim());
rule.setCurrency(currency);
}
private static List<OverseasStorageFeeTierEntity> tierEntities(
long ruleId, long customerId, List<OverseasStorageFeeDtos.TierRequest> source) {
List<OverseasStorageFeeTierEntity> result = new ArrayList<>(source.size());
for (int index = 0; index < source.size(); index++) {
OverseasStorageFeeDtos.TierRequest request = source.get(index);
OverseasStorageFeeTierEntity tier = new OverseasStorageFeeTierEntity();
tier.setTenantId(customerId);
tier.setRuleId(ruleId);
tier.setStartDay(request.startDay());
tier.setEndDay(request.endDay());
tier.setUnitPrice(request.unitPrice());
tier.setSortNo(index + 1);
result.add(tier);
}
return result;
}
private static OverseasStorageFeeDtos.View view(OverseasStorageFeeRuleEntity rule,
List<OverseasStorageFeeTierEntity> tiers) {
return new OverseasStorageFeeDtos.View(rule.getId(), rule.getTenantId(), rule.getCustomerName(),
rule.getCurrency(), tiers.stream().map(tier -> new OverseasStorageFeeDtos.TierView(
tier.getId(), tier.getStartDay(), tier.getEndDay(), tier.getUnitPrice(), tier.getSortNo()))
.toList(), rule.getVersion(), rule.getCreatedAt(), rule.getUpdatedAt());
}
}
@@ -22,7 +22,9 @@ public class WarehouseSchemaReadinessService {
"SELECT id, tenant_id, company_code, company_name, status, deleted FROM wh_express_company LIMIT 0",
"SELECT id, tenant_id, warehouse_id, zone_code, zone_name, location_code, location_type, max_weight_kg, shared_overflow, status, deleted, version FROM wh_location LIMIT 0",
"SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, reservation_status, expires_at, version FROM wh_location_reservation LIMIT 0",
"SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, occupancy_status, active_location_occupancy, active_pallet_occupancy, version FROM wh_location_occupancy LIMIT 0"
"SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, occupancy_status, active_location_occupancy, active_pallet_occupancy, version FROM wh_location_occupancy LIMIT 0",
"SELECT id, tenant_id, customer_name, currency, version FROM wh_overseas_storage_fee_rule LIMIT 0",
"SELECT id, tenant_id, rule_id, start_day, end_day, unit_price, sort_no, version FROM wh_overseas_storage_fee_tier LIMIT 0"
);
@Resource
@@ -0,0 +1,36 @@
package com.baiyue.warehouse.controller;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformOverseasStorageFeeControllerContractTest {
@Test
void exposesCrud() {
assertThat(PlatformOverseasStorageFeeController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/overseas-storage-fees");
assertMethod("page", GetMapping.class, "platform:overseas-storage-fee:list");
assertMethod("detail", GetMapping.class, "platform:overseas-storage-fee:list");
assertMethod("create", PostMapping.class, "platform:overseas-storage-fee:create");
assertMethod("update", PutMapping.class, "platform:overseas-storage-fee:edit");
assertMethod("delete", DeleteMapping.class, "platform:overseas-storage-fee:delete");
}
private static void assertMethod(String name, Class<? extends Annotation> mapping, String authority) {
Method method = Arrays.stream(PlatformOverseasStorageFeeController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow();
assertThat(method.getAnnotation(mapping)).isNotNull();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + authority + "')");
}
}
@@ -0,0 +1,31 @@
package com.baiyue.warehouse.repository;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class OverseasStorageFeeSqlContractTest {
@Test
void containsSchemaAndPermissions() throws IOException {
String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"));
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_rule")
.contains("CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_tier")
.contains("uk_overseas_fee_active_customer")
.contains("idx_overseas_fee_page")
.contains("idx_overseas_fee_customer_name")
.contains("uk_overseas_fee_tier_start")
.contains("idx_overseas_fee_tier_rule")
.contains("platform:overseas-storage-fee:list")
.contains("platform:overseas-storage-fee:create")
.contains("platform:overseas-storage-fee:edit")
.contains("platform:overseas-storage-fee:delete")
.contains("CALL unified_assert_overseas_storage_fee()")
.contains("海外仓阶梯费用表、索引和权限校验通过");
}
}
@@ -0,0 +1,110 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.OverseasStorageFeeDtos;
import com.baiyue.warehouse.entity.OverseasStorageFeeRuleEntity;
import com.baiyue.warehouse.entity.OverseasStorageFeeTierEntity;
import com.baiyue.warehouse.repository.OverseasStorageFeeRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(OverseasStorageFeeService.class)
class OverseasStorageFeeServiceTest {
@MockBean
private OverseasStorageFeeRepository repository;
@Resource
private OverseasStorageFeeService service;
@Test
void createsRule() {
doAnswer(invocation -> {
OverseasStorageFeeRuleEntity rule = invocation.getArgument(0);
rule.setId(10L);
return null;
}).when(repository).insertRule(any());
Long id = service.create(new OverseasStorageFeeDtos.CreateRequest(
10001L, "示例客户", "USD", List.of(tier(8, 30, "0.8"), tier(1, 7, "0.5"))));
assertThat(id).isEqualTo(10L);
verify(repository).insertTiers(any());
}
@Test
void rejectsTierGap() {
OverseasStorageFeeDtos.CreateRequest request = new OverseasStorageFeeDtos.CreateRequest(
10001L, "示例客户", "USD", List.of(tier(1, 7, "0.5"), tier(9, 30, "0.8")));
assertThatThrownBy(() -> service.create(request)).isInstanceOf(BizException.class);
verify(repository, never()).insertRule(any());
}
@Test
void rejectsDuplicateCustomer() {
when(repository.existsCustomer(10001L, null)).thenReturn(true);
OverseasStorageFeeDtos.CreateRequest request = new OverseasStorageFeeDtos.CreateRequest(
10001L, "示例客户", "USD", List.of(tier(1, 30, "0.8")));
assertThatThrownBy(() -> service.create(request)).isInstanceOf(BizException.class);
}
@Test
void pagesWithTiers() {
OverseasStorageFeeRuleEntity rule = rule();
OverseasStorageFeeTierEntity tier = new OverseasStorageFeeTierEntity();
tier.setId(20L);
tier.setRuleId(10L);
tier.setStartDay(1);
tier.setEndDay(30);
tier.setUnitPrice(new BigDecimal("0.8"));
tier.setSortNo(1);
OverseasStorageFeeDtos.PageQuery query = new OverseasStorageFeeDtos.PageQuery(1, 20, null, null);
when(repository.page(query)).thenReturn(new PageResult<>(List.of(rule), 1, 20, 1, 1));
when(repository.findTiers(List.of(10L))).thenReturn(List.of(tier));
OverseasStorageFeeDtos.View result = service.page(query).records().get(0);
assertThat(result.customerId()).isEqualTo(10001L);
assertThat(result.tiers()).singleElement().extracting(OverseasStorageFeeDtos.TierView::unitPrice)
.isEqualTo(new BigDecimal("0.8"));
}
@Test
void detectsStaleUpdate() {
OverseasStorageFeeRuleEntity rule = rule();
when(repository.find(10L)).thenReturn(rule);
OverseasStorageFeeDtos.UpdateRequest request = new OverseasStorageFeeDtos.UpdateRequest(
10001L, "示例客户", "USD", List.of(tier(1, 30, "0.8")), 2);
assertThatThrownBy(() -> service.update(10L, request)).isInstanceOf(BizException.class);
verify(repository, never()).updateRule(any());
}
private static OverseasStorageFeeDtos.TierRequest tier(int start, int end, String price) {
return new OverseasStorageFeeDtos.TierRequest(start, end, new BigDecimal(price));
}
private static OverseasStorageFeeRuleEntity rule() {
OverseasStorageFeeRuleEntity rule = new OverseasStorageFeeRuleEntity();
rule.setId(10L);
rule.setTenantId(10001L);
rule.setCustomerName("示例客户");
rule.setCurrency("USD");
rule.setVersion(1);
return rule;
}
}