diff --git a/src/main/java/com/ycwl/basic/pricing/CLAUDE.md b/src/main/java/com/ycwl/basic/pricing/CLAUDE.md new file mode 100644 index 0000000..c1c0ebf --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/CLAUDE.md @@ -0,0 +1,557 @@ +# 价格查询系统 (Pricing Module) 开发指南 + +此文档为pricing包的专用开发指南,提供该模块的详细架构说明和开发最佳实践。 + +## 模块概览 + +价格查询系统 (`com.ycwl.basic.pricing`) 是一个独立的业务模块,提供商品定价、优惠券管理、券码管理和价格计算功能。采用分层架构设计,具备完整的CRUD操作、异常处理和数据统计功能。支持优惠券和券码的同时使用,券码优先级更高。 + +## 目录结构 + +``` +com.ycwl.basic.pricing/ +├── controller/ # REST API控制器层 +│ ├── CouponManagementController.java # 优惠券管理API +│ ├── PriceCalculationController.java # 价格计算API +│ └── PricingConfigController.java # 价格配置管理API +├── dto/ # 数据传输对象 +│ ├── BundleProductItem.java # 套餐商品项 +│ ├── CouponInfo.java # 优惠券信息 +│ ├── CouponUseRequest.java # 优惠券使用请求 +│ ├── CouponUseResult.java # 优惠券使用结果 +│ ├── DiscountDetail.java # 折扣详情 +│ ├── PriceCalculationRequest.java # 价格计算请求 +│ ├── PriceCalculationResult.java # 价格计算结果 +│ ├── PriceDetails.java # 价格详情 +│ ├── ProductItem.java # 商品项 +│ ├── ProductPriceInfo.java # 商品价格信息 +│ ├── VoucherInfo.java # 券码信息 +│ ├── DiscountDetectionContext.java # 优惠检测上下文 +│ ├── DiscountInfo.java # 优惠信息 +│ ├── DiscountResult.java # 优惠结果 +│ └── DiscountCombinationResult.java # 优惠组合结果 +├── entity/ # 数据库实体类 +│ ├── BaseEntity.java # 基础实体类 +│ ├── PriceBundleConfig.java # 套餐配置 +│ ├── PriceCouponClaimRecord.java # 优惠券领取记录 +│ ├── PriceCouponConfig.java # 优惠券配置 +│ ├── PriceProductConfig.java # 商品价格配置 +│ ├── PriceTierConfig.java # 分层价格配置 +│ ├── PriceVoucherBatchConfig.java # 券码批次配置 +│ └── PriceVoucherCode.java # 券码实体 +├── enums/ # 枚举类 +│ ├── CouponStatus.java # 优惠券状态 +│ ├── CouponType.java # 优惠券类型 +│ ├── ProductType.java # 商品类型 +│ ├── VoucherDiscountType.java # 券码优惠类型 +│ └── VoucherCodeStatus.java # 券码状态 +├── exception/ # 异常处理 +│ ├── CouponInvalidException.java # 优惠券无效异常 +│ ├── PriceCalculationException.java # 价格计算异常 +│ ├── PricingExceptionHandler.java # 定价异常处理器 +│ ├── ProductConfigNotFoundException.java # 商品配置未找到异常 +│ ├── VoucherInvalidException.java # 券码无效异常 +│ ├── VoucherAlreadyUsedException.java # 券码已使用异常 +│ ├── VoucherNotClaimableException.java # 券码不可领取异常 +│ └── DiscountDetectionException.java # 优惠检测异常 +├── handler/ # 自定义处理器 +│ └── BundleProductListTypeHandler.java # 套餐商品列表类型处理器 +├── mapper/ # MyBatis数据访问层 +│ ├── PriceBundleConfigMapper.java +│ ├── PriceCouponClaimRecordMapper.java +│ ├── PriceCouponConfigMapper.java +│ ├── PriceProductConfigMapper.java +│ ├── PriceTierConfigMapper.java +│ ├── PriceVoucherBatchConfigMapper.java +│ └── PriceVoucherCodeMapper.java +└── service/ # 业务逻辑层 + ├── ICouponManagementService.java # 优惠券管理服务接口 + ├── ICouponService.java # 优惠券服务接口 + ├── IPriceBundleService.java # 套餐服务接口 + ├── IPriceCalculationService.java # 价格计算服务接口 + ├── IPricingManagementService.java # 定价管理服务接口 + ├── IProductConfigService.java # 商品配置服务接口 + ├── IVoucherService.java # 券码服务接口 + ├── IDiscountProvider.java # 优惠提供者接口 + ├── IDiscountDetectionService.java # 优惠检测服务接口 + └── impl/ # 服务实现类 + ├── CouponManagementServiceImpl.java + ├── CouponServiceImpl.java + ├── PriceBundleServiceImpl.java + ├── PriceCalculationServiceImpl.java + ├── PricingManagementServiceImpl.java + ├── ProductConfigServiceImpl.java + ├── VoucherServiceImpl.java + ├── CouponDiscountProvider.java + ├── VoucherDiscountProvider.java + └── DiscountDetectionServiceImpl.java +``` + +## 核心功能 + +### 1. 价格计算引擎 + +#### API端点 +- `POST /api/pricing/calculate` - 执行价格计算 + +#### 计算流程 +```java +// 价格计算核心流程 +1. 验证PriceCalculationRequest请求参数 +2. 加载商品基础配置 (PriceProductConfig) +3. 应用分层定价规则 (PriceTierConfig) +4. 处理套餐商品逻辑 (BundleProductItem) +5. 使用统一优惠检测系统处理券码和优惠券 +6. 按优先级应用优惠:券码 > 优惠券 +7. 计算最终价格并返回详细结果 +``` + +#### 关键类 +- `PriceCalculationService`: 价格计算核心逻辑 +- `PriceCalculationRequest`: 计算请求DTO +- `PriceCalculationResult`: 计算结果DTO + +### 2. 优惠券管理系统 + +#### API端点 +- `GET /api/pricing/admin/coupons/` - 分页查询优惠券配置 +- `POST /api/pricing/admin/coupons/` - 创建优惠券配置 +- `PUT /api/pricing/admin/coupons/{id}` - 更新优惠券配置 +- `DELETE /api/pricing/admin/coupons/{id}` - 删除优惠券配置 +- `GET /api/pricing/admin/coupons/{id}/claims` - 查询优惠券领取记录 +- `GET /api/pricing/admin/coupons/{id}/stats` - 获取优惠券统计信息 + +#### 优惠券类型 +```java +public enum CouponType { + PERCENTAGE, // 百分比折扣 + FIXED_AMOUNT // 固定金额减免 +} + +public enum CouponStatus { + CLAIMED, // 已领取 + USED, // 已使用 + EXPIRED // 已过期 +} +``` + +#### 关键特性 +- **商品类型限制**: 通过`applicableProducts` JSON字段控制适用商品 +- **消费限制**: 支持最小消费金额和最大折扣限制 +- **时效性**: 基于时间的有效期控制 +- **统计分析**: 完整的使用统计和分析功能 + +### 3. 商品配置管理 + +#### API端点 +- `GET /api/pricing/config/products` - 查询商品配置 +- `POST /api/pricing/config/products` - 创建商品配置 +- `PUT /api/pricing/config/products/{id}` - 更新商品配置 + +#### 商品类型定义 +```java +public enum ProductType { + VLOG_VIDEO("vlog_video", "Vlog视频"), + RECORDING_SET("recording_set", "录像集"), + PHOTO_SET("photo_set", "照相集"), + PHOTO_PRINT("photo_print", "照片打印"), + MACHINE_PRINT("machine_print", "一体机打印"); +} +``` + +#### 分层定价 +支持基于数量的分层定价策略,通过`PriceTierConfig`实体配置不同数量区间的单价。 + +## 开发最佳实践 + +### 1. 添加新商品类型 + +```java +// 步骤1: 在ProductType枚举中添加新类型 +public enum ProductType { + // 现有类型... + NEW_PRODUCT("new_product", "新商品类型"); +} + +// 步骤2: 在数据库中添加default配置 +INSERT INTO price_product_config (product_type, base_price, ...) +VALUES ('new_product', 100.00, ...); + +// 步骤3: 添加分层定价配置(可选) +INSERT INTO price_tier_config (product_type, min_quantity, max_quantity, unit_price, ...) +VALUES ('new_product', 1, 10, 95.00, ...); + +// 步骤4: 更新前端产品类型映射 +``` + +### 2. 扩展优惠券类型 + +```java +// 步骤1: 在CouponType枚举中添加新类型 +public enum CouponType { + PERCENTAGE, + FIXED_AMOUNT, + NEW_COUPON_TYPE // 新增类型 +} + +// 步骤2: 在CouponServiceImpl中实现计算逻辑 +@Override +public BigDecimal calculateDiscount(CouponConfig coupon, BigDecimal originalPrice) { + return switch (coupon.getCouponType()) { + case PERCENTAGE -> calculatePercentageDiscount(coupon, originalPrice); + case FIXED_AMOUNT -> calculateFixedAmountDiscount(coupon, originalPrice); + case NEW_COUPON_TYPE -> calculateNewTypeDiscount(coupon, originalPrice); + }; +} + +// 步骤3: 更新applicableProducts验证规则 +``` + +### 3. 自定义TypeHandler使用 + +项目使用MyBatis自定义TypeHandler处理复杂JSON字段: + +```java +// BundleProductListTypeHandler处理套餐商品列表 +@MappedTypes(List.class) +@MappedJdbcTypes(JdbcType.VARCHAR) +public class BundleProductListTypeHandler extends BaseTypeHandler> { + // JSON序列化/反序列化逻辑 +} + +// 在实体类中使用 +public class SomeEntity { + @TableField(typeHandler = BundleProductListTypeHandler.class) + private List bundleProducts; +} +``` + +### 4. 异常处理模式 + +```java +// 自定义异常类 +public class PriceCalculationException extends RuntimeException { + public PriceCalculationException(String message) { + super(message); + } +} + +// 在PricingExceptionHandler中统一处理 +@ExceptionHandler(PriceCalculationException.class) +public ApiResponse handlePriceCalculationException(PriceCalculationException e) { + return ApiResponse.error(ErrorCode.PRICE_CALCULATION_ERROR, e.getMessage()); +} +``` + +### 5. 分页查询实现 + +```java +// 使用PageHelper实现分页 +@Override +public PageInfo getCouponsByPage(int pageNum, int pageSize, String status, String name) { + PageHelper.startPage(pageNum, pageSize); + + QueryWrapper queryWrapper = new QueryWrapper<>(); + if (StringUtils.hasText(status)) { + queryWrapper.eq("status", status); + } + if (StringUtils.hasText(name)) { + queryWrapper.like("name", name); + } + + List list = couponConfigMapper.selectList(queryWrapper); + return new PageInfo<>(list); +} +``` + +## 测试策略 + +### 1. 单元测试 +每个服务类都应有对应的测试类,重点测试: +- 价格计算逻辑的准确性 +- 优惠券适用性验证 +- 边界条件处理 +- 异常场景覆盖 + +### 2. 集成测试 +- 数据库操作测试 +- JSON序列化测试 +- 分页功能测试 +- API端点测试 + +### 3. 配置验证测试 +- `DefaultConfigValidationTest`: 验证default配置的完整性和正确性 +- 确保所有ProductType都有对应的基础配置 + +## 数据库设计 + +### 核心表结构 +- `price_product_config`: 商品价格基础配置 +- `price_tier_config`: 分层定价配置 +- `price_bundle_config`: 套餐配置 +- `price_coupon_config`: 优惠券配置 +- `price_coupon_claim_record`: 优惠券领取记录 + +### 关键字段设计 +- JSON字段处理: `applicable_products`使用JSON存储适用商品类型列表 +- 时间字段: 统一使用`LocalDateTime`类型 +- 价格字段: 使用`BigDecimal`确保精度 +- 状态字段: 使用枚举类型确保数据一致性 + +## 性能优化建议 + +1. **数据库查询优化** + - 为常用查询字段添加索引 + - 使用分页查询避免大量数据加载 + - 优化复杂的JOIN查询 + +2. **缓存策略** + - 对商品配置进行缓存,减少数据库访问 + - 使用Redis缓存热点优惠券信息 + +3. **计算性能** + - 价格计算使用BigDecimal确保精度 + - 批量计算时考虑并行处理 + +## 安全考虑 + +1. **输入验证** + - 严格验证所有输入参数 + - 防止SQL注入和XSS攻击 + +2. **权限控制** + - 管理接口需要适当的权限验证 + - 用户只能访问自己的优惠券记录 + +3. **数据完整性** + - 使用事务确保数据一致性 + - 关键操作添加审计日志 + +## 券码管理系统 (Voucher System) + +### 1. 核心特性 + +券码系统是从原`voucher`包迁移而来,现已完全集成到pricing包中,与优惠券系统并行工作。 + +#### 券码优惠类型 +```java +public enum VoucherDiscountType { + FREE_ALL(0, "全场免费"), // 所有商品免费,优先级最高且不可叠加 + REDUCE_PRICE(1, "商品降价"), // 每个商品减免固定金额 + DISCOUNT(2, "商品打折") // 每个商品按百分比打折 +} +``` + +#### 券码状态流转 +``` +UNCLAIMED(0) → CLAIMED_UNUSED(1) → USED(2) + 未领取 → 已领取未使用 → 已使用 +``` + +### 2. 数据库表结构 + +#### 券码批次配置表 (price_voucher_batch_config) +- 批次管理:支持按景区、推客创建券码批次 +- 统计功能:实时统计已领取数、已使用数 +- 状态控制:支持启用/禁用批次 + +#### 券码表 (price_voucher_code) +- 唯一性约束:每个券码全局唯一 +- 用户限制:同一用户在同一景区只能领取一次券码 +- 时间追踪:记录领取时间、使用时间 + +### 3. 关键业务规则 + +#### 领取限制 +- 同一`faceId`在同一`scenicId`中只能领取一次券码 +- 只有启用状态的批次才能领取券码 +- 批次必须有可用券码才能成功领取 + +#### 使用验证 +- 券码必须是`CLAIMED_UNUSED`状态才能使用 +- 必须验证券码与景区的匹配关系 +- 使用后自动更新批次统计数据 + +## 统一优惠检测系统 (Unified Discount Detection) + +### 1. 架构设计 + +采用策略模式设计的可扩展优惠检测系统,支持多种优惠类型的统一管理和自动优化组合。 + +#### 核心接口 +```java +// 优惠提供者接口 +public interface IDiscountProvider { + String getProviderType(); // 提供者类型 + int getPriority(); // 优先级(数字越大越高) + List detectAvailableDiscounts(); // 检测可用优惠 + DiscountResult applyDiscount(); // 应用优惠 +} + +// 优惠检测服务接口 +public interface IDiscountDetectionService { + DiscountCombinationResult calculateOptimalCombination(); // 计算最优组合 + DiscountCombinationResult previewOptimalCombination(); // 预览优惠组合 +} +``` + +### 2. 优惠提供者实现 + +#### VoucherDiscountProvider (优先级: 100) +- 处理券码优惠逻辑 +- 支持用户主动输入券码或自动选择最优券码 +- 全场免费券码不可与其他优惠叠加 + +#### CouponDiscountProvider (优先级: 80) +- 处理优惠券优惠逻辑 +- 自动选择最优优惠券 +- 可与券码叠加使用(除全场免费券码外) + +### 3. 优惠应用策略 + +#### 优先级规则 +``` +券码优惠 (Priority: 100) → 优惠券优惠 (Priority: 80) +``` + +#### 叠加逻辑 +```java +原价 → 应用券码优惠 → 应用优惠券优惠 → 最终价格 + +特殊情况: +- 全场免费券码:最终价格直接为0,不再应用其他优惠 +- 其他券码类型:可与优惠券叠加使用 +``` + +#### 显示顺序 +``` +1. 券码优惠 (sortOrder: 1) +2. 限时立减 (sortOrder: 2) +3. 优惠券优惠 (sortOrder: 3) +4. 一口价优惠 (sortOrder: 4) +``` + +### 4. 扩展支持 + +#### 添加新优惠类型 +```java +@Component +public class FlashSaleDiscountProvider implements IDiscountProvider { + @Override + public String getProviderType() { return "FLASH_SALE"; } + + @Override + public int getPriority() { return 90; } // 介于券码和优惠券之间 + + // 实现其他方法... +} +``` + +#### 动态注册 +```java +// 系统启动时自动扫描所有IDiscountProvider实现类 +// 按优先级排序并注册到DiscountDetectionService中 +``` + +## API接口扩展 + +### 1. 价格计算接口扩展 + +#### 新增请求参数 +```java +public class PriceCalculationRequest { + // 原有字段... + private String voucherCode; // 用户输入的券码 + private Long scenicId; // 景区ID + private Long faceId; // 用户faceId + private Boolean autoUseVoucher; // 是否自动使用券码 + private Boolean previewOnly; // 是否仅预览优惠 +} +``` + +#### 新增响应字段 +```java +public class PriceCalculationResult { + // 原有字段... + private VoucherInfo usedVoucher; // 使用的券码信息 + private List availableDiscounts; // 可用优惠列表(预览模式) +} +``` + +### 2. 券码管理接口 + +#### 移动端接口 +- `POST /api/pricing/mobile/voucher/claim` - 领取券码 +- `GET /api/pricing/mobile/voucher/my-codes` - 我的券码列表 + +#### 管理端接口 +- `POST /api/pricing/admin/voucher/batch/create` - 创建券码批次 +- `GET /api/pricing/admin/voucher/batch/list` - 批次列表查询 +- `GET /api/pricing/admin/voucher/codes` - 券码列表查询 + +## 开发最佳实践更新 + +### 1. 优惠检测开发 +```java +// 检测上下文构建 +DiscountDetectionContext context = new DiscountDetectionContext(); +context.setUserId(userId); +context.setFaceId(faceId); +context.setScenicId(scenicId); +context.setProducts(products); +context.setCurrentAmount(amount); +context.setVoucherCode(voucherCode); + +// 使用统一服务检测优惠 +DiscountCombinationResult result = discountDetectionService + .calculateOptimalCombination(context); +``` + +### 2. 券码服务使用 +```java +// 验证券码 +VoucherInfo voucherInfo = voucherService.validateAndGetVoucherInfo( + voucherCode, faceId, scenicId); + +// 计算券码优惠 +BigDecimal discount = voucherService.calculateVoucherDiscount( + voucherInfo, context); + +// 标记券码已使用 +voucherService.markVoucherAsUsed(voucherCode, "订单使用"); +``` + +### 3. 异常处理扩展 +```java +// 券码相关异常 +try { + // 券码操作 +} catch (VoucherInvalidException e) { + // 券码无效 +} catch (VoucherAlreadyUsedException e) { + // 券码已使用 +} catch (VoucherNotClaimableException e) { + // 券码不可领取 +} +``` + +## 数据库扩展 + +### 新增表结构 +- `price_voucher_batch_config`: 券码批次配置表 +- `price_voucher_code`: 券码表 + +### 索引优化 +```sql +-- 券码查询优化 +CREATE INDEX idx_voucher_code ON price_voucher_code(code); +CREATE INDEX idx_face_scenic ON price_voucher_code(face_id, scenic_id); + +-- 批次查询优化 +CREATE INDEX idx_scenic_broker ON price_voucher_batch_config(scenic_id, broker_id); +``` + +### 性能考虑 +- 券码表可能数据量较大,考虑按景区分表 +- 定期清理已删除的过期数据 +- 使用数据完整性检查SQL验证统计数据准确性 \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/controller/VoucherController.java b/src/main/java/com/ycwl/basic/pricing/controller/VoucherManagementController.java similarity index 83% rename from src/main/java/com/ycwl/basic/voucher/controller/VoucherController.java rename to src/main/java/com/ycwl/basic/pricing/controller/VoucherManagementController.java index fea2ab1..dda20ab 100644 --- a/src/main/java/com/ycwl/basic/voucher/controller/VoucherController.java +++ b/src/main/java/com/ycwl/basic/pricing/controller/VoucherManagementController.java @@ -1,25 +1,25 @@ -package com.ycwl.basic.voucher.controller; +package com.ycwl.basic.pricing.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ycwl.basic.pricing.dto.req.VoucherBatchCreateReq; +import com.ycwl.basic.pricing.dto.req.VoucherBatchQueryReq; +import com.ycwl.basic.pricing.dto.req.VoucherClaimReq; +import com.ycwl.basic.pricing.dto.req.VoucherCodeQueryReq; +import com.ycwl.basic.pricing.dto.resp.VoucherBatchResp; +import com.ycwl.basic.pricing.dto.resp.VoucherBatchStatsResp; +import com.ycwl.basic.pricing.dto.resp.VoucherCodeResp; +import com.ycwl.basic.pricing.service.VoucherBatchService; +import com.ycwl.basic.pricing.service.VoucherCodeService; import com.ycwl.basic.utils.ApiResponse; -import com.ycwl.basic.voucher.dto.req.VoucherBatchCreateReq; -import com.ycwl.basic.voucher.dto.req.VoucherBatchQueryReq; -import com.ycwl.basic.voucher.dto.req.VoucherClaimReq; -import com.ycwl.basic.voucher.dto.req.VoucherCodeQueryReq; -import com.ycwl.basic.voucher.dto.resp.VoucherBatchResp; -import com.ycwl.basic.voucher.dto.resp.VoucherBatchStatsResp; -import com.ycwl.basic.voucher.dto.resp.VoucherCodeResp; -import com.ycwl.basic.voucher.service.VoucherBatchService; -import com.ycwl.basic.voucher.service.VoucherCodeService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController -@RequestMapping("/api/voucher") +@RequestMapping("/api/pricing/voucher") @RequiredArgsConstructor -public class VoucherController { +public class VoucherManagementController { private final VoucherBatchService voucherBatchService; private final VoucherCodeService voucherCodeService; diff --git a/src/main/java/com/ycwl/basic/pricing/dto/CouponUseResult.java b/src/main/java/com/ycwl/basic/pricing/dto/CouponUseResult.java index b5df283..a82aa00 100644 --- a/src/main/java/com/ycwl/basic/pricing/dto/CouponUseResult.java +++ b/src/main/java/com/ycwl/basic/pricing/dto/CouponUseResult.java @@ -3,7 +3,7 @@ package com.ycwl.basic.pricing.dto; import lombok.Data; import java.math.BigDecimal; -import java.time.LocalDateTime; +import java.util.Date; /** * 优惠券使用结果DTO @@ -29,7 +29,7 @@ public class CouponUseResult { /** * 使用时间 */ - private LocalDateTime useTime; + private Date useTime; /** * 优惠金额 diff --git a/src/main/java/com/ycwl/basic/pricing/dto/DiscountCombinationResult.java b/src/main/java/com/ycwl/basic/pricing/dto/DiscountCombinationResult.java new file mode 100644 index 0000000..7478b80 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/dto/DiscountCombinationResult.java @@ -0,0 +1,53 @@ +package com.ycwl.basic.pricing.dto; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 优惠组合结果 + */ +@Data +public class DiscountCombinationResult { + + /** + * 原始金额 + */ + private BigDecimal originalAmount; + + /** + * 最终金额 + */ + private BigDecimal finalAmount; + + /** + * 总优惠金额 + */ + private BigDecimal totalDiscountAmount; + + /** + * 应用的优惠列表(按优先级排序) + */ + private List appliedDiscounts; + + /** + * 可用但未应用的优惠列表 + */ + private List availableDiscounts; + + /** + * 优惠详情列表(用于展示) + */ + private List discountDetails; + + /** + * 计算是否成功 + */ + private Boolean success; + + /** + * 错误信息(如果success为false) + */ + private String errorMessage; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/dto/DiscountDetail.java b/src/main/java/com/ycwl/basic/pricing/dto/DiscountDetail.java index 30954e7..e7bacc8 100644 --- a/src/main/java/com/ycwl/basic/pricing/dto/DiscountDetail.java +++ b/src/main/java/com/ycwl/basic/pricing/dto/DiscountDetail.java @@ -44,7 +44,7 @@ public class DiscountDetail { detail.setDiscountName("限时立减"); detail.setDiscountAmount(discountAmount); detail.setDescription("限时优惠,立即享受"); - detail.setSortOrder(1); // 限时立减排在最前面 + detail.setSortOrder(2); // 限时立减排在券码后面 return detail; } @@ -57,7 +57,20 @@ public class DiscountDetail { detail.setDiscountName(couponName); detail.setDiscountAmount(discountAmount); detail.setDescription("优惠券减免"); - detail.setSortOrder(2); // 优惠券排在限时立减后面 + detail.setSortOrder(3); // 优惠券排在限时立减后面 + return detail; + } + + /** + * 创建券码折扣明细 + */ + public static DiscountDetail createVoucherDiscount(String voucherCode, String discountTypeName, BigDecimal discountAmount) { + DiscountDetail detail = new DiscountDetail(); + detail.setDiscountType("VOUCHER"); + detail.setDiscountName("券码优惠"); + detail.setDiscountAmount(discountAmount); + detail.setDescription(String.format("券码 %s - %s", voucherCode, discountTypeName)); + detail.setSortOrder(1); // 券码优先级最高,排在最前面 return detail; } @@ -70,7 +83,7 @@ public class DiscountDetail { detail.setDiscountName("一口价优惠"); detail.setDiscountAmount(discountAmount); detail.setDescription("一口价购买更优惠"); - detail.setSortOrder(3); // 一口价排在最后 + detail.setSortOrder(4); // 一口价排在最后 return detail; } } \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/dto/DiscountDetectionContext.java b/src/main/java/com/ycwl/basic/pricing/dto/DiscountDetectionContext.java new file mode 100644 index 0000000..a0a08ea --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/dto/DiscountDetectionContext.java @@ -0,0 +1,53 @@ +package com.ycwl.basic.pricing.dto; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 优惠检测上下文 + */ +@Data +public class DiscountDetectionContext { + + /** + * 用户ID + */ + private Long userId; + + /** + * 用户faceId + */ + private Long faceId; + + /** + * 景区ID + */ + private Long scenicId; + + /** + * 商品列表 + */ + private List products; + + /** + * 当前金额 + */ + private BigDecimal currentAmount; + + /** + * 用户主动输入的券码 + */ + private String voucherCode; + + /** + * 是否自动使用优惠券 + */ + private Boolean autoUseCoupon; + + /** + * 是否自动使用券码 + */ + private Boolean autoUseVoucher; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/dto/DiscountInfo.java b/src/main/java/com/ycwl/basic/pricing/dto/DiscountInfo.java new file mode 100644 index 0000000..f5d59c3 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/dto/DiscountInfo.java @@ -0,0 +1,82 @@ +package com.ycwl.basic.pricing.dto; + +import lombok.Data; + +import java.math.BigDecimal; + +/** + * 优惠信息DTO + */ +@Data +public class DiscountInfo { + + /** + * 优惠ID + */ + private Long discountId; + + /** + * 优惠类型(COUPON, VOUCHER等) + */ + private String discountType; + + /** + * 优惠名称 + */ + private String discountName; + + /** + * 优惠描述 + */ + private String discountDescription; + + /** + * 优惠金额 + */ + private BigDecimal discountAmount; + + /** + * 原始优惠值(用于百分比折扣等) + */ + private BigDecimal originalValue; + + /** + * 优惠值类型(PERCENTAGE, FIXED_AMOUNT等) + */ + private String valueType; + + /** + * 最小消费金额限制 + */ + private BigDecimal minAmount; + + /** + * 最大优惠金额限制 + */ + private BigDecimal maxDiscount; + + /** + * 优惠提供者类型 + */ + private String providerType; + + /** + * 优惠优先级 + */ + private Integer priority; + + /** + * 是否可与其他优惠叠加 + */ + private Boolean stackable; + + /** + * 券码(如果是voucher类型) + */ + private String voucherCode; + + /** + * 优惠券ID(如果是coupon类型) + */ + private Long couponId; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/dto/DiscountResult.java b/src/main/java/com/ycwl/basic/pricing/dto/DiscountResult.java new file mode 100644 index 0000000..e59c835 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/dto/DiscountResult.java @@ -0,0 +1,43 @@ +package com.ycwl.basic.pricing.dto; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 优惠应用结果 + */ +@Data +public class DiscountResult { + + /** + * 应用的优惠信息 + */ + private DiscountInfo discountInfo; + + /** + * 实际优惠金额 + */ + private BigDecimal actualDiscountAmount; + + /** + * 应用后的金额 + */ + private BigDecimal finalAmount; + + /** + * 是否成功应用 + */ + private Boolean success; + + /** + * 失败原因(如果success为false) + */ + private String failureReason; + + /** + * 影响的商品项(用于商品级别的优惠) + */ + private List affectedProducts; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/dto/PriceCalculationRequest.java b/src/main/java/com/ycwl/basic/pricing/dto/PriceCalculationRequest.java index 72b2048..9563334 100644 --- a/src/main/java/com/ycwl/basic/pricing/dto/PriceCalculationRequest.java +++ b/src/main/java/com/ycwl/basic/pricing/dto/PriceCalculationRequest.java @@ -24,4 +24,29 @@ public class PriceCalculationRequest { * 是否自动使用优惠券 */ private Boolean autoUseCoupon = true; + + /** + * 用户输入的券码 + */ + private String voucherCode; + + /** + * 景区ID(用于券码验证) + */ + private Long scenicId; + + /** + * 用户faceId(用于券码领取资格验证) + */ + private Long faceId; + + /** + * 是否自动使用券码优惠 + */ + private Boolean autoUseVoucher = true; + + /** + * 是否仅预览优惠(不实际使用) + */ + private Boolean previewOnly = false; } \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/dto/PriceCalculationResult.java b/src/main/java/com/ycwl/basic/pricing/dto/PriceCalculationResult.java index bd00cdb..c23e8bb 100644 --- a/src/main/java/com/ycwl/basic/pricing/dto/PriceCalculationResult.java +++ b/src/main/java/com/ycwl/basic/pricing/dto/PriceCalculationResult.java @@ -37,10 +37,20 @@ public class PriceCalculationResult { private CouponInfo usedCoupon; /** - * 折扣明细列表(包含限时立减、优惠券、一口价等) + * 使用的券码信息 + */ + private VoucherInfo usedVoucher; + + /** + * 折扣明细列表(包含限时立减、优惠券、券码、一口价等) */ private List discountDetails; + /** + * 可用但未使用的优惠列表(预览时使用) + */ + private List availableDiscounts; + /** * 商品明细列表 */ diff --git a/src/main/java/com/ycwl/basic/pricing/dto/VoucherInfo.java b/src/main/java/com/ycwl/basic/pricing/dto/VoucherInfo.java new file mode 100644 index 0000000..b4e80b8 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/dto/VoucherInfo.java @@ -0,0 +1,84 @@ +package com.ycwl.basic.pricing.dto; + +import com.ycwl.basic.pricing.enums.VoucherDiscountType; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * 券码信息DTO + */ +@Data +public class VoucherInfo { + + /** + * 券码ID + */ + private Long voucherId; + + /** + * 券码 + */ + private String voucherCode; + + /** + * 批次ID + */ + private Long batchId; + + /** + * 批次名称 + */ + private String batchName; + + /** + * 景区ID + */ + private Long scenicId; + + /** + * 推客ID + */ + private Long brokerId; + + /** + * 优惠类型 + */ + private VoucherDiscountType discountType; + + /** + * 优惠值 + */ + private BigDecimal discountValue; + + /** + * 实际优惠金额 + */ + private BigDecimal actualDiscountAmount; + + /** + * 状态 + */ + private Integer status; + + /** + * 领取时间 + */ + private Date claimedTime; + + /** + * 使用时间 + */ + private Date usedTime; + + /** + * 是否可用 + */ + private Boolean available; + + /** + * 不可用原因 + */ + private String unavailableReason; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherBatchCreateReq.java b/src/main/java/com/ycwl/basic/pricing/dto/req/VoucherBatchCreateReq.java similarity index 87% rename from src/main/java/com/ycwl/basic/voucher/dto/req/VoucherBatchCreateReq.java rename to src/main/java/com/ycwl/basic/pricing/dto/req/VoucherBatchCreateReq.java index 47c1653..8582fea 100644 --- a/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherBatchCreateReq.java +++ b/src/main/java/com/ycwl/basic/pricing/dto/req/VoucherBatchCreateReq.java @@ -1,4 +1,4 @@ -package com.ycwl.basic.voucher.dto.req; +package com.ycwl.basic.pricing.dto.req; import lombok.Data; diff --git a/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherBatchQueryReq.java b/src/main/java/com/ycwl/basic/pricing/dto/req/VoucherBatchQueryReq.java similarity index 89% rename from src/main/java/com/ycwl/basic/voucher/dto/req/VoucherBatchQueryReq.java rename to src/main/java/com/ycwl/basic/pricing/dto/req/VoucherBatchQueryReq.java index ce82a73..426c947 100644 --- a/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherBatchQueryReq.java +++ b/src/main/java/com/ycwl/basic/pricing/dto/req/VoucherBatchQueryReq.java @@ -1,4 +1,4 @@ -package com.ycwl.basic.voucher.dto.req; +package com.ycwl.basic.pricing.dto.req; import com.ycwl.basic.model.common.BaseQueryParameterReq; import lombok.Data; diff --git a/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherClaimReq.java b/src/main/java/com/ycwl/basic/pricing/dto/req/VoucherClaimReq.java similarity index 77% rename from src/main/java/com/ycwl/basic/voucher/dto/req/VoucherClaimReq.java rename to src/main/java/com/ycwl/basic/pricing/dto/req/VoucherClaimReq.java index e6d318d..385b1ac 100644 --- a/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherClaimReq.java +++ b/src/main/java/com/ycwl/basic/pricing/dto/req/VoucherClaimReq.java @@ -1,4 +1,4 @@ -package com.ycwl.basic.voucher.dto.req; +package com.ycwl.basic.pricing.dto.req; import lombok.Data; diff --git a/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherCodeQueryReq.java b/src/main/java/com/ycwl/basic/pricing/dto/req/VoucherCodeQueryReq.java similarity index 89% rename from src/main/java/com/ycwl/basic/voucher/dto/req/VoucherCodeQueryReq.java rename to src/main/java/com/ycwl/basic/pricing/dto/req/VoucherCodeQueryReq.java index ea853bd..a0065c5 100644 --- a/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherCodeQueryReq.java +++ b/src/main/java/com/ycwl/basic/pricing/dto/req/VoucherCodeQueryReq.java @@ -1,4 +1,4 @@ -package com.ycwl.basic.voucher.dto.req; +package com.ycwl.basic.pricing.dto.req; import com.ycwl.basic.model.common.BaseQueryParameterReq; import lombok.Data; diff --git a/src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherBatchResp.java b/src/main/java/com/ycwl/basic/pricing/dto/resp/VoucherBatchResp.java similarity index 93% rename from src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherBatchResp.java rename to src/main/java/com/ycwl/basic/pricing/dto/resp/VoucherBatchResp.java index cbee09d..7c75781 100644 --- a/src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherBatchResp.java +++ b/src/main/java/com/ycwl/basic/pricing/dto/resp/VoucherBatchResp.java @@ -1,4 +1,4 @@ -package com.ycwl.basic.voucher.dto.resp; +package com.ycwl.basic.pricing.dto.resp; import lombok.Data; diff --git a/src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherBatchStatsResp.java b/src/main/java/com/ycwl/basic/pricing/dto/resp/VoucherBatchStatsResp.java similarity index 88% rename from src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherBatchStatsResp.java rename to src/main/java/com/ycwl/basic/pricing/dto/resp/VoucherBatchStatsResp.java index 285aee1..423036c 100644 --- a/src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherBatchStatsResp.java +++ b/src/main/java/com/ycwl/basic/pricing/dto/resp/VoucherBatchStatsResp.java @@ -1,4 +1,4 @@ -package com.ycwl.basic.voucher.dto.resp; +package com.ycwl.basic.pricing.dto.resp; import lombok.Data; diff --git a/src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherCodeResp.java b/src/main/java/com/ycwl/basic/pricing/dto/resp/VoucherCodeResp.java similarity index 93% rename from src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherCodeResp.java rename to src/main/java/com/ycwl/basic/pricing/dto/resp/VoucherCodeResp.java index 6b9a498..b7f0f8a 100644 --- a/src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherCodeResp.java +++ b/src/main/java/com/ycwl/basic/pricing/dto/resp/VoucherCodeResp.java @@ -1,4 +1,4 @@ -package com.ycwl.basic.voucher.dto.resp; +package com.ycwl.basic.pricing.dto.resp; import lombok.Data; diff --git a/src/main/java/com/ycwl/basic/pricing/entity/BaseEntity.java b/src/main/java/com/ycwl/basic/pricing/entity/BaseEntity.java index b66c468..849b036 100644 --- a/src/main/java/com/ycwl/basic/pricing/entity/BaseEntity.java +++ b/src/main/java/com/ycwl/basic/pricing/entity/BaseEntity.java @@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import lombok.Data; -import java.time.LocalDateTime; +import java.util.Date; /** * 基础实体类 @@ -15,7 +15,15 @@ public class BaseEntity { @TableId(type = IdType.AUTO) private Long id; - private LocalDateTime createdTime; + private Date createdTime; - private LocalDateTime updatedTime; + private Date updatedTime; + + private Long createBy; + + private Long updateBy; + + private Integer deleted; + + private Date deletedAt; } \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/entity/PriceCouponClaimRecord.java b/src/main/java/com/ycwl/basic/pricing/entity/PriceCouponClaimRecord.java index 22f1824..a48511d 100644 --- a/src/main/java/com/ycwl/basic/pricing/entity/PriceCouponClaimRecord.java +++ b/src/main/java/com/ycwl/basic/pricing/entity/PriceCouponClaimRecord.java @@ -5,7 +5,7 @@ import com.ycwl.basic.pricing.enums.CouponStatus; import lombok.Data; import lombok.EqualsAndHashCode; -import java.time.LocalDateTime; +import java.util.Date; /** * 优惠券领用记录实体 @@ -28,12 +28,12 @@ public class PriceCouponClaimRecord extends BaseEntity { /** * 领取时间 */ - private LocalDateTime claimTime; + private Date claimTime; /** * 使用时间 */ - private LocalDateTime useTime; + private Date useTime; /** * 订单ID diff --git a/src/main/java/com/ycwl/basic/pricing/entity/PriceVoucherBatchConfig.java b/src/main/java/com/ycwl/basic/pricing/entity/PriceVoucherBatchConfig.java new file mode 100644 index 0000000..a68510c --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/entity/PriceVoucherBatchConfig.java @@ -0,0 +1,72 @@ +package com.ycwl.basic.pricing.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * 券码批次配置实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("price_voucher_batch_config") +public class PriceVoucherBatchConfig extends BaseEntity { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 券码批次名称 + */ + private String batchName; + + /** + * 景区ID + */ + private Long scenicId; + + /** + * 推客ID + */ + private Long brokerId; + + /** + * 优惠类型:0=全场免费,1=商品降价,2=商品打折 + */ + private Integer discountType; + + /** + * 优惠值(降价金额或折扣百分比) + */ + private BigDecimal discountValue; + + /** + * 总券码数量 + */ + private Integer totalCount; + + /** + * 已使用数量 + */ + private Integer usedCount; + + /** + * 已领取数量 + */ + private Integer claimedCount; + + /** + * 状态:0=禁用,1=启用 + */ + private Integer status; + + /** + * 创建人ID + */ + private Long createBy; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/entity/PriceVoucherCode.java b/src/main/java/com/ycwl/basic/pricing/entity/PriceVoucherCode.java new file mode 100644 index 0000000..6efcbdc --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/entity/PriceVoucherCode.java @@ -0,0 +1,61 @@ +package com.ycwl.basic.pricing.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +/** + * 券码实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("price_voucher_code") +public class PriceVoucherCode extends BaseEntity { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 批次ID + */ + private Long batchId; + + /** + * 景区ID + */ + private Long scenicId; + + /** + * 券码 + */ + private String code; + + /** + * 状态:0=未领取,1=已领取未使用,2=已使用 + */ + private Integer status; + + /** + * 领取人faceId + */ + private Long faceId; + + /** + * 领取时间 + */ + private Date claimedTime; + + /** + * 使用时间 + */ + private Date usedTime; + + /** + * 使用备注 + */ + private String remark; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/enums/VoucherCodeStatus.java b/src/main/java/com/ycwl/basic/pricing/enums/VoucherCodeStatus.java new file mode 100644 index 0000000..b4651bb --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/enums/VoucherCodeStatus.java @@ -0,0 +1,74 @@ +package com.ycwl.basic.pricing.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 券码状态枚举 + */ +@Getter +@AllArgsConstructor +public enum VoucherCodeStatus { + + /** + * 未领取 + */ + UNCLAIMED(0, "未领取"), + + /** + * 已领取未使用 + */ + CLAIMED_UNUSED(1, "已领取未使用"), + + /** + * 已使用 + */ + USED(2, "已使用"); + + private final Integer code; + private final String name; + + /** + * 根据代码获取枚举值 + * @param code 代码 + * @return 枚举值 + */ + public static VoucherCodeStatus getByCode(Integer code) { + if (code == null) { + return null; + } + for (VoucherCodeStatus status : values()) { + if (status.getCode().equals(code)) { + return status; + } + } + return null; + } + + /** + * 检查是否为有效的状态代码 + * @param code 代码 + * @return 是否有效 + */ + public static boolean isValidCode(Integer code) { + return getByCode(code) != null; + } + + /** + * 检查是否可以使用(已领取未使用状态) + * @param code 状态代码 + * @return 是否可以使用 + */ + public static boolean canUse(Integer code) { + return CLAIMED_UNUSED.getCode().equals(code); + } + + /** + * 检查是否已使用 + * @param code 状态代码 + * @return 是否已使用 + */ + public static boolean isUsed(Integer code) { + return USED.getCode().equals(code); + } +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/enums/VoucherDiscountType.java b/src/main/java/com/ycwl/basic/pricing/enums/VoucherDiscountType.java similarity index 54% rename from src/main/java/com/ycwl/basic/voucher/enums/VoucherDiscountType.java rename to src/main/java/com/ycwl/basic/pricing/enums/VoucherDiscountType.java index 5e53d8d..af9fdfc 100644 --- a/src/main/java/com/ycwl/basic/voucher/enums/VoucherDiscountType.java +++ b/src/main/java/com/ycwl/basic/pricing/enums/VoucherDiscountType.java @@ -1,19 +1,39 @@ -package com.ycwl.basic.voucher.enums; +package com.ycwl.basic.pricing.enums; import lombok.AllArgsConstructor; import lombok.Getter; +/** + * 券码优惠类型枚举 + */ @Getter @AllArgsConstructor public enum VoucherDiscountType { + + /** + * 全场免费 - 所有商品免费 + */ FREE_ALL(0, "全场免费", "所有商品免费"), + + /** + * 商品降价 - 每个商品减免指定金额 + */ REDUCE_PRICE(1, "商品降价", "每个商品减免指定金额"), + + /** + * 商品打折 - 每个商品按百分比打折 + */ DISCOUNT(2, "商品打折", "每个商品按百分比打折"); private final Integer code; private final String name; private final String description; + /** + * 根据代码获取枚举值 + * @param code 代码 + * @return 枚举值 + */ public static VoucherDiscountType getByCode(Integer code) { if (code == null) { return null; @@ -25,4 +45,13 @@ public enum VoucherDiscountType { } return null; } + + /** + * 检查是否为有效的优惠类型代码 + * @param code 代码 + * @return 是否有效 + */ + public static boolean isValidCode(Integer code) { + return getByCode(code) != null; + } } \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/exception/DiscountDetectionException.java b/src/main/java/com/ycwl/basic/pricing/exception/DiscountDetectionException.java new file mode 100644 index 0000000..b1b2852 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/exception/DiscountDetectionException.java @@ -0,0 +1,15 @@ +package com.ycwl.basic.pricing.exception; + +/** + * 优惠检测异常 + */ +public class DiscountDetectionException extends RuntimeException { + + public DiscountDetectionException(String message) { + super(message); + } + + public DiscountDetectionException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/exception/PricingExceptionHandler.java b/src/main/java/com/ycwl/basic/pricing/exception/PricingExceptionHandler.java index d4acdd0..9b84eb9 100644 --- a/src/main/java/com/ycwl/basic/pricing/exception/PricingExceptionHandler.java +++ b/src/main/java/com/ycwl/basic/pricing/exception/PricingExceptionHandler.java @@ -54,6 +54,46 @@ public class PricingExceptionHandler { return ApiResponse.buildResponse(400, "参数错误: " + e.getMessage()); } + /** + * 处理券码无效异常 + */ + @ExceptionHandler(VoucherInvalidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleVoucherInvalidException(VoucherInvalidException e) { + log.error("券码无效异常", e); + return ApiResponse.buildResponse(400, e.getMessage()); + } + + /** + * 处理券码已使用异常 + */ + @ExceptionHandler(VoucherAlreadyUsedException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleVoucherAlreadyUsedException(VoucherAlreadyUsedException e) { + log.error("券码已使用异常", e); + return ApiResponse.buildResponse(400, e.getMessage()); + } + + /** + * 处理券码不可领取异常 + */ + @ExceptionHandler(VoucherNotClaimableException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleVoucherNotClaimableException(VoucherNotClaimableException e) { + log.error("券码不可领取异常", e); + return ApiResponse.buildResponse(400, e.getMessage()); + } + + /** + * 处理优惠检测异常 + */ + @ExceptionHandler(DiscountDetectionException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleDiscountDetectionException(DiscountDetectionException e) { + log.error("优惠检测异常", e); + return ApiResponse.buildResponse(400, e.getMessage()); + } + /** * 处理通用异常 */ diff --git a/src/main/java/com/ycwl/basic/pricing/exception/VoucherAlreadyUsedException.java b/src/main/java/com/ycwl/basic/pricing/exception/VoucherAlreadyUsedException.java new file mode 100644 index 0000000..2067de0 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/exception/VoucherAlreadyUsedException.java @@ -0,0 +1,15 @@ +package com.ycwl.basic.pricing.exception; + +/** + * 券码已使用异常 + */ +public class VoucherAlreadyUsedException extends RuntimeException { + + public VoucherAlreadyUsedException(String message) { + super(message); + } + + public VoucherAlreadyUsedException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/exception/VoucherInvalidException.java b/src/main/java/com/ycwl/basic/pricing/exception/VoucherInvalidException.java new file mode 100644 index 0000000..6c62288 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/exception/VoucherInvalidException.java @@ -0,0 +1,15 @@ +package com.ycwl.basic.pricing.exception; + +/** + * 券码无效异常 + */ +public class VoucherInvalidException extends RuntimeException { + + public VoucherInvalidException(String message) { + super(message); + } + + public VoucherInvalidException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/exception/VoucherNotClaimableException.java b/src/main/java/com/ycwl/basic/pricing/exception/VoucherNotClaimableException.java new file mode 100644 index 0000000..2ea2e2d --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/exception/VoucherNotClaimableException.java @@ -0,0 +1,15 @@ +package com.ycwl.basic.pricing.exception; + +/** + * 券码不可领取异常 + */ +public class VoucherNotClaimableException extends RuntimeException { + + public VoucherNotClaimableException(String message) { + super(message); + } + + public VoucherNotClaimableException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponClaimRecordMapper.java b/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponClaimRecordMapper.java index b95bf3d..725c9f7 100644 --- a/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponClaimRecordMapper.java +++ b/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponClaimRecordMapper.java @@ -44,7 +44,7 @@ public interface PriceCouponClaimRecordMapper extends BaseMapper { + + /** + * 根据景区ID和推客ID查询有效的批次列表 + * @param scenicId 景区ID + * @param brokerId 推客ID + * @return 批次列表 + */ + List selectActiveBatchesByScenicAndBroker(@Param("scenicId") Long scenicId, + @Param("brokerId") Long brokerId); + + /** + * 更新批次的已领取数量 + * @param batchId 批次ID + * @param increment 增量(可为负数) + * @return 影响行数 + */ + int updateClaimedCount(@Param("batchId") Long batchId, @Param("increment") Integer increment); + + /** + * 更新批次的已使用数量 + * @param batchId 批次ID + * @param increment 增量(可为负数) + * @return 影响行数 + */ + int updateUsedCount(@Param("batchId") Long batchId, @Param("increment") Integer increment); + + /** + * 获取批次统计信息 + * @param batchId 批次ID + * @return 统计信息 + */ + PriceVoucherBatchConfig selectBatchStats(@Param("batchId") Long batchId); +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/mapper/PriceVoucherCodeMapper.java b/src/main/java/com/ycwl/basic/pricing/mapper/PriceVoucherCodeMapper.java new file mode 100644 index 0000000..732d414 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/mapper/PriceVoucherCodeMapper.java @@ -0,0 +1,94 @@ +package com.ycwl.basic.pricing.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ycwl.basic.pricing.entity.PriceVoucherCode; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 券码Mapper + */ +@Mapper +public interface PriceVoucherCodeMapper extends BaseMapper { + + /** + * 根据券码查询券码信息 + * @param code 券码 + * @return 券码信息 + */ + PriceVoucherCode selectByCode(@Param("code") String code); + + /** + * 根据faceId和scenicId统计已领取的券码数量 + * @param faceId 用户faceId + * @param scenicId 景区ID + * @return 数量 + */ + Integer countByFaceIdAndScenicId(@Param("faceId") Long faceId, @Param("scenicId") Long scenicId); + + /** + * 查询用户在指定景区的可用券码列表 + * @param faceId 用户faceId + * @param scenicId 景区ID + * @return 券码列表 + */ + List selectAvailableVouchersByFaceIdAndScenicId(@Param("faceId") Long faceId, + @Param("scenicId") Long scenicId); + + /** + * 根据批次ID获取可领取的券码(未领取状态) + * @param batchId 批次ID + * @param limit 限制数量 + * @return 券码列表 + */ + List selectUnclaimedVouchersByBatchId(@Param("batchId") Long batchId, + @Param("limit") Integer limit); + + /** + * 领取券码(更新状态为已领取) + * @param id 券码ID + * @param faceId 用户faceId + * @param claimedTime 领取时间 + * @return 影响行数 + */ + int claimVoucher(@Param("id") Long id, + @Param("faceId") Long faceId, + @Param("claimedTime") LocalDateTime claimedTime); + + /** + * 使用券码(更新状态为已使用) + * @param code 券码 + * @param usedTime 使用时间 + * @param remark 使用备注 + * @return 影响行数 + */ + int useVoucher(@Param("code") String code, + @Param("usedTime") LocalDateTime usedTime, + @Param("remark") String remark); + + /** + * 根据批次ID查询券码列表(支持分页) + * @param batchId 批次ID + * @return 券码列表 + */ + List selectByBatchId(@Param("batchId") Long batchId); + + /** + * 查询用户的券码列表 + * @param faceId 用户faceId + * @param scenicId 景区ID(可选) + * @return 券码列表 + */ + List selectUserVouchers(@Param("faceId") Long faceId, + @Param("scenicId") Long scenicId); + + /** + * 根据批次ID查询第一个可用的券码 + * @param batchId 批次ID + * @return 可用券码 + */ + PriceVoucherCode findFirstAvailableByBatchId(@Param("batchId") Long batchId); +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/service/IDiscountDetectionService.java b/src/main/java/com/ycwl/basic/pricing/service/IDiscountDetectionService.java new file mode 100644 index 0000000..549d21b --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/service/IDiscountDetectionService.java @@ -0,0 +1,47 @@ +package com.ycwl.basic.pricing.service; + +import com.ycwl.basic.pricing.dto.DiscountCombinationResult; +import com.ycwl.basic.pricing.dto.DiscountDetectionContext; +import com.ycwl.basic.pricing.dto.DiscountInfo; + +import java.util.List; + +/** + * 优惠检测服务接口 + * 负责协调所有优惠提供者,计算最优优惠组合 + */ +public interface IDiscountDetectionService { + + /** + * 检测所有可用的优惠 + * @param context 检测上下文 + * @return 所有可用的优惠列表 + */ + List detectAllAvailableDiscounts(DiscountDetectionContext context); + + /** + * 计算最优优惠组合 + * @param context 检测上下文 + * @return 最优优惠组合结果 + */ + DiscountCombinationResult calculateOptimalCombination(DiscountDetectionContext context); + + /** + * 预览优惠组合(不实际应用) + * @param context 检测上下文 + * @return 预览结果 + */ + DiscountCombinationResult previewOptimalCombination(DiscountDetectionContext context); + + /** + * 注册优惠提供者 + * @param provider 优惠提供者 + */ + void registerProvider(IDiscountProvider provider); + + /** + * 获取所有已注册的优惠提供者 + * @return 优惠提供者列表 + */ + List getAllProviders(); +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/service/IDiscountProvider.java b/src/main/java/com/ycwl/basic/pricing/service/IDiscountProvider.java new file mode 100644 index 0000000..4d2a562 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/service/IDiscountProvider.java @@ -0,0 +1,61 @@ +package com.ycwl.basic.pricing.service; + +import com.ycwl.basic.pricing.dto.DiscountDetectionContext; +import com.ycwl.basic.pricing.dto.DiscountInfo; +import com.ycwl.basic.pricing.dto.DiscountResult; + +import java.util.List; + +/** + * 优惠提供者接口 + * 所有优惠类型(coupon、voucher、促销活动等)都需要实现此接口 + */ +public interface IDiscountProvider { + + /** + * 获取提供者类型 + * @return 提供者类型标识,如 "COUPON", "VOUCHER", "FLASH_SALE" 等 + */ + String getProviderType(); + + /** + * 获取优先级 + * @return 优先级,数字越大优先级越高 + */ + int getPriority(); + + /** + * 检测可用的优惠 + * @param context 优惠检测上下文 + * @return 可用的优惠列表 + */ + List detectAvailableDiscounts(DiscountDetectionContext context); + + /** + * 应用优惠 + * @param discountInfo 要应用的优惠信息 + * @param context 优惠检测上下文 + * @return 优惠应用结果 + */ + DiscountResult applyDiscount(DiscountInfo discountInfo, DiscountDetectionContext context); + + /** + * 验证优惠是否可以应用 + * @param discountInfo 优惠信息 + * @param context 优惠检测上下文 + * @return 是否可以应用 + */ + default boolean canApply(DiscountInfo discountInfo, DiscountDetectionContext context) { + return true; + } + + /** + * 获取优惠的最大可能折扣金额(用于排序) + * @param discountInfo 优惠信息 + * @param context 优惠检测上下文 + * @return 最大可能折扣金额 + */ + default java.math.BigDecimal getMaxPossibleDiscount(DiscountInfo discountInfo, DiscountDetectionContext context) { + return discountInfo.getDiscountAmount() != null ? discountInfo.getDiscountAmount() : java.math.BigDecimal.ZERO; + } +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/service/IVoucherService.java b/src/main/java/com/ycwl/basic/pricing/service/IVoucherService.java new file mode 100644 index 0000000..51be47e --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/service/IVoucherService.java @@ -0,0 +1,62 @@ +package com.ycwl.basic.pricing.service; + +import com.ycwl.basic.pricing.dto.DiscountDetectionContext; +import com.ycwl.basic.pricing.dto.VoucherInfo; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 券码服务接口 + */ +public interface IVoucherService { + + /** + * 验证并获取券码信息 + * @param voucherCode 券码 + * @param faceId 用户faceId + * @param scenicId 景区ID + * @return 券码信息(如果有效) + */ + VoucherInfo validateAndGetVoucherInfo(String voucherCode, Long faceId, Long scenicId); + + /** + * 获取用户在指定景区的可用券码列表 + * @param faceId 用户faceId + * @param scenicId 景区ID + * @return 可用券码列表 + */ + List getAvailableVouchers(Long faceId, Long scenicId); + + /** + * 标记券码为已使用 + * @param voucherCode 券码 + * @param remark 使用备注 + */ + void markVoucherAsUsed(String voucherCode, String remark); + + /** + * 检查用户是否可以在指定景区领取券码 + * @param faceId 用户faceId + * @param scenicId 景区ID + * @return 是否可以领取 + */ + boolean canClaimVoucher(Long faceId, Long scenicId); + + /** + * 计算券码优惠金额 + * @param voucherInfo 券码信息 + * @param context 检测上下文 + * @return 优惠金额 + */ + BigDecimal calculateVoucherDiscount(VoucherInfo voucherInfo, DiscountDetectionContext context); + + /** + * 获取最优的券码(如果用户有多个可用券码) + * @param faceId 用户faceId + * @param scenicId 景区ID + * @param context 检测上下文 + * @return 最优券码信息 + */ + VoucherInfo getBestVoucher(Long faceId, Long scenicId, DiscountDetectionContext context); +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/service/VoucherBatchService.java b/src/main/java/com/ycwl/basic/pricing/service/VoucherBatchService.java similarity index 54% rename from src/main/java/com/ycwl/basic/voucher/service/VoucherBatchService.java rename to src/main/java/com/ycwl/basic/pricing/service/VoucherBatchService.java index 063e0b8..f54d9be 100644 --- a/src/main/java/com/ycwl/basic/voucher/service/VoucherBatchService.java +++ b/src/main/java/com/ycwl/basic/pricing/service/VoucherBatchService.java @@ -1,11 +1,11 @@ -package com.ycwl.basic.voucher.service; +package com.ycwl.basic.pricing.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.ycwl.basic.voucher.dto.req.VoucherBatchCreateReq; -import com.ycwl.basic.voucher.dto.req.VoucherBatchQueryReq; -import com.ycwl.basic.voucher.dto.resp.VoucherBatchResp; -import com.ycwl.basic.voucher.dto.resp.VoucherBatchStatsResp; -import com.ycwl.basic.voucher.entity.VoucherBatchEntity; +import com.ycwl.basic.pricing.dto.req.VoucherBatchCreateReq; +import com.ycwl.basic.pricing.dto.req.VoucherBatchQueryReq; +import com.ycwl.basic.pricing.dto.resp.VoucherBatchResp; +import com.ycwl.basic.pricing.dto.resp.VoucherBatchStatsResp; +import com.ycwl.basic.pricing.entity.PriceVoucherBatchConfig; public interface VoucherBatchService { @@ -23,5 +23,5 @@ public interface VoucherBatchService { void updateBatchUsedCount(Long batchId); - VoucherBatchEntity getAvailableBatch(Long scenicId, Long brokerId); + PriceVoucherBatchConfig getAvailableBatch(Long scenicId, Long brokerId); } \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/service/VoucherCodeService.java b/src/main/java/com/ycwl/basic/pricing/service/VoucherCodeService.java similarity index 71% rename from src/main/java/com/ycwl/basic/voucher/service/VoucherCodeService.java rename to src/main/java/com/ycwl/basic/pricing/service/VoucherCodeService.java index 118acf1..27854f5 100644 --- a/src/main/java/com/ycwl/basic/voucher/service/VoucherCodeService.java +++ b/src/main/java/com/ycwl/basic/pricing/service/VoucherCodeService.java @@ -1,9 +1,9 @@ -package com.ycwl.basic.voucher.service; +package com.ycwl.basic.pricing.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.ycwl.basic.voucher.dto.req.VoucherClaimReq; -import com.ycwl.basic.voucher.dto.req.VoucherCodeQueryReq; -import com.ycwl.basic.voucher.dto.resp.VoucherCodeResp; +import com.ycwl.basic.pricing.dto.req.VoucherClaimReq; +import com.ycwl.basic.pricing.dto.req.VoucherCodeQueryReq; +import com.ycwl.basic.pricing.dto.resp.VoucherCodeResp; import java.util.List; diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/CouponDiscountProvider.java b/src/main/java/com/ycwl/basic/pricing/service/impl/CouponDiscountProvider.java new file mode 100644 index 0000000..1e88cf8 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/CouponDiscountProvider.java @@ -0,0 +1,106 @@ +package com.ycwl.basic.pricing.service.impl; + +import com.ycwl.basic.pricing.dto.*; +import com.ycwl.basic.pricing.service.ICouponService; +import com.ycwl.basic.pricing.service.IDiscountProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +/** + * 优惠券折扣提供者 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponDiscountProvider implements IDiscountProvider { + + private final ICouponService couponService; + + @Override + public String getProviderType() { + return "COUPON"; + } + + @Override + public int getPriority() { + return 80; // 优惠券优先级为80,低于券码的100 + } + + @Override + public List detectAvailableDiscounts(DiscountDetectionContext context) { + List discounts = new ArrayList<>(); + + if (!Boolean.TRUE.equals(context.getAutoUseCoupon()) || context.getUserId() == null) { + return discounts; + } + + try { + CouponInfo bestCoupon = couponService.selectBestCoupon( + context.getUserId(), + context.getProducts(), + context.getCurrentAmount() + ); + + if (bestCoupon != null && bestCoupon.getActualDiscountAmount().compareTo(BigDecimal.ZERO) > 0) { + DiscountInfo discountInfo = new DiscountInfo(); + discountInfo.setDiscountId(bestCoupon.getCouponId()); + discountInfo.setDiscountType("COUPON"); + discountInfo.setDiscountName(bestCoupon.getCouponName()); + discountInfo.setDiscountDescription("优惠券减免"); + discountInfo.setDiscountAmount(bestCoupon.getActualDiscountAmount()); + discountInfo.setProviderType(getProviderType()); + discountInfo.setPriority(getPriority()); + discountInfo.setStackable(true); // 优惠券可与券码叠加 + discountInfo.setCouponId(bestCoupon.getCouponId()); + + discounts.add(discountInfo); + } + } catch (Exception e) { + log.warn("检测优惠券时发生异常", e); + } + + return discounts; + } + + @Override + public DiscountResult applyDiscount(DiscountInfo discountInfo, DiscountDetectionContext context) { + DiscountResult result = new DiscountResult(); + result.setDiscountInfo(discountInfo); + + try { + // 应用优惠券逻辑 + BigDecimal actualDiscount = discountInfo.getDiscountAmount(); + BigDecimal finalAmount = context.getCurrentAmount().subtract(actualDiscount); + + if (finalAmount.compareTo(BigDecimal.ZERO) < 0) { + finalAmount = BigDecimal.ZERO; + actualDiscount = context.getCurrentAmount(); + } + + result.setActualDiscountAmount(actualDiscount); + result.setFinalAmount(finalAmount); + result.setSuccess(true); + + log.info("成功应用优惠券: {}, 优惠金额: {}", discountInfo.getDiscountName(), actualDiscount); + + } catch (Exception e) { + log.error("应用优惠券失败: " + discountInfo.getDiscountName(), e); + result.setSuccess(false); + result.setFailureReason("优惠券应用失败: " + e.getMessage()); + } + + return result; + } + + @Override + public boolean canApply(DiscountInfo discountInfo, DiscountDetectionContext context) { + return "COUPON".equals(discountInfo.getDiscountType()) && + Boolean.TRUE.equals(context.getAutoUseCoupon()) && + context.getUserId() != null; + } +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java index a908f27..c28335c 100644 --- a/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java @@ -13,6 +13,8 @@ import com.ycwl.basic.pricing.mapper.PriceCouponClaimRecordMapper; import com.ycwl.basic.pricing.mapper.PriceCouponConfigMapper; import com.ycwl.basic.pricing.service.ICouponService; import lombok.RequiredArgsConstructor; + +import java.util.Date; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -144,13 +146,13 @@ public class CouponServiceImpl implements ICouponService { throw new CouponInvalidException("优惠券使用失败,可能已达到使用上限"); } - LocalDateTime useTime = LocalDateTime.now(); + Date useTime = new Date(); // 设置使用时间、订单信息和景区信息 record.setStatus(CouponStatus.USED); record.setUseTime(useTime); record.setOrderId(request.getOrderId()); - record.setUpdatedTime(LocalDateTime.now()); + record.setUpdatedTime(new Date()); // 如果请求中包含景区ID,记录到使用记录中 if (request.getScenicId() != null && !request.getScenicId().isEmpty()) { diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/DiscountDetectionServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/DiscountDetectionServiceImpl.java new file mode 100644 index 0000000..111c7ef --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/DiscountDetectionServiceImpl.java @@ -0,0 +1,206 @@ +package com.ycwl.basic.pricing.service.impl; + +import com.ycwl.basic.pricing.dto.*; +import com.ycwl.basic.pricing.service.IDiscountDetectionService; +import com.ycwl.basic.pricing.service.IDiscountProvider; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 优惠检测服务实现 + */ +@Slf4j +@Service +public class DiscountDetectionServiceImpl implements IDiscountDetectionService { + + private final List discountProviders = new ArrayList<>(); + + @Autowired + public DiscountDetectionServiceImpl(List providers) { + this.discountProviders.addAll(providers); + // 按优先级排序(优先级高的在前) + this.discountProviders.sort(Comparator.comparing(IDiscountProvider::getPriority).reversed()); + + log.info("注册了 {} 个优惠提供者: {}", + providers.size(), + providers.stream().map(IDiscountProvider::getProviderType).collect(Collectors.toList())); + } + + @Override + public List detectAllAvailableDiscounts(DiscountDetectionContext context) { + List allDiscounts = new ArrayList<>(); + + for (IDiscountProvider provider : discountProviders) { + try { + List providerDiscounts = provider.detectAvailableDiscounts(context); + if (providerDiscounts != null && !providerDiscounts.isEmpty()) { + allDiscounts.addAll(providerDiscounts); + log.debug("提供者 {} 检测到 {} 个优惠", provider.getProviderType(), providerDiscounts.size()); + } + } catch (Exception e) { + log.error("优惠提供者 {} 检测失败", provider.getProviderType(), e); + } + } + + // 按优先级排序 + allDiscounts.sort(Comparator.comparing(DiscountInfo::getPriority).reversed()); + + return allDiscounts; + } + + @Override + public DiscountCombinationResult calculateOptimalCombination(DiscountDetectionContext context) { + DiscountCombinationResult result = new DiscountCombinationResult(); + result.setOriginalAmount(context.getCurrentAmount()); + + try { + List availableDiscounts = detectAllAvailableDiscounts(context); + result.setAvailableDiscounts(availableDiscounts); + + if (availableDiscounts.isEmpty()) { + result.setFinalAmount(context.getCurrentAmount()); + result.setTotalDiscountAmount(BigDecimal.ZERO); + result.setAppliedDiscounts(new ArrayList<>()); + result.setDiscountDetails(new ArrayList<>()); + result.setSuccess(true); + return result; + } + + List appliedDiscounts = new ArrayList<>(); + List discountDetails = new ArrayList<>(); + BigDecimal currentAmount = context.getCurrentAmount(); + + // 按优先级应用优惠 + for (DiscountInfo discountInfo : availableDiscounts) { + IDiscountProvider provider = findProvider(discountInfo.getProviderType()); + if (provider == null || !provider.canApply(discountInfo, context)) { + continue; + } + + // 更新上下文中的当前金额 + context.setCurrentAmount(currentAmount); + + DiscountResult discountResult = provider.applyDiscount(discountInfo, context); + if (Boolean.TRUE.equals(discountResult.getSuccess())) { + appliedDiscounts.add(discountResult); + + // 创建显示用的优惠详情 + DiscountDetail detail = createDiscountDetail(discountResult); + if (detail != null) { + discountDetails.add(detail); + } + + // 更新当前金额 + currentAmount = discountResult.getFinalAmount(); + + log.info("成功应用优惠: {} - {}, 优惠金额: {}", + discountInfo.getProviderType(), + discountInfo.getDiscountName(), + discountResult.getActualDiscountAmount()); + + // 如果是不可叠加的优惠(如全场免费),则停止应用其他优惠 + if (!Boolean.TRUE.equals(discountInfo.getStackable())) { + log.info("遇到不可叠加优惠,停止应用其他优惠: {}", discountInfo.getDiscountName()); + break; + } + } else { + log.warn("优惠应用失败: {} - {}, 原因: {}", + discountInfo.getProviderType(), + discountInfo.getDiscountName(), + discountResult.getFailureReason()); + } + } + + // 计算总优惠金额 + BigDecimal totalDiscountAmount = appliedDiscounts.stream() + .map(DiscountResult::getActualDiscountAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + // 按显示顺序排序折扣详情 + discountDetails.sort(Comparator.comparing(DiscountDetail::getSortOrder)); + + result.setFinalAmount(currentAmount); + result.setTotalDiscountAmount(totalDiscountAmount); + result.setAppliedDiscounts(appliedDiscounts); + result.setDiscountDetails(discountDetails); + result.setSuccess(true); + + } catch (Exception e) { + log.error("计算最优优惠组合失败", e); + result.setSuccess(false); + result.setErrorMessage("优惠计算失败: " + e.getMessage()); + result.setFinalAmount(context.getCurrentAmount()); + result.setTotalDiscountAmount(BigDecimal.ZERO); + } + + return result; + } + + @Override + public DiscountCombinationResult previewOptimalCombination(DiscountDetectionContext context) { + // 预览模式与正常计算相同,但不会实际标记优惠为已使用 + return calculateOptimalCombination(context); + } + + @Override + public void registerProvider(IDiscountProvider provider) { + if (provider != null && !discountProviders.contains(provider)) { + discountProviders.add(provider); + // 重新排序 + discountProviders.sort(Comparator.comparing(IDiscountProvider::getPriority).reversed()); + log.info("注册新的优惠提供者: {}", provider.getProviderType()); + } + } + + @Override + public List getAllProviders() { + return new ArrayList<>(discountProviders); + } + + /** + * 查找指定类型的优惠提供者 + */ + private IDiscountProvider findProvider(String providerType) { + return discountProviders.stream() + .filter(provider -> providerType.equals(provider.getProviderType())) + .findFirst() + .orElse(null); + } + + /** + * 创建显示用的优惠详情 + */ + private DiscountDetail createDiscountDetail(DiscountResult discountResult) { + DiscountInfo discountInfo = discountResult.getDiscountInfo(); + String providerType = discountInfo.getProviderType(); + + return switch (providerType) { + case "VOUCHER" -> DiscountDetail.createVoucherDiscount( + discountInfo.getVoucherCode(), + discountInfo.getDiscountDescription(), + discountResult.getActualDiscountAmount() + ); + case "COUPON" -> DiscountDetail.createCouponDiscount( + discountInfo.getDiscountName(), + discountResult.getActualDiscountAmount() + ); + default -> { + // 其他类型的优惠,创建通用的折扣详情 + DiscountDetail detail = new DiscountDetail(); + detail.setDiscountType(providerType); + detail.setDiscountName(discountInfo.getDiscountName()); + detail.setDiscountAmount(discountResult.getActualDiscountAmount()); + detail.setDescription(discountInfo.getDiscountDescription()); + detail.setSortOrder(10); // 默认排序 + yield detail; + } + }; + } +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/PriceCalculationServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/PriceCalculationServiceImpl.java index 59795ee..4eaac53 100644 --- a/src/main/java/com/ycwl/basic/pricing/service/impl/PriceCalculationServiceImpl.java +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/PriceCalculationServiceImpl.java @@ -5,10 +5,7 @@ import com.ycwl.basic.pricing.entity.PriceProductConfig; import com.ycwl.basic.pricing.entity.PriceTierConfig; import com.ycwl.basic.pricing.enums.ProductType; import com.ycwl.basic.pricing.exception.PriceCalculationException; -import com.ycwl.basic.pricing.service.ICouponService; -import com.ycwl.basic.pricing.service.IPriceBundleService; -import com.ycwl.basic.pricing.service.IPriceCalculationService; -import com.ycwl.basic.pricing.service.IProductConfigService; +import com.ycwl.basic.pricing.service.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -30,6 +27,8 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService { private final IProductConfigService productConfigService; private final ICouponService couponService; private final IPriceBundleService bundleService; + private final IDiscountDetectionService discountDetectionService; + private final IVoucherService voucherService; @Override public PriceCalculationResult calculatePrice(PriceCalculationRequest request) { @@ -59,35 +58,60 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService { log.info("使用一口价: {}, 优惠: {}", bundlePrice, bundleDiscount); } + // 构建价格计算结果 PriceCalculationResult result = new PriceCalculationResult(); result.setOriginalAmount(originalTotalAmount); // 原总价 result.setSubtotalAmount(priceDetails.getTotalAmount()); // 商品小计 result.setProductDetails(request.getProducts()); - // 处理优惠券 - BigDecimal couponDiscountAmount = BigDecimal.ZERO; - if (Boolean.TRUE.equals(request.getAutoUseCoupon()) && request.getUserId() != null) { - CouponInfo bestCoupon = couponService.selectBestCoupon( - request.getUserId(), request.getProducts(), totalAmount); - - if (bestCoupon != null && bestCoupon.getActualDiscountAmount().compareTo(BigDecimal.ZERO) > 0) { - result.setUsedCoupon(bestCoupon); - couponDiscountAmount = bestCoupon.getActualDiscountAmount(); - discountDetails.add(DiscountDetail.createCouponDiscount(bestCoupon.getCouponName(), couponDiscountAmount)); - } - } + // 使用新的优惠检测系统处理所有优惠(券码 + 优惠券) + DiscountCombinationResult discountResult = calculateDiscounts(request, totalAmount); - // 计算总优惠金额 - BigDecimal totalDiscountAmount = discountDetails.stream() + if (Boolean.TRUE.equals(discountResult.getSuccess())) { + // 合并所有优惠详情 + List allDiscountDetails = new ArrayList<>(discountDetails); + if (discountResult.getDiscountDetails() != null) { + allDiscountDetails.addAll(discountResult.getDiscountDetails()); + } + + // 重新排序 + allDiscountDetails.sort(Comparator.comparing(DiscountDetail::getSortOrder)); + + // 计算总优惠金额(包括限时立减、一口价和其他优惠) + BigDecimal totalDiscountAmount = allDiscountDetails.stream() .map(DiscountDetail::getDiscountAmount) .reduce(BigDecimal.ZERO, BigDecimal::add); + + // 设置结果 + result.setDiscountAmount(totalDiscountAmount); + result.setDiscountDetails(allDiscountDetails); + result.setFinalAmount(originalTotalAmount.subtract(totalDiscountAmount)); + + // 设置使用的券码和优惠券信息 + setUsedDiscountInfo(result, discountResult, request); + + // 如果是预览模式,设置可用优惠列表 + if (Boolean.TRUE.equals(request.getPreviewOnly())) { + result.setAvailableDiscounts(discountResult.getAvailableDiscounts()); + } + + } else { + log.warn("优惠计算失败: {}", discountResult.getErrorMessage()); + + // 降级处理:仅使用基础优惠(限时立减、一口价) + BigDecimal totalDiscountAmount = discountDetails.stream() + .map(DiscountDetail::getDiscountAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + result.setDiscountAmount(totalDiscountAmount); + result.setDiscountDetails(discountDetails); + result.setFinalAmount(originalTotalAmount.subtract(totalDiscountAmount)); + } - // 按排序排列折扣明细 - discountDetails.sort(Comparator.comparing(DiscountDetail::getSortOrder)); - - result.setDiscountAmount(totalDiscountAmount); - result.setDiscountDetails(discountDetails); - result.setFinalAmount(originalTotalAmount.subtract(totalDiscountAmount)); + // 标记使用的优惠(仅在非预览模式下) + if (!Boolean.TRUE.equals(request.getPreviewOnly())) { + markDiscountsAsUsed(result, request); + } return result; } @@ -269,4 +293,96 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService { return new ProductPriceInfo(actualPrice, originalPrice); } + + /** + * 计算优惠(券码 + 优惠券) + */ + private DiscountCombinationResult calculateDiscounts(PriceCalculationRequest request, BigDecimal currentAmount) { + try { + // 构建优惠检测上下文 + DiscountDetectionContext context = new DiscountDetectionContext(); + context.setUserId(request.getUserId()); + context.setFaceId(request.getFaceId()); + context.setScenicId(request.getScenicId()); + context.setProducts(request.getProducts()); + context.setCurrentAmount(currentAmount); + context.setVoucherCode(request.getVoucherCode()); + context.setAutoUseCoupon(request.getAutoUseCoupon()); + context.setAutoUseVoucher(request.getAutoUseVoucher()); + + // 使用优惠检测服务计算最优组合 + if (Boolean.TRUE.equals(request.getPreviewOnly())) { + return discountDetectionService.previewOptimalCombination(context); + } else { + return discountDetectionService.calculateOptimalCombination(context); + } + + } catch (Exception e) { + log.error("计算优惠时发生异常", e); + + // 返回失败结果 + DiscountCombinationResult failureResult = new DiscountCombinationResult(); + failureResult.setOriginalAmount(currentAmount); + failureResult.setFinalAmount(currentAmount); + failureResult.setTotalDiscountAmount(BigDecimal.ZERO); + failureResult.setSuccess(false); + failureResult.setErrorMessage("优惠计算失败: " + e.getMessage()); + return failureResult; + } + } + + /** + * 设置使用的优惠信息到结果中 + */ + private void setUsedDiscountInfo(PriceCalculationResult result, DiscountCombinationResult discountResult, PriceCalculationRequest request) { + if (discountResult.getAppliedDiscounts() == null) { + return; + } + + for (DiscountResult discountApplied : discountResult.getAppliedDiscounts()) { + DiscountInfo discountInfo = discountApplied.getDiscountInfo(); + + if ("COUPON".equals(discountInfo.getProviderType()) && discountInfo.getCouponId() != null) { + // 构建优惠券信息(这里可能需要重新查询完整信息) + CouponInfo couponInfo = new CouponInfo(); + couponInfo.setCouponId(discountInfo.getCouponId()); + couponInfo.setCouponName(discountInfo.getDiscountName()); + couponInfo.setActualDiscountAmount(discountApplied.getActualDiscountAmount()); + result.setUsedCoupon(couponInfo); + + } else if ("VOUCHER".equals(discountInfo.getProviderType()) && discountInfo.getVoucherCode() != null) { + // 获取券码信息 + VoucherInfo voucherInfo = voucherService.validateAndGetVoucherInfo( + discountInfo.getVoucherCode(), + request.getFaceId(), + request.getScenicId() + ); + if (voucherInfo != null) { + voucherInfo.setActualDiscountAmount(discountApplied.getActualDiscountAmount()); + result.setUsedVoucher(voucherInfo); + } + } + } + } + + /** + * 标记优惠为已使用(仅在非预览模式下调用) + */ + private void markDiscountsAsUsed(PriceCalculationResult result, PriceCalculationRequest request) { + try { + // 标记券码为已使用 + if (result.getUsedVoucher() != null && result.getUsedVoucher().getVoucherCode() != null) { + String remark = String.format("价格计算使用 - 订单金额: %s", result.getFinalAmount()); + voucherService.markVoucherAsUsed(result.getUsedVoucher().getVoucherCode(), remark); + log.info("已标记券码为使用: {}", result.getUsedVoucher().getVoucherCode()); + } + + // 优惠券的使用标记由原有的CouponService处理 + // 这里不需要额外处理 + + } catch (Exception e) { + log.error("标记优惠使用状态时发生异常", e); + // 不抛出异常,避免影响主流程 + } + } } \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/PricingManagementServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/PricingManagementServiceImpl.java index 12bfec9..20f0fcc 100644 --- a/src/main/java/com/ycwl/basic/pricing/service/impl/PricingManagementServiceImpl.java +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/PricingManagementServiceImpl.java @@ -8,7 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; +import java.util.Date; /** * 价格管理服务实现(用于配置管理,手动处理时间字段) @@ -35,8 +35,8 @@ public class PricingManagementServiceImpl implements IPricingManagementService { } } - config.setCreatedTime(LocalDateTime.now()); - config.setUpdatedTime(LocalDateTime.now()); + config.setCreatedTime(new Date()); + config.setUpdatedTime(new Date()); productConfigMapper.insertProductConfig(config); return config.getId(); } @@ -44,7 +44,7 @@ public class PricingManagementServiceImpl implements IPricingManagementService { @Override @Transactional public boolean updateProductConfig(PriceProductConfig config) { - config.setUpdatedTime(LocalDateTime.now()); + config.setUpdatedTime(new Date()); return productConfigMapper.updateProductConfig(config) > 0; } @@ -58,8 +58,8 @@ public class PricingManagementServiceImpl implements IPricingManagementService { config.getProductType(), config.getMinQuantity(), config.getMaxQuantity()); } - config.setCreatedTime(LocalDateTime.now()); - config.setUpdatedTime(LocalDateTime.now()); + config.setCreatedTime(new Date()); + config.setUpdatedTime(new Date()); tierConfigMapper.insertTierConfig(config); return config.getId(); } @@ -67,15 +67,15 @@ public class PricingManagementServiceImpl implements IPricingManagementService { @Override @Transactional public boolean updateTierConfig(PriceTierConfig config) { - config.setUpdatedTime(LocalDateTime.now()); + config.setUpdatedTime(new Date()); return tierConfigMapper.updateTierConfig(config) > 0; } @Override @Transactional public Long createCouponConfig(PriceCouponConfig config) { - config.setCreatedTime(LocalDateTime.now()); - config.setUpdatedTime(LocalDateTime.now()); + config.setCreatedTime(new Date()); + config.setUpdatedTime(new Date()); couponConfigMapper.insertCoupon(config); return config.getId(); } @@ -83,16 +83,16 @@ public class PricingManagementServiceImpl implements IPricingManagementService { @Override @Transactional public boolean updateCouponConfig(PriceCouponConfig config) { - config.setUpdatedTime(LocalDateTime.now()); + config.setUpdatedTime(new Date()); return couponConfigMapper.updateCoupon(config) > 0; } @Override @Transactional public Long createCouponClaimRecord(PriceCouponClaimRecord record) { - record.setClaimTime(LocalDateTime.now()); - record.setCreatedTime(LocalDateTime.now()); - record.setUpdatedTime(LocalDateTime.now()); + record.setClaimTime(new Date()); + record.setCreatedTime(new Date()); + record.setUpdatedTime(new Date()); couponClaimRecordMapper.insertClaimRecord(record); return record.getId(); } @@ -100,15 +100,15 @@ public class PricingManagementServiceImpl implements IPricingManagementService { @Override @Transactional public boolean updateCouponClaimRecord(PriceCouponClaimRecord record) { - record.setUpdatedTime(LocalDateTime.now()); + record.setUpdatedTime(new Date()); return couponClaimRecordMapper.updateClaimRecord(record) > 0; } @Override @Transactional public Long createBundleConfig(PriceBundleConfig config) { - config.setCreatedTime(LocalDateTime.now()); - config.setUpdatedTime(LocalDateTime.now()); + config.setCreatedTime(new Date()); + config.setUpdatedTime(new Date()); bundleConfigMapper.insertBundleConfig(config); return config.getId(); } @@ -116,7 +116,7 @@ public class PricingManagementServiceImpl implements IPricingManagementService { @Override @Transactional public boolean updateBundleConfig(PriceBundleConfig config) { - config.setUpdatedTime(LocalDateTime.now()); + config.setUpdatedTime(new Date()); return bundleConfigMapper.updateBundleConfig(config) > 0; } diff --git a/src/main/java/com/ycwl/basic/voucher/service/impl/VoucherBatchServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherBatchServiceImpl.java similarity index 64% rename from src/main/java/com/ycwl/basic/voucher/service/impl/VoucherBatchServiceImpl.java rename to src/main/java/com/ycwl/basic/pricing/service/impl/VoucherBatchServiceImpl.java index 0a4bf9d..8f1d2de 100644 --- a/src/main/java/com/ycwl/basic/voucher/service/impl/VoucherBatchServiceImpl.java +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherBatchServiceImpl.java @@ -1,18 +1,18 @@ -package com.ycwl.basic.voucher.service.impl; +package com.ycwl.basic.pricing.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ycwl.basic.constant.BaseContextHandler; import com.ycwl.basic.exception.BizException; -import com.ycwl.basic.interceptor.BaseContextHandler; -import com.ycwl.basic.voucher.dto.req.VoucherBatchCreateReq; -import com.ycwl.basic.voucher.dto.req.VoucherBatchQueryReq; -import com.ycwl.basic.voucher.dto.resp.VoucherBatchResp; -import com.ycwl.basic.voucher.dto.resp.VoucherBatchStatsResp; -import com.ycwl.basic.voucher.entity.VoucherBatchEntity; -import com.ycwl.basic.voucher.enums.VoucherDiscountType; -import com.ycwl.basic.voucher.mapper.VoucherBatchMapper; -import com.ycwl.basic.voucher.service.VoucherBatchService; -import com.ycwl.basic.voucher.service.VoucherCodeService; +import com.ycwl.basic.pricing.dto.req.VoucherBatchCreateReq; +import com.ycwl.basic.pricing.dto.req.VoucherBatchQueryReq; +import com.ycwl.basic.pricing.dto.resp.VoucherBatchResp; +import com.ycwl.basic.pricing.dto.resp.VoucherBatchStatsResp; +import com.ycwl.basic.pricing.entity.PriceVoucherBatchConfig; +import com.ycwl.basic.pricing.enums.VoucherDiscountType; +import com.ycwl.basic.pricing.mapper.PriceVoucherBatchConfigMapper; +import com.ycwl.basic.pricing.service.VoucherBatchService; +import com.ycwl.basic.pricing.service.VoucherCodeService; import lombok.RequiredArgsConstructor; import org.springframework.beans.BeanUtils; import org.springframework.stereotype.Service; @@ -25,7 +25,7 @@ import java.util.Date; @RequiredArgsConstructor public class VoucherBatchServiceImpl implements VoucherBatchService { - private final VoucherBatchMapper voucherBatchMapper; + private final PriceVoucherBatchConfigMapper voucherBatchMapper; private final VoucherCodeService voucherCodeService; @Override @@ -56,13 +56,16 @@ public class VoucherBatchServiceImpl implements VoucherBatchService { throw new BizException(400, "优惠金额不能为空"); } - VoucherBatchEntity batch = new VoucherBatchEntity(); + PriceVoucherBatchConfig batch = new PriceVoucherBatchConfig(); BeanUtils.copyProperties(req, batch); batch.setUsedCount(0); batch.setClaimedCount(0); batch.setStatus(1); - batch.setCreateTime(new Date()); - batch.setCreateBy(BaseContextHandler.getUserId()); + batch.setCreatedTime(new Date()); + String userIdStr = BaseContextHandler.getUserId(); + if (userIdStr != null) { + batch.setCreateBy(Long.valueOf(userIdStr)); + } batch.setDeleted(0); voucherBatchMapper.insert(batch); @@ -74,17 +77,17 @@ public class VoucherBatchServiceImpl implements VoucherBatchService { @Override public Page queryBatchList(VoucherBatchQueryReq req) { - Page page = new Page<>(req.getPageNum(), req.getPageSize()); + Page page = new Page<>(req.getPageNum(), req.getPageSize()); - LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.eq(VoucherBatchEntity::getDeleted, 0) - .eq(req.getScenicId() != null, VoucherBatchEntity::getScenicId, req.getScenicId()) - .eq(req.getBrokerId() != null, VoucherBatchEntity::getBrokerId, req.getBrokerId()) - .eq(req.getStatus() != null, VoucherBatchEntity::getStatus, req.getStatus()) - .like(StringUtils.hasText(req.getBatchName()), VoucherBatchEntity::getBatchName, req.getBatchName()) - .orderByDesc(VoucherBatchEntity::getCreateTime); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PriceVoucherBatchConfig::getDeleted, 0) + .eq(req.getScenicId() != null, PriceVoucherBatchConfig::getScenicId, req.getScenicId()) + .eq(req.getBrokerId() != null, PriceVoucherBatchConfig::getBrokerId, req.getBrokerId()) + .eq(req.getStatus() != null, PriceVoucherBatchConfig::getStatus, req.getStatus()) + .like(StringUtils.hasText(req.getBatchName()), PriceVoucherBatchConfig::getBatchName, req.getBatchName()) + .orderByDesc(PriceVoucherBatchConfig::getCreatedTime); - Page entityPage = voucherBatchMapper.selectPage(page, wrapper); + Page entityPage = voucherBatchMapper.selectPage(page, wrapper); Page respPage = new Page<>(); BeanUtils.copyProperties(entityPage, respPage); @@ -96,7 +99,7 @@ public class VoucherBatchServiceImpl implements VoucherBatchService { @Override public VoucherBatchResp getBatchDetail(Long id) { - VoucherBatchEntity batch = voucherBatchMapper.selectById(id); + PriceVoucherBatchConfig batch = voucherBatchMapper.selectById(id); if (batch == null || batch.getDeleted() == 1) { throw new BizException(404, "券码批次不存在"); } @@ -106,7 +109,7 @@ public class VoucherBatchServiceImpl implements VoucherBatchService { @Override public VoucherBatchStatsResp getBatchStats(Long id) { - VoucherBatchEntity batch = voucherBatchMapper.selectById(id); + PriceVoucherBatchConfig batch = voucherBatchMapper.selectById(id); if (batch == null || batch.getDeleted() == 1) { throw new BizException(404, "券码批次不存在"); } @@ -132,7 +135,7 @@ public class VoucherBatchServiceImpl implements VoucherBatchService { @Override public void updateBatchStatus(Long id, Integer status) { - VoucherBatchEntity batch = new VoucherBatchEntity(); + PriceVoucherBatchConfig batch = new PriceVoucherBatchConfig(); batch.setId(id); batch.setStatus(status); @@ -144,7 +147,7 @@ public class VoucherBatchServiceImpl implements VoucherBatchService { @Override public void updateBatchClaimedCount(Long batchId) { - VoucherBatchEntity batch = voucherBatchMapper.selectById(batchId); + PriceVoucherBatchConfig batch = voucherBatchMapper.selectById(batchId); if (batch != null) { batch.setClaimedCount(batch.getClaimedCount() + 1); voucherBatchMapper.updateById(batch); @@ -153,7 +156,7 @@ public class VoucherBatchServiceImpl implements VoucherBatchService { @Override public void updateBatchUsedCount(Long batchId) { - VoucherBatchEntity batch = voucherBatchMapper.selectById(batchId); + PriceVoucherBatchConfig batch = voucherBatchMapper.selectById(batchId); if (batch != null) { batch.setUsedCount(batch.getUsedCount() + 1); voucherBatchMapper.updateById(batch); @@ -161,19 +164,19 @@ public class VoucherBatchServiceImpl implements VoucherBatchService { } @Override - public VoucherBatchEntity getAvailableBatch(Long scenicId, Long brokerId) { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.eq(VoucherBatchEntity::getScenicId, scenicId) - .eq(VoucherBatchEntity::getBrokerId, brokerId) - .eq(VoucherBatchEntity::getStatus, 1) - .eq(VoucherBatchEntity::getDeleted, 0) - .lt(VoucherBatchEntity::getClaimedCount, VoucherBatchEntity::getTotalCount) - .orderByDesc(VoucherBatchEntity::getCreateTime); + public PriceVoucherBatchConfig getAvailableBatch(Long scenicId, Long brokerId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PriceVoucherBatchConfig::getScenicId, scenicId) + .eq(PriceVoucherBatchConfig::getBrokerId, brokerId) + .eq(PriceVoucherBatchConfig::getStatus, 1) + .eq(PriceVoucherBatchConfig::getDeleted, 0) + .apply("claimed_count < total_count") + .orderByDesc(PriceVoucherBatchConfig::getCreatedTime); return voucherBatchMapper.selectOne(wrapper); } - private VoucherBatchResp convertToResp(VoucherBatchEntity batch) { + private VoucherBatchResp convertToResp(PriceVoucherBatchConfig batch) { VoucherBatchResp resp = new VoucherBatchResp(); BeanUtils.copyProperties(batch, resp); diff --git a/src/main/java/com/ycwl/basic/voucher/service/impl/VoucherCodeServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherCodeServiceImpl.java similarity index 63% rename from src/main/java/com/ycwl/basic/voucher/service/impl/VoucherCodeServiceImpl.java rename to src/main/java/com/ycwl/basic/pricing/service/impl/VoucherCodeServiceImpl.java index c30ed73..cdb2c54 100644 --- a/src/main/java/com/ycwl/basic/voucher/service/impl/VoucherCodeServiceImpl.java +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherCodeServiceImpl.java @@ -1,19 +1,19 @@ -package com.ycwl.basic.voucher.service.impl; +package com.ycwl.basic.pricing.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ycwl.basic.exception.BizException; -import com.ycwl.basic.voucher.dto.req.VoucherClaimReq; -import com.ycwl.basic.voucher.dto.req.VoucherCodeQueryReq; -import com.ycwl.basic.voucher.dto.resp.VoucherCodeResp; -import com.ycwl.basic.voucher.entity.VoucherBatchEntity; -import com.ycwl.basic.voucher.entity.VoucherCodeEntity; -import com.ycwl.basic.voucher.enums.VoucherCodeStatus; -import com.ycwl.basic.voucher.enums.VoucherDiscountType; -import com.ycwl.basic.voucher.mapper.VoucherBatchMapper; -import com.ycwl.basic.voucher.mapper.VoucherCodeMapper; -import com.ycwl.basic.voucher.service.VoucherBatchService; -import com.ycwl.basic.voucher.service.VoucherCodeService; +import com.ycwl.basic.pricing.dto.req.VoucherClaimReq; +import com.ycwl.basic.pricing.dto.req.VoucherCodeQueryReq; +import com.ycwl.basic.pricing.dto.resp.VoucherCodeResp; +import com.ycwl.basic.pricing.entity.PriceVoucherBatchConfig; +import com.ycwl.basic.pricing.entity.PriceVoucherCode; +import com.ycwl.basic.pricing.enums.VoucherCodeStatus; +import com.ycwl.basic.pricing.enums.VoucherDiscountType; +import com.ycwl.basic.pricing.mapper.PriceVoucherBatchConfigMapper; +import com.ycwl.basic.pricing.mapper.PriceVoucherCodeMapper; +import com.ycwl.basic.pricing.service.VoucherBatchService; +import com.ycwl.basic.pricing.service.VoucherCodeService; import lombok.RequiredArgsConstructor; import org.springframework.beans.BeanUtils; import org.springframework.stereotype.Service; @@ -29,26 +29,26 @@ import java.util.UUID; @RequiredArgsConstructor public class VoucherCodeServiceImpl implements VoucherCodeService { - private final VoucherCodeMapper voucherCodeMapper; - private final VoucherBatchMapper voucherBatchMapper; + private final PriceVoucherCodeMapper voucherCodeMapper; + private final PriceVoucherBatchConfigMapper voucherBatchMapper; private final VoucherBatchService voucherBatchService; @Override public void generateVoucherCodes(Long batchId, Long scenicId, Integer count) { - List codes = new ArrayList<>(); + List codes = new ArrayList<>(); for (int i = 0; i < count; i++) { - VoucherCodeEntity code = new VoucherCodeEntity(); + PriceVoucherCode code = new PriceVoucherCode(); code.setBatchId(batchId); code.setScenicId(scenicId); code.setCode(generateVoucherCode()); code.setStatus(VoucherCodeStatus.UNCLAIMED.getCode()); - code.setCreateTime(new Date()); + code.setCreatedTime(new Date()); code.setDeleted(0); codes.add(code); } - for (VoucherCodeEntity code : codes) { + for (PriceVoucherCode code : codes) { voucherCodeMapper.insert(code); } } @@ -70,12 +70,12 @@ public class VoucherCodeServiceImpl implements VoucherCodeService { throw new BizException(400, "该用户在此景区已领取过券码"); } - VoucherBatchEntity batch = voucherBatchService.getAvailableBatch(req.getScenicId(), req.getBrokerId()); + PriceVoucherBatchConfig batch = voucherBatchService.getAvailableBatch(req.getScenicId(), req.getBrokerId()); if (batch == null) { throw new BizException(400, "暂无可用券码批次"); } - VoucherCodeEntity availableCode = voucherCodeMapper.findFirstAvailableByBatchId(batch.getId()); + PriceVoucherCode availableCode = voucherCodeMapper.findFirstAvailableByBatchId(batch.getId()); if (availableCode == null) { throw new BizException(400, "券码已领完"); } @@ -93,25 +93,25 @@ public class VoucherCodeServiceImpl implements VoucherCodeService { @Override public Page queryCodeList(VoucherCodeQueryReq req) { - Page page = new Page<>(req.getPageNum(), req.getPageSize()); + Page page = new Page<>(req.getPageNum(), req.getPageSize()); - LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.eq(VoucherCodeEntity::getDeleted, 0) - .eq(req.getBatchId() != null, VoucherCodeEntity::getBatchId, req.getBatchId()) - .eq(req.getScenicId() != null, VoucherCodeEntity::getScenicId, req.getScenicId()) - .eq(req.getFaceId() != null, VoucherCodeEntity::getFaceId, req.getFaceId()) - .eq(req.getStatus() != null, VoucherCodeEntity::getStatus, req.getStatus()) - .like(StringUtils.hasText(req.getCode()), VoucherCodeEntity::getCode, req.getCode()) - .orderByDesc(VoucherCodeEntity::getCreateTime); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PriceVoucherCode::getDeleted, 0) + .eq(req.getBatchId() != null, PriceVoucherCode::getBatchId, req.getBatchId()) + .eq(req.getScenicId() != null, PriceVoucherCode::getScenicId, req.getScenicId()) + .eq(req.getFaceId() != null, PriceVoucherCode::getFaceId, req.getFaceId()) + .eq(req.getStatus() != null, PriceVoucherCode::getStatus, req.getStatus()) + .like(StringUtils.hasText(req.getCode()), PriceVoucherCode::getCode, req.getCode()) + .orderByDesc(PriceVoucherCode::getCreatedTime); - Page entityPage = voucherCodeMapper.selectPage(page, wrapper); + Page entityPage = voucherCodeMapper.selectPage(page, wrapper); Page respPage = new Page<>(); BeanUtils.copyProperties(entityPage, respPage); List respList = new ArrayList<>(); - for (VoucherCodeEntity code : entityPage.getRecords()) { - VoucherBatchEntity batch = voucherBatchMapper.selectById(code.getBatchId()); + for (PriceVoucherCode code : entityPage.getRecords()) { + PriceVoucherBatchConfig batch = voucherBatchMapper.selectById(code.getBatchId()); respList.add(convertToResp(code, batch)); } respPage.setRecords(respList); @@ -121,16 +121,16 @@ public class VoucherCodeServiceImpl implements VoucherCodeService { @Override public List getMyVoucherCodes(Long faceId) { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.eq(VoucherCodeEntity::getFaceId, faceId) - .eq(VoucherCodeEntity::getDeleted, 0) - .orderByDesc(VoucherCodeEntity::getClaimedTime); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PriceVoucherCode::getFaceId, faceId) + .eq(PriceVoucherCode::getDeleted, 0) + .orderByDesc(PriceVoucherCode::getClaimedTime); - List codes = voucherCodeMapper.selectList(wrapper); + List codes = voucherCodeMapper.selectList(wrapper); List respList = new ArrayList<>(); - for (VoucherCodeEntity code : codes) { - VoucherBatchEntity batch = voucherBatchMapper.selectById(code.getBatchId()); + for (PriceVoucherCode code : codes) { + PriceVoucherBatchConfig batch = voucherBatchMapper.selectById(code.getBatchId()); respList.add(convertToResp(code, batch)); } @@ -140,7 +140,7 @@ public class VoucherCodeServiceImpl implements VoucherCodeService { @Override @Transactional public void markCodeAsUsed(Long codeId, String remark) { - VoucherCodeEntity code = voucherCodeMapper.selectById(codeId); + PriceVoucherCode code = voucherCodeMapper.selectById(codeId); if (code == null || code.getDeleted() == 1) { throw new BizException(404, "券码不存在"); } @@ -168,7 +168,7 @@ public class VoucherCodeServiceImpl implements VoucherCodeService { return UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(); } - private VoucherCodeResp convertToResp(VoucherCodeEntity code, VoucherBatchEntity batch) { + private VoucherCodeResp convertToResp(PriceVoucherCode code, PriceVoucherBatchConfig batch) { VoucherCodeResp resp = new VoucherCodeResp(); BeanUtils.copyProperties(code, resp); diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherDiscountProvider.java b/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherDiscountProvider.java new file mode 100644 index 0000000..32e5643 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherDiscountProvider.java @@ -0,0 +1,175 @@ +package com.ycwl.basic.pricing.service.impl; + +import com.ycwl.basic.pricing.dto.*; +import com.ycwl.basic.pricing.enums.VoucherDiscountType; +import com.ycwl.basic.pricing.service.IDiscountProvider; +import com.ycwl.basic.pricing.service.IVoucherService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +/** + * 券码折扣提供者 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class VoucherDiscountProvider implements IDiscountProvider { + + private final IVoucherService voucherService; + + @Override + public String getProviderType() { + return "VOUCHER"; + } + + @Override + public int getPriority() { + return 100; // 券码优先级最高 + } + + @Override + public List detectAvailableDiscounts(DiscountDetectionContext context) { + List discounts = new ArrayList<>(); + + if (context.getFaceId() == null || context.getScenicId() == null) { + return discounts; + } + + try { + VoucherInfo voucherInfo = null; + + // 优先检查用户主动输入的券码 + if (StringUtils.hasText(context.getVoucherCode())) { + voucherInfo = voucherService.validateAndGetVoucherInfo( + context.getVoucherCode(), + context.getFaceId(), + context.getScenicId() + ); + } + // 如果没有输入券码且允许自动使用,则查找最优券码 + else if (Boolean.TRUE.equals(context.getAutoUseVoucher())) { + voucherInfo = voucherService.getBestVoucher( + context.getFaceId(), + context.getScenicId(), + context + ); + } + + if (voucherInfo != null && Boolean.TRUE.equals(voucherInfo.getAvailable())) { + // 计算券码优惠金额 + BigDecimal discountAmount = voucherService.calculateVoucherDiscount(voucherInfo, context); + + if (discountAmount.compareTo(BigDecimal.ZERO) > 0) { + DiscountInfo discountInfo = new DiscountInfo(); + discountInfo.setDiscountId(voucherInfo.getVoucherId()); + discountInfo.setDiscountType("VOUCHER"); + discountInfo.setDiscountName("券码优惠"); + discountInfo.setDiscountDescription(buildDiscountDescription(voucherInfo)); + discountInfo.setDiscountAmount(discountAmount); + discountInfo.setProviderType(getProviderType()); + discountInfo.setPriority(getPriority()); + discountInfo.setStackable(isStackable(voucherInfo)); // 只有全场免费不可叠加 + discountInfo.setVoucherCode(voucherInfo.getVoucherCode()); + + discounts.add(discountInfo); + } + } + } catch (Exception e) { + log.warn("检测券码优惠时发生异常", e); + } + + return discounts; + } + + @Override + public DiscountResult applyDiscount(DiscountInfo discountInfo, DiscountDetectionContext context) { + DiscountResult result = new DiscountResult(); + result.setDiscountInfo(discountInfo); + + try { + String voucherCode = discountInfo.getVoucherCode(); + if (!StringUtils.hasText(voucherCode)) { + result.setSuccess(false); + result.setFailureReason("券码信息丢失"); + return result; + } + + // 重新验证券码 + VoucherInfo voucherInfo = voucherService.validateAndGetVoucherInfo( + voucherCode, + context.getFaceId(), + context.getScenicId() + ); + + if (voucherInfo == null || !Boolean.TRUE.equals(voucherInfo.getAvailable())) { + result.setSuccess(false); + result.setFailureReason("券码无效或不可用"); + return result; + } + + // 计算实际优惠金额 + BigDecimal actualDiscount = voucherService.calculateVoucherDiscount(voucherInfo, context); + BigDecimal finalAmount; + + // 对于全场免费券码,最终金额为0 + if (voucherInfo.getDiscountType() == VoucherDiscountType.FREE_ALL) { + finalAmount = BigDecimal.ZERO; + actualDiscount = context.getCurrentAmount(); + } else { + finalAmount = context.getCurrentAmount().subtract(actualDiscount); + if (finalAmount.compareTo(BigDecimal.ZERO) < 0) { + finalAmount = BigDecimal.ZERO; + actualDiscount = context.getCurrentAmount(); + } + } + + result.setActualDiscountAmount(actualDiscount); + result.setFinalAmount(finalAmount); + result.setSuccess(true); + + log.info("成功应用券码: {}, 优惠金额: {}", voucherCode, actualDiscount); + + } catch (Exception e) { + log.error("应用券码失败: " + discountInfo.getVoucherCode(), e); + result.setSuccess(false); + result.setFailureReason("券码应用失败: " + e.getMessage()); + } + + return result; + } + + @Override + public boolean canApply(DiscountInfo discountInfo, DiscountDetectionContext context) { + return "VOUCHER".equals(discountInfo.getDiscountType()) && + context.getFaceId() != null && + context.getScenicId() != null && + StringUtils.hasText(discountInfo.getVoucherCode()); + } + + /** + * 构建优惠描述 + */ + private String buildDiscountDescription(VoucherInfo voucherInfo) { + if (voucherInfo.getDiscountType() == null) { + return "券码优惠"; + } + + return String.format("券码 %s - %s", + voucherInfo.getVoucherCode(), + voucherInfo.getDiscountType().getName()); + } + + /** + * 判断是否可以与其他优惠叠加 + */ + private boolean isStackable(VoucherInfo voucherInfo) { + // 全场免费券码不可与其他优惠叠加 + return voucherInfo.getDiscountType() != VoucherDiscountType.FREE_ALL; + } +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherServiceImpl.java new file mode 100644 index 0000000..943267d --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherServiceImpl.java @@ -0,0 +1,223 @@ +package com.ycwl.basic.pricing.service.impl; + +import com.ycwl.basic.pricing.dto.DiscountDetectionContext; +import com.ycwl.basic.pricing.dto.ProductItem; +import com.ycwl.basic.pricing.dto.VoucherInfo; +import com.ycwl.basic.pricing.entity.PriceVoucherBatchConfig; +import com.ycwl.basic.pricing.entity.PriceVoucherCode; +import com.ycwl.basic.pricing.enums.VoucherCodeStatus; +import com.ycwl.basic.pricing.enums.VoucherDiscountType; +import com.ycwl.basic.pricing.mapper.PriceVoucherBatchConfigMapper; +import com.ycwl.basic.pricing.mapper.PriceVoucherCodeMapper; +import com.ycwl.basic.pricing.service.IVoucherService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 券码服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class VoucherServiceImpl implements IVoucherService { + + private final PriceVoucherCodeMapper voucherCodeMapper; + private final PriceVoucherBatchConfigMapper voucherBatchConfigMapper; + + @Override + public VoucherInfo validateAndGetVoucherInfo(String voucherCode, Long faceId, Long scenicId) { + if (!StringUtils.hasText(voucherCode)) { + return null; + } + + // 查询券码信息 + PriceVoucherCode voucherCodeEntity = voucherCodeMapper.selectByCode(voucherCode); + if (voucherCodeEntity == null || voucherCodeEntity.getDeleted() == 1) { + return null; + } + + // 查询批次信息 + PriceVoucherBatchConfig batchConfig = voucherBatchConfigMapper.selectById(voucherCodeEntity.getBatchId()); + if (batchConfig == null || batchConfig.getDeleted() == 1 || batchConfig.getStatus() == 0) { + return null; + } + + // 验证景区匹配 + if (scenicId != null && !scenicId.equals(voucherCodeEntity.getScenicId())) { + return null; + } + + VoucherInfo voucherInfo = buildVoucherInfo(voucherCodeEntity, batchConfig); + + // 检查券码状态和可用性 + if (VoucherCodeStatus.UNCLAIMED.getCode().equals(voucherCodeEntity.getStatus())) { + // 未领取状态,检查是否可以领取 + if (faceId != null && canClaimVoucher(faceId, voucherCodeEntity.getScenicId())) { + voucherInfo.setAvailable(true); + } else { + voucherInfo.setAvailable(false); + voucherInfo.setUnavailableReason("您已在该景区领取过券码"); + } + } else if (VoucherCodeStatus.CLAIMED_UNUSED.getCode().equals(voucherCodeEntity.getStatus())) { + // 已领取未使用,检查是否为当前用户 + if (faceId != null && faceId.equals(voucherCodeEntity.getFaceId())) { + voucherInfo.setAvailable(true); + } else { + voucherInfo.setAvailable(false); + voucherInfo.setUnavailableReason("券码已被其他用户领取"); + } + } else { + // 已使用 + voucherInfo.setAvailable(false); + voucherInfo.setUnavailableReason("券码已使用"); + } + + return voucherInfo; + } + + @Override + public List getAvailableVouchers(Long faceId, Long scenicId) { + if (faceId == null || scenicId == null) { + return new ArrayList<>(); + } + + List voucherCodes = voucherCodeMapper.selectAvailableVouchersByFaceIdAndScenicId(faceId, scenicId); + List voucherInfos = new ArrayList<>(); + + for (PriceVoucherCode voucherCode : voucherCodes) { + PriceVoucherBatchConfig batchConfig = voucherBatchConfigMapper.selectById(voucherCode.getBatchId()); + if (batchConfig != null && batchConfig.getDeleted() == 0 && batchConfig.getStatus() == 1) { + VoucherInfo voucherInfo = buildVoucherInfo(voucherCode, batchConfig); + voucherInfo.setAvailable(true); + voucherInfos.add(voucherInfo); + } + } + + return voucherInfos; + } + + @Override + public void markVoucherAsUsed(String voucherCode, String remark) { + if (!StringUtils.hasText(voucherCode)) { + return; + } + + int result = voucherCodeMapper.useVoucher(voucherCode, LocalDateTime.now(), remark); + if (result > 0) { + // 更新批次统计 + PriceVoucherCode voucherCodeEntity = voucherCodeMapper.selectByCode(voucherCode); + if (voucherCodeEntity != null) { + voucherBatchConfigMapper.updateUsedCount(voucherCodeEntity.getBatchId(), 1); + } + log.info("券码已标记为使用: {}", voucherCode); + } + } + + @Override + public boolean canClaimVoucher(Long faceId, Long scenicId) { + if (faceId == null || scenicId == null) { + return false; + } + + Integer count = voucherCodeMapper.countByFaceIdAndScenicId(faceId, scenicId); + return count == 0; + } + + @Override + public BigDecimal calculateVoucherDiscount(VoucherInfo voucherInfo, DiscountDetectionContext context) { + if (voucherInfo == null || !Boolean.TRUE.equals(voucherInfo.getAvailable()) || + context.getProducts() == null || context.getProducts().isEmpty()) { + return BigDecimal.ZERO; + } + + VoucherDiscountType discountType = voucherInfo.getDiscountType(); + BigDecimal discountValue = voucherInfo.getDiscountValue(); + + if (discountType == null) { + return BigDecimal.ZERO; + } + + return switch (discountType) { + case FREE_ALL -> { + // 全场免费,返回当前总金额 + yield context.getCurrentAmount() != null ? context.getCurrentAmount() : BigDecimal.ZERO; + } + case REDUCE_PRICE -> { + // 商品降价,每个商品减免固定金额 + if (discountValue == null || discountValue.compareTo(BigDecimal.ZERO) <= 0) { + yield BigDecimal.ZERO; + } + BigDecimal totalDiscount = BigDecimal.ZERO; + for (ProductItem product : context.getProducts()) { + BigDecimal productDiscount = discountValue.multiply(BigDecimal.valueOf(product.getQuantity())); + totalDiscount = totalDiscount.add(productDiscount); + } + yield totalDiscount; + } + case DISCOUNT -> { + // 商品打折,按百分比计算 + if (discountValue == null || discountValue.compareTo(BigDecimal.ZERO) <= 0 || + discountValue.compareTo(BigDecimal.valueOf(100)) >= 0) { + yield BigDecimal.ZERO; + } + BigDecimal currentAmount = context.getCurrentAmount() != null ? context.getCurrentAmount() : BigDecimal.ZERO; + BigDecimal discountRate = discountValue.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP); + yield currentAmount.multiply(discountRate).setScale(2, RoundingMode.HALF_UP); + } + }; + } + + @Override + public VoucherInfo getBestVoucher(Long faceId, Long scenicId, DiscountDetectionContext context) { + List availableVouchers = getAvailableVouchers(faceId, scenicId); + if (availableVouchers.isEmpty()) { + return null; + } + + // 计算每个券码的优惠金额,选择最优的 + VoucherInfo bestVoucher = null; + BigDecimal maxDiscount = BigDecimal.ZERO; + + for (VoucherInfo voucher : availableVouchers) { + BigDecimal discount = calculateVoucherDiscount(voucher, context); + voucher.setActualDiscountAmount(discount); + + if (discount.compareTo(maxDiscount) > 0) { + maxDiscount = discount; + bestVoucher = voucher; + } + } + + return bestVoucher; + } + + /** + * 构建券码信息DTO + */ + private VoucherInfo buildVoucherInfo(PriceVoucherCode voucherCode, PriceVoucherBatchConfig batchConfig) { + VoucherInfo voucherInfo = new VoucherInfo(); + voucherInfo.setVoucherId(voucherCode.getId()); + voucherInfo.setVoucherCode(voucherCode.getCode()); + voucherInfo.setBatchId(batchConfig.getId()); + voucherInfo.setBatchName(batchConfig.getBatchName()); + voucherInfo.setScenicId(voucherCode.getScenicId()); + voucherInfo.setBrokerId(batchConfig.getBrokerId()); + voucherInfo.setDiscountType(VoucherDiscountType.getByCode(batchConfig.getDiscountType())); + voucherInfo.setDiscountValue(batchConfig.getDiscountValue()); + voucherInfo.setStatus(voucherCode.getStatus()); + voucherInfo.setClaimedTime(voucherCode.getClaimedTime()); + voucherInfo.setUsedTime(voucherCode.getUsedTime()); + + return voucherInfo; + } +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/CLAUDE.md b/src/main/java/com/ycwl/basic/voucher/CLAUDE.md deleted file mode 100644 index cadf343..0000000 --- a/src/main/java/com/ycwl/basic/voucher/CLAUDE.md +++ /dev/null @@ -1,179 +0,0 @@ -# 券码核销功能模块 - -本模块实现景区券码的批量创建、分发和核销管理功能。支持全场免费、商品降价、商品打折三种优惠模式,确保每个用户在每个景区只能领取一次券码。 - -## 功能概述 - -- **批量创建券码**:管理员可创建券码批次,自动生成指定数量的唯一券码 -- **精准分发控制**:通过景区ID、推客ID、用户faceId进行精准投放 -- **三种优惠模式**:全场免费、商品降价、商品打折 -- **唯一性保证**:同一用户在同一景区只能领取一次券码 -- **完整管理功能**:批次管理、券码查询、使用统计、手动核销 - -## 数据库表结构 - -### 券码批次表 (voucher_batch) -```sql -CREATE TABLE voucher_batch ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - batch_name VARCHAR(100) NOT NULL COMMENT '券码批次名称', - scenic_id BIGINT NOT NULL COMMENT '景区ID', - broker_id BIGINT NOT NULL COMMENT '推客ID', - discount_type TINYINT NOT NULL COMMENT '优惠类型:0=全场免费,1=商品降价,2=商品打折', - discount_value DECIMAL(10,2) COMMENT '优惠值(降价金额或折扣百分比)', - total_count INT NOT NULL COMMENT '总券码数量', - used_count INT DEFAULT 0 COMMENT '已使用数量', - claimed_count INT DEFAULT 0 COMMENT '已领取数量', - status TINYINT DEFAULT 1 COMMENT '状态:0=禁用,1=启用', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP, - create_by BIGINT COMMENT '创建人ID', - deleted TINYINT DEFAULT 0, - deleted_at DATETIME -); -``` - -### 券码表 (voucher_code) -```sql -CREATE TABLE voucher_code ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - batch_id BIGINT NOT NULL COMMENT '批次ID', - scenic_id BIGINT NOT NULL COMMENT '景区ID', - code VARCHAR(32) NOT NULL UNIQUE COMMENT '券码', - status TINYINT DEFAULT 0 COMMENT '状态:0=未领取,1=已领取未使用,2=已使用', - face_id BIGINT COMMENT '领取人faceId', - claimed_time DATETIME COMMENT '领取时间', - used_time DATETIME COMMENT '使用时间', - remark VARCHAR(500) COMMENT '使用备注', - create_time DATETIME DEFAULT CURRENT_TIMESTAMP, - deleted TINYINT DEFAULT 0, - deleted_at DATETIME -); -``` - -## 包结构说明 - -``` -com.ycwl.basic.voucher/ -├── controller/ -│ └── VoucherController.java # 控制器:PC端管理和移动端用户接口 -├── service/ -│ ├── VoucherBatchService.java # 券码批次服务接口 -│ ├── VoucherCodeService.java # 券码服务接口 -│ └── impl/ -│ ├── VoucherBatchServiceImpl.java # 券码批次服务实现 -│ └── VoucherCodeServiceImpl.java # 券码服务实现 -├── mapper/ -│ ├── VoucherBatchMapper.java # 券码批次数据访问 -│ └── VoucherCodeMapper.java # 券码数据访问 -├── entity/ -│ ├── VoucherBatchEntity.java # 券码批次实体 -│ └── VoucherCodeEntity.java # 券码实体 -├── dto/ -│ ├── req/ # 请求DTO -│ │ ├── VoucherBatchCreateReq.java # 创建批次请求 -│ │ ├── VoucherBatchQueryReq.java # 批次查询请求 -│ │ ├── VoucherCodeQueryReq.java # 券码查询请求 -│ │ └── VoucherClaimReq.java # 券码领取请求 -│ └── resp/ # 响应DTO -│ ├── VoucherBatchResp.java # 批次响应 -│ ├── VoucherCodeResp.java # 券码响应 -│ └── VoucherBatchStatsResp.java # 批次统计响应 -└── enums/ - ├── VoucherDiscountType.java # 优惠类型枚举 - └── VoucherCodeStatus.java # 券码状态枚举 -``` - -## 核心业务逻辑 - -### 1. 券码生成规则 -- 使用UUID生成8位大写字母数字组合 -- 确保每个券码在系统中唯一 -- 券码初始状态为"未领取" - -### 2. 领取验证逻辑 -```java -// 核心验证:同一faceId在同一scenicId中只能领取一次 -public boolean canClaimVoucher(Long faceId, Long scenicId) { - Integer count = voucherCodeMapper.countByFaceIdAndScenicId(faceId, scenicId); - return count == 0; -} -``` - -### 3. 优惠类型说明 -- **全场免费(0)**:所有商品免费,discountValue可为空 -- **商品降价(1)**:每个商品减免固定金额,discountValue为减免金额 -- **商品打折(2)**:每个商品按百分比打折,discountValue为折扣百分比 - -### 4. 状态流转 -券码状态:未领取(0) → 已领取未使用(1) → 已使用(2) - -## 主要接口说明 - -### PC端管理接口 -- `POST /api/voucher/batch/create` - 创建券码批次 -- `POST /api/voucher/batch/list` - 批次列表查询 -- `GET /api/voucher/batch/{id}` - 批次详情 -- `GET /api/voucher/batch/{id}/stats` - 批次统计 -- `PUT /api/voucher/batch/{id}/status` - 启用/禁用批次 -- `POST /api/voucher/codes` - 券码列表查询 -- `PUT /api/voucher/code/{id}/use` - 手动标记券码已使用 -- `GET /api/voucher/scenic/{scenicId}/users` - 查看景区下用户领取情况 - -### 移动端用户接口 -- `POST /api/voucher/mobile/claim` - 领取券码(一步到位) -- `GET /api/voucher/mobile/my-codes` - 我的券码列表 - -## 关键技术特点 - -### 1. 事务管理 -- 使用`@Transactional`确保券码领取操作的原子性 -- 批次创建和券码生成在同一事务中完成 - -### 2. 并发控制 -- 通过应用层验证避免数据库唯一约束冲突 -- 使用数据库索引优化查询性能 - -### 3. 参数验证 -- 在Service层进行手动参数校验 -- 使用BizException统一错误处理 - -### 4. 数据统计 -- 实时更新批次的已领取数量和已使用数量 -- 提供详细的使用统计和分析数据 - -## 使用示例 - -### 创建券码批次 -```java -VoucherBatchCreateReq req = new VoucherBatchCreateReq(); -req.setBatchName("春节特惠券"); -req.setScenicId(1001L); -req.setBrokerId(2001L); -req.setDiscountType(1); // 商品降价 -req.setDiscountValue(new BigDecimal("10.00")); -req.setTotalCount(1000); - -Long batchId = voucherBatchService.createBatch(req); -``` - -### 用户领取券码 -```java -VoucherClaimReq req = new VoucherClaimReq(); -req.setScenicId(1001L); -req.setBrokerId(2001L); -req.setFaceId(3001L); - -VoucherCodeResp result = voucherCodeService.claimVoucher(req); -``` - -## 注意事项 - -1. **唯一性限制**:同一个faceId在同一个scenicId中只能领取一次券码 -2. **批次状态**:只有启用状态的批次才能领取券码 -3. **券码数量**:确保批次有可用券码才能成功领取 -4. **优惠值验证**:除全场免费外,其他优惠类型必须设置优惠值 -5. **删除机制**:使用逻辑删除,deleted字段标记删除状态 - -## 扩展说明 - -本模块设计为独立功能模块,不依赖支付系统,为后续接入其他优惠策略预留了扩展空间。所有接口都提供了完整的错误处理和参数验证,确保系统的稳定性和可维护性。 \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/entity/VoucherBatchEntity.java b/src/main/java/com/ycwl/basic/voucher/entity/VoucherBatchEntity.java deleted file mode 100644 index 9d2dfef..0000000 --- a/src/main/java/com/ycwl/basic/voucher/entity/VoucherBatchEntity.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.ycwl.basic.voucher.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.math.BigDecimal; -import java.util.Date; - -@Data -@TableName("voucher_batch") -public class VoucherBatchEntity { - @TableId(value = "id", type = IdType.AUTO) - private Long id; - - private String batchName; - private Long scenicId; - private Long brokerId; - private Integer discountType; - private BigDecimal discountValue; - private Integer totalCount; - private Integer usedCount; - private Integer claimedCount; - private Integer status; - private Date createTime; - private Long createBy; - private Integer deleted; - private Date deletedAt; -} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/entity/VoucherCodeEntity.java b/src/main/java/com/ycwl/basic/voucher/entity/VoucherCodeEntity.java deleted file mode 100644 index 63a5dab..0000000 --- a/src/main/java/com/ycwl/basic/voucher/entity/VoucherCodeEntity.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.ycwl.basic.voucher.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.util.Date; - -@Data -@TableName("voucher_code") -public class VoucherCodeEntity { - @TableId(value = "id", type = IdType.AUTO) - private Long id; - - private Long batchId; - private Long scenicId; - private String code; - private Integer status; - private Long faceId; - private Date claimedTime; - private Date usedTime; - private String remark; - private Date createTime; - private Integer deleted; - private Date deletedAt; -} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/enums/VoucherCodeStatus.java b/src/main/java/com/ycwl/basic/voucher/enums/VoucherCodeStatus.java deleted file mode 100644 index a55bbca..0000000 --- a/src/main/java/com/ycwl/basic/voucher/enums/VoucherCodeStatus.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.ycwl.basic.voucher.enums; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum VoucherCodeStatus { - UNCLAIMED(0, "未领取"), - CLAIMED_UNUSED(1, "已领取未使用"), - USED(2, "已使用"); - - private final Integer code; - private final String name; - - public static VoucherCodeStatus getByCode(Integer code) { - if (code == null) { - return null; - } - for (VoucherCodeStatus status : values()) { - if (status.getCode().equals(code)) { - return status; - } - } - return null; - } -} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/mapper/VoucherBatchMapper.java b/src/main/java/com/ycwl/basic/voucher/mapper/VoucherBatchMapper.java deleted file mode 100644 index e3f561a..0000000 --- a/src/main/java/com/ycwl/basic/voucher/mapper/VoucherBatchMapper.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.ycwl.basic.voucher.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.ycwl.basic.voucher.entity.VoucherBatchEntity; -import org.apache.ibatis.annotations.Mapper; - -@Mapper -public interface VoucherBatchMapper extends BaseMapper { -} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/mapper/VoucherCodeMapper.java b/src/main/java/com/ycwl/basic/voucher/mapper/VoucherCodeMapper.java deleted file mode 100644 index f7c6011..0000000 --- a/src/main/java/com/ycwl/basic/voucher/mapper/VoucherCodeMapper.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.ycwl.basic.voucher.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.ycwl.basic.voucher.entity.VoucherCodeEntity; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; - -@Mapper -public interface VoucherCodeMapper extends BaseMapper { - - @Select("SELECT COUNT(*) FROM voucher_code WHERE scenic_id = #{scenicId} AND face_id = #{faceId} AND status != 0 AND deleted = 0") - Integer countByFaceIdAndScenicId(@Param("faceId") Long faceId, @Param("scenicId") Long scenicId); - - @Select("SELECT * FROM voucher_code WHERE batch_id = #{batchId} AND status = 0 AND deleted = 0 LIMIT 1") - VoucherCodeEntity findFirstAvailableByBatchId(@Param("batchId") Long batchId); -} \ No newline at end of file