浏览代码

Merge task/S-cfc-407 into cfclub (cf.teamshare.weightedShare 团队池档位权重加权瓜分实现使绿 S-cfc-407)

# Conflicts:
#	cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java
Agnes Agent 3 小时之前
父节点
当前提交
0cbcc594e1

+ 60 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/CfTeamPoolOrder.java

@@ -0,0 +1,60 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 团队分润池整单(成本 × T%)。
+ *
+ * <p>设计:docs/superpowers/plans/2026-11-14-cf-reward-model-v2-design.md §2.2(迁移463)。
+ * 幂等唯一键 {@code uk_order(order_type, order_id)}(§1.5 三层兜底第一层),重复结算命中唯一键直接返回。</p>
+ *
+ * <p>生命周期(§5):建池 {@code pooled} → 瓜分 {@code share_issued} / 无成员可瓜分 {@code unclaimed}。</p>
+ */
+@Data
+@TableName("cf_team_pool_order")
+public class CfTeamPoolOrder implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 来源订单ID */
+    private Long orderId;
+
+    /** 订单类型 */
+    private String orderType;
+
+    /** 商品ID(非商品订单可空) */
+    private Long productId;
+
+    /** 买家用户ID(users.id) */
+    private Long buyerUserId;
+
+    /** 团队池金额(分) */
+    private Integer poolCent;
+
+    /** 实际采用T(bps) */
+    private Integer tBps;
+
+    /** 命中作用域 GLOBAL/SYSTEM/MINIAPP */
+    private String configScope;
+
+    /** pooled=已建池/share_issued=已瓜分/unclaimed=无成员可瓜分 */
+    private String status;
+
+    /** 备注 */
+    private String remark;
+
+    /** 创建时间 */
+    private Date createdAt;
+
+    /** 结算时间 */
+    private Date settledAt;
+}

+ 56 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/CfTeamPoolShare.java

@@ -0,0 +1,56 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 团队池成员瓜分明细。
+ *
+ * <p>设计:docs/superpowers/plans/2026-11-14-cf-reward-model-v2-design.md §2.3(迁移464)。
+ * 幂等唯一键 {@code uk_pool_member(pool_id, member_id)}(§1.5 三层兜底第二层),
+ * 保证重复结算不重复瓜分。</p>
+ *
+ * <p>{@code weight} 为档位权重(= rate_percent,0 = 无档位跳过);
+ * {@code shareCent} 为分得金额(分,Σ == 池金额,尾差守恒 §8#9)。</p>
+ */
+@Data
+@TableName("cf_team_pool_share")
+public class CfTeamPoolShare implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** cf_team_pool_order.id */
+    private Long poolId;
+
+    /** family_members.id */
+    private Long memberId;
+
+    /** users.id(推荐链成员) */
+    private Long userId;
+
+    /** 推荐层级 1..8(买家上溯) */
+    private Integer layer;
+
+    /** 该成员直推档位率(%) */
+    private Integer ratePercent;
+
+    /** 档位权重(=rate_percent, 0=无档位跳过) */
+    private Integer weight;
+
+    /** 分得金额(分) */
+    private Integer shareCent;
+
+    /** issued=已发放 */
+    private String status;
+
+    /** 创建时间 */
+    private Date createdAt;
+}

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/CfTeamPoolOrderMapper.java

@@ -0,0 +1,14 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.CfTeamPoolOrder;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 团队分润池整单 Mapper(设计 §6 T04,cf_team_pool_order 表,MyBatis-Plus)。
+ *
+ * <p>幂等唯一键 {@code uk_order(order_type, order_id)}(建池幂等,§2.2)。</p>
+ */
+@Mapper
+public interface CfTeamPoolOrderMapper extends BaseMapper<CfTeamPoolOrder> {
+}

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/CfTeamPoolShareMapper.java

@@ -0,0 +1,14 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.CfTeamPoolShare;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 团队池成员瓜分明细 Mapper(设计 §6 T05,cf_team_pool_share 表,MyBatis-Plus)。
+ *
+ * <p>幂等唯一键 {@code uk_pool_member(pool_id, member_id)}(重复结算不重复瓜分,§2.3)。</p>
+ */
+@Mapper
+public interface CfTeamPoolShareMapper extends BaseMapper<CfTeamPoolShare> {
+}

