|
@@ -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;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|