|
@@ -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 + " 团队奖比率不得提高");
|
|
|
|
|
+ }
|
|
|
|
|
+}
|