Просмотр исходного кода

Merge remote-tracking branch 'origin/cfclub' into cfclub

Sisyphus 1 час назад
Родитель
Сommit
2fd012515b

+ 313 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/SupplierAfterSalesHandleContractTest.java

@@ -0,0 +1,313 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.AfterSalesRequest;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.mapper.AfterSalesRequestMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.service.AfterSalesService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测(TDD-RED,S-cfc-240):能力键 {@code supplier.afterSales.handle}
+ * (商品服务商 · 售后/退款处理 SC-S-06)。
+ *
+ * <p><b>需求/设计溯源</b>:
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} <b>SC-S-06</b>(售后/退款处理:收售后 → 同意/拒 → 退款,
+ *       边界「重复退款拦截」);映射见 {@code docs/tdd/orchestration/sc-key-map.md}(S-cfc-240)。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} <b>§4.8</b> 商品/订单 + <b>§4.60.12</b>
+ *       <b>AdminAfterSalesController</b>{@code /api/admin/shop/after-sales}(list/detail/review/complete)+
+ *       <b>AdminProductOrderController</b>{@code /api/admin/product/order}(refund/approve|reject)。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(src/main 逐类核实,2026-11-13 HEAD=3cdb534e7)</b>:契约宿主
+ * {@code AdminAfterSalesController}/{@code AdminProductOrderController}/{@code AfterSalesService} 均已合入主链
+ * (861f463d6 feat: add AfterSales module),端点骨架齐备;但 <b>契约行为未落地</b>:
+ * <ul>
+ *   <li><b>RED-1 重复退款拦截缺失</b>:{@code AfterSalesService.review(id, "approve", ...)} 未校验订单退款状态——
+ *       订单已 {@code refunded}/{@code refundStatus=2} 仍可再次审核通过(SC-S-06 边界「重复退款拦截」);</li>
+ *   <li><b>RED-2 空转/假完成缺失</b>:{@code AfterSalesService.complete(id)} 未校验订单退款已执行
+ *       ({@code status=refunded}/{@code refundStatus=2})即标记 {@code completed}——退款未执行也能"完成";</li>
+ *   <li><b>RED-3 行级隔离缺失</b>:AdminAfterSalesController.list 未按供应商作用域过滤(对比 AdminProductOrderController.list
+ *       的 B-5 行级隔离 {@code AdminRoles.isSupplierOnly → supplierUserId}),AfterSalesService 亦无按
+ *       {@code order.supplierId/supplySystemId} 过滤的重载——纯供应商可见/可处理全部商户售后;</li>
+ *   <li><b>RED-4 角色守卫缺失</b>:list/detail/review/complete 均未声明 {@code @RequestAttribute("roles")}
+ *       (JwtInterceptor 已注入 roles,B-142 双层防线约定 controller 层自身须角色守卫),非后台角色可越权处理售后。</li>
+ * </ul>
+ * 上述行为缺失处断言失败(红)=RED 完成;GREEN 阶段据此落地行为。
+ *
+ * <p><b>测试风格</b>:与同批 RED 契约测试({@code AfterSalesScS06ContractTest}/
+ * {@code AdminSupplierManageContractTest})一致——纯 JUnit5 + Mockito + 反射,无 Spring 上下文 / 无 DB / 无外部服务。
+ * 已落地骨架保留绿证锚定断言(锚定-1/2),契约缺失行为断言红。</p>
+ */
+class SupplierAfterSalesHandleContractTest {
+
+    /** 设计载体:管理端-商城售后(API_REFERENCE §4.8 / §4.60.12,SC-S-06) */
+    private static final String ADMIN_AFTER_SALES_CTRL =
+            "com.etotem.cfc.controller.admin.AdminAfterSalesController";
+
+    /** 设计载体:管理端-订单退款人工审批(API_REFERENCE §4.60.12,SC-S-06/SC-A-12) */
+    private static final String ADMIN_PRODUCT_ORDER_CTRL =
+            "com.etotem.cfc.controller.admin.AdminProductOrderController";
+
+    /** 设计载体:售后业务服务 */
+    private static final String AFTER_SALES_SERVICE = "com.etotem.cfc.service.AfterSalesService";
+
+    private AfterSalesService service;
+    private AfterSalesRequestMapper requestMapper;
+    private ProductOrderMapper orderMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new AfterSalesService();
+        requestMapper = mock(AfterSalesRequestMapper.class);
+        orderMapper = mock(ProductOrderMapper.class);
+
+        inject(service, "afterSalesRequestMapper", requestMapper);
+        inject(service, "productOrderMapper", orderMapper);
+    }
+
+    // ==================== 绿证锚定(契约宿主骨架已合入主链) ====================
+
+    @Test
+    @DisplayName("锚定-1:AdminAfterSalesController 存在且暴露 /api/admin/shop/after-sales list/detail/review/complete")
+    void anchor_adminAfterSalesControllerExists() {
+        Class<?> ctrl = loadClass(ADMIN_AFTER_SALES_CTRL);
+        assertTrue(ctrl != null, "[supplier.afterSales.handle] 契约宿主 AdminAfterSalesController 未实现(实现未落地)。");
+
+        assertTrue(hasMapping(ctrl, "/api/admin/shop/after-sales"),
+                "类级 @RequestMapping 应为 /api/admin/shop/after-sales(API_REFERENCE §4.60.12)。");
+
+        assertTrue(hasPublicMethod(ctrl, "list") && hasPublicMethod(ctrl, "detail")
+                        && hasPublicMethod(ctrl, "review") && hasPublicMethod(ctrl, "complete"),
+                "合同端点 list/detail/review/complete 必须齐全(SC-S-06 售后处理载体)。");
+    }
+
+    @Test
+    @DisplayName("锚定-2:AdminProductOrderController 存在且暴露 refund/approve + refund/reject")
+    void anchor_adminProductOrderControllerExists() {
+        Class<?> ctrl = loadClass(ADMIN_PRODUCT_ORDER_CTRL);
+        assertTrue(ctrl != null, "[supplier.afterSales.handle] 契约宿主 AdminProductOrderController 未实现(实现未落地)。");
+
+        assertTrue(hasPublicMethod(ctrl, "approveRefund") && hasPublicMethod(ctrl, "rejectRefund"),
+                "退款人工审批端点 approveRefund/rejectRefund 必须存在(API_REFERENCE §4.60.12)。");
+        assertTrue(methodHasPostMapping(ctrl, "approveRefund", "/refund/approve")
+                        && methodHasPostMapping(ctrl, "rejectRefund", "/refund/reject"),
+                "approveRefund→POST /refund/approve、rejectRefund→POST /refund/reject 路径契约。");
+    }
+
+    // ==================== RED-1:重复退款拦截——审核通过须校验订单退款状态(SC-S-06 边界) ====================
+
+    @Test
+    @DisplayName("RED-1 重复退款拦截:订单已退款(refunded/refundStatus=2)后 review(approve) 须拒绝")
+    void review_onAlreadyRefundedOrder_mustReject() {
+        // 售后申请处于待审核;但其关联订单已完成退款(SC-S-06 边界「重复退款拦截」)
+        AfterSalesRequest req = request(500L, "pending", 1L);
+        when(requestMapper.selectById(500L)).thenReturn(req);
+
+        ProductOrder order = order(100L, "PO_REFUNDED");
+        order.setStatus("refunded");
+        order.setRefundStatus(2);
+        when(orderMapper.selectById(100L)).thenReturn(order);
+
+        Result<String> r = service.review(500L, "approve", null, 9L);
+
+        assertEquals(500, r.getCode(),
+                "RED[supplier.afterSales.handle] 契约事实:review(approve) 未校验订单退款状态,"
+                        + "订单已 refunded/refundStatus=2 仍放行 code=" + r.getCode()
+                        + "/msg=" + r.getMessage()
+                        + " —— SC-S-06 边界「重复退款拦截」缺失,GREEN 须在 review 通过前校验 order.refundStatus!=2。");
+        assertEquals("pending", req.getStatus(), "拒绝时申请状态不应被修改");
+    }
+
+    // ==================== RED-2:空转/假完成拦截——complete 须校验订单退款已执行(SC-S-06 边界) ====================
+
+    @Test
+    @DisplayName("RED-2 空转/假完成拦截:订单退款未执行(refunding/refundStatus=1)时 complete 须拒绝")
+    void complete_withoutOrderRefundExecuted_mustReject() {
+        // 售后申请已批准,但关联订单退款尚未执行(order 仍 refunding、refundStatus=1)
+        AfterSalesRequest req = request(500L, "approved", 1L);
+        when(requestMapper.selectById(500L)).thenReturn(req);
+
+        ProductOrder order = order(100L, "PO_PENDING_REFUND");
+        order.setStatus("refunding");
+        order.setRefundStatus(1);
+        when(orderMapper.selectById(100L)).thenReturn(order);
+
+        Result<String> r = service.complete(500L);
+
+        assertEquals(500, r.getCode(),
+                "RED[supplier.afterSales.handle] 契约事实:complete 未校验订单退款已执行"
+                        + "(order.status=refunded / refundStatus=2),退款未完成即标记 completed,code=" + r.getCode()
+                        + "/msg=" + r.getMessage()
+                        + " —— 空转/假完成,SC-S-06 边界「重复退款拦截」缺失,GREEN 须在 complete 前校验订单退款已执行。");
+        assertEquals("approved", req.getStatus(), "拒绝时申请状态不应被修改");
+    }
+
+    // ==================== RED-3:供应商行级隔离(跨商户可见性) ====================
+
+    @Test
+    @DisplayName("RED-3 行级隔离:售后列表/处理须按供应商作用域过滤(对比 AdminProductOrderController B-5)")
+    void supplierRowLevelIsolation_missing() {
+        Object ctrl = contractHost(ADMIN_AFTER_SALES_CTRL);
+        // 契约:list 须声明 @RequestAttribute("roles") 并按 AdminRoles.isSupplierOnly → supplierUserId 过滤(对照 AdminProductOrderController.list)
+        boolean listCarriesRoles = methodHasRequestAttribute(ctrl, "list", "roles");
+
+        Object svc = contractHost(AFTER_SALES_SERVICE);
+        // 契约:服务层须提供按供应商作用域的售后列表(status + 供应商身份)
+        boolean supplierScopedList = hasPublicMethodWithParams(svc, "adminList", String.class, Long.class)
+                || hasPublicMethodWithParams(svc, "adminList", Long.class, String.class)
+                || hasPublicMethodWithParams(svc, "adminListBySupplier", Long.class, String.class)
+                || hasPublicMethodWithParams(svc, "adminListBySupplier", String.class, Long.class);
+
+        assertTrue(listCarriesRoles || supplierScopedList,
+                "RED[supplier.afterSales.handle] 供应商售后处理无行级隔离:"
+                        + "AdminAfterSalesController.list 未声明 @RequestAttribute(\"roles\") 角色作用域,"
+                        + "AfterSalesService 亦无按供应商维度(order.supplierId/supplySystemId)过滤的列表重载——"
+                        + "纯供应商 role=list 将返回/可处理全部商户售后(跨商户隔离缺失)。"
+                        + "GREEN 阶段须参照 AdminProductOrderController.list 的 B-5 行级隔离落地。");
+    }
+
+    // ==================== RED-4:角色守卫(越权防护,B-142 双层防线) ====================
+
+    @Test
+    @DisplayName("RED-4 角色守卫:售后处理端点须声明 @RequestAttribute(\"roles\"),非后台角色不得越权处理")
+    void roleGuard_missing_onHandleEndpoints() {
+        Object ctrl = contractHost(ADMIN_AFTER_SALES_CTRL);
+        boolean anyRoleGuard = methodHasRequestAttribute(ctrl, "list", "roles")
+                || methodHasRequestAttribute(ctrl, "detail", "roles")
+                || methodHasRequestAttribute(ctrl, "review", "roles")
+                || methodHasRequestAttribute(ctrl, "complete", "roles");
+
+        assertTrue(anyRoleGuard,
+                "RED[supplier.afterSales.handle] 售后处理端点无角色守卫:"
+                        + "AdminAfterSalesController 的 list/detail/review/complete 均未声明 @RequestAttribute(\"roles\")"
+                        + "(JwtInterceptor 已注入 roles 属性,B-142 约定 controller 层自身须守卫,且 /api/admin/** 仅保证登录不保证角色)"
+                        + "—— 非后台角色(如 parent/teacher)可越权查看/审核/完成售后。GREEN 阶段须补角色守卫(supplier/admin 等域内角色)。");
+    }
+
+    // ==================== 测试数据辅助(同 AfterSalesScS06ContractTest 风格) ====================
+
+    private AfterSalesRequest request(Long id, String status, Long userId) {
+        AfterSalesRequest r = new AfterSalesRequest();
+        r.setId(id);
+        r.setOrderId(100L);
+        r.setOrderNo("PO_AS");
+        r.setUserId(userId);
+        r.setStatus(status);
+        r.setRefundAmount(9900);
+        return r;
+    }
+
+    private ProductOrder order(Long id, String orderNo) {
+        ProductOrder o = new ProductOrder();
+        o.setId(id);
+        o.setOrderNo(orderNo);
+        o.setBuyerId(1L);
+        o.setMoneyAmount(9900);
+        return o;
+    }
+
+    // ==================== 反射辅助(锚定契约宿主,避免编译期依赖未落地类) ====================
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private void inject(Object target, String field, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    /** 实例化契约宿主;宿主缺失即断言失败(红)。 */
+    private Object contractHost(String fqcn) {
+        try {
+            Class<?> clazz = Class.forName(fqcn);
+            return clazz.getDeclaredConstructor().newInstance();
+        } catch (ClassNotFoundException e) {
+            org.junit.jupiter.api.Assertions.fail(
+                    "RED[supplier.afterSales.handle] 契约宿主 " + simpleName(fqcn) + " 未实现(实现未落地):" + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        } catch (Exception e) {
+            org.junit.jupiter.api.Assertions.fail("契约宿主 " + fqcn + " 实例化失败:" + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    private boolean hasMapping(Class<?> clazz, String expectedPath) {
+        RequestMapping rm = clazz.getAnnotation(RequestMapping.class);
+        return rm != null && rm.value().length > 0 && expectedPath.equals(rm.value()[0]);
+    }
+
+    private boolean hasPublicMethod(Class<?> clazz, String name) {
+        for (Method m : clazz.getMethods()) {
+            if (m.getName().equals(name)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean hasPublicMethodWithParams(Object host, String name, Class<?>... paramTypes) {
+        for (Method m : host.getClass().getMethods()) {
+            if (m.getName().equals(name) && java.util.Arrays.equals(m.getParameterTypes(), paramTypes)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean methodHasPostMapping(Class<?> clazz, String methodName, String path) {
+        for (Method m : clazz.getMethods()) {
+            if (!m.getName().equals(methodName)) {
+                continue;
+            }
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm != null && pm.value().length > 0 && path.equals(pm.value()[0])) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean methodHasRequestAttribute(Object host, String methodName, String attrValue) {
+        for (Method m : host.getClass().getMethods()) {
+            if (!m.getName().equals(methodName)) {
+                continue;
+            }
+            for (Parameter p : m.getParameters()) {
+                RequestAttribute ra = p.getAnnotation(RequestAttribute.class);
+                if (ra != null && attrValue.equals(ra.value())) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private String simpleName(String fqcn) {
+        int idx = fqcn.lastIndexOf('.');
+        return idx >= 0 ? fqcn.substring(idx + 1) : fqcn;
+    }
+}

+ 464 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/SupplierInventoryManageContractTest.java

@@ -0,0 +1,464 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.admin.AdminInventoryController;
+import com.etotem.cfc.entity.InventoryCountingRecord;
+import com.etotem.cfc.entity.InventoryInboundItem;
+import com.etotem.cfc.entity.InventoryInboundOrder;
+import com.etotem.cfc.entity.InventoryTransaction;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.mapper.InventoryCountingRecordMapper;
+import com.etotem.cfc.mapper.InventoryInboundItemMapper;
+import com.etotem.cfc.mapper.InventoryInboundOrderMapper;
+import com.etotem.cfc.mapper.InventoryTransactionMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductSkuMapper;
+import com.etotem.cfc.service.ComboService;
+import com.etotem.cfc.service.InventoryService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:商品服务商库存管理(能力键 {@code supplier.inventory.manage},TDD-RED 阶段)。
+ *
+ * <p><b>需求/设计溯源</b></p>
+ * <ul>
+ *   <li>场景:{@code tests/user-scenarios/应用场景总览.md} SC-S-05(库存管理)
+ *       ——「目标:保障供应。流程:调库存 → 同步。边界:负库存拦截。模块:库存服务。」;</li>
+ *   <li>用例:{@code tests/test-cases/vendor.md}
+ *       <b>TC-S-05</b>(调库存→同步;负库存拦截;同步后前台可见)、
+ *       <b>TC-S-05-E</b>(① 调库存为负 → 400 拦截;② 并发扣减超卖保护不出现负库存;
+ *       ③ 同步失败重试前台一致)、
+ *       <b>TC-S-05-P</b>(非本服务商资源操作被拒;跨商户数据隔离);</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.13 +
+ *       {@code AdminInventoryController} {@code /api/admin/inventory}
+ *       (入库单 / 库存流水 / 盘点 / 库存预警 / 商品库存详情 / 手动出库 / 套餐商品);</li>
+ *   <li>任务登记:sc-key-map.md「SC-S-05 → supplier.inventory.manage → S-cfc-239」。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为与「实现差距」(RED 依据)</b></p>
+ * <ol>
+ *   <li><b>正常流(结构锚点,绿基线)</b>:
+ *       {@code AdminInventoryController} 暴露 13 个约定 POST 端点(入库/流水/盘点/预警/库存详情/出库)。</li>
+ *   <li><b>正常流(行为,绿基线)</b>:
+ *       {@link InventoryService#confirmInbound} 确认后商品主库存同步 +quantity 且写入 {@code inbound} 流水
+ *       (TC-S-05:调库存 → 同步后前台可见);</li>
+ *   <li><b>权限流·读侧(绿基线,B-5)</b>:
+ *       {@link AdminInventoryController#listTransactions} 对纯供应商
+ *       ({@code @RequestAttribute("roles") = [supplier]})传入 {@code supplierUserId} 行级隔离;
+ *       对平台角色(admin)不施加供应商范围(当前实现已具备该分流)。</li>
+ *   <li><b>边界流(RED)· 负库存拦截(TC-S-05-E① / SC-S-05 边界)</b>:
+ *       <ul>
+ *         <li>入库明细数量为负应 400 拦截——当前 {@code createInboundOrder} 仅校验 items 非空,
+ *             不校验单条 {@code quantity > 0};</li>
+ *         <li>盘点实盘数为负应 400 拦截——当前 {@code createCounting} 仅校验 {@code actualStock} 非空;</li>
+ *         <li>盘点确认后商品库存不得为负——当前 {@code confirmCounting} 直接把
+ *             {@code product.stock = actualStock} 落库(盘盈差为负时产生负库存)。</li>
+ *       </ul></li>
+ *   <li><b>边界流(RED)· 并发超卖保护(TC-S-05-E②)</b>:手动出库应使用条件原子扣减
+ *       {@code ProductMapper.decreaseStockAtomic}({@code UPDATE ... SET stock=stock-qty WHERE stock >= qty});
+ *       当前 {@code createManualOutbound} 为「读改写」非原子实现({@code selectById → setStock → updateById}),
+ *       并发下两个请求都可读过充足库存后互相覆盖 → 超卖。</li>
+ *   <li><b>权限流(RED)· 写侧跨商户隔离(TC-S-05-P)</b>:
+ *       入库/盘点/出库/商品库存详情等写操作端点应携带 {@code @RequestAttribute("roles")} 做供应商范围校验
+ *       (复用 B-5 的模式),当前仅 {@code /transactions/list} 一处传递 {@code roles},写入口径全缺失——
+ *       纯供应商可对他商商品执行入库/盘点/出库;且全控制器无 {@code @RequestAttribute("role")} 角色守卫,
+ *       非 admin 不能 403 拒绝(对标 {@code AdminAuditController}/{@code AdminArticleController} 模式)。</li>
+ * </ol>
+ *
+ * <p><b>RED 预期</b>:结构锚点 + 入库同步 + 供应商流水读侧隔离用例通过(绿,基线);
+ * 负库存拦截 / 并发超卖保护 / 写入口径 roles 范围校验 / role 守卫用例断言失败(红),
+ * 断言原文清晰标注契约所需的「400 拦截」「原子条件扣减」「roles 范围校验」「role 守卫」缺失。
+ * GREEN 阶段实现后本测试自动转绿。
+ *
+ * <p><b>说明</b>:TC-S-05-E③「同步失败重试前台一致」为集成/E2E 口径({@code tests/e2e}),
+ * 本契约单测不覆盖。</p>
+ *
+ * <p><b>测试范围</b>:纯 Mockito + 反射,无 Spring 上下文 / 无 DB / 无外部服务。</p>
+ */
+@SuppressWarnings("unchecked")
+class SupplierInventoryManageContractTest extends EnergyContractTestBase {
+
+    // ==================== 被校验对象 ====================
+
+    private AdminInventoryController controller;
+
+    /** 契约行为验证用真实 Service(注入 mock Mapper) */
+    private InventoryService service;
+    @Mock
+    private ProductMapper productMapper;
+    @Mock
+    private ProductSkuMapper productSkuMapper;
+    @Mock
+    private InventoryTransactionMapper transactionMapper;
+    @Mock
+    private InventoryInboundOrderMapper inboundOrderMapper;
+    @Mock
+    private InventoryInboundItemMapper inboundItemMapper;
+    @Mock
+    private InventoryCountingRecordMapper countingRecordMapper;
+
+    /** Controller 层 B-5 行级隔离分流验证用 mock Service */
+    @Mock
+    private InventoryService controllerService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        initTable(Product.class);
+        initTable(ProductSku.class);
+        initTable(InventoryTransaction.class);
+        initTable(InventoryInboundItem.class);
+        initTable(InventoryInboundOrder.class);
+        MockitoAnnotations.openMocks(this);
+
+        // 真实 InventoryService:验证契约行为(负库存拦截 / 原子扣减 / 入库同步)
+        service = new InventoryService();
+        inject(service, "productMapper", productMapper);
+        inject(service, "productSkuMapper", productSkuMapper);
+        inject(service, "transactionMapper", transactionMapper);
+        inject(service, "inboundOrderMapper", inboundOrderMapper);
+        inject(service, "inboundItemMapper", inboundItemMapper);
+        inject(service, "countingRecordMapper", countingRecordMapper);
+
+        // Controller:注入 mock service,聚焦层间契约
+        controller = new AdminInventoryController();
+        inject(controller, "inventoryService", controllerService);
+        inject(controller, "comboService", org.mockito.Mockito.mock(ComboService.class));
+    }
+
+    // ==================== 正常流(结构锚点,SC-S-05:调库存 → 同步)====================
+
+    @Test
+    @DisplayName("SC-S-05 正常流(结构锚点):AdminInventoryController 应暴露入库维护 POST 端点")
+    void controller_exposesInboundEndpoints() {
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/inbound/create"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/inbound/create");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/inbound/list"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/inbound/list");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/inbound/detail"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/inbound/detail");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/inbound/confirm"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/inbound/confirm");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/inbound/cancel"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/inbound/cancel");
+    }
+
+    @Test
+    @DisplayName("SC-S-05 正常流(结构锚点):AdminInventoryController 应暴露流水/盘点/预警/库存/出库 POST 端点")
+    void controller_exposesTransactionsCountingOutboundEndpoints() {
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/transactions/list"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/transactions/list");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/counting/create"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/counting/create");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/counting/confirm"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/counting/confirm");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/counting/list"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/counting/list");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/alert/list"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/alert/list");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/product/stock"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/product/stock");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/outbound/create"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/outbound/create");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/outbound/list"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/outbound/list");
+    }
+
+    // ==================== 正常流(行为,SC-S-05:调库存 → 同步)====================
+
+    @Test
+    @DisplayName("SC-S-05 正常流(行为):确认入库应同步商品库存并写入 inbound 流水")
+    void inboundConfirm_syncsStockAndWritesTransaction() {
+        Long orderId = 1L;
+        Long productId = 1L;
+        int qty = 4;
+
+        InventoryInboundOrder order = new InventoryInboundOrder();
+        order.setId(orderId);
+        order.setStatus("pending");
+        when(inboundOrderMapper.selectById(orderId)).thenReturn(order);
+
+        InventoryInboundItem item = new InventoryInboundItem();
+        item.setProductId(productId);
+        item.setQuantity(qty);
+        when(inboundItemMapper.selectList(any())).thenReturn(Collections.singletonList(item));
+
+        Product product = new Product();
+        product.setId(productId);
+        product.setStock(10);
+        when(productMapper.selectById(productId)).thenReturn(product);
+
+        Result<String> result = service.confirmInbound(orderId, 100L);
+
+        assertNotNull(result);
+        assertEquals(200, result.getCode(),
+                "RED[supplier.inventory.manage] 确认入库应成功(TC-S-05 流程:调库存→同步),当前 code="
+                        + result.getCode());
+        assertEquals(Integer.valueOf(10 + qty), product.getStock(),
+                "RED[supplier.inventory.manage] 入库后商品主库存应同步为 10+" + qty
+                        + "(同步后前台可见),实际=" + product.getStock());
+
+        ArgumentCaptor<InventoryTransaction> captor = ArgumentCaptor.forClass(InventoryTransaction.class);
+        verify(transactionMapper).insert(captor.capture());
+        InventoryTransaction tx = captor.getValue();
+        assertEquals("inbound", tx.getType(), "RED[supplier.inventory.manage] 入库流水 type 应为 inbound");
+        assertEquals(Integer.valueOf(1), tx.getDirection(),
+                "RED[supplier.inventory.manage] 入库流水 direction 应为 +1");
+        assertEquals(Integer.valueOf(10), tx.getBeforeStock(),
+                "RED[supplier.inventory.manage] 入库流水 beforeStock 应为 10");
+        assertEquals(Integer.valueOf(14), tx.getAfterStock(),
+                "RED[supplier.inventory.manage] 入库流水 afterStock 应为 14");
+    }
+
+    // ==================== 边界流(TC-S-05-E① / SC-S-05 边界):负库存拦截 ====================
+
+    @Test
+    @DisplayName("TC-S-05-E① 边界:入库明细数量为负应 400 拦截,不得创建入库单")
+    void inbound_negativeItemQuantity_rejected() {
+        Map<String, Object> badItem = new HashMap<>();
+        badItem.put("productId", 1L);
+        badItem.put("quantity", -5);
+
+        Result<Map<String, Object>> result = service.createInboundOrder(
+                9L, "供应商A", Collections.singletonList(badItem), 100L, null, null, null);
+
+        assertNotNull(result);
+        assertEquals(400, result.getCode(),
+                "RED[supplier.inventory.manage] 入库明细数量为负(-5)应 400 拦截(TC-S-05-E① 负库存拦截),"
+                        + "当前 code=" + result.getCode()
+                        + "(createInboundOrder 仅校验 items 非空,未校验单条 quantity>0)");
+        verify(inboundOrderMapper, never()).insert(any());
+    }
+
+    @Test
+    @DisplayName("TC-S-05-E①/SC-S-05 边界:盘点实盘数为负应 400 拦截,不得创建盘点记录")
+    void counting_negativeActualStock_rejected() {
+        Product product = new Product();
+        product.setId(1L);
+        product.setStock(10);
+        when(productMapper.selectById(1L)).thenReturn(product);
+
+        Result<String> result = service.createCounting(1L, null, -5, 100L, "盘亏");
+
+        assertNotNull(result);
+        assertEquals(400, result.getCode(),
+                "RED[supplier.inventory.manage] 盘点实盘数为负(-5)应 400 拦截(SC-S-05 边界:负库存拦截),"
+                        + "当前 code=" + result.getCode()
+                        + "(createCounting 仅校验 actualStock 非空,未校验 actualStock>=0)");
+        verify(countingRecordMapper, never()).insert(any());
+    }
+
+    @Test
+    @DisplayName("TC-S-05-E① 边界:盘点确认后商品库存不得为负")
+    void countingConfirm_neverPersistsNegativeStock() {
+        InventoryCountingRecord record = new InventoryCountingRecord();
+        record.setId(50L);
+        record.setProductId(1L);
+        record.setExpectedStock(10);
+        record.setActualStock(-3);
+        record.setDifference(-13);
+        record.setStatus("pending");
+        when(countingRecordMapper.selectById(50L)).thenReturn(record);
+
+        Product product = new Product();
+        product.setId(1L);
+        product.setStock(10);
+        when(productMapper.selectById(1L)).thenReturn(product);
+
+        Result<String> result = service.confirmCounting(50L, 100L);
+
+        assertNotNull(result);
+        assertTrue(product.getStock() != null && product.getStock() >= 0,
+                "RED[supplier.inventory.manage] 盘点确认后商品库存不得为负(TC-S-05-E① 负库存拦截),"
+                        + "当前 stock=" + product.getStock()
+                        + "(confirmCounting 直接把 product.stock=actualStock 落库,未做下界保护)");
+    }
+
+    // ==================== 边界流(TC-S-05-E②):并发超卖保护 ====================
+
+    @Test
+    @DisplayName("TC-S-05-E② 边界:手动出库应走条件原子扣减 decreaseStockAtomic(WHERE stock>=qty)")
+    void manualOutbound_usesAtomicGuardedDecrement() {
+        Product product = new Product();
+        product.setId(1L);
+        product.setStock(5);
+        when(productMapper.selectById(1L)).thenReturn(product);
+
+        Result<String> result = service.createManualOutbound(1L, null, 3, "调拨", 100L, null, null);
+
+        assertNotNull(result);
+        assertEquals(200, result.getCode(),
+                "RED[supplier.inventory.manage] 库存充足时手动出库应成功,当前 code=" + result.getCode());
+        // 契约要求:出库必须走 ProductMapper.decreaseStockAtomic(已存在的方法:UPDATE ... WHERE stock >= qty)。
+        // 当前实现为 selectById → setStock → updateById 读改写,非原子。
+        verify(productMapper).decreaseStockAtomic(1L, 3);
+    }
+
+    @Test
+    @DisplayName("TC-S-05-E② 边界:出库超库存时应失败且不写流水(单线程守卫已存在,测试不回归)")
+    void manualOutbound_overStock_failsWithoutTransaction() {
+        Product product = new Product();
+        product.setId(1L);
+        product.setStock(2);
+        when(productMapper.selectById(1L)).thenReturn(product);
+
+        Result<String> result = service.createManualOutbound(1L, null, 5, "调拨", 100L, null, null);
+
+        assertNotNull(result);
+        assertTrue(result.getCode() != 200,
+                "RED[supplier.inventory.manage] 出库量(5) 超过库存(2) 应失败(TC-S-05-E② 不出现负库存),"
+                        + "当前 code=" + result.getCode());
+        verify(transactionMapper, never()).insert(any());
+    }
+
+    // ==================== 权限流(TC-S-05-P):跨商户隔离 + 角色守卫 ====================
+
+    @Test
+    @DisplayName("TC-S-05-P 权限(绿基线):纯供应商查库存流水应传 supplierUserId 做行级隔离")
+    void listTransactions_supplierOnly_passesSupplierScope() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("page", 1);
+        params.put("size", 20);
+
+        when(controllerService.listTransactions(eq(1), eq(20), isNull(), isNull(),
+                isNull(), isNull(), isNull(), eq(99L)))
+                .thenReturn(Result.success(new HashMap<>()));
+
+        Result<Map<String, Object>> result = controller.listTransactions(params, 99L,
+                Collections.singletonList("supplier"));
+
+        assertNotNull(result);
+        verify(controllerService).listTransactions(eq(1), eq(20), isNull(), isNull(),
+                isNull(), isNull(), isNull(), eq(99L));
+    }
+
+    @Test
+    @DisplayName("TC-S-05-P 权限(绿基线):平台角色查库存流水不应施加供应商范围限制")
+    void listTransactions_admin_notScopedToSupplier() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("page", 1);
+        params.put("size", 20);
+
+        when(controllerService.listTransactions(eq(1), eq(20), isNull(), isNull(),
+                isNull(), isNull(), isNull(), isNull()))
+                .thenReturn(Result.success(new HashMap<>()));
+
+        Result<Map<String, Object>> result = controller.listTransactions(params, 1L,
+                Collections.singletonList("admin"));
+
+        assertNotNull(result);
+        verify(controllerService).listTransactions(eq(1), eq(20), isNull(), isNull(),
+                isNull(), isNull(), isNull(), isNull());
+    }
+
+    @Test
+    @DisplayName("TC-S-05-P/SC-S-05-P 权限:库存写操作端点应带 @RequestAttribute(\"roles\") 跨商户隔离参数")
+    void inventoryWriteEndpoints_haveSupplierScopeGuard() {
+        String[] writeOrStockPaths = {
+                "/inbound/create", "/inbound/confirm", "/inbound/cancel",
+                "/counting/create", "/counting/confirm",
+                "/outbound/create", "/product/stock"
+        };
+        List<String> missing = new ArrayList<>();
+        for (String path : writeOrStockPaths) {
+            if (!hasRolesParam(AdminInventoryController.class, path)) {
+                missing.add(path);
+            }
+        }
+        assertTrue(missing.isEmpty(),
+                "RED[supplier.inventory.manage] 以下库存端点缺少 @RequestAttribute(\"roles\") 跨商户隔离参数"
+                        + "(TC-S-05-P:非本服务商资源操作被拒 / 跨商户数据隔离),纯供应商可对他商商品执行入库/盘点/出库:"
+                        + missing
+                        + ";当前仅 /transactions/list 落地了 supplierUserId 行级隔离(B-5),写入口径缺失。");
+    }
+
+    @Test
+    @DisplayName("TC-S-05-P/SC-S-05-P 权限:库存写操作端点应带 @RequestAttribute(\"role\") 角色守卫")
+    void inventoryWriteEndpoints_haveRoleGuard() {
+        assertTrue(hasRoleGuard(AdminInventoryController.class),
+                "RED[supplier.inventory.manage] AdminInventoryController 写操作应带"
+                        + " @RequestAttribute(\"role\") 守卫,非 admin 访问 403(库存维护属管理端接口,§4.13);"
+                        + "当前仅有 JwtInterceptor 通用鉴权,无角色级二次校验(对标 AdminAuditController/AdminArticleController 模式)。");
+    }
+
+    // ==================== 反射辅助 ====================
+
+    /** 控制器是否暴露指定 POST 路径端点 */
+    private boolean hasEndpoint(Class<?> controller, String path) {
+        for (java.lang.reflect.Method m : controller.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm != null) {
+                for (String v : pm.value()) {
+                    if (path.equals(v)) {
+                        return true;
+                    }
+                }
+            }
+        }
+        return false;
+    }
+
+    /** 控制器任意方法是否带 @RequestAttribute("role") 守卫 */
+    private boolean hasRoleGuard(Class<?> controller) {
+        for (java.lang.reflect.Method m : controller.getDeclaredMethods()) {
+            for (java.lang.reflect.Parameter p : m.getParameters()) {
+                RequestAttribute ra = p.getAnnotation(RequestAttribute.class);
+                if (ra != null && "role".equals(ra.value())) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    /** 指定 POST 路径的方法是否携带 @RequestAttribute("roles") 跨商户隔离参数 */
+    private boolean hasRolesParam(Class<?> controller, String path) {
+        for (java.lang.reflect.Method m : controller.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm == null) {
+                continue;
+            }
+            boolean isTarget = false;
+            for (String v : pm.value()) {
+                if (path.equals(v)) {
+                    isTarget = true;
+                    break;
+                }
+            }
+            if (!isTarget) {
+                continue;
+            }
+            for (java.lang.reflect.Parameter p : m.getParameters()) {
+                RequestAttribute ra = p.getAnnotation(RequestAttribute.class);
+                if (ra != null && "roles".equals(ra.value())) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+}

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-8038be3288291745bec9999b551952d7ab5c0295
+3fafe93b658393557998264d8b12817e598e5c1b

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.2451",
+  "version": "1.0.2452",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.2451",
+      "version": "1.0.2452",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.2452",
+  "version": "1.0.2453",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 72 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,78 @@
 
 ---
 
+## v1.0.2453 (2026-11-13)
+
+### 测试
+- TDD-RED supplier.inventory.manage (SC-S-05) - S-cfc-239
+- S-cfc-243 supplier.data.exportCompliance 数据导出合规 RED 契约测试
+- RED contract test for supplier.afterSales.handle (S-cfc-240)
+- supplier.nutrition.supply TDD-RED 契约测试(13红/13)——食谱供给宿主/格式校验/XSS合规/商户隔离 契约待落地
+
+### 其他
+- 溯源:
+- - 场景 SC-S-05:库存管理(调库存→同步,负库存拦截)
+- - 用例 TC-S-05 / TC-S-05-E①② / TC-S-05-P
+- - 设计 API_REFERENCE §4.13 + AdminInventoryController /api/admin/inventory
+- 新增测试 12 例(纯 Mockito/反射,无 Spring/DB/外部服务):
+- - 绿基线(6):结构锚点 2、入库同步 1、供应商流水读侧隔离 2、超库存拦截 1
+- - 红契约(6):
+-   * inbound_negativeItemQuantity_rejected: 入库明细负数量应 400 拦截
+-   * counting_negativeActualStock_rejected: 盘点负实盘应 400 拦截
+-   * countingConfirm_neverPersistsNegativeStock: 盘点确认后库存不得为负
+-   * manualOutbound_usesAtomicGuardedDecrement: 手动出库应走 decreaseStockAtomic 原子条件扣减
+-   * inventoryWriteEndpoints_haveSupplierScopeGuard: 写操作端点缺少 @RequestAttribute("roles") 跨商户隔离
+-   * inventoryWriteEndpoints_haveRoleGuard: 写操作端点缺少 @RequestAttribute("role") 角色守卫
+- 红测结果:Tests run 12, Failures 6, Errors 0(6 红 = 上述契约缺口)。
+- 
+- S-cfc-245: ai.coach.proactive 契约级 RED 单测(SC-B-02 主动提醒 + SC-B-12 方案跟进)
+- - 覆盖 SC-B-12(同步规划师建议主动跟进:激活同步/节奏推送/停滞提示/可暂停/未激活不触发)
+- - 纯反射 + schema.sql 结构契约探测,无 Spring/DB/外部服务;宿主未落地,24/25 红(RED 达成)
+- - 新增文件:cfc-backend/src/test/java/com/etotem/cfc/unit/CoachProactiveContractTest.java
+- 
+- 契约锚点:
+- - 需求:tests/user-scenarios/应用场景总览.md SC-S-11(授权内用数;越授权拒;脱敏)
+- - 设计:API_REFERENCE §4.62.6 + specs/2026-11-07-service-provider-contract.md §1.6
+-   (UserController /api/user/data-export + ReportMemberPermissionController 同源授权链)
+- - 派生用例:tests/test-cases/vendor.md TC-S-11 / TC-S-11-E / TC-S-11-P
+- 断言缺失契约行为(纯 Mockito/反射,无 Spring/DB/外部服务):
+- - 正常流:exportUserData 敏感字段(phone/realName/idCard)强制脱敏(DataMaskUtil)
+- - 边界流:导出必须具备授权范围入口(memberId/scope),越授权可拒(TC-S-11)
+- - 合规流:AccountCleanupService 注入 UserOperationLogService 合规审计(OperationLogController)
+- RED 预期:6 tests run / 5 failures(脱敏 3 + 授权范围 1 + 审计注入 1),
+- 1 个通过为权限流说明性用例;失败均为功能缺失断言(非编译/拼写错误)。
+- 
+- (SC-S-06,映射 docs/tdd/orchestration/sc-key-map.md)锚定契约行为:
+- - 锚定-1/2(绿证):AdminAfterSalesController /api/admin/shop/after-sales(list/detail/review/complete)
+-   与 AdminProductOrderController /api/admin/product/order(refund/approve|reject)宿主与端点齐备;
+- - RED-1(红):review(approve) 未校验订单退款状态(已 refunded/refundStatus=2 仍可放行)
+-   —— SC-S-06 边界「重复退款拦截」缺失;
+- - RED-2(红):complete 未校验订单退款已执行(status=refunded/refundStatus=2),退款未完成即标记
+-   completed —— 空转/假完成;
+- - RED-3(红):售后列表/处理无供应商行级隔离(list 无 @RequestAttribute("roles") 角色作用域、
+-   AfterSalesService 无按 supplierId/supplySystemId 过滤重载),跨商户可见/可处理;
+- - RED-4(红):list/detail/review/complete 均无角色守卫,非后台角色可越权处理售后。
+- 风格同批:纯 JUnit5 + Mockito + 反射,无 Spring 上下文/无 DB/无外部服务。
+- RED 验证:mvn -Dtest=SupplierAfterSalesHandleContractTest test → Tests run: 6, Failures: 4(预期红)。
+- GREEN 阶段据此落地行为使绿。
+- 
+- S-cfc-234: teacher.message.push 契约实现核验收口(SC-T-19)
+- - 核验结论:实现已落地 origin/cfclub(TeacherMessageController /api/guide/messages/{send,reply,list,unread-count,my} + UserNotificationController /api/notification/{list,read}),无 src/main 变更需求
+- - sc-key-map SC-T-19 键阶段 RED→YELLOW(三类流 e2e 证据待测试侧补证)
+- 
+-   selfcheck 来源写入 five_dimension_scores(自检 0-9 制换算 0-100,静默兜底不阻断主流程)
+- - schema.sql five_dimension_scores.source_type 注释补充 selfcheck
+- - DatabaseInitializer 迁移468 校准存量表列注释(幂等)+ 迁移68 建表注释同步
+- TeacherDimensionScoreContractTest: 6 用例 2 失败 → 全绿(0 失败)
+- 
+
+### 文档
+- 研发负责人第143轮闭环汇报 2026-11-13 1957(54247bf9 错峰轮·tdd-orchestrator 直连)
+
+### 新功能
+- S-cfc-236 teacher.dimension.score 五维自评桥接五维分数变化(SC-T-32 使绿)
+
+
 ## v1.0.2452 (2026-11-13)
 
 ### 其他

+ 73 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.2452
+> 当前版本: v1.0.2453
 
 ## 历史版本
 
@@ -8,6 +8,78 @@
 
 ---
 
+## v1.0.2453 (2026-11-13)
+
+### 测试
+- TDD-RED supplier.inventory.manage (SC-S-05) - S-cfc-239
+- S-cfc-243 supplier.data.exportCompliance 数据导出合规 RED 契约测试
+- RED contract test for supplier.afterSales.handle (S-cfc-240)
+- supplier.nutrition.supply TDD-RED 契约测试(13红/13)——食谱供给宿主/格式校验/XSS合规/商户隔离 契约待落地
+
+### 其他
+- 溯源:
+- - 场景 SC-S-05:库存管理(调库存→同步,负库存拦截)
+- - 用例 TC-S-05 / TC-S-05-E①② / TC-S-05-P
+- - 设计 API_REFERENCE §4.13 + AdminInventoryController /api/admin/inventory
+- 新增测试 12 例(纯 Mockito/反射,无 Spring/DB/外部服务):
+- - 绿基线(6):结构锚点 2、入库同步 1、供应商流水读侧隔离 2、超库存拦截 1
+- - 红契约(6):
+-   * inbound_negativeItemQuantity_rejected: 入库明细负数量应 400 拦截
+-   * counting_negativeActualStock_rejected: 盘点负实盘应 400 拦截
+-   * countingConfirm_neverPersistsNegativeStock: 盘点确认后库存不得为负
+-   * manualOutbound_usesAtomicGuardedDecrement: 手动出库应走 decreaseStockAtomic 原子条件扣减
+-   * inventoryWriteEndpoints_haveSupplierScopeGuard: 写操作端点缺少 @RequestAttribute("roles") 跨商户隔离
+-   * inventoryWriteEndpoints_haveRoleGuard: 写操作端点缺少 @RequestAttribute("role") 角色守卫
+- 红测结果:Tests run 12, Failures 6, Errors 0(6 红 = 上述契约缺口)。
+- 
+- S-cfc-245: ai.coach.proactive 契约级 RED 单测(SC-B-02 主动提醒 + SC-B-12 方案跟进)
+- - 覆盖 SC-B-12(同步规划师建议主动跟进:激活同步/节奏推送/停滞提示/可暂停/未激活不触发)
+- - 纯反射 + schema.sql 结构契约探测,无 Spring/DB/外部服务;宿主未落地,24/25 红(RED 达成)
+- - 新增文件:cfc-backend/src/test/java/com/etotem/cfc/unit/CoachProactiveContractTest.java
+- 
+- 契约锚点:
+- - 需求:tests/user-scenarios/应用场景总览.md SC-S-11(授权内用数;越授权拒;脱敏)
+- - 设计:API_REFERENCE §4.62.6 + specs/2026-11-07-service-provider-contract.md §1.6
+-   (UserController /api/user/data-export + ReportMemberPermissionController 同源授权链)
+- - 派生用例:tests/test-cases/vendor.md TC-S-11 / TC-S-11-E / TC-S-11-P
+- 断言缺失契约行为(纯 Mockito/反射,无 Spring/DB/外部服务):
+- - 正常流:exportUserData 敏感字段(phone/realName/idCard)强制脱敏(DataMaskUtil)
+- - 边界流:导出必须具备授权范围入口(memberId/scope),越授权可拒(TC-S-11)
+- - 合规流:AccountCleanupService 注入 UserOperationLogService 合规审计(OperationLogController)
+- RED 预期:6 tests run / 5 failures(脱敏 3 + 授权范围 1 + 审计注入 1),
+- 1 个通过为权限流说明性用例;失败均为功能缺失断言(非编译/拼写错误)。
+- 
+- (SC-S-06,映射 docs/tdd/orchestration/sc-key-map.md)锚定契约行为:
+- - 锚定-1/2(绿证):AdminAfterSalesController /api/admin/shop/after-sales(list/detail/review/complete)
+-   与 AdminProductOrderController /api/admin/product/order(refund/approve|reject)宿主与端点齐备;
+- - RED-1(红):review(approve) 未校验订单退款状态(已 refunded/refundStatus=2 仍可放行)
+-   —— SC-S-06 边界「重复退款拦截」缺失;
+- - RED-2(红):complete 未校验订单退款已执行(status=refunded/refundStatus=2),退款未完成即标记
+-   completed —— 空转/假完成;
+- - RED-3(红):售后列表/处理无供应商行级隔离(list 无 @RequestAttribute("roles") 角色作用域、
+-   AfterSalesService 无按 supplierId/supplySystemId 过滤重载),跨商户可见/可处理;
+- - RED-4(红):list/detail/review/complete 均无角色守卫,非后台角色可越权处理售后。
+- 风格同批:纯 JUnit5 + Mockito + 反射,无 Spring 上下文/无 DB/无外部服务。
+- RED 验证:mvn -Dtest=SupplierAfterSalesHandleContractTest test → Tests run: 6, Failures: 4(预期红)。
+- GREEN 阶段据此落地行为使绿。
+- 
+- S-cfc-234: teacher.message.push 契约实现核验收口(SC-T-19)
+- - 核验结论:实现已落地 origin/cfclub(TeacherMessageController /api/guide/messages/{send,reply,list,unread-count,my} + UserNotificationController /api/notification/{list,read}),无 src/main 变更需求
+- - sc-key-map SC-T-19 键阶段 RED→YELLOW(三类流 e2e 证据待测试侧补证)
+- 
+-   selfcheck 来源写入 five_dimension_scores(自检 0-9 制换算 0-100,静默兜底不阻断主流程)
+- - schema.sql five_dimension_scores.source_type 注释补充 selfcheck
+- - DatabaseInitializer 迁移468 校准存量表列注释(幂等)+ 迁移68 建表注释同步
+- TeacherDimensionScoreContractTest: 6 用例 2 失败 → 全绿(0 失败)
+- 
+
+### 文档
+- 研发负责人第143轮闭环汇报 2026-11-13 1957(54247bf9 错峰轮·tdd-orchestrator 直连)
+
+### 新功能
+- S-cfc-236 teacher.dimension.score 五维自评桥接五维分数变化(SC-T-32 使绿)
+
+
 ## v1.0.2452 (2026-11-13)
 
 ### 其他