|
@@ -0,0 +1,283 @@
|
|
|
|
|
+package com.etotem.cfc.service;
|
|
|
|
|
+
|
|
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
|
|
+import com.etotem.cfc.config.ConfigService;
|
|
|
|
|
+import com.etotem.cfc.entity.ProfitPoolOrder;
|
|
|
|
|
+import com.etotem.cfc.entity.ServiceConfigItem;
|
|
|
|
|
+import com.etotem.cfc.entity.TeamShareRecord;
|
|
|
|
|
+import com.etotem.cfc.mapper.ProfitPoolOrderMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.ServiceConfigItemMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.TeamAwardRecordMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.TeamShareRecordMapper;
|
|
|
|
|
+import org.slf4j.Logger;
|
|
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
|
|
+
|
|
|
|
|
+import javax.annotation.Resource;
|
|
|
|
|
+import java.util.Arrays;
|
|
|
|
|
+import java.util.Date;
|
|
|
|
|
+import java.util.HashSet;
|
|
|
|
|
+import java.util.Set;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 单笔订单利润池计算服务(规格 §2.1 总账模型 + §4.7 杠杆 1 收入管道化 + §8.3 幂等)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>核心职责(T9 收入管道化,能力键 {@code team.profit.pipeline})</b>:</p>
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>分润触发源扩展 {@code membership / subscription / assessment_retest}(§4.7 杠杆 1),
|
|
|
|
|
+ * 续费/复购在推荐关系存续期间持续产生分润;</li>
|
|
|
|
|
+ * <li>按 §2.1 对每笔订单完成利润漏斗核算(S/C/P/O/F/D/R/T + 8 份池 + 团队奖),
|
|
|
|
|
+ * 只扩展触发源、不提高分配比率(§10#5);</li>
|
|
|
|
|
+ * <li>{@code D > 0} 才进入分润流程并生成 {@code team_share_record}(pending),
|
|
|
|
|
+ * {@code D ≤ 0}(低于保本线)整单不分润、仅落 {@code profit_pool_order} 供统计(§9#3);</li>
|
|
|
|
|
+ * <li>重复回调按唯一键 {@code uk_order(order_type, order_id)} 幂等(§8.3),不重复发放;</li>
|
|
|
|
|
+ * <li>未知触发源被拒绝(防误分配)。</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>配置读取:优先 {@link ConfigService}(缓存 + 兜底),未注入时回退
|
|
|
|
|
+ * {@link ServiceConfigItemMapper} 直读,均缺失则用 §7.8 默认比率
|
|
|
|
|
+ * (ops 10% / cf 1% / referral 70% / team 30% / eight 80% / award 20%)。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>规格:docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md v0.7
|
|
|
|
|
+ * §2.1 + §2.2 + §4.7 杠杆 1 + §7.1 + §7.8 + §8.3 + §9#3 + §10#5 + §12-T9。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+@Service
|
|
|
|
|
+public class ProfitPoolService {
|
|
|
|
|
+
|
|
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(ProfitPoolService.class);
|
|
|
|
|
+
|
|
|
|
|
+ /** §7.1 order_type 允许值(§4.7 杠杆 1 扩展 membership/subscription/assessment_retest) */
|
|
|
|
|
+ private static final Set<String> ALLOWED_ORDER_TYPES = new HashSet<>(Arrays.asList(
|
|
|
|
|
+ "product", "service", "assessment", "package",
|
|
|
|
|
+ "membership", "subscription", "assessment_retest"));
|
|
|
|
|
+
|
|
|
|
|
+ // §7.8 默认比率(配置未写入时使用)
|
|
|
|
|
+ private static final double DEFAULT_OPS_RATE = 0.10;
|
|
|
|
|
+ private static final double DEFAULT_CF_COST_RATE = 0.01;
|
|
|
|
|
+ private static final double DEFAULT_REFERRAL_SHARE = 0.70;
|
|
|
|
|
+ private static final double DEFAULT_TEAM_SHARE = 0.30;
|
|
|
|
|
+ private static final double DEFAULT_EIGHT_SHARE_RATIO = 0.80;
|
|
|
|
|
+ private static final double DEFAULT_TEAM_AWARD_RATIO = 0.20;
|
|
|
|
|
+ private static final int DEFAULT_SHARE_TOTAL = 8;
|
|
|
|
|
+
|
|
|
|
|
+ private static final String CFG_OPS_RATE = "referral_profit.ops_rate";
|
|
|
|
|
+ private static final String CFG_CF_COST_RATE = "referral_profit.cf_cost_rate";
|
|
|
|
|
+ private static final String CFG_REFERRAL_SHARE = "referral_profit.referral_share";
|
|
|
|
|
+ private static final String CFG_TEAM_SHARE = "referral_profit.team_share";
|
|
|
|
|
+ private static final String CFG_EIGHT_SHARE_RATIO = "team_profit.eight_share_ratio";
|
|
|
|
|
+ private static final String CFG_TEAM_AWARD_RATIO = "team_profit.team_award_ratio";
|
|
|
|
|
+ private static final String CFG_SHARE_TOTAL = "team_profit.share_total";
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ConfigService configService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ProfitPoolOrderMapper profitPoolOrderMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private TeamShareRecordMapper teamShareRecordMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private TeamAwardRecordMapper teamAwardRecordMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ServiceConfigItemMapper serviceConfigItemMapper;
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 分润结算入口(§12-T2 契约,能力键 {@code team.profit.settle})。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>签名 {@code (Long orderId, String orderType, Long productId, long amountCent, long costCent)}
|
|
|
|
|
+ * 由契约测试 {@code ProfitPoolOrderContractRedTest} 锚定;内部与管道化入口共用核算核心。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+ public ProfitPoolOrder settle(Long orderId, String orderType, Long productId,
|
|
|
|
|
+ long amountCent, long costCent) {
|
|
|
|
|
+ return doSettle(orderId, orderType, productId, amountCent, costCent, null);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 分润结算入口(§4.7 杠杆 1 收入管道化,能力键 {@code team.profit.pipeline})。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>触发源扩展 {@code membership / subscription / assessment_retest};{@code memberId}
|
|
|
|
|
+ * 为订单归属成员(服务提供者/推荐链根),用于生成 {@code team_share_record} 流水。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+ public ProfitPoolOrder settle(long orderId, String orderType, long memberId,
|
|
|
|
|
+ int amountCent, int costCent) {
|
|
|
|
|
+ return doSettle(orderId, orderType, null, amountCent, costCent, memberId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 核算核心:幂等检查 → 触发源校验 → §2.1 利润漏斗 → 落单 → D>0 生成团队分润流水。
|
|
|
|
|
+ */
|
|
|
|
|
+ private ProfitPoolOrder doSettle(Long orderId, String orderType, Long productId,
|
|
|
|
|
+ long amountCent, long costCent, Long memberId) {
|
|
|
|
|
+ // 幂等(§8.3 uk_order):重复回调返回既有记录,不重复发放
|
|
|
|
|
+ ProfitPoolOrder existing = findExisting(orderId, orderType);
|
|
|
|
|
+ if (existing != null) {
|
|
|
|
|
+ return existing;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 未知触发源拒绝(防误分配)
|
|
|
|
|
+ if (orderType == null || !ALLOWED_ORDER_TYPES.contains(orderType)) {
|
|
|
|
|
+ throw new IllegalArgumentException("未知分润触发源: " + orderType);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // §2.1 一般化公式(金额单位:分)
|
|
|
|
|
+ double opsRate = rate(CFG_OPS_RATE, DEFAULT_OPS_RATE);
|
|
|
|
|
+ double cfCostRate = rate(CFG_CF_COST_RATE, DEFAULT_CF_COST_RATE);
|
|
|
|
|
+ double referralShare = rate(CFG_REFERRAL_SHARE, DEFAULT_REFERRAL_SHARE);
|
|
|
|
|
+ double teamShare = rate(CFG_TEAM_SHARE, DEFAULT_TEAM_SHARE);
|
|
|
|
|
+ double eightShareRatio = rate(CFG_EIGHT_SHARE_RATIO, DEFAULT_EIGHT_SHARE_RATIO);
|
|
|
|
|
+ // 团队奖按预算守恒取 T−8份池(8份池占 T×80%、团队奖占 T×20%,§2.1 恒等)
|
|
|
|
|
+
|
|
|
|
|
+ long p = amountCent - costCent;
|
|
|
|
|
+ long o = roundPct(p, opsRate);
|
|
|
|
|
+ long f = roundPct(amountCent, cfCostRate);
|
|
|
|
|
+ long d = p - o - f;
|
|
|
|
|
+
|
|
|
|
|
+ long r;
|
|
|
|
|
+ long t;
|
|
|
|
|
+ long eight;
|
|
|
|
|
+ long award;
|
|
|
|
|
+ if (d <= 0) {
|
|
|
|
|
+ // §2.2/§9#3:D≤0 整单不产生推荐人/团队分润,仅落 profit_pool_order 供统计
|
|
|
|
|
+ r = 0;
|
|
|
|
|
+ t = 0;
|
|
|
|
|
+ eight = 0;
|
|
|
|
|
+ award = 0;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ r = roundPct(d, referralShare);
|
|
|
|
|
+ t = roundPct(d, teamShare);
|
|
|
|
|
+ eight = roundPct(t, eightShareRatio);
|
|
|
|
|
+ // 预算守恒:8 份池 + 团队奖 = T(§2.1,不超 D 预算)
|
|
|
|
|
+ award = t - eight;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ProfitPoolOrder pool = new ProfitPoolOrder();
|
|
|
|
|
+ pool.setOrderId(orderId);
|
|
|
|
|
+ pool.setOrderType(orderType);
|
|
|
|
|
+ pool.setProductId(productId);
|
|
|
|
|
+ pool.setAmountCent((int) amountCent);
|
|
|
|
|
+ pool.setCostCent((int) costCent);
|
|
|
|
|
+ pool.setPlatformRetainCent((int) p);
|
|
|
|
|
+ pool.setOpsCent((int) o);
|
|
|
|
|
+ pool.setCfCostCent((int) f);
|
|
|
|
|
+ pool.setPoolCent((int) d);
|
|
|
|
|
+ pool.setReferralShareCent((int) r);
|
|
|
|
|
+ pool.setTeamShareCent((int) t);
|
|
|
|
|
+ pool.setTeamEightShareCent((int) eight);
|
|
|
|
|
+ pool.setTeamAwardCent((int) award);
|
|
|
|
|
+ pool.setStatus("pending");
|
|
|
|
|
+ pool.setCreatedAt(new Date());
|
|
|
|
|
+ profitPoolOrderMapper.insert(pool);
|
|
|
|
|
+
|
|
|
|
|
+ if (d > 0) {
|
|
|
|
|
+ // §8.1:D>0 生成团队 8 份分佣流水(pending,冷静期后 settled)
|
|
|
|
|
+ genTeamShareRecord(pool, memberId);
|
|
|
|
|
+ }
|
|
|
|
|
+ return pool;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 幂等查询:uk_order(order_type, order_id) 既有记录。 */
|
|
|
|
|
+ private ProfitPoolOrder findExisting(Long orderId, String orderType) {
|
|
|
|
|
+ if (profitPoolOrderMapper == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ return profitPoolOrderMapper.selectOne(new LambdaQueryWrapper<ProfitPoolOrder>()
|
|
|
|
|
+ .eq(ProfitPoolOrder::getOrderType, orderType)
|
|
|
|
|
+ .eq(ProfitPoolOrder::getOrderId, orderId)
|
|
|
|
|
+ .last("LIMIT 1"));
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("查询 profit_pool_order 幂等失败 orderType={} orderId={}: {}",
|
|
|
|
|
+ orderType, orderId, e.getMessage());
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 生成团队 8 份分佣流水(§7.3)。管道化(T9)阶段以订单归属成员为第 1 层基础记录,
|
|
|
|
|
+ * 星级/份数/金额的完整分配(§4.2 星级份数 + §4.4 团队奖)由团队分润服务按团队树与
|
|
|
|
|
+ * 星级快照落地(T2),此处仅落 pending 占位流水并保留 8 份池金额。
|
|
|
|
|
+ */
|
|
|
|
|
+ private void genTeamShareRecord(ProfitPoolOrder pool, Long memberId) {
|
|
|
|
|
+ if (teamShareRecordMapper == null) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ Long uid = memberId != null ? memberId : 0L;
|
|
|
|
|
+ int shareTotal = intRate(CFG_SHARE_TOTAL, DEFAULT_SHARE_TOTAL);
|
|
|
|
|
+ int shareTotalSafe = Math.max(1, shareTotal);
|
|
|
|
|
+ TeamShareRecord record = new TeamShareRecord();
|
|
|
|
|
+ record.setOrderId(pool.getOrderId());
|
|
|
|
|
+ record.setOrderType(pool.getOrderType());
|
|
|
|
|
+ record.setPoolId(pool.getId());
|
|
|
|
|
+ record.setProviderUserId(uid);
|
|
|
|
|
+ record.setRecipientUserId(uid);
|
|
|
|
|
+ record.setLayer(1);
|
|
|
|
|
+ record.setShares(1);
|
|
|
|
|
+ record.setShareCent(pool.getTeamEightShareCent() / shareTotalSafe);
|
|
|
|
|
+ record.setStatus("pending");
|
|
|
|
|
+ record.setCreatedAt(new Date());
|
|
|
|
|
+ teamShareRecordMapper.insert(record);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** §2.1 金额取整口径(逐项 round,HALF_UP 等价)。 */
|
|
|
|
|
+ private long roundPct(long base, double rate) {
|
|
|
|
|
+ return Math.round(base * rate);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 读取 double 比率:ConfigService 优先,回退 service_config 直读,再回退默认。 */
|
|
|
|
|
+ private double rate(String key, double def) {
|
|
|
|
|
+ if (configService != null) {
|
|
|
|
|
+ return configService.getDoubleConfig(key, def);
|
|
|
|
|
+ }
|
|
|
|
|
+ return readConfigDouble(key, def);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 读取 int 配置:ConfigService 优先,回退 service_config 直读,再回退默认。 */
|
|
|
|
|
+ private int intRate(String key, int def) {
|
|
|
|
|
+ if (configService != null) {
|
|
|
|
|
+ return configService.getIntConfig(key, def);
|
|
|
|
|
+ }
|
|
|
|
|
+ return readConfigInt(key, def);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private double readConfigDouble(String key, double def) {
|
|
|
|
|
+ String v = readConfigValue(key);
|
|
|
|
|
+ if (v == null) {
|
|
|
|
|
+ return def;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ return Double.parseDouble(v.trim());
|
|
|
|
|
+ } catch (NumberFormatException e) {
|
|
|
|
|
+ return def;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private int readConfigInt(String key, int def) {
|
|
|
|
|
+ String v = readConfigValue(key);
|
|
|
|
|
+ if (v == null) {
|
|
|
|
|
+ return def;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ return Integer.parseInt(v.trim());
|
|
|
|
|
+ } catch (NumberFormatException e) {
|
|
|
|
|
+ return def;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String readConfigValue(String key) {
|
|
|
|
|
+ if (serviceConfigItemMapper == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ ServiceConfigItem item = serviceConfigItemMapper.selectOne(
|
|
|
|
|
+ new LambdaQueryWrapper<ServiceConfigItem>()
|
|
|
|
|
+ .eq(ServiceConfigItem::getConfigKey, key)
|
|
|
|
|
+ .last("LIMIT 1"));
|
|
|
|
|
+ return item != null ? item.getConfigValue() : null;
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("读取团队分润配置失败 key={}: {}", key, e.getMessage());
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|