+ 209 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java

@@ -8,6 +8,8 @@ import com.etotem.cfc.entity.CfDirectSalesTier;
 import com.etotem.cfc.entity.CfEarnRule;
 import com.etotem.cfc.entity.CfIndirectSalesTier;
 import com.etotem.cfc.entity.CfPlatformRevenueLog;
+import com.etotem.cfc.entity.CfTeamPoolOrder;
+import com.etotem.cfc.entity.CfTeamPoolShare;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.OctopusEffectKeyPerson;
 import com.etotem.cfc.entity.OctopusEffectRecord;
@@ -21,6 +23,8 @@ import com.etotem.cfc.entity.ProductOrder;
 import com.etotem.cfc.entity.ReferralTree;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.CfPlatformRevenueLogMapper;
+import com.etotem.cfc.mapper.CfTeamPoolOrderMapper;
+import com.etotem.cfc.mapper.CfTeamPoolShareMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.MembershipLevelMapper;
 import com.etotem.cfc.mapper.ProductGiftRuleMapper;
@@ -42,6 +46,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -83,6 +88,9 @@ public class CfCommissionService {
     /** 间接销售奖规则编码(cf_earn_rule.code) */
     public static final String RULE_INDIRECT_SALES = "F03b";
 
+    /** 团队分润池规则编码(cf_earn_rule.code,设计 §3.1 RULE_TEAM_POOL = "F03c") */
+    public static final String RULE_TEAM_POOL = "F03c";
+
     /** F03a 团队口径最大层级(§4.1.1:团队 8 层以内) */
     private static final int TEAM_DEPTH = 8;
 
@@ -123,6 +131,10 @@ public class CfCommissionService {
     @Resource
     private CfEarnRuleService cfEarnRuleService;
     @Resource
+    private CfTeamPoolOrderMapper cfTeamPoolOrderMapper;
+    @Resource
+    private CfTeamPoolShareMapper cfTeamPoolShareMapper;
+    @Resource
     private FamilyMemberMapper familyMemberMapper;
     @Resource
     private PpointConsumptionLogMapper ppointConsumptionLogMapper;
@@ -185,6 +197,203 @@ public class CfCommissionService {
                 orderAmountCent, paidAmountCent, null);
     }
 
+    // ==================== 团队分润池(能力键 cf.teamshare.weightedShare,设计 §5 / §3.1 T05) ====================
+
+    /**
+     * 纯加权瓜分(设计 §5 step 5 算法抽离,能力键 {@code cf.teamshare.weightedShare})。
+     *
+     * <p>池金额按成员档位权重({@code weight = rate_percent},与 F03a 直推档位同一口径)比例分配;
+     * <b>末位成员吸收尾差</b>保证 Σ shares == poolCent(设计 §8#9,金额不蒸发);全部无档位
+     * (totalW ≤ 0)返回空列表(不私发,调用方将池置 unclaimed,§5 step 4)。</p>
+     *
+     * <p>数学校验锚点(设计 §8#1):池 3000 分、权重 {70,50}、totalW=120 →
+     * raw₁ = floor(3000×70/120) = 1750,raw₂ = 3000 − 1750 = 1250(末位补齐),Σ = 3000。</p>
+     *
+     * @param weights  成员档位权重数组(rate_percent,0 = 无档位)
+     * @param poolCent 团队池金额(分)
+     * @return 每位成员分得(分),下标与 weights 对齐;权重全 0(totalW ≤ 0)返回空列表
+     */
+    public List<Integer> allocateTeamPoolShareByWeight(int[] weights, int poolCent) {
+        List<Integer> shares = new ArrayList<>();
+        if (weights == null || weights.length == 0) {
+            return shares;
+        }
+        long totalW = 0;
+        for (int w : weights) {
+            totalW += w;
+        }
+        if (totalW <= 0) {
+            // 无档位 → 无人可分(不私发,调用方将池置 unclaimed)
+            return shares;
+        }
+        long issued = 0;
+        for (int i = 0; i < weights.length; i++) {
+            long raw;
+            if (i == weights.length - 1) {
+                raw = poolCent - issued;                     // 末位吸收尾差(Σ == poolCent)
+            } else {
+                raw = (long) poolCent * weights[i] / totalW; // floor 向下取整
+            }
+            shares.add((int) raw);
+            issued += raw;
+        }
+        return shares;
+    }
+
+    /**
+     * 层级树瓜分流水入口(设计 §3.1 T05 / §5,能力键 {@code cf.teamshare.weightedShare})。
+     *
+     * <p>流程:取买家 referral_tree 上溯成员(跳过同家庭,≤max_depth)→ 档位权重
+     * ({@link CfReferralService#getTeamAnnualPpoints} + {@code matchDirectSalesTier},与 F03a
+     * 同口径)→ {@link #allocateTeamPoolShareByWeight} 加权瓜分(尾差守恒)→
+     * {@code earnByRule(memberId, "F03c", poolId, {amount: raw})}(幂等)→
+     * cf_team_pool_share 幂等落库(uk_pool_member)→ 池状态置 share_issued(无成员可瓜分置 unclaimed)。</p>
+     *
+     * <p>池单按唯一键 {@code uk_order(order_type, order_id)} 读取(T04 {@code settleTeamPool} 建池,
+     * 本方法只负责瓜分既有 pooled 池单;未建池或已瓜分/已回流直接返回,幂等)。</p>
+     *
+     * @param orderId       来源订单ID
+     * @param orderType     订单类型
+     * @param buyerUserId   买家用户ID(users.id,层级树起点)
+     * @param buyerFamilyId 买家家庭ID(同家庭成员跳过不参与瓜分)
+     * @param paidCent      实付金额(分,供池金额计算链路使用)
+     * @param productId     商品ID
+     */
+    @Transactional
+    public void distributeTeamPool(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
+                                   Integer paidCent, Long productId) {
+        if (orderId == null || orderType == null || buyerUserId == null) {
+            log.warn("团队池瓜分入参缺失,跳过: orderId={}, orderType={}, buyerUserId={}",
+                    orderId, orderType, buyerUserId);
+            return;
+        }
+        if (!configService.getBooleanConfig(CfConfigKeys.TEAM_POOL_ENABLED, true)) {
+            log.info("团队池总开关关闭,跳过瓜分: orderType={}, orderId={}", orderType, orderId);
+            return;
+        }
+        CfTeamPoolOrder pool = cfTeamPoolOrderMapper.selectOne(
+                new LambdaQueryWrapper<CfTeamPoolOrder>()
+                        .eq(CfTeamPoolOrder::getOrderType, orderType)
+                        .eq(CfTeamPoolOrder::getOrderId, orderId)
+                        .last("LIMIT 1"));
+        if (pool == null) {
+            // 建池未落地(T04 未接线)或幂等短路 → 无池可瓜分
+            log.info("团队池单不存在,跳过瓜分: orderType={}, orderId={}", orderType, orderId);
+            return;
+        }
+        if (!"pooled".equals(pool.getStatus())
+                || pool.getPoolCent() == null || pool.getPoolCent() <= 0) {
+            log.info("团队池单状态不可瓜分(status={}),跳过: poolId={}", pool.getStatus(), pool.getId());
+            return;
+        }
+        int poolCent = pool.getPoolCent();
+
+        // 取买家上溯层级树成员(跳过同家庭,≤max_depth;与 distributeToReferrers 同源同序)
+        int maxDepth = configService.getIntConfig(CfConfigKeys.TEAM_POOL_MAX_DEPTH, TEAM_DEPTH);
+        List<ReferralTree> referrals = referralTreeMapper.selectList(
+                new LambdaQueryWrapper<ReferralTree>()
+                        .eq(ReferralTree::getMemberId, buyerUserId)
+                        .orderByAsc(ReferralTree::getLevel));
+        Set<Long> processed = new HashSet<>();
+        List<Long> memberUserIds = new ArrayList<>();
+        List<Integer> memberLayers = new ArrayList<>();
+        if (referrals != null) {
+            for (ReferralTree ref : referrals) {
+                if (memberUserIds.size() >= maxDepth) break;
+                Long referrerId = ref.getParentId();
+                if (referrerId == null || processed.contains(referrerId)) continue;
+                processed.add(referrerId);
+                User referrer = userMapper.selectById(referrerId);
+                if (referrer == null) continue;
+                // 同家庭跳过(§1.4):团队成员与买家同家庭则剔除
+                if (buyerFamilyId != null && buyerFamilyId.equals(referrer.getFamilyId())) continue;
+                memberUserIds.add(referrerId);
+                memberLayers.add(ref.getLevel());
+            }
+        }
+        if (memberUserIds.isEmpty()) {
+            markPoolUnclaimed(pool);
+            return;
+        }
+
+        // 档位权重(与 F03a 同口径):团队 8 层内滚动一年 P 点 → 直推档位 ratePercent
+        int[] weights = new int[memberUserIds.size()];
+        for (int i = 0; i < memberUserIds.size(); i++) {
+            int teamPp = cfReferralService.getTeamAnnualPpoints(memberUserIds.get(i), TEAM_DEPTH);
+            CfDirectSalesTier tier = cfReferralService.matchDirectSalesTier(teamPp);
+            weights[i] = (tier == null || tier.getRatePercent() == null) ? 0 : tier.getRatePercent();
+        }
+
+        // 加权瓜分(尾差守恒,Σ == poolCent;权重全 0 返回空 → 无人可分)
+        List<Integer> shares = allocateTeamPoolShareByWeight(weights, poolCent);
+        if (shares.isEmpty()) {
+            markPoolUnclaimed(pool);
+            return;
+        }
+
+        // 发放 + 幂等落库(earnByRule F03c & uk_pool_member 兜底)
+        boolean anyIssued = false;
+        for (int i = 0; i < memberUserIds.size(); i++) {
+            Integer raw = shares.get(i);
+            if (raw == null || raw <= 0) continue;
+            Long userId = memberUserIds.get(i);
+            Long memberId = resolveMemberId(userId);
+            if (memberId == null) {
+                log.warn("团队池瓜分跳过(无家庭成员档案): userId={}, poolId={}", userId, pool.getId());
+                continue;
+            }
+            Map<String, Object> ctx = new HashMap<>();
+            ctx.put("amount", raw);
+            try {
+                cfBalanceService.earnByRule(memberId, RULE_TEAM_POOL, pool.getId(), ctx);
+            } catch (Exception e) {
+                log.error("团队池瓜分发放失败(F03c): memberId={}, poolId={}, amount={}, err={}",
+                        memberId, pool.getId(), raw, e.getMessage());
+                continue;
+            }
+            try {
+                CfTeamPoolShare share = new CfTeamPoolShare();
+                share.setPoolId(pool.getId());
+                share.setMemberId(memberId);
+                share.setUserId(userId);
+                share.setLayer(memberLayers.get(i));
+                share.setRatePercent(weights[i]);
+                share.setWeight(weights[i]);
+                share.setShareCent(raw);
+                share.setStatus("issued");
+                share.setCreatedAt(new Date());
+                cfTeamPoolShareMapper.insert(share);
+            } catch (DuplicateKeyException e) {
+                log.info("团队池成员瓜分明细已存在,跳过重复写入: poolId={}, memberId={}",
+                        pool.getId(), memberId);
+            } catch (Exception e) {
+                log.warn("写团队池瓜分明细失败: poolId={}, memberId={}, err={}",
+                        pool.getId(), memberId, e.getMessage());
+                continue;
+            }
+            anyIssued = true;
+        }
+
+        if (anyIssued) {
+            pool.setStatus("share_issued");
+            pool.setSettledAt(new Date());
+            cfTeamPoolOrderMapper.updateById(pool);
+            log.info("团队池瓜分完成: poolId={}, orderType={}, orderId={}",
+                    pool.getId(), orderType, orderId);
+        }
+    }
+
+    /** 无成员可分 / 全部无档位 → 池置 unclaimed(不私发,§5 step 4) */
+    private void markPoolUnclaimed(CfTeamPoolOrder pool) {
+        try {
+            pool.setStatus("unclaimed");
+            cfTeamPoolOrderMapper.updateById(pool);
+            log.info("团队池无成员可分,置 unclaimed: poolId={}", pool.getId());
+        } catch (Exception e) {
+            log.warn("团队池置 unclaimed 失败: poolId={}, err={}", pool.getId(), e.getMessage());
+        }
+    }
+
     /**
      * 当前消费人返 CF(F02):统一走 {@link CfEarnDispatchService},数量/幂等由 cf_earn_rule 决定。
      */