|
|
@@ -0,0 +1,190 @@
|
|
|
+package com.etotem.num.service;
|
|
|
+
|
|
|
+import com.etotem.num.entity.MonthlyBonusRecord;
|
|
|
+import com.etotem.num.entity.Order;
|
|
|
+import com.etotem.num.entity.User;
|
|
|
+import com.etotem.num.repository.MonthlyBonusRecordRepository;
|
|
|
+import com.etotem.num.repository.OrderRepository;
|
|
|
+import com.etotem.num.repository.UserRepository;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.scheduling.annotation.Scheduled;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.transaction.annotation.Transactional;
|
|
|
+
|
|
|
+import java.time.LocalDate;
|
|
|
+import java.time.LocalDateTime;
|
|
|
+import java.time.LocalTime;
|
|
|
+import java.time.YearMonth;
|
|
|
+import java.time.format.DateTimeFormatter;
|
|
|
+import java.util.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 能量师月度分红结算服务(资金池竞争制)。
|
|
|
+ *
|
|
|
+ * 每月 1 日 00:00 执行:
|
|
|
+ * 1. 计算平台月总收入(实付金额-退款)
|
|
|
+ * 2. 划出个人分红池(5%)和团队分红池(5%)
|
|
|
+ * 3. 筛选当月有直接销售的活跃能量师
|
|
|
+ * 4. 计算各能量师的个人直接销售额和团队销售额(递归,遇无销售截断)
|
|
|
+ * 5. 按占比分配两个资金池
|
|
|
+ * 6. 写入 monthly_bonus_records
|
|
|
+ */
|
|
|
+@Service
|
|
|
+public class BonusSettlementService {
|
|
|
+
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(BonusSettlementService.class);
|
|
|
+ private static final DateTimeFormatter YEAR_MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM");
|
|
|
+
|
|
|
+ private final OrderRepository orderRepository;
|
|
|
+ private final UserRepository userRepository;
|
|
|
+ private final MonthlyBonusRecordRepository bonusRecordRepository;
|
|
|
+
|
|
|
+ public BonusSettlementService(OrderRepository orderRepository,
|
|
|
+ UserRepository userRepository,
|
|
|
+ MonthlyBonusRecordRepository bonusRecordRepository) {
|
|
|
+ this.orderRepository = orderRepository;
|
|
|
+ this.userRepository = userRepository;
|
|
|
+ this.bonusRecordRepository = bonusRecordRepository;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Scheduled(cron = "0 0 1 1 * ?") // 每月1日 00:00
|
|
|
+ @Transactional
|
|
|
+ public void settleMonthlyBonus() {
|
|
|
+ YearMonth lastMonth = YearMonth.now().minusMonths(1);
|
|
|
+ String yearMonth = lastMonth.format(YEAR_MONTH_FORMAT);
|
|
|
+ log.info("[Bonus] 开始结算 {} 月度分红", yearMonth);
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 1. 计算平台当月总收入
|
|
|
+ LocalDateTime startDateTime = lastMonth.atDay(1).atStartOfDay();
|
|
|
+ LocalDateTime endDateTime = lastMonth.atEndOfMonth().atTime(LocalTime.MAX);
|
|
|
+
|
|
|
+ List<Order> paidOrders = orderRepository.findByStatusAndPaidAtBetween(
|
|
|
+ "paid", startDateTime, endDateTime);
|
|
|
+ long totalRevenue = paidOrders.stream()
|
|
|
+ .mapToLong(o -> o.getTotalFee() != null ? o.getTotalFee() : 0)
|
|
|
+ .sum();
|
|
|
+
|
|
|
+ List<Order> refundedOrders = orderRepository.findByStatusAndPaidAtBetween(
|
|
|
+ "refunded", startDateTime, endDateTime);
|
|
|
+ long totalRefunded = refundedOrders.stream()
|
|
|
+ .mapToLong(o -> o.getTotalFee() != null ? o.getTotalFee() : 0)
|
|
|
+ .sum();
|
|
|
+
|
|
|
+ long netRevenue = totalRevenue - totalRefunded;
|
|
|
+ if (netRevenue <= 0) {
|
|
|
+ log.info("[Bonus] {} 月无净收入,跳过结算", yearMonth);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 计算两个资金池(各5%)
|
|
|
+ long personalPool = (long) (netRevenue * 0.05);
|
|
|
+ long teamPool = (long) (netRevenue * 0.05);
|
|
|
+ log.info("[Bonus] 平台净收入={},个人池={},团队池={}", netRevenue, personalPool, teamPool);
|
|
|
+
|
|
|
+ // 3. 查询所有能量师
|
|
|
+ List<User> practitioners = userRepository.findByVipType("practitioner");
|
|
|
+ if (practitioners.isEmpty()) {
|
|
|
+ log.info("[Bonus] 无能量师,跳过结算");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4. 计算每个能量师的当月直接销售额,筛选活跃能量师
|
|
|
+ Map<Long, Long> personalSalesMap = new HashMap<>();
|
|
|
+ List<User> activePractitioners = new ArrayList<>();
|
|
|
+ for (User p : practitioners) {
|
|
|
+ long personalSales = getMonthlySales(p.getId(), startDateTime, endDateTime);
|
|
|
+ if (personalSales > 0) {
|
|
|
+ personalSalesMap.put(p.getId(), personalSales);
|
|
|
+ activePractitioners.add(p);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (activePractitioners.isEmpty()) {
|
|
|
+ log.info("[Bonus] 无活跃能量师,跳过结算");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 5. 计算每个活跃能量师的团队销售额(递归)
|
|
|
+ Map<Long, Long> teamSalesMap = new HashMap<>();
|
|
|
+ for (User p : activePractitioners) {
|
|
|
+ long teamSales = calcTeamSalesRecursive(p.getId(), startDateTime, endDateTime, personalSalesMap);
|
|
|
+ teamSalesMap.put(p.getId(), teamSales);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 6. 计算总分母
|
|
|
+ long totalPersonalSales = personalSalesMap.values().stream().mapToLong(Long::longValue).sum();
|
|
|
+ long totalTeamSales = teamSalesMap.values().stream().mapToLong(Long::longValue).sum();
|
|
|
+
|
|
|
+ // 7. 按占比分配,写入记录
|
|
|
+ for (User p : activePractitioners) {
|
|
|
+ Long personalSales = personalSalesMap.getOrDefault(p.getId(), 0L);
|
|
|
+ Long teamSales = teamSalesMap.getOrDefault(p.getId(), 0L);
|
|
|
+
|
|
|
+ int personalPct = totalPersonalSales > 0 ? (int) (personalSales * 10000 / totalPersonalSales) : 0;
|
|
|
+ int teamPct = totalTeamSales > 0 ? (int) (teamSales * 10000 / totalTeamSales) : 0;
|
|
|
+
|
|
|
+ long personalBonus = (long) (personalPct / 10000.0 * personalPool);
|
|
|
+ long teamBonus = (long) (teamPct / 10000.0 * teamPool);
|
|
|
+ long totalBonus = personalBonus + teamBonus;
|
|
|
+
|
|
|
+ MonthlyBonusRecord record = bonusRecordRepository
|
|
|
+ .findByEnergyMasterIdAndYearMonth(p.getId(), yearMonth)
|
|
|
+ .orElse(new MonthlyBonusRecord());
|
|
|
+
|
|
|
+ record.setEnergyMasterId(p.getId());
|
|
|
+ record.setYearMonth(yearMonth);
|
|
|
+ record.setPlatformRevenueFen(netRevenue);
|
|
|
+ record.setPersonalPoolFen(personalPool);
|
|
|
+ record.setTeamPoolFen(teamPool);
|
|
|
+ record.setPersonalSalesFen(personalSales);
|
|
|
+ record.setTotalPersonalSalesFen(totalPersonalSales);
|
|
|
+ record.setPersonalSharePct(personalPct);
|
|
|
+ record.setTeamSalesFen(teamSales);
|
|
|
+ record.setTotalTeamSalesFen(totalTeamSales);
|
|
|
+ record.setTeamSharePct(teamPct);
|
|
|
+ record.setPersonalBonusFen(personalBonus);
|
|
|
+ record.setTeamBonusFen(teamBonus);
|
|
|
+ record.setTotalBonusFen(totalBonus);
|
|
|
+ record.setQualified(true);
|
|
|
+ record.setStatus("settled");
|
|
|
+ record.setSettledAt(LocalDateTime.now());
|
|
|
+
|
|
|
+ bonusRecordRepository.save(record);
|
|
|
+ log.info("[Bonus] 能量师 {} 分红: 个人={}, 团队={}, 合计={}",
|
|
|
+ p.getId(), personalBonus, teamBonus, totalBonus);
|
|
|
+ }
|
|
|
+
|
|
|
+ log.info("[Bonus] {} 月度分红结算完成,共 {} 位能量师参与", yearMonth, activePractitioners.size());
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[Bonus] {} 月度分红结算异常", yearMonth, e);
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private long getMonthlySales(Long userId, LocalDateTime startDateTime, LocalDateTime endDateTime) {
|
|
|
+ List<Order> orders = orderRepository.findByUserIdAndStatusAndPaidAtBetween(
|
|
|
+ userId, "paid", startDateTime, endDateTime);
|
|
|
+ return orders.stream()
|
|
|
+ .mapToLong(o -> o.getTotalFee() != null ? o.getTotalFee() : 0)
|
|
|
+ .sum();
|
|
|
+ }
|
|
|
+
|
|
|
+ private long calcTeamSalesRecursive(Long userId, LocalDateTime startDateTime, LocalDateTime endDateTime,
|
|
|
+ Map<Long, Long> personalSalesMap) {
|
|
|
+ // 自身直接销售额
|
|
|
+ long selfSales = personalSalesMap.getOrDefault(userId, 0L);
|
|
|
+
|
|
|
+ // 团队销售额 = 自身直接销售 + 下级团队销售(仅当月有销售的下级才递归)
|
|
|
+ List<User> directReferrals = userRepository.findByInvitedBy(userId);
|
|
|
+ long teamSales = selfSales;
|
|
|
+ for (User referral : directReferrals) {
|
|
|
+ Long referralPersonalSales = personalSalesMap.get(referral.getId());
|
|
|
+ if (referralPersonalSales != null && referralPersonalSales > 0) {
|
|
|
+ // 只有当月有销售的下级才计入团队销售,并递归
|
|
|
+ teamSales += calcTeamSalesRecursive(referral.getId(), startDateTime, endDateTime, personalSalesMap);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return teamSales;
|
|
|
+ }
|
|
|
+}
|