فهرست منبع

test(distribution): T37 负责人结算单元测试 + 管理端负责人可维护

- DistributionServiceUnitTest 覆盖 6 场景:ownerId 唯一结算/金额=佣金总额/旧字段置null、
  ownerId 为空降级取树 root 节点、无负责人走无团队结算、体系禁用与订单金额不合法错误分支、
  updateSystem 透传 ownerId
- VirtualTeamList 编辑/创建弹窗新增「负责人」输入(用户ID,留空由根节点兜底),负责人可维护
- 运行方式: mvn test -Dtest=DistributionServiceUnitTest -DskipTests=false

spec: docs/superpowers/specs/2026-11-01-team-system-design.md (T37)
Sisyphus 2 هفته پیش
والد
کامیت
2ac0afd5e8

+ 190 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/DistributionServiceUnitTest.java

@@ -0,0 +1,190 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.DistributionRelation;
+import com.etotem.cfc.entity.DistributionSystem;
+import com.etotem.cfc.mapper.DistributionRelationMapper;
+import com.etotem.cfc.mapper.DistributionSystemMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.DistributionService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * T37 销售体系结算唯一负责人口径(gap C2)单元测试。
+ *
+ * <p>覆盖:ownerId 作为唯一结算对象(settleToUserId)、金额=佣金总额(settleAmount=totalCommission)、
+ * ownerId 为空降级取体系树 root 节点用户、无负责人走「无团队结算」分支、
+ * 旧 70/30 拆分字段一期置 null、体系不可用/订单金额不合法错误分支、updateSystem 透传 ownerId。</p>
+ */
+class DistributionServiceUnitTest {
+
+    private DistributionService service;
+    private DistributionSystemMapper systemMapper;
+    private DistributionRelationMapper relationMapper;
+    private UserMapper userMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new DistributionService();
+        systemMapper = mock(DistributionSystemMapper.class);
+        relationMapper = mock(DistributionRelationMapper.class);
+        userMapper = mock(UserMapper.class);
+        inject(service, "distributionSystemMapper", systemMapper);
+        inject(service, "distributionRelationMapper", relationMapper);
+        inject(service, "userMapper", userMapper);
+    }
+
+    /** 系统启用 + ownerId 非空:结算对象 = ownerId,金额 = 佣金总额,旧字段置 null,不再查关系表 */
+    @Test
+    void calculateCommission_ownerId非空_结算给负责人且金额为佣金总额() {
+        DistributionSystem system = new DistributionSystem();
+        system.setId(1L);
+        system.setOwnerId(100L);
+        system.setProfitRate(50); // 千分比
+        system.setEnabled(true);
+        when(systemMapper.selectById(1L)).thenReturn(system);
+
+        // 订单 20000 分,佣金 = 20000*50/1000 = 1000 分
+        Result<Map<String, Object>> result = service.calculateCommission(1L, 999L, 20000);
+
+        assertEquals(200, result.getCode());
+        Map<String, Object> data = result.getData();
+        assertEquals(1000, data.get("totalCommission"));
+        assertEquals(100L, data.get("settleToUserId"));
+        assertEquals(1000, data.get("settleAmount"));
+        // 旧 70/30 拆分字段一期置 null
+        assertNull(data.get("level1Amount"));
+        assertNull(data.get("level1User"));
+        assertNull(data.get("level2Amount"));
+        assertNull(data.get("level2User"));
+        assertNull(data.get("platformAmount"));
+        // ownerId 存在时不再查询关系表
+        verify(relationMapper, never()).selectList(any());
+    }
+
+    /** ownerId 为空:降级取体系树 root 节点(parentId=null)用户作为结算对象 */
+    @Test
+    void calculateCommission_ownerId为空_降级取树root节点用户() {
+        DistributionSystem system = new DistributionSystem();
+        system.setId(1L);
+        system.setOwnerId(null);
+        system.setProfitRate(50);
+        system.setEnabled(true);
+        when(systemMapper.selectById(1L)).thenReturn(system);
+
+        DistributionRelation root = new DistributionRelation();
+        root.setId(10L);
+        root.setSystemId(1L);
+        root.setUserId(200L);
+        root.setParentId(null);
+        root.setDepth(0);
+        when(relationMapper.selectList(any())).thenReturn(Collections.singletonList(root));
+
+        Result<Map<String, Object>> result = service.calculateCommission(1L, 999L, 20000);
+
+        assertEquals(200, result.getCode());
+        Map<String, Object> data = result.getData();
+        assertEquals(1000, data.get("totalCommission"));
+        assertEquals(200L, data.get("settleToUserId"));
+        assertEquals(1000, data.get("settleAmount"));
+    }
+
+    /** ownerId 为空且树无 root 节点:走「无团队结算」分支(settleToUserId=null,settleAmount=0) */
+    @Test
+    void calculateCommission_无负责人_走无团队结算分支() {
+        DistributionSystem system = new DistributionSystem();
+        system.setId(1L);
+        system.setOwnerId(null);
+        system.setProfitRate(50);
+        system.setEnabled(true);
+        when(systemMapper.selectById(1L)).thenReturn(system);
+        when(relationMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        Result<Map<String, Object>> result = service.calculateCommission(1L, 999L, 20000);
+
+        assertEquals(200, result.getCode());
+        Map<String, Object> data = result.getData();
+        assertEquals(1000, data.get("totalCommission"));
+        assertNull(data.get("settleToUserId"));
+        assertEquals(0, data.get("settleAmount"));
+    }
+
+    /** 体系禁用:返回「销售体系不可用」,不结算 */
+    @Test
+    void calculateCommission_体系禁用_返回不可用() {
+        DistributionSystem system = new DistributionSystem();
+        system.setId(1L);
+        system.setEnabled(false);
+        when(systemMapper.selectById(1L)).thenReturn(system);
+
+        Result<Map<String, Object>> result = service.calculateCommission(1L, 999L, 20000);
+
+        assertEquals(500, result.getCode());
+        assertEquals("销售体系不可用", result.getMessage());
+        verify(relationMapper, never()).selectList(any());
+    }
+
+    /** 订单金额为空:返回「订单金额不合法」,不结算 */
+    @Test
+    void calculateCommission_订单金额为空_返回不合法() {
+        DistributionSystem system = new DistributionSystem();
+        system.setId(1L);
+        system.setEnabled(true);
+        when(systemMapper.selectById(1L)).thenReturn(system);
+
+        Result<Map<String, Object>> result = service.calculateCommission(1L, 999L, null);
+
+        assertEquals(500, result.getCode());
+        assertEquals("订单金额不合法", result.getMessage());
+    }
+
+    /** updateSystem 透传 ownerId:传入 ownerId 时更新既有体系负责人 */
+    @Test
+    void updateSystem_传入ownerId_更新负责人() {
+        DistributionSystem existing = new DistributionSystem();
+        existing.setId(1L);
+        existing.setName("旧体系");
+        existing.setOwnerId(null);
+        existing.setProfitRate(30);
+        when(systemMapper.selectById(1L)).thenReturn(existing);
+        when(systemMapper.updateById(any(DistributionSystem.class))).thenReturn(1);
+
+        DistributionSystem incoming = new DistributionSystem();
+        incoming.setId(1L);
+        incoming.setOwnerId(555L); // 仅改负责人
+
+        Result<String> result = service.updateSystem(incoming);
+
+        assertEquals(200, result.getCode());
+        ArgumentCaptor<DistributionSystem> captor = ArgumentCaptor.forClass(DistributionSystem.class);
+        verify(systemMapper).updateById(captor.capture());
+        DistributionSystem updated = captor.getValue();
+        assertEquals(555L, updated.getOwnerId());
+        assertEquals("旧体系", updated.getName()); // 未传入的字段保持不变
+    }
+
+    private static void inject(Object target, String fieldName, Object value) throws Exception {
+        Class<?> type = target.getClass();
+        while (type != null) {
+            try {
+                Field f = type.getDeclaredField(fieldName);
+                f.setAccessible(true);
+                f.set(target, value);
+                return;
+            } catch (NoSuchFieldException e) {
+                type = type.getSuperclass();
+            }
+        }
+        throw new NoSuchFieldException(fieldName);
+    }
+}

