Compare commits

..
50 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
timi 3b19d9e930 Merge pull request 'v1.0.36' (#48) from dev into master
Reviewed-on: #48
2026-09-08 17:52:47 +00:00
Timi 2ea06fb5c0 v1.0.36
CI / build-deploy (pull_request) Successful in 25s
CI / notify-on-failure (pull_request) Skipped
2026-09-09 01:49:52 +08:00
Timi 00bb300755 support split fulfillment by base unit 2026-09-09 01:49:40 +08:00
Timi ea44dd5c0e update point rule groovy script 2026-09-09 01:08:45 +08:00
Timi 818207d6c9 fix FileController 2026-09-08 18:09:31 +08:00
Timi af76eb43b8 update SystemSetting 2026-09-08 16:52:32 +08:00
timi 5fd11e9caf Merge pull request 'v1.0.35' (#47) from dev into master
Reviewed-on: #47
2026-09-07 17:06:28 +00:00
Timi d920b8630d v1.0.35
CI / build-deploy (pull_request) Successful in 25s
CI / notify-on-failure (pull_request) Skipped
2026-09-08 01:05:45 +08:00
150 changed files with 4244 additions and 1113 deletions
+6 -1
View File
@@ -11,7 +11,7 @@
<groupId>com.imyeyu.timiserverapi</groupId>
<artifactId>TimiServerAPI</artifactId>
<version>1.0.34</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>
@@ -1,35 +0,0 @@
package com.imyeyu.api.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 启用配置注解
*
* @author 夜雨
* @since 2023-07-15 10:00
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EnableSetting {
/**
*
*
* @author 夜雨
* @since 2026-04-27 14:45
*/
enum Logic {
AND,
OR
}
Logic logic() default Logic.AND;
/** @return 未启用配置时响应消息语言映射键 */
String message() default "service.offline";
}
@@ -1,55 +0,0 @@
package com.imyeyu.api.annotation;
import com.imyeyu.api.modules.common.service.SettingService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Lazy;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
/**
* 启用配置注解处理器
*
* @author 夜雨
* @since 2023-07-15 10:01
*/
@Component
@RequiredArgsConstructor(onConstructor_ = {@Lazy})
public class EnableSettingInterceptor implements HandlerInterceptor {
private final SettingService service;
public boolean preHandle(@NonNull HttpServletRequest req, @NonNull HttpServletResponse resp, @NonNull Object handler) throws InvocationTargetException, IllegalAccessException {
if (handler instanceof HandlerMethod handlerMethod) {
EnableSetting annotation = handlerMethod.getMethodAnnotation(EnableSetting.class);
if (annotation == null) {
return true;
}
EnableSetting.Logic logic = annotation.logic();
List<Boolean> valueList = new ArrayList<>();
for (Method method : annotation.getClass().getMethods()) {
if (method.getName().equals("message")) {
continue;
}
Enum<?>[] enums = (Enum<?>[]) method.invoke(annotation);
for (Enum<?> anEnum : enums) {
valueList.add(service.getSystem(anEnum).isTrue());
}
}
if (logic == EnableSetting.Logic.AND) {
return valueList.stream().allMatch(Boolean::booleanValue);
} else {
return valueList.stream().anyMatch(Boolean::booleanValue);
}
}
return true;
}
}
@@ -0,0 +1,17 @@
package com.imyeyu.api.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
///
///
/// @author 夜雨
/// @since 2026-09-08 15:22
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface SettingBizType {
String value();
}
@@ -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),
@@ -0,0 +1,27 @@
package com.imyeyu.api.bean;
import com.imyeyu.api.annotation.SettingBizType;
import com.imyeyu.api.modules.common.entity.Setting;
import com.imyeyu.utils.Text;
///
///
/// @author 夜雨
/// @since 2026-09-08 15:18
public interface SettingEnum {
default String getBizType() {
Class<? extends SettingEnum> implEnum = getClass();
SettingBizType bizType = implEnum.getAnnotation(SettingBizType.class);
if (bizType == null) {
return Text.bizClassName(implEnum);
}
return bizType.value();
}
String name();
String getDefaultValue();
Setting.ValueType getValueType();
}
@@ -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;
}
}
@@ -6,7 +6,6 @@ import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.imyeyu.api.annotation.EnableSettingInterceptor;
import com.imyeyu.api.annotation.RequestRateLimitInterceptor;
import com.imyeyu.api.annotation.RequireCorePermissionInterceptor;
import com.imyeyu.api.modules.gao.annotation.RequireGaoPermissionInterceptor;
@@ -40,7 +39,6 @@ public class WebConfig implements WebMvcConfigurer {
private final ObjectMapper jackson;
private final SystemAPIInterceptor systemAPIInterceptor;
private final JournalAPIInterceptor journalAPIInterceptor;
private final EnableSettingInterceptor enableSettingInterceptor;
private final RequestRateLimitInterceptor requestRateLimitInterceptor;
private final RequireGaoPermissionInterceptor requireGaoPermissionInterceptor;
private final RequireCorePermissionInterceptor requireCorePermissionInterceptor;
@@ -54,7 +52,6 @@ public class WebConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(systemAPIInterceptor).addPathPatterns(SystemAPIInterceptor.PATH);
registry.addInterceptor(journalAPIInterceptor).addPathPatterns(JournalAPIInterceptor.PATH);
registry.addInterceptor(enableSettingInterceptor).addPathPatterns("/**");
registry.addInterceptor(requireCorePermissionInterceptor).addPathPatterns("/**");
registry.addInterceptor(requireGaoPermissionInterceptor).addPathPatterns("/**");
registry.addInterceptor(requestRateLimitInterceptor).addPathPatterns("/**");
@@ -1,52 +0,0 @@
package com.imyeyu.api.modules.common.bean;
import com.imyeyu.api.modules.common.entity.Setting;
import com.imyeyu.java.bean.timi.TimiCode;
import com.imyeyu.java.bean.timi.TimiException;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 设置模块
*
* @author 夜雨
* @since 2026-04-24 00:59
*/
@Getter
@AllArgsConstructor
public enum SettingModule {
COMMON(Setting.Module.Common.class),
DOMAIN(Setting.Module.Domain.class),
FOREVER_MC(Setting.Module.ForeverMC.class),
MULTILINGUAL(Setting.Module.Multilingual.class),
GITEA(Setting.Module.Gitea.class),
MUSIC(Setting.Module.Music.class),
JOURNAL(Setting.Module.Journal.class),
TEMP_FILE(Setting.Module.TempFile.class),
USER(Setting.Module.User.class),
SYSTEM(Setting.Module.System.class),
SYSTEM_STATUS(Setting.Module.SystemStatus.class);
/** 模块枚举类 */
final Class<? extends Enum<?>> clazz;
public static <T extends Enum<?>> SettingModule fromClass(Class<T> clazz) {
for (SettingModule value : values()) {
if (value.clazz.equals(clazz)) {
return value;
}
}
throw new TimiException(TimiCode.ERROR, "not found System Module: %s".formatted(clazz.getName()));
}
}
@@ -0,0 +1,41 @@
package com.imyeyu.api.modules.common.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-04-24 14:47
*/
@Getter
@SettingBizType("TEMP_FILE")
@AllArgsConstructor
public enum TempFileSetting implements SettingEnum {
/** 启用服务 */
ENABLE(Boolean.TRUE.toString(), Setting.ValueType.BOOLEAN),
/** 最小缓存时长 */
TTL_MIN("1h", Setting.ValueType.TIME),
/** 默认缓存时长 */
TTL_DEFAULT("3d", Setting.ValueType.TIME),
/** 最大缓存时长 */
TTL_MAX("7d", Setting.ValueType.TIME),
/** 已过期文件残留时长 */
RESIDUE_TTL("90d", Setting.ValueType.TIME),
/** 每个 IP 限制有效临时文件容量 */
LIMIT_SIZE_OF_IP("10 GB", Setting.ValueType.BYTE_SIZE);
final String defaultValue;
final Setting.ValueType valueType;
}
@@ -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);
}
}
@@ -58,6 +58,14 @@ public class NotifyController {
@RequiredToken
@PostMapping("/create")
public Notify create(@RequestBody Notify notify) {
String sendFrom = userLoginService.isLogged() ? userLoginService.getRequireLoginUserId() : null;
notify.setCreatedBy(sendFrom);
for (NotifyDetail detail : notify.getDetailList()) {
detail.setNotifyId(notify.getId());
if (TimiJava.isNotEmpty(sendFrom)) {
detail.setSendFrom(sendFrom);
}
}
service.create(notify);
return notify;
}
@@ -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);
}
/**
* 查询配置列表
*
@@ -53,6 +76,7 @@ public class SettingController {
*/
@JsonView(ResponseView.Admin.class)
@RequestRateLimit
@RequireCorePermission(CorePermissionCode.SETTING_READ)
@PostMapping("/detail")
public Setting detail(@RequestBody String id) {
return service.get(id);
@@ -66,6 +90,7 @@ public class SettingController {
*/
@AOPLog
@JsonView(ResponseView.Admin.class)
@RequireCorePermission(CorePermissionCode.SETTING_CREATE)
@RequestRateLimit
@PostMapping("/create")
public Setting create(@RequestBody Setting setting) {
@@ -80,6 +105,7 @@ public class SettingController {
*/
@AOPLog
@RequestRateLimit
@RequireCorePermission(CorePermissionCode.SETTING_UPDATE)
@PostMapping("/update")
public void update(@RequestBody Setting setting) {
service.update(setting);
@@ -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
@@ -1,5 +1,6 @@
package com.imyeyu.api.modules.common.entity;
import com.imyeyu.spring.annotation.table.Transient;
import com.imyeyu.spring.entity.UUIDEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -39,4 +40,7 @@ public class GroovyScript extends UUIDEntity {
/// 当前发布版本 ID
private String publishedVersionId;
@Transient
private GroovyScriptVersion latest;
}
@@ -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;
@@ -7,7 +7,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.imyeyu.api.TimiServerAPI;
import com.imyeyu.api.modules.common.bean.SettingModule;
import com.imyeyu.spring.annotation.table.Transient;
import com.imyeyu.spring.entity.UUIDEntity;
import com.imyeyu.spring.util.ResponseView;
@@ -62,6 +61,9 @@ public class Setting extends UUIDEntity {
/** 时间 */
TIME,
/// 字节大小
BYTE_SIZE,
/** 时长 */
DURATION,
@@ -102,16 +104,6 @@ public class Setting extends UUIDEntity {
@Transient
private Enum<?> keyRaw;
public Setting(Enum<?> key) {
setKeyRaw(key);
}
public Setting(Enum<?> key, Object value, ValueType valueType) {
setKeyRaw(key);
this.value = value.toString();
this.valueType = valueType;
}
/**
* 以字符串形式返回配置值
*
@@ -221,298 +213,4 @@ public class Setting extends UUIDEntity {
throw new IllegalArgumentException("setting value is not valid json: %s".formatted(value), e);
}
}
public void setKeyRaw(Enum<?> key) {
this.keyRaw = key;
this.bizType = SettingModule.fromClass(key.getClass()).toString();
this.code = key.toString();
}
public boolean is(Enum<?> key) {
return SettingModule.fromClass(key.getClass()).toString().equals(bizType) && key.toString().equals(code);
}
/**
*
*
* @author 夜雨
* @since 2026-04-24 14:23
*/
public static final class Module {
/**
*
*
* @author 夜雨
* @since 2026-04-24 15:39
*/
public enum Common {
/** 微信收款码 */
WECHAT_RECEIVE_QR_CODE
}
/**
* 域名
*
* @author 夜雨
* @since 2026-04-26 17:18
*/
public enum Domain {
ROOT,
API,
GIT,
BLOG,
SPACE,
FOREVER_MC
}
/**
*
*
* @author 夜雨
* @since 2026-04-24 15:14
*/
public enum Blog {
}
/**
*
*
* @author 夜雨
* @since 2026-04-24 15:03
*/
public enum User {
/** 启用登录 */
ENABLE_LOGIN,
/** 启用注册 */
ENABLE_REGISTER,
/** 允许数据更新 */
ENABLE_PROFILE_UPDATE,
/** 令牌有效期 */
TOKEN_TTL
}
/**
*
*
* @author 夜雨
* @since 2026-04-24 15:21
*/
public enum Comment {
/** 启用服务 */
ENABLE
}
/**
*
*
* @author 夜雨
* @since 2026-04-24 15:03
*/
public enum ICP {
IMYEYU_COM
}
/**
*
*
* @author 夜雨
* @since 2026-04-24 15:01
*/
public enum ForeverMC {
PLAYER_BIND_MAX,
LOGIN_TOKEN_TTL
}
/**
*
*
* @author 夜雨
* @since 2026-04-24 14:57
*/
public enum Multilingual {
/** 翻译 API */
TRANSLATE_API,
/** 翻译 AppId */
TRANSLATE_APP_ID,
/** 翻译密钥 */
TRANSLATE_KEY,
/** 翻译计划任务表达式 */
TRANSLATE_CORN,
/** 缓存时长 */
CACHE_TTL
}
/**
*
*
* @author 夜雨
* @since 2026-04-24 14:55
*/
public enum Gitea {
/** 接口地址 */
API,
/** 关于页面所属文章 ID */
ABOUT_ARTICLE_ID,
/** 仓库文件地址 */
REPOSITORY_PATH
}
/**
*
*
* @author 夜雨
* @since 2026-04-24 14:52
*/
public enum Music {
/** 启用服务 */
ENABLE,
/** 通信消息最大帧长度 */
MAX_FRAME_LENGTH,
/** 受控端 IP */
PLAYER_IP,
/** 受控端端口 */
PLAYER_PORT,
/** 控制端 IP */
CONTROLLER_IP,
/** 控制端端口 */
CONTROLLER_PORT,
/** 控制端入口地址 */
CONTROLLER_URI,
}
/**
*
*
* @author 夜雨
* @since 2026-04-24 14:49
*/
public enum Journal {
/** 启用服务 */
ENABLE,
/** 审核中 */
REVIEWING,
/** 访问密钥 */
KEY,
/** 微信小程序 AppId */
APP_ID,
/** 微信小程序 AppSecret */
APP_SECRET,
/** 备忘录内容 */
MEMO,
}
/**
*
*
* @author 夜雨
* @since 2026-04-24 14:47
*/
public enum TempFile {
/** 启用服务 */
ENABLE,
/** 最小缓存时长 */
TTL_MIN,
/** 默认缓存时长 */
TTL_DEFAULT,
/** 最大缓存时长 */
TTL_MAX,
/** 已过期文件残留时长 */
RESIDUE_TTL,
/** 每个 IP 限制有效临时文件容量 */
LIMIT_SIZE_OF_IP
}
/**
*
*
* @author 夜雨
* @since 2026-04-27 14:35
*/
public enum FileSync {
ENABLE,
CRON,
}
/**
*
*
* @author 夜雨
* @since 2026-04-24 14:23
*/
public enum System {
/** 文件系统基准路径 */
FILE_BASE_PATH,
/** 设置缓存时长 */
SETTING_TTL,
/** 哀悼滤镜 */
ENABLE_MOURN_FILTER,
}
/**
*
*
* @author 夜雨
* @since 2026-04-24 14:23
*/
public enum SystemStatus {
/** 状态采集数据量 */
LIMIT,
/** 状态采集网卡 */
NETWORK_MAC,
/** 状态采集频率 */
RATE,
}
}
}
@@ -2,10 +2,14 @@ package com.imyeyu.api.modules.common.mapper;
import com.imyeyu.api.modules.common.entity.GroovyScriptVersion;
import com.imyeyu.spring.mapper.BaseMapper;
import org.apache.ibatis.annotations.Select;
/// Groovy 脚本版本 Mapper
///
/// @author Codex
/// @since 2026-09-04
public interface GroovyScriptVersionMapper extends BaseMapper<GroovyScriptVersion, String> {
@Select("SELECT * FROM `groovy_script_version` WHERE `script_id` = #{scriptId} " + NOT_DELETE + " ORDER BY `version` DESC " + LIMIT_1)
GroovyScriptVersion selectLatest(String scriptId);
}
@@ -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);
}
@@ -1,8 +1,8 @@
package com.imyeyu.api.modules.common.service;
import com.imyeyu.api.modules.common.bean.GroovyScriptExecutionResult;
import com.imyeyu.api.modules.common.entity.GroovyScript;
import com.imyeyu.api.modules.common.entity.GroovyScriptVersion;
import com.imyeyu.api.modules.common.bean.GroovyScriptExecutionResult;
import com.imyeyu.spring.service.BaseService;
import java.util.List;
@@ -16,6 +16,12 @@ import java.util.Map;
/// @since 2026-09-04
public interface GroovyScriptService extends BaseService<GroovyScript, String> {
/// 按业务归属查询脚本列表
///
/// @param bizType 业务归属
/// @return 脚本列表
List<GroovyScript> listByBizType(GroovyScript.BizType bizType);
/// 按业务归属和代码查询脚本
///
/// @param bizType 业务归属
@@ -23,26 +29,14 @@ public interface GroovyScriptService extends BaseService<GroovyScript, String> {
/// @return 脚本,不存在时返回 null
GroovyScript getByCode(GroovyScript.BizType bizType, String code);
/// 创建脚本元数据
///
/// 新建脚本默认停用,且没有已发布版本
///
/// @param script 脚本元数据
void create(GroovyScript script);
/// 更新脚本元数据
///
/// 业务归属和代码是脚本稳定标识,创建后不可变更
///
/// @param script 脚本元数据
void update(GroovyScript script);
/// 设置脚本启用状态
///
/// @param id 脚本 ID
/// @param enabled true 为允许执行已发布版本
void setEnabled(String id, boolean enabled);
GroovyScriptVersion getLatestVersion(String scriptId);
/// 创建不可变的脚本版本
///
/// @param scriptId 脚本 ID
@@ -67,11 +61,6 @@ public interface GroovyScriptService extends BaseService<GroovyScript, String> {
/// @param scriptId 脚本 ID
void unpublish(String scriptId);
/// 删除脚本
///
/// @param id 脚本 ID
void delete(String id);
/// 执行已发布且启用的脚本
///
/// @param bizType 业务归属
@@ -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);
/// 分页查询当前用户创建的通知
///
@@ -1,12 +1,13 @@
package com.imyeyu.api.modules.common.service;
import com.imyeyu.api.modules.common.bean.SettingModule;
import com.imyeyu.api.bean.SettingEnum;
import com.imyeyu.api.modules.common.bean.attachment.SettingSupport;
import com.imyeyu.api.modules.common.entity.Setting;
import com.imyeyu.java.TimiJava;
import com.imyeyu.spring.service.BaseService;
import java.util.List;
import java.util.Map;
/**
* 配置服务
@@ -18,12 +19,22 @@ public interface SettingService extends BaseService<Setting, String> {
Setting getByCode(String bizType, String bizId, String code);
default Setting getSystem(Enum<?> bizType) {
return getByCode(SettingModule.fromClass(bizType.getClass()).name(), TimiJava.ZERO_UUID, bizType.toString());
default Setting getSystem(SettingEnum setting) {
return getByCode(setting.getBizType(), TimiJava.ZERO_UUID, setting.name());
}
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());
@@ -1,9 +1,9 @@
package com.imyeyu.api.modules.common.service.implement;
import com.imyeyu.api.config.dbsource.TimiServerDBConfig;
import com.imyeyu.api.modules.common.bean.GroovyScriptExecutionResult;
import com.imyeyu.api.modules.common.entity.GroovyScript;
import com.imyeyu.api.modules.common.entity.GroovyScriptVersion;
import com.imyeyu.api.modules.common.bean.GroovyScriptExecutionResult;
import com.imyeyu.api.modules.common.mapper.GroovyScriptMapper;
import com.imyeyu.api.modules.common.mapper.GroovyScriptVersionMapper;
import com.imyeyu.api.modules.common.service.GroovyScriptService;
@@ -52,6 +52,14 @@ public class GroovyScriptServiceImplement extends AbstractEntityService<GroovySc
return mapper;
}
@Override
public List<GroovyScript> listByBizType(GroovyScript.BizType bizType) {
TimiException.required(bizType, "未找到脚本业务归属");
GroovyScript example = new GroovyScript();
example.setBizType(bizType);
return mapper.selectAllByExample(example);
}
@Override
public GroovyScript getByCode(GroovyScript.BizType bizType, String code) {
GroovyScript example = new GroovyScript();
@@ -95,6 +103,11 @@ public class GroovyScriptServiceImplement extends AbstractEntityService<GroovySc
invalidateAfterCommit(script.getId());
}
@Override
public GroovyScriptVersion getLatestVersion(String scriptId) {
return versionMapper.selectLatest(scriptId);
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GroovyScriptVersion createVersion(String scriptId, String content) {
@@ -104,7 +117,7 @@ public class GroovyScriptServiceImplement extends AbstractEntityService<GroovySc
GroovyScriptVersion version = new GroovyScriptVersion();
version.setScriptId(scriptId);
version.setVersion(nextVersion(scriptId));
version.setVersion(versionMapper.selectLatest(scriptId).getVersion() + 1);
version.setContent(content);
version.setChecksum(checksum(content));
versionMapper.insert(version);
@@ -218,13 +231,6 @@ public class GroovyScriptServiceImplement extends AbstractEntityService<GroovySc
return version;
}
private int nextVersion(String scriptId) {
return listVersion(scriptId).stream()
.map(GroovyScriptVersion::getVersion)
.max(Integer::compareTo)
.orElse(0) + 1;
}
private String cacheKey(GroovyScriptVersion version) {
return "%s:%s".formatted(version.getId(), version.getChecksum());
}
@@ -6,10 +6,9 @@ import com.imyeyu.api.modules.common.entity.NotifyDetail;
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.system.entity.Logger;
import com.imyeyu.api.modules.system.service.LoggerService;
import com.imyeyu.api.modules.common.vo.NotifyManagementItem;
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,46 +43,54 @@ 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 {
prepareCreate(notify);
super.create(notify);
createDetails(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);
}
/// 校验并补全通知创建字段
///
/// @param notify 通知
private void prepareCreate(Notify notify) {
TimiException.required(notify, "未找到通知");
TimiException.required(notify.getDetailList(), "未找到通知详情列表");
String sendFrom = userLoginService.isLogged() ? userLoginService.getRequireLoginUserId() : null;
notify.setCreatedBy(sendFrom);
super.create(notify);
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());
if (TimiJava.isNotEmpty(sendFrom)) {
detail.setSendFrom(sendFrom);
}
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);
}
}
/// 分页查询当前用户创建的通知
@@ -122,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);
@@ -132,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);
@@ -140,11 +148,10 @@ 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);
@@ -152,20 +159,6 @@ public class NotifyServiceImplement extends AbstractEntityService<Notify, String
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);
}
}
/// 取消通知中尚未成功发送的通知详情
@@ -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) {
@@ -3,6 +3,7 @@ package com.imyeyu.api.modules.common.service.implement;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.imyeyu.api.modules.common.bean.TempFileSetting;
import com.imyeyu.api.modules.common.entity.Attachment;
import com.imyeyu.api.modules.common.entity.Setting;
import com.imyeyu.api.modules.common.service.AttachmentService;
@@ -52,11 +53,11 @@ public class TempFileServiceImplement implements TempFileService {
try {
logger.setContent(jackson.writeValueAsString(logRequestArgs(files, ttl)));
Setting ttlMin = settingService.getSystem(Setting.Module.TempFile.TTL_MIN);
Setting ttlMax = settingService.getSystem(Setting.Module.TempFile.TTL_MAX);
Setting ttlDefault = settingService.getSystem(Setting.Module.TempFile.TTL_DEFAULT);
Setting residueTime = settingService.getSystem(Setting.Module.TempFile.RESIDUE_TTL);
String limitStr = settingService.getSystem(Setting.Module.TempFile.LIMIT_SIZE_OF_IP).asString();
Setting ttlMin = settingService.getSystem(TempFileSetting.TTL_MIN);
Setting ttlMax = settingService.getSystem(TempFileSetting.TTL_MAX);
Setting ttlDefault = settingService.getSystem(TempFileSetting.TTL_DEFAULT);
Setting residueTime = settingService.getSystem(TempFileSetting.RESIDUE_TTL);
String limitStr = settingService.getSystem(TempFileSetting.LIMIT_SIZE_OF_IP).asString();
long limit = IOSize.parse(limitStr);
@@ -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,5 +1,7 @@
package com.imyeyu.api.modules.gao.controller;
import com.imyeyu.api.modules.common.entity.GroovyScript;
import com.imyeyu.api.modules.common.service.GroovyScriptService;
import com.imyeyu.api.modules.gao.annotation.RequireGaoPermission;
import com.imyeyu.api.modules.gao.bean.GaoPermissionCode;
import com.imyeyu.api.modules.gao.entity.GaoPointRule;
@@ -31,6 +33,7 @@ public class GaoPointRuleController {
private final GaoStoreScopeService storeScopeService;
private final GaoPointRuleService ruleService;
private final GroovyScriptService groovyScriptService;
@AOPLog
@RequestRateLimit
@@ -54,9 +57,22 @@ public class GaoPointRuleController {
GaoPointRule rule = ruleService.get(id);
TimiException.required(rule, "not found point rule");
storeScopeService.checkStoreId(rule.getStoreId());
if (rule.getRuleMode() == GaoPointRule.RuleMode.GROOVY) {
GroovyScript script = groovyScriptService.get(rule.getGroovyScriptId());
script.setLatest(groovyScriptService.getLatestVersion(script.getId()));
rule.setGroovyScript(script);
}
return rule;
}
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.POINT_RULE_READ)
@PostMapping("/rule/script/list")
public List<GroovyScript> scriptList() {
return groovyScriptService.listByBizType(GroovyScript.BizType.GAO_POINT_RULE);
}
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.POINT_RULE_CREATE)
@@ -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;
/// 性别
@@ -29,7 +29,7 @@ public class GaoOrderFulfillmentItem implements IDEntity<String>, Creatable {
/// SKU ID
private String skuId;
/// SKU 单位 ID
/// 本次实际履约 SKU 单位 ID
private String skuUnitId;
/// 产品图片附件
@@ -45,17 +45,17 @@ public class GaoOrderFulfillmentItem implements IDEntity<String>, Creatable {
/// 基础单位数量
private BigDecimal baseQuantity;
/// 操作前待履约数量
private BigDecimal reservedBefore;
/// 操作前待履约基础单位数量
private BigDecimal reservedBaseBefore;
/// 操作后待履约数量
private BigDecimal reservedAfter;
/// 操作后待履约基础单位数量
private BigDecimal reservedBaseAfter;
/// 操作前已履约数量
private BigDecimal fulfilledBefore;
/// 操作前已履约基础单位数量
private BigDecimal fulfilledBaseBefore;
/// 操作后已履约数量
private BigDecimal fulfilledAfter;
/// 操作后已履约基础单位数量
private BigDecimal fulfilledBaseAfter;
/// 产品名称快照
private String productNameSnapshot;
@@ -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,5 +1,7 @@
package com.imyeyu.api.modules.gao.entity;
import com.imyeyu.api.modules.common.entity.GroovyScript;
import com.imyeyu.spring.annotation.table.Transient;
import com.imyeyu.spring.entity.UUIDEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -96,4 +98,7 @@ public class GaoPointRule extends UUIDEntity {
/// 备注
private String remark;
@Transient
private GroovyScript groovyScript;
}
@@ -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,14 +71,25 @@ public class GaoSalesOrderItem extends UUIDEntity {
/// 基础单位数量系数快照
private BigDecimal baseQuantityPerUnitSnapshot;
/// 当前 SKU 是否允许业务数量保留小数
@Transient
private Boolean allowDecimalQuantity;
/// 当前 SKU 基础单位名称
@Transient
private String baseUnitName;
/// 购买数量
private BigDecimal quantity;
/// 待履约数量
private BigDecimal reservedQuantity;
/// 购买基础单位数量
private BigDecimal baseQuantity;
/// 已履约数量
private BigDecimal fulfilledQuantity;
/// 待履约基础单位数量
private BigDecimal reservedBaseQuantity;
/// 已履约基础单位数量
private BigDecimal fulfilledBaseQuantity;
/// 实际单价,单位分
private Long unitPrice;
@@ -89,15 +103,15 @@ public class GaoSalesOrderItem extends UUIDEntity {
/// 备注
private String remark;
/// 待核销数量
/// 待核销基础单位数量
@Transient
private BigDecimal pendingQuantity;
private BigDecimal pendingBaseQuantity;
/// 当前可履约数量
/// 当前可履约基础单位数量
@Transient
private BigDecimal availableQuantity;
private BigDecimal availableBaseQuantity;
/// 权益剩余数量
/// 权益剩余基础单位数量
@Transient
private BigDecimal remainingQuantity;
private BigDecimal remainingBaseQuantity;
}
@@ -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);
@@ -22,9 +22,9 @@ public interface GaoSalesOrderItemMapper extends BaseMapper<GaoSalesOrderItem, S
/// 锁定订单明细
GaoSalesOrderItem selectForUpdate(@Param("id") String id);
/// 原子调整待履约数量
int updateReservedQuantity(@Param("id") String id, @Param("delta") BigDecimal delta);
/// 原子调整待履约基础单位数量
int updateReservedBaseQuantity(@Param("id") String id, @Param("delta") BigDecimal delta);
/// 原子调整已履约数量
int updateFulfilledQuantity(@Param("id") String id, @Param("delta") BigDecimal delta);
/// 原子调整已履约基础单位数量
int updateFulfilledBaseQuantity(@Param("id") String id, @Param("delta") BigDecimal delta);
}
@@ -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);
}
@@ -58,11 +58,11 @@ public interface GaoSalesOrderService extends BaseService<GaoSalesOrder, String>
/// 检查履约权限
void checkFulfillmentPermission(GaoSalesOrder order);
/// 原子调整订单项待履约数量
int updateItemReservedQuantity(String itemId, BigDecimal delta);
/// 原子调整订单项待履约基础单位数量
int updateItemReservedBaseQuantity(String itemId, BigDecimal delta);
/// 原子调整订单项已履约数量
int updateItemFulfilledQuantity(String itemId, BigDecimal delta);
/// 原子调整订单项已履约基础单位数量
int updateItemFulfilledBaseQuantity(String itemId, BigDecimal delta);
/// 锁定订单明细
GaoSalesOrderItem getItemForUpdate(String itemId);
@@ -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;
@@ -22,9 +24,10 @@ import com.imyeyu.api.modules.gao.vo.GaoCustomerPackagePage;
import com.imyeyu.api.modules.gao.vo.GaoCustomerPackagePurchaseRequest;
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;
@@ -38,7 +41,12 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
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;
@@ -53,16 +61,18 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class GaoCustomerPackageServiceImplement extends AbstractEntityService<GaoCustomerPackage, String> implements GaoCustomerPackageService {
private static final BigDecimal ZERO = BigDecimal.ZERO;
private final GaoCustomerPackageMapper mapper;
private final GaoPackageConfigService packageConfigService;
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() {
@@ -121,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) {
@@ -143,30 +154,33 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
if (request.getItemList() == null) {
request.setItemList(defaultPurchaseItems(configItemList));
}
List<GaoCustomerPackagePurchaseItem> itemList = new java.util.ArrayList<>(request.getItemList());
verifyPurchaseItems(itemList);
itemList.sort(java.util.Comparator.comparing(GaoCustomerPackagePurchaseItem::getSort));
List<GaoCustomerPackagePurchaseItem> requestItemList = new ArrayList<>(request.getItemList());
normalizePurchaseItems(requestItemList);
verifyPurchaseItems(requestItemList);
boolean canOverridePriceAndQuantity = permissionChecker.hasAny(ModuleCode.GAO,
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);
GaoSalesOrder existingOrder = salesOrderService.getByStoreIdAndRequestNo(config.getStoreId(), request.getRequestNo());
if (existingOrder != null) {
GaoCustomerPackage existingPackage = getBySalesOrderId(existingOrder.getId());
verifyPurchaseIdempotency(existingOrder, existingPackage, request);
straightThroughService.complete(salesOrderService.detail(existingOrder.getId()), request.getStraightThrough());
return existingPackage;
}
verifyConfigSellableItems(config.getStoreId(), configItemList);
Long purchasedAt = TimiJava.defaultIfNull(request.getPurchasedAt(), Time.now());
Long now = Time.now();
TimiException.requiredTrue(config.getStatus() == GaoPackageConfig.Status.ACTIVE, "套餐配置不可购买");
TimiException.requiredTrue(config.getBeginAt() == null || config.getBeginAt() <= now, "套餐配置尚未生效");
TimiException.requiredTrue(config.getEndAt() == null || now <= config.getEndAt(), "套餐配置已过期");
GaoCustomer customer = validateCustomer(request.getCustomerId(), config.getStoreId());
verifySameStructureAsConfig(itemList, configItemList);
boolean canOverridePriceAndQuantity = permissionChecker.hasAny(ModuleCode.GAO, com.imyeyu.api.modules.gao.bean.GaoPermissionCode.CUSTOMER_PACKAGE_PRICE_OVERRIDE.getValue());
if (!canOverridePriceAndQuantity) {
TimiException.requiredTrue(request.getSaleAmount().equals(config.getStandardAmount()), "普通员工不能修改套餐成交价");
verifySameQuantityAsConfig(itemList, configItemList);
}
List<GaoSalesOrderItem> orderItemList = itemList.stream().map(this::toOrderItem).toList();
@@ -194,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) {
@@ -239,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());
@@ -258,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());
@@ -267,8 +286,9 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
item.setUnitSnapshot(source.getUnitSnapshot());
item.setBaseQuantityPerUnitSnapshot(source.getBaseQuantityPerUnitSnapshot());
item.setQuantity(source.getTotalQuantity());
item.setReservedQuantity(BigDecimal.ZERO);
item.setFulfilledQuantity(BigDecimal.ZERO);
item.setBaseQuantity(toBaseQuantity(source.getTotalQuantity(), source.getBaseQuantityPerUnitSnapshot(), "套餐权益基础数量最多保留三位小数"));
item.setReservedBaseQuantity(ZERO);
item.setFulfilledBaseQuantity(ZERO);
item.setUnitPrice(null);
item.setLineAmount(null);
item.setSort(source.getSort());
@@ -276,8 +296,33 @@ 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) {
TimiException.requiredTrue(!itemList.isEmpty(), "购买套餐至少需要一个产品");
Set<String> keySet = new HashSet<>();
for (int i = 0; i < itemList.size(); i++) {
GaoCustomerPackagePurchaseItem item = itemList.get(i);
@@ -285,46 +330,93 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
TimiException.required(item.getSkuId(), "购买 SKU 不能为空");
TimiException.required(item.getSkuUnitId(), "购买 SKU 单位不能为空");
TimiException.required(item.getTotalQuantity(), "购买产品数量不能为空");
TimiException.requiredTrue(0 < item.getTotalQuantity().signum(), "购买产品数量必须大于 0");
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 void verifyConfigSellableItems(String storeId, List<GaoPackageConfigItem> itemList) {
for (GaoPackageConfigItem item : itemList) {
TimiException.required(item.getSkuId(), "套餐配置缺少 SKU");
TimiException.required(item.getSkuUnitId(), "套餐配置缺少 SKU 单位");
// 通过 SKU 服务唯一选品入口重新校验 SKU 和单位仍处于可销售状态
skuService.getSellableSkuUnit(storeId, item.getSkuId(), item.getSkuUnitId());
private List<GaoCustomerPackagePurchaseItem> resolvePurchaseItems(String storeId, List<GaoCustomerPackagePurchaseItem> requestItemList,
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<>();
for (GaoPackageConfigItem configItem : configItemList) {
configBaseQuantityMap.merge(configItem.getSkuId(),
toBaseQuantity(configItem.getDefaultQuantity(), configItem.getBaseQuantityPerUnitSnapshot(), "套餐配置基础数量最多保留三位小数"), BigDecimal::add);
}
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());
if (gifted) {
TimiException.requiredTrue(canCreateGift, "没有客户套餐创建赠送权限");
} else {
TimiException.requiredTrue(skuConfigItemList != null && !skuConfigItemList.isEmpty(), "购买 SKU 不属于套餐配置");
}
GaoProductSkuUnitSelection selection = skuService.getSellableSkuUnit(storeId, requestItem.getSkuId(), requestItem.getSkuUnitId());
if (!gifted) {
TimiException.requiredTrue(skuConfigItemList.stream().anyMatch(configItem -> 0 <= configItem.getBaseQuantityPerUnitSnapshot()
.compareTo(selection.getUnit().getBaseQuantityPerUnit())), "套餐权益单位不能高于配置单位");
}
BigDecimal requestBaseQuantity = toBaseQuantity(requestItem.getTotalQuantity(), selection.getUnit().getBaseQuantityPerUnit(), "套餐权益基础数量最多保留三位小数");
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 不允许小数数量");
}
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());
item.setSkuNameSnapshot(selection.getSku().getName());
item.setSpecSummarySnapshot(selection.getSku().getSpecSummary());
item.setUnitSnapshot(selection.getUnit().getName());
item.setBaseQuantityPerUnitSnapshot(selection.getUnit().getBaseQuantityPerUnit());
item.setTotalQuantity(requestItem.getTotalQuantity());
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(), "购买套餐至少需要一个产品");
if (!canOverridePriceAndQuantity) {
for (Map.Entry<String, BigDecimal> entry : configBaseQuantityMap.entrySet()) {
TimiException.requiredTrue(entry.getValue().compareTo(requestBaseQuantityMap.getOrDefault(entry.getKey(), ZERO)) == 0,
"普通员工不能修改套餐权益配额");
}
}
return result;
}
private void verifySameStructureAsConfig(List<GaoCustomerPackagePurchaseItem> itemList, List<GaoPackageConfigItem> configItemList) {
TimiException.requiredTrue(itemList.size() == configItemList.size(), "套餐权益项目必须与套餐配置一致");
Map<String, GaoCustomerPackagePurchaseItem> itemMap = itemList.stream().collect(Collectors.toMap(item -> item.getSkuId() + "|" + item.getSkuUnitId(), Function.identity()));
for (GaoPackageConfigItem configItem : configItemList) {
GaoCustomerPackagePurchaseItem item = itemMap.get(configItem.getSkuId() + "|" + configItem.getSkuUnitId());
TimiException.required(item, "套餐权益项目必须与套餐配置一致");
item.setProductNameSnapshot(configItem.getProductNameSnapshot());
item.setProductIdSnapshot(configItem.getProductIdSnapshot());
item.setSkuCodeSnapshot(configItem.getSkuCodeSnapshot());
item.setSkuNameSnapshot(configItem.getSkuNameSnapshot());
item.setSpecSummarySnapshot(configItem.getSpecSummarySnapshot());
item.setUnitSnapshot(configItem.getUnitSnapshot());
item.setBaseQuantityPerUnitSnapshot(configItem.getBaseQuantityPerUnitSnapshot());
TimiException.requiredTrue(item.getSort().equals(configItem.getSort())
&& sameText(item.getRemark(), configItem.getRemark()), "套餐权益项目的名称、单位、排序和备注不可修改");
}
private GaoPackageConfigItem resolveItemTemplate(List<GaoPackageConfigItem> configItemList, String skuUnitId) {
return configItemList.stream().filter(item -> skuUnitId.equals(item.getSkuUnitId())).findFirst()
.orElseGet(() -> configItemList.stream().min(Comparator.comparing(GaoPackageConfigItem::getSort)).orElseThrow());
}
private void verifySameQuantityAsConfig(List<GaoCustomerPackagePurchaseItem> itemList, List<GaoPackageConfigItem> configItemList) {
Map<String, GaoCustomerPackagePurchaseItem> itemMap = itemList.stream().collect(Collectors.toMap(item -> item.getSkuId() + "|" + item.getSkuUnitId(), Function.identity()));
for (GaoPackageConfigItem configItem : configItemList) {
GaoCustomerPackagePurchaseItem item = itemMap.get(configItem.getSkuId() + "|" + configItem.getSkuUnitId());
TimiException.requiredTrue(item.getTotalQuantity().compareTo(configItem.getDefaultQuantity()) == 0, "普通员工不能修改套餐权益配额");
private BigDecimal toBaseQuantity(BigDecimal quantity, BigDecimal baseQuantityPerUnit, String message) {
BigDecimal result = quantity.multiply(baseQuantityPerUnit);
TimiException.requiredTrue(3 >= result.stripTrailingZeros().scale(), message);
try {
return result.setScale(3, RoundingMode.UNNECESSARY);
} catch (ArithmeticException e) {
throw new TimiException(com.imyeyu.java.bean.timi.TimiCode.ERROR, message, e);
}
}
@@ -340,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;
@@ -29,9 +29,10 @@ import com.imyeyu.api.modules.gao.vo.GaoOrderFulfillmentItemRequest;
import com.imyeyu.api.modules.gao.vo.GaoOrderFulfillmentPage;
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;
@@ -46,6 +47,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
@@ -77,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() {
@@ -145,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) {
@@ -181,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) {
@@ -194,18 +197,18 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
GaoOrderFulfillment snapshot = mapper.selectById(request.getFulfillmentId());
TimiException.required(snapshot, "not found fulfillment");
GaoSalesOrder order = salesOrderService.getForUpdate(snapshot.getSalesOrderId());
List<SourceEntry> sourceList = loadSources(order, itemRequests(snapshot.getId()), false);
List<SourceEntry> sourceList = loadPersistedSources(order, itemMapper.selectByFulfillmentId(snapshot.getId()));
GaoOrderFulfillment fulfillment = lockFulfillment(snapshot.getId(), order);
storeScopeService.checkStoreId(fulfillment.getStoreId());
TimiException.requiredTrue(fulfillment.getStatus() == GaoOrderFulfillment.Status.WAITING_STOCK, "履约单不是等待库存状态");
TimiException.requiredTrue(order.getStatus() != GaoSalesOrder.Status.CANCELLED, "已取消订单不能准备库存");
salesOrderService.checkFulfillmentPermission(order);
List<GaoOrderFulfillmentItem> fulfillmentItemList = itemMapper.selectByFulfillmentIdForUpdate(fulfillment.getId());
TimiException.requiredTrue(!fulfillmentItemList.isEmpty(), "履约单没有明细");
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);
@@ -216,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) {
@@ -228,12 +232,11 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
GaoOrderFulfillment snapshot = mapper.selectById(request.getFulfillmentId());
TimiException.required(snapshot, "not found fulfillment");
GaoSalesOrder order = salesOrderService.getForUpdate(snapshot.getSalesOrderId());
List<SourceEntry> sourceList = loadSources(order, itemRequests(snapshot.getId()), false);
List<SourceEntry> sourceList = loadPersistedSources(order, itemMapper.selectByFulfillmentId(snapshot.getId()));
GaoOrderFulfillment fulfillment = lockFulfillment(snapshot.getId(), order);
storeScopeService.checkStoreId(fulfillment.getStoreId());
TimiException.requiredTrue(fulfillment.getStatus() == GaoOrderFulfillment.Status.PENDING, "履约单不是待核销状态");
TimiException.requiredTrue(order.getStatus() != GaoSalesOrder.Status.CANCELLED, "已取消订单不能核销履约");
salesOrderService.checkFulfillmentPermission(order);
List<GaoOrderFulfillmentItem> fulfillmentItemList = itemMapper.selectByFulfillmentIdForUpdate(fulfillment.getId());
TimiException.requiredTrue(!fulfillmentItemList.isEmpty(), "履约单没有明细");
assertReserved(sourceList);
@@ -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) {
@@ -283,7 +288,7 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
GaoOrderFulfillment snapshot = mapper.selectById(request.getFulfillmentId());
TimiException.required(snapshot, "not found fulfillment");
GaoSalesOrder order = salesOrderService.getForUpdate(snapshot.getSalesOrderId());
List<SourceEntry> sourceList = loadSources(order, itemRequests(snapshot.getId()), false);
List<SourceEntry> sourceList = loadPersistedSources(order, itemMapper.selectByFulfillmentId(snapshot.getId()));
GaoOrderFulfillment fulfillment = lockFulfillment(snapshot.getId(), order);
storeScopeService.checkStoreId(fulfillment.getStoreId());
TimiException.requiredTrue(fulfillment.getStatus() == GaoOrderFulfillment.Status.WAITING_STOCK
@@ -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) {
@@ -326,7 +332,7 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
}
GaoSalesOrder order = salesOrderService.getForUpdate(sourceSnapshot.getSalesOrderId());
List<GaoOrderFulfillmentItem> sourceItemList = itemMapper.selectByFulfillmentId(sourceSnapshot.getId());
List<SourceEntry> sourceList = loadSources(order, copyRequestItems(sourceItemList), false);
List<SourceEntry> sourceList = loadPersistedSources(order, sourceItemList);
GaoOrderFulfillment source = lockFulfillment(sourceSnapshot.getId(), order);
storeScopeService.checkStoreId(source.getStoreId());
TimiException.requiredTrue(source.getStatus() == GaoOrderFulfillment.Status.VERIFIED && source.getSourceFulfillmentId() == null, "只有原始已核销履约可以冲正");
@@ -356,10 +362,10 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
TimiException.required(sourceItem, "履约明细来源订单项不存在");
GaoOrderFulfillmentItem reverseItem = copyFulfillmentItem(sourceItem);
reverseItem.setFulfillmentId(reverse.getId());
reverseItem.setReservedBefore(sourceEntry.reservedBefore());
reverseItem.setReservedAfter(sourceEntry.reservedBefore());
reverseItem.setFulfilledBefore(sourceEntry.fulfilledBefore());
reverseItem.setFulfilledAfter(sourceEntry.fulfilledBefore().subtract(sourceEntry.quantity()));
reverseItem.setReservedBaseBefore(sourceEntry.reservedBaseBefore());
reverseItem.setReservedBaseAfter(sourceEntry.reservedBaseBefore());
reverseItem.setFulfilledBaseBefore(sourceEntry.fulfilledBaseBefore());
reverseItem.setFulfilledBaseAfter(sourceEntry.fulfilledBaseBefore().subtract(sourceEntry.baseQuantity()));
itemMapper.insert(reverseItem);
}
if (!stockItems.isEmpty()) {
@@ -397,28 +403,28 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
private void insertSourceReservations(GaoOrderFulfillment fulfillment, List<SourceEntry> sourceList) {
for (SourceEntry source : sourceList) {
TimiException.requiredTrue(1 == salesOrderService.updateItemReservedQuantity(source.sourceId(), source.quantity()), "订单明细占用失败");
TimiException.requiredTrue(1 == salesOrderService.updateItemReservedBaseQuantity(source.sourceId(), source.baseQuantity()), "订单明细占用失败");
GaoOrderFulfillmentItem item = source.toFulfillmentItem(fulfillment.getId());
itemMapper.insert(item);
}
}
private void decreaseReserved(SourceEntry source) {
TimiException.requiredTrue(1 == salesOrderService.updateItemReservedQuantity(source.sourceId(), source.quantity().negate()), "订单明细占用释放失败");
TimiException.requiredTrue(1 == salesOrderService.updateItemReservedBaseQuantity(source.sourceId(), source.baseQuantity().negate()), "订单明细占用释放失败");
}
private void increaseFulfilled(SourceEntry source) {
TimiException.requiredTrue(1 == salesOrderService.updateItemFulfilledQuantity(source.sourceId(), source.quantity()), "订单明细履约失败");
TimiException.requiredTrue(1 == salesOrderService.updateItemFulfilledBaseQuantity(source.sourceId(), source.baseQuantity()), "订单明细履约失败");
}
private void decreaseFulfilled(SourceEntry source) {
TimiException.requiredTrue(0 <= source.fulfilledBefore().compareTo(source.quantity()), "已履约数量不足以冲正");
TimiException.requiredTrue(1 == salesOrderService.updateItemFulfilledQuantity(source.sourceId(), source.quantity().negate()), "订单明细冲正失败");
TimiException.requiredTrue(0 <= source.fulfilledBaseBefore().compareTo(source.baseQuantity()), "已履约数量不足以冲正");
TimiException.requiredTrue(1 == salesOrderService.updateItemFulfilledBaseQuantity(source.sourceId(), source.baseQuantity().negate()), "订单明细冲正失败");
}
private void assertReserved(List<SourceEntry> sourceList) {
for (SourceEntry source : sourceList) {
TimiException.requiredTrue(0 <= source.reservedBefore().compareTo(source.quantity()), "履约待占用数量不足");
TimiException.requiredTrue(0 <= source.reservedBaseBefore().compareTo(source.baseQuantity()), "履约待占用数量不足");
}
}
@@ -430,6 +436,7 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
TimiException.required(requestItem, "履约明细不能为空");
String sourceId = requiredText(requestItem.getSourceOrderItemId(), "履约明细来源订单项不能为空");
requestItem.setSourceOrderItemId(sourceId);
requestItem.setSkuUnitId(requiredText(requestItem.getSkuUnitId(), "履约 SKU 单位不能为空"));
TimiException.requiredTrue(sourceIdSet.add(sourceId), "同一履约单不能重复来源明细");
TimiException.required(requestItem.getQuantity(), "履约数量不能为空");
TimiException.requiredTrue(0 < requestItem.getQuantity().signum(), "履约数量必须大于 0");
@@ -444,40 +451,93 @@ 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, "订单项类型与订单类型不匹配");
result.add(toSource(order, source, requestItem.getQuantity(), checkAvailable));
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 toSource(GaoSalesOrder order, GaoSalesOrderItem source, BigDecimal quantity, boolean checkAvailable) {
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()), "履约来源产品不属于当前门店");
BigDecimal baseQuantityPerUnit = requiredBaseQuantityPerUnit(source.getBaseQuantityPerUnitSnapshot());
GaoProductSkuUnit unit = snapshotUnit(source.getSkuUnitId(), source.getUnitSnapshot(), baseQuantityPerUnit);
JsonNode skuSpecSnapshot = source.getSkuSpecSnapshot();
return buildSource(order, source.getId(), source.getSkuId(), source.getSkuUnitId(), source.getProductIdSnapshot(), source.getProductNameSnapshot(), source.getSkuCodeSnapshot(), source.getSkuNameSnapshot(), source.getSpecSummarySnapshot(), skuSpecSnapshot,
source.getUnitSnapshot(), baseQuantityPerUnit, quantity, source.getQuantity(), source.getReservedQuantity(), source.getFulfilledQuantity(),
product, sku, unit, checkAvailable);
}
private SourceEntry buildSource(GaoSalesOrder order, String orderItemId, String skuId, String unitId, String productId,
String productName, String skuCode, String skuName, String specSummary, JsonNode skuSpecSnapshot, String unitName, BigDecimal baseQuantityPerUnit, BigDecimal quantity, BigDecimal totalQuantity,
BigDecimal reservedQuantity, BigDecimal fulfilledQuantity, GaoProduct product, GaoProductSku sku, GaoProductSkuUnit unit,
boolean checkAvailable) {
quantity = requiredPositiveQuantity(quantity, "履约数量");
if (!Boolean.TRUE.equals(sku.getAllowDecimalQuantity())) {
TimiException.requiredTrue(quantity.stripTrailingZeros().scale() <= 0, "该产品不允许小数数量");
}
BigDecimal reservedBefore = decimal(reservedQuantity);
BigDecimal fulfilledBefore = decimal(fulfilledQuantity);
if (checkAvailable) {
BigDecimal available = decimal(totalQuantity).subtract(reservedBefore).subtract(fulfilledBefore);
TimiException.requiredTrue(0 <= available.compareTo(quantity), "来源产品可履约数量不足");
BigDecimal baseQuantityPerUnit = requiredBaseQuantityPerUnit(source.getBaseQuantityPerUnitSnapshot());
BigDecimal baseQuantity = toBaseQuantity(quantity, baseQuantityPerUnit, "履约基础数量最多保留三位小数");
return buildSource(order, source, source.getSkuUnitId(), source.getUnitSnapshot(), baseQuantityPerUnit, quantity,
baseQuantity, product, sku, checkAvailable);
}
return new SourceEntry(orderItemId, skuId, unitId, productId, productName, skuCode, skuName, specSummary, skuSpecSnapshot, unitName, baseQuantityPerUnit,
quantity, decimal(totalQuantity), reservedBefore, fulfilledBefore, product, sku, unit);
private List<SourceEntry> loadPersistedSources(GaoSalesOrder order, List<GaoOrderFulfillmentItem> itemList) {
TimiException.requiredTrue(itemList != null && !itemList.isEmpty(), "履约单没有明细");
List<GaoOrderFulfillmentItem> sortedItemList = new ArrayList<>(itemList);
sortedItemList.sort(Comparator.comparing(GaoOrderFulfillmentItem::getSourceOrderItemId));
Set<String> sourceIdSet = new HashSet<>();
List<SourceEntry> result = new ArrayList<>();
for (GaoOrderFulfillmentItem item : sortedItemList) {
TimiException.required(item, "履约明细不能为空");
String sourceId = requiredText(item.getSourceOrderItemId(), "履约明细来源订单项不能为空");
TimiException.requiredTrue(sourceIdSet.add(sourceId), "同一履约单不能重复来源明细");
GaoSalesOrderItem source = salesOrderService.getItemForUpdate(sourceId);
validateSourceOrderItem(order, source);
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()), "履约来源产品不属于当前门店");
BigDecimal baseQuantityPerUnit = requiredBaseQuantityPerUnit(item.getBaseQuantityPerUnitSnapshot());
BigDecimal quantity = requiredPositiveQuantity(item.getQuantity(), "履约数量");
BigDecimal baseQuantity = requiredBaseQuantity(item.getBaseQuantity());
TimiException.requiredTrue(baseQuantity.compareTo(toBaseQuantity(quantity, baseQuantityPerUnit, "履约基础数量最多保留三位小数")) == 0,
"履约基础数量与单位换算快照不一致");
result.add(buildSource(order, source, item.getSkuUnitId(), item.getUnitSnapshot(), baseQuantityPerUnit, quantity, baseQuantity, product, sku, false));
}
return result;
}
private SourceEntry toSource(GaoSalesOrder order, GaoSalesOrderItem source, GaoProduct product, GaoProductSku sku,
GaoProductSkuUnit unit, BigDecimal quantity, boolean checkAvailable) {
TimiException.requiredTrue(source.getSkuId().equals(sku.getId()), "履约单位不属于来源 SKU");
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(unit.getBaseQuantityPerUnit());
BigDecimal baseQuantity = toBaseQuantity(quantity, baseQuantityPerUnit, "履约基础数量最多保留三位小数");
return buildSource(order, source, unit.getId(), unit.getName(), baseQuantityPerUnit, quantity, baseQuantity, product, sku, checkAvailable);
}
private SourceEntry buildSource(GaoSalesOrder order, GaoSalesOrderItem source, String unitId, String unitName,
BigDecimal baseQuantityPerUnit, BigDecimal quantity, BigDecimal baseQuantity, GaoProduct product, GaoProductSku sku,
boolean checkAvailable) {
BigDecimal reservedBaseBefore = decimal(source.getReservedBaseQuantity());
BigDecimal fulfilledBaseBefore = decimal(source.getFulfilledBaseQuantity());
BigDecimal totalBaseQuantity = requiredBaseQuantity(source.getBaseQuantity());
if (checkAvailable) {
BigDecimal availableBaseQuantity = totalBaseQuantity.subtract(reservedBaseBefore).subtract(fulfilledBaseBefore);
TimiException.requiredTrue(0 <= availableBaseQuantity.compareTo(baseQuantity), "来源产品可履约数量不足");
}
return new SourceEntry(source.getId(), source.getSkuId(), unitId, source.getProductIdSnapshot(), source.getProductNameSnapshot(),
source.getSkuCodeSnapshot(), source.getSkuNameSnapshot(), source.getSpecSummarySnapshot(), source.getSkuSpecSnapshot(), unitName,
baseQuantityPerUnit, quantity, baseQuantity, totalBaseQuantity, reservedBaseBefore, fulfilledBaseBefore, product, sku);
}
private void validateSourceOrderItem(GaoSalesOrder order, GaoSalesOrderItem source) {
TimiException.required(source, "not found sales order item");
TimiException.requiredTrue(order.getId().equals(source.getSalesOrderId()), "订单明细不属于当前订单");
GaoSalesOrderItem.ItemType expectedItemType = order.getOrderType() == GaoSalesOrder.OrderType.PACKAGE
? GaoSalesOrderItem.ItemType.PACKAGE_ENTITLEMENT : GaoSalesOrderItem.ItemType.DIRECT_PRODUCT;
TimiException.requiredTrue(source.getItemType() == expectedItemType, "订单项类型与订单类型不匹配");
}
private List<GaoStockChangeItem> buildStockItems(String storeId, List<SourceEntry> sourceList) {
@@ -489,7 +549,7 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
GaoProductSkuUnit baseUnit = skuService.listUnitBySkuId(source.sku().getId()).stream()
.filter(unit -> Boolean.TRUE.equals(unit.getIsBase())).findFirst().orElse(null);
TimiException.required(baseUnit, "实物产品缺少基础库存单位");
BigDecimal baseQuantity = source.quantity().multiply(source.unit().getBaseQuantityPerUnit());
BigDecimal baseQuantity = source.baseQuantity();
GaoStockChangeItem item = itemMap.get(source.sku().getId());
if (item == null) {
item = GaoStockChangeItem.of(source.product(), source.sku(), baseUnit, baseQuantity, baseQuantity);
@@ -528,21 +588,6 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
}
}
private List<GaoOrderFulfillmentItemRequest> itemRequests(String fulfillmentId) {
return copyRequestItems(itemMapper.selectByFulfillmentId(fulfillmentId));
}
private List<GaoOrderFulfillmentItemRequest> copyRequestItems(List<GaoOrderFulfillmentItem> itemList) {
List<GaoOrderFulfillmentItemRequest> result = new ArrayList<>();
for (GaoOrderFulfillmentItem item : TimiJava.safeIterable(itemList)) {
GaoOrderFulfillmentItemRequest request = new GaoOrderFulfillmentItemRequest();
request.setSourceOrderItemId(item.getSourceOrderItemId());
request.setQuantity(item.getQuantity());
result.add(request);
}
return result;
}
private GaoOrderFulfillmentItem copyFulfillmentItem(GaoOrderFulfillmentItem source) {
GaoOrderFulfillmentItem result = new GaoOrderFulfillmentItem();
result.setSkuId(source.getSkuId());
@@ -561,20 +606,36 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
return result;
}
private GaoProductSkuUnit snapshotUnit(String id, String name, BigDecimal baseQuantityPerUnit) {
GaoProductSkuUnit unit = new GaoProductSkuUnit();
unit.setId(id);
unit.setName(name);
unit.setBaseQuantityPerUnit(baseQuantityPerUnit);
return unit;
}
private BigDecimal requiredBaseQuantityPerUnit(BigDecimal value) {
TimiException.required(value, "履约来源缺少基础单位数量系数快照");
TimiException.requiredTrue(0 < value.signum(), "履约来源基础单位数量系数无效");
return value;
}
private BigDecimal requiredPositiveQuantity(BigDecimal value, String fieldName) {
TimiException.required(value, fieldName + "不能为空");
TimiException.requiredTrue(0 < value.signum(), fieldName + "必须大于 0");
TimiException.requiredTrue(3 >= value.stripTrailingZeros().scale(), fieldName + "最多保留三位小数");
return value;
}
private BigDecimal requiredBaseQuantity(BigDecimal value) {
TimiException.required(value, "基础单位数量不能为空");
TimiException.requiredTrue(0 < value.signum(), "基础单位数量必须大于 0");
TimiException.requiredTrue(3 >= value.stripTrailingZeros().scale(), "基础单位数量最多保留三位小数");
return value;
}
private BigDecimal toBaseQuantity(BigDecimal quantity, BigDecimal baseQuantityPerUnit, String message) {
BigDecimal result = quantity.multiply(baseQuantityPerUnit);
TimiException.requiredTrue(3 >= result.stripTrailingZeros().scale(), message);
try {
return result.setScale(3, RoundingMode.UNNECESSARY);
} catch (ArithmeticException e) {
throw new TimiException(TimiCode.ERROR, message, e);
}
}
private void verifyIdempotency(GaoOrderFulfillment existing, GaoOrderFulfillmentCreateRequest request) {
TimiException.requiredTrue(existing.getSalesOrderId().equals(request.getSalesOrderId())
&& (request.getDeliveredAt() == null || request.getDeliveredAt().equals(existing.getDeliveredAt()))
@@ -582,12 +643,14 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
List<GaoOrderFulfillmentItem> existingItems = itemMapper.selectByFulfillmentId(existing.getId());
List<GaoOrderFulfillmentItemRequest> requestItems = request.getItemList();
TimiException.requiredTrue(existingItems.size() == (requestItems == null ? 0 : requestItems.size()), "requestNo 对应履约请求内容不一致");
Map<String, BigDecimal> requestMap = new HashMap<>();
Map<String, GaoOrderFulfillmentItemRequest> requestMap = new HashMap<>();
for (GaoOrderFulfillmentItemRequest item : TimiJava.safeIterable(requestItems)) {
requestMap.put(item.getSourceOrderItemId(), item.getQuantity());
requestMap.put(item.getSourceOrderItemId(), item);
}
for (GaoOrderFulfillmentItem item : existingItems) {
TimiException.requiredTrue(item.getQuantity().compareTo(requestMap.getOrDefault(item.getSourceOrderItemId(), ZERO)) == 0, "requestNo 对应履约请求内容不一致");
GaoOrderFulfillmentItemRequest requestItem = requestMap.get(item.getSourceOrderItemId());
TimiException.requiredTrue(requestItem != null && item.getSkuUnitId().equals(requestItem.getSkuUnitId())
&& item.getQuantity().compareTo(requestItem.getQuantity()) == 0, "requestNo 对应履约请求内容不一致");
}
}
@@ -671,8 +734,8 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
private record SourceEntry(String sourceOrderItemId, String skuId, String skuUnitId, String productIdSnapshot,
String productNameSnapshot, String skuCodeSnapshot, String skuNameSnapshot, String specSummarySnapshot, JsonNode skuSpecSnapshot,
String unitSnapshot, BigDecimal baseQuantityPerUnitSnapshot, BigDecimal quantity,
BigDecimal totalQuantity, BigDecimal reservedBefore, BigDecimal fulfilledBefore, GaoProduct product, GaoProductSku sku,
GaoProductSkuUnit unit) {
BigDecimal baseQuantity, BigDecimal totalBaseQuantity, BigDecimal reservedBaseBefore, BigDecimal fulfilledBaseBefore,
GaoProduct product, GaoProductSku sku) {
private String sourceId() {
return sourceOrderItemId;
@@ -686,11 +749,11 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
result.setProductIdSnapshot(productIdSnapshot);
result.setSourceOrderItemId(sourceOrderItemId);
result.setQuantity(quantity);
result.setBaseQuantity(quantity.multiply(baseQuantityPerUnitSnapshot).setScale(3));
result.setReservedBefore(reservedBefore);
result.setReservedAfter(reservedBefore.add(quantity));
result.setFulfilledBefore(fulfilledBefore);
result.setFulfilledAfter(fulfilledBefore);
result.setBaseQuantity(baseQuantity);
result.setReservedBaseBefore(reservedBaseBefore);
result.setReservedBaseAfter(reservedBaseBefore.add(baseQuantity));
result.setFulfilledBaseBefore(fulfilledBaseBefore);
result.setFulfilledBaseAfter(fulfilledBaseBefore);
result.setProductNameSnapshot(productNameSnapshot);
result.setSkuCodeSnapshot(skuCodeSnapshot);
result.setSkuNameSnapshot(skuNameSnapshot);
@@ -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,19 +78,37 @@ 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);
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);
TimiException.requiredTrue(record.getOperationType() == GaoFundRecord.OperationType.RECEIVE && record.getStatus() == GaoFundRecord.Status.PENDING, "收款记录不是待核销状态");
GaoSalesOrder order = orderService.getForUpdate(record.getBizId());
@@ -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,10 +202,10 @@ 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");
GaoSalesOrder order = orderService.getForUpdate(request.getSalesOrderId());
String requestNo = requiredText(request.getRequestNo(), "not found requestNo");
@@ -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;
});
}
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;
});
}
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;
});
}
/// 套餐配置只允许逻辑删除
@@ -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) {
}
}
@@ -68,7 +68,7 @@ public class GaoPointTriggerServiceImplement implements GaoPointTriggerService {
GaoPointScriptContext context = eventContext(rule, record);
GroovyScriptExecutionResult<GaoPointDecision> execution = evaluate(rule, record.getStoreId(), record.getCustomerId(), record.getRegisteredAt(),
GaoPointRule.TriggerRefType.EVENT.name(), record.getEventId());
if (execution == null) {
if (execution.result() == null) {
continue;
}
pointDeltaTotal += applyDecision(rule, execution, GaoPointLedger.TriggerSourceType.EVENT_RECORD, record.getId(), null,
@@ -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,7 +66,6 @@ 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(), "产品分类列表包含不存在的分类");
@@ -74,23 +73,20 @@ public class GaoProductCategoryServiceImplement extends AbstractEntityService<Ga
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;
});
}
@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());
@@ -101,14 +97,12 @@ public class GaoProductCategoryServiceImplement extends AbstractEntityService<Ga
category.setStatus(request.getStatus());
prepare(category, category.getId());
super.update(category);
return 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());
@@ -116,8 +110,6 @@ public class GaoProductCategoryServiceImplement extends AbstractEntityService<Ga
category.setUpdatedAt(Time.now());
category.setSort(TimiJava.defaultIfNull(category.getSort(), 0));
super.update(category);
return 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,24 +100,22 @@ 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;
});
}
@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());
@@ -139,14 +137,12 @@ public class GaoProductServiceImplement extends AbstractEntityService<GaoProduct
if (request.getAttachmentList() != null) {
attachmentService.updateByBizId(Attachment.BizType.GAO_PRODUCT, product.getId(), request.getAttachmentList());
}
return product;
});
}
@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");
@@ -154,8 +150,6 @@ public class GaoProductServiceImplement extends AbstractEntityService<GaoProduct
product.setUpdatedBy(userLoginService.getRequireLoginUserId());
product.setUpdatedAt(Time.now());
super.update(product);
return 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;
@@ -73,10 +74,14 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
private static final BigDecimal ZERO = BigDecimal.ZERO;
// 过渡兼容:旧事件登记系统已收款,但新订单可能仍为零有效收款,暂时允许店长发起履约;过渡结束改为 false 并删除此分支
private static final boolean ALLOW_STORE_MANAGER_ZERO_PAYMENT_FULFILLMENT = true;
private final GaoSalesOrderMapper mapper;
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;
@@ -88,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() {
@@ -113,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);
}
@@ -136,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());
});
}
private GaoSalesOrder createDirectInternal(GaoDirectProductOrderCreateRequest request) {
@@ -219,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) {
@@ -280,18 +288,21 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
if (safe(order.getSaleAmount()) == 0 || safe(order.getPaidAmount()) == safe(order.getSaleAmount())) {
return;
}
TimiException.requiredTrue(0 < safe(order.getPaidAmount()), "订单未有已核销收款,不能履约");
if (ALLOW_STORE_MANAGER_ZERO_PAYMENT_FULFILLMENT) {
roleChecker.checkAny(ModuleCode.GAO, GaoRoleCode.STORE_MANAGER.name());
return;
}
TimiException.requiredTrue(false, "订单未全额收款,不能发起履约");
}
@Override
public int updateItemReservedQuantity(String itemId, BigDecimal delta) {
return itemMapper.updateReservedQuantity(itemId, delta);
public int updateItemReservedBaseQuantity(String itemId, BigDecimal delta) {
return itemMapper.updateReservedBaseQuantity(itemId, delta);
}
@Override
public int updateItemFulfilledQuantity(String itemId, BigDecimal delta) {
return itemMapper.updateFulfilledQuantity(itemId, delta);
public int updateItemFulfilledBaseQuantity(String itemId, BigDecimal delta) {
return itemMapper.updateFulfilledBaseQuantity(itemId, delta);
}
@Override
@@ -309,7 +320,7 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
List<GaoSalesOrderItem> itemList = itemMapper.selectBySalesOrderId(order.getId());
boolean fulfilled = !itemList.isEmpty();
for (GaoSalesOrderItem item : itemList) {
if (decimal(item.getFulfilledQuantity()).compareTo(decimal(item.getQuantity())) < 0) {
if (decimal(item.getFulfilledBaseQuantity()).compareTo(decimal(item.getBaseQuantity())) < 0) {
fulfilled = false;
break;
}
@@ -383,8 +394,9 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
item.setUnitSnapshot(unit.getName());
item.setBaseQuantityPerUnitSnapshot(unit.getBaseQuantityPerUnit());
item.setQuantity(quantity);
item.setReservedQuantity(ZERO);
item.setFulfilledQuantity(ZERO);
item.setBaseQuantity(toBaseQuantity(quantity, unit.getBaseQuantityPerUnit(), "订单项基础单位数量最多保留三位小数"));
item.setReservedBaseQuantity(ZERO);
item.setFulfilledBaseQuantity(ZERO);
item.setUnitPrice(unitPrice);
item.setLineAmount(lineAmount);
item.setSort(TimiJava.defaultIfNull(source.getSort(), i));
@@ -445,20 +457,25 @@ 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");
TimiException.requiredTrue(3 >= item.getQuantity().stripTrailingZeros().scale(), "订单项数量最多保留三位小数");
TimiException.required(item.getBaseQuantityPerUnitSnapshot(), "订单项单位换算快照不能为空");
TimiException.requiredTrue(0 < item.getBaseQuantityPerUnitSnapshot().signum(), "订单项单位换算快照必须大于 0");
TimiException.requiredTrue(item.getReservedQuantity() != null && item.getFulfilledQuantity() != null,
TimiException.required(item.getBaseQuantity(), "订单项基础单位数量不能为空");
TimiException.requiredTrue(item.getBaseQuantity().compareTo(toBaseQuantity(item.getQuantity(), item.getBaseQuantityPerUnitSnapshot(), "订单项基础单位数量最多保留三位小数")) == 0,
"订单项基础单位数量与单位换算快照不一致");
TimiException.requiredTrue(item.getReservedBaseQuantity() != null && item.getFulfilledBaseQuantity() != null,
"订单项履约数量不能为空");
TimiException.requiredTrue(0 <= item.getReservedQuantity().signum() && 0 <= item.getFulfilledQuantity().signum(),
TimiException.requiredTrue(0 <= item.getReservedBaseQuantity().signum() && 0 <= item.getFulfilledBaseQuantity().signum(),
"订单项履约数量不能为负数");
TimiException.requiredTrue(item.getReservedQuantity().add(item.getFulfilledQuantity()).compareTo(item.getQuantity()) <= 0,
TimiException.requiredTrue(item.getReservedBaseQuantity().add(item.getFulfilledBaseQuantity()).compareTo(item.getBaseQuantity()) <= 0,
"订单项履约数量不能超过购买数量");
if (expectedItemType == GaoSalesOrderItem.ItemType.PACKAGE_ENTITLEMENT) {
TimiException.requiredNull(item.getUnitPrice(), "套餐订单项不能填写单价");
@@ -500,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 对应订单内容不一致");
@@ -544,12 +564,43 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
///
/// @param item 订单项
private void fillItemBalance(GaoSalesOrderItem item) {
BigDecimal quantity = decimal(item.getQuantity());
BigDecimal reserved = decimal(item.getReservedQuantity());
BigDecimal fulfilled = decimal(item.getFulfilledQuantity());
item.setPendingQuantity(reserved);
item.setAvailableQuantity(quantity.subtract(reserved).subtract(fulfilled));
item.setRemainingQuantity(quantity.subtract(fulfilled));
BigDecimal baseQuantity = decimal(item.getBaseQuantity());
BigDecimal reservedBaseQuantity = decimal(item.getReservedBaseQuantity());
BigDecimal fulfilledBaseQuantity = decimal(item.getFulfilledBaseQuantity());
item.setPendingBaseQuantity(reservedBaseQuantity);
item.setAvailableBaseQuantity(baseQuantity.subtract(reservedBaseQuantity).subtract(fulfilledBaseQuantity));
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) {
@@ -606,6 +657,24 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
return quantity;
}
/// 转换基础单位数量
///
/// @param quantity 当前单位数量
/// @param baseQuantityPerUnit 当前单位折算基础单位系数
/// @param message 精度错误信息
/// @return 三位小数的基础单位数量
private BigDecimal toBaseQuantity(BigDecimal quantity, BigDecimal baseQuantityPerUnit, String message) {
TimiException.required(quantity, "数量不能为空");
TimiException.required(baseQuantityPerUnit, "基础单位数量系数不能为空");
BigDecimal result = quantity.multiply(baseQuantityPerUnit);
TimiException.requiredTrue(3 >= result.stripTrailingZeros().scale(), message);
try {
return result.setScale(3, RoundingMode.UNNECESSARY);
} catch (ArithmeticException e) {
throw new TimiException(TimiCode.ERROR, message, e);
}
}
private long toCentAmount(BigDecimal quantity, long unitPrice) {
try {
return BigDecimal.valueOf(unitPrice).multiply(quantity).setScale(0, RoundingMode.UNNECESSARY).longValueExact();
@@ -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;
}
@@ -14,6 +14,9 @@ public class GaoOrderFulfillmentItemRequest {
/// 来源销售订单明细 ID
private String sourceOrderItemId;
/// 本次实际履约 SKU 单位 ID
private String skuUnitId;
/// 本次履约数量
private BigDecimal quantity;
}
@@ -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;
}

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