diff --git a/src/main/java/com/imyeyu/api/modules/common/entity/Attachment.java b/src/main/java/com/imyeyu/api/modules/common/entity/Attachment.java index deabf72..0683663 100644 --- a/src/main/java/com/imyeyu/api/modules/common/entity/Attachment.java +++ b/src/main/java/com/imyeyu/api/modules/common/entity/Attachment.java @@ -52,6 +52,10 @@ public class Attachment extends UUIDEntity { GAO_SALES_ORDER, + GAO_POINT_REDEMPTION, + + GAO_POINT_REDEMPTION_RETURN, + TEMP_FILE, MIRROR diff --git a/src/main/java/com/imyeyu/api/modules/gao/bean/GaoPermissionCode.java b/src/main/java/com/imyeyu/api/modules/gao/bean/GaoPermissionCode.java index b67202c..2b1a6ae 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/bean/GaoPermissionCode.java +++ b/src/main/java/com/imyeyu/api/modules/gao/bean/GaoPermissionCode.java @@ -139,6 +139,14 @@ public enum GaoPermissionCode implements BuiltinPermissionCode { POINT_SOURCE_VALUE("POINT:SOURCE_VALUE", "登记提交自定义积分值", true, true), + POINT_REDEMPTION_CREATE("POINT_REDEMPTION:CREATE", "积分兑换创建和读取", true, true), + + POINT_REDEMPTION_POINT_ADJUST("POINT_REDEMPTION:POINT_ADJUST", "积分兑换减免积分", true, true), + + POINT_REDEMPTION_RETURN("POINT_REDEMPTION:RETURN", "积分兑换退货", true, true), + + POINT_REDEEM_SKU_MANAGE("POINT_REDEEM_SKU:MANAGE", "积分兑换 SKU 配置管理", true, true), + STAT_READ("STAT:READ", "统计读取", false, false), QR_CODE_CREATE("QR_CODE:CREATE", "二维码创建", false, false), diff --git a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPointRedeemSkuController.java b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPointRedeemSkuController.java new file mode 100644 index 0000000..3fa56dd --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPointRedeemSkuController.java @@ -0,0 +1,85 @@ +package com.imyeyu.api.modules.gao.controller; + +import com.imyeyu.api.modules.gao.annotation.RequireGaoPermission; +import com.imyeyu.api.modules.gao.bean.GaoPermissionCode; +import com.imyeyu.api.modules.gao.entity.GaoPointRedeemSku; +import com.imyeyu.api.modules.gao.service.GaoPointRedeemSkuService; +import com.imyeyu.api.modules.gao.service.GaoStoreScopeService; +import com.imyeyu.api.modules.gao.vo.GaoPointRedeemSkuPage; +import com.imyeyu.api.modules.gao.vo.GaoPointRedeemSkuRequest; +import com.imyeyu.java.bean.timi.TimiException; +import com.imyeyu.spring.annotation.AOPLog; +import com.imyeyu.spring.annotation.RequestRateLimit; +import com.imyeyu.spring.bean.PageResult; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/// GAO 积分兑换 SKU 配置接口 +/// +/// @author Codex +/// @since 2026-09-14 +@RestController +@RequiredArgsConstructor +@RequestMapping("/gao/point/redeem-sku") +public class GaoPointRedeemSkuController { + + private final GaoPointRedeemSkuService service; + private final GaoStoreScopeService storeScopeService; + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_REDEEM_SKU_MANAGE) + @PostMapping("/list") + public PageResult list(@RequestBody GaoPointRedeemSkuPage page) { + TimiException.required(page, "not found redeem sku page"); + page.setStoreId(storeScopeService.resolveStoreId(page.getStoreId())); + return service.pageByQuery(page); + } + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_REDEEM_SKU_MANAGE) + @PostMapping("/detail") + public GaoPointRedeemSku detail(@RequestParam String id, @RequestParam(required = false) String storeId) { + return service.detail(storeScopeService.resolveStoreId(storeId), id); + } + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_REDEEM_SKU_MANAGE) + @PostMapping("/create") + public GaoPointRedeemSku create(@RequestBody GaoPointRedeemSkuRequest request) { + TimiException.required(request, "not found redeem sku request"); + request.setStoreId(storeScopeService.resolveStoreId(request.getStoreId())); + return service.create(request); + } + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_REDEEM_SKU_MANAGE) + @PostMapping("/update") + public GaoPointRedeemSku update(@RequestBody GaoPointRedeemSkuRequest request) { + TimiException.required(request, "not found redeem sku request"); + return service.update(request); + } + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_REDEEM_SKU_MANAGE) + @PostMapping("/disable") + public void disable(@RequestParam String id, @RequestParam(required = false) String storeId) { + service.disable(storeScopeService.resolveStoreId(storeId), id); + } + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_REDEEM_SKU_MANAGE) + @PostMapping("/delete") + public void delete(@RequestParam String id, @RequestParam(required = false) String storeId) { + service.delete(storeScopeService.resolveStoreId(storeId), id); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPointRedemptionController.java b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPointRedemptionController.java new file mode 100644 index 0000000..2b15b6a --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPointRedemptionController.java @@ -0,0 +1,80 @@ +package com.imyeyu.api.modules.gao.controller; + +import com.imyeyu.api.modules.gao.annotation.RequireGaoPermission; +import com.imyeyu.api.modules.gao.bean.GaoPermissionCode; +import com.imyeyu.api.modules.gao.entity.GaoPointRedemption; +import com.imyeyu.api.modules.gao.entity.GaoPointRedemptionReturn; +import com.imyeyu.api.modules.gao.service.GaoPointRedemptionService; +import com.imyeyu.api.modules.gao.service.GaoStoreScopeService; +import com.imyeyu.api.modules.gao.vo.GaoPointRedemptionCreateRequest; +import com.imyeyu.api.modules.gao.vo.GaoPointRedemptionPage; +import com.imyeyu.api.modules.gao.vo.GaoPointRedemptionReturnRequest; +import com.imyeyu.java.bean.timi.TimiException; +import com.imyeyu.spring.annotation.AOPLog; +import com.imyeyu.spring.annotation.RequestRateLimit; +import com.imyeyu.spring.bean.PageResult; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/// GAO 积分兑换单接口 +/// +/// @author Codex +/// @since 2026-09-14 +@RestController +@RequiredArgsConstructor +@RequestMapping("/gao/point/redemption") +public class GaoPointRedemptionController { + + private final GaoPointRedemptionService service; + private final GaoStoreScopeService storeScopeService; + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_REDEMPTION_CREATE) + @PostMapping("/list") + public PageResult list(@RequestBody GaoPointRedemptionPage page) { + TimiException.required(page, "not found redemption page"); + page.setStoreId(storeScopeService.resolveStoreId(page.getStoreId())); + return service.pageByQuery(page); + } + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_REDEMPTION_CREATE) + @PostMapping("/detail") + public GaoPointRedemption detail(@RequestParam String id, @RequestParam(required = false) String storeId) { + return service.detail(storeScopeService.resolveStoreId(storeId), id); + } + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_REDEMPTION_CREATE) + @PostMapping("/create") + public GaoPointRedemption create(@RequestBody GaoPointRedemptionCreateRequest request) { + TimiException.required(request, "not found redemption request"); + request.setStoreId(storeScopeService.resolveStoreId(request.getStoreId())); + return service.create(request); + } + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_REDEMPTION_RETURN) + @PostMapping("/return") + public GaoPointRedemption returnRedemption(@RequestBody GaoPointRedemptionReturnRequest request) { + return service.returnRedemption(request); + } + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_REDEMPTION_RETURN) + @PostMapping("/return/list") + public List returnList(@RequestParam String redemptionId, @RequestParam(required = false) String storeId) { + return service.listReturns(storeScopeService.resolveStoreId(storeId), redemptionId); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointLedger.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointLedger.java index 97a385c..1cb8e4d 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointLedger.java +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointLedger.java @@ -47,7 +47,10 @@ public class GaoPointLedger extends UUIDEntity { EVENT_RECORD, /// 订单首次全额实收 - SALES_ORDER_PAID + SALES_ORDER_PAID, + + /// 积分兑换 + POINT_REDEMPTION } /// 客户积分账户 ID diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointRedeemSku.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointRedeemSku.java new file mode 100644 index 0000000..90a44f3 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointRedeemSku.java @@ -0,0 +1,78 @@ +package com.imyeyu.api.modules.gao.entity; + +import com.imyeyu.spring.annotation.table.Transient; +import com.imyeyu.spring.entity.UUIDEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; + +/// GAO 积分兑换 SKU 配置 +/// +/// @author Codex +/// @since 2026-09-14 +@Data +@EqualsAndHashCode(callSuper = true) +public class GaoPointRedeemSku extends UUIDEntity { + + /// 配置状态 + public enum Status { + + /// 草稿 + DRAFT, + + /// 启用 + ACTIVE, + + /// 停用 + INACTIVE, + + /// 删除 + DELETED + } + + /// 门店 ID + private String storeId; + + /// SKU ID + private String skuId; + + /// SKU 单位 ID + private String skuUnitId; + + /// 配置状态 + private Status status; + + /// 每个业务单位默认所需积分 + private Long defaultPointCost; + + /// 生效开始时间戳,毫秒 + private Long beginAt; + + /// 生效结束时间戳,毫秒 + private Long endAt; + + /// 每客户累计最多兑换数量 + private BigDecimal maxTotalQuantity; + + /// 每客户自然日最多兑换数量 + private BigDecimal maxDailyQuantity; + + /// 每客户自然月最多兑换数量 + private BigDecimal maxMonthlyQuantity; + + /// 备注 + private String remark; + + /// 产品 + @Transient + private GaoProduct product; + + /// SKU + @Transient + private GaoProductSku sku; + + /// SKU 单位 + @Transient + private GaoProductSkuUnit unit; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointRedemption.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointRedemption.java new file mode 100644 index 0000000..375932d --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointRedemption.java @@ -0,0 +1,123 @@ +package com.imyeyu.api.modules.gao.entity; + +import com.imyeyu.api.modules.common.entity.Attachment; +import com.imyeyu.spring.annotation.table.Transient; +import com.imyeyu.spring.entity.UUIDEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.util.List; + +/// GAO 积分兑换单 +/// +/// @author Codex +/// @since 2026-09-14 +@Data +@EqualsAndHashCode(callSuper = true) +public class GaoPointRedemption extends UUIDEntity { + + /// 兑换单状态 + public enum Status { + + /// 已完成兑换 + COMPLETED, + + /// 已部分退货 + PARTIALLY_RETURNED, + + /// 已全部退货 + FULLY_RETURNED + } + + /// 门店 ID + private String storeId; + + /// 客户 ID + private String customerId; + + /// 创建幂等号 + private String requestNo; + + /// 兑换配置 ID 快照 + private String redeemSkuIdSnapshot; + + /// 产品 ID 快照 + private String productIdSnapshot; + + /// 产品名称快照 + private String productNameSnapshot; + + /// 产品类型快照 + private GaoProduct.ProductType productTypeSnapshot; + + /// SKU ID 快照 + private String skuIdSnapshot; + + /// SKU 编码快照 + private String skuCodeSnapshot; + + /// SKU 名称快照 + private String skuNameSnapshot; + + /// SKU 规格结构快照 + private com.fasterxml.jackson.databind.JsonNode skuSpecSnapshot; + + /// SKU 单位 ID 快照 + private String skuUnitIdSnapshot; + + /// 单位名称快照 + private String unitSnapshot; + + /// 每个业务单位基础数量快照 + private BigDecimal baseQuantityPerUnitSnapshot; + + /// true 为允许小数数量快照 + private Boolean allowDecimalQuantitySnapshot; + + /// 兑换业务数量 + private BigDecimal quantity; + + /// 兑换基础数量 + private BigDecimal baseQuantity; + + /// 默认总积分 + private Long defaultPointCost; + + /// 实际扣除总积分 + private Long actualPointCost; + + /// 累计已退业务数量 + private BigDecimal returnedQuantity; + + /// 累计已返积分 + private Long returnedPointCost; + + /// 兑换单状态 + private Status status; + + /// 兑换操作人 + private String operatorUserId; + + /// 兑换完成时间 + private Long redeemedAt; + + /// 备注 + private String remark; + + /// 客户 + @Transient + private GaoCustomer customer; + + /// 产品类型 + @Transient + private GaoProduct.ProductType productType; + + /// 兑换照片 + @Transient + private List attachmentList; + + /// 退货记录 + @Transient + private List returnList; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointRedemptionReturn.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointRedemptionReturn.java new file mode 100644 index 0000000..be76bf1 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointRedemptionReturn.java @@ -0,0 +1,50 @@ +package com.imyeyu.api.modules.gao.entity; + +import com.imyeyu.api.modules.common.entity.Attachment; +import com.imyeyu.spring.annotation.table.Transient; +import com.imyeyu.spring.entity.UUIDEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.util.List; + +/// GAO 积分兑换退货记录 +/// +/// @author Codex +/// @since 2026-09-14 +@Data +@EqualsAndHashCode(callSuper = true) +public class GaoPointRedemptionReturn extends UUIDEntity { + + /// 兑换单 ID + private String redemptionId; + + /// 退货幂等号 + private String requestNo; + + /// 本次退货业务数量 + private BigDecimal quantity; + + /// 本次退货基础数量 + private BigDecimal baseQuantity; + + /// 本次实际返还积分 + private Long pointCost; + + /// 实物退货入库流水 ID + private String stockRecordId; + + /// 退货操作人 + private String operatorUserId; + + /// 退货时间 + private Long returnedAt; + + /// 退货原因和备注 + private String remark; + + /// 退货照片 + @Transient + private List attachmentList; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoProductStockRecord.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoProductStockRecord.java index e1f0ded..02fb527 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoProductStockRecord.java +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoProductStockRecord.java @@ -27,7 +27,10 @@ public class GaoProductStockRecord implements IDEntity, Creatable { PRODUCT_STOCK, /// 订单履约库存操作 - ORDER_FULFILLMENT + ORDER_FULFILLMENT, + + /// 积分兑换库存操作 + POINT_REDEMPTION } /// 库存操作类型 @@ -57,6 +60,12 @@ public class GaoProductStockRecord implements IDEntity, Creatable { /// 履约冲正入库 ORDER_REVOKE, + /// 积分兑换实物出库 + POINT_REDEMPTION_OUT, + + /// 积分兑换退货入库 + POINT_REDEMPTION_RETURN_IN, + /// 盘盈 STOCKTAKE_GAIN, diff --git a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointRedeemSkuMapper.java b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointRedeemSkuMapper.java new file mode 100644 index 0000000..b4eb63d --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointRedeemSkuMapper.java @@ -0,0 +1,33 @@ +package com.imyeyu.api.modules.gao.mapper; + +import com.imyeyu.api.modules.gao.entity.GaoPointRedeemSku; +import com.imyeyu.api.modules.gao.vo.GaoPointRedeemSkuPage; +import com.imyeyu.spring.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/// GAO 积分兑换 SKU 配置 Mapper +/// +/// @author Codex +/// @since 2026-09-14 +public interface GaoPointRedeemSkuMapper extends BaseMapper { + + /// 按门店和配置 ID 查询 + GaoPointRedeemSku selectByStoreIdAndId(@Param("storeId") String storeId, @Param("id") String id); + + /// 按门店、SKU 和单位查询 + GaoPointRedeemSku selectByStoreSkuUnit(@Param("storeId") String storeId, @Param("skuId") String skuId, @Param("skuUnitId") String skuUnitId); + + /// 锁定兑换 SKU 配置 + GaoPointRedeemSku selectForUpdate(@Param("id") String id); + + /// 按条件统计配置数量 + long countByQuery(GaoPointRedeemSkuPage page); + + /// 按条件分页查询配置 + List selectByQuery(GaoPointRedeemSkuPage page); + + /// 更新兑换 SKU 配置,可清空可空字段 + void updateConfig(GaoPointRedeemSku config); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointRedemptionMapper.java b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointRedemptionMapper.java new file mode 100644 index 0000000..e75f5e0 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointRedemptionMapper.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.mapper; + +import com.imyeyu.api.modules.gao.entity.GaoPointRedemption; +import com.imyeyu.api.modules.gao.vo.GaoPointRedemptionPage; +import com.imyeyu.spring.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; + +import java.math.BigDecimal; +import java.util.List; + +/// GAO 积分兑换单 Mapper +/// +/// @author Codex +/// @since 2026-09-14 +public interface GaoPointRedemptionMapper extends BaseMapper { + + /// 按门店和创建幂等号查询兑换单 + GaoPointRedemption selectByStoreIdAndRequestNo(@Param("storeId") String storeId, @Param("requestNo") String requestNo); + + /// 按门店和 ID 查询兑换单 + GaoPointRedemption selectByStoreIdAndId(@Param("storeId") String storeId, @Param("id") String id); + + /// 锁定兑换单 + GaoPointRedemption selectForUpdate(@Param("id") String id); + + /// 按条件统计兑换单 + long countByQuery(GaoPointRedemptionPage page); + + /// 按条件分页查询兑换单 + List selectByQuery(GaoPointRedemptionPage page); + + /// 统计客户指定 SKU 的有效兑换数量 + BigDecimal sumValidQuantity(@Param("storeId") String storeId, @Param("customerId") String customerId, + @Param("skuId") String skuId, @Param("beginAt") Long beginAt, @Param("endAt") Long endAt); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointRedemptionReturnMapper.java b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointRedemptionReturnMapper.java new file mode 100644 index 0000000..4c421f9 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointRedemptionReturnMapper.java @@ -0,0 +1,26 @@ +package com.imyeyu.api.modules.gao.mapper; + +import com.imyeyu.api.modules.gao.entity.GaoPointRedemptionReturn; +import com.imyeyu.spring.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/// GAO 积分兑换退货 Mapper +/// +/// @author Codex +/// @since 2026-09-14 +public interface GaoPointRedemptionReturnMapper extends BaseMapper { + + /// 按兑换单查询退货记录 + List selectByRedemptionId(@Param("redemptionId") String redemptionId); + + /// 批量按兑换单查询退货记录 + List selectByRedemptionIdList(@Param("redemptionIdList") List redemptionIdList); + + /// 按兑换单和退货幂等号查询 + GaoPointRedemptionReturn selectByRedemptionIdAndRequestNo(@Param("redemptionId") String redemptionId, @Param("requestNo") String requestNo); + + /// 锁定退货记录 + GaoPointRedemptionReturn selectForUpdate(@Param("id") String id); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointLedgerService.java b/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointLedgerService.java index 78708b1..36e5cbc 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointLedgerService.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointLedgerService.java @@ -53,6 +53,31 @@ public interface GaoPointLedgerService extends BaseService pageByQuery(GaoPointRedeemSkuPage page); + + /// 查询兑换 SKU 配置详情 + GaoPointRedeemSku detail(String storeId, String id); + + /// 查询并锁定兑换 SKU 配置 + GaoPointRedeemSku getForUpdate(String id); + + /// 创建兑换 SKU 配置 + GaoPointRedeemSku create(GaoPointRedeemSkuRequest request); + + /// 修改兑换 SKU 配置 + GaoPointRedeemSku update(GaoPointRedeemSkuRequest request); + + /// 停用兑换 SKU 配置 + void disable(String storeId, String id); + + /// 标记删除兑换 SKU 配置 + void delete(String storeId, String id); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointRedemptionService.java b/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointRedemptionService.java new file mode 100644 index 0000000..72f265f --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointRedemptionService.java @@ -0,0 +1,32 @@ +package com.imyeyu.api.modules.gao.service; + +import com.imyeyu.api.modules.gao.entity.GaoPointRedemption; +import com.imyeyu.api.modules.gao.entity.GaoPointRedemptionReturn; +import com.imyeyu.api.modules.gao.vo.GaoPointRedemptionCreateRequest; +import com.imyeyu.api.modules.gao.vo.GaoPointRedemptionPage; +import com.imyeyu.api.modules.gao.vo.GaoPointRedemptionReturnRequest; +import com.imyeyu.spring.bean.PageResult; + +import java.util.List; + +/// GAO 积分兑换业务服务 +/// +/// @author Codex +/// @since 2026-09-14 +public interface GaoPointRedemptionService { + + /// 分页查询兑换单 + PageResult pageByQuery(GaoPointRedemptionPage page); + + /// 查询兑换单详情 + GaoPointRedemption detail(String storeId, String id); + + /// 创建并完成积分兑换 + GaoPointRedemption create(GaoPointRedemptionCreateRequest request); + + /// 发起部分或全部退货 + GaoPointRedemption returnRedemption(GaoPointRedemptionReturnRequest request); + + /// 查询兑换单退货记录 + List listReturns(String storeId, String redemptionId); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/GaoProductStockService.java b/src/main/java/com/imyeyu/api/modules/gao/service/GaoProductStockService.java index f85156c..3037e78 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/GaoProductStockService.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/GaoProductStockService.java @@ -1,6 +1,7 @@ package com.imyeyu.api.modules.gao.service; import com.imyeyu.api.modules.gao.entity.GaoProductStock; +import com.imyeyu.api.modules.gao.entity.GaoProductStockRecord; import com.imyeyu.api.modules.gao.vo.GaoProductStockChangeRequest; import com.imyeyu.api.modules.gao.vo.GaoProductStockManualRevokeRequest; import com.imyeyu.api.modules.gao.vo.GaoProductStockPage; @@ -60,4 +61,10 @@ public interface GaoProductStockService { /// 履约库存冲正入库 List revokeForFulfillment(String storeId, String bizId, List items, String requestNo, String operatorUserId); + + /// 为积分兑换实物出库 + GaoProductStockRecord outForRedemption(String storeId, String redemptionId, GaoStockChangeItem item, String requestNo, String operatorUserId); + + /// 为积分兑换退货入库 + GaoProductStockRecord inForRedemptionReturn(String storeId, String returnId, GaoStockChangeItem item, String requestNo, String operatorUserId); } diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointLedgerServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointLedgerServiceImplement.java index 06c77d0..e29c2ef 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointLedgerServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointLedgerServiceImplement.java @@ -124,6 +124,37 @@ public class GaoPointLedgerServiceImplement extends AbstractEntityService listRuleChangeByTriggerSource(GaoPointLedger.TriggerSourceType triggerSourceType, String triggerSourceId) { TimiException.required(triggerSourceType, "not found triggerSourceType"); @@ -186,6 +217,7 @@ public class GaoPointLedgerServiceImplement extends AbstractEntityService "GAO_EVENT_RECORD:%s:RULE:%s:EARN".formatted(triggerSourceId, ruleId); case SALES_ORDER_PAID -> "GAO_SALES_ORDER:%s:RULE:%s:EARN".formatted(triggerSourceId, ruleId); + case POINT_REDEMPTION -> "GAO_POINT_REDEMPTION:%s:RULE:%s:EARN".formatted(triggerSourceId, ruleId); }; } diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointRedeemSkuServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointRedeemSkuServiceImplement.java new file mode 100644 index 0000000..06bed54 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointRedeemSkuServiceImplement.java @@ -0,0 +1,210 @@ +package com.imyeyu.api.modules.gao.service.implement; + +import com.imyeyu.api.config.dbsource.TimiServerDBConfig; +import com.imyeyu.api.modules.gao.entity.GaoProduct; +import com.imyeyu.api.modules.gao.entity.GaoProductSku; +import com.imyeyu.api.modules.gao.entity.GaoProductSkuUnit; +import com.imyeyu.api.modules.gao.entity.GaoPointRedeemSku; +import com.imyeyu.api.modules.gao.mapper.GaoPointRedeemSkuMapper; +import com.imyeyu.api.modules.gao.service.GaoPointRedeemSkuService; +import com.imyeyu.api.modules.gao.service.GaoProductService; +import com.imyeyu.api.modules.gao.service.GaoProductSkuService; +import com.imyeyu.api.modules.gao.service.GaoStoreScopeService; +import com.imyeyu.api.modules.gao.vo.GaoPointRedeemSkuPage; +import com.imyeyu.api.modules.gao.vo.GaoPointRedeemSkuRequest; +import com.imyeyu.java.TimiJava; +import com.imyeyu.java.bean.timi.TimiException; +import com.imyeyu.spring.bean.PageResult; +import com.imyeyu.utils.Time; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/// GAO 积分兑换 SKU 配置服务实现 +/// +/// @author Codex +/// @since 2026-09-14 +@Service +@RequiredArgsConstructor +public class GaoPointRedeemSkuServiceImplement implements GaoPointRedeemSkuService { + + private final GaoPointRedeemSkuMapper mapper; + private final GaoProductService productService; + private final GaoProductSkuService skuService; + private final GaoStoreScopeService storeScopeService; + + @Override + public PageResult pageByQuery(GaoPointRedeemSkuPage page) { + TimiException.required(page, "not found redeem sku page"); + PageResult result = new PageResult<>(); + result.setTotal(mapper.countByQuery(page)); + List list = mapper.selectByQuery(page); + fillTransient(list); + result.setList(list); + return result; + } + + @Override + public GaoPointRedeemSku detail(String storeId, String id) { + TimiException.required(storeId, "not found storeId"); + TimiException.required(id, "not found redeemSkuId"); + GaoPointRedeemSku result = storeId == null ? mapper.select(id) : mapper.selectByStoreIdAndId(storeId, id); + TimiException.required(result, "not found redeem sku"); + fillTransient(List.of(result)); + return result; + } + + @Override + public GaoPointRedeemSku getForUpdate(String id) { + TimiException.required(id, "not found redeemSkuId"); + GaoPointRedeemSku result = mapper.selectForUpdate(id); + TimiException.required(result, "not found redeem sku"); + return result; + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public GaoPointRedeemSku create(GaoPointRedeemSkuRequest request) { + TimiException.required(request, "not found redeem sku request"); + String storeId = storeScopeService.resolveStoreId(request.getStoreId()); + GaoPointRedeemSku config = new GaoPointRedeemSku(); + config.setStoreId(storeId); + config.setSkuId(request.getSkuId()); + config.setSkuUnitId(request.getSkuUnitId()); + config.setStatus(TimiJava.defaultIfNull(request.getStatus(), GaoPointRedeemSku.Status.DRAFT)); + config.setDefaultPointCost(request.getDefaultPointCost()); + config.setBeginAt(request.getBeginAt()); + config.setEndAt(request.getEndAt()); + config.setMaxTotalQuantity(request.getMaxTotalQuantity()); + config.setMaxDailyQuantity(request.getMaxDailyQuantity()); + config.setMaxMonthlyQuantity(request.getMaxMonthlyQuantity()); + config.setRemark(request.getRemark()); + validate(config, config.getStatus() == GaoPointRedeemSku.Status.ACTIVE); + GaoPointRedeemSku existing = mapper.selectByStoreSkuUnit(storeId, config.getSkuId(), config.getSkuUnitId()); + TimiException.requiredNull(existing, "该 SKU 单位已经配置积分兑换"); + config.setId(UUID.randomUUID().toString()); + config.setCreatedAt(Time.now()); + mapper.insert(config); + return detail(storeId, config.getId()); + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public GaoPointRedeemSku update(GaoPointRedeemSkuRequest request) { + TimiException.required(request, "not found redeem sku request"); + TimiException.required(request.getId(), "not found redeemSkuId"); + GaoPointRedeemSku current = mapper.select(request.getId()); + TimiException.required(current, "not found redeem sku"); + storeScopeService.checkStoreId(current.getStoreId()); + GaoPointRedeemSku config = new GaoPointRedeemSku(); + config.setId(current.getId()); + config.setStoreId(current.getStoreId()); + config.setSkuId(TimiJava.defaultIfNull(request.getSkuId(), current.getSkuId())); + config.setSkuUnitId(TimiJava.defaultIfNull(request.getSkuUnitId(), current.getSkuUnitId())); + config.setStatus(TimiJava.defaultIfNull(request.getStatus(), current.getStatus())); + config.setDefaultPointCost(TimiJava.defaultIfNull(request.getDefaultPointCost(), current.getDefaultPointCost())); + config.setBeginAt(request.getBeginAt()); + config.setEndAt(request.getEndAt()); + config.setMaxTotalQuantity(request.getMaxTotalQuantity()); + config.setMaxDailyQuantity(request.getMaxDailyQuantity()); + config.setMaxMonthlyQuantity(request.getMaxMonthlyQuantity()); + config.setRemark(request.getRemark()); + validate(config, config.getStatus() == GaoPointRedeemSku.Status.ACTIVE); + GaoPointRedeemSku existing = mapper.selectByStoreSkuUnit(config.getStoreId(), config.getSkuId(), config.getSkuUnitId()); + TimiException.requiredTrue(existing == null || existing.getId().equals(config.getId()), "该 SKU 单位已经配置积分兑换"); + config.setUpdatedAt(Time.now()); + mapper.updateConfig(config); + return detail(config.getStoreId(), config.getId()); + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public void disable(String storeId, String id) { + GaoPointRedeemSku config = storeId == null ? mapper.select(id) : mapper.selectByStoreIdAndId(storeId, id); + TimiException.required(config, "not found redeem sku"); + config.setStatus(GaoPointRedeemSku.Status.INACTIVE); + config.setUpdatedAt(Time.now()); + mapper.updateSelective(config); + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public void delete(String storeId, String id) { + GaoPointRedeemSku config = storeId == null ? mapper.select(id) : mapper.selectByStoreIdAndId(storeId, id); + TimiException.required(config, "not found redeem sku"); + config.setStatus(GaoPointRedeemSku.Status.DELETED); + config.setUpdatedAt(Time.now()); + mapper.updateSelective(config); + } + + private void validate(GaoPointRedeemSku config, boolean active) { + TimiException.required(config.getStoreId(), "not found storeId"); + TimiException.required(config.getSkuId(), "not found skuId"); + TimiException.required(config.getSkuUnitId(), "not found skuUnitId"); + TimiException.required(config.getStatus(), "not found status"); + TimiException.required(config.getDefaultPointCost(), "not found defaultPointCost"); + TimiException.requiredTrue(0 < config.getDefaultPointCost(), "默认积分必须大于 0"); + if (config.getBeginAt() != null && config.getEndAt() != null) { + TimiException.requiredTrue(config.getBeginAt() < config.getEndAt(), "生效开始时间必须早于结束时间"); + } + validateLimit(config.getMaxTotalQuantity(), "累计兑换上限"); + validateLimit(config.getMaxDailyQuantity(), "自然日兑换上限"); + validateLimit(config.getMaxMonthlyQuantity(), "自然月兑换上限"); + + GaoProductSku sku = skuService.get(config.getSkuId()); + TimiException.required(sku, "SKU 不存在"); + TimiException.requiredTrue(config.getStoreId().equals(sku.getStoreId()), "SKU 不属于当前门店"); + GaoProduct product = productService.get(sku.getProductId()); + TimiException.required(product, "产品不存在"); + TimiException.requiredTrue(config.getStoreId().equals(product.getStoreId()), "产品不属于当前门店"); + TimiException.requiredTrue(product.getProductType() == GaoProduct.ProductType.GOODS + || product.getProductType() == GaoProduct.ProductType.SERVICE, "只有实物或服务产品可以配置积分兑换"); + GaoProductSkuUnit unit = skuService.listUnitBySkuId(sku.getId()).stream() + .filter(item -> item.getId().equals(config.getSkuUnitId())).findFirst().orElse(null); + TimiException.required(unit, "SKU 单位不存在"); + if (active) { + TimiException.requiredTrue(product.getStatus() == GaoProduct.Status.ACTIVE, "产品已停用"); + TimiException.requiredTrue(sku.getStatus() == GaoProductSku.Status.ACTIVE, "SKU 已停用"); + if (product.getProductType() == GaoProduct.ProductType.GOODS) { + TimiException.requiredTrue(Boolean.TRUE.equals(sku.getStockEnabled()), "实物 SKU 必须启用库存管理"); + } + } + } + + private void validateLimit(BigDecimal value, String name) { + if (value == null) { + return; + } + TimiException.requiredTrue(0 < value.signum(), name + "必须大于 0"); + TimiException.requiredTrue(value.stripTrailingZeros().scale() <= 3, name + "最多保留三位小数"); + } + + private void fillTransient(List list) { + if (list == null || list.isEmpty()) { + return; + } + Map skuMap = new HashMap<>(); + for (GaoProductSku sku : skuService.listByIdList(list.stream().map(GaoPointRedeemSku::getSkuId).distinct().toList())) { + skuMap.put(sku.getId(), sku); + } + Map productMap = productService.listByIdList(skuMap.values().stream().map(GaoProductSku::getProductId).distinct().toList()) + .stream().collect(java.util.stream.Collectors.toMap(GaoProduct::getId, item -> item)); + Map> unitMap = new HashMap<>(); + for (String skuId : skuMap.keySet()) { + unitMap.put(skuId, skuService.listUnitBySkuId(skuId)); + } + for (GaoPointRedeemSku item : list) { + GaoProductSku sku = skuMap.get(item.getSkuId()); + item.setSku(sku); + item.setProduct(sku == null ? null : productMap.get(sku.getProductId())); + item.setUnit(unitMap.getOrDefault(item.getSkuId(), List.of()).stream() + .filter(unit -> unit.getId().equals(item.getSkuUnitId())).findFirst().orElse(null)); + } + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointRedemptionServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointRedemptionServiceImplement.java new file mode 100644 index 0000000..92b167a --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointRedemptionServiceImplement.java @@ -0,0 +1,445 @@ +package com.imyeyu.api.modules.gao.service.implement; + +import com.imyeyu.api.config.dbsource.TimiServerDBConfig; +import com.imyeyu.api.modules.common.entity.Attachment; +import com.imyeyu.api.modules.common.service.AttachmentService; +import com.imyeyu.api.modules.gao.bean.GaoPermissionCode; +import com.imyeyu.api.modules.gao.entity.GaoCustomer; +import com.imyeyu.api.modules.gao.entity.GaoPointAccount; +import com.imyeyu.api.modules.gao.entity.GaoPointRedeemSku; +import com.imyeyu.api.modules.gao.entity.GaoPointRedemption; +import com.imyeyu.api.modules.gao.entity.GaoPointRedemptionReturn; +import com.imyeyu.api.modules.gao.entity.GaoProduct; +import com.imyeyu.api.modules.gao.entity.GaoProductSku; +import com.imyeyu.api.modules.gao.entity.GaoProductSkuUnit; +import com.imyeyu.api.modules.gao.entity.GaoProductStockRecord; +import com.imyeyu.api.modules.gao.mapper.GaoPointRedemptionMapper; +import com.imyeyu.api.modules.gao.mapper.GaoPointRedemptionReturnMapper; +import com.imyeyu.api.modules.gao.service.GaoCustomerService; +import com.imyeyu.api.modules.gao.service.GaoPointAccountService; +import com.imyeyu.api.modules.gao.service.GaoPointLedgerService; +import com.imyeyu.api.modules.gao.service.GaoPointRedeemSkuService; +import com.imyeyu.api.modules.gao.service.GaoPointRedemptionService; +import com.imyeyu.api.modules.gao.service.GaoProductService; +import com.imyeyu.api.modules.gao.service.GaoProductSkuService; +import com.imyeyu.api.modules.gao.service.GaoProductStockService; +import com.imyeyu.api.modules.gao.service.GaoStoreScopeService; +import com.imyeyu.api.modules.gao.vo.GaoPointRedemptionCreateRequest; +import com.imyeyu.api.modules.gao.vo.GaoPointRedemptionPage; +import com.imyeyu.api.modules.gao.vo.GaoPointRedemptionReturnRequest; +import com.imyeyu.api.modules.gao.vo.GaoStockChangeItem; +import com.imyeyu.api.modules.user.service.PermissionChecker; +import com.imyeyu.api.modules.user.service.UserLoginService; +import com.imyeyu.api.bean.ModuleCode; +import com.imyeyu.java.TimiJava; +import com.imyeyu.java.bean.timi.TimiCode; +import com.imyeyu.java.bean.timi.TimiException; +import com.imyeyu.spring.bean.PageResult; +import com.imyeyu.utils.Time; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +/// GAO 积分兑换业务服务实现 +/// +/// @author Codex +/// @since 2026-09-14 +@Service +@RequiredArgsConstructor +public class GaoPointRedemptionServiceImplement implements GaoPointRedemptionService { + + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final BigDecimal ZERO = BigDecimal.ZERO; + + private final GaoPointRedemptionMapper mapper; + private final GaoPointRedemptionReturnMapper returnMapper; + private final GaoPointRedeemSkuService redeemSkuService; + private final GaoCustomerService customerService; + private final GaoProductService productService; + private final GaoProductSkuService skuService; + private final GaoPointAccountService pointAccountService; + private final GaoPointLedgerService pointLedgerService; + private final GaoProductStockService stockService; + private final GaoStoreScopeService storeScopeService; + private final AttachmentService attachmentService; + private final UserLoginService userLoginService; + private final PermissionChecker permissionChecker; + + @Override + public PageResult pageByQuery(GaoPointRedemptionPage page) { + TimiException.required(page, "not found redemption page"); + PageResult result = new PageResult<>(); + result.setTotal(mapper.countByQuery(page)); + List list = mapper.selectByQuery(page); + fillTransient(list); + result.setList(list); + return result; + } + + @Override + public GaoPointRedemption detail(String storeId, String id) { + TimiException.required(id, "not found redemptionId"); + GaoPointRedemption result = storeId == null ? mapper.select(id) : mapper.selectByStoreIdAndId(storeId, id); + TimiException.required(result, "not found redemption"); + fillTransient(List.of(result)); + return result; + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public GaoPointRedemption create(GaoPointRedemptionCreateRequest request) { + TimiException.required(request, "not found redemption request"); + String requestNo = requiredText(request.getRequestNo(), "not found requestNo"); + String storeId = storeScopeService.resolveStoreId(request.getStoreId()); + TimiException.required(storeId, "not found storeId"); + String customerId = requiredText(request.getCustomerId(), "not found customerId"); + String redeemSkuId = requiredText(request.getRedeemSkuId(), "not found redeemSkuId"); + BigDecimal quantity = validateQuantity(request.getQuantity(), null); + GaoPointRedemption existing = mapper.selectByStoreIdAndRequestNo(storeId, requestNo); + if (existing != null) { + verifyExisting(existing, customerId, redeemSkuId, quantity, request.getActualPointCost()); + return detail(storeId, existing.getId()); + } + TimiException.requiredTrue(hasPhoto(request.getAttachmentList()), "兑换必须上传至少一张照片"); + + GaoCustomer customer = customerService.get(customerId); + TimiException.required(customer, "客户不存在"); + TimiException.requiredTrue(storeId.equals(customer.getStoreId()), "客户不属于当前门店"); + GaoPointRedeemSku preview = redeemSkuService.detail(storeId, redeemSkuId); + long now = Time.now(); + validateActiveConfig(preview, now); + + // 同一客户的兑换限额由积分账户锁串行化,锁顺序为积分账户 → 配置和库存 + GaoPointAccount account = pointAccountService.getOrCreateForUpdate(storeId, customerId); + GaoPointRedeemSku config = redeemSkuService.getForUpdate(redeemSkuId); + TimiException.requiredTrue(storeId.equals(config.getStoreId()), "兑换配置不属于当前门店"); + validateActiveConfig(config, now); + GaoProductSkuSelection selection = loadSelection(config, storeId); + quantity = validateQuantity(request.getQuantity(), selection.sku()); + BigDecimal baseQuantity = baseQuantity(quantity, selection.unit().getBaseQuantityPerUnit()); + long defaultPointCost = totalPointCost(config.getDefaultPointCost(), quantity); + long actualPointCost = resolveActualPointCost(request.getActualPointCost(), defaultPointCost); + if (request.getActualPointCost() != null) { + permissionChecker.checkAll(ModuleCode.GAO, GaoPermissionCode.POINT_REDEMPTION_POINT_ADJUST.getValue()); + } + checkLimit(storeId, customerId, config, selection.sku().getId(), quantity, now); + + String operatorUserId = userLoginService.getRequireLoginUserId(); + GaoPointRedemption redemption = new GaoPointRedemption(); + redemption.setId(UUID.randomUUID().toString()); + redemption.setStoreId(storeId); + redemption.setCustomerId(customerId); + redemption.setRequestNo(requestNo); + redemption.setRedeemSkuIdSnapshot(config.getId()); + redemption.setProductIdSnapshot(selection.product().getId()); + redemption.setProductNameSnapshot(selection.product().getName()); + redemption.setProductTypeSnapshot(selection.product().getProductType()); + redemption.setSkuIdSnapshot(selection.sku().getId()); + redemption.setSkuCodeSnapshot(selection.sku().getCode()); + redemption.setSkuNameSnapshot(selection.sku().getName()); + redemption.setSkuSpecSnapshot(skuService.getSkuSpecSnapshot(selection.sku())); + redemption.setSkuUnitIdSnapshot(selection.unit().getId()); + redemption.setUnitSnapshot(selection.unit().getName()); + redemption.setBaseQuantityPerUnitSnapshot(selection.unit().getBaseQuantityPerUnit()); + redemption.setAllowDecimalQuantitySnapshot(selection.sku().getAllowDecimalQuantity()); + redemption.setQuantity(quantity); + redemption.setBaseQuantity(baseQuantity); + redemption.setDefaultPointCost(defaultPointCost); + redemption.setActualPointCost(actualPointCost); + redemption.setReturnedQuantity(ZERO); + redemption.setReturnedPointCost(0L); + redemption.setStatus(GaoPointRedemption.Status.COMPLETED); + redemption.setOperatorUserId(operatorUserId); + redemption.setRedeemedAt(now); + redemption.setRemark(request.getRemark()); + redemption.setCreatedAt(now); + mapper.insert(redemption); + attachmentService.updateByBizId(Attachment.BizType.GAO_POINT_REDEMPTION, redemption.getId(), request.getAttachmentList()); + + if (0 < actualPointCost) { + pointLedgerService.consumeForRedemption(storeId, customerId, redemption.getId(), actualPointCost, operatorUserId, request.getRemark()); + } + if (selection.product().getProductType() == GaoProduct.ProductType.GOODS) { + GaoStockChangeItem stockItem = stockItem(selection, quantity, baseQuantity); + stockService.outForRedemption(storeId, redemption.getId(), stockItem, requestNo, operatorUserId); + } + return detail(storeId, redemption.getId()); + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public GaoPointRedemption returnRedemption(GaoPointRedemptionReturnRequest request) { + TimiException.required(request, "not found redemption return request"); + String redemptionId = requiredText(request.getRedemptionId(), "not found redemptionId"); + String requestNo = requiredText(request.getRequestNo(), "not found requestNo"); + GaoPointRedemption preview = mapper.select(redemptionId); + TimiException.required(preview, "not found redemption"); + storeScopeService.checkStoreId(preview.getStoreId()); + // 与创建兑换保持一致,固定先锁积分账户,再锁兑换单和库存行 + pointAccountService.getOrCreateForUpdate(preview.getStoreId(), preview.getCustomerId()); + GaoPointRedemption redemption = mapper.selectForUpdate(redemptionId); + TimiException.required(redemption, "not found redemption"); + GaoPointRedemptionReturn existing = returnMapper.selectByRedemptionIdAndRequestNo(redemptionId, requestNo); + BigDecimal requestedQuantity = validateQuantity(request.getQuantity(), null); + if (existing != null) { + TimiException.requiredTrue(existing.getQuantity().compareTo(requestedQuantity) == 0 + && sameText(existing.getRemark(), request.getRemark()), "requestNo 对应退货内容不一致"); + return detail(redemption.getStoreId(), redemptionId); + } + TimiException.requiredTrue(hasPhoto(request.getAttachmentList()), "退货必须上传至少一张照片"); + BigDecimal quantity = requestedQuantity; + if (!Boolean.TRUE.equals(redemption.getAllowDecimalQuantitySnapshot())) { + TimiException.requiredTrue(quantity.stripTrailingZeros().scale() <= 0, "该兑换单不允许小数退货数量"); + } + BigDecimal remainingQuantity = redemption.getQuantity().subtract(redemption.getReturnedQuantity()); + TimiException.requiredTrue(quantity.compareTo(remainingQuantity) <= 0, "退货数量超过可退数量"); + BigDecimal baseQuantity = baseQuantity(quantity, redemption.getBaseQuantityPerUnitSnapshot()); + boolean lastReturn = quantity.compareTo(remainingQuantity) == 0; + long pointCost = returnPointCost(redemption, quantity, lastReturn); + String operatorUserId = userLoginService.getRequireLoginUserId(); + GaoPointRedemptionReturn returnRecord = new GaoPointRedemptionReturn(); + returnRecord.setId(UUID.randomUUID().toString()); + returnRecord.setRedemptionId(redemptionId); + returnRecord.setRequestNo(requestNo); + returnRecord.setQuantity(quantity); + returnRecord.setBaseQuantity(baseQuantity); + returnRecord.setPointCost(pointCost); + returnRecord.setOperatorUserId(operatorUserId); + returnRecord.setReturnedAt(Time.now()); + returnRecord.setRemark(request.getRemark()); + returnRecord.setCreatedAt(Time.now()); + returnMapper.insert(returnRecord); + attachmentService.updateByBizId(Attachment.BizType.GAO_POINT_REDEMPTION_RETURN, returnRecord.getId(), request.getAttachmentList()); + + if (redemption.getProductTypeSnapshot() == GaoProduct.ProductType.GOODS) { + GaoStockChangeItem stockItem = stockItem(redemption, quantity, baseQuantity); + GaoProductStockRecord stockRecord = stockService.inForRedemptionReturn(redemption.getStoreId(), returnRecord.getId(), stockItem, + requestNo, operatorUserId); + returnRecord.setStockRecordId(stockRecord.getId()); + returnRecord.setUpdatedAt(Time.now()); + returnMapper.updateSelective(returnRecord); + } + if (0 < pointCost) { + pointLedgerService.revokeForRedemption(redemption.getStoreId(), redemption.getCustomerId(), redemptionId, returnRecord.getId(), + pointCost, operatorUserId, request.getRemark()); + } + redemption.setReturnedQuantity(redemption.getReturnedQuantity().add(quantity)); + redemption.setReturnedPointCost(redemption.getReturnedPointCost() + pointCost); + redemption.setStatus(lastReturn ? GaoPointRedemption.Status.FULLY_RETURNED : GaoPointRedemption.Status.PARTIALLY_RETURNED); + redemption.setUpdatedAt(Time.now()); + mapper.updateSelective(redemption); + return detail(redemption.getStoreId(), redemptionId); + } + + @Override + public List listReturns(String storeId, String redemptionId) { + GaoPointRedemption redemption = storeId == null ? mapper.select(redemptionId) : mapper.selectByStoreIdAndId(storeId, redemptionId); + TimiException.required(redemption, "not found redemption"); + List list = returnMapper.selectByRedemptionId(redemptionId); + fillReturnAttachments(list); + return list; + } + + private void checkLimit(String storeId, String customerId, GaoPointRedeemSku config, String skuId, BigDecimal quantity, long now) { + checkOneLimit(config.getMaxTotalQuantity(), mapper.sumValidQuantity(storeId, customerId, skuId, null, null), quantity, "累计兑换数量已达上限"); + checkOneLimit(config.getMaxDailyQuantity(), mapper.sumValidQuantity(storeId, customerId, skuId, startOfDay(now), startOfNextDay(now) - 1), quantity, "今日兑换数量已达上限"); + checkOneLimit(config.getMaxMonthlyQuantity(), mapper.sumValidQuantity(storeId, customerId, skuId, startOfMonth(now), startOfNextMonth(now) - 1), quantity, "本月兑换数量已达上限"); + } + + private void checkOneLimit(BigDecimal limit, BigDecimal used, BigDecimal quantity, String message) { + if (limit != null) { + TimiException.requiredTrue(used.add(quantity).compareTo(limit) <= 0, message); + } + } + + private void validateActiveConfig(GaoPointRedeemSku config, long now) { + TimiException.required(config, "not found redeem sku"); + TimiException.requiredTrue(config.getStatus() == GaoPointRedeemSku.Status.ACTIVE, "兑换配置未启用"); + TimiException.requiredTrue(config.getBeginAt() == null || config.getBeginAt() <= now, "兑换配置尚未生效"); + TimiException.requiredTrue(config.getEndAt() == null || now <= config.getEndAt(), "兑换配置已失效"); + } + + private GaoProductSkuSelection loadSelection(GaoPointRedeemSku config, String storeId) { + GaoProductSku sku = skuService.get(config.getSkuId()); + TimiException.required(sku, "SKU 不存在"); + TimiException.requiredTrue(storeId.equals(sku.getStoreId()), "SKU 不属于当前门店"); + TimiException.requiredTrue(sku.getStatus() == GaoProductSku.Status.ACTIVE, "SKU 已停用"); + GaoProduct product = productService.get(sku.getProductId()); + TimiException.required(product, "产品不存在"); + TimiException.requiredTrue(storeId.equals(product.getStoreId()), "产品不属于当前门店"); + TimiException.requiredTrue(product.getStatus() == GaoProduct.Status.ACTIVE, "产品已停用"); + TimiException.requiredTrue(product.getProductType() == GaoProduct.ProductType.GOODS + || product.getProductType() == GaoProduct.ProductType.SERVICE, "只有实物或服务产品可以兑换"); + if (product.getProductType() == GaoProduct.ProductType.GOODS) { + TimiException.requiredTrue(Boolean.TRUE.equals(sku.getStockEnabled()), "实物 SKU 必须启用库存管理"); + } + GaoProductSkuUnit unit = skuService.listUnitBySkuId(sku.getId()).stream() + .filter(item -> item.getId().equals(config.getSkuUnitId())).findFirst().orElse(null); + TimiException.required(unit, "SKU 单位不存在"); + return new GaoProductSkuSelection(product, sku, unit); + } + + private BigDecimal validateQuantity(BigDecimal quantity, GaoProductSku sku) { + TimiException.required(quantity, "数量不能为空"); + TimiException.requiredTrue(0 < quantity.signum(), "数量必须大于 0"); + TimiException.requiredTrue(quantity.stripTrailingZeros().scale() <= 3, "数量最多保留三位小数"); + if (sku != null && !Boolean.TRUE.equals(sku.getAllowDecimalQuantity())) { + TimiException.requiredTrue(quantity.stripTrailingZeros().scale() <= 0, "该 SKU 不允许小数数量"); + } + return quantity; + } + + private BigDecimal baseQuantity(BigDecimal quantity, BigDecimal coefficient) { + TimiException.required(coefficient, "单位换算系数不能为空"); + TimiException.requiredTrue(0 < coefficient.signum(), "单位换算系数必须大于 0"); + BigDecimal result = quantity.multiply(coefficient); + TimiException.requiredTrue(result.stripTrailingZeros().scale() <= 3, "基础数量最多保留三位小数"); + return result; + } + + private long totalPointCost(long unitCost, BigDecimal quantity) { + try { + long result = BigDecimal.valueOf(unitCost).multiply(quantity).longValueExact(); + TimiException.requiredTrue(0 < result, "默认总积分必须大于 0"); + return result; + } catch (ArithmeticException e) { + throw new TimiException(TimiCode.ARG_BAD, "默认总积分溢出或不是整数"); + } + } + + private long resolveActualPointCost(Long actualPointCost, long defaultPointCost) { + long result = actualPointCost == null ? defaultPointCost : actualPointCost; + TimiException.requiredTrue(0 <= result && result <= defaultPointCost, "实际总积分必须在 0 到默认总积分之间"); + return result; + } + + private long returnPointCost(GaoPointRedemption redemption, BigDecimal quantity, boolean lastReturn) { + if (lastReturn) { + return redemption.getActualPointCost() - redemption.getReturnedPointCost(); + } + return BigDecimal.valueOf(redemption.getActualPointCost()).multiply(quantity) + .divide(redemption.getQuantity(), 0, RoundingMode.FLOOR).longValueExact(); + } + + private GaoStockChangeItem stockItem(GaoProductSkuSelection selection, BigDecimal quantity, BigDecimal baseQuantity) { + GaoStockChangeItem result = GaoStockChangeItem.of(selection.product(), selection.sku(), selection.unit(), quantity, baseQuantity); + result.setSkuSpecSnapshot(skuService.getSkuSpecSnapshot(selection.sku())); + return result; + } + + private GaoStockChangeItem stockItem(GaoPointRedemption redemption, BigDecimal quantity, BigDecimal baseQuantity) { + GaoStockChangeItem result = new GaoStockChangeItem(); + result.setSkuId(redemption.getSkuIdSnapshot()); + result.setSkuUnitId(redemption.getSkuUnitIdSnapshot()); + result.setProductIdSnapshot(redemption.getProductIdSnapshot()); + result.setProductNameSnapshot(redemption.getProductNameSnapshot()); + result.setSkuCodeSnapshot(redemption.getSkuCodeSnapshot()); + result.setSkuNameSnapshot(redemption.getSkuNameSnapshot()); + result.setSkuSpecSnapshot(redemption.getSkuSpecSnapshot()); + result.setUnitSnapshot(redemption.getUnitSnapshot()); + result.setBaseQuantityPerUnitSnapshot(redemption.getBaseQuantityPerUnitSnapshot()); + result.setQuantity(quantity); + result.setBaseQuantity(baseQuantity); + return result; + } + + private void verifyExisting(GaoPointRedemption existing, String customerId, String redeemSkuId, BigDecimal quantity, Long actualPointCost) { + Long expectedActual = actualPointCost; + if (expectedActual == null) { + expectedActual = existing.getActualPointCost().equals(existing.getDefaultPointCost()) ? existing.getDefaultPointCost() : Long.MIN_VALUE; + } + TimiException.requiredTrue(customerId.equals(existing.getCustomerId()) + && redeemSkuId.equals(existing.getRedeemSkuIdSnapshot()) + && quantity.compareTo(existing.getQuantity()) == 0 + && expectedActual.equals(existing.getActualPointCost()), "requestNo 对应兑换内容不一致"); + } + + private boolean hasPhoto(List list) { + for (Attachment item : TimiJava.safeIterable(list)) { + if (item != null && (item.getAttachType() == null || "PHOTO".equalsIgnoreCase(item.getAttachType()))) { + return true; + } + } + return false; + } + + private void fillTransient(List list) { + if (list == null || list.isEmpty()) { + return; + } + List idList = list.stream().map(GaoPointRedemption::getId).toList(); + Map> attachmentMap = attachmentService.mapByBizIdList(Attachment.BizType.GAO_POINT_REDEMPTION, idList); + Map customerMap = customerService.mapByIdList(list.stream().map(GaoPointRedemption::getCustomerId).distinct().toList()); + Map> customerAttachmentMap = attachmentService.mapByBizIdList( + Attachment.BizType.GAO_CUSTOMER, + customerMap.keySet().stream().toList()); + List returnList = returnMapper.selectByRedemptionIdList(idList); + Map> returnMap = returnList.stream() + .collect(Collectors.groupingBy(GaoPointRedemptionReturn::getRedemptionId)); + fillReturnAttachments(returnList); + for (GaoPointRedemption item : list) { + item.setAttachmentList(attachmentMap.getOrDefault(item.getId(), List.of())); + GaoCustomer customer = customerMap.get(item.getCustomerId()); + if (customer != null) { + customer.setAttachmentList(customerAttachmentMap.getOrDefault(customer.getId(), List.of())); + } + item.setCustomer(customer); + item.setReturnList(returnMap.getOrDefault(item.getId(), List.of())); + } + } + + private void fillReturnAttachments(List list) { + if (list == null || list.isEmpty()) { + return; + } + Map> attachmentMap = attachmentService.mapByBizIdList(Attachment.BizType.GAO_POINT_REDEMPTION_RETURN, + list.stream().map(GaoPointRedemptionReturn::getId).toList()); + for (GaoPointRedemptionReturn item : list) { + item.setAttachmentList(attachmentMap.getOrDefault(item.getId(), List.of())); + } + } + + private String requiredText(String value, String message) { + TimiException.required(value, message); + String result = value.trim(); + TimiException.requiredTrue(!result.isEmpty(), message); + return result; + } + + private boolean sameText(String left, String right) { + return left == null ? right == null : left.equals(right); + } + + private long startOfDay(long timestamp) { + LocalDate date = Instant.ofEpochMilli(timestamp).atZone(BUSINESS_ZONE).toLocalDate(); + return date.atStartOfDay(BUSINESS_ZONE).toInstant().toEpochMilli(); + } + + private long startOfNextDay(long timestamp) { + LocalDate date = Instant.ofEpochMilli(timestamp).atZone(BUSINESS_ZONE).toLocalDate().plusDays(1); + return date.atStartOfDay(BUSINESS_ZONE).toInstant().toEpochMilli(); + } + + private long startOfMonth(long timestamp) { + LocalDate date = Instant.ofEpochMilli(timestamp).atZone(BUSINESS_ZONE).toLocalDate().withDayOfMonth(1); + return date.atStartOfDay(BUSINESS_ZONE).toInstant().toEpochMilli(); + } + + private long startOfNextMonth(long timestamp) { + LocalDate date = Instant.ofEpochMilli(timestamp).atZone(BUSINESS_ZONE).toLocalDate().withDayOfMonth(1).plusMonths(1); + return date.atStartOfDay(BUSINESS_ZONE).toInstant().toEpochMilli(); + } + + private record GaoProductSkuSelection(GaoProduct product, GaoProductSku sku, GaoProductSkuUnit unit) { + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointTriggerServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointTriggerServiceImplement.java index 93d33ea..dc8d8b6 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointTriggerServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointTriggerServiceImplement.java @@ -233,6 +233,7 @@ public class GaoPointTriggerServiceImplement implements GaoPointTriggerService { replayRule.setTriggerSourceType(switch (origin.getTriggerSourceType()) { case EVENT_RECORD -> GaoPointRule.TriggerSourceType.EVENT_RECORD; case SALES_ORDER_PAID -> GaoPointRule.TriggerSourceType.SALES_ORDER_PAID; + case POINT_REDEMPTION -> null; }); GaoPointScriptContext context = buildContext(replayRule, origin.getStoreId(), origin.getCustomerId(), origin.getOccurredAt() == null ? Time.now() : origin.getOccurredAt(), diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoProductStockServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoProductStockServiceImplement.java index bc7ef7c..740cb50 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoProductStockServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoProductStockServiceImplement.java @@ -206,6 +206,34 @@ public class GaoProductStockServiceImplement implements GaoProductStockService { return executeInternal(storeId, bizId, items, requestNo, operatorUserId, GaoProductStockRecord.OperationType.ORDER_REVOKE, null); } + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public GaoProductStockRecord outForRedemption(String storeId, String redemptionId, GaoStockChangeItem item, String requestNo, String operatorUserId) { + TimiException.required(item, "积分兑换库存项不能为空"); + TimiException.required(item.getSkuId(), "not found skuId"); + executeInternal(storeId, redemptionId, List.of(item), requestNo, operatorUserId, + GaoProductStockRecord.OperationType.POINT_REDEMPTION_OUT, null, null, null, + GaoProductStockRecord.BizType.POINT_REDEMPTION); + GaoProductStockRecord record = recordService.getByBizSku(GaoProductStockRecord.BizType.POINT_REDEMPTION, redemptionId, + item.getSkuId(), GaoProductStockRecord.OperationType.POINT_REDEMPTION_OUT); + TimiException.required(record, "兑换出库流水创建失败"); + return record; + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public GaoProductStockRecord inForRedemptionReturn(String storeId, String returnId, GaoStockChangeItem item, String requestNo, String operatorUserId) { + TimiException.required(item, "积分兑换退货库存项不能为空"); + TimiException.required(item.getSkuId(), "not found skuId"); + executeInternal(storeId, returnId, List.of(item), requestNo, operatorUserId, + GaoProductStockRecord.OperationType.POINT_REDEMPTION_RETURN_IN, null, null, null, + GaoProductStockRecord.BizType.POINT_REDEMPTION); + GaoProductStockRecord record = recordService.getByBizSku(GaoProductStockRecord.BizType.POINT_REDEMPTION, returnId, + item.getSkuId(), GaoProductStockRecord.OperationType.POINT_REDEMPTION_RETURN_IN); + TimiException.required(record, "兑换退货入库流水创建失败"); + return record; + } + private GaoProductStock change(GaoProductStockChangeRequest request, GaoProductStockRecord.OperationType operationType) { TimiException.required(request, "not found stock change request"); String requestNo = requiredText(request.getRequestNo(), "not found requestNo"); @@ -311,11 +339,18 @@ public class GaoProductStockServiceImplement implements GaoProductStockService { private List executeInternal(String storeId, String bizId, List sourceList, String requestNo, String operatorUserId, GaoProductStockRecord.OperationType operationType, String remark) { - return executeInternal(storeId, bizId, sourceList, requestNo, operatorUserId, operationType, null, remark, null); + return executeInternal(storeId, bizId, sourceList, requestNo, operatorUserId, operationType, null, remark, null, defaultBizType(operationType)); } private List executeInternal(String storeId, String bizId, List sourceList, String requestNo, String operatorUserId, GaoProductStockRecord.OperationType operationType, Long occurredAt, String remark, List attachmentList) { + return executeInternal(storeId, bizId, sourceList, requestNo, operatorUserId, operationType, occurredAt, remark, attachmentList, + defaultBizType(operationType)); + } + + private List executeInternal(String storeId, String bizId, List sourceList, String requestNo, + String operatorUserId, GaoProductStockRecord.OperationType operationType, Long occurredAt, String remark, List attachmentList, + GaoProductStockRecord.BizType bizType) { TimiException.required(storeId, "not found storeId"); TimiException.required(bizId, "not found stock bizId"); TimiException.requiredTrue(sourceList != null && !sourceList.isEmpty(), "库存变更至少需要一个产品"); @@ -371,7 +406,7 @@ public class GaoProductStockServiceImplement implements GaoProductStockService { record.setOnHandAfter(onHandAfter); record.setReservedBefore(reservedBefore); record.setReservedAfter(reservedAfter); - record.setBizType(operationType.name().startsWith("ORDER_") ? GaoProductStockRecord.BizType.ORDER_FULFILLMENT : GaoProductStockRecord.BizType.PRODUCT_STOCK); + record.setBizType(bizType); record.setBizId(bizId); record.setSourceRecordId(item.getSourceRecordId()); record.setRequestNo(normalizedRequestNo); @@ -409,8 +444,8 @@ public class GaoProductStockServiceImplement implements GaoProductStockService { private BigDecimal onHandDelta(GaoProductStockRecord.OperationType operationType, BigDecimal quantity) { return switch (operationType) { - case INITIAL_IN, MANUAL_IN, STOCKTAKE_GAIN, ORDER_REVOKE -> quantity; - case MANUAL_OUT, STOCKTAKE_LOSS, ORDER_OUT -> quantity.negate(); + case INITIAL_IN, MANUAL_IN, STOCKTAKE_GAIN, ORDER_REVOKE, POINT_REDEMPTION_RETURN_IN -> quantity; + case MANUAL_OUT, STOCKTAKE_LOSS, ORDER_OUT, POINT_REDEMPTION_OUT -> quantity.negate(); case ORDER_RESERVE, ORDER_RELEASE -> ZERO; case MANUAL_REVOKE -> throw new TimiException(TimiCode.ERROR, "手工库存冲正必须基于原库存流水"); }; @@ -502,4 +537,10 @@ public class GaoProductStockServiceImplement implements GaoProductStockService { private BigDecimal decimal(BigDecimal value) { return value == null ? ZERO : value; } + + private GaoProductStockRecord.BizType defaultBizType(GaoProductStockRecord.OperationType operationType) { + return operationType.name().startsWith("ORDER_") + ? GaoProductStockRecord.BizType.ORDER_FULFILLMENT + : GaoProductStockRecord.BizType.PRODUCT_STOCK; + } } diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointRedeemSkuPage.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointRedeemSkuPage.java new file mode 100644 index 0000000..323eb81 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointRedeemSkuPage.java @@ -0,0 +1,27 @@ +package com.imyeyu.api.modules.gao.vo; + +import com.imyeyu.api.modules.gao.entity.GaoPointRedeemSku; +import com.imyeyu.java.bean.BasePage; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/// GAO 积分兑换 SKU 配置分页请求 +/// +/// @author Codex +/// @since 2026-09-14 +@Data +@EqualsAndHashCode(callSuper = true) +public class GaoPointRedeemSkuPage extends BasePage { + + /// 门店 ID + private String storeId; + + /// SKU ID + private String skuId; + + /// 配置状态 + private GaoPointRedeemSku.Status status; + + /// 产品或 SKU 关键词 + private String keyword; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointRedeemSkuRequest.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointRedeemSkuRequest.java new file mode 100644 index 0000000..9676e76 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointRedeemSkuRequest.java @@ -0,0 +1,50 @@ +package com.imyeyu.api.modules.gao.vo; + +import com.imyeyu.api.modules.gao.entity.GaoPointRedeemSku; +import lombok.Data; + +import java.math.BigDecimal; + +/// GAO 积分兑换 SKU 配置请求 +/// +/// @author Codex +/// @since 2026-09-14 +@Data +public class GaoPointRedeemSkuRequest { + + /// 配置 ID,修改时必填 + private String id; + + /// 门店 ID + private String storeId; + + /// SKU ID + private String skuId; + + /// SKU 单位 ID + private String skuUnitId; + + /// 配置状态 + private GaoPointRedeemSku.Status status; + + /// 每个业务单位默认所需积分 + private Long defaultPointCost; + + /// 生效开始时间戳,毫秒 + private Long beginAt; + + /// 生效结束时间戳,毫秒 + private Long endAt; + + /// 每客户累计最多兑换数量 + private BigDecimal maxTotalQuantity; + + /// 每客户自然日最多兑换数量 + private BigDecimal maxDailyQuantity; + + /// 每客户自然月最多兑换数量 + private BigDecimal maxMonthlyQuantity; + + /// 备注 + private String remark; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointRedemptionCreateRequest.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointRedemptionCreateRequest.java new file mode 100644 index 0000000..bd983bf --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointRedemptionCreateRequest.java @@ -0,0 +1,39 @@ +package com.imyeyu.api.modules.gao.vo; + +import com.imyeyu.api.modules.common.entity.Attachment; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/// GAO 积分兑换创建请求 +/// +/// @author Codex +/// @since 2026-09-14 +@Data +public class GaoPointRedemptionCreateRequest { + + /// 创建幂等号 + private String requestNo; + + /// 门店 ID + private String storeId; + + /// 客户 ID + private String customerId; + + /// 兑换 SKU 配置 ID + private String redeemSkuId; + + /// 兑换业务数量 + private BigDecimal quantity; + + /// 实际扣除总积分,为空时使用默认总积分 + private Long actualPointCost; + + /// 兑换照片,至少一张 + private List attachmentList; + + /// 备注 + private String remark; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointRedemptionPage.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointRedemptionPage.java new file mode 100644 index 0000000..c95ec7e --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointRedemptionPage.java @@ -0,0 +1,39 @@ +package com.imyeyu.api.modules.gao.vo; + +import com.imyeyu.api.modules.gao.entity.GaoPointRedemption; +import com.imyeyu.java.bean.BasePage; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/// GAO 积分兑换单分页请求 +/// +/// @author Codex +/// @since 2026-09-14 +@Data +@EqualsAndHashCode(callSuper = true) +public class GaoPointRedemptionPage extends BasePage { + + /// 门店 ID + private String storeId; + + /// 客户 ID + private String customerId; + + /// 兑换 SKU 配置 ID + private String redeemSkuId; + + /// SKU ID 快照 + private String skuIdSnapshot; + + /// 兑换单状态 + private GaoPointRedemption.Status status; + + /// 产品或 SKU 关键词 + private String keyword; + + /// 开始兑换时间 + private Long beginAt; + + /// 结束兑换时间 + private Long endAt; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointRedemptionReturnRequest.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointRedemptionReturnRequest.java new file mode 100644 index 0000000..6e6847d --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointRedemptionReturnRequest.java @@ -0,0 +1,30 @@ +package com.imyeyu.api.modules.gao.vo; + +import com.imyeyu.api.modules.common.entity.Attachment; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/// GAO 积分兑换退货请求 +/// +/// @author Codex +/// @since 2026-09-14 +@Data +public class GaoPointRedemptionReturnRequest { + + /// 兑换单 ID + private String redemptionId; + + /// 退货幂等号 + private String requestNo; + + /// 本次退货业务数量 + private BigDecimal quantity; + + /// 退货照片,至少一张 + private List attachmentList; + + /// 退货原因和备注 + private String remark; +} diff --git a/src/main/resources/db/migration/timiserver/V79__create_gao_point_redemption_tables.sql b/src/main/resources/db/migration/timiserver/V79__create_gao_point_redemption_tables.sql new file mode 100644 index 0000000..10dc242 --- /dev/null +++ b/src/main/resources/db/migration/timiserver/V79__create_gao_point_redemption_tables.sql @@ -0,0 +1,77 @@ +CREATE TABLE `gao_point_redeem_sku` ( + `id` VARCHAR(36) NOT NULL, + `store_id` VARCHAR(36) NOT NULL COMMENT '门店 ID', + `sku_id` VARCHAR(36) NOT NULL COMMENT 'SKU ID', + `sku_unit_id` VARCHAR(36) NOT NULL COMMENT 'SKU 单位 ID', + `status` VARCHAR(32) NOT NULL COMMENT 'DRAFT 草稿,ACTIVE 启用,INACTIVE 停用,DELETED 删除', + `default_point_cost` BIGINT NOT NULL COMMENT '每个业务单位默认所需积分', + `begin_at` BIGINT NULL COMMENT '生效开始时间戳,毫秒', + `end_at` BIGINT NULL COMMENT '生效结束时间戳,毫秒', + `max_total_quantity` DECIMAL(18,3) NULL COMMENT '每客户累计最多兑换数量', + `max_daily_quantity` DECIMAL(18,3) NULL COMMENT '每客户自然日最多兑换数量', + `max_monthly_quantity` DECIMAL(18,3) NULL COMMENT '每客户自然月最多兑换数量', + `remark` TEXT NULL COMMENT '备注', + `created_at` BIGINT NOT NULL, + `updated_at` BIGINT NULL, + `deleted_at` BIGINT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_gao_point_redeem_sku_store_sku_unit` (`store_id`, `sku_id`, `sku_unit_id`), + KEY `idx_gao_point_redeem_sku_store_status_time` (`store_id`, `status`, `begin_at`, `end_at`, `created_at`), + KEY `idx_gao_point_redeem_sku_sku` (`store_id`, `sku_id`, `sku_unit_id`) +) COMMENT='GAO 积分兑换 SKU 配置'; + +CREATE TABLE `gao_point_redemption` ( + `id` VARCHAR(36) NOT NULL, + `store_id` VARCHAR(36) NOT NULL COMMENT '门店 ID', + `customer_id` VARCHAR(36) NOT NULL COMMENT '客户 ID', + `request_no` VARCHAR(128) NOT NULL COMMENT '创建幂等号', + `redeem_sku_id_snapshot` VARCHAR(36) NOT NULL COMMENT '兑换 SKU 配置 ID 快照', + `product_id_snapshot` VARCHAR(36) NOT NULL COMMENT '产品 ID 快照', + `product_name_snapshot` VARCHAR(128) NOT NULL COMMENT '产品名称快照', + `product_type_snapshot` VARCHAR(32) NOT NULL COMMENT '产品类型快照', + `sku_id_snapshot` VARCHAR(36) NOT NULL COMMENT 'SKU ID 快照', + `sku_code_snapshot` VARCHAR(64) NOT NULL COMMENT 'SKU 编码快照', + `sku_name_snapshot` VARCHAR(128) NOT NULL COMMENT 'SKU 名称快照', + `sku_spec_snapshot` JSON NOT NULL COMMENT 'SKU 规格结构快照', + `sku_unit_id_snapshot` VARCHAR(36) NOT NULL COMMENT 'SKU 单位 ID 快照', + `unit_snapshot` VARCHAR(32) NOT NULL COMMENT '单位名称快照', + `base_quantity_per_unit_snapshot` DECIMAL(18,6) NOT NULL COMMENT '每个业务单位基础数量快照', + `allow_decimal_quantity_snapshot` TINYINT(1) NOT NULL COMMENT 'true 为允许小数数量快照', + `quantity` DECIMAL(18,3) NOT NULL COMMENT '兑换业务数量', + `base_quantity` DECIMAL(18,3) NOT NULL COMMENT '兑换基础数量', + `default_point_cost` BIGINT NOT NULL COMMENT '默认总积分', + `actual_point_cost` BIGINT NOT NULL COMMENT '实际扣除总积分', + `returned_quantity` DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '累计已退业务数量', + `returned_point_cost` BIGINT NOT NULL DEFAULT 0 COMMENT '累计已返积分', + `status` VARCHAR(32) NOT NULL COMMENT 'COMPLETED 完成,PARTIALLY_RETURNED 部分退货,FULLY_RETURNED 全部退货', + `operator_user_id` VARCHAR(36) NOT NULL COMMENT '兑换操作人', + `redeemed_at` BIGINT NOT NULL COMMENT '兑换完成时间', + `remark` TEXT NULL COMMENT '备注', + `created_at` BIGINT NOT NULL, + `updated_at` BIGINT NULL, + `deleted_at` BIGINT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_gao_point_redemption_store_request` (`store_id`, `request_no`), + KEY `idx_gao_point_redemption_customer_status_time` (`store_id`, `customer_id`, `status`, `redeemed_at`), + KEY `idx_gao_point_redemption_sku_status_time` (`store_id`, `sku_id_snapshot`, `status`, `redeemed_at`), + KEY `idx_gao_point_redemption_config_time` (`redeem_sku_id_snapshot`, `redeemed_at`) +) COMMENT='GAO 积分兑换单'; + +CREATE TABLE `gao_point_redemption_return` ( + `id` VARCHAR(36) NOT NULL, + `redemption_id` VARCHAR(36) NOT NULL COMMENT '兑换单 ID', + `request_no` VARCHAR(128) NOT NULL COMMENT '退货幂等号', + `quantity` DECIMAL(18,3) NOT NULL COMMENT '本次退货业务数量', + `base_quantity` DECIMAL(18,3) NOT NULL COMMENT '本次退货基础数量', + `point_cost` BIGINT NOT NULL COMMENT '本次实际返还积分', + `stock_record_id` VARCHAR(36) NULL COMMENT '实物退货入库流水 ID', + `operator_user_id` VARCHAR(36) NOT NULL COMMENT '退货操作人', + `returned_at` BIGINT NOT NULL COMMENT '退货时间', + `remark` TEXT NULL COMMENT '退货原因和备注', + `created_at` BIGINT NOT NULL, + `updated_at` BIGINT NULL, + `deleted_at` BIGINT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_gao_point_redemption_return_request` (`redemption_id`, `request_no`), + KEY `idx_gao_point_redemption_return_time` (`redemption_id`, `returned_at`) +) COMMENT='GAO 积分兑换退货记录'; diff --git a/src/main/resources/mapper/timi-server/gao/GaoPointRedeemSkuMapper.xml b/src/main/resources/mapper/timi-server/gao/GaoPointRedeemSkuMapper.xml new file mode 100644 index 0000000..ea8f4e6 --- /dev/null +++ b/src/main/resources/mapper/timi-server/gao/GaoPointRedeemSkuMapper.xml @@ -0,0 +1,62 @@ + + + + + + + + `r`.`deleted_at` IS NULL + AND `r`.`store_id` = #{storeId} + AND `r`.`sku_id` = #{skuId} + AND `r`.`status` = #{status} + + AND (`p`.`name` LIKE CONCAT('%', #{keyword}, '%') OR `p`.`code` LIKE CONCAT('%', #{keyword}, '%') + OR `k`.`name` LIKE CONCAT('%', #{keyword}, '%') OR `k`.`code` LIKE CONCAT('%', #{keyword}, '%')) + + + + + + UPDATE `gao_point_redeem_sku` + SET `sku_id` = #{skuId}, + `sku_unit_id` = #{skuUnitId}, + `status` = #{status}, + `default_point_cost` = #{defaultPointCost}, + `begin_at` = #{beginAt}, + `end_at` = #{endAt}, + `max_total_quantity` = #{maxTotalQuantity}, + `max_daily_quantity` = #{maxDailyQuantity}, + `max_monthly_quantity` = #{maxMonthlyQuantity}, + `remark` = #{remark}, + `updated_at` = #{updatedAt} + WHERE `id` = #{id} AND `deleted_at` IS NULL + + diff --git a/src/main/resources/mapper/timi-server/gao/GaoPointRedemptionMapper.xml b/src/main/resources/mapper/timi-server/gao/GaoPointRedemptionMapper.xml new file mode 100644 index 0000000..6e51f3c --- /dev/null +++ b/src/main/resources/mapper/timi-server/gao/GaoPointRedemptionMapper.xml @@ -0,0 +1,66 @@ + + + + + + + + `r`.`deleted_at` IS NULL + AND `r`.`store_id` = #{storeId} + AND `r`.`customer_id` = #{customerId} + AND `r`.`redeem_sku_id_snapshot` = #{redeemSkuId} + AND `r`.`sku_id_snapshot` = #{skuIdSnapshot} + AND `r`.`status` = #{status} + + AND (`r`.`product_name_snapshot` LIKE CONCAT('%', #{keyword}, '%') + OR `r`.`sku_code_snapshot` LIKE CONCAT('%', #{keyword}, '%') + OR `r`.`sku_name_snapshot` LIKE CONCAT('%', #{keyword}, '%') + OR `c`.`code` LIKE CONCAT('%', #{keyword}, '%') + OR `c`.`name` LIKE CONCAT('%', #{keyword}, '%') + OR `c`.`telephone` LIKE CONCAT('%', #{keyword}, '%') + OR `c`.`name_pinyin_full` LIKE CONCAT(#{keyword}, '%') + OR `c`.`name_pinyin_initial` LIKE CONCAT(#{keyword}, '%') + OR `c`.`name_pinyin_mixed` LIKE CONCAT(#{keyword}, '%')) + + AND `r`.`redeemed_at` >= #{beginAt} + AND `r`.`redeemed_at` <= #{endAt} + + + + + diff --git a/src/main/resources/mapper/timi-server/gao/GaoPointRedemptionReturnMapper.xml b/src/main/resources/mapper/timi-server/gao/GaoPointRedemptionReturnMapper.xml new file mode 100644 index 0000000..940de89 --- /dev/null +++ b/src/main/resources/mapper/timi-server/gao/GaoPointRedemptionReturnMapper.xml @@ -0,0 +1,29 @@ + + + + + + + +