+ 6 - 2
cfc-web/src/views/admin/VirtualTeamList.vue

@@ -102,6 +102,10 @@
           <el-input-number v-model="form.profitRate" :min="0" :max="100" :precision="1" :step="0.5" class="w-200"></el-input-number>
           <span class="suffix-hint">%</span>
         </el-form-item>
+        <el-form-item label="负责人" prop="ownerId">
+          <el-input-number v-model="form.ownerId" :min="0" :step="1" :step-strictly="true" controls-position="right" class="w-200"></el-input-number>
+          <span class="suffix-hint">唯一结算负责人用户ID,留空则由体系根节点成员兜底</span>
+        </el-form-item>
       </el-form>
       <div slot="footer">
         <el-button @click="dialogVisible = false">取消</el-button>
@@ -175,7 +179,7 @@ export default {
   methods: {
     loadList() { this.loadData() },
     getEmptyForm() {
-      return { id: null, name: '', description: '', profitRate: 0 }
+      return { id: null, name: '', description: '', profitRate: 0, ownerId: null }
     },
 
     handleSearch() {
@@ -204,7 +208,7 @@ export default {
     },
     handleEdit(row) {
       this.isEdit = true
-      this.form = { id: row.id, name: row.name, description: row.description, profitRate: row.profitRate || 0 }
+      this.form = { id: row.id, name: row.name, description: row.description, profitRate: row.profitRate || 0, ownerId: row.ownerId || null }
       this.dialogVisible = true
       this.$nextTick(function() {
         if (this.$refs.teamForm) this.$refs.teamForm.clearValidate()