Ver Fonte

merge(task/S-cfc-48): RED 契约级单测并入主链

Agnes Agent há 5 dias atrás
pai
commit
a86101d33f

+ 249 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeamProfitPipelineContractTest.java

@@ -0,0 +1,249 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.etotem.cfc.entity.ProfitPoolOrder;
+import com.etotem.cfc.entity.TeamAwardRecord;
+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 com.etotem.cfc.service.ProfitPoolService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 能力键 {@code team.profit.pipeline}(单元 team)契约级单测(S-cfc-48,TDD-RED 阶段)。
+ *
+ * <p>契约溯源:{@code docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md} v0.7
+ * §12-T9(收入管道化)+ §4.7 杠杆 1(触发源扩展 / 续费复购持续分润)+ §7.1(profit_pool_order
+ * 表)+ §7.8(team_profit 配置)+ §10#5(合规:只扩触发源、不提比率)。</p>
+ *
+ * <p><b>契约范围</b>:{@code profit_pool_order} 分润触发源扩展
+ * {@code membership / subscription / assessment_retest},且推荐关系存续期间
+ * 续费/复购**持续**产生分润(收入管道化)——只扩展触发源、不提高分配比率。</p>
+ *
+ * <p><b>覆盖流</b>(纯 Mockito,无 Spring 上下文 / 无 DB / 无外部服务):</p>
+ * <ul>
+ *   <li><b>正常流</b>:三种新增触发源各自落单并完成 §2.1 池核算(S-C-O-F-D-R-T);续费/复购
+ *       跨订单持续分润;重复回调按唯一键 {@code uk_order(order_type, order_id)} 幂等;</li>
+ *   <li><b>边界流</b>:{@code D ≤ 0}(低于保本线)整单不分润、仅落 profit_pool_order 供统计;
+ *       未知触发源被拒绝(防误分配);</li>
+ *   <li><b>合规/权限流</b>:只扩触发源、不提比率——新增触发源的团队分润比率仍为默认 30%
+ *       (8 份池 80% / 团队奖 20%),不因管道化而放大资金池(§10#5)。</li>
+ * </ul>
+ *
+ * <p><b>数学校验锚点</b>(§2.1 一般化公式,金额单位分):取 {@code S=20000, C=8000}:</p>
+ * <pre>
+ *   P = S - C = 12000
+ *   O = round(P × 10%) = 1200
+ *   F = round(S × 1%)  = 200
+ *   D = P - O - F = 10600
+ *   R = round(D × 70%) = 7420   (推荐人体系,CF 值预算参照)
+ *   T = round(D × 30%) = 3180   (团队体系,货币直接分佣——本次新增,比率不变)
+ *   8份池 = round(T × 80%) = 2544
+ *   团队奖 = round(T × 20%) = 636
+ * </pre>
+ *
+ * <p>⚠ 本类引用的 {@link ProfitPoolService} / {@link ProfitPoolOrder} /
+ * {@link TeamShareRecord} / {@link TeamAwardRecord} 及其 Mapper 为 T9 契约目标类,
+ * 当前 {@code src/main} 尚未落地(真实缺口)——测试先行定义契约,编译失败即 RED 信号,
+ * 由后续 GREEN 实现使本契约通过。</p>
+ */
+class TeamProfitPipelineContractTest {
+
+    /** §7.1 order_type 允许值:T9 扩展后新增三种触发源(§4.7 杠杆 1) */
+    private static final String TYPE_MEMBERSHIP = "membership";
+    private static final String TYPE_SUBSCRIPTION = "subscription";
+    private static final String TYPE_ASSESSMENT_RETEST = "assessment_retest";
+
+    /** §2.1 数学校验锚点 */
+    private static final int S = 20000;
+    private static final int C = 8000;
+    private static final int P = 12000;
+    private static final int O = 1200;
+    private static final int F = 200;
+    private static final int D = 10600;
+    private static final int R = 7420;
+    private static final int T = 3180;
+    private static final int EIGHT_SHARE = 2544;
+    private static final int AWARD = 636;
+
+    private ProfitPoolService service;
+    private ProfitPoolOrderMapper profitPoolOrderMapper;
+    private TeamShareRecordMapper teamShareRecordMapper;
+    private TeamAwardRecordMapper teamAwardRecordMapper;
+    private ServiceConfigItemMapper serviceConfigItemMapper;
+    private AtomicLong idSeq;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new ProfitPoolService();
+        profitPoolOrderMapper = mock(ProfitPoolOrderMapper.class);
+        teamShareRecordMapper = mock(TeamShareRecordMapper.class);
+        teamAwardRecordMapper = mock(TeamAwardRecordMapper.class);
+        serviceConfigItemMapper = mock(ServiceConfigItemMapper.class);
+        idSeq = new AtomicLong(1L);
+
+        inject(service, "profitPoolOrderMapper", profitPoolOrderMapper);
+        inject(service, "teamShareRecordMapper", teamShareRecordMapper);
+        inject(service, "teamAwardRecordMapper", teamAwardRecordMapper);
+        inject(service, "serviceConfigItemMapper", serviceConfigItemMapper);
+
+        // §7.8 配置未显式写入时使用默认值(ops 10% / cf 1% / referral 70% / team 30% / eight 80% / award 20%)
+        when(serviceConfigItemMapper.selectOne(any(Wrapper.class))).thenReturn(null);
+        // 落库自增 id(模拟 MySQL AUTO_INCREMENT)
+        when(profitPoolOrderMapper.insert(any(ProfitPoolOrder.class))).thenAnswer(inv -> {
+            ProfitPoolOrder o = inv.getArgument(0);
+            if (o.getId() == null) {
+                o.setId(idSeq.getAndIncrement());
+            }
+            return 1;
+        });
+    }
+
+    private static void inject(Object target, String field, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    // ==================== 正常流:新增触发源各自落单核算 ====================
+
+    @Test
+    void 正常流_membership触发源_落单核算() {
+        assertSettled(TYPE_MEMBERSHIP, 1001L);
+    }
+
+    @Test
+    void 正常流_subscription触发源_落单核算() {
+        assertSettled(TYPE_SUBSCRIPTION, 1002L);
+    }
+
+    @Test
+    void 正常流_assessmentRetest触发源_落单核算() {
+        assertSettled(TYPE_ASSESSMENT_RETEST, 1003L);
+    }
+
+    /**
+     * 契约断言:某新增触发源订单经 {@link ProfitPoolService#settle} 后,
+     * 必须落一条 {@code profit_pool_order},按 §2.1 完成 S/C/P/O/F/D/R/T 与
+     * 8 份池/团队奖核算,状态 {@code pending}(冷静期后结算),且触发团队持续分润。
+     */
+    private void assertSettled(String orderType, long orderId) {
+        ProfitPoolOrder record = service.settle(orderId, orderType, 5L, S, C);
+
+        assertNotNull(record, orderType + " 结算后必须返回 profit_pool_order 记录");
+        assertEquals(orderType, record.getOrderType(), "触发源类型应原样落库");
+        assertEquals(Long.valueOf(orderId), record.getOrderId(), "来源订单ID应落库");
+        assertEquals(S, record.getAmountCent(), "售价S应落库");
+        assertEquals(C, record.getCostCent(), "平台成本价C应落库");
+        assertEquals(P, record.getPlatformRetainCent(), "P = S - C 应正确");
+        assertEquals(O, record.getOpsCent(), "O = round(P×10%) 应正确");
+        assertEquals(F, record.getCfCostCent(), "F = round(S×1%) 应正确");
+        assertEquals(D, record.getPoolCent(), "D = P - O - F 应正确");
+        assertEquals(R, record.getReferralShareCent(), "R = round(D×70%) 应正确(CF值预算参照)");
+        assertEquals(T, record.getTeamShareCent(), "T = round(D×30%) 应正确(货币直接分佣)");
+        assertEquals(EIGHT_SHARE, record.getTeamEightShareCent(), "8份池 = T×80% 应正确");
+        assertEquals(AWARD, record.getTeamAwardCent(), "团队奖 = T×20% 应正确");
+        assertEquals("pending", record.getStatus(), "结算后应 pending(冷静期后 settled)");
+
+        verify(profitPoolOrderMapper).insert(any(ProfitPoolOrder.class));
+        // 管道化:D>0 必须持续触发团队分润流水(8份池 + 团队奖),保证续费/复购持续产生收益
+        verify(teamShareRecordMapper, atLeastOnce()).insert(any(TeamShareRecord.class));
+    }
+
+    // ==================== 正常流:续费/复购持续分润(管道化) ====================
+
+    @Test
+    void 正常流_续费复购_跨订单持续分润() {
+        // 同一成员两笔不同续费订单(orderId 不同)——推荐关系存续期间续费/复购持续分润
+        ProfitPoolOrder first = service.settle(2001L, TYPE_MEMBERSHIP, 5L, S, C);
+        ProfitPoolOrder second = service.settle(2002L, TYPE_MEMBERSHIP, 5L, S, C);
+
+        assertNotNull(first);
+        assertNotNull(second);
+        assertNotEquals(first.getId(), second.getId(), "两笔续费应各自独立落单(收入管道化)");
+        assertEquals(TYPE_MEMBERSHIP, second.getOrderType());
+        assertEquals(T, second.getTeamShareCent(), "续费复购仍按默认30%持续分润");
+        verify(profitPoolOrderMapper, times(2)).insert(any(ProfitPoolOrder.class));
+    }
+
+    // ==================== 正常流:重复回调幂等(唯一键 uk_order) ====================
+
+    @Test
+    void 正常流_重复回调_唯一键幂等() {
+        ProfitPoolOrder first = service.settle(3001L, TYPE_SUBSCRIPTION, 5L, S, C);
+        assertNotNull(first);
+
+        // 同一 (order_type, order_id) 重复回调 → 命中 uk_order 唯一键 → 返回既有,不重复落库
+        when(profitPoolOrderMapper.selectOne(any(Wrapper.class))).thenReturn(first);
+        ProfitPoolOrder second = service.settle(3001L, TYPE_SUBSCRIPTION, 5L, S, C);
+
+        assertNotNull(second, "幂等:重复回调应返回既有记录而非空");
+        assertEquals(first.getId(), second.getId(), "幂等:重复回调不得新插记录");
+        verify(profitPoolOrderMapper, times(1)).insert(any(ProfitPoolOrder.class));
+    }
+
+    // ==================== 边界流:D ≤ 0(低于保本线) ====================
+
+    @Test
+    void 边界流_D小于等于0_整单不分润仅记录() {
+        // S=9000 < C=10000 → P<0,D≤0:整单不产生推荐人/团队分润,仅落 profit_pool_order 供统计
+        ProfitPoolOrder record = service.settle(4001L, TYPE_ASSESSMENT_RETEST, 5L, 9000, 10000);
+
+        assertNotNull(record, "保本线下仍应落 profit_pool_order 供统计");
+        assertEquals(0, record.getTeamShareCent(), "D≤0 团队体系 T 应为 0");
+        assertEquals(0, record.getTeamEightShareCent(), "D≤0 8份池应为 0");
+        assertEquals(0, record.getTeamAwardCent(), "D≤0 团队奖应为 0");
+        verify(profitPoolOrderMapper).insert(any(ProfitPoolOrder.class));
+        verify(teamShareRecordMapper, never()).insert(any(TeamShareRecord.class));
+        verify(teamAwardRecordMapper, never()).insert(any(TeamAwardRecord.class));
+    }
+
+    // ==================== 边界流:未知触发源拒绝(防误分配) ====================
+
+    @Test
+    void 边界流_未知触发源_拒绝() {
+        assertThrows(IllegalArgumentException.class,
+                () -> service.settle(5001L, "unknown_trigger", 5L, S, C),
+                "未知触发源不得进入分润管道");
+        verify(profitPoolOrderMapper, never()).insert(any(ProfitPoolOrder.class));
+        verify(teamShareRecordMapper, never()).insert(any(TeamShareRecord.class));
+    }
+
+    // ==================== 合规流:只扩触发源、不提比率(§10#5) ====================
+
+    @Test
+    void 合规流_只扩触发源_不提比率() {
+        // 三种新增触发源:T 恒为 round(D×30%)、8份池恒为 T×80%、团队奖恒为 T×20% —— 比率不因管道化放大
+        assertRatioUnchanged(TYPE_MEMBERSHIP);
+        assertRatioUnchanged(TYPE_SUBSCRIPTION);
+        assertRatioUnchanged(TYPE_ASSESSMENT_RETEST);
+    }
+
+    private void assertRatioUnchanged(String orderType) {
+        ProfitPoolOrder record = service.settle(6000L + D, orderType, 5L, S, C);
+        assertNotNull(record);
+        assertEquals(D, record.getPoolCent(), "D 与基准一致");
+        // 不提高比率:团队体系仍 30%、8份池仍 80%、团队奖仍 20%(与基准完全一致)
+        assertEquals(T, record.getTeamShareCent(), orderType + " 团队分润比率不得提高");
+        assertEquals(EIGHT_SHARE, record.getTeamEightShareCent(), orderType + " 8份池比率不得提高");
+        assertEquals(AWARD, record.getTeamAwardCent(), orderType + " 团队奖比率不得提高");
+    }
+}