|
|
@@ -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 决定。
|
|
|
*/
|