Compare commits

...
42 Commits
Author SHA1 Message Date
timi eba4461381 Merge pull request 'v1.0.47' (#59) from dev into master
Reviewed-on: #59
2026-09-14 17:30:09 +00:00
Timi aa7b83ed1e v1.0.47
CI / build-deploy (pull_request) Successful in 26s
CI / notify-on-failure (pull_request) Skipped
2026-09-15 01:25:48 +08:00
Timi ac310414fe add point redemption 2026-09-15 01:25:32 +08:00
timi f5ad238015 Merge pull request 'v1.0.46' (#58) from dev into master
Reviewed-on: #58
2026-09-14 06:14:05 +00:00
Timi 8618a00e5d v1.0.46
CI / build-deploy (pull_request) Successful in 25s
CI / notify-on-failure (pull_request) Skipped
2026-09-14 14:12:57 +08:00
Timi 5f97a1933e add GAO_HELP type 2026-09-14 14:12:42 +08:00
timi 4bdb24e9ac Merge pull request 'v1.0.45' (#57) from dev into master
Reviewed-on: #57
2026-09-13 18:13:38 +00:00
Timi 398e1b8095 v1.0.45
CI / build-deploy (pull_request) Successful in 26s
CI / notify-on-failure (pull_request) Skipped
2026-09-14 02:13:15 +08:00
Timi 086d2b42e2 add SalesOrderStraightThroughRequest paymentAmount 2026-09-14 00:47:59 +08:00
timi 425922fd04 Merge pull request 'v1.0.44' (#56) from dev into master
Reviewed-on: #56
2026-09-12 17:42:51 +00:00
Timi b24d6b3a01 v1.0.44
CI / build-deploy (pull_request) Successful in 25s
CI / notify-on-failure (pull_request) Skipped
2026-09-13 01:37:51 +08:00
Timi c35132d06b total_earned DESC order for point account 2026-09-13 01:37:41 +08:00
Timi 91072d6d50 auto complete order 2026-09-13 01:25:48 +08:00
timi d65424e5bf Merge pull request 'v1.0.43' (#55) from dev into master
Reviewed-on: #55
2026-09-11 17:26:21 +00:00
Timi f0b72b70e7 v1.0.43
CI / build-deploy (pull_request) Successful in 25s
CI / notify-on-failure (pull_request) Skipped
2026-09-12 01:24:03 +08:00
Timi daa5a6aa5e fix off product sku for keep fulfillment 2026-09-12 01:23:47 +08:00
timi 2a35793baf Merge pull request 'v1.0.42' (#54) from dev into master
Reviewed-on: #54
2026-09-11 16:16:04 +00:00
Timi 4a157e9578 v1.0.42
CI / build-deploy (pull_request) Successful in 24s
CI / notify-on-failure (pull_request) Skipped
2026-09-12 00:11:21 +08:00
Timi eabb4c6aa6 fix event record CONTINUOUS_DAYS rank 2026-09-12 00:11:07 +08:00
timi d22df698b6 Merge pull request 'v1.0.41' (#53) from dev into master
Reviewed-on: #53
2026-09-11 15:55:02 +00:00
Timi cb6051cc41 v1.0.41
CI / build-deploy (pull_request) Successful in 59s
CI / notify-on-failure (pull_request) Skipped
2026-09-11 23:53:06 +08:00
Timi 1d1009b121 add customer list for GaoCustomerDailyStateView 2026-09-11 23:52:43 +08:00
Timi 94f3fbb867 add customer age and disease chart 2026-09-11 20:24:09 +08:00
timi 1771bf9909 Merge pull request 'v1.0.40' (#52) from dev into master
Reviewed-on: #52
2026-09-10 16:29:28 +00:00
Timi fc4d55517b v1.0.40
CI / build-deploy (pull_request) Successful in 25s
CI / notify-on-failure (pull_request) Skipped
2026-09-11 00:26:14 +08:00
Timi ec72a99d74 response customer pinyin 2026-09-11 00:24:58 +08:00
Timi a431db6e6e fix article detail request method 2026-09-10 20:27:53 +08:00
Timi 9cfb31b015 support pinyin for product 2026-09-10 20:27:32 +08:00
timi 15d69f9361 Merge pull request 'v1.0.39' (#51) from dev into master
Reviewed-on: #51
2026-09-10 06:10:30 +00:00
Timi b000390a75 v1.0.39
CI / build-deploy (pull_request) Successful in 24s
CI / notify-on-failure (pull_request) Skipped
2026-09-10 14:09:49 +08:00
Timi 1b9f92caf5 add setting map 2026-09-10 14:09:38 +08:00
Timi 7b08641acc add customer order map 2026-09-10 14:09:30 +08:00
Timi 66c13062b2 fix notify fail 2026-09-10 12:47:29 +08:00
timi 2b4089ef59 Merge pull request 'v1.0.38' (#50) from dev into master
Reviewed-on: #50
2026-09-09 17:33:25 +00:00
Timi cdf8a426e9 v1.0.38
CI / build-deploy (pull_request) Successful in 25s
CI / notify-on-failure (pull_request) Skipped
2026-09-10 01:31:20 +08:00
Timi a2d69fe721 support gift 2026-09-10 01:31:06 +08:00
Timi 19db357b44 update article CRUD 2026-09-10 01:30:50 +08:00
Timi 07ffef3e2a add cancel operator 2026-09-09 17:14:29 +08:00
timi 20c3e1f422 Merge pull request 'v1.0.37' (#49) from dev into master
Reviewed-on: #49
2026-09-09 06:21:55 +00:00
Timi f93ec3cd4d v1.0.37
CI / build-deploy (pull_request) Successful in 25s
CI / notify-on-failure (pull_request) Skipped
2026-09-09 14:21:18 +08:00
Timi 3cdac98832 add allowDecimalQuantity 2026-09-09 14:21:04 +08:00
Timi c122c35376 refactor LoggerService.excute 2026-09-09 13:04:16 +08:00
110 changed files with 3514 additions and 410 deletions
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$PROJECT_DIR$/src/main/java/com/imyeyu/api/modules/common/mapper/GroovyScriptVersionMapper.java" value="05742bdc-a11a-4147-9168-ef976a70258a" />
</component>
</project>
+6 -1
View File
@@ -11,7 +11,7 @@
<groupId>com.imyeyu.timiserverapi</groupId>
<artifactId>TimiServerAPI</artifactId>
<version>1.0.36</version>
<version>1.0.47</version>
<packaging>jar</packaging>
<name>TimiServerAPI</name>
<description>imyeyu.com API</description>
@@ -215,6 +215,11 @@
<artifactId>tinypinyin</artifactId>
<version>2.0.3</version>
</dependency>
<dependency>
<groupId>com.hankcs</groupId>
<artifactId>hanlp</artifactId>
<version>portable-1.8.4</version>
</dependency>
<dependency>
<groupId>com.imyeyu.font.icon</groupId>
<artifactId>timi-icon-font</artifactId>
@@ -45,6 +45,11 @@ public enum CorePermissionCode implements BuiltinPermissionCode {
TAG_UPDATE("TAG:UPDATE", "标签修改", false, true),
TAG_DELETE("TAG:DELETE", "标签删除", false, true),
ARTICLE_CREATE("ARTICLE:CREATE", "文章创建", false, true),
ARTICLE_READ("ARTICLE:READ", "文章读取", false, true),
ARTICLE_UPDATE("ARTICLE:UPDATE", "文章修改", false, true),
ARTICLE_DELETE("ARTICLE:DELETE", "文章删除", false, true),
USER_CREATE("USER:CREATE", "用户创建", false, true),
USER_READ("USER:READ", "用户读取", false, true),
USER_UPDATE("USER:UPDATE", "用户修改", false, true),
@@ -32,4 +32,22 @@ public class ThreadPoolConfig {
executor.initialize();
return executor;
}
/// 创建操作审计线程池
///
/// @return 操作审计线程池
@Bean(name = "auditTaskExecutor")
public ThreadPoolTaskExecutor auditTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(2000);
executor.setKeepAliveSeconds(60);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
executor.setThreadNamePrefix("audit-task-executor-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
@@ -0,0 +1,96 @@
package com.imyeyu.api.modules.common.controller;
import com.fasterxml.jackson.annotation.JsonView;
import com.imyeyu.api.annotation.RequireCorePermission;
import com.imyeyu.api.bean.CorePermissionCode;
import com.imyeyu.api.modules.common.entity.Article;
import com.imyeyu.api.modules.common.entity.Attachment;
import com.imyeyu.api.modules.common.service.ArticleService;
import com.imyeyu.api.modules.common.service.AttachmentService;
import com.imyeyu.spring.annotation.AOPLog;
import com.imyeyu.spring.annotation.RequestRateLimit;
import com.imyeyu.spring.bean.Page;
import com.imyeyu.spring.bean.PageResult;
import com.imyeyu.spring.util.ResponseView;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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;
/// 文章接口
///
/// @author Codex
/// @since 2026-09-09
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/article")
public class ArticleController {
private final ArticleService service;
private final AttachmentService attachmentService;
/// 查询文章列表
///
/// @param page 分页参数
/// @return 文章分页
@JsonView(ResponseView.Public.class)
@RequestRateLimit
@RequireCorePermission(CorePermissionCode.ARTICLE_READ)
@PostMapping("/list")
public PageResult<Article> list(@RequestBody Page<Article> page) {
return service.page(page);
}
/// 查询文章详情
///
/// @param id 文章 ID
/// @return 文章详情
@JsonView(ResponseView.Public.class)
@RequestRateLimit
@RequestMapping("/detail")
public Article detail(@RequestParam String id) {
Article article = service.get(id);
article.setAttachmentList(attachmentService.listByBizId(Attachment.BizType.ARTICLE, article.getId()));
return article;
}
/// 创建文章
///
/// @param article 文章
/// @return 创建后的文章
@AOPLog
@JsonView(ResponseView.Public.class)
@RequestRateLimit
@RequireCorePermission(CorePermissionCode.ARTICLE_CREATE)
@PostMapping("/create")
public Article create(@RequestBody Article article) {
service.create(article);
return article;
}
/// 更新文章
///
/// @param article 文章
@AOPLog
@RequestRateLimit
@RequireCorePermission(CorePermissionCode.ARTICLE_UPDATE)
@PostMapping("/update")
public void update(@RequestBody Article article) {
service.update(article);
}
/// 删除文章
///
/// @param id 文章 ID
@AOPLog
@RequestRateLimit
@RequireCorePermission(CorePermissionCode.ARTICLE_DELETE)
@PostMapping("/delete")
public void delete(@RequestParam String id) {
service.delete(id);
}
}
@@ -1,5 +1,6 @@
package com.imyeyu.api.modules.common.controller;
import com.imyeyu.api.modules.common.vo.ClientLogRequest;
import com.imyeyu.api.modules.system.entity.Logger;
import com.imyeyu.api.modules.system.service.LoggerService;
import com.imyeyu.spring.annotation.AOPLog;
@@ -29,7 +30,9 @@ public class LoggerController {
@AOPLog
@RequiredToken
@PostMapping("/create")
public void create(@Valid @RequestBody Logger logger) {
public void create(@Valid @RequestBody ClientLogRequest request) {
Logger logger = Logger.info(Logger.Module.COMMON, "CLIENT_LOG");
logger.setContent(request.getContent());
service.create(logger);
}
}
}
@@ -15,8 +15,12 @@ import lombok.extern.slf4j.Slf4j;
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;
import java.util.Map;
/**
*
*
@@ -26,11 +30,30 @@ import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/system/setting")
@RequestMapping("/setting")
public class SettingController {
private final SettingService service;
/**
* 查询公开配置映射
*
* @param bizType 业务类型
* @param bizId 业务 ID,可为空
* @param code 配置代码列表,可为空
* @return 公开配置映射
*/
@JsonView(ResponseView.Public.class)
@RequestRateLimit
@RequestMapping("/map")
public Map<String, Setting> map(
@RequestParam String bizType,
@RequestParam(required = false) String bizId,
@RequestParam(required = false) List<String> code
) {
return service.mapPublic(bizType, bizId, code);
}
/**
* 查询配置列表
*
@@ -34,6 +34,10 @@ public class Article extends UUIDEntity implements SettingSupport {
/// GAO 门店日记
GAO_STORE_DIARY,
GAO_HELP,
GAO_SYSTEM,
/// 反馈
FEEDBACK
}
@@ -52,6 +52,10 @@ public class Attachment extends UUIDEntity {
GAO_SALES_ORDER,
GAO_POINT_REDEMPTION,
GAO_POINT_REDEMPTION_RETURN,
TEMP_FILE,
MIRROR
@@ -20,11 +20,11 @@ public class Notify extends UUIDEntity {
/** 业务类型 */
private String bizType;
/** 提醒业务类型 */
/** 业务提醒类型 */
private String bizRemindedType;
/** 提醒业务 ID */
private String bizRemindedId;
/** 业务提醒查重键 */
private String bizRemindedKey;
/** 参数 */
private String args;
@@ -2,9 +2,8 @@ package com.imyeyu.api.modules.common.mapper;
import com.imyeyu.api.modules.common.entity.Notify;
import com.imyeyu.api.modules.common.vo.NotifyManagementItem;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import com.imyeyu.spring.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@@ -16,9 +15,8 @@ import java.util.List;
*/
public interface NotifyMapper extends BaseMapper<Notify, String> {
/// 查询业务提醒是否已创建
@Select("SELECT EXISTS (SELECT 1 FROM `notify` WHERE `biz_type` = #{bizType} AND `biz_reminded_type` = #{bizRemindedType} AND `biz_reminded_id` = #{bizRemindedId} AND `deleted_at` IS NULL)")
boolean existsByBizReminded(@Param("bizType") String bizType, @Param("bizRemindedType") String bizRemindedType, @Param("bizRemindedId") String bizRemindedId);
/// 原子创建业务提醒
int insertIfAbsent(Notify notify);
/// 分页查询管理端通知
///
@@ -3,6 +3,8 @@ package com.imyeyu.api.modules.common.mapper;
import com.imyeyu.api.modules.common.entity.Setting;
import com.imyeyu.spring.mapper.BaseMapper;
import java.util.List;
/**
* 系统配置
*
@@ -10,4 +12,6 @@ import com.imyeyu.spring.mapper.BaseMapper;
* @since 2021-07-20 22:26
*/
public interface SettingMapper extends BaseMapper<Setting, String> {
List<Setting> selectPublicByQuery(String bizType, String bizId, List<String> codeList);
}
@@ -14,13 +14,10 @@ import com.imyeyu.spring.service.BaseService;
*/
public interface NotifyService extends BaseService<Notify, String> {
/// 查询业务提醒是否已创建
/// 原子创建业务提醒,重复查重键不会创建通知详情
///
/// @param bizType 业务类型
/// @param bizRemindedType 被提醒业务类型
/// @param bizRemindedId 被提醒业务 ID
/// @return true 为已创建
boolean existsByBizReminded(String bizType, String bizRemindedType, String bizRemindedId);
/// @param notify 通知
void createIfAbsent(Notify notify);
/// 分页查询当前用户创建的通知
///
@@ -7,6 +7,7 @@ import com.imyeyu.java.TimiJava;
import com.imyeyu.spring.service.BaseService;
import java.util.List;
import java.util.Map;
/**
* 配置服务
@@ -24,6 +25,16 @@ public interface SettingService extends BaseService<Setting, String> {
List<Setting> listByBizId(String bizType, String bizId);
/**
* 查询公开配置映射
*
* @param bizType 业务类型
* @param bizId 业务 ID,可为空
* @param code 配置代码列表,可为空
* @return 公开配置映射
*/
Map<String, Setting> mapPublic(String bizType, String bizId, List<String> code);
default List<Setting> listBySettingSupport(SettingSupport settingSupport) {
String bizType = settingSupport.getClass().getSimpleName().toUpperCase();
return listByBizId(bizType, settingSupport.getId());
@@ -7,9 +7,8 @@ import com.imyeyu.api.modules.common.mapper.NotifyMapper;
import com.imyeyu.api.modules.common.service.NotifyDetailService;
import com.imyeyu.api.modules.common.service.NotifyService;
import com.imyeyu.api.modules.common.vo.NotifyManagementItem;
import com.imyeyu.api.modules.system.entity.Logger;
import com.imyeyu.api.modules.system.service.LoggerService;
import com.imyeyu.api.modules.user.service.UserLoginService;
import com.imyeyu.api.modules.system.audit.AuditOperation;
import com.imyeyu.api.modules.system.audit.OperationAudit;
import com.imyeyu.java.TimiJava;
import com.imyeyu.java.bean.timi.TimiCode;
import com.imyeyu.java.bean.timi.TimiException;
@@ -17,12 +16,13 @@ import com.imyeyu.spring.bean.Page;
import com.imyeyu.spring.bean.PageResult;
import com.imyeyu.spring.mapper.BaseMapper;
import com.imyeyu.spring.service.AbstractEntityService;
import com.imyeyu.utils.Time;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
/**
* 通知服务实现
@@ -30,13 +30,10 @@ import java.util.List;
* @author 夜雨
* @since 2026-05-13 14:46
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NotifyServiceImplement extends AbstractEntityService<Notify, String> implements NotifyService {
private final LoggerService loggerService;
private final UserLoginService userLoginService;
private final NotifyDetailService detailService;
private final NotifyMapper mapper;
@@ -46,43 +43,53 @@ public class NotifyServiceImplement extends AbstractEntityService<Notify, String
return mapper;
}
@Override
public boolean existsByBizReminded(String bizType, String bizRemindedType, String bizRemindedId) {
return mapper.existsByBizReminded(bizType, bizRemindedType, bizRemindedId);
}
/// 创建通知并级联创建通知详情
///
/// @param notify 通知
@OperationAudit(AuditOperation.COMMON_NOTIFY_CREATE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void create(Notify notify) {
Logger logger = new Logger(Logger.Module.COMMON, "COMMON_NOTIFY_CREATE");
try {
TimiException.required(notify, "未找到通知");
TimiException.required(notify.getDetailList(), "未找到通知详情列表");
prepareCreate(notify);
super.create(notify);
createDetails(notify);
}
notify.setCreatedBy(TimiJava.defaultIfEmpty(notify.getCreatedBy(), TimiJava.ZERO_UUID));
super.create(notify);
/// 原子创建业务提醒并级联创建通知详情
///
/// @param notify 通知
@OperationAudit(AuditOperation.COMMON_NOTIFY_CREATE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void createIfAbsent(Notify notify) {
prepareCreate(notify);
if (mapper.insertIfAbsent(notify) == 0) {
return;
}
createDetails(notify);
}
for (NotifyDetail detail : notify.getDetailList()) {
detail.setNotifyId(notify.getId());
detail.setSendFrom(TimiJava.defaultIfEmpty(detail.getSendFrom(), TimiJava.ZERO_UUID));
detailService.create(detail);
}
logger.setLevel(Logger.Level.INFO);
logger.setResult(notify.getId());
} catch (TimiException e) {
logger.setLevel(Logger.Level.WARN);
logger.setException(e.getMessage());
throw e;
} catch (Exception e) {
logger.setLevel(Logger.Level.ERROR);
logger.setException(TimiJava.serializeThrowable(e));
log.error("创建通知失败", e);
throw new TimiException(TimiCode.ERROR, "创建通知失败", e);
} finally {
loggerService.create(logger);
/// 校验并补全通知创建字段
///
/// @param notify 通知
private void prepareCreate(Notify notify) {
TimiException.required(notify, "未找到通知");
TimiException.required(notify.getDetailList(), "未找到通知详情列表");
notify.setId(TimiJava.defaultIfEmpty(notify.getId(), UUID.randomUUID().toString()));
notify.setCreatedBy(TimiJava.defaultIfEmpty(notify.getCreatedBy(), TimiJava.ZERO_UUID));
notify.setCreatedAt(TimiJava.defaultIfNull(notify.getCreatedAt(), Time.now()));
notify.setUpdatedAt(null);
notify.setDeletedAt(null);
}
/// 创建通知详情
///
/// @param notify 通知
private void createDetails(Notify notify) {
for (NotifyDetail detail : notify.getDetailList()) {
detail.setNotifyId(notify.getId());
detail.setSendFrom(TimiJava.defaultIfEmpty(detail.getSendFrom(), TimiJava.ZERO_UUID));
detailService.create(detail);
}
}
@@ -120,6 +127,8 @@ public class NotifyServiceImplement extends AbstractEntityService<Notify, String
/// @param notifyId 通知 ID
/// @param userId 用户 ID
@Override
@Transactional(TimiServerDBConfig.ROLLBACKER)
@OperationAudit(AuditOperation.COMMON_NOTIFY_DELETE)
public void deleteManagement(String notifyId, String userId) {
getManagement(notifyId, userId);
delete(notifyId);
@@ -130,6 +139,7 @@ public class NotifyServiceImplement extends AbstractEntityService<Notify, String
/// @param notifyId 通知 ID
/// @param userId 用户 ID
@Override
@Transactional(TimiServerDBConfig.ROLLBACKER)
public void cancelManagement(String notifyId, String userId) {
getManagement(notifyId, userId);
cancel(notifyId);
@@ -138,32 +148,17 @@ public class NotifyServiceImplement extends AbstractEntityService<Notify, String
/// 删除通知并级联软删除通知详情
///
/// @param notifyId 通知 ID
@OperationAudit(AuditOperation.COMMON_NOTIFY_DELETE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void delete(String notifyId) {
Logger logger = new Logger(Logger.Module.COMMON, "COMMON_NOTIFY_DELETE");
try {
TimiException.required(notifyId, "未找到通知 ID");
TimiException.required(get(notifyId), "未找到通知");
List<NotifyDetail> detailList = detailService.listByNotifyId(notifyId);
for (NotifyDetail detail : detailList) {
detailService.delete(detail.getId());
}
super.delete(notifyId);
logger.setLevel(Logger.Level.INFO);
logger.setResult(notifyId);
} catch (TimiException e) {
logger.setLevel(Logger.Level.WARN);
logger.setException(e.getMessage());
throw e;
} catch (Exception e) {
logger.setLevel(Logger.Level.ERROR);
logger.setException(TimiJava.serializeThrowable(e));
log.error("删除通知失败", e);
throw new TimiException(TimiCode.ERROR, "删除通知失败", e);
} finally {
loggerService.create(logger);
TimiException.required(notifyId, "未找到通知 ID");
TimiException.required(get(notifyId), "未找到通知");
List<NotifyDetail> detailList = detailService.listByNotifyId(notifyId);
for (NotifyDetail detail : detailList) {
detailService.delete(detail.getId());
}
super.delete(notifyId);
}
/// 取消通知中尚未成功发送的通知详情
@@ -17,6 +17,8 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 系统配置服务实现
@@ -53,6 +55,12 @@ public class SettingServiceImplement extends AbstractEntityService<Setting, Stri
return mapper.selectAllByExample(example);
}
@Override
public Map<String, Setting> mapPublic(String bizType, String bizId, List<String> code) {
TimiException.required(bizType, "not found bizType");
return mapper.selectPublicByQuery(bizType, bizId, code).stream().collect(Collectors.toMap(Setting::getCode, Function.identity()));
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void updateByBizId(String bizType, String bizId, List<Setting> list) {
@@ -0,0 +1,18 @@
package com.imyeyu.api.modules.common.vo;
import jakarta.validation.constraints.Size;
import lombok.Data;
/// 客户端诊断日志请求
///
/// 客户端日志不是操作审计,操作编码、模块、级别、用户和 IP 均由服务端决定
///
/// @author Codex
/// @since 2026-09-09
@Data
public class ClientLogRequest {
/// 日志内容
@Size(max = 32768, message = "日志内容不能超过 32 KB")
private String content;
}
@@ -111,6 +111,8 @@ public enum GaoPermissionCode implements BuiltinPermissionCode {
CUSTOMER_PACKAGE_PRICE_OVERRIDE("CUSTOMER_PACKAGE:PRICE_OVERRIDE", "客户套餐成交价和权益配额调整", true, true),
CUSTOMER_PACKAGE_GIFT_CREATE("CUSTOMER_PACKAGE:GIFT_CREATE", "客户套餐创建赠送", true, true),
FUND_RECORD_READ("FUND_RECORD:READ", "资金流水读取", true, true),
FUND_RECEIVE_CREATE("FUND_RECEIVE:CREATE", "线下收款创建", true, true),
@@ -137,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),
@@ -0,0 +1,27 @@
package com.imyeyu.api.modules.gao.bean;
import com.imyeyu.api.annotation.SettingBizType;
import com.imyeyu.api.bean.SettingEnum;
import com.imyeyu.api.modules.common.entity.Setting;
import lombok.AllArgsConstructor;
import lombok.Getter;
///
///
/// @author 夜雨
/// @since 2026-09-09 16:11
@Getter
@SettingBizType("GAO_SYSTEM")
@AllArgsConstructor
public enum GaoSystemSetting implements SettingEnum {
SERVICE_LICENSE(null, Setting.ValueType.STRING),
PRIVACY_LICENSE(null, Setting.ValueType.STRING),
OPEN_SOURCE_LICENSE(null, Setting.ValueType.STRING);
final String defaultValue;
final Setting.ValueType valueType;
}
@@ -5,6 +5,8 @@ import com.imyeyu.api.modules.gao.bean.GaoPermissionCode;
import com.imyeyu.api.modules.gao.service.GaoCustomerChartService;
import com.imyeyu.api.modules.gao.service.GaoStoreScopeService;
import com.imyeyu.api.modules.gao.vo.GaoCustomerChartReq;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDiseaseWordReq;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDiseaseWordView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordCalendarView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordDailyStatView;
import com.imyeyu.java.bean.timi.TimiException;
@@ -51,4 +53,15 @@ public class GaoCustomerChartController {
req.setStoreId(storeScopeService.resolveStoreId(req.getStoreId()));
return service.eventRecordCalendar(req);
}
/// 查询客户病症词频
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.STAT_READ)
@PostMapping("/customer/disease/word")
public List<GaoCustomerDiseaseWordView> customerDiseaseWord(@RequestBody GaoCustomerDiseaseWordReq req) {
TimiException.required(req, "not found req");
req.setStoreId(storeScopeService.resolveStoreId(req.getStoreId()));
return service.customerDiseaseWord(req);
}
}
@@ -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<GaoPointRedeemSku> 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);
}
}
@@ -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<GaoPointRedemption> 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<GaoPointRedemptionReturn> returnList(@RequestParam String redemptionId, @RequestParam(required = false) String storeId) {
return service.listReturns(storeScopeService.resolveStoreId(storeId), redemptionId);
}
}
@@ -1,8 +1,8 @@
package com.imyeyu.api.modules.gao.controller;
import com.imyeyu.api.bean.PermissionMatchMode;
import com.imyeyu.api.modules.gao.annotation.RequireGaoPermission;
import com.imyeyu.api.modules.gao.bean.GaoPermissionCode;
import com.imyeyu.api.bean.PermissionMatchMode;
import com.imyeyu.api.modules.gao.entity.GaoFundRecord;
import com.imyeyu.api.modules.gao.entity.GaoSalesOrder;
import com.imyeyu.api.modules.gao.service.GaoOrderPaymentService;
@@ -17,6 +17,8 @@ import com.imyeyu.api.modules.gao.vo.GaoOrderPaymentRevokeRequest;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderCancelRequest;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderPage;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderUpdateRequest;
import com.imyeyu.api.modules.user.service.UserService;
import com.imyeyu.java.TimiJava;
import com.imyeyu.java.bean.timi.TimiException;
import com.imyeyu.spring.annotation.AOPLog;
import com.imyeyu.spring.annotation.RequestRateLimit;
@@ -40,6 +42,7 @@ public class GaoSalesOrderController {
private final GaoSalesOrderService service;
private final GaoOrderPaymentService paymentService;
private final GaoStoreScopeService storeScopeService;
private final UserService userService;
@AOPLog
@RequestRateLimit
@@ -56,7 +59,11 @@ public class GaoSalesOrderController {
@RequireGaoPermission(GaoPermissionCode.SALES_ORDER_READ)
@PostMapping("/detail")
public GaoSalesOrder detail(@RequestParam String id) {
return service.detail(id);
GaoSalesOrder result = service.detail(id);
if (TimiJava.isNotEmpty(result.getCancelOperatorUserId())) {
result.setCancelOperator(userService.get(result.getCancelOperatorUserId()));
}
return result;
}
@AOPLog
@@ -4,6 +4,7 @@ import com.imyeyu.api.modules.gao.annotation.RequireGaoPermission;
import com.imyeyu.api.modules.gao.bean.GaoPermissionCode;
import com.imyeyu.api.modules.gao.service.GaoStoreChartService;
import com.imyeyu.api.modules.gao.service.GaoStoreScopeService;
import com.imyeyu.api.modules.gao.vo.GaoCustomerAgeStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerGenderStatView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordCalendarView;
@@ -54,6 +55,15 @@ public class GaoStoreChartController {
return service.customerGender(storeScopeService.resolveStoreId(storeId));
}
/// 查询客户年龄分布
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.STAT_READ)
@PostMapping("/customer/age")
public List<GaoCustomerAgeStatView> customerAge(@RequestParam String storeId) {
return service.customerAge(storeScopeService.resolveStoreId(storeId));
}
/// 查询登记事件每日统计,可同时支撑登记趋势和事件结构图表
@AOPLog
@RequestRateLimit
@@ -6,7 +6,6 @@ import com.imyeyu.api.modules.common.entity.Attachment;
import com.imyeyu.api.modules.common.entity.TagApply;
import com.imyeyu.api.modules.user.bean.Gender;
import com.imyeyu.api.modules.user.entity.User;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.imyeyu.java.TimiJava;
import com.imyeyu.spring.annotation.table.Column;
import com.imyeyu.spring.annotation.table.Transient;
@@ -62,15 +61,12 @@ public class GaoCustomer extends UUIDEntity {
private String name;
@Column("name_pinyin_full")
@JsonIgnore
private String namePinyinFull;
@Column("name_pinyin_initial")
@JsonIgnore
private String namePinyinInitial;
@Column("name_pinyin_mixed")
@JsonIgnore
private String namePinyinMixed;
/// 性别
@@ -47,7 +47,10 @@ public class GaoPointLedger extends UUIDEntity {
EVENT_RECORD,
/// 订单首次全额实收
SALES_ORDER_PAID
SALES_ORDER_PAID,
/// 积分兑换
POINT_REDEMPTION
}
/// 客户积分账户 ID
@@ -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;
}
@@ -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<Attachment> attachmentList;
/// 退货记录
@Transient
private List<GaoPointRedemptionReturn> returnList;
}
@@ -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<Attachment> attachmentList;
}
@@ -1,6 +1,8 @@
package com.imyeyu.api.modules.gao.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.imyeyu.api.modules.common.entity.Attachment;
import com.imyeyu.spring.annotation.table.Column;
import com.imyeyu.spring.annotation.table.Transient;
import com.imyeyu.spring.entity.UUIDEntity;
import lombok.Data;
@@ -61,6 +63,18 @@ public class GaoProduct extends UUIDEntity {
/// 产品名称
private String name;
@Column("name_pinyin_full")
@JsonIgnore
private String namePinyinFull;
@Column("name_pinyin_initial")
@JsonIgnore
private String namePinyinInitial;
@Column("name_pinyin_mixed")
@JsonIgnore
private String namePinyinMixed;
/// 产品类型
private ProductType productType;
@@ -27,7 +27,10 @@ public class GaoProductStockRecord implements IDEntity<String>, Creatable {
PRODUCT_STOCK,
/// 订单履约库存操作
ORDER_FULFILLMENT
ORDER_FULFILLMENT,
/// 积分兑换库存操作
POINT_REDEMPTION
}
/// 库存操作类型
@@ -57,6 +60,12 @@ public class GaoProductStockRecord implements IDEntity<String>, Creatable {
/// 履约冲正入库
ORDER_REVOKE,
/// 积分兑换实物出库
POINT_REDEMPTION_OUT,
/// 积分兑换退货入库
POINT_REDEMPTION_RETURN_IN,
/// 盘盈
STOCKTAKE_GAIN,
@@ -34,6 +34,9 @@ public class GaoSalesOrderItem extends UUIDEntity {
/// 订单项类型
private ItemType itemType;
/// true 为赠送权益
private Boolean gifted;
/// SKU ID
private String skuId;
@@ -68,6 +71,14 @@ public class GaoSalesOrderItem extends UUIDEntity {
/// 基础单位数量系数快照
private BigDecimal baseQuantityPerUnitSnapshot;
/// 当前 SKU 是否允许业务数量保留小数
@Transient
private Boolean allowDecimalQuantity;
/// 当前 SKU 基础单位名称
@Transient
private String baseUnitName;
/// 购买数量
private BigDecimal quantity;
@@ -1,7 +1,10 @@
package com.imyeyu.api.modules.gao.mapper;
import com.imyeyu.api.modules.gao.entity.GaoCustomer;
import com.imyeyu.api.modules.gao.vo.GaoCustomerAgeStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateCustomerView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDiseaseTextView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerGenderStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerPage;
import com.imyeyu.spring.mapper.BaseMapper;
@@ -27,6 +30,14 @@ public interface GaoCustomerMapper extends BaseMapper<GaoCustomer, String> {
/// @return 每日新增客户数
List<GaoCustomerDailyStateView> stateDailyNewCustomer(@Param("storeId") String storeId, @Param("beginCreatedAt") Long beginCreatedAt, @Param("endCreatedAt") Long endCreatedAt);
/// 查询每日新增客户摘要
///
/// @param storeId 门店 ID
/// @param beginCreatedAt 开始创建时间
/// @param endCreatedAt 结束创建时间
/// @return 每日新增客户摘要
List<GaoCustomerDailyStateCustomerView> selectDailyNewCustomer(@Param("storeId") String storeId, @Param("beginCreatedAt") Long beginCreatedAt, @Param("endCreatedAt") Long endCreatedAt);
/// 统计开始时间前客户总数
///
/// @param beginCreatedAt 开始创建时间
@@ -38,6 +49,18 @@ public interface GaoCustomerMapper extends BaseMapper<GaoCustomer, String> {
/// @return 性别统计列表
List<GaoCustomerGenderStatView> stateGender(@Param("storeId") String storeId);
/// 统计客户年龄分布
///
/// @param storeId 门店 ID
/// @return 客户年龄列表
List<GaoCustomerAgeStatView> stateAge(@Param("storeId") String storeId);
/// 查询客户病症原始文本
///
/// @param storeId 门店 ID
/// @return 客户备注和病症
List<GaoCustomerDiseaseTextView> selectDiseaseTextByStoreId(@Param("storeId") String storeId);
/// 查询客户总数
///
/// @param page 查询条件
@@ -55,5 +78,5 @@ public interface GaoCustomerMapper extends BaseMapper<GaoCustomer, String> {
/// @param customerId 客户 ID
/// @param delta 变化量
@Update("UPDATE `gao_customer` SET `invited_count` = GREATEST(IFNULL(`invited_count`, 0) + #{delta}, 0), `updated_at` = " + UNIX_TIME + " WHERE `id` = #{customerId}" + NOT_DELETE)
void updateInvitedCountDelta(@Param("customerId") String customerId, @Param("delta") Long delta);
void updateInvitedCountDelta(String customerId, Long delta);
}
@@ -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<GaoPointRedeemSku, String> {
/// 按门店和配置 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<GaoPointRedeemSku> selectByQuery(GaoPointRedeemSkuPage page);
/// 更新兑换 SKU 配置,可清空可空字段
void updateConfig(GaoPointRedeemSku config);
}
@@ -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, String> {
/// 按门店和创建幂等号查询兑换单
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<GaoPointRedemption> 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);
}
@@ -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<GaoPointRedemptionReturn, String> {
/// 按兑换单查询退货记录
List<GaoPointRedemptionReturn> selectByRedemptionId(@Param("redemptionId") String redemptionId);
/// 批量按兑换单查询退货记录
List<GaoPointRedemptionReturn> selectByRedemptionIdList(@Param("redemptionIdList") List<String> redemptionIdList);
/// 按兑换单和退货幂等号查询
GaoPointRedemptionReturn selectByRedemptionIdAndRequestNo(@Param("redemptionId") String redemptionId, @Param("requestNo") String requestNo);
/// 锁定退货记录
GaoPointRedemptionReturn selectForUpdate(@Param("id") String id);
}
@@ -13,6 +13,12 @@ import java.util.List;
/// @since 2026-09-06
public interface GaoProductSkuMapper extends BaseMapper<GaoProductSku, String> {
/// 批量查询 SKU
///
/// @param idList SKU ID 集合
/// @return SKU 列表
List<GaoProductSku> selectByIdList(@Param("idList") Collection<String> idList);
/// 按产品查询 SKU
List<GaoProductSku> selectByProductId(@Param("productId") String productId);
@@ -1,6 +1,8 @@
package com.imyeyu.api.modules.gao.service;
import com.imyeyu.api.modules.gao.vo.GaoCustomerChartReq;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDiseaseWordReq;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDiseaseWordView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordCalendarView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordDailyStatView;
@@ -17,4 +19,7 @@ public interface GaoCustomerChartService {
/// 查询客户登记日历记录
List<GaoEventRecordCalendarView> eventRecordCalendar(GaoCustomerChartReq req);
/// 查询客户病症词频
List<GaoCustomerDiseaseWordView> customerDiseaseWord(GaoCustomerDiseaseWordReq req);
}
@@ -1,6 +1,7 @@
package com.imyeyu.api.modules.gao.service;
import com.imyeyu.api.modules.gao.entity.GaoCustomer;
import com.imyeyu.api.modules.gao.vo.GaoCustomerAgeStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerGenderStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerPage;
@@ -87,4 +88,10 @@ public interface GaoCustomerService extends BaseService<GaoCustomer, String> {
/// @param storeId 门店 ID
/// @return 性别统计列表
List<GaoCustomerGenderStatView> stateGender(String storeId);
/// 统计客户年龄分布
///
/// @param storeId 门店 ID
/// @return 客户年龄列表
List<GaoCustomerAgeStatView> stateAge(String storeId);
}
@@ -16,6 +16,9 @@ public interface GaoOrderPaymentService {
/// 登记收款
GaoFundRecord receive(GaoOrderPaymentReceiveRequest request);
/// 登记已收款并直接核销
GaoFundRecord receiveAndVerify(GaoOrderPaymentReceiveRequest request);
/// 核销收款
GaoFundRecord verify(String fundRecordId);
@@ -0,0 +1,17 @@
package com.imyeyu.api.modules.gao.service;
import com.imyeyu.api.modules.gao.entity.GaoSalesOrder;
import com.imyeyu.api.modules.gao.vo.GaoOrderStraightThroughRequest;
/// GAO 订单创建直通服务
///
/// @author Codex
/// @since 2026-09-13
public interface GaoOrderStraightThroughService {
/// 在订单创建事务内完成可选的全额收款和全部履约
///
/// @param order 销售订单
/// @param straightThrough 直通请求,空为不直通
void complete(GaoSalesOrder order, GaoOrderStraightThroughRequest straightThrough);
}
@@ -53,6 +53,31 @@ public interface GaoPointLedgerService extends BaseService<GaoPointLedger, Strin
/// @return 调整流水
GaoPointLedger adjust(GaoPointAdjustRequest request, String operatorUserId);
/// 为积分兑换扣减积分
///
/// @param storeId 门店 ID
/// @param customerId 客户 ID
/// @param redemptionId 兑换单 ID
/// @param pointCost 实际扣减积分
/// @param operatorUserId 操作用户 ID
/// @param remark 备注
/// @return 积分流水;积分为 0 时返回 null
GaoPointLedger consumeForRedemption(String storeId, String customerId, String redemptionId, long pointCost,
String operatorUserId, String remark);
/// 为积分兑换退货返还积分
///
/// @param storeId 门店 ID
/// @param customerId 客户 ID
/// @param redemptionId 兑换单 ID
/// @param returnId 退货记录 ID
/// @param pointCost 实际返还积分
/// @param operatorUserId 操作用户 ID
/// @param remark 备注
/// @return 积分流水;积分为 0 时返回 null
GaoPointLedger revokeForRedemption(String storeId, String customerId, String redemptionId, String returnId, long pointCost,
String operatorUserId, String remark);
/// 查询触发来源产生的原始规则积分流水
///
/// @param triggerSourceType 直接触发来源类型
@@ -0,0 +1,34 @@
package com.imyeyu.api.modules.gao.service;
import com.imyeyu.api.modules.gao.entity.GaoPointRedeemSku;
import com.imyeyu.api.modules.gao.vo.GaoPointRedeemSkuPage;
import com.imyeyu.api.modules.gao.vo.GaoPointRedeemSkuRequest;
import com.imyeyu.spring.bean.PageResult;
/// GAO 积分兑换 SKU 配置服务
///
/// @author Codex
/// @since 2026-09-14
public interface GaoPointRedeemSkuService {
/// 分页查询兑换 SKU 配置
PageResult<GaoPointRedeemSku> 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);
}
@@ -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<GaoPointRedemption> pageByQuery(GaoPointRedemptionPage page);
/// 查询兑换单详情
GaoPointRedemption detail(String storeId, String id);
/// 创建并完成积分兑换
GaoPointRedemption create(GaoPointRedemptionCreateRequest request);
/// 发起部分或全部退货
GaoPointRedemption returnRedemption(GaoPointRedemptionReturnRequest request);
/// 查询兑换单退货记录
List<GaoPointRedemptionReturn> listReturns(String storeId, String redemptionId);
}
@@ -15,6 +15,12 @@ import java.util.List;
/// @since 2026-09-06
public interface GaoProductSkuService extends BaseService<GaoProductSku, String> {
/// 批量查询 SKU
///
/// @param idList SKU ID 集合
/// @return SKU 列表,包含单位、规格和附件临时数据
List<GaoProductSku> listByIdList(Collection<String> idList);
/// 查询 SKU 详情
GaoProductSku detail(String id);
@@ -27,6 +33,14 @@ public interface GaoProductSkuService extends BaseService<GaoProductSku, String>
/// 查询并校验可销售 SKU 单位
GaoProductSkuUnitSelection getSellableSkuUnit(String storeId, String skuId, String skuUnitId);
/// 查询并校验可用于历史订单履约的 SKU 单位
///
/// @param storeId 门店 ID
/// @param skuId SKU ID
/// @param skuUnitId SKU 单位 ID
/// @return SKU 单位选择结果
GaoProductSkuUnitSelection getFulfillableSkuUnit(String storeId, String skuId, String skuUnitId);
/// 查询并校验 SKU 单位
GaoProductSkuUnitSelection getSkuUnit(String storeId, String skuId, String skuUnitId, boolean purchase);
@@ -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;
@@ -46,6 +47,9 @@ public interface GaoProductStockService {
/// 为履约预占库存
boolean hasSufficientForFulfillment(String storeId, List<GaoStockChangeItem> items);
/// 获取履约库存缺口提示,无缺口时返回 null
String getFulfillmentStockShortageMessage(String storeId, List<GaoStockChangeItem> items);
/// 为履约预占库存
List<GaoProductStock> reserveForFulfillment(String storeId, String bizId, List<GaoStockChangeItem> items, String requestNo, String operatorUserId);
@@ -57,4 +61,10 @@ public interface GaoProductStockService {
/// 履约库存冲正入库
List<GaoProductStock> revokeForFulfillment(String storeId, String bizId, List<GaoStockChangeItem> 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);
}
@@ -1,6 +1,7 @@
package com.imyeyu.api.modules.gao.service;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerAgeStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerGenderStatView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordCalendarView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordDailyStatView;
@@ -21,6 +22,9 @@ public interface GaoStoreChartService {
/// 查询客户性别统计
List<GaoCustomerGenderStatView> customerGender(String storeId);
/// 查询客户年龄分布
List<GaoCustomerAgeStatView> customerAge(String storeId);
/// 查询登记事件每日统计
List<GaoEventRecordDailyStatView> eventRecordDailyStat(GaoStoreChartReq req);
@@ -1,15 +1,23 @@
package com.imyeyu.api.modules.gao.service.implement;
import com.hankcs.hanlp.seg.common.Term;
import com.hankcs.hanlp.tokenizer.StandardTokenizer;
import com.imyeyu.api.modules.gao.mapper.GaoCustomerMapper;
import com.imyeyu.api.modules.gao.service.GaoCustomerChartService;
import com.imyeyu.api.modules.gao.service.GaoEventRecordService;
import com.imyeyu.api.modules.gao.vo.GaoCustomerChartReq;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDiseaseTextView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDiseaseWordReq;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDiseaseWordView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordCalendarView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordDailyStatView;
import com.imyeyu.java.bean.timi.TimiException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/// 客户报表服务实现
///
@@ -20,6 +28,7 @@ import java.util.List;
public class GaoCustomerChartServiceImplement implements GaoCustomerChartService {
private final GaoEventRecordService eventRecordService;
private final GaoCustomerMapper customerMapper;
@Override
public List<GaoEventRecordDailyStatView> eventRecordDailyStat(GaoCustomerChartReq req) {
@@ -33,6 +42,42 @@ public class GaoCustomerChartServiceImplement implements GaoCustomerChartService
return eventRecordService.calendarByCustomerRange(req);
}
@Override
public List<GaoCustomerDiseaseWordView> customerDiseaseWord(GaoCustomerDiseaseWordReq req) {
TimiException.required(req, "not found req");
TimiException.required(req.getStoreId(), "not found req.storeId");
int topN = normalizeTopN(req.getTopN());
Map<String, Long> wordCountMap = new HashMap<>();
for (GaoCustomerDiseaseTextView customer : customerMapper.selectDiseaseTextByStoreId(req.getStoreId())) {
countNounWord(customer.getDisease(), wordCountMap);
countNounWord(customer.getRemark(), wordCountMap);
}
return wordCountMap.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed().thenComparing(Map.Entry.comparingByKey()))
.limit(topN)
.map(item -> new GaoCustomerDiseaseWordView(item.getKey(), item.getValue()))
.toList();
}
private void countNounWord(String text, Map<String, Long> wordCountMap) {
if (text == null || text.isBlank()) {
return;
}
for (Term term : StandardTokenizer.segment(text)) {
if (term.nature != null && term.nature.toString().startsWith("n")) {
wordCountMap.merge(term.word, 1L, Long::sum);
}
}
}
private int normalizeTopN(Integer topN) {
if (topN == null) {
return 60;
}
return Math.clamp(topN, 1, 100);
}
private void checkRangeReq(GaoCustomerChartReq req) {
TimiException.required(req, "not found req");
TimiException.required(req.getStoreId(), "not found req.storeId");
@@ -4,6 +4,7 @@ import com.imyeyu.api.bean.ModuleCode;
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.GaoCustomerPackage;
import com.imyeyu.api.modules.gao.entity.GaoPackageConfig;
@@ -12,6 +13,7 @@ import com.imyeyu.api.modules.gao.entity.GaoSalesOrder;
import com.imyeyu.api.modules.gao.entity.GaoSalesOrderItem;
import com.imyeyu.api.modules.gao.mapper.GaoCustomerPackageMapper;
import com.imyeyu.api.modules.gao.service.GaoSalesOrderService;
import com.imyeyu.api.modules.gao.service.GaoOrderStraightThroughService;
import com.imyeyu.api.modules.gao.service.GaoCustomerService;
import com.imyeyu.api.modules.gao.service.GaoCustomerPackageService;
import com.imyeyu.api.modules.gao.service.GaoPackageConfigService;
@@ -24,8 +26,8 @@ import com.imyeyu.api.modules.gao.vo.GaoCustomerPackagePurchaseItem;
import com.imyeyu.api.modules.gao.vo.GaoPackageOrderCreateData;
import com.imyeyu.api.modules.gao.vo.GaoProductSkuUnitSelection;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderCancelRequest;
import com.imyeyu.api.modules.system.entity.Logger;
import com.imyeyu.api.modules.system.service.LoggerService;
import com.imyeyu.api.modules.system.audit.AuditOperation;
import com.imyeyu.api.modules.system.audit.OperationAudit;
import com.imyeyu.api.modules.user.service.PermissionChecker;
import com.imyeyu.api.modules.user.service.UserLoginService;
import com.imyeyu.java.TimiJava;
@@ -44,6 +46,7 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -65,11 +68,11 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
private final GaoProductSkuService skuService;
private final GaoCustomerService customerService;
private final GaoSalesOrderService salesOrderService;
private final GaoOrderStraightThroughService straightThroughService;
private final GaoStoreScopeService storeScopeService;
private final AttachmentService attachmentService;
private final UserLoginService userLoginService;
private final PermissionChecker permissionChecker;
private final LoggerService loggerService;
@Override
protected BaseMapper<GaoCustomerPackage, String> mapper() {
@@ -128,10 +131,11 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
return super.get(id);
}
@OperationAudit(AuditOperation.GAO_CUSTOMER_PACKAGE_PURCHASE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoCustomerPackage purchase(GaoCustomerPackagePurchaseRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_CUSTOMER_PACKAGE_PURCHASE", request, () -> purchaseInternal(request));
return purchaseInternal(request);
}
private GaoCustomerPackage purchaseInternal(GaoCustomerPackagePurchaseRequest request) {
@@ -151,10 +155,13 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
request.setItemList(defaultPurchaseItems(configItemList));
}
List<GaoCustomerPackagePurchaseItem> requestItemList = new ArrayList<>(request.getItemList());
normalizePurchaseItems(requestItemList);
verifyPurchaseItems(requestItemList);
boolean canOverridePriceAndQuantity = permissionChecker.hasAny(ModuleCode.GAO,
com.imyeyu.api.modules.gao.bean.GaoPermissionCode.CUSTOMER_PACKAGE_PRICE_OVERRIDE.getValue());
List<GaoCustomerPackagePurchaseItem> itemList = resolvePurchaseItems(config.getStoreId(), requestItemList, configItemList, canOverridePriceAndQuantity);
GaoPermissionCode.CUSTOMER_PACKAGE_PRICE_OVERRIDE.getValue());
boolean canCreateGift = permissionChecker.hasAny(ModuleCode.GAO, GaoPermissionCode.CUSTOMER_PACKAGE_GIFT_CREATE.getValue());
List<GaoCustomerPackagePurchaseItem> itemList = resolvePurchaseItems(config.getStoreId(), requestItemList, configItemList,
canOverridePriceAndQuantity, canCreateGift);
itemList.sort(Comparator.comparing(GaoCustomerPackagePurchaseItem::getSort).thenComparing(GaoCustomerPackagePurchaseItem::getSkuId)
.thenComparing(GaoCustomerPackagePurchaseItem::getSkuUnitId));
request.setItemList(itemList);
@@ -163,6 +170,7 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
if (existingOrder != null) {
GaoCustomerPackage existingPackage = getBySalesOrderId(existingOrder.getId());
verifyPurchaseIdempotency(existingOrder, existingPackage, request);
straightThroughService.complete(salesOrderService.detail(existingOrder.getId()), request.getStraightThrough());
return existingPackage;
}
Long purchasedAt = TimiJava.defaultIfNull(request.getPurchasedAt(), Time.now());
@@ -200,16 +208,19 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
result.setStandardAmountSnapshot(config.getStandardAmount());
result.setRemark(request.getRemark());
super.create(result);
straightThroughService.complete(salesOrder, request.getStraightThrough());
salesOrder = salesOrderService.detail(salesOrder.getId());
result.setSalesOrder(salesOrder);
result.setItemList(salesOrder.getItemList());
result.setStatus(salesOrder.getStatus());
return result;
}
@OperationAudit(AuditOperation.GAO_CUSTOMER_PACKAGE_CANCEL)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoCustomerPackage cancel(GaoCustomerPackageCancelRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_CUSTOMER_PACKAGE_CANCEL", request, () -> cancelInternal(request));
return cancelInternal(request);
}
private GaoCustomerPackage cancelInternal(GaoCustomerPackageCancelRequest request) {
@@ -245,6 +256,7 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
GaoCustomerPackagePurchaseItem item = new GaoCustomerPackagePurchaseItem();
item.setSkuId(configItem.getSkuId());
item.setSkuUnitId(configItem.getSkuUnitId());
item.setGifted(false);
item.setProductNameSnapshot(configItem.getProductNameSnapshot());
item.setProductIdSnapshot(configItem.getProductIdSnapshot());
item.setSkuCodeSnapshot(configItem.getSkuCodeSnapshot());
@@ -264,6 +276,7 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
item.setItemType(GaoSalesOrderItem.ItemType.PACKAGE_ENTITLEMENT);
item.setSkuId(source.getSkuId());
item.setSkuUnitId(source.getSkuUnitId());
item.setGifted(Boolean.TRUE.equals(source.getGifted()));
item.setProductNameSnapshot(source.getProductNameSnapshot());
item.setProductIdSnapshot(source.getProductIdSnapshot());
item.setSkuCodeSnapshot(source.getSkuCodeSnapshot());
@@ -283,6 +296,32 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
return item;
}
private void normalizePurchaseItems(List<GaoCustomerPackagePurchaseItem> itemList) {
Map<String, Integer> indexMap = new LinkedHashMap<>();
for (int i = 0; i < itemList.size(); i++) {
GaoCustomerPackagePurchaseItem item = itemList.get(i);
if (item == null) {
continue;
}
item.setGifted(Boolean.TRUE.equals(item.getGifted()));
if (item.getSkuId() == null || item.getSkuUnitId() == null || item.getTotalQuantity() == null) {
continue;
}
String key = item.getSkuId() + "|" + item.getSkuUnitId() + "|" + item.getGifted();
Integer existingIndex = indexMap.putIfAbsent(key, i);
if (existingIndex == null) {
continue;
}
GaoCustomerPackagePurchaseItem existingItem = itemList.get(existingIndex);
if (existingItem == null || existingItem.getTotalQuantity() == null) {
continue;
}
existingItem.setTotalQuantity(existingItem.getTotalQuantity().add(item.getTotalQuantity()));
itemList.set(i, null);
}
itemList.removeIf(item -> item == null);
}
private void verifyPurchaseItems(List<GaoCustomerPackagePurchaseItem> itemList) {
Set<String> keySet = new HashSet<>();
for (int i = 0; i < itemList.size(); i++) {
@@ -293,14 +332,15 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
TimiException.required(item.getTotalQuantity(), "购买产品数量不能为空");
TimiException.requiredTrue(0 <= item.getTotalQuantity().signum(), "购买产品数量不能为负数");
TimiException.requiredTrue(3 >= item.getTotalQuantity().stripTrailingZeros().scale(), "购买产品数量最多保留三位小数");
String key = item.getSkuId() + "|" + item.getSkuUnitId();
item.setGifted(Boolean.TRUE.equals(item.getGifted()));
String key = item.getSkuId() + "|" + item.getSkuUnitId() + "|" + item.getGifted();
TimiException.requiredTrue(keySet.add(key), "购买套餐内 SKU 和单位不能重复");
item.setSort(TimiJava.defaultIfNull(item.getSort(), i));
}
}
private List<GaoCustomerPackagePurchaseItem> resolvePurchaseItems(String storeId, List<GaoCustomerPackagePurchaseItem> requestItemList,
List<GaoPackageConfigItem> configItemList, boolean canOverridePriceAndQuantity) {
List<GaoPackageConfigItem> configItemList, boolean canOverridePriceAndQuantity, boolean canCreateGift) {
Map<String, List<GaoPackageConfigItem>> configItemMap = configItemList.stream()
.collect(Collectors.groupingBy(GaoPackageConfigItem::getSkuId));
Map<String, BigDecimal> configBaseQuantityMap = new HashMap<>();
@@ -311,23 +351,32 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
Map<String, BigDecimal> requestBaseQuantityMap = new HashMap<>();
List<GaoCustomerPackagePurchaseItem> result = new ArrayList<>();
for (GaoCustomerPackagePurchaseItem requestItem : requestItemList) {
boolean gifted = Boolean.TRUE.equals(requestItem.getGifted());
List<GaoPackageConfigItem> skuConfigItemList = configItemMap.get(requestItem.getSkuId());
TimiException.requiredTrue(skuConfigItemList != null && !skuConfigItemList.isEmpty(), "购买 SKU 不属于套餐配置");
if (gifted) {
TimiException.requiredTrue(canCreateGift, "没有客户套餐创建赠送权限");
} else {
TimiException.requiredTrue(skuConfigItemList != null && !skuConfigItemList.isEmpty(), "购买 SKU 不属于套餐配置");
}
GaoProductSkuUnitSelection selection = skuService.getSellableSkuUnit(storeId, requestItem.getSkuId(), requestItem.getSkuUnitId());
TimiException.requiredTrue(skuConfigItemList.stream().anyMatch(configItem -> 0 <= configItem.getBaseQuantityPerUnitSnapshot()
.compareTo(selection.getUnit().getBaseQuantityPerUnit())), "套餐权益单位不能高于配置单位");
if (!gifted) {
TimiException.requiredTrue(skuConfigItemList.stream().anyMatch(configItem -> 0 <= configItem.getBaseQuantityPerUnitSnapshot()
.compareTo(selection.getUnit().getBaseQuantityPerUnit())), "套餐权益单位不能高于配置单位");
}
BigDecimal requestBaseQuantity = toBaseQuantity(requestItem.getTotalQuantity(), selection.getUnit().getBaseQuantityPerUnit(), "套餐权益基础数量最多保留三位小数");
requestBaseQuantityMap.merge(requestItem.getSkuId(), requestBaseQuantity, BigDecimal::add);
if (!gifted) {
requestBaseQuantityMap.merge(requestItem.getSkuId(), requestBaseQuantity, BigDecimal::add);
}
if (requestItem.getTotalQuantity().signum() == 0) {
continue;
}
if (!Boolean.TRUE.equals(selection.getSku().getAllowDecimalQuantity())) {
TimiException.requiredTrue(requestItem.getTotalQuantity().stripTrailingZeros().scale() <= 0, "该 SKU 不允许小数数量");
}
GaoPackageConfigItem template = resolveItemTemplate(skuConfigItemList, selection.getUnit().getId());
GaoCustomerPackagePurchaseItem item = new GaoCustomerPackagePurchaseItem();
item.setSkuId(selection.getSku().getId());
item.setSkuUnitId(selection.getUnit().getId());
item.setGifted(gifted);
item.setProductNameSnapshot(selection.getProduct().getName());
item.setProductIdSnapshot(selection.getProduct().getId());
item.setSkuCodeSnapshot(selection.getSku().getCode());
@@ -336,8 +385,14 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
item.setUnitSnapshot(selection.getUnit().getName());
item.setBaseQuantityPerUnitSnapshot(selection.getUnit().getBaseQuantityPerUnit());
item.setTotalQuantity(requestItem.getTotalQuantity());
item.setSort(template.getSort());
item.setRemark(template.getRemark());
if (gifted) {
item.setSort(requestItem.getSort());
item.setRemark(requestItem.getRemark());
} else {
GaoPackageConfigItem template = resolveItemTemplate(skuConfigItemList, selection.getUnit().getId());
item.setSort(template.getSort());
item.setRemark(template.getRemark());
}
result.add(item);
}
TimiException.requiredTrue(!result.isEmpty(), "购买套餐至少需要一个产品");
@@ -377,9 +432,10 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
List<GaoCustomerPackagePurchaseItem> requestItemList = TimiJava.defaultIfNull(request.getItemList(), List.of());
verifyPurchaseItems(requestItemList);
TimiException.requiredTrue(existingItemList.size() == requestItemList.size(), "requestNo 对应购买请求内容不一致");
Map<String, GaoSalesOrderItem> existingItemMap = existingItemList.stream().collect(Collectors.toMap(item -> item.getSkuId() + "|" + item.getSkuUnitId(), Function.identity()));
Map<String, GaoSalesOrderItem> existingItemMap = existingItemList.stream().collect(Collectors.toMap(item -> item.getSkuId() + "|" + item.getSkuUnitId() + "|"
+ Boolean.TRUE.equals(item.getGifted()), Function.identity()));
for (GaoCustomerPackagePurchaseItem item : requestItemList) {
GaoSalesOrderItem existingItem = existingItemMap.get(item.getSkuId() + "|" + item.getSkuUnitId());
GaoSalesOrderItem existingItem = existingItemMap.get(item.getSkuId() + "|" + item.getSkuUnitId() + "|" + Boolean.TRUE.equals(item.getGifted()));
TimiException.required(existingItem, "requestNo 对应购买请求内容不一致");
TimiException.requiredTrue(existingItem.getItemType() == GaoSalesOrderItem.ItemType.PACKAGE_ENTITLEMENT
&& existingItem.getQuantity().compareTo(item.getTotalQuantity()) == 0
@@ -14,7 +14,9 @@ import com.imyeyu.api.modules.gao.service.GaoCustomerService;
import com.imyeyu.api.modules.gao.service.GaoEventRecordService;
import com.imyeyu.api.modules.gao.service.GaoPointAccountService;
import com.imyeyu.api.modules.gao.util.CustomerXSSFBuilder;
import com.imyeyu.api.modules.gao.vo.GaoCustomerAgeStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateCustomerView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerGenderStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerPage;
import com.imyeyu.api.modules.gao.vo.GaoCustomerTrendStateReq;
@@ -410,6 +412,10 @@ public class GaoCustomerServiceImplement extends AbstractEntityService<GaoCustom
for (GaoCustomerDailyStateView view : mapper.stateDailyNewCustomer(req.getStoreId(), beginCreatedAt, endCreatedAt)) {
newCustomerCountMap.put(view.getDateValue(), TimiJava.defaultIfNull(view.getNewCustomerCount(), 0L));
}
Map<Integer, List<GaoCustomerDailyStateCustomerView>> customerListMap = new HashMap<>();
for (GaoCustomerDailyStateCustomerView customer : mapper.selectDailyNewCustomer(req.getStoreId(), beginCreatedAt, endCreatedAt)) {
customerListMap.computeIfAbsent(customer.getDateValue(), key -> new ArrayList<>()).add(customer);
}
long totalCustomerCount = TimiJava.defaultIfNull(mapper.countCreatedBefore(req.getStoreId(), beginCreatedAt), 0L);
List<GaoCustomerDailyStateView> list = new ArrayList<>();
for (LocalDate date = beginDate; !date.isAfter(endDate); date = date.plusDays(1)) {
@@ -420,6 +426,7 @@ public class GaoCustomerServiceImplement extends AbstractEntityService<GaoCustom
view.setDateValue(dateValue);
view.setNewCustomerCount(newCustomerCount);
view.setTotalCustomerCount(totalCustomerCount);
view.setCustomerList(customerListMap.getOrDefault(dateValue, List.of()));
list.add(view);
}
return list;
@@ -431,6 +438,12 @@ public class GaoCustomerServiceImplement extends AbstractEntityService<GaoCustom
return mapper.stateGender(storeId);
}
@Override
public List<GaoCustomerAgeStatView> stateAge(String storeId) {
TimiException.required(storeId, "not found storeId");
return mapper.stateAge(storeId);
}
private void changeInvitedCount(String customerId, long delta) {
if (TimiJava.isEmpty(customerId) || delta == 0) {
return;
@@ -31,8 +31,8 @@ import com.imyeyu.api.modules.gao.vo.GaoOrderFulfillmentPrepareStockRequest;
import com.imyeyu.api.modules.gao.vo.GaoOrderFulfillmentRevokeRequest;
import com.imyeyu.api.modules.gao.vo.GaoProductSkuUnitSelection;
import com.imyeyu.api.modules.gao.vo.GaoStockChangeItem;
import com.imyeyu.api.modules.system.entity.Logger;
import com.imyeyu.api.modules.system.service.LoggerService;
import com.imyeyu.api.modules.system.audit.AuditOperation;
import com.imyeyu.api.modules.system.audit.OperationAudit;
import com.imyeyu.api.modules.user.service.RoleChecker;
import com.imyeyu.api.modules.user.service.UserLoginService;
import com.imyeyu.java.TimiJava;
@@ -79,7 +79,6 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
private final RoleChecker roleChecker;
private final UserLoginService userLoginService;
private final AttachmentService attachmentService;
private final LoggerService loggerService;
@Override
protected BaseMapper<GaoOrderFulfillment, String> mapper() {
@@ -147,10 +146,11 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
return mapper.countPendingBySalesOrderId(salesOrderId);
}
@OperationAudit(AuditOperation.GAO_ORDER_FULFILLMENT_CREATE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoOrderFulfillment create(GaoOrderFulfillmentCreateRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_ORDER_FULFILLMENT_CREATE", request, () -> createInternal(request));
return createInternal(request);
}
private GaoOrderFulfillment createInternal(GaoOrderFulfillmentCreateRequest request) {
@@ -183,10 +183,11 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
return detail(fulfillment.getId());
}
@OperationAudit(AuditOperation.GAO_ORDER_FULFILLMENT_PREPARE_STOCK)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoOrderFulfillment prepareStock(GaoOrderFulfillmentPrepareStockRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_ORDER_FULFILLMENT_PREPARE_STOCK", request, () -> prepareStockInternal(request));
return prepareStockInternal(request);
}
private GaoOrderFulfillment prepareStockInternal(GaoOrderFulfillmentPrepareStockRequest request) {
@@ -206,7 +207,8 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
List<GaoStockChangeItem> stockItems = buildStockItems(order.getStoreId(), sourceList);
applySkuSpecSnapshots(stockItems, fulfillmentItemList);
TimiException.requiredTrue(!stockItems.isEmpty(), "履约单没有需要占用的实物库存");
TimiException.requiredTrue(productStockService.hasSufficientForFulfillment(order.getStoreId(), stockItems), "库存仍然不足,不能准备履约");
String stockShortageMessage = productStockService.getFulfillmentStockShortageMessage(order.getStoreId(), stockItems);
TimiException.requiredTrue(stockShortageMessage == null, stockShortageMessage);
String operatorUserId = userLoginService.getRequireLoginUserId();
productStockService.reserveForFulfillment(order.getStoreId(), fulfillment.getId(), stockItems,
stockRequestNo(fulfillment, "RESERVE"), operatorUserId);
@@ -217,10 +219,11 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
return detail(fulfillment.getId());
}
@OperationAudit(AuditOperation.GAO_ORDER_FULFILLMENT_VERIFY)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoOrderFulfillment verify(GaoOrderFulfillmentActionRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_ORDER_FULFILLMENT_VERIFY", request, () -> verifyInternal(request));
return verifyInternal(request);
}
private GaoOrderFulfillment verifyInternal(GaoOrderFulfillmentActionRequest request) {
@@ -254,16 +257,18 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
return detail(fulfillment.getId());
}
@OperationAudit(AuditOperation.GAO_ORDER_FULFILLMENT_REJECT)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoOrderFulfillment reject(GaoOrderFulfillmentActionRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_ORDER_FULFILLMENT_REJECT", request, () -> releaseInternal(request, GaoOrderFulfillment.Status.REJECTED));
return releaseInternal(request, GaoOrderFulfillment.Status.REJECTED);
}
@OperationAudit(AuditOperation.GAO_ORDER_FULFILLMENT_CANCEL)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoOrderFulfillment cancel(GaoOrderFulfillmentActionRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_ORDER_FULFILLMENT_CANCEL", request, () -> cancelInternal(request));
return cancelInternal(request);
}
private GaoOrderFulfillment cancelInternal(GaoOrderFulfillmentActionRequest request) {
@@ -306,10 +311,11 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
return detail(fulfillment.getId());
}
@OperationAudit(AuditOperation.GAO_ORDER_FULFILLMENT_REVOKE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoOrderFulfillment revoke(GaoOrderFulfillmentRevokeRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_ORDER_FULFILLMENT_REVOKE", request, () -> revokeInternal(request));
return revokeInternal(request);
}
private GaoOrderFulfillment revokeInternal(GaoOrderFulfillmentRevokeRequest request) {
@@ -445,12 +451,32 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
GaoSalesOrderItem.ItemType expectedItemType = order.getOrderType() == GaoSalesOrder.OrderType.PACKAGE
? GaoSalesOrderItem.ItemType.PACKAGE_ENTITLEMENT : GaoSalesOrderItem.ItemType.DIRECT_PRODUCT;
TimiException.requiredTrue(source.getItemType() == expectedItemType, "订单项类型与订单类型不匹配");
GaoProductSkuUnitSelection selection = skuService.getSellableSkuUnit(order.getStoreId(), source.getSkuId(), requestItem.getSkuUnitId());
if (requestItem.getSkuUnitId().equals(source.getSkuUnitId())) {
result.add(toSourceByOrderSnapshot(order, source, requestItem.getQuantity(), checkAvailable));
continue;
}
GaoProductSkuUnitSelection selection = skuService.getFulfillableSkuUnit(order.getStoreId(), source.getSkuId(), requestItem.getSkuUnitId());
result.add(toSource(order, source, selection.getProduct(), selection.getSku(), selection.getUnit(), requestItem.getQuantity(), checkAvailable));
}
return result;
}
private SourceEntry toSourceByOrderSnapshot(GaoSalesOrder order, GaoSalesOrderItem source, BigDecimal quantity, boolean checkAvailable) {
GaoProductSku sku = skuService.get(source.getSkuId());
TimiException.required(sku, "履约来源 SKU 不存在");
GaoProduct product = productService.get(sku.getProductId());
TimiException.required(product, "履约来源产品不存在");
TimiException.requiredTrue(order.getStoreId().equals(product.getStoreId()), "履约来源产品不属于当前门店");
quantity = requiredPositiveQuantity(quantity, "履约数量");
if (!Boolean.TRUE.equals(sku.getAllowDecimalQuantity())) {
TimiException.requiredTrue(quantity.stripTrailingZeros().scale() <= 0, "该产品不允许小数数量");
}
BigDecimal baseQuantityPerUnit = requiredBaseQuantityPerUnit(source.getBaseQuantityPerUnitSnapshot());
BigDecimal baseQuantity = toBaseQuantity(quantity, baseQuantityPerUnit, "履约基础数量最多保留三位小数");
return buildSource(order, source, source.getSkuUnitId(), source.getUnitSnapshot(), baseQuantityPerUnit, quantity,
baseQuantity, product, sku, checkAvailable);
}
private List<SourceEntry> loadPersistedSources(GaoSalesOrder order, List<GaoOrderFulfillmentItem> itemList) {
TimiException.requiredTrue(itemList != null && !itemList.isEmpty(), "履约单没有明细");
List<GaoOrderFulfillmentItem> sortedItemList = new ArrayList<>(itemList);
@@ -16,8 +16,8 @@ import com.imyeyu.api.modules.gao.vo.GaoOrderPaymentNoRefundRequest;
import com.imyeyu.api.modules.gao.vo.GaoOrderPaymentReceiveRequest;
import com.imyeyu.api.modules.gao.vo.GaoOrderPaymentRefundRequest;
import com.imyeyu.api.modules.gao.vo.GaoOrderPaymentRevokeRequest;
import com.imyeyu.api.modules.system.entity.Logger;
import com.imyeyu.api.modules.system.service.LoggerService;
import com.imyeyu.api.modules.system.audit.AuditOperation;
import com.imyeyu.api.modules.system.audit.OperationAudit;
import com.imyeyu.api.modules.user.service.UserLoginService;
import com.imyeyu.api.modules.user.service.PermissionChecker;
import com.imyeyu.java.TimiJava;
@@ -44,15 +44,22 @@ public class GaoOrderPaymentServiceImplement implements GaoOrderPaymentService {
private final AttachmentService attachmentService;
private final UserLoginService userLoginService;
private final PermissionChecker permissionChecker;
private final LoggerService loggerService;
@OperationAudit(AuditOperation.GAO_SALES_ORDER_PAYMENT_RECEIVE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoFundRecord receive(GaoOrderPaymentReceiveRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_SALES_ORDER_PAYMENT_RECEIVE", request, () -> receiveInternal(request));
return receiveInternal(request, false);
}
private GaoFundRecord receiveInternal(GaoOrderPaymentReceiveRequest request) {
@OperationAudit(AuditOperation.GAO_SALES_ORDER_PAYMENT_RECEIVE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoFundRecord receiveAndVerify(GaoOrderPaymentReceiveRequest request) {
return receiveInternal(request, true);
}
private GaoFundRecord receiveInternal(GaoOrderPaymentReceiveRequest request, boolean verified) {
TimiException.required(request, "not found order payment request");
TimiException.required(request.getSalesOrderId(), "not found salesOrderId");
TimiException.required(request.getAmount(), "收款金额不能为空");
@@ -62,7 +69,7 @@ public class GaoOrderPaymentServiceImplement implements GaoOrderPaymentService {
GaoFundRecord existing = fundRecordService.getByRequestNo(order.getStoreId(), requestNo);
if (existing != null) {
verifySameReceive(existing, order, request);
return existing;
return verified && existing.getStatus() == GaoFundRecord.Status.PENDING ? verify(existing.getId()) : existing;
}
TimiException.requiredTrue(order.getStatus() != GaoSalesOrder.Status.CANCELLED, "已取消订单不能收款");
TimiException.requiredTrue(order.getRefundStatus() != GaoSalesOrder.RefundStatus.REFUNDED, "已完成退款订单不能收款");
@@ -71,20 +78,38 @@ public class GaoOrderPaymentServiceImplement implements GaoOrderPaymentService {
TimiException.requiredTrue(pending + paid + request.getAmount() <= safe(order.getSaleAmount()), "累计收款不能超过成交价");
TimiException.requiredTrue(hasPhoto(request.getAttachmentList()), "收款必须上传照片");
GaoFundRecord record = newRecord(order, GaoFundRecord.OperationType.RECEIVE, requestNo, request.getAmount(), request.getOccurredAt(), request.getRemark());
record.setStatus(GaoFundRecord.Status.PENDING);
record.setStatus(verified ? GaoFundRecord.Status.VERIFIED : GaoFundRecord.Status.PENDING);
String verifierUserId = verified ? userLoginService.getRequireLoginUserId() : null;
Long verifiedAt = verified ? Time.now() : null;
if (verified) {
record.setVerifierUserId(verifierUserId);
record.setVerifiedAt(verifiedAt);
}
fundRecordService.createByOrderPayment(record);
order.setPendingPaidAmount(pending + request.getAmount());
if (!verified) {
order.setPendingPaidAmount(pending + request.getAmount());
} else {
Long paidAtBefore = order.getPaidAt();
order.setPaidAmount(paid + request.getAmount());
orderService.recomputePaymentStatus(order);
orderService.updatePayment(order);
if (paidAtBefore == null && order.getPaidAt() != null) {
pointTriggerService.onSalesOrderPaid(order);
}
attachmentService.updateByBizId(Attachment.BizType.GAO_FUND_RECORD, record.getId(), request.getAttachmentList());
return record;
}
orderService.recomputePaymentStatus(order);
orderService.updatePayment(order);
attachmentService.updateByBizId(Attachment.BizType.GAO_FUND_RECORD, record.getId(), request.getAttachmentList());
return record;
}
@OperationAudit(AuditOperation.GAO_SALES_ORDER_PAYMENT_VERIFY)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoFundRecord verify(String fundRecordId) {
return loggerService.execute(Logger.Module.GAO, "GAO_SALES_ORDER_PAYMENT_VERIFY", fundRecordId, () -> {
GaoFundRecord record = lockOrderRecord(fundRecordId);
GaoFundRecord record = lockOrderRecord(fundRecordId);
TimiException.requiredTrue(record.getOperationType() == GaoFundRecord.OperationType.RECEIVE && record.getStatus() == GaoFundRecord.Status.PENDING, "收款记录不是待核销状态");
GaoSalesOrder order = orderService.getForUpdate(record.getBizId());
Long paidAtBefore = order.getPaidAt();
@@ -102,7 +127,6 @@ public class GaoOrderPaymentServiceImplement implements GaoOrderPaymentService {
pointTriggerService.onSalesOrderPaid(order);
}
return record;
});
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@@ -140,10 +164,11 @@ public class GaoOrderPaymentServiceImplement implements GaoOrderPaymentService {
return record;
}
@OperationAudit(AuditOperation.GAO_SALES_ORDER_REFUND)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoFundRecord refund(GaoOrderPaymentRefundRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_SALES_ORDER_REFUND", request, () -> refundInternal(request));
return refundInternal(request);
}
private GaoFundRecord refundInternal(GaoOrderPaymentRefundRequest request) {
@@ -177,11 +202,11 @@ public class GaoOrderPaymentServiceImplement implements GaoOrderPaymentService {
return record;
}
@OperationAudit(AuditOperation.GAO_SALES_ORDER_CONFIRM_NO_REFUND)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoFundRecord confirmNoRefund(GaoOrderPaymentNoRefundRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_SALES_ORDER_CONFIRM_NO_REFUND", request, () -> {
TimiException.required(request, "not found no refund request");
TimiException.required(request, "not found no refund request");
GaoSalesOrder order = orderService.getForUpdate(request.getSalesOrderId());
String requestNo = requiredText(request.getRequestNo(), "not found requestNo");
String reason = requiredText(request.getReason(), "确认无需退款原因不能为空");
@@ -199,13 +224,13 @@ public class GaoOrderPaymentServiceImplement implements GaoOrderPaymentService {
order.setRefundStatus(GaoSalesOrder.RefundStatus.REFUNDED);
orderService.updatePayment(order);
return record;
});
}
@OperationAudit(AuditOperation.GAO_SALES_ORDER_FUND_REVOKE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoFundRecord revoke(GaoOrderPaymentRevokeRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_SALES_ORDER_FUND_REVOKE", request, () -> revokeInternal(request));
return revokeInternal(request);
}
private GaoFundRecord revokeInternal(GaoOrderPaymentRevokeRequest request) {
@@ -103,20 +103,17 @@ public class GaoOrderReminderServiceImplement implements GaoOrderReminderService
log.warn("GAO 订单提醒跳过不存在的店长用户: storeId[{}], userId[{}]", storeId, manager.getUserId());
continue;
}
String remindedId = "%s:%s:%s:%s".formatted(LocalDate.now(ZoneId.of(ZONE_ID)), storeId, manager.getUserId(), reminderType);
if (notifyService.existsByBizReminded(NOTIFY_BIZ_TYPE, reminderType, remindedId)) {
continue;
}
String remindedKey = "%s:%s:%s:%s".formatted(LocalDate.now(ZoneId.of(ZONE_ID)), storeId, manager.getUserId(), reminderType);
long sendAt = Time.now();
Notify notify = new Notify();
notify.setBizType(NOTIFY_BIZ_TYPE);
notify.setBizRemindedType(reminderType);
notify.setBizRemindedId(remindedId);
notify.setBizRemindedKey(remindedKey);
notify.setDetailList(List.of(
createInternalDetail(manager.getUserId(), content, sendAt),
createSmsDetail(user.getPhoneNo(), customerNames, action, content, sendAt)
));
notifyService.create(notify);
notifyService.createIfAbsent(notify);
} catch (Exception e) {
log.error("GAO 订单提醒发送失败: storeId[{}], userId[{}], reminderType[{}]", storeId, manager.getUserId(), reminderType, e);
}
@@ -0,0 +1,155 @@
package com.imyeyu.api.modules.gao.service.implement;
import com.imyeyu.api.bean.ModuleCode;
import com.imyeyu.api.config.dbsource.TimiServerDBConfig;
import com.imyeyu.api.modules.gao.bean.GaoPermissionCode;
import com.imyeyu.api.modules.gao.entity.GaoFundRecord;
import com.imyeyu.api.modules.gao.entity.GaoOrderFulfillment;
import com.imyeyu.api.modules.gao.entity.GaoSalesOrder;
import com.imyeyu.api.modules.gao.entity.GaoSalesOrderItem;
import com.imyeyu.api.modules.gao.service.GaoOrderFulfillmentService;
import com.imyeyu.api.modules.gao.service.GaoOrderPaymentService;
import com.imyeyu.api.modules.gao.service.GaoOrderStraightThroughService;
import com.imyeyu.api.modules.gao.vo.GaoOrderFulfillmentCreateRequest;
import com.imyeyu.api.modules.gao.vo.GaoOrderFulfillmentActionRequest;
import com.imyeyu.api.modules.gao.vo.GaoOrderFulfillmentItemRequest;
import com.imyeyu.api.modules.gao.vo.GaoOrderPaymentReceiveRequest;
import com.imyeyu.api.modules.gao.vo.GaoOrderStraightThroughRequest;
import com.imyeyu.api.modules.user.service.PermissionChecker;
import com.imyeyu.java.TimiJava;
import com.imyeyu.java.bean.timi.TimiException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
/// GAO 订单创建直通服务实现
///
/// @author Codex
/// @since 2026-09-13
@Service
@RequiredArgsConstructor
public class GaoOrderStraightThroughServiceImplement implements GaoOrderStraightThroughService {
private final GaoOrderPaymentService paymentService;
private final GaoOrderFulfillmentService fulfillmentService;
private final PermissionChecker permissionChecker;
@Override
@Transactional(TimiServerDBConfig.ROLLBACKER)
public void complete(GaoSalesOrder order, GaoOrderStraightThroughRequest straightThrough) {
if (straightThrough == null) {
return;
}
boolean paymentVerified = Boolean.TRUE.equals(straightThrough.getPaymentVerified());
boolean receiveFullPayment = Boolean.TRUE.equals(straightThrough.getReceiveFullPayment());
boolean fulfillAll = Boolean.TRUE.equals(straightThrough.getFulfillAll());
Long paymentAmount = paymentVerified ? straightThrough.getPaymentAmount() : order.getSaleAmount();
if (!paymentVerified && !receiveFullPayment && !fulfillAll) {
return;
}
TimiException.required(order, "not found sales order");
TimiException.required(order.getId(), "not found salesOrderId");
TimiException.requiredTrue(!(paymentVerified && receiveFullPayment), "已核销收款和已全额收款和核销不能同时启用");
if (paymentVerified) {
TimiException.required(paymentAmount, "已核销收款金额不能为空");
TimiException.requiredTrue(0 < paymentAmount && paymentAmount <= safe(order.getSaleAmount()), "已核销收款金额不能超过订单金额");
}
TimiException.requiredTrue(!fulfillAll || receiveFullPayment || order.getSaleAmount() == 0
|| (paymentVerified && paymentAmount.equals(order.getSaleAmount())),
"全部履约直通必须同时完成收款核销");
checkPermissions(order.getOrderType(), paymentVerified || receiveFullPayment, fulfillAll);
if (receiveFullPayment) {
settle(order, straightThrough);
} else if (paymentVerified) {
verifyReceivedPayment(order, straightThrough, paymentAmount);
}
if (fulfillAll) {
fulfill(order, straightThrough);
}
}
private void checkPermissions(GaoSalesOrder.OrderType orderType, boolean paymentCompleted, boolean fulfillAll) {
List<String> permissionCodeList = new ArrayList<>();
permissionCodeList.add(orderType == GaoSalesOrder.OrderType.PACKAGE
? GaoPermissionCode.CUSTOMER_PACKAGE_CREATE.getValue() : GaoPermissionCode.SALES_ORDER_CREATE.getValue());
if (paymentCompleted) {
permissionCodeList.add(GaoPermissionCode.FUND_RECEIVE_CREATE.getValue());
permissionCodeList.add(GaoPermissionCode.FUND_RECEIVE_VERIFY.getValue());
}
if (fulfillAll) {
permissionCodeList.add(GaoPermissionCode.ORDER_FULFILLMENT_CREATE.getValue());
permissionCodeList.add(GaoPermissionCode.ORDER_FULFILLMENT_VERIFY.getValue());
}
permissionChecker.checkAll(ModuleCode.GAO, permissionCodeList.toArray(String[]::new));
}
private void settle(GaoSalesOrder order, GaoOrderStraightThroughRequest straightThrough) {
if (order.getSaleAmount() == null || order.getSaleAmount() == 0) {
return;
}
GaoOrderPaymentReceiveRequest paymentRequest = new GaoOrderPaymentReceiveRequest();
paymentRequest.setSalesOrderId(order.getId());
paymentRequest.setRequestNo("ORDER-%s-FULL-PAY".formatted(order.getId()));
paymentRequest.setAmount(order.getSaleAmount());
paymentRequest.setOccurredAt(TimiJava.defaultIfNull(straightThrough.getPaymentOccurredAt(), order.getPurchasedAt()));
paymentRequest.setAttachmentList(straightThrough.getPaymentAttachmentList());
paymentRequest.setRemark(TimiJava.defaultIfEmpty(straightThrough.getPaymentRemark(), "随订单创建直接全额收款"));
GaoFundRecord record = paymentService.receive(paymentRequest);
if (record.getStatus() == GaoFundRecord.Status.PENDING) {
paymentService.verify(record.getId());
return;
}
TimiException.requiredTrue(record.getStatus() == GaoFundRecord.Status.VERIFIED, "直通收款记录不是已核销状态");
}
private void verifyReceivedPayment(GaoSalesOrder order, GaoOrderStraightThroughRequest straightThrough, Long paymentAmount) {
GaoOrderPaymentReceiveRequest paymentRequest = new GaoOrderPaymentReceiveRequest();
paymentRequest.setSalesOrderId(order.getId());
paymentRequest.setRequestNo("ORDER-%s-VERIFIED-PAY".formatted(order.getId()));
paymentRequest.setAmount(paymentAmount);
paymentRequest.setOccurredAt(TimiJava.defaultIfNull(straightThrough.getPaymentOccurredAt(), order.getPurchasedAt()));
paymentRequest.setAttachmentList(straightThrough.getPaymentAttachmentList());
paymentRequest.setRemark(TimiJava.defaultIfEmpty(straightThrough.getPaymentRemark(), "随订单创建直接核销已有收款"));
GaoFundRecord record = paymentService.receiveAndVerify(paymentRequest);
TimiException.requiredTrue(record.getStatus() == GaoFundRecord.Status.VERIFIED, "直通收款记录不是已核销状态");
}
private void fulfill(GaoSalesOrder order, GaoOrderStraightThroughRequest straightThrough) {
List<GaoSalesOrderItem> orderItemList = order.getItemList();
TimiException.requiredTrue(orderItemList != null && !orderItemList.isEmpty(), "销售订单没有可履约明细");
List<GaoOrderFulfillmentItemRequest> itemList = orderItemList.stream().map(item -> {
GaoOrderFulfillmentItemRequest result = new GaoOrderFulfillmentItemRequest();
result.setSourceOrderItemId(item.getId());
result.setSkuUnitId(item.getSkuUnitId());
result.setQuantity(item.getQuantity());
return result;
}).toList();
GaoOrderFulfillmentCreateRequest fulfillmentRequest = new GaoOrderFulfillmentCreateRequest();
fulfillmentRequest.setSalesOrderId(order.getId());
fulfillmentRequest.setRequestNo("ORDER-%s-FULL-FULFILL".formatted(order.getId()));
fulfillmentRequest.setDeliveredAt(TimiJava.defaultIfNull(straightThrough.getDeliveredAt(), order.getPurchasedAt()));
fulfillmentRequest.setItemList(itemList);
fulfillmentRequest.setAttachmentList(straightThrough.getFulfillmentAttachmentList());
fulfillmentRequest.setRemark(TimiJava.defaultIfEmpty(straightThrough.getFulfillmentRemark(), "随订单创建直接全部履约"));
GaoOrderFulfillment fulfillment = fulfillmentService.create(fulfillmentRequest);
if (fulfillment.getStatus() == GaoOrderFulfillment.Status.PENDING) {
fulfillmentService.verify(actionRequest(fulfillment.getId()));
return;
}
TimiException.requiredTrue(fulfillment.getStatus() == GaoOrderFulfillment.Status.VERIFIED,
fulfillment.getStatus() == GaoOrderFulfillment.Status.WAITING_STOCK ? "库存不足,不能全部履约直通" : "直通履约单不是已核销状态");
}
private GaoOrderFulfillmentActionRequest actionRequest(String fulfillmentId) {
GaoOrderFulfillmentActionRequest request = new GaoOrderFulfillmentActionRequest();
request.setFulfillmentId(fulfillmentId);
return request;
}
private long safe(Long value) {
return value == null ? 0L : value;
}
}
@@ -6,8 +6,8 @@ import com.imyeyu.api.modules.gao.entity.GaoPackageConfigItem;
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.system.entity.Logger;
import com.imyeyu.api.modules.system.service.LoggerService;
import com.imyeyu.api.modules.system.audit.AuditOperation;
import com.imyeyu.api.modules.system.audit.OperationAudit;
import com.imyeyu.api.modules.gao.mapper.GaoPackageConfigItemMapper;
import com.imyeyu.api.modules.gao.mapper.GaoPackageConfigMapper;
import com.imyeyu.api.modules.gao.service.GaoPackageConfigService;
@@ -44,7 +44,6 @@ public class GaoPackageConfigServiceImplement extends AbstractEntityService<GaoP
private final GaoPackageConfigItemMapper itemMapper;
private final GaoProductSkuService skuService;
private final UserLoginService userLoginService;
private final LoggerService loggerService;
@Override
protected BaseMapper<GaoPackageConfig, String> mapper() {
@@ -90,13 +89,11 @@ public class GaoPackageConfigServiceImplement extends AbstractEntityService<GaoP
.collect(java.util.stream.Collectors.groupingBy(GaoPackageConfigItem::getPackageConfigId));
}
@OperationAudit(AuditOperation.GAO_PACKAGE_CONFIG_CREATE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void create(GaoPackageConfig config) {
loggerService.execute(Logger.Module.GAO, "GAO_PACKAGE_CONFIG_CREATE", config, () -> {
createInternal(config);
return config;
});
createInternal(config);
}
private void createInternal(GaoPackageConfig config) {
@@ -109,13 +106,11 @@ public class GaoPackageConfigServiceImplement extends AbstractEntityService<GaoP
saveItems(config.getId(), config.getItemList());
}
@OperationAudit(AuditOperation.GAO_PACKAGE_CONFIG_UPDATE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void update(GaoPackageConfig request) {
loggerService.execute(Logger.Module.GAO, "GAO_PACKAGE_CONFIG_UPDATE", request, () -> {
updateInternal(request);
return request;
});
updateInternal(request);
}
private void updateInternal(GaoPackageConfig request) {
@@ -141,15 +136,13 @@ public class GaoPackageConfigServiceImplement extends AbstractEntityService<GaoP
saveItems(dbConfig.getId(), request.getItemList());
}
@OperationAudit(AuditOperation.GAO_PACKAGE_CONFIG_DELETE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void delete(String id) {
loggerService.execute(Logger.Module.GAO, "GAO_PACKAGE_CONFIG_DELETE", id, () -> {
GaoPackageConfig config = get(id);
TimiException.required(config, "not found package config");
super.delete(id);
return id;
});
GaoPackageConfig config = get(id);
TimiException.required(config, "not found package config");
super.delete(id);
}
/// 套餐配置只允许逻辑删除
@@ -124,6 +124,37 @@ public class GaoPointLedgerServiceImplement extends AbstractEntityService<GaoPoi
null, null, null, null, null, null, idempotencyKey, request.getRemark(), operatorUserId, Time.now());
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoPointLedger consumeForRedemption(String storeId, String customerId, String redemptionId, long pointCost,
String operatorUserId, String remark) {
TimiException.required(storeId, "not found storeId");
TimiException.required(customerId, "not found customerId");
TimiException.required(redemptionId, "not found redemptionId");
TimiException.required(operatorUserId, "not found operatorUserId");
TimiException.requiredTrue(0 < pointCost, "pointCost invalid");
String idempotencyKey = "GAO_POINT_REDEMPTION:%s:CONSUME".formatted(redemptionId);
return apply(storeId, customerId, GaoPointLedger.ChangeType.CONSUME, -pointCost, null,
GaoPointLedger.TriggerSourceType.POINT_REDEMPTION, redemptionId, null, null, null, idempotencyKey,
remark, operatorUserId, Time.now());
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoPointLedger revokeForRedemption(String storeId, String customerId, String redemptionId, String returnId, long pointCost,
String operatorUserId, String remark) {
TimiException.required(storeId, "not found storeId");
TimiException.required(customerId, "not found customerId");
TimiException.required(redemptionId, "not found redemptionId");
TimiException.required(returnId, "not found returnId");
TimiException.required(operatorUserId, "not found operatorUserId");
TimiException.requiredTrue(0 < pointCost, "pointCost invalid");
String idempotencyKey = "GAO_POINT_REDEMPTION:%s:RETURN:%s".formatted(redemptionId, returnId);
return apply(storeId, customerId, GaoPointLedger.ChangeType.REVOKE, pointCost, null,
GaoPointLedger.TriggerSourceType.POINT_REDEMPTION, redemptionId, null, null,
jackson.createObjectNode().put("returnId", returnId), idempotencyKey, remark, operatorUserId, Time.now());
}
@Override
public List<GaoPointLedger> listRuleChangeByTriggerSource(GaoPointLedger.TriggerSourceType triggerSourceType, String triggerSourceId) {
TimiException.required(triggerSourceType, "not found triggerSourceType");
@@ -186,6 +217,7 @@ public class GaoPointLedgerServiceImplement extends AbstractEntityService<GaoPoi
return switch (triggerSourceType) {
case EVENT_RECORD -> "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);
};
}
@@ -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<GaoPointRedeemSku> pageByQuery(GaoPointRedeemSkuPage page) {
TimiException.required(page, "not found redeem sku page");
PageResult<GaoPointRedeemSku> result = new PageResult<>();
result.setTotal(mapper.countByQuery(page));
List<GaoPointRedeemSku> 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<GaoPointRedeemSku> list) {
if (list == null || list.isEmpty()) {
return;
}
Map<String, GaoProductSku> skuMap = new HashMap<>();
for (GaoProductSku sku : skuService.listByIdList(list.stream().map(GaoPointRedeemSku::getSkuId).distinct().toList())) {
skuMap.put(sku.getId(), sku);
}
Map<String, GaoProduct> productMap = productService.listByIdList(skuMap.values().stream().map(GaoProductSku::getProductId).distinct().toList())
.stream().collect(java.util.stream.Collectors.toMap(GaoProduct::getId, item -> item));
Map<String, List<GaoProductSkuUnit>> 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));
}
}
}
@@ -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<GaoPointRedemption> pageByQuery(GaoPointRedemptionPage page) {
TimiException.required(page, "not found redemption page");
PageResult<GaoPointRedemption> result = new PageResult<>();
result.setTotal(mapper.countByQuery(page));
List<GaoPointRedemption> 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<GaoPointRedemptionReturn> listReturns(String storeId, String redemptionId) {
GaoPointRedemption redemption = storeId == null ? mapper.select(redemptionId) : mapper.selectByStoreIdAndId(storeId, redemptionId);
TimiException.required(redemption, "not found redemption");
List<GaoPointRedemptionReturn> 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<Attachment> 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<GaoPointRedemption> list) {
if (list == null || list.isEmpty()) {
return;
}
List<String> idList = list.stream().map(GaoPointRedemption::getId).toList();
Map<String, List<Attachment>> attachmentMap = attachmentService.mapByBizIdList(Attachment.BizType.GAO_POINT_REDEMPTION, idList);
Map<String, GaoCustomer> customerMap = customerService.mapByIdList(list.stream().map(GaoPointRedemption::getCustomerId).distinct().toList());
Map<String, List<Attachment>> customerAttachmentMap = attachmentService.mapByBizIdList(
Attachment.BizType.GAO_CUSTOMER,
customerMap.keySet().stream().toList());
List<GaoPointRedemptionReturn> returnList = returnMapper.selectByRedemptionIdList(idList);
Map<String, List<GaoPointRedemptionReturn>> 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<GaoPointRedemptionReturn> list) {
if (list == null || list.isEmpty()) {
return;
}
Map<String, List<Attachment>> 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) {
}
}
@@ -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(),
@@ -6,8 +6,8 @@ import com.imyeyu.api.modules.gao.mapper.GaoProductCategoryMapper;
import com.imyeyu.api.modules.gao.service.GaoProductCategoryService;
import com.imyeyu.api.modules.gao.service.GaoStoreScopeService;
import com.imyeyu.api.modules.gao.vo.GaoProductCategoryPage;
import com.imyeyu.api.modules.system.entity.Logger;
import com.imyeyu.api.modules.system.service.LoggerService;
import com.imyeyu.api.modules.system.audit.AuditOperation;
import com.imyeyu.api.modules.system.audit.OperationAudit;
import com.imyeyu.api.modules.user.service.UserLoginService;
import com.imyeyu.java.TimiJava;
import com.imyeyu.java.bean.timi.TimiException;
@@ -37,7 +37,6 @@ public class GaoProductCategoryServiceImplement extends AbstractEntityService<Ga
private final GaoProductCategoryMapper mapper;
private final GaoStoreScopeService storeScopeService;
private final UserLoginService userLoginService;
private final LoggerService loggerService;
@Override
protected BaseMapper<GaoProductCategory, String> mapper() {
@@ -59,6 +58,7 @@ public class GaoProductCategoryServiceImplement extends AbstractEntityService<Ga
return mapper.selectByStoreId(storeId);
}
@OperationAudit(AuditOperation.GAO_PRODUCT_CATEGORY_SORT)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void sort(List<String> idList) {
@@ -66,58 +66,50 @@ public class GaoProductCategoryServiceImplement extends AbstractEntityService<Ga
return;
}
List<String> distinctIdList = new ArrayList<>(new LinkedHashSet<>(idList));
loggerService.execute(Logger.Module.GAO, "GAO_PRODUCT_CATEGORY_SORT", distinctIdList, () -> {
Map<String, GaoProductCategory> categoryMap = mapper.selectByIdList(new LinkedHashSet<>(distinctIdList)).stream()
.collect(Collectors.toMap(GaoProductCategory::getId, Function.identity()));
TimiException.requiredTrue(categoryMap.size() == distinctIdList.size(), "产品分类列表包含不存在的分类");
for (GaoProductCategory category : categoryMap.values()) {
storeScopeService.checkStoreId(category.getStoreId());
}
mapper.updateSort(distinctIdList, Time.now());
});
Map<String, GaoProductCategory> categoryMap = mapper.selectByIdList(new LinkedHashSet<>(distinctIdList)).stream()
.collect(Collectors.toMap(GaoProductCategory::getId, Function.identity()));
TimiException.requiredTrue(categoryMap.size() == distinctIdList.size(), "产品分类列表包含不存在的分类");
for (GaoProductCategory category : categoryMap.values()) {
storeScopeService.checkStoreId(category.getStoreId());
}
mapper.updateSort(distinctIdList, Time.now());
}
@OperationAudit(AuditOperation.GAO_PRODUCT_CATEGORY_CREATE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void create(GaoProductCategory category) {
loggerService.execute(Logger.Module.GAO, "GAO_PRODUCT_CATEGORY_CREATE", category, () -> {
prepare(category, null);
super.create(category);
return category;
});
prepare(category, null);
super.create(category);
}
@OperationAudit(AuditOperation.GAO_PRODUCT_CATEGORY_UPDATE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void update(GaoProductCategory request) {
loggerService.execute(Logger.Module.GAO, "GAO_PRODUCT_CATEGORY_UPDATE", request, () -> {
TimiException.required(request, "not found category");
TimiException.required(request.getId(), "not found category.id");
GaoProductCategory category = get(request.getId());
TimiException.required(category, "not found category");
storeScopeService.checkStoreId(category.getStoreId());
category.setName(request.getName());
category.setSort(request.getSort());
category.setStatus(request.getStatus());
prepare(category, category.getId());
super.update(category);
return category;
});
TimiException.required(request, "not found category");
TimiException.required(request.getId(), "not found category.id");
GaoProductCategory category = get(request.getId());
TimiException.required(category, "not found category");
storeScopeService.checkStoreId(category.getStoreId());
category.setName(request.getName());
category.setSort(request.getSort());
category.setStatus(request.getStatus());
prepare(category, category.getId());
super.update(category);
}
@OperationAudit(AuditOperation.GAO_PRODUCT_CATEGORY_DISABLE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void disable(String id) {
loggerService.execute(Logger.Module.GAO, "GAO_PRODUCT_CATEGORY_DISABLE", id, () -> {
GaoProductCategory category = get(id);
TimiException.required(category, "not found category");
storeScopeService.checkStoreId(category.getStoreId());
category.setStatus(GaoProductCategory.Status.INACTIVE);
category.setUpdatedAt(Time.now());
category.setSort(TimiJava.defaultIfNull(category.getSort(), 0));
super.update(category);
return category;
});
GaoProductCategory category = get(id);
TimiException.required(category, "not found category");
storeScopeService.checkStoreId(category.getStoreId());
category.setStatus(GaoProductCategory.Status.INACTIVE);
category.setUpdatedAt(Time.now());
category.setSort(TimiJava.defaultIfNull(category.getSort(), 0));
super.update(category);
}
@Override
@@ -14,9 +14,10 @@ import com.imyeyu.api.modules.gao.service.GaoProductService;
import com.imyeyu.api.modules.gao.service.GaoProductSkuService;
import com.imyeyu.api.modules.gao.service.GaoProductSpecService;
import com.imyeyu.api.modules.gao.vo.GaoProductPage;
import com.imyeyu.api.modules.system.entity.Logger;
import com.imyeyu.api.modules.system.service.LoggerService;
import com.imyeyu.api.modules.system.audit.AuditOperation;
import com.imyeyu.api.modules.system.audit.OperationAudit;
import com.imyeyu.api.modules.user.service.UserLoginService;
import com.imyeyu.api.util.PinyinUtil;
import com.imyeyu.java.TimiJava;
import com.imyeyu.java.bean.timi.TimiCode;
import com.imyeyu.java.bean.timi.TimiException;
@@ -52,7 +53,6 @@ public class GaoProductServiceImplement extends AbstractEntityService<GaoProduct
private final ObjectProvider<GaoProductSkuService> skuServiceProvider;
private final ObjectProvider<GaoProductSpecService> specServiceProvider;
private final UserLoginService userLoginService;
private final LoggerService loggerService;
@Override
protected BaseMapper<GaoProduct, String> mapper() {
@@ -100,62 +100,56 @@ public class GaoProductServiceImplement extends AbstractEntityService<GaoProduct
return list.stream().filter(product -> !product.getSkuList().isEmpty()).toList();
}
@OperationAudit(AuditOperation.GAO_PRODUCT_CREATE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void create(GaoProduct product) {
loggerService.execute(Logger.Module.GAO, "GAO_PRODUCT_CREATE", product, () -> {
prepare(product, null);
super.create(product);
Map<String, String> specValueIdMap = saveSpecs(product, product.getSpecList());
normalizeSkuSpecValueIds(product.getSkuList(), specValueIdMap);
saveSkus(product, product.getSkuList());
attachmentService.updateByBizId(Attachment.BizType.GAO_PRODUCT, product.getId(), product.getAttachmentList());
return product;
});
prepare(product, null);
super.create(product);
Map<String, String> specValueIdMap = saveSpecs(product, product.getSpecList());
normalizeSkuSpecValueIds(product.getSkuList(), specValueIdMap);
saveSkus(product, product.getSkuList());
attachmentService.updateByBizId(Attachment.BizType.GAO_PRODUCT, product.getId(), product.getAttachmentList());
}
@OperationAudit(AuditOperation.GAO_PRODUCT_UPDATE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void update(GaoProduct request) {
loggerService.execute(Logger.Module.GAO, "GAO_PRODUCT_UPDATE", request, () -> {
TimiException.required(request, "not found product");
TimiException.required(request.getId(), "not found product.id");
GaoProduct product = get(request.getId());
TimiException.required(product, "not found product");
product.setCategoryId(request.getCategoryId());
product.setCode(TimiJava.defaultIfNull(request.getCode(), product.getCode()));
product.setName(TimiJava.defaultIfNull(request.getName(), product.getName()));
product.setProductType(TimiJava.defaultIfNull(request.getProductType(), product.getProductType()));
product.setStockEnabled(TimiJava.defaultIfNull(request.getStockEnabled(), product.getStockEnabled()));
product.setStatus(TimiJava.defaultIfNull(request.getStatus(), product.getStatus()));
product.setSort(TimiJava.defaultIfNull(request.getSort(), product.getSort()));
product.setRemark(request.getRemark());
product.setSkuList(request.getSkuList() == null ? skuServiceProvider.getObject().listByProductId(product.getId()) : request.getSkuList());
prepare(product, product.getId());
super.update(product);
if (request.getSkuList() != null) {
saveSkus(product, request.getSkuList());
}
if (request.getAttachmentList() != null) {
attachmentService.updateByBizId(Attachment.BizType.GAO_PRODUCT, product.getId(), request.getAttachmentList());
}
return product;
});
TimiException.required(request, "not found product");
TimiException.required(request.getId(), "not found product.id");
GaoProduct product = get(request.getId());
TimiException.required(product, "not found product");
product.setCategoryId(request.getCategoryId());
product.setCode(TimiJava.defaultIfNull(request.getCode(), product.getCode()));
product.setName(TimiJava.defaultIfNull(request.getName(), product.getName()));
product.setProductType(TimiJava.defaultIfNull(request.getProductType(), product.getProductType()));
product.setStockEnabled(TimiJava.defaultIfNull(request.getStockEnabled(), product.getStockEnabled()));
product.setStatus(TimiJava.defaultIfNull(request.getStatus(), product.getStatus()));
product.setSort(TimiJava.defaultIfNull(request.getSort(), product.getSort()));
product.setRemark(request.getRemark());
product.setSkuList(request.getSkuList() == null ? skuServiceProvider.getObject().listByProductId(product.getId()) : request.getSkuList());
prepare(product, product.getId());
super.update(product);
if (request.getSkuList() != null) {
saveSkus(product, request.getSkuList());
}
if (request.getAttachmentList() != null) {
attachmentService.updateByBizId(Attachment.BizType.GAO_PRODUCT, product.getId(), request.getAttachmentList());
}
}
@OperationAudit(AuditOperation.GAO_PRODUCT_DISABLE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void disable(String id) {
loggerService.execute(Logger.Module.GAO, "GAO_PRODUCT_DISABLE", id, () -> {
GaoProduct product = get(id);
TimiException.required(product, "not found product");
TimiException.requiredTrue(skuServiceProvider.getObject().listByProductId(id).stream().noneMatch(sku -> sku.getStatus() == com.imyeyu.api.modules.gao.entity.GaoProductSku.Status.ACTIVE), "请先停用产品的全部 SKU");
product.setStatus(GaoProduct.Status.INACTIVE);
product.setUpdatedBy(userLoginService.getRequireLoginUserId());
product.setUpdatedAt(Time.now());
super.update(product);
return product;
});
GaoProduct product = get(id);
TimiException.required(product, "not found product");
TimiException.requiredTrue(skuServiceProvider.getObject().listByProductId(id).stream().noneMatch(sku -> sku.getStatus() == com.imyeyu.api.modules.gao.entity.GaoProductSku.Status.ACTIVE), "请先停用产品的全部 SKU");
product.setStatus(GaoProduct.Status.INACTIVE);
product.setUpdatedBy(userLoginService.getRequireLoginUserId());
product.setUpdatedAt(Time.now());
super.update(product);
}
@Override
@@ -173,6 +167,7 @@ public class GaoProductServiceImplement extends AbstractEntityService<GaoProduct
TimiException.required(product.getStoreId(), "not found product.storeId");
product.setCode(requiredText(product.getCode(), "产品编码不能为空"));
product.setName(requiredText(product.getName(), "产品名称不能为空"));
fillPinyin(product);
product.setProductType(TimiJava.defaultIfNull(product.getProductType(), GaoProduct.ProductType.GOODS));
product.setStockEnabled(TimiJava.defaultIfNull(product.getStockEnabled(), product.getProductType() != GaoProduct.ProductType.SERVICE));
TimiException.requiredTrue(product.getProductType() != GaoProduct.ProductType.SERVICE || !product.getStockEnabled(), "SERVICE 产品不能启用库存");
@@ -268,4 +263,14 @@ public class GaoProductServiceImplement extends AbstractEntityService<GaoProduct
TimiException.requiredTrue(!result.isEmpty(), message);
return result;
}
/// 填充产品名称拼音搜索字段
///
/// @param product 产品
private void fillPinyin(GaoProduct product) {
String name = product.getName();
product.setNamePinyinFull(PinyinUtil.full(name));
product.setNamePinyinInitial(PinyinUtil.initial(name));
product.setNamePinyinMixed(PinyinUtil.mixed(name));
}
}
@@ -76,6 +76,16 @@ public class GaoProductSkuServiceImplement extends AbstractEntityService<GaoProd
return sku;
}
@Override
public List<GaoProductSku> listByIdList(Collection<String> idList) {
if (idList == null || idList.isEmpty()) {
return List.of();
}
List<GaoProductSku> result = mapper.selectByIdList(idList);
fillTransient(result);
return result;
}
@Override
public List<GaoProductSku> listByProductId(String productId) {
TimiException.required(productId, "not found productId");
@@ -99,6 +109,22 @@ public class GaoProductSkuServiceImplement extends AbstractEntityService<GaoProd
return getSkuUnit(storeId, skuId, skuUnitId, false);
}
@Override
public GaoProductSkuUnitSelection getFulfillableSkuUnit(String storeId, String skuId, String skuUnitId) {
TimiException.required(storeId, "门店 ID 不能为空");
TimiException.required(skuId, "SKU ID 不能为空");
TimiException.required(skuUnitId, "SKU 单位 ID 不能为空");
GaoProductSku sku = mapper.selectForUpdate(skuId);
TimiException.required(sku, "SKU 不存在");
TimiException.requiredTrue(storeId.equals(sku.getStoreId()), "SKU 不属于当前门店");
GaoProduct product = productServiceProvider.getObject().get(sku.getProductId());
TimiException.required(product, "产品不存在");
GaoProductSkuUnit unit = unitMapper.selectForUpdate(skuUnitId);
TimiException.required(unit, "SKU 单位不存在");
TimiException.requiredTrue(skuId.equals(unit.getSkuId()), "SKU 单位不属于当前 SKU");
return new GaoProductSkuUnitSelection(product, sku, unit);
}
@Override
public GaoProductSkuUnitSelection getSkuUnit(String storeId, String skuId, String skuUnitId, boolean purchase) {
TimiException.required(storeId, "not found storeId");
@@ -344,11 +370,8 @@ public class GaoProductSkuServiceImplement extends AbstractEntityService<GaoProd
TimiException.requiredTrue(1 == unitMapper.updateUnit(unit), "SKU 单位更新失败");
}
}
for (GaoProductSkuUnit existing : existingUnitList) {
if (!retainedIdSet.contains(existing.getId())) {
unitMapper.delete(existing.getId());
}
}
TimiException.requiredTrue(existingUnitList.stream().allMatch(existing -> retainedIdSet.contains(existing.getId())),
"已保存的 SKU 单位不能删除,请关闭允许销售和允许采购");
}
private void saveSpecValues(String skuId, List<String> specValueIdList) {
@@ -4,24 +4,24 @@ 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.entity.GaoProduct;
import com.imyeyu.api.modules.gao.entity.GaoProductStock;
import com.imyeyu.api.modules.gao.entity.GaoProductSku;
import com.imyeyu.api.modules.gao.entity.GaoProductSkuUnit;
import com.imyeyu.api.modules.gao.entity.GaoProductStock;
import com.imyeyu.api.modules.gao.entity.GaoProductStockRecord;
import com.imyeyu.api.modules.gao.mapper.GaoProductStockMapper;
import com.imyeyu.api.modules.gao.service.GaoProductService;
import com.imyeyu.api.modules.gao.service.GaoProductStockService;
import com.imyeyu.api.modules.gao.service.GaoProductSkuService;
import com.imyeyu.api.modules.gao.service.GaoProductStockRecordService;
import com.imyeyu.api.modules.gao.service.GaoProductStockService;
import com.imyeyu.api.modules.gao.service.GaoStoreScopeService;
import com.imyeyu.api.modules.gao.vo.GaoProductSkuUnitSelection;
import com.imyeyu.api.modules.gao.vo.GaoProductStockChangeRequest;
import com.imyeyu.api.modules.gao.vo.GaoProductStockManualRevokeRequest;
import com.imyeyu.api.modules.gao.vo.GaoProductStockPage;
import com.imyeyu.api.modules.gao.vo.GaoProductStockProductView;
import com.imyeyu.api.modules.gao.vo.GaoProductSkuUnitSelection;
import com.imyeyu.api.modules.gao.vo.GaoStockChangeItem;
import com.imyeyu.api.modules.system.entity.Logger;
import com.imyeyu.api.modules.system.service.LoggerService;
import com.imyeyu.api.modules.system.audit.AuditOperation;
import com.imyeyu.api.modules.system.audit.OperationAudit;
import com.imyeyu.api.modules.user.service.UserLoginService;
import com.imyeyu.java.TimiJava;
import com.imyeyu.java.bean.timi.TimiCode;
@@ -59,7 +59,6 @@ public class GaoProductStockServiceImplement implements GaoProductStockService {
private final GaoProductSkuService skuService;
private final GaoStoreScopeService storeScopeService;
private final UserLoginService userLoginService;
private final LoggerService loggerService;
@Override
public PageResult<GaoProductStock> pageByQuery(GaoProductStockPage page) {
@@ -112,58 +111,75 @@ public class GaoProductStockServiceImplement implements GaoProductStockService {
return stock;
}
@OperationAudit(AuditOperation.GAO_PRODUCT_STOCK_INITIAL_IN)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoProductStock initialIn(GaoProductStockChangeRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_PRODUCT_STOCK_INITIAL_IN", request, () -> change(request, GaoProductStockRecord.OperationType.INITIAL_IN));
return change(request, GaoProductStockRecord.OperationType.INITIAL_IN);
}
@OperationAudit(AuditOperation.GAO_PRODUCT_STOCK_MANUAL_IN)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoProductStock manualIn(GaoProductStockChangeRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_PRODUCT_STOCK_MANUAL_IN", request, () -> change(request, GaoProductStockRecord.OperationType.MANUAL_IN));
return change(request, GaoProductStockRecord.OperationType.MANUAL_IN);
}
@OperationAudit(AuditOperation.GAO_PRODUCT_STOCK_MANUAL_OUT)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoProductStock manualOut(GaoProductStockChangeRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_PRODUCT_STOCK_MANUAL_OUT", request, () -> change(request, GaoProductStockRecord.OperationType.MANUAL_OUT));
return change(request, GaoProductStockRecord.OperationType.MANUAL_OUT);
}
@OperationAudit(AuditOperation.GAO_PRODUCT_STOCK_MANUAL_REVOKE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoProductStock manualRevoke(GaoProductStockManualRevokeRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_PRODUCT_STOCK_MANUAL_REVOKE", request, () -> manualRevokeInternal(request));
return manualRevokeInternal(request);
}
@OperationAudit(AuditOperation.GAO_PRODUCT_STOCK_STOCKTAKE_GAIN)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoProductStock stocktakeGain(GaoProductStockChangeRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_PRODUCT_STOCK_STOCKTAKE_GAIN", request, () -> change(request, GaoProductStockRecord.OperationType.STOCKTAKE_GAIN));
return change(request, GaoProductStockRecord.OperationType.STOCKTAKE_GAIN);
}
@OperationAudit(AuditOperation.GAO_PRODUCT_STOCK_STOCKTAKE_LOSS)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoProductStock stocktakeLoss(GaoProductStockChangeRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_PRODUCT_STOCK_STOCKTAKE_LOSS", request, () -> change(request, GaoProductStockRecord.OperationType.STOCKTAKE_LOSS));
return change(request, GaoProductStockRecord.OperationType.STOCKTAKE_LOSS);
}
@Override
@Transactional(TimiServerDBConfig.ROLLBACKER)
public boolean hasSufficientForFulfillment(String storeId, List<GaoStockChangeItem> items) {
return getFulfillmentStockShortageMessage(storeId, items) == null;
}
@Override
@Transactional(TimiServerDBConfig.ROLLBACKER)
public String getFulfillmentStockShortageMessage(String storeId, List<GaoStockChangeItem> items) {
TimiException.required(storeId, "not found storeId");
if (items == null || items.isEmpty()) {
return true;
return null;
}
List<GaoStockChangeItem> sortedItems = new ArrayList<>(items);
sortedItems.sort(Comparator.comparing(GaoStockChangeItem::getSkuId));
List<String> shortageList = new ArrayList<>();
for (GaoStockChangeItem item : sortedItems) {
GaoProductStock stock = stockMapper.selectForUpdate(storeId, item.getSkuId());
if (stock == null || decimal(stock.getOnHandQuantity()).subtract(decimal(stock.getReservedQuantity())).compareTo(item.getBaseQuantity()) < 0) {
return false;
BigDecimal availableQuantity = stock == null ? ZERO : decimal(stock.getOnHandQuantity()).subtract(decimal(stock.getReservedQuantity()));
BigDecimal shortageQuantity = item.getBaseQuantity().subtract(availableQuantity);
if (0 < shortageQuantity.signum()) {
shortageList.add("%s(%s)可用 %s %s,缺少 %s %s".formatted(
item.getProductNameSnapshot(), item.getSkuNameSnapshot(), availableQuantity.toPlainString(), item.getUnitSnapshot(),
shortageQuantity.toPlainString(), item.getUnitSnapshot()));
}
}
return true;
return shortageList.isEmpty() ? null : "库存不足:" + String.join(";", shortageList);
}
@Override
@@ -190,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");
@@ -216,7 +260,7 @@ public class GaoProductStockServiceImplement implements GaoProductStockService {
GaoStockChangeItem item = GaoStockChangeItem.of(product, sku, unit, quantity, baseQuantity);
item.setSkuSpecSnapshot(skuService.getSkuSpecSnapshot(sku));
List<GaoProductStock> result = executeInternal(storeId, sku.getId(), List.of(item), requestNo,
userLoginService.getRequireLoginUserId(), operationType, request.getRemark(), request.getAttachmentList());
userLoginService.getRequireLoginUserId(), operationType, request.getOccurredAt(), request.getRemark(), request.getAttachmentList());
return result.getFirst();
}
@@ -295,11 +339,18 @@ public class GaoProductStockServiceImplement implements GaoProductStockService {
private List<GaoProductStock> executeInternal(String storeId, String bizId, List<GaoStockChangeItem> sourceList, String requestNo,
String operatorUserId, GaoProductStockRecord.OperationType operationType, String remark) {
return executeInternal(storeId, bizId, sourceList, requestNo, operatorUserId, operationType, remark, null);
return executeInternal(storeId, bizId, sourceList, requestNo, operatorUserId, operationType, null, remark, null, defaultBizType(operationType));
}
private List<GaoProductStock> executeInternal(String storeId, String bizId, List<GaoStockChangeItem> sourceList, String requestNo,
String operatorUserId, GaoProductStockRecord.OperationType operationType, String remark, List<Attachment> attachmentList) {
String operatorUserId, GaoProductStockRecord.OperationType operationType, Long occurredAt, String remark, List<Attachment> attachmentList) {
return executeInternal(storeId, bizId, sourceList, requestNo, operatorUserId, operationType, occurredAt, remark, attachmentList,
defaultBizType(operationType));
}
private List<GaoProductStock> executeInternal(String storeId, String bizId, List<GaoStockChangeItem> sourceList, String requestNo,
String operatorUserId, GaoProductStockRecord.OperationType operationType, Long occurredAt, String remark, List<Attachment> attachmentList,
GaoProductStockRecord.BizType bizType) {
TimiException.required(storeId, "not found storeId");
TimiException.required(bizId, "not found stock bizId");
TimiException.requiredTrue(sourceList != null && !sourceList.isEmpty(), "库存变更至少需要一个产品");
@@ -355,12 +406,12 @@ 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);
record.setOperatorUserId(operatorUserId);
record.setOccurredAt(Time.now());
record.setOccurredAt(TimiJava.defaultIfNull(occurredAt, Time.now()));
record.setRemark(remark);
recordService.createOperationRecord(record, attachmentList);
stock.setOnHandQuantity(onHandAfter);
@@ -393,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, "手工库存冲正必须基于原库存流水");
};
@@ -486,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;
}
}
@@ -21,6 +21,7 @@ import com.imyeyu.api.modules.gao.service.GaoCustomerService;
import com.imyeyu.api.modules.gao.service.GaoEventRecordService;
import com.imyeyu.api.modules.gao.service.GaoFundRecordService;
import com.imyeyu.api.modules.gao.service.GaoOrderFulfillmentService;
import com.imyeyu.api.modules.gao.service.GaoOrderStraightThroughService;
import com.imyeyu.api.modules.gao.service.GaoPointTriggerService;
import com.imyeyu.api.modules.gao.service.GaoProductService;
import com.imyeyu.api.modules.gao.service.GaoProductSkuService;
@@ -34,8 +35,8 @@ import com.imyeyu.api.modules.gao.vo.GaoSalesOrderCancelRequest;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderItemRequest;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderPage;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderUpdateRequest;
import com.imyeyu.api.modules.system.entity.Logger;
import com.imyeyu.api.modules.system.service.LoggerService;
import com.imyeyu.api.modules.system.audit.AuditOperation;
import com.imyeyu.api.modules.system.audit.OperationAudit;
import com.imyeyu.api.modules.user.service.PermissionChecker;
import com.imyeyu.api.modules.user.service.RoleChecker;
import com.imyeyu.api.modules.user.service.UserLoginService;
@@ -80,6 +81,7 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
private final GaoSalesOrderItemMapper itemMapper;
private final GaoFundRecordService fundRecordService;
private final ObjectProvider<GaoOrderFulfillmentService> fulfillmentServiceProvider;
private final ObjectProvider<GaoOrderStraightThroughService> straightThroughServiceProvider;
private final GaoCustomerService customerService;
private final GaoProductService productService;
private final GaoProductSkuService skuService;
@@ -91,7 +93,6 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
private final PermissionChecker permissionChecker;
private final RoleChecker roleChecker;
private final UserLoginService userLoginService;
private final LoggerService loggerService;
@Override
protected BaseMapper<GaoSalesOrder, String> mapper() {
@@ -116,6 +117,7 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
TimiException.required(order, "not found sales order");
storeScopeService.checkStoreId(order.getStoreId());
List<GaoSalesOrderItem> itemList = itemMapper.selectBySalesOrderId(order.getId());
fillItemDisplayInfo(itemList);
for (GaoSalesOrderItem item : itemList) {
fillItemBalance(item);
}
@@ -139,23 +141,25 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
return order;
}
@OperationAudit(AuditOperation.GAO_SALES_ORDER_DIRECT_PRODUCT_CREATE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoSalesOrder createDirectProductOrder(GaoDirectProductOrderCreateRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_SALES_ORDER_DIRECT_PRODUCT_CREATE", request, () -> createDirectInternal(request));
GaoSalesOrder order = createDirectInternal(request);
straightThroughServiceProvider.getObject().complete(order, request.getStraightThrough());
return detail(order.getId());
}
@OperationAudit(AuditOperation.GAO_SALES_ORDER_UPDATE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoSalesOrder updateRemarkAndAttachments(GaoSalesOrderUpdateRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_SALES_ORDER_UPDATE", request, () -> {
TimiException.required(request, "not found sales order update request");
TimiException.required(request.getId(), "not found sales order.id");
GaoSalesOrder order = getForUpdate(request.getId());
TimiException.requiredTrue(1 == mapper.updateRemark(order.getId(), request.getRemark()), "订单备注更新失败");
attachmentService.updateByBizId(Attachment.BizType.GAO_SALES_ORDER, order.getId(), request.getAttachmentList());
return detail(order.getId());
});
TimiException.required(request, "not found sales order update request");
TimiException.required(request.getId(), "not found sales order.id");
GaoSalesOrder order = getForUpdate(request.getId());
TimiException.requiredTrue(1 == mapper.updateRemark(order.getId(), request.getRemark()), "订单备注更新失败");
attachmentService.updateByBizId(Attachment.BizType.GAO_SALES_ORDER, order.getId(), request.getAttachmentList());
return detail(order.getId());
}
private GaoSalesOrder createDirectInternal(GaoDirectProductOrderCreateRequest request) {
@@ -222,10 +226,11 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
data.getSaleAmount(), data.getItemList());
}
@OperationAudit(AuditOperation.GAO_SALES_ORDER_CANCEL)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoSalesOrder cancel(GaoSalesOrderCancelRequest request) {
return loggerService.execute(Logger.Module.GAO, "GAO_SALES_ORDER_CANCEL", request, () -> cancelInternal(request));
return cancelInternal(request);
}
private GaoSalesOrder cancelInternal(GaoSalesOrderCancelRequest request) {
@@ -452,9 +457,11 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
for (GaoSalesOrderItem item : itemList) {
TimiException.required(item, "销售订单明细不能为空");
TimiException.requiredTrue(item.getItemType() == expectedItemType, "订单项类型与订单类型不匹配");
item.setGifted(Boolean.TRUE.equals(item.getGifted()));
TimiException.required(item.getSkuId(), "订单项 SKU 不能为空");
TimiException.required(item.getSkuUnitId(), "订单项 SKU 单位不能为空");
TimiException.requiredTrue(skuUnitKeySet.add(item.getSkuId() + "|" + item.getSkuUnitId()),
String skuUnitKey = item.getSkuId() + "|" + item.getSkuUnitId() + "|" + item.getGifted();
TimiException.requiredTrue(skuUnitKeySet.add(skuUnitKey),
"同一订单不能重复 SKU 和单位");
TimiException.required(item.getQuantity(), "订单项数量不能为空");
TimiException.requiredTrue(0 < item.getQuantity().signum(), "订单项数量必须大于 0");
@@ -510,11 +517,14 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
List<GaoSalesOrderItem> existingItemList = itemMapper.selectBySalesOrderId(existing.getId());
TimiException.requiredTrue(existingItemList.size() == requestItemList.size(), "requestNo 对应订单内容不一致");
Map<String, GaoSalesOrderItem> existingItemMap = existingItemList.stream()
.collect(java.util.stream.Collectors.toMap(item -> item.getSkuId() + "|" + item.getSkuUnitId(), java.util.function.Function.identity()));
.collect(java.util.stream.Collectors.toMap(item -> item.getSkuId() + "|" + item.getSkuUnitId() + "|" + Boolean.TRUE.equals(item.getGifted()),
java.util.function.Function.identity()));
for (GaoSalesOrderItem requestItem : requestItemList) {
GaoSalesOrderItem existingItem = existingItemMap.get(requestItem.getSkuId() + "|" + requestItem.getSkuUnitId());
GaoSalesOrderItem existingItem = existingItemMap.get(requestItem.getSkuId() + "|" + requestItem.getSkuUnitId() + "|"
+ Boolean.TRUE.equals(requestItem.getGifted()));
TimiException.required(existingItem, "requestNo 对应订单内容不一致");
TimiException.requiredTrue(existingItem.getItemType() == GaoSalesOrderItem.ItemType.PACKAGE_ENTITLEMENT
&& Boolean.TRUE.equals(existingItem.getGifted()) == Boolean.TRUE.equals(requestItem.getGifted())
&& existingItem.getQuantity().compareTo(requestItem.getQuantity()) == 0
&& existingItem.getSort().equals(requestItem.getSort())
&& sameText(existingItem.getRemark(), requestItem.getRemark()), "requestNo 对应订单内容不一致");
@@ -562,6 +572,37 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
item.setRemainingBaseQuantity(baseQuantity.subtract(fulfilledBaseQuantity));
}
/// 填充订单项数量展示所需的 SKU 信息
///
/// @param itemList 订单项列表
private void fillItemDisplayInfo(List<GaoSalesOrderItem> itemList) {
if (itemList == null || itemList.isEmpty()) {
return;
}
List<String> skuIdList = itemList.stream()
.map(GaoSalesOrderItem::getSkuId)
.filter(value -> value != null && !value.isBlank())
.distinct()
.toList();
if (skuIdList.isEmpty()) {
return;
}
Map<String, GaoProductSku> skuMap = skuService.listByIdList(skuIdList).stream()
.collect(java.util.stream.Collectors.toMap(GaoProductSku::getId, java.util.function.Function.identity()));
for (GaoSalesOrderItem item : itemList) {
GaoProductSku sku = skuMap.get(item.getSkuId());
if (sku == null) {
continue;
}
item.setAllowDecimalQuantity(sku.getAllowDecimalQuantity());
item.setBaseUnitName(sku.getUnitList().stream()
.filter(unit -> Boolean.TRUE.equals(unit.getIsBase()))
.map(GaoProductSkuUnit::getName)
.findFirst()
.orElse(null));
}
}
private void fillItemAttachments(List<GaoSalesOrderItem> itemList) {
if (itemList == null || itemList.isEmpty()) {
return;
@@ -3,6 +3,7 @@ package com.imyeyu.api.modules.gao.service.implement;
import com.imyeyu.api.modules.gao.service.GaoCustomerService;
import com.imyeyu.api.modules.gao.service.GaoEventRecordService;
import com.imyeyu.api.modules.gao.service.GaoStoreChartService;
import com.imyeyu.api.modules.gao.vo.GaoCustomerAgeStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerGenderStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerTrendStateReq;
@@ -43,6 +44,12 @@ public class GaoStoreChartServiceImplement implements GaoStoreChartService {
return customerService.stateGender(storeId);
}
@Override
public List<GaoCustomerAgeStatView> customerAge(String storeId) {
TimiException.required(storeId, "not found storeId");
return customerService.stateAge(storeId);
}
@Override
public List<GaoEventRecordDailyStatView> eventRecordDailyStat(GaoStoreChartReq req) {
checkRangeReq(req);
@@ -0,0 +1,18 @@
package com.imyeyu.api.modules.gao.vo;
import lombok.Data;
///
/// 客户年龄统计视图
///
/// @author Codex
/// @since 2026-09-11
@Data
public class GaoCustomerAgeStatView {
/// 客户年龄
private Long age;
/// 该年龄的客户数量
private Long count;
}
@@ -0,0 +1,20 @@
package com.imyeyu.api.modules.gao.vo;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
///
/// 客户每日统计中的客户摘要
///
/// @author Codex
/// @since 2026-09-11
@Data
public class GaoCustomerDailyStateCustomerView {
/// 用于服务层按日期分组,不返回给前端
@JsonIgnore
private Integer dateValue;
/// 客户姓名
private String name;
}
@@ -2,6 +2,8 @@ package com.imyeyu.api.modules.gao.vo;
import lombok.Data;
import java.util.List;
///
/// 客户每日统计视图
///
@@ -18,4 +20,7 @@ public class GaoCustomerDailyStateView {
/// 截止当日客户总数
private Long totalCustomerCount;
/// 当日新增客户列表
private List<GaoCustomerDailyStateCustomerView> customerList;
}
@@ -0,0 +1,19 @@
package com.imyeyu.api.modules.gao.vo;
import lombok.Data;
/// 客户病症原始文本
///
/// 只用于后端分词,不对外返回。
///
/// @author Codex
/// @since 2026-09-11
@Data
public class GaoCustomerDiseaseTextView {
/// 备注
private String remark;
/// 病症
private String disease;
}
@@ -0,0 +1,17 @@
package com.imyeyu.api.modules.gao.vo;
import lombok.Data;
/// 客户病症词频查询请求
///
/// @author Codex
/// @since 2026-09-11
@Data
public class GaoCustomerDiseaseWordReq {
/// 门店 ID
private String storeId;
/// 返回词语数量
private Integer topN;
}
@@ -0,0 +1,19 @@
package com.imyeyu.api.modules.gao.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
/// 客户病症词频
///
/// @author Codex
/// @since 2026-09-11
@Data
@AllArgsConstructor
public class GaoCustomerDiseaseWordView {
/// 词语
private String word;
/// 词语出现次数
private Long value;
}
@@ -17,6 +17,9 @@ public class GaoCustomerPackagePurchaseItem {
/// SKU 单位 ID
private String skuUnitId;
/// true 表示请求将该明细作为赠送权益创建
private Boolean gifted;
/// 产品名称快照,由后端生成
private String productNameSnapshot;
@@ -33,6 +33,9 @@ public class GaoCustomerPackagePurchaseRequest {
/// 购买照片
private List<Attachment> attachmentList;
/// 创建订单后的直通处理
private GaoOrderStraightThroughRequest straightThrough;
/// 备注
private String remark;
}
@@ -1,6 +1,7 @@
package com.imyeyu.api.modules.gao.vo;
import com.imyeyu.java.bean.BasePage;
import com.imyeyu.api.modules.gao.entity.GaoCustomer;
import com.imyeyu.spring.bean.Page;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -12,7 +13,7 @@ import java.util.List;
/// @since 2026-08-13
@Data
@EqualsAndHashCode(callSuper = true)
public class GaoCustomerPage extends BasePage {
public class GaoCustomerPage extends Page<GaoCustomer> {
/// 门店 ID
private String storeId;
@@ -22,4 +23,10 @@ public class GaoCustomerPage extends BasePage {
/// 标签 ID 列表,客户必须同时拥有列表内全部标签
private List<String> tagIdList;
/// 录入时间起始,包含,Unix 毫秒时间戳
private Long beginCreatedAt;
/// 录入时间结束,包含,Unix 毫秒时间戳
private Long endCreatedAt;
}
@@ -30,6 +30,9 @@ public class GaoDirectProductOrderCreateRequest {
/// 购买照片
private List<Attachment> attachmentList;
/// 创建订单后的直通处理
private GaoOrderStraightThroughRequest straightThrough;
/// 备注
private String remark;
}
@@ -0,0 +1,44 @@
package com.imyeyu.api.modules.gao.vo;
import com.imyeyu.api.modules.common.entity.Attachment;
import lombok.Data;
import java.util.List;
/// GAO 订单创建直通请求
///
/// @author Codex
/// @since 2026-09-13
@Data
public class GaoOrderStraightThroughRequest {
/// true 为已有收款并直接核销
private Boolean paymentVerified;
/// 已核销收款金额,单位分
private Long paymentAmount;
/// true 为直接全额收款并核销
private Boolean receiveFullPayment;
/// 实际收款时间,空为订单下单时间
private Long paymentOccurredAt;
/// 收款凭证照片
private List<Attachment> paymentAttachmentList;
/// 收款备注
private String paymentRemark;
/// true 为直接全部履约并核销
private Boolean fulfillAll;
/// 实际履约时间,空为订单下单时间
private Long deliveredAt;
/// 履约凭证照片
private List<Attachment> fulfillmentAttachmentList;
/// 履约备注
private String fulfillmentRemark;
}
@@ -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;
}
@@ -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;
}
@@ -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<Attachment> attachmentList;
/// 备注
private String remark;
}
@@ -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;
}
@@ -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<Attachment> attachmentList;
/// 退货原因和备注
private String remark;
}
@@ -30,6 +30,6 @@ public class GaoProductPage extends BasePage {
/// 产品类型
private GaoProduct.ProductType productType;
/// 名称或编码关键词
/// 名称、拼音或编码关键词
private String keyword;
}
@@ -28,6 +28,9 @@ public class GaoProductStockChangeRequest {
/// 业务数量
private BigDecimal quantity;
/// 库存发生时间,毫秒时间戳
private Long occurredAt;
/// 备注
private String remark;
@@ -18,6 +18,6 @@ public class GaoProductStockPage extends BasePage {
/// 产品 ID
private String productId;
/// 产品或 SKU 名称、编码、规格摘要关键词
/// 产品或 SKU 名称、拼音、编码、规格摘要关键词
private String keyword;
}
@@ -19,7 +19,7 @@ public class GaoProductStockRecordPage extends BasePage {
/// 产品 ID
private String productId;
/// 产品名称或编码关键词
/// 产品名称、拼音或编码关键词
private String keyword;
/// 来源业务类型
@@ -0,0 +1,51 @@
package com.imyeyu.api.modules.system.audit;
import com.imyeyu.api.modules.system.entity.Logger;
import lombok.AllArgsConstructor;
import lombok.Getter;
/// 操作审计编码
///
/// @author Codex
/// @since 2026-09-09
@Getter
@AllArgsConstructor
public enum AuditOperation {
COMMON_NOTIFY_CREATE(Logger.Module.COMMON),
COMMON_NOTIFY_DELETE(Logger.Module.COMMON),
GAO_CUSTOMER_PACKAGE_PURCHASE(Logger.Module.GAO),
GAO_CUSTOMER_PACKAGE_CANCEL(Logger.Module.GAO),
GAO_SALES_ORDER_PAYMENT_RECEIVE(Logger.Module.GAO),
GAO_SALES_ORDER_PAYMENT_VERIFY(Logger.Module.GAO),
GAO_SALES_ORDER_REFUND(Logger.Module.GAO),
GAO_SALES_ORDER_CONFIRM_NO_REFUND(Logger.Module.GAO),
GAO_SALES_ORDER_FUND_REVOKE(Logger.Module.GAO),
GAO_ORDER_FULFILLMENT_CREATE(Logger.Module.GAO),
GAO_ORDER_FULFILLMENT_PREPARE_STOCK(Logger.Module.GAO),
GAO_ORDER_FULFILLMENT_VERIFY(Logger.Module.GAO),
GAO_ORDER_FULFILLMENT_REJECT(Logger.Module.GAO),
GAO_ORDER_FULFILLMENT_CANCEL(Logger.Module.GAO),
GAO_ORDER_FULFILLMENT_REVOKE(Logger.Module.GAO),
GAO_PACKAGE_CONFIG_CREATE(Logger.Module.GAO),
GAO_PACKAGE_CONFIG_UPDATE(Logger.Module.GAO),
GAO_PACKAGE_CONFIG_DELETE(Logger.Module.GAO),
GAO_PRODUCT_CATEGORY_SORT(Logger.Module.GAO),
GAO_PRODUCT_CATEGORY_CREATE(Logger.Module.GAO),
GAO_PRODUCT_CATEGORY_UPDATE(Logger.Module.GAO),
GAO_PRODUCT_CATEGORY_DISABLE(Logger.Module.GAO),
GAO_PRODUCT_CREATE(Logger.Module.GAO),
GAO_PRODUCT_UPDATE(Logger.Module.GAO),
GAO_PRODUCT_DISABLE(Logger.Module.GAO),
GAO_PRODUCT_STOCK_INITIAL_IN(Logger.Module.GAO),
GAO_PRODUCT_STOCK_MANUAL_IN(Logger.Module.GAO),
GAO_PRODUCT_STOCK_MANUAL_OUT(Logger.Module.GAO),
GAO_PRODUCT_STOCK_MANUAL_REVOKE(Logger.Module.GAO),
GAO_PRODUCT_STOCK_STOCKTAKE_GAIN(Logger.Module.GAO),
GAO_PRODUCT_STOCK_STOCKTAKE_LOSS(Logger.Module.GAO),
GAO_SALES_ORDER_DIRECT_PRODUCT_CREATE(Logger.Module.GAO),
GAO_SALES_ORDER_UPDATE(Logger.Module.GAO),
GAO_SALES_ORDER_CANCEL(Logger.Module.GAO);
final Logger.Module module;
}
@@ -0,0 +1,128 @@
package com.imyeyu.api.modules.system.audit;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.imyeyu.spring.entity.IDEntity;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.lang.reflect.Array;
import java.util.Locale;
import java.util.Set;
/// 操作审计参数和结果序列化器
///
/// @author Codex
/// @since 2026-09-09
@Component
@RequiredArgsConstructor
public class AuditPayloadSerializer {
private static final int MAX_LENGTH = 32 * 1024;
private static final Set<String> SECRET_FIELD_SET = Set.of(
"password",
"passwordconfirm",
"token",
"accesstoken",
"refreshtoken",
"secret",
"credential",
"authorization"
);
private final ObjectMapper objectMapper;
/// 序列化方法参数
///
/// @param arguments 方法参数
/// @return 脱敏后的参数 JSON
public String serializeArguments(Object[] arguments) {
if (arguments == null || 0 == arguments.length) {
return null;
}
return serialize(arguments.length == 1 ? arguments[0] : arguments);
}
/// 序列化业务结果
///
/// @param result 方法返回值
/// @param arguments 方法参数
/// @return 结果摘要
public String serializeResult(Object result, Object[] arguments) {
if (result instanceof IDEntity<?> entity && entity.getId() != null) {
return truncate(String.valueOf(entity.getId()));
}
if (result != null) {
return summarize(result);
}
if (arguments != null) {
for (Object argument : arguments) {
if (argument instanceof IDEntity<?> entity && entity.getId() != null) {
return truncate(String.valueOf(entity.getId()));
}
}
if (1 == arguments.length && arguments[0] instanceof CharSequence value) {
return truncate(value.toString());
}
}
return null;
}
private String serialize(Object value) {
try {
JsonNode node = objectMapper.valueToTree(value);
mask(node);
return truncate(objectMapper.writeValueAsString(node));
} catch (Exception e) {
return value == null ? null : truncate(value.getClass().getName());
}
}
private void mask(JsonNode node) {
if (node == null) {
return;
}
if (node.isObject()) {
ObjectNode objectNode = (ObjectNode) node;
objectNode.fieldNames().forEachRemaining(fieldName -> {
JsonNode field = objectNode.get(fieldName);
if (SECRET_FIELD_SET.contains(fieldName.toLowerCase(Locale.ROOT))) {
objectNode.put(fieldName, "***");
} else {
mask(field);
}
});
} else if (node.isArray()) {
node.forEach(this::mask);
}
}
private String summarize(Object value) {
if (value instanceof CharSequence || value instanceof Number || value instanceof Boolean || value instanceof Enum<?>) {
return truncate(String.valueOf(value));
}
if (value instanceof Iterable<?> iterable) {
int size = 0;
for (Object ignored : iterable) {
size++;
}
return "%s[%s]".formatted(value.getClass().getSimpleName(), size);
}
if (value instanceof java.util.Map<?, ?> map) {
return "%s[%s]".formatted(value.getClass().getSimpleName(), map.size());
}
if (value.getClass().isArray()) {
return "%s[%s]".formatted(value.getClass().getComponentType().getSimpleName(), Array.getLength(value));
}
return value.getClass().getSimpleName();
}
private String truncate(String value) {
if (value == null || value.length() <= MAX_LENGTH) {
return value;
}
return value.substring(0, MAX_LENGTH) + "...";
}
}
@@ -0,0 +1,17 @@
package com.imyeyu.api.modules.system.audit;
import com.imyeyu.api.modules.system.entity.Logger;
/// 操作审计记录器
///
/// 业务切面只依赖该写入端口,不直接依赖日志查询服务
///
/// @author Codex
/// @since 2026-09-09
public interface AuditRecorder {
/// 记录操作审计
///
/// @param logger 审计记录
void record(Logger logger);
}
@@ -0,0 +1,22 @@
package com.imyeyu.api.modules.system.audit;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/// 标记需要记录操作审计的应用服务方法
///
/// 只应标记对外的业务操作入口,不要标记被入口调用的内部方法
///
/// @author Codex
/// @since 2026-09-09
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationAudit {
/// 操作编码
///
/// @return 操作编码
AuditOperation value();
}
@@ -0,0 +1,83 @@
package com.imyeyu.api.modules.system.audit;
import com.imyeyu.api.modules.system.entity.Logger;
import com.imyeyu.java.TimiJava;
import com.imyeyu.java.bean.timi.TimiException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
/// 操作审计切面
///
/// 该切面放在事务拦截器外层,确保成功日志只在事务提交成功后生成
///
/// @author Codex
/// @since 2026-09-09
@Aspect
@Component
@Order(Ordered.LOWEST_PRECEDENCE - 100)
@RequiredArgsConstructor
@Slf4j
public class OperationAuditAspect {
private final AuditRecorder auditRecorder;
private final AuditPayloadSerializer payloadSerializer;
/// 执行并记录操作审计
///
/// @param joinPoint 连接点
/// @param operationAudit 审计配置
/// @return 业务返回值
/// @throws Throwable 业务异常
@Around("@annotation(operationAudit)")
public Object around(ProceedingJoinPoint joinPoint, OperationAudit operationAudit) throws Throwable {
AuditOperation operation = operationAudit.value();
Object[] arguments = joinPoint.getArgs();
Logger logger = new Logger(operation.getModule(), operation.name());
logger.setContent(payloadSerializer.serializeArguments(arguments));
long startNanos = System.nanoTime();
try {
Object result = joinPoint.proceed();
logger.setLevel(Logger.Level.INFO);
logger.setResult(payloadSerializer.serializeResult(result, arguments));
logger.setDuration(duration(startNanos));
record(logger);
return result;
} catch (TimiException e) {
logger.setLevel(Logger.Level.WARN);
logger.setException(e.getMessage());
logger.setDuration(duration(startNanos));
record(logger);
throw e;
} catch (Throwable e) {
logger.setLevel(Logger.Level.ERROR);
logger.setException(TimiJava.serializeThrowable(e));
logger.setDuration(duration(startNanos));
log.error("业务操作失败: {}", operation.name(), e);
record(logger);
throw e;
}
}
private void record(Logger logger) {
try {
auditRecorder.record(logger);
} catch (Exception e) {
log.error("记录操作审计失败: {}", logger.getOperation(), e);
}
}
private int duration(long startNanos) {
long millis = (System.nanoTime() - startNanos) / 1_000_000L;
if (Integer.MAX_VALUE < millis) {
return Integer.MAX_VALUE;
}
return (int) Math.max(0L, millis);
}
}
@@ -1,19 +0,0 @@
package com.imyeyu.api.modules.system.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
/**
*
*
* @author 夜雨
* @since 2026-04-23 16:31
*/
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/system/setting")
public class SettingController {
}
@@ -6,8 +6,6 @@ import com.imyeyu.spring.service.DestroyableService;
import com.imyeyu.spring.service.GettableService;
import com.imyeyu.spring.service.PageableService;
import java.util.function.Supplier;
/**
* 日志服务
*
@@ -18,51 +16,6 @@ public interface LoggerService extends GettableService<Logger, String>, Pageable
void createSync(Logger logger);
/// 执行并记录有返回值的业务操作
///
/// @param logger 日志对象,调用方负责设置模块、操作和请求内容
/// @param action 业务操作
/// @param <T> 返回值类型
/// @return 业务操作返回值
<T> T execute(Logger logger, Supplier<T> action);
/// 执行并记录无返回值的业务操作
///
/// @param logger 日志对象,调用方负责设置模块、操作和请求内容
/// @param action 业务操作
default void execute(Logger logger, Runnable action) {
execute(logger, () -> {
action.run();
return null;
});
}
/// 创建日志并执行有返回值的业务操作
///
/// @param module 日志模块
/// @param operation 操作描述
/// @param content 请求内容
/// @param action 业务操作
/// @param <T> 返回值类型
/// @return 业务操作返回值
default <T> T execute(Logger.Module module, String operation, Object content, Supplier<T> action) {
Logger logger = new Logger(module, operation);
logger.setContent(content == null ? null : content.toString());
return execute(logger, action);
}
/// 创建日志并执行无返回值的业务操作
///
/// @param module 日志模块
/// @param operation 操作描述
/// @param content 请求内容
/// @param action 业务操作
default void execute(Logger.Module module, String operation, Object content, Runnable action) {
Logger logger = new Logger(module, operation);
logger.setContent(content == null ? null : content.toString());
execute(logger, action);
}
void create(Logger.Module module, Logger.Level level, String operation, String content, String result);
default void info(Logger.Module module, String operation) {
@@ -1,37 +1,41 @@
package com.imyeyu.api.modules.system.service.implement;
import com.imyeyu.api.modules.system.entity.Logger;
import com.imyeyu.api.modules.system.audit.AuditRecorder;
import com.imyeyu.api.modules.system.mapper.LoggerMapper;
import com.imyeyu.api.modules.system.service.LoggerService;
import com.imyeyu.api.modules.user.entity.User;
import com.imyeyu.api.modules.user.service.UserLoginService;
import com.imyeyu.java.TimiJava;
import com.imyeyu.java.bean.timi.TimiException;
import com.imyeyu.spring.TimiSpring;
import com.imyeyu.spring.entity.IDEntity;
import com.imyeyu.spring.mapper.BaseMapper;
import com.imyeyu.spring.service.AbstractEntityService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import java.util.function.Supplier;
/**
* 日志服务实现
*
* @author 夜雨
* @since 2026-05-13 14:46
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LoggerServiceImplement extends AbstractEntityService<Logger, String> implements LoggerService {
public class LoggerServiceImplement extends AbstractEntityService<Logger, String> implements LoggerService, AuditRecorder {
private final UserLoginService userLoginService;
private final LoggerMapper mapper;
private final ThreadPoolTaskExecutor auditTaskExecutor;
public LoggerServiceImplement(UserLoginService userLoginService, LoggerMapper mapper,
@Qualifier("auditTaskExecutor") ThreadPoolTaskExecutor auditTaskExecutor) {
this.userLoginService = userLoginService;
this.mapper = mapper;
this.auditTaskExecutor = auditTaskExecutor;
}
@Override
protected BaseMapper<Logger, String> mapper() {
return mapper;
@@ -39,9 +43,13 @@ public class LoggerServiceImplement extends AbstractEntityService<Logger, String
@Override
public void create(Logger logger) {
// 默认异步
fillContext(logger);
Thread.startVirtualThread(() -> createSync(logger));
auditTaskExecutor.execute(() -> createSync(logger));
}
@Override
public void record(Logger logger) {
create(logger);
}
@Override
@@ -50,32 +58,6 @@ public class LoggerServiceImplement extends AbstractEntityService<Logger, String
super.create(logger);
}
@Override
public <T> T execute(Logger logger, Supplier<T> action) {
try {
T result = action.get();
logger.setLevel(Logger.Level.INFO);
if (result instanceof IDEntity<?> entity) {
logger.setResult(String.valueOf(entity.getId()));
} else if (result != null) {
logger.setResult(result.toString());
}
create(logger);
return result;
} catch (TimiException e) {
logger.setLevel(Logger.Level.WARN);
logger.setException(e.getMessage());
create(logger);
throw e;
} catch (RuntimeException e) {
logger.setLevel(Logger.Level.ERROR);
logger.setException(TimiJava.serializeThrowable(e));
log.error("业务操作失败: {}", logger.getOperation(), e);
create(logger);
throw e;
}
}
public void create(Logger.Module module, Logger.Level level, String operation, String content, String result) {
Logger log = new Logger(module, level, operation);
log.setContent(content);
@@ -0,0 +1,55 @@
package db.migration.timiserver;
import com.imyeyu.api.util.PinyinUtil;
import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
/// 回填 GAO 产品名称拼音搜索字段
///
/// @author Codex
/// @since 2026-09-10
public class V78__backfill_gao_product_pinyin_search extends BaseJavaMigration {
private static final int BATCH_SIZE = 500;
@Override
public void migrate(Context context) throws Exception {
Connection connection = context.getConnection();
try (
PreparedStatement select = connection.prepareStatement("""
SELECT `id`, `name`
FROM `gao_product`
WHERE `name` IS NOT NULL
AND (`name_pinyin_full` IS NULL OR `name_pinyin_initial` IS NULL OR `name_pinyin_mixed` IS NULL)
""");
PreparedStatement update = connection.prepareStatement("""
UPDATE `gao_product`
SET `name_pinyin_full` = ?, `name_pinyin_initial` = ?, `name_pinyin_mixed` = ?
WHERE `id` = ?
""")) {
try (ResultSet resultSet = select.executeQuery()) {
int batchSize = 0;
while (resultSet.next()) {
String name = resultSet.getString("name");
update.setString(1, PinyinUtil.full(name));
update.setString(2, PinyinUtil.initial(name));
update.setString(3, PinyinUtil.mixed(name));
update.setString(4, resultSet.getString("id"));
update.addBatch();
batchSize++;
if (batchSize >= BATCH_SIZE) {
update.executeBatch();
batchSize = 0;
}
}
if (0 < batchSize) {
update.executeBatch();
}
}
}
}
}
@@ -0,0 +1,5 @@
ALTER TABLE `gao_sales_order_item`
ADD COLUMN `gifted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'true 为赠送权益' AFTER `item_type`,
DROP KEY `uk_gao_sales_order_item_sku_unit`,
ADD UNIQUE KEY `uk_gao_sales_order_item_sku_unit_gifted` (`sales_order_id`, `sku_id`, `sku_unit_id`, `gifted`),
ADD KEY `idx_gao_sales_order_item_order_gifted` (`sales_order_id`, `gifted`, `sort`, `created_at`);
@@ -0,0 +1,25 @@
ALTER TABLE `notify`
CHANGE COLUMN `biz_reminded_id` `biz_reminded_key` VARCHAR(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '业务提醒查重键',
ADD COLUMN `biz_reminded_active_key` TINYINT(1) GENERATED ALWAYS AS (IF(`deleted_at` IS NULL, 1, NULL)) STORED COMMENT '未删除查重键' INVISIBLE AFTER `deleted_at`,
DROP INDEX `idx_biz_reminded_created`,
ADD INDEX `idx_biz_reminded_key_created`(`biz_type` ASC, `biz_reminded_type` ASC, `biz_reminded_key` ASC, `created_at` ASC) USING BTREE;
UPDATE `notify` target
INNER JOIN (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY biz_type, biz_reminded_type, biz_reminded_key
ORDER BY created_at DESC, id DESC
) AS rn
FROM `notify`
WHERE deleted_at IS NULL
AND biz_type IS NOT NULL
AND biz_reminded_type IS NOT NULL
AND biz_reminded_key IS NOT NULL
) duplicate ON duplicate.id = target.id
SET target.deleted_at = UNIX_TIMESTAMP() * 1000
WHERE duplicate.rn > 1;
ALTER TABLE `notify`
ADD UNIQUE INDEX `uk_biz_reminded_key_active`(`biz_type` ASC, `biz_reminded_type` ASC, `biz_reminded_key` ASC, `biz_reminded_active_key` ASC) USING BTREE;
@@ -0,0 +1,7 @@
ALTER TABLE `gao_product`
ADD COLUMN `name_pinyin_full` VARCHAR(768) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '产品名称全拼' AFTER `name`,
ADD COLUMN `name_pinyin_initial` VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '产品名称拼音首字母' AFTER `name_pinyin_full`,
ADD COLUMN `name_pinyin_mixed` VARCHAR(768) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '产品名称混合拼音' AFTER `name_pinyin_initial`,
ADD KEY `idx_gao_product_store_name_pinyin_full` (`store_id`, `deleted_at`, `name_pinyin_full`(128)),
ADD KEY `idx_gao_product_store_name_pinyin_initial` (`store_id`, `deleted_at`, `name_pinyin_initial`),
ADD KEY `idx_gao_product_store_name_pinyin_mixed` (`store_id`, `deleted_at`, `name_pinyin_mixed`(128));
@@ -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 积分兑换退货记录';
@@ -1,6 +1,31 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.imyeyu.api.modules.common.mapper.NotifyMapper">
<insert id="insertIfAbsent">
INSERT INTO `notify` (
`id`,
`biz_type`,
`biz_reminded_type`,
`biz_reminded_key`,
`args`,
`created_by`,
`created_at`,
`updated_at`,
`deleted_at`
) VALUES (
#{id},
#{bizType},
#{bizRemindedType},
#{bizRemindedKey},
#{args},
#{createdBy},
#{createdAt},
#{updatedAt},
#{deletedAt}
)
ON DUPLICATE KEY UPDATE `id` = `id`
</insert>
<select id="selectManagementPage" resultType="com.imyeyu.api.modules.common.vo.NotifyManagementItem">
SELECT
n.`id`,
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.imyeyu.api.modules.common.mapper.SettingMapper">
<select id="selectPublicByQuery" resultType="com.imyeyu.api.modules.common.entity.Setting">
SELECT *
FROM `setting`
WHERE `biz_type` = #{bizType}
AND `is_private` = 0
<if test="bizId != null and bizId != ''">
AND `biz_id` = #{bizId}
</if>
<if test="codeList != null and !codeList.isEmpty()">
AND `code` IN
<foreach collection="codeList" item="code" separator="," open="(" close=")">
#{code}
</foreach>
</if>
AND (`deleted_at` IS NULL OR FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000) &lt; `deleted_at`)
</select>
</mapper>

Some files were not shown because too many files have changed in this diff Show More