This commit is contained in:
irving
2025-10-27 22:53:40 -04:00
parent f7461abc83
commit 1ec92cc2ab
19 changed files with 1394 additions and 15 deletions

View File

@@ -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")

View File

@@ -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
}

View File

@@ -0,0 +1,20 @@
package com.starry.admin.modules.statistics.module.enums;
/**
* 排序方向。
*/
public enum SortDirection {
/**
* 升序。
*/
ASC,
/**
* 降序。
*/
DESC;
/**
* 默认排序方向。
*/
public static final SortDirection DEFAULT = DESC;
}

View File

@@ -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<CompositionSlice> orderComposition = Collections.emptyList();
@ApiModelProperty("金额构成")
private List<CompositionSlice> amountComposition = Collections.emptyList();
@ApiModelProperty("顾客构成")
private List<CompositionSlice> 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;
}
}

View File

@@ -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;
}

View File

@@ -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<ClerkPerformanceTrendPointVo> trend = Collections.emptyList();
@ApiModelProperty("趋势维度")
private String trendDimension = "DAY";
}

View File

@@ -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<String> endOrderTime) {
super.setEndOrderTime(endOrderTime);
}
}

View File

@@ -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<ClerkPerformanceSnapshotVo> rankings = Collections.emptyList();
@ApiModelProperty("榜单总人数")
private Integer total = 0;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<PlayOrderInfoEntity> orderInfoEntities, List<PlayClerkLevelInfoEntity> clerkLevelInfoEntity,
List<PlayPersonnelGroupInfoEntity> groupInfoEntities);
ClerkPerformanceOverviewResponseVo queryOverview(ClerkPerformanceOverviewQueryVo vo);
ClerkPerformanceDetailResponseVo queryDetail(ClerkPerformanceDetailQueryVo vo);
}

View File

@@ -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<PlayOrderInfoEntity> orderInfoEntities, List<PlayClerkLevelInfoEntity> clerkLevelInfoEntities,
List<PlayPersonnelGroupInfoEntity> groupInfoEntities) {
Set<String> 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<PlayClerkUserInfoEntity> 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<String, String> levelNameMap = playClerkLevelInfoService.selectAll().stream().collect(
Collectors.toMap(PlayClerkLevelInfoEntity::getId, PlayClerkLevelInfoEntity::getName, (a, b) -> a));
Map<String, String> groupNameMap = playPersonnelGroupInfoService.selectAll().stream().collect(
Collectors.toMap(PlayPersonnelGroupInfoEntity::getId, PlayPersonnelGroupInfoEntity::getGroupName,
(a, b) -> a));
List<ClerkPerformanceSnapshotVo> snapshots = new ArrayList<>(clerks.size());
for (PlayClerkUserInfoEntity clerk : clerks) {
List<PlayOrderInfoEntity> 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<ClerkPerformanceSnapshotVo> 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<String> accessibleIds =
playPersonnelGroupInfoService.getValidClerkIdList(SecurityUtils.getLoginUser(), null);
if (CollectionUtil.isEmpty(accessibleIds) || !accessibleIds.contains(clerk.getId())) {
throw new ServiceException("无权查看该店员业绩");
}
Map<String, String> levelNameMap = playClerkLevelInfoService.selectAll().stream().collect(
Collectors.toMap(PlayClerkLevelInfoEntity::getId, PlayClerkLevelInfoEntity::getName, (a, b) -> a));
Map<String, String> groupNameMap = playPersonnelGroupInfoService.selectAll().stream().collect(
Collectors.toMap(PlayPersonnelGroupInfoEntity::getId, PlayPersonnelGroupInfoEntity::getGroupName,
(a, b) -> a));
List<PlayOrderInfoEntity> 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<String, String> levelNameMap,
Map<String, String> 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<ClerkPerformanceTrendPointVo> buildTrend(List<PlayOrderInfoEntity> 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<LocalDate, List<PlayOrderInfoEntity>> grouped = orders.stream()
.filter(order -> order.getPurchaserTime() != null)
.collect(Collectors.groupingBy(order -> order.getPurchaserTime().toLocalDate()));
List<ClerkPerformanceTrendPointVo> points = new ArrayList<>();
LocalDate cursor = start;
while (!cursor.isAfter(end)) {
List<PlayOrderInfoEntity> dayOrders = grouped.getOrDefault(cursor, Collections.emptyList());
points.add(buildTrendPoint(cursor, dayOrders));
cursor = cursor.plusDays(1);
}
return points;
}
private List<ClerkPerformanceTrendPointVo> buildEmptyTrend(DateRange range, int trendDays) {
List<ClerkPerformanceTrendPointVo> 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<PlayOrderInfoEntity> 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<ClerkPerformanceDetailCompositionVo.CompositionSlice> 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<ClerkPerformanceDetailCompositionVo.CompositionSlice> 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<ClerkPerformanceDetailCompositionVo.CompositionSlice> 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<ClerkPerformanceSnapshotVo> snapshots, ClerkPerformanceSortField sortField,
SortDirection direction) {
if (CollectionUtil.isEmpty(snapshots)) {
return;
}
Comparator<ClerkPerformanceSnapshotVo> 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<ClerkPerformanceSnapshotVo> snapshots) {
for (int i = 0; i < snapshots.size(); i++) {
snapshots.get(i).setRank(i + 1);
}
}
private ClerkPerformanceOverviewSummaryVo aggregateSummary(List<ClerkPerformanceSnapshotVo> 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<PlayOrderInfoEntity> orders,
Map<String, String> levelNameMap, Map<String, String> 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<String, Integer> 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<PlayClerkUserInfoEntity> loadAccessibleClerks(ClerkPerformanceOverviewQueryVo vo) {
List<String> accessibleIds =
playPersonnelGroupInfoService.getValidClerkIdList(SecurityUtils.getLoginUser(), null);
if (CollectionUtil.isEmpty(accessibleIds)) {
return Collections.emptyList();
}
LambdaQueryWrapper<PlayClerkUserInfoEntity> 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<String> 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;
}
}
}

View File

@@ -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());

View File

@@ -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("管理员取消测试"));
}

View File

@@ -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<ClerkPerformanceOverviewQueryVo> 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<ClerkPerformanceDetailQueryVo> 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());
}
}

View File

@@ -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<PlayClerkUserInfoEntity>) 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<PlayOrderInfoEntity> 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<PlayOrderInfoEntity> 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<PlayOrderInfoEntity> 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;
}
}