diff --git a/play-admin/pom.xml b/play-admin/pom.xml
index f538f2e..8938048 100644
--- a/play-admin/pom.xml
+++ b/play-admin/pom.xml
@@ -158,6 +158,21 @@
mockito-junit-jupiter
test
+
+ org.springframework
+ spring-test
+ test
+
+
+ org.hamcrest
+ hamcrest
+ test
+
+
+ com.jayway.jsonpath
+ json-path
+ test
+
diff --git a/play-admin/src/main/java/com/starry/admin/modules/statistics/controller/PlayClerkPerformanceController.java b/play-admin/src/main/java/com/starry/admin/modules/statistics/controller/PlayClerkPerformanceController.java
index d84c825..0449cc5 100644
--- a/play-admin/src/main/java/com/starry/admin/modules/statistics/controller/PlayClerkPerformanceController.java
+++ b/play-admin/src/main/java/com/starry/admin/modules/statistics/controller/PlayClerkPerformanceController.java
@@ -9,6 +9,10 @@ import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceDetailQueryVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceDetailResponseVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceOverviewQueryVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceOverviewResponseVo;
import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoQueryVo;
import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoReturnVo;
import com.starry.admin.modules.statistics.service.IPlayClerkPerformanceService;
@@ -81,6 +85,20 @@ public class PlayClerkPerformanceController {
return R.ok(voPage);
}
+ @ApiOperation(value = "店员业绩概览", notes = "汇总店员业绩榜单和汇总数据")
+ @PostMapping("/overview")
+ public R overview(
+ @ApiParam(value = "查询条件", required = true) @Validated @RequestBody ClerkPerformanceOverviewQueryVo vo) {
+ return R.ok(playClerkPerformanceService.queryOverview(vo));
+ }
+
+ @ApiOperation(value = "店员业绩详情", notes = "查看单个店员的业绩构成和趋势")
+ @PostMapping("/detail")
+ public R detail(
+ @ApiParam(value = "查询条件", required = true) @Validated @RequestBody ClerkPerformanceDetailQueryVo vo) {
+ return R.ok(playClerkPerformanceService.queryDetail(vo));
+ }
+
@ApiOperation(value = "按日查询业绩", notes = "按日期查询店员业绩统计信息")
@ApiResponses({@ApiResponse(code = 200, message = "操作成功")})
@PostMapping("/listByTime")
diff --git a/play-admin/src/main/java/com/starry/admin/modules/statistics/module/enums/ClerkPerformanceSortField.java b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/enums/ClerkPerformanceSortField.java
new file mode 100644
index 0000000..7f9e53c
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/enums/ClerkPerformanceSortField.java
@@ -0,0 +1,26 @@
+package com.starry.admin.modules.statistics.module.enums;
+
+/**
+ * 排序指标字段。
+ *
+ * @author admin
+ * @since 2024/08/30
+ */
+public enum ClerkPerformanceSortField {
+ /**
+ * 根据成交金额(GMV)排序。
+ */
+ GMV,
+ /**
+ * 根据订单数量排序。
+ */
+ ORDER_COUNT,
+ /**
+ * 根据续单率排序。
+ */
+ CONTINUE_RATE,
+ /**
+ * 根据预估收入排序。
+ */
+ ESTIMATED_REVENUE
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/statistics/module/enums/SortDirection.java b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/enums/SortDirection.java
new file mode 100644
index 0000000..aef0a31
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/enums/SortDirection.java
@@ -0,0 +1,20 @@
+package com.starry.admin.modules.statistics.module.enums;
+
+/**
+ * 排序方向。
+ */
+public enum SortDirection {
+ /**
+ * 升序。
+ */
+ ASC,
+ /**
+ * 降序。
+ */
+ DESC;
+
+ /**
+ * 默认排序方向。
+ */
+ public static final SortDirection DEFAULT = DESC;
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceDetailCompositionVo.java b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceDetailCompositionVo.java
new file mode 100644
index 0000000..102f71d
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceDetailCompositionVo.java
@@ -0,0 +1,48 @@
+package com.starry.admin.modules.statistics.module.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import java.math.BigDecimal;
+import java.util.Collections;
+import java.util.List;
+import lombok.Data;
+
+/**
+ * 店员业绩详情组成。
+ */
+@Data
+@ApiModel("店员业绩详情组成数据")
+public class ClerkPerformanceDetailCompositionVo {
+
+ @ApiModelProperty("订单构成")
+ private List orderComposition = Collections.emptyList();
+
+ @ApiModelProperty("金额构成")
+ private List amountComposition = Collections.emptyList();
+
+ @ApiModelProperty("顾客构成")
+ private List customerComposition = Collections.emptyList();
+
+ @Data
+ @ApiModel("业绩组成占比")
+ public static class CompositionSlice {
+
+ @ApiModelProperty("标识")
+ private String key;
+
+ @ApiModelProperty("名称")
+ private String label;
+
+ @ApiModelProperty("数量")
+ private Integer count;
+
+ @ApiModelProperty("数量占比")
+ private BigDecimal countRatio = BigDecimal.ZERO;
+
+ @ApiModelProperty("金额")
+ private BigDecimal amount = BigDecimal.ZERO;
+
+ @ApiModelProperty("金额占比")
+ private BigDecimal amountRatio = BigDecimal.ZERO;
+ }
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceDetailQueryVo.java b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceDetailQueryVo.java
new file mode 100644
index 0000000..514da89
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceDetailQueryVo.java
@@ -0,0 +1,29 @@
+package com.starry.admin.modules.statistics.module.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import javax.validation.constraints.NotBlank;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 店员业绩详情查询参数。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ApiModel("店员业绩详情查询参数")
+public class ClerkPerformanceDetailQueryVo extends PlayClerkPerformanceInfoQueryVo {
+
+ @Override
+ @NotBlank(message = "店员ID不能为空")
+ @ApiModelProperty(value = "店员ID", required = true)
+ public String getClerkId() {
+ return super.getClerkId();
+ }
+
+ @ApiModelProperty(value = "是否返回趋势数据")
+ private Boolean includeTrend = Boolean.TRUE;
+
+ @ApiModelProperty(value = "趋势天数,默认7")
+ private Integer trendDays = 7;
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceDetailResponseVo.java b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceDetailResponseVo.java
new file mode 100644
index 0000000..75302a4
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceDetailResponseVo.java
@@ -0,0 +1,30 @@
+package com.starry.admin.modules.statistics.module.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import java.util.Collections;
+import java.util.List;
+import lombok.Data;
+
+/**
+ * 店员业绩详情响应。
+ */
+@Data
+@ApiModel("店员业绩详情响应")
+public class ClerkPerformanceDetailResponseVo {
+
+ @ApiModelProperty("基础信息")
+ private ClerkPerformanceProfileVo profile;
+
+ @ApiModelProperty("业绩快照")
+ private ClerkPerformanceSnapshotVo snapshot;
+
+ @ApiModelProperty("业绩组成")
+ private ClerkPerformanceDetailCompositionVo composition;
+
+ @ApiModelProperty("趋势数据")
+ private List trend = Collections.emptyList();
+
+ @ApiModelProperty("趋势维度")
+ private String trendDimension = "DAY";
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceOverviewQueryVo.java b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceOverviewQueryVo.java
new file mode 100644
index 0000000..65d5260
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceOverviewQueryVo.java
@@ -0,0 +1,38 @@
+package com.starry.admin.modules.statistics.module.vo;
+
+import com.starry.admin.modules.statistics.module.enums.ClerkPerformanceSortField;
+import com.starry.admin.modules.statistics.module.enums.SortDirection;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import java.util.List;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 店员业绩概览查询参数。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ApiModel("店员业绩概览查询参数")
+public class ClerkPerformanceOverviewQueryVo extends PlayClerkPerformanceInfoQueryVo {
+
+ @ApiModelProperty(value = "排序字段", allowableValues = "GMV,ORDER_COUNT,CONTINUE_RATE,ESTIMATED_REVENUE")
+ private ClerkPerformanceSortField sortField = ClerkPerformanceSortField.GMV;
+
+ @ApiModelProperty(value = "排序方向", allowableValues = "ASC,DESC")
+ private SortDirection sortDirection = SortDirection.DEFAULT;
+
+ @ApiModelProperty(value = "返回前N名数据,未设置则返回分页数据")
+ private Integer limit;
+
+ @ApiModelProperty(value = "是否包含汇总数据")
+ private Boolean includeSummary = Boolean.TRUE;
+
+ @ApiModelProperty(value = "是否包含排行列表数据")
+ private Boolean includeRankings = Boolean.TRUE;
+
+ @Override
+ public void setEndOrderTime(List endOrderTime) {
+ super.setEndOrderTime(endOrderTime);
+ }
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceOverviewResponseVo.java b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceOverviewResponseVo.java
new file mode 100644
index 0000000..37214d5
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceOverviewResponseVo.java
@@ -0,0 +1,24 @@
+package com.starry.admin.modules.statistics.module.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import java.util.Collections;
+import java.util.List;
+import lombok.Data;
+
+/**
+ * 店员业绩概览响应。
+ */
+@Data
+@ApiModel("店员业绩概览响应")
+public class ClerkPerformanceOverviewResponseVo {
+
+ @ApiModelProperty("汇总信息")
+ private ClerkPerformanceOverviewSummaryVo summary;
+
+ @ApiModelProperty("业绩排行榜")
+ private List rankings = Collections.emptyList();
+
+ @ApiModelProperty("榜单总人数")
+ private Integer total = 0;
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceOverviewSummaryVo.java b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceOverviewSummaryVo.java
new file mode 100644
index 0000000..7a85564
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceOverviewSummaryVo.java
@@ -0,0 +1,53 @@
+package com.starry.admin.modules.statistics.module.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import java.math.BigDecimal;
+import lombok.Data;
+
+/**
+ * 店员业绩概览汇总。
+ */
+@Data
+@ApiModel("店员业绩概览汇总")
+public class ClerkPerformanceOverviewSummaryVo {
+
+ @ApiModelProperty("GMV汇总")
+ private BigDecimal totalGmv = BigDecimal.ZERO;
+
+ @ApiModelProperty("订单总数")
+ private Integer totalOrderCount = 0;
+
+ @ApiModelProperty("首单总数")
+ private Integer totalFirstOrderCount = 0;
+
+ @ApiModelProperty("续单总数")
+ private Integer totalContinuedOrderCount = 0;
+
+ @ApiModelProperty("退款总数")
+ private Integer totalRefundOrderCount = 0;
+
+ @ApiModelProperty("续单率(加权)")
+ private BigDecimal continuedRate = BigDecimal.ZERO;
+
+ @ApiModelProperty("续费率(续单金额/GMV)")
+ private BigDecimal continuedAmountRate = BigDecimal.ZERO;
+
+ @ApiModelProperty("退单率")
+ private BigDecimal refundRate = BigDecimal.ZERO;
+
+ @ApiModelProperty("客单价")
+ private BigDecimal averageTicketPrice = BigDecimal.ZERO;
+
+ @ApiModelProperty("预估工资汇总")
+ private BigDecimal totalEstimatedRevenue = BigDecimal.ZERO;
+
+ @ApiModelProperty("复购用户数")
+ private Integer totalContinuedUserCount = 0;
+
+ @ApiModelProperty("用户总数")
+ private Integer totalUserCount = 0;
+
+ @ApiModelProperty("续客率")
+ private BigDecimal continuedCustomerRate = BigDecimal.ZERO;
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceProfileVo.java b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceProfileVo.java
new file mode 100644
index 0000000..382e80a
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceProfileVo.java
@@ -0,0 +1,40 @@
+package com.starry.admin.modules.statistics.module.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+/**
+ * 店员基础信息。
+ */
+@Data
+@ApiModel("店员业绩详情-基础信息")
+public class ClerkPerformanceProfileVo {
+
+ @ApiModelProperty("店员ID")
+ private String clerkId;
+
+ @ApiModelProperty("店员昵称")
+ private String nickname;
+
+ @ApiModelProperty("头像地址")
+ private String avatar;
+
+ @ApiModelProperty("性别")
+ private String sex;
+
+ @ApiModelProperty("所属组名称")
+ private String groupName;
+
+ @ApiModelProperty("等级名称")
+ private String levelName;
+
+ @ApiModelProperty("上架状态")
+ private String listingState;
+
+ @ApiModelProperty("在线状态")
+ private String onlineState;
+
+ @ApiModelProperty("角色")
+ private String role;
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceSnapshotVo.java b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceSnapshotVo.java
new file mode 100644
index 0000000..1ed1097
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceSnapshotVo.java
@@ -0,0 +1,92 @@
+package com.starry.admin.modules.statistics.module.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import java.math.BigDecimal;
+import lombok.Data;
+
+/**
+ * 单个店员的业绩快照。
+ */
+@Data
+@ApiModel("店员业绩快照")
+public class ClerkPerformanceSnapshotVo {
+
+ @ApiModelProperty("店员ID")
+ private String clerkId;
+
+ @ApiModelProperty("店员昵称")
+ private String clerkNickname;
+
+ @ApiModelProperty("头像地址")
+ private String avatar;
+
+ @ApiModelProperty("店员性别")
+ private String sex;
+
+ @ApiModelProperty("所属组名称")
+ private String groupName;
+
+ @ApiModelProperty("等级名称")
+ private String levelName;
+
+ @ApiModelProperty("GMV(支付金额)")
+ private BigDecimal gmv = BigDecimal.ZERO;
+
+ @ApiModelProperty("首单金额")
+ private BigDecimal firstOrderAmount = BigDecimal.ZERO;
+
+ @ApiModelProperty("续单金额")
+ private BigDecimal continuedOrderAmount = BigDecimal.ZERO;
+
+ @ApiModelProperty("打赏金额")
+ private BigDecimal rewardAmount = BigDecimal.ZERO;
+
+ @ApiModelProperty("退款金额")
+ private BigDecimal refundAmount = BigDecimal.ZERO;
+
+ @ApiModelProperty("预估工资")
+ private BigDecimal estimatedRevenue = BigDecimal.ZERO;
+
+ @ApiModelProperty("订单数量")
+ private Integer orderCount = 0;
+
+ @ApiModelProperty("首单数量")
+ private Integer firstOrderCount = 0;
+
+ @ApiModelProperty("续单数量")
+ private Integer continuedOrderCount = 0;
+
+ @ApiModelProperty("退款订单数量")
+ private Integer refundOrderCount = 0;
+
+ @ApiModelProperty("超时未接单数量")
+ private Integer expiredOrderCount = 0;
+
+ @ApiModelProperty("用户数量")
+ private Integer userCount = 0;
+
+ @ApiModelProperty("复购用户数量")
+ private Integer continuedUserCount = 0;
+
+ @ApiModelProperty("续单率")
+ private BigDecimal continuedRate = BigDecimal.ZERO;
+
+ @ApiModelProperty("续费率(续单金额/GMV)")
+ private BigDecimal continuedAmountRate = BigDecimal.ZERO;
+
+ @ApiModelProperty("退单率")
+ private BigDecimal refundRate = BigDecimal.ZERO;
+
+ @ApiModelProperty("续客率")
+ private BigDecimal continuedCustomerRate = BigDecimal.ZERO;
+
+ @ApiModelProperty("客单价")
+ private BigDecimal averageTicketPrice = BigDecimal.ZERO;
+
+ @ApiModelProperty("在线时长(单位:秒)")
+ private Long onlineDuration = 0L;
+
+ @ApiModelProperty("排行榜名次")
+ private Integer rank;
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceTrendPointVo.java b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceTrendPointVo.java
new file mode 100644
index 0000000..626ab87
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceTrendPointVo.java
@@ -0,0 +1,33 @@
+package com.starry.admin.modules.statistics.module.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import lombok.Data;
+
+/**
+ * 店员业绩趋势点。
+ */
+@Data
+@ApiModel("店员业绩趋势点")
+public class ClerkPerformanceTrendPointVo {
+
+ @ApiModelProperty("日期")
+ private LocalDate date;
+
+ @ApiModelProperty("GMV")
+ private BigDecimal gmv = BigDecimal.ZERO;
+
+ @ApiModelProperty("订单数量")
+ private Integer orderCount = 0;
+
+ @ApiModelProperty("续单率")
+ private BigDecimal continuedRate = BigDecimal.ZERO;
+
+ @ApiModelProperty("续费金额")
+ private BigDecimal continuedAmount = BigDecimal.ZERO;
+
+ @ApiModelProperty("首单金额")
+ private BigDecimal firstAmount = BigDecimal.ZERO;
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/statistics/service/IPlayClerkPerformanceService.java b/play-admin/src/main/java/com/starry/admin/modules/statistics/service/IPlayClerkPerformanceService.java
index 155904a..43b3854 100644
--- a/play-admin/src/main/java/com/starry/admin/modules/statistics/service/IPlayClerkPerformanceService.java
+++ b/play-admin/src/main/java/com/starry/admin/modules/statistics/service/IPlayClerkPerformanceService.java
@@ -4,6 +4,10 @@ import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceDetailQueryVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceDetailResponseVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceOverviewQueryVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceOverviewResponseVo;
import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoReturnVo;
import java.util.List;
@@ -29,4 +33,8 @@ public interface IPlayClerkPerformanceService {
PlayClerkPerformanceInfoReturnVo getClerkPerformanceInfo(PlayClerkUserInfoEntity userInfo,
List orderInfoEntities, List clerkLevelInfoEntity,
List groupInfoEntities);
+
+ ClerkPerformanceOverviewResponseVo queryOverview(ClerkPerformanceOverviewQueryVo vo);
+
+ ClerkPerformanceDetailResponseVo queryDetail(ClerkPerformanceDetailQueryVo vo);
}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/statistics/service/impl/PlayClerkPerformanceServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/statistics/service/impl/PlayClerkPerformanceServiceImpl.java
index 9808d12..4c89dcf 100644
--- a/play-admin/src/main/java/com/starry/admin/modules/statistics/service/impl/PlayClerkPerformanceServiceImpl.java
+++ b/play-admin/src/main/java/com/starry/admin/modules/statistics/service/impl/PlayClerkPerformanceServiceImpl.java
@@ -1,29 +1,75 @@
package com.starry.admin.modules.statistics.service.impl;
+import cn.hutool.core.collection.CollectionUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.starry.admin.common.exception.ServiceException;
import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
+import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService;
+import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
+import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
+import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
+import com.starry.admin.modules.statistics.module.enums.ClerkPerformanceSortField;
+import com.starry.admin.modules.statistics.module.enums.SortDirection;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceDetailCompositionVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceDetailQueryVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceDetailResponseVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceOverviewQueryVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceOverviewResponseVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceOverviewSummaryVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceProfileVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceSnapshotVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceTrendPointVo;
import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoReturnVo;
import com.starry.admin.modules.statistics.service.IPlayClerkPerformanceService;
+import com.starry.admin.utils.SecurityUtils;
import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
+import java.util.Map;
+import java.util.Objects;
import java.util.Set;
+import java.util.stream.Collectors;
+import javax.annotation.Resource;
import org.springframework.stereotype.Service;
/**
- * @author admin
- * @since 2024/6/15 下午3:29
- **/
+ * 店员业绩数据服务。
+ */
@Service
public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceService {
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+ private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ @Resource
+ private IPlayClerkUserInfoService clerkUserInfoService;
+
+ @Resource
+ private IPlayOrderInfoService playOrderInfoService;
+
+ @Resource
+ private IPlayClerkLevelInfoService playClerkLevelInfoService;
+
+ @Resource
+ private IPlayPersonnelGroupInfoService playPersonnelGroupInfoService;
+
@Override
public PlayClerkPerformanceInfoReturnVo getClerkPerformanceInfo(PlayClerkUserInfoEntity userInfo,
List orderInfoEntities, List clerkLevelInfoEntities,
List groupInfoEntities) {
-
Set customIds = new HashSet<>();
int orderContinueNumber = 0;
int orderRefundNumber = 0;
@@ -36,35 +82,36 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
BigDecimal estimatedRevenue = BigDecimal.ZERO;
for (PlayOrderInfoEntity orderInfoEntity : orderInfoEntities) {
customIds.add(orderInfoEntity.getPurchaserBy());
- finalAmount = finalAmount.add(orderInfoEntity.getFinalAmount());
+ finalAmount = finalAmount.add(defaultZero(orderInfoEntity.getFinalAmount()));
if ("1".equals(orderInfoEntity.getFirstOrder())) {
- orderFirstAmount = orderFirstAmount.add(orderInfoEntity.getFinalAmount());
+ orderFirstAmount = orderFirstAmount.add(defaultZero(orderInfoEntity.getFinalAmount()));
} else {
orderContinueNumber++;
- orderTotalAmount = orderTotalAmount.add(orderInfoEntity.getFinalAmount());
+ orderTotalAmount = orderTotalAmount.add(defaultZero(orderInfoEntity.getFinalAmount()));
}
if ("2".equals(orderInfoEntity.getPlaceType())) {
- orderRewardAmount = orderRewardAmount.add(orderInfoEntity.getFinalAmount());
+ orderRewardAmount = orderRewardAmount.add(defaultZero(orderInfoEntity.getFinalAmount()));
}
if ("1".equals(orderInfoEntity.getRefundType())) {
orderRefundNumber++;
- orderRefundAmount = orderRefundAmount.add(orderInfoEntity.getRefundAmount());
+ orderRefundAmount = orderRefundAmount.add(defaultZero(orderInfoEntity.getRefundAmount()));
}
if ("1".equals(orderInfoEntity.getOrdersExpiredState())) {
ordersExpiredNumber++;
}
+ estimatedRevenue = estimatedRevenue.add(defaultZero(orderInfoEntity.getEstimatedRevenue()));
}
PlayClerkPerformanceInfoReturnVo returnVo = new PlayClerkPerformanceInfoReturnVo();
returnVo.setClerkId(userInfo.getId());
returnVo.setClerkNickname(userInfo.getNickname());
returnVo.setClerkSex(userInfo.getSex());
for (PlayClerkLevelInfoEntity infoEntity : clerkLevelInfoEntities) {
- if (infoEntity.getId().equals(userInfo.getLevelId())) {
+ if (Objects.equals(infoEntity.getId(), userInfo.getLevelId())) {
returnVo.setLevelName(infoEntity.getName());
}
}
for (PlayPersonnelGroupInfoEntity infoEntity : groupInfoEntities) {
- if (infoEntity.getId().equals(userInfo.getGroupId())) {
+ if (Objects.equals(infoEntity.getId(), userInfo.getGroupId())) {
returnVo.setGroupName(infoEntity.getGroupName());
}
}
@@ -79,7 +126,456 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
returnVo.setOrderRefundAmount(orderRefundAmount);
returnVo.setCustomNumber(customIds.size());
returnVo.setEstimatedRevenue(estimatedRevenue);
-
return returnVo;
}
+
+ @Override
+ public ClerkPerformanceOverviewResponseVo queryOverview(ClerkPerformanceOverviewQueryVo vo) {
+ DateRange range = resolveDateRange(vo.getEndOrderTime());
+ List clerks = loadAccessibleClerks(vo);
+ ClerkPerformanceOverviewResponseVo responseVo = new ClerkPerformanceOverviewResponseVo();
+ if (CollectionUtil.isEmpty(clerks)) {
+ responseVo.setSummary(new ClerkPerformanceOverviewSummaryVo());
+ responseVo.setRankings(Collections.emptyList());
+ responseVo.setTotal(0);
+ return responseVo;
+ }
+ Map levelNameMap = playClerkLevelInfoService.selectAll().stream().collect(
+ Collectors.toMap(PlayClerkLevelInfoEntity::getId, PlayClerkLevelInfoEntity::getName, (a, b) -> a));
+ Map groupNameMap = playPersonnelGroupInfoService.selectAll().stream().collect(
+ Collectors.toMap(PlayPersonnelGroupInfoEntity::getId, PlayPersonnelGroupInfoEntity::getGroupName,
+ (a, b) -> a));
+ List snapshots = new ArrayList<>(clerks.size());
+ for (PlayClerkUserInfoEntity clerk : clerks) {
+ List orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(),
+ range.startTime, range.endTime);
+ snapshots.add(buildSnapshot(clerk, orders, levelNameMap, groupNameMap));
+ }
+ int total = snapshots.size();
+ ClerkPerformanceOverviewSummaryVo summary = aggregateSummary(snapshots);
+ List sorted = new ArrayList<>(snapshots);
+ applySortAndRank(sorted, vo.getSortField(), vo.getSortDirection());
+ if (vo.getLimit() != null && vo.getLimit() > 0 && vo.getLimit() < sorted.size()) {
+ sorted = new ArrayList<>(sorted.subList(0, vo.getLimit()));
+ applySequentialRank(sorted);
+ }
+ responseVo.setSummary(Boolean.TRUE.equals(vo.getIncludeSummary()) ? summary : null);
+ responseVo.setRankings(Boolean.TRUE.equals(vo.getIncludeRankings()) ? sorted : Collections.emptyList());
+ responseVo.setTotal(total);
+ return responseVo;
+ }
+
+ @Override
+ public ClerkPerformanceDetailResponseVo queryDetail(ClerkPerformanceDetailQueryVo vo) {
+ DateRange range = resolveDateRange(vo.getEndOrderTime());
+ PlayClerkUserInfoEntity clerk = clerkUserInfoService.getById(vo.getClerkId());
+ if (clerk == null) {
+ throw new ServiceException("店员不存在");
+ }
+ List accessibleIds =
+ playPersonnelGroupInfoService.getValidClerkIdList(SecurityUtils.getLoginUser(), null);
+ if (CollectionUtil.isEmpty(accessibleIds) || !accessibleIds.contains(clerk.getId())) {
+ throw new ServiceException("无权查看该店员业绩");
+ }
+ Map levelNameMap = playClerkLevelInfoService.selectAll().stream().collect(
+ Collectors.toMap(PlayClerkLevelInfoEntity::getId, PlayClerkLevelInfoEntity::getName, (a, b) -> a));
+ Map groupNameMap = playPersonnelGroupInfoService.selectAll().stream().collect(
+ Collectors.toMap(PlayPersonnelGroupInfoEntity::getId, PlayPersonnelGroupInfoEntity::getGroupName,
+ (a, b) -> a));
+ List orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(),
+ range.startTime, range.endTime);
+ ClerkPerformanceSnapshotVo snapshot = buildSnapshot(clerk, orders, levelNameMap, groupNameMap);
+ ClerkPerformanceDetailResponseVo responseVo = new ClerkPerformanceDetailResponseVo();
+ responseVo.setProfile(buildProfile(clerk, levelNameMap, groupNameMap));
+ responseVo.setSnapshot(snapshot);
+ responseVo.setComposition(buildComposition(snapshot));
+ if (Boolean.TRUE.equals(vo.getIncludeTrend())) {
+ responseVo.setTrend(buildTrend(orders, range,
+ vo.getTrendDays() == null || vo.getTrendDays() <= 0 ? 7 : vo.getTrendDays()));
+ responseVo.setTrendDimension("DAY");
+ } else {
+ responseVo.setTrend(Collections.emptyList());
+ }
+ return responseVo;
+ }
+
+ private ClerkPerformanceProfileVo buildProfile(PlayClerkUserInfoEntity clerk, Map levelNameMap,
+ Map groupNameMap) {
+ ClerkPerformanceProfileVo profile = new ClerkPerformanceProfileVo();
+ profile.setClerkId(clerk.getId());
+ profile.setNickname(clerk.getNickname());
+ profile.setAvatar(clerk.getAvatar());
+ profile.setSex(clerk.getSex());
+ profile.setGroupName(groupNameMap.getOrDefault(clerk.getGroupId(), ""));
+ profile.setLevelName(levelNameMap.getOrDefault(clerk.getLevelId(), ""));
+ profile.setListingState(clerk.getListingState());
+ profile.setOnlineState(clerk.getOnlineState());
+ profile.setRole(null);
+ return profile;
+ }
+
+ private List buildTrend(List orders, DateRange range,
+ int trendDays) {
+ if (CollectionUtil.isEmpty(orders)) {
+ return buildEmptyTrend(range, trendDays);
+ }
+ LocalDate end = range.endDate;
+ LocalDate start = end.minusDays(trendDays - 1);
+ if (start.isBefore(range.startDate)) {
+ start = range.startDate;
+ }
+ Map> grouped = orders.stream()
+ .filter(order -> order.getPurchaserTime() != null)
+ .collect(Collectors.groupingBy(order -> order.getPurchaserTime().toLocalDate()));
+ List points = new ArrayList<>();
+ LocalDate cursor = start;
+ while (!cursor.isAfter(end)) {
+ List dayOrders = grouped.getOrDefault(cursor, Collections.emptyList());
+ points.add(buildTrendPoint(cursor, dayOrders));
+ cursor = cursor.plusDays(1);
+ }
+ return points;
+ }
+
+ private List buildEmptyTrend(DateRange range, int trendDays) {
+ List points = new ArrayList<>();
+ LocalDate end = range.endDate;
+ LocalDate start = end.minusDays(trendDays - 1);
+ if (start.isBefore(range.startDate)) {
+ start = range.startDate;
+ }
+ LocalDate cursor = start;
+ while (!cursor.isAfter(end)) {
+ points.add(buildTrendPoint(cursor, Collections.emptyList()));
+ cursor = cursor.plusDays(1);
+ }
+ return points;
+ }
+
+ private ClerkPerformanceTrendPointVo buildTrendPoint(LocalDate date, List orders) {
+ ClerkPerformanceTrendPointVo point = new ClerkPerformanceTrendPointVo();
+ point.setDate(date);
+ BigDecimal gmv = BigDecimal.ZERO;
+ BigDecimal continuedAmount = BigDecimal.ZERO;
+ BigDecimal firstAmount = BigDecimal.ZERO;
+ int continuedCount = 0;
+ for (PlayOrderInfoEntity order : orders) {
+ BigDecimal finalAmount = defaultZero(order.getFinalAmount());
+ gmv = gmv.add(finalAmount);
+ if ("1".equals(order.getFirstOrder())) {
+ firstAmount = firstAmount.add(finalAmount);
+ } else {
+ continuedAmount = continuedAmount.add(finalAmount);
+ continuedCount++;
+ }
+ }
+ point.setGmv(gmv);
+ point.setOrderCount(orders.size());
+ point.setContinuedAmount(continuedAmount);
+ point.setFirstAmount(firstAmount);
+ point.setContinuedRate(calcPercentage(continuedCount, orders.size()));
+ return point;
+ }
+
+ private ClerkPerformanceDetailCompositionVo buildComposition(ClerkPerformanceSnapshotVo snapshot) {
+ ClerkPerformanceDetailCompositionVo composition = new ClerkPerformanceDetailCompositionVo();
+ int orderTotal = snapshot.getOrderCount();
+ BigDecimal gmv = snapshot.getGmv();
+ List orderSlices = new ArrayList<>();
+ orderSlices.add(createSlice("FIRST_ORDER", "首单", snapshot.getFirstOrderCount(), orderTotal,
+ snapshot.getFirstOrderAmount(), gmv));
+ orderSlices.add(createSlice("CONTINUED_ORDER", "续单", snapshot.getContinuedOrderCount(), orderTotal,
+ snapshot.getContinuedOrderAmount(), gmv));
+ orderSlices.add(createSlice("REFUND_ORDER", "退款", snapshot.getRefundOrderCount(), orderTotal,
+ snapshot.getRefundAmount(), gmv));
+ orderSlices.add(createSlice("EXPIRED_ORDER", "超时", snapshot.getExpiredOrderCount(), orderTotal,
+ BigDecimal.ZERO, gmv));
+ composition.setOrderComposition(orderSlices);
+ List amountSlices = new ArrayList<>();
+ amountSlices.add(createSlice("FIRST_AMOUNT", "首单金额", 0, orderTotal, snapshot.getFirstOrderAmount(), gmv));
+ amountSlices.add(
+ createSlice("CONTINUED_AMOUNT", "续单金额", 0, orderTotal, snapshot.getContinuedOrderAmount(), gmv));
+ amountSlices.add(createSlice("REWARD_AMOUNT", "打赏金额", 0, orderTotal, snapshot.getRewardAmount(), gmv));
+ amountSlices.add(createSlice("REFUND_AMOUNT", "退款金额", 0, orderTotal, snapshot.getRefundAmount(), gmv));
+ composition.setAmountComposition(amountSlices);
+ int continuedCustomers = snapshot.getContinuedUserCount();
+ int totalCustomers = snapshot.getUserCount();
+ int newCustomers = Math.max(totalCustomers - continuedCustomers, 0);
+ List customerSlices = new ArrayList<>();
+ customerSlices.add(createSlice("NEW_CUSTOMER", "新顾客", newCustomers, totalCustomers, BigDecimal.ZERO, gmv));
+ customerSlices
+ .add(createSlice("CONTINUED_CUSTOMER", "复购顾客", continuedCustomers, totalCustomers, BigDecimal.ZERO,
+ gmv));
+ composition.setCustomerComposition(customerSlices);
+ return composition;
+ }
+
+ private ClerkPerformanceDetailCompositionVo.CompositionSlice createSlice(String key, String label, int count,
+ int totalCount, BigDecimal amount, BigDecimal totalAmount) {
+ ClerkPerformanceDetailCompositionVo.CompositionSlice slice =
+ new ClerkPerformanceDetailCompositionVo.CompositionSlice();
+ slice.setKey(key);
+ slice.setLabel(label);
+ slice.setCount(count);
+ slice.setCountRatio(calcPercentage(count, totalCount));
+ slice.setAmount(amount);
+ slice.setAmountRatio(calcPercentage(amount, totalAmount));
+ return slice;
+ }
+
+ private void applySortAndRank(List snapshots, ClerkPerformanceSortField sortField,
+ SortDirection direction) {
+ if (CollectionUtil.isEmpty(snapshots)) {
+ return;
+ }
+ Comparator comparator;
+ if (sortField == null) {
+ sortField = ClerkPerformanceSortField.GMV;
+ }
+ switch (sortField) {
+ case ORDER_COUNT:
+ comparator = Comparator.comparing(ClerkPerformanceSnapshotVo::getOrderCount, Comparator.nullsLast(Integer::compareTo));
+ break;
+ case CONTINUE_RATE:
+ comparator = Comparator.comparing(ClerkPerformanceSnapshotVo::getContinuedRate, Comparator.nullsLast(BigDecimal::compareTo));
+ break;
+ case ESTIMATED_REVENUE:
+ comparator = Comparator.comparing(ClerkPerformanceSnapshotVo::getEstimatedRevenue, Comparator.nullsLast(BigDecimal::compareTo));
+ break;
+ case GMV:
+ default:
+ comparator = Comparator.comparing(ClerkPerformanceSnapshotVo::getGmv, Comparator.nullsLast(BigDecimal::compareTo));
+ break;
+ }
+ comparator = comparator.thenComparing(ClerkPerformanceSnapshotVo::getClerkNickname,
+ Comparator.nullsFirst(String::compareTo));
+ if (direction == null) {
+ direction = SortDirection.DEFAULT;
+ }
+ if (direction == SortDirection.DESC) {
+ comparator = comparator.reversed();
+ }
+ snapshots.sort(comparator);
+ applySequentialRank(snapshots);
+ }
+
+ private void applySequentialRank(List snapshots) {
+ for (int i = 0; i < snapshots.size(); i++) {
+ snapshots.get(i).setRank(i + 1);
+ }
+ }
+
+ private ClerkPerformanceOverviewSummaryVo aggregateSummary(List snapshots) {
+ ClerkPerformanceOverviewSummaryVo summary = new ClerkPerformanceOverviewSummaryVo();
+ if (CollectionUtil.isEmpty(snapshots)) {
+ return summary;
+ }
+ BigDecimal totalGmv = BigDecimal.ZERO;
+ BigDecimal continuedAmount = BigDecimal.ZERO;
+ BigDecimal refundAmount = BigDecimal.ZERO;
+ BigDecimal estimatedRevenue = BigDecimal.ZERO;
+ int orderTotal = 0;
+ int firstOrderTotal = 0;
+ int continuedOrderTotal = 0;
+ int refundOrderTotal = 0;
+ int userTotal = 0;
+ int continuedUserTotal = 0;
+ for (ClerkPerformanceSnapshotVo snapshot : snapshots) {
+ totalGmv = totalGmv.add(defaultZero(snapshot.getGmv()));
+ continuedAmount = continuedAmount.add(defaultZero(snapshot.getContinuedOrderAmount()));
+ refundAmount = refundAmount.add(defaultZero(snapshot.getRefundAmount()));
+ estimatedRevenue = estimatedRevenue.add(defaultZero(snapshot.getEstimatedRevenue()));
+ orderTotal += snapshot.getOrderCount();
+ firstOrderTotal += snapshot.getFirstOrderCount();
+ continuedOrderTotal += snapshot.getContinuedOrderCount();
+ refundOrderTotal += snapshot.getRefundOrderCount();
+ userTotal += snapshot.getUserCount();
+ continuedUserTotal += snapshot.getContinuedUserCount();
+ }
+ summary.setTotalGmv(totalGmv);
+ summary.setTotalOrderCount(orderTotal);
+ summary.setTotalFirstOrderCount(firstOrderTotal);
+ summary.setTotalContinuedOrderCount(continuedOrderTotal);
+ summary.setTotalRefundOrderCount(refundOrderTotal);
+ summary.setTotalEstimatedRevenue(estimatedRevenue);
+ summary.setTotalUserCount(userTotal);
+ summary.setTotalContinuedUserCount(continuedUserTotal);
+ summary.setContinuedRate(calcPercentage(continuedOrderTotal, orderTotal));
+ summary.setContinuedAmountRate(calcPercentage(continuedAmount, totalGmv));
+ summary.setRefundRate(calcPercentage(refundOrderTotal, orderTotal));
+ summary.setAverageTicketPrice(calcAverage(totalGmv, orderTotal));
+ summary.setContinuedCustomerRate(calcPercentage(continuedUserTotal, userTotal));
+ return summary;
+ }
+
+ private ClerkPerformanceSnapshotVo buildSnapshot(PlayClerkUserInfoEntity clerk, List orders,
+ Map levelNameMap, Map groupNameMap) {
+ ClerkPerformanceSnapshotVo snapshot = new ClerkPerformanceSnapshotVo();
+ snapshot.setClerkId(clerk.getId());
+ snapshot.setClerkNickname(clerk.getNickname());
+ snapshot.setAvatar(clerk.getAvatar());
+ snapshot.setSex(clerk.getSex());
+ snapshot.setGroupName(groupNameMap.getOrDefault(clerk.getGroupId(), ""));
+ snapshot.setLevelName(levelNameMap.getOrDefault(clerk.getLevelId(), ""));
+ BigDecimal gmv = BigDecimal.ZERO;
+ BigDecimal firstAmount = BigDecimal.ZERO;
+ BigDecimal continuedAmount = BigDecimal.ZERO;
+ BigDecimal rewardAmount = BigDecimal.ZERO;
+ BigDecimal refundAmount = BigDecimal.ZERO;
+ BigDecimal estimatedRevenue = BigDecimal.ZERO;
+ int firstCount = 0;
+ int continuedCount = 0;
+ int refundCount = 0;
+ int expiredCount = 0;
+ Map userOrderMap = new HashMap<>();
+ for (PlayOrderInfoEntity order : orders) {
+ BigDecimal finalAmount = defaultZero(order.getFinalAmount());
+ gmv = gmv.add(finalAmount);
+ userOrderMap.merge(order.getPurchaserBy(), 1, Integer::sum);
+ if ("1".equals(order.getFirstOrder())) {
+ firstCount++;
+ firstAmount = firstAmount.add(finalAmount);
+ } else {
+ continuedCount++;
+ continuedAmount = continuedAmount.add(finalAmount);
+ }
+ if ("2".equals(order.getPlaceType())) {
+ rewardAmount = rewardAmount.add(finalAmount);
+ }
+ if ("1".equals(order.getRefundType())) {
+ refundCount++;
+ refundAmount = refundAmount.add(defaultZero(order.getRefundAmount()));
+ }
+ if ("1".equals(order.getOrdersExpiredState())) {
+ expiredCount++;
+ }
+ estimatedRevenue = estimatedRevenue.add(defaultZero(order.getEstimatedRevenue()));
+ }
+ int orderCount = orders.size();
+ int userCount = userOrderMap.size();
+ int continuedUserCount = (int) userOrderMap.values().stream().filter(cnt -> cnt > 1).count();
+ snapshot.setGmv(gmv);
+ snapshot.setFirstOrderAmount(firstAmount);
+ snapshot.setContinuedOrderAmount(continuedAmount);
+ snapshot.setRewardAmount(rewardAmount);
+ snapshot.setRefundAmount(refundAmount);
+ snapshot.setEstimatedRevenue(estimatedRevenue);
+ snapshot.setOrderCount(orderCount);
+ snapshot.setFirstOrderCount(firstCount);
+ snapshot.setContinuedOrderCount(continuedCount);
+ snapshot.setRefundOrderCount(refundCount);
+ snapshot.setExpiredOrderCount(expiredCount);
+ snapshot.setUserCount(userCount);
+ snapshot.setContinuedUserCount(continuedUserCount);
+ snapshot.setContinuedRate(calcPercentage(continuedCount, orderCount));
+ snapshot.setContinuedAmountRate(calcPercentage(continuedAmount, gmv));
+ snapshot.setRefundRate(calcPercentage(refundCount, orderCount));
+ snapshot.setContinuedCustomerRate(calcPercentage(continuedUserCount, userCount));
+ snapshot.setAverageTicketPrice(calcAverage(gmv, orderCount));
+ snapshot.setOnlineDuration(0L);
+ return snapshot;
+ }
+
+ private List loadAccessibleClerks(ClerkPerformanceOverviewQueryVo vo) {
+ List accessibleIds =
+ playPersonnelGroupInfoService.getValidClerkIdList(SecurityUtils.getLoginUser(), null);
+ if (CollectionUtil.isEmpty(accessibleIds)) {
+ return Collections.emptyList();
+ }
+ LambdaQueryWrapper wrapper =
+ Wrappers.lambdaQuery(PlayClerkUserInfoEntity.class).in(PlayClerkUserInfoEntity::getId, accessibleIds)
+ .eq(PlayClerkUserInfoEntity::getClerkState, "1");
+ if (StrUtil.isNotBlank(vo.getClerkId())) {
+ wrapper.eq(PlayClerkUserInfoEntity::getId, vo.getClerkId());
+ }
+ if (StrUtil.isNotBlank(vo.getGroupId())) {
+ wrapper.eq(PlayClerkUserInfoEntity::getGroupId, vo.getGroupId());
+ }
+ if (StrUtil.isNotBlank(vo.getSex())) {
+ wrapper.eq(PlayClerkUserInfoEntity::getSex, vo.getSex());
+ }
+ if (StrUtil.isNotBlank(vo.getListingState())) {
+ wrapper.eq(PlayClerkUserInfoEntity::getListingState, vo.getListingState());
+ }
+ return clerkUserInfoService.list(wrapper);
+ }
+
+ private DateRange resolveDateRange(List endOrderTime) {
+ String startStr;
+ String endStr;
+ if (CollectionUtil.isNotEmpty(endOrderTime) && endOrderTime.size() >= 2) {
+ startStr = normalizeStart(endOrderTime.get(0));
+ endStr = normalizeEnd(endOrderTime.get(1));
+ } else {
+ LocalDate end = LocalDate.now();
+ LocalDate start = end.minusDays(6);
+ startStr = start.format(DATE_FORMATTER) + " 00:00:00";
+ endStr = end.format(DATE_FORMATTER) + " 23:59:59";
+ }
+ LocalDate startDate = LocalDate.parse(startStr.substring(0, 10), DATE_FORMATTER);
+ LocalDate endDate = LocalDate.parse(endStr.substring(0, 10), DATE_FORMATTER);
+ return new DateRange(startStr, endStr, startDate, endDate);
+ }
+
+ private String normalizeStart(String raw) {
+ if (StrUtil.isBlank(raw)) {
+ return LocalDate.now().minusDays(6).format(DATE_FORMATTER) + " 00:00:00";
+ }
+ if (raw.length() > 10) {
+ LocalDateTime.parse(raw, DATE_TIME_FORMATTER);
+ return raw;
+ }
+ return raw + " 00:00:00";
+ }
+
+ private String normalizeEnd(String raw) {
+ if (StrUtil.isBlank(raw)) {
+ return LocalDate.now().format(DATE_FORMATTER) + " 23:59:59";
+ }
+ if (raw.length() > 10) {
+ LocalDateTime.parse(raw, DATE_TIME_FORMATTER);
+ return raw;
+ }
+ return raw + " 23:59:59";
+ }
+
+ private BigDecimal calcPercentage(int numerator, int denominator) {
+ if (denominator <= 0) {
+ return BigDecimal.ZERO;
+ }
+ return BigDecimal.valueOf(numerator).multiply(BigDecimal.valueOf(100))
+ .divide(BigDecimal.valueOf(denominator), 2, RoundingMode.HALF_UP);
+ }
+
+ private BigDecimal calcPercentage(BigDecimal numerator, BigDecimal denominator) {
+ if (denominator == null || BigDecimal.ZERO.compareTo(denominator) == 0 || numerator == null) {
+ return BigDecimal.ZERO;
+ }
+ return numerator.multiply(BigDecimal.valueOf(100)).divide(denominator, 2, RoundingMode.HALF_UP);
+ }
+
+ private BigDecimal calcAverage(BigDecimal numerator, int denominator) {
+ if (denominator <= 0 || numerator == null) {
+ return BigDecimal.ZERO;
+ }
+ return numerator.divide(BigDecimal.valueOf(denominator), 2, RoundingMode.HALF_UP);
+ }
+
+ private BigDecimal defaultZero(BigDecimal value) {
+ return value == null ? BigDecimal.ZERO : value;
+ }
+
+ private static final class DateRange {
+ private final String startTime;
+ private final String endTime;
+ private final LocalDate startDate;
+ private final LocalDate endDate;
+
+ private DateRange(String startTime, String endTime, LocalDate startDate, LocalDate endDate) {
+ this.startTime = startTime;
+ this.endTime = endTime;
+ this.startDate = startDate;
+ this.endDate = endDate;
+ }
+ }
}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxCustomMpService.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxCustomMpService.java
index 253ea3d..8f95fa4 100644
--- a/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxCustomMpService.java
+++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxCustomMpService.java
@@ -193,7 +193,7 @@ public class WxCustomMpService {
* @author admin
* @since 2024/7/31 10:51
**/
- public void sendOrderMessage(PlayOrderInfoEntity orderInfo) {
+ private void sendOrderMessage(PlayOrderInfoEntity orderInfo) {
SysTenantEntity tenant = tenantService.selectSysTenantByTenantId(orderInfo.getTenantId());
PlayClerkUserInfoEntity clerkUserInfo = clerkUserInfoService.selectById(orderInfo.getAcceptBy());
PlayCustomUserInfoEntity customUserInfo = customUserInfoService.selectById(orderInfo.getPurchaserBy());
diff --git a/play-admin/src/test/java/com/starry/admin/modules/order/service/PlayOrderInfoServiceTest.java b/play-admin/src/test/java/com/starry/admin/modules/order/service/PlayOrderInfoServiceTest.java
index 2b51b8f..e885589 100644
--- a/play-admin/src/test/java/com/starry/admin/modules/order/service/PlayOrderInfoServiceTest.java
+++ b/play-admin/src/test/java/com/starry/admin/modules/order/service/PlayOrderInfoServiceTest.java
@@ -472,7 +472,7 @@ class PlayOrderInfoServiceTest {
any(BigDecimal.class), anyString(), anyString(), any(BigDecimal.class), any(BigDecimal.class), eq(orderId));
doNothing().when(playOrderRefundInfoService).add(eq(orderId), eq("customer-1"), eq("clerk-1"), anyString(),
anyString(), any(BigDecimal.class), anyString(), anyString(), anyString(), anyString(), anyString());
- doNothing().when(wxCustomMpService).sendOrderCancelMessage(any(PlayOrderInfoEntity.class), anyString());
+ doNothing().when(wxCustomMpService).sendOrderCancelMessageAsync(any(PlayOrderInfoEntity.class), anyString());
assertDoesNotThrow(() -> orderService.forceCancelOngoingOrder("2", "admin-1", orderId,
BigDecimal.valueOf(80), "管理员取消测试", Collections.emptyList()));
@@ -483,7 +483,7 @@ class PlayOrderInfoServiceTest {
eq(orderId));
verify(playOrderRefundInfoService, times(1)).add(eq(orderId), eq("customer-1"), eq("clerk-1"), anyString(),
eq("0"), eq(BigDecimal.valueOf(80)), eq("管理员取消测试"), eq("2"), eq("admin-1"), eq("0"), eq("0"));
- verify(wxCustomMpService, times(1)).sendOrderCancelMessage(any(PlayOrderInfoEntity.class),
+ verify(wxCustomMpService, times(1)).sendOrderCancelMessageAsync(any(PlayOrderInfoEntity.class),
eq("管理员取消测试"));
}
diff --git a/play-admin/src/test/java/com/starry/admin/modules/statistics/controller/PlayClerkPerformanceControllerTest.java b/play-admin/src/test/java/com/starry/admin/modules/statistics/controller/PlayClerkPerformanceControllerTest.java
new file mode 100644
index 0000000..62d1923
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/modules/statistics/controller/PlayClerkPerformanceControllerTest.java
@@ -0,0 +1,159 @@
+package com.starry.admin.modules.statistics.controller;
+
+import static org.hamcrest.Matchers.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceDetailCompositionVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceDetailQueryVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceDetailResponseVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceOverviewQueryVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceOverviewResponseVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceOverviewSummaryVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceProfileVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceSnapshotVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceTrendPointVo;
+import com.starry.admin.modules.statistics.service.IPlayClerkPerformanceService;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.Arrays;
+import java.util.Collections;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+/**
+ * MockMvc tests for {@link PlayClerkPerformanceController} verifying new overview/detail endpoints.
+ */
+@ExtendWith(MockitoExtension.class)
+class PlayClerkPerformanceControllerTest {
+
+ @Mock
+ private IPlayClerkPerformanceService playClerkPerformanceService;
+
+ @InjectMocks
+ private PlayClerkPerformanceController controller;
+
+ private MockMvc mockMvc;
+ private ObjectMapper objectMapper;
+
+ @BeforeEach
+ void setUp() {
+ objectMapper = new ObjectMapper();
+ objectMapper.findAndRegisterModules();
+ mockMvc = MockMvcBuilders.standaloneSetup(controller)
+ .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper))
+ .build();
+ }
+
+ @Test
+ @DisplayName("POST /statistics/performance/overview should delegate and wrap service response")
+ void overviewEndpointReturnsAggregatedData() throws Exception {
+ ClerkPerformanceSnapshotVo snapshot = new ClerkPerformanceSnapshotVo();
+ snapshot.setClerkId("c1");
+ snapshot.setClerkNickname("Alice");
+ snapshot.setGmv(new BigDecimal("300.00"));
+ snapshot.setOrderCount(5);
+ snapshot.setContinuedRate(new BigDecimal("60.00"));
+
+ ClerkPerformanceOverviewSummaryVo summary = new ClerkPerformanceOverviewSummaryVo();
+ summary.setTotalGmv(new BigDecimal("300.00"));
+ summary.setTotalOrderCount(5);
+ summary.setContinuedRate(new BigDecimal("60.00"));
+
+ ClerkPerformanceOverviewResponseVo responseVo = new ClerkPerformanceOverviewResponseVo();
+ responseVo.setSummary(summary);
+ responseVo.setRankings(Collections.singletonList(snapshot));
+ responseVo.setTotal(1);
+
+ when(playClerkPerformanceService.queryOverview(any())).thenReturn(responseVo);
+
+ String payload = "{\"endOrderTime\":[\"2024-08-01 00:00:00\",\"2024-08-07 23:59:59\"],\"limit\":3}";
+
+ mockMvc.perform(MockMvcRequestBuilders.post("/statistics/performance/overview")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(MockMvcResultMatchers.status().isOk())
+ .andExpect(MockMvcResultMatchers.jsonPath("$.success").value(true))
+ .andExpect(MockMvcResultMatchers.jsonPath("$.data.rankings", hasSize(1)))
+ .andExpect(MockMvcResultMatchers.jsonPath("$.data.rankings[0].clerkId").value("c1"))
+ .andExpect(MockMvcResultMatchers.jsonPath("$.data.summary.totalGmv").value(300.00))
+ .andExpect(MockMvcResultMatchers.jsonPath("$.data.total").value(1));
+
+ ArgumentCaptor captor = ArgumentCaptor
+ .forClass(ClerkPerformanceOverviewQueryVo.class);
+ verify(playClerkPerformanceService).queryOverview(captor.capture());
+ ClerkPerformanceOverviewQueryVo captured = captor.getValue();
+ assertEquals(Integer.valueOf(3), captured.getLimit());
+ assertEquals(Arrays.asList("2024-08-01 00:00:00", "2024-08-07 23:59:59"), captured.getEndOrderTime());
+ }
+
+ @Test
+ @DisplayName("POST /statistics/performance/detail should return snapshot and trend data")
+ void detailEndpointReturnsSnapshot() throws Exception {
+ ClerkPerformanceProfileVo profile = new ClerkPerformanceProfileVo();
+ profile.setClerkId("c1");
+ profile.setNickname("Alice");
+ profile.setGroupName("一组");
+
+ ClerkPerformanceSnapshotVo snapshotVo = new ClerkPerformanceSnapshotVo();
+ snapshotVo.setClerkId("c1");
+ snapshotVo.setGmv(new BigDecimal("260.00"));
+ snapshotVo.setOrderCount(3);
+ snapshotVo.setContinuedRate(new BigDecimal("66.67"));
+
+ ClerkPerformanceDetailCompositionVo composition = new ClerkPerformanceDetailCompositionVo();
+ ClerkPerformanceDetailCompositionVo.CompositionSlice slice = new ClerkPerformanceDetailCompositionVo.CompositionSlice();
+ slice.setKey("FIRST_ORDER");
+ slice.setLabel("首单");
+ slice.setCount(1);
+ slice.setCountRatio(new BigDecimal("33.33"));
+ composition.setOrderComposition(Collections.singletonList(slice));
+
+ ClerkPerformanceTrendPointVo trendPoint = new ClerkPerformanceTrendPointVo();
+ trendPoint.setDate(LocalDate.of(2024, 8, 1));
+ trendPoint.setGmv(new BigDecimal("120.00"));
+
+ ClerkPerformanceDetailResponseVo detailResponse = new ClerkPerformanceDetailResponseVo();
+ detailResponse.setProfile(profile);
+ detailResponse.setSnapshot(snapshotVo);
+ detailResponse.setComposition(composition);
+ detailResponse.setTrend(Collections.singletonList(trendPoint));
+ detailResponse.setTrendDimension("DAY");
+
+ when(playClerkPerformanceService.queryDetail(any())).thenReturn(detailResponse);
+
+ String payload = "{\"clerkId\":\"c1\",\"endOrderTime\":[\"2024-08-01 00:00:00\",\"2024-08-03 23:59:59\"],\"includeTrend\":true}";
+
+ mockMvc.perform(MockMvcRequestBuilders.post("/statistics/performance/detail")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(MockMvcResultMatchers.status().isOk())
+ .andExpect(MockMvcResultMatchers.jsonPath("$.success").value(true))
+ .andExpect(MockMvcResultMatchers.jsonPath("$.data.profile.clerkId").value("c1"))
+ .andExpect(MockMvcResultMatchers.jsonPath("$.data.snapshot.gmv").value(260.00))
+ .andExpect(MockMvcResultMatchers.jsonPath("$.data.trend[0].gmv").value(120.00));
+
+ ArgumentCaptor captor = ArgumentCaptor
+ .forClass(ClerkPerformanceDetailQueryVo.class);
+ verify(playClerkPerformanceService).queryDetail(captor.capture());
+ ClerkPerformanceDetailQueryVo captured = captor.getValue();
+ assertTrue(captured.getIncludeTrend());
+ assertEquals("c1", captured.getClerkId());
+ assertEquals(Arrays.asList("2024-08-01 00:00:00", "2024-08-03 23:59:59"), captured.getEndOrderTime());
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/modules/statistics/service/PlayClerkPerformanceServiceImplTest.java b/play-admin/src/test/java/com/starry/admin/modules/statistics/service/PlayClerkPerformanceServiceImplTest.java
new file mode 100644
index 0000000..4d477fc
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/modules/statistics/service/PlayClerkPerformanceServiceImplTest.java
@@ -0,0 +1,250 @@
+package com.starry.admin.modules.statistics.service;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.starry.admin.common.domain.LoginUser;
+import com.starry.admin.common.exception.ServiceException;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
+import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService;
+import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
+import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
+import com.starry.admin.modules.order.service.IPlayOrderInfoService;
+import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
+import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceDetailQueryVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceDetailResponseVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceOverviewQueryVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceOverviewResponseVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceOverviewSummaryVo;
+import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceSnapshotVo;
+import com.starry.admin.modules.statistics.service.impl.PlayClerkPerformanceServiceImpl;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+/**
+ * Unit tests for {@link PlayClerkPerformanceServiceImpl} covering aggregation, ranking and detail logic.
+ */
+@ExtendWith(MockitoExtension.class)
+class PlayClerkPerformanceServiceImplTest {
+
+ @Mock
+ private IPlayClerkUserInfoService clerkUserInfoService;
+
+ @Mock
+ private IPlayOrderInfoService playOrderInfoService;
+
+ @Mock
+ private IPlayClerkLevelInfoService playClerkLevelInfoService;
+
+ @Mock
+ private IPlayPersonnelGroupInfoService playPersonnelGroupInfoService;
+
+ @InjectMocks
+ private PlayClerkPerformanceServiceImpl service;
+
+ @Test
+ @DisplayName("queryOverview should aggregate metrics and sort clerks by GMV")
+ void queryOverviewAggregatesAndSorts() {
+ ClerkPerformanceOverviewQueryVo vo = new ClerkPerformanceOverviewQueryVo();
+ vo.setEndOrderTime(Arrays.asList("2024-08-01 00:00:00", "2024-08-07 23:59:59"));
+ vo.setIncludeSummary(true);
+ vo.setIncludeRankings(true);
+
+ PlayClerkUserInfoEntity clerkA = buildClerk("c1", "Alice", "g1", "l1");
+ PlayClerkUserInfoEntity clerkB = buildClerk("c2", "Bob", "g2", "l2");
+
+ when(playPersonnelGroupInfoService.getValidClerkIdList(any(), any())).thenReturn(Arrays.asList("c1", "c2"));
+ when(clerkUserInfoService.list((Wrapper) any())).thenReturn(Arrays.asList(clerkA, clerkB));
+ when(playClerkLevelInfoService.selectAll()).thenReturn(Arrays.asList(level("l1", "白银"), level("l2", "黄金")));
+ when(playPersonnelGroupInfoService.selectAll())
+ .thenReturn(Arrays.asList(group("g1", "一组"), group("g2", "二组")));
+
+ List ordersA = Arrays.asList(
+ order("c1", "userA", "1", "0", "0", new BigDecimal("100.00"), new BigDecimal("60.00"),
+ LocalDateTime.of(2024, Month.AUGUST, 1, 10, 0)),
+ order("c1", "userA", "0", "2", "0", new BigDecimal("150.00"), new BigDecimal("90.00"),
+ LocalDateTime.of(2024, Month.AUGUST, 2, 14, 0)),
+ withRefund(order("c1", "userB", "0", "0", "1", new BigDecimal("50.00"), new BigDecimal("30.00"),
+ LocalDateTime.of(2024, Month.AUGUST, 3, 9, 0)), new BigDecimal("30.00")));
+
+ List ordersB = Collections.singletonList(
+ order("c2", "userC", "1", "0", "0", new BigDecimal("80.00"), new BigDecimal("50.00"),
+ LocalDateTime.of(2024, Month.AUGUST, 1, 12, 0)));
+
+ when(playOrderInfoService.clerkSelectOrderInfoList(eq("c1"), anyString(), anyString())).thenReturn(ordersA);
+ when(playOrderInfoService.clerkSelectOrderInfoList(eq("c2"), anyString(), anyString())).thenReturn(ordersB);
+
+ setAuthentication();
+ try {
+ ClerkPerformanceOverviewResponseVo response = service.queryOverview(vo);
+
+ assertNotNull(response.getSummary(), "summary should be present when includeSummary=true");
+ assertEquals(2, response.getTotal());
+ assertEquals(2, response.getRankings().size());
+
+ ClerkPerformanceSnapshotVo top = response.getRankings().get(0);
+ assertEquals("c1", top.getClerkId(), "Highest GMV clerk should rank first");
+ assertEquals(new BigDecimal("300.00"), top.getGmv());
+ assertEquals(new BigDecimal("100.00"), top.getFirstOrderAmount());
+ assertEquals(new BigDecimal("200.00"), top.getContinuedOrderAmount());
+ assertEquals(new BigDecimal("150.00"), top.getRewardAmount());
+ assertEquals(new BigDecimal("30.00"), top.getRefundAmount());
+ assertEquals(3, top.getOrderCount());
+ assertEquals(2, top.getContinuedOrderCount());
+ assertEquals(new BigDecimal("66.67"), top.getContinuedRate());
+ assertEquals(new BigDecimal("150.00"), top.getAverageTicketPrice());
+
+ ClerkPerformanceOverviewSummaryVo summary = response.getSummary();
+ assertEquals(new BigDecimal("380.00"), summary.getTotalGmv());
+ assertEquals(4, summary.getTotalOrderCount());
+ assertEquals(2, summary.getTotalFirstOrderCount());
+ assertEquals(2, summary.getTotalContinuedOrderCount());
+ assertEquals(new BigDecimal("50.00"), summary.getContinuedRate());
+ assertEquals(new BigDecimal("126.67"), summary.getAverageTicketPrice());
+ } finally {
+ clearAuthentication();
+ }
+ }
+
+ @Test
+ @DisplayName("queryDetail should build snapshot, composition and trend for accessible clerk")
+ void queryDetailBuildsSnapshotCompositionAndTrend() {
+ ClerkPerformanceDetailQueryVo vo = new ClerkPerformanceDetailQueryVo();
+ vo.setClerkId("c1");
+ vo.setEndOrderTime(Arrays.asList("2024-08-01 00:00:00", "2024-08-03 23:59:59"));
+ vo.setIncludeTrend(true);
+ vo.setTrendDays(3);
+
+ PlayClerkUserInfoEntity clerk = buildClerk("c1", "Alice", "g1", "l1");
+
+ when(playPersonnelGroupInfoService.getValidClerkIdList(any(), any())).thenReturn(Collections.singletonList("c1"));
+ when(clerkUserInfoService.getById("c1")).thenReturn(clerk);
+ when(playClerkLevelInfoService.selectAll()).thenReturn(Collections.singletonList(level("l1", "白银")));
+ when(playPersonnelGroupInfoService.selectAll()).thenReturn(Collections.singletonList(group("g1", "一组")));
+
+ List orders = Arrays.asList(
+ order("c1", "userA", "1", "0", "0", new BigDecimal("120.00"), new BigDecimal("70.00"),
+ LocalDateTime.of(2024, Month.AUGUST, 1, 9, 0)),
+ order("c1", "userA", "0", "0", "0", new BigDecimal("80.00"), new BigDecimal("40.00"),
+ LocalDateTime.of(2024, Month.AUGUST, 2, 10, 0)),
+ withRefund(order("c1", "userB", "0", "2", "1", new BigDecimal("60.00"), new BigDecimal("30.00"),
+ LocalDateTime.of(2024, Month.AUGUST, 2, 18, 0)), new BigDecimal("20.00")));
+ when(playOrderInfoService.clerkSelectOrderInfoList(eq("c1"), anyString(), anyString())).thenReturn(orders);
+
+ setAuthentication();
+ try {
+ ClerkPerformanceDetailResponseVo response = service.queryDetail(vo);
+ assertNotNull(response.getProfile());
+ assertEquals("Alice", response.getProfile().getNickname());
+ assertEquals("一组", response.getProfile().getGroupName());
+ assertNotNull(response.getSnapshot());
+ assertEquals(new BigDecimal("260.00"), response.getSnapshot().getGmv());
+ assertEquals(3, response.getSnapshot().getOrderCount());
+ assertEquals(new BigDecimal("66.67"), response.getSnapshot().getContinuedRate());
+ assertEquals(new BigDecimal("130.00"), response.getSnapshot().getAverageTicketPrice());
+
+ assertNotNull(response.getComposition());
+ assertEquals(4, response.getComposition().getOrderComposition().size());
+ assertEquals(new BigDecimal("33.33"), response.getComposition().getOrderComposition().get(0).getCountRatio());
+
+ assertEquals(3, response.getTrend().size());
+ assertEquals("DAY", response.getTrendDimension());
+ assertEquals(new BigDecimal("120.00"), response.getTrend().get(0).getGmv());
+ assertEquals(new BigDecimal("140.00"), response.getTrend().get(1).getGmv());
+ } finally {
+ clearAuthentication();
+ }
+ }
+
+ @Test
+ @DisplayName("queryDetail should reject access when clerk not under current user")
+ void queryDetailShouldEnforcePermissions() {
+ ClerkPerformanceDetailQueryVo vo = new ClerkPerformanceDetailQueryVo();
+ vo.setClerkId("c99");
+ vo.setEndOrderTime(Arrays.asList("2024-08-01 00:00:00", "2024-08-02 23:59:59"));
+
+ when(playPersonnelGroupInfoService.getValidClerkIdList(any(), any())).thenReturn(Collections.singletonList("c1"));
+ when(clerkUserInfoService.getById("c99")).thenReturn(buildClerk("c99", "Ghost", "g1", "l1"));
+
+ setAuthentication();
+ try {
+ ServiceException ex = assertThrows(ServiceException.class, () -> service.queryDetail(vo));
+ assertTrue(ex.getMessage().contains("无权"));
+ } finally {
+ clearAuthentication();
+ }
+ }
+
+ private PlayClerkUserInfoEntity buildClerk(String id, String name, String groupId, String levelId) {
+ PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
+ entity.setId(id);
+ entity.setNickname(name);
+ entity.setGroupId(groupId);
+ entity.setLevelId(levelId);
+ entity.setSex("1");
+ entity.setListingState("1");
+ entity.setOnlineState("1");
+ return entity;
+ }
+
+ private PlayClerkLevelInfoEntity level(String id, String name) {
+ PlayClerkLevelInfoEntity level = new PlayClerkLevelInfoEntity();
+ level.setId(id);
+ level.setName(name);
+ return level;
+ }
+
+ private PlayPersonnelGroupInfoEntity group(String id, String name) {
+ PlayPersonnelGroupInfoEntity entity = new PlayPersonnelGroupInfoEntity();
+ entity.setId(id);
+ entity.setGroupName(name);
+ return entity;
+ }
+
+ private PlayOrderInfoEntity order(String clerkId, String purchaser, String firstOrder, String placeType,
+ String refundType, BigDecimal finalAmount, BigDecimal estimatedRevenue, LocalDateTime purchaserTime) {
+ PlayOrderInfoEntity order = new PlayOrderInfoEntity();
+ order.setAcceptBy(clerkId);
+ order.setPurchaserBy(purchaser);
+ order.setFirstOrder(firstOrder);
+ order.setPlaceType(placeType);
+ order.setRefundType(refundType);
+ order.setFinalAmount(finalAmount);
+ order.setEstimatedRevenue(estimatedRevenue);
+ order.setOrdersExpiredState("1".equals(refundType) ? "1" : "0");
+ order.setPurchaserTime(purchaserTime);
+ return order;
+ }
+
+ private void setAuthentication() {
+ LoginUser loginUser = new LoginUser();
+ UsernamePasswordAuthenticationToken authentication =
+ new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList());
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ }
+
+ private void clearAuthentication() {
+ SecurityContextHolder.clearContext();
+ }
+
+ private PlayOrderInfoEntity withRefund(PlayOrderInfoEntity order, BigDecimal refundAmount) {
+ order.setRefundAmount(refundAmount);
+ return order;
+ }
+}