From cac377de96ebd3d8514b69464f3cd97adfeab4bc Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 5 Aug 2026 11:20:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=B5=B7=E5=A4=96=E4=BB=93?= =?UTF-8?q?=E9=98=B6=E6=A2=AF=E8=B4=B9=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 5 +- .../auth/service/TokenSessionStore.java | 3 +- deploy/sql/README.md | 7 + deploy/sql/头程数据库统一增量-MySQL8.sql | 120 ++++++++++++- .../security/RedisNonceReplayGuard.java | 5 +- gateway/src/main/resources/application.yml | 2 +- .../GatewayFileRouteConfigurationTest.java | 3 +- warehouse/AGENTS.md | 1 + .../PlatformOverseasStorageFeeController.java | 93 ++++++++++ .../warehouse/dto/OverseasStorageFeeDtos.java | 86 ++++++++++ .../entity/OverseasStorageFeeRuleEntity.java | 20 +++ .../entity/OverseasStorageFeeTierEntity.java | 25 +++ .../mapper/OverseasStorageFeeRuleMapper.java | 8 + .../mapper/OverseasStorageFeeTierMapper.java | 8 + .../OverseasStorageFeeRepository.java | 83 +++++++++ .../service/OverseasStorageFeeService.java | 161 ++++++++++++++++++ .../WarehouseSchemaReadinessService.java | 4 +- ...rseasStorageFeeControllerContractTest.java | 36 ++++ .../OverseasStorageFeeSqlContractTest.java | 31 ++++ .../OverseasStorageFeeServiceTest.java | 110 ++++++++++++ 20 files changed, 801 insertions(+), 10 deletions(-) create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeController.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasStorageFeeDtos.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/entity/OverseasStorageFeeRuleEntity.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/entity/OverseasStorageFeeTierEntity.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/mapper/OverseasStorageFeeRuleMapper.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/mapper/OverseasStorageFeeTierMapper.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/repository/OverseasStorageFeeRepository.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/service/OverseasStorageFeeService.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStorageFeeServiceTest.java diff --git a/AGENTS.md b/AGENTS.md index 19c07a38b..b4d191662 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//` 起算;二期链路跨 `order`、`firstleg`、`warehouse`、`file`、`integration-service`,修改 DTO、内部令牌、状态或表结构时必须逐个检查调用方与被调用方。 - 二期入库主状态归 `firstleg`,订单货物主数据仍归 `order`,托盘/库位资源归 `warehouse`,文件对象归 `file`,OCR 供应商适配归 `integration-service`;禁止通过跨库查询绕过内部 API。 diff --git a/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java b/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java index 44d42b03a..254839ee1 100644 --- a/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java +++ b/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java @@ -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; diff --git a/deploy/sql/README.md b/deploy/sql/README.md index f39b064d0..f29ea10e0 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -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`。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 52176c6bc..5e3dfc0b8 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/security/RedisNonceReplayGuard.java b/firstleg/src/main/java/com/baiyue/firstleg/security/RedisNonceReplayGuard.java index fd82cab41..2cfd4c1e7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/security/RedisNonceReplayGuard.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/security/RedisNonceReplayGuard.java @@ -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 diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index 35d15cefb..9faae6430 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -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: diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java index 2ce509a57..750c7bb5b 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java @@ -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/**"); }); } } diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index adb308af7..85481bc52 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -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` | diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeController.java new file mode 100644 index 000000000..c79082c07 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeController.java @@ -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> 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 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 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 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 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, "仅允许平台端访问"); + } + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasStorageFeeDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasStorageFeeDtos.java new file mode 100644 index 000000000..7bf0688d0 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasStorageFeeDtos.java @@ -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 tiers, + Integer version, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/OverseasStorageFeeRuleEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/OverseasStorageFeeRuleEntity.java new file mode 100644 index 000000000..9dde3a88b --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/OverseasStorageFeeRuleEntity.java @@ -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; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/OverseasStorageFeeTierEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/OverseasStorageFeeTierEntity.java new file mode 100644 index 000000000..e8a163796 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/OverseasStorageFeeTierEntity.java @@ -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; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/OverseasStorageFeeRuleMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/OverseasStorageFeeRuleMapper.java new file mode 100644 index 000000000..9c84c55da --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/OverseasStorageFeeRuleMapper.java @@ -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 { +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/OverseasStorageFeeTierMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/OverseasStorageFeeTierMapper.java new file mode 100644 index 000000000..22a404b1d --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/OverseasStorageFeeTierMapper.java @@ -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 { +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/OverseasStorageFeeRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/OverseasStorageFeeRepository.java new file mode 100644 index 000000000..95dab9de0 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/OverseasStorageFeeRepository.java @@ -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 page(OverseasStorageFeeDtos.PageQuery query) { + LambdaQueryWrapper wrapper = + new LambdaQueryWrapper() + .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() + .eq(OverseasStorageFeeRuleEntity::getTenantId, customerId) + .ne(excludedId != null, OverseasStorageFeeRuleEntity::getId, excludedId)) > 0; + } + + public List findTiers(Collection ruleIds) { + if (ruleIds == null || ruleIds.isEmpty()) { + return List.of(); + } + return tierMapper.selectList(new LambdaQueryWrapper() + .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 tiers) { + tiers.forEach(tierMapper::insert); + } + + public void deleteTiers(long ruleId) { + tierMapper.delete(new LambdaQueryWrapper() + .eq(OverseasStorageFeeTierEntity::getRuleId, ruleId)); + } + + public int deleteRule(long id, int version) { + return ruleMapper.update(null, new LambdaUpdateWrapper() + .eq(OverseasStorageFeeRuleEntity::getId, id) + .eq(OverseasStorageFeeRuleEntity::getVersion, version) + .set(OverseasStorageFeeRuleEntity::getDeleted, true) + .set(OverseasStorageFeeRuleEntity::getVersion, version + 1)); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/OverseasStorageFeeService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/OverseasStorageFeeService.java new file mode 100644 index 000000000..23f1e3b92 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/OverseasStorageFeeService.java @@ -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 page(OverseasStorageFeeDtos.PageQuery query) { + PageResult source = repository.page(query); + Map> 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 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 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 normalize( + List 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 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 tierEntities( + long ruleId, long customerId, List source) { + List 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 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()); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java index 3065ea89a..a652cce94 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java @@ -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 diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java new file mode 100644 index 000000000..c93548db4 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java @@ -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 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 + "')"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java new file mode 100644 index 000000000..7e692463c --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java @@ -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("海外仓阶梯费用表、索引和权限校验通过"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStorageFeeServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStorageFeeServiceTest.java new file mode 100644 index 000000000..e9cd06aef --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStorageFeeServiceTest.java @@ -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; + } +}