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

feat: 031 US7 自取堂食订单支持现金支付——闸门按订单类型与商家勾选管控CASH

qmj 6 дней назад
Родитель
Сommit
b1a294e410

+ 32 - 23
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -231,10 +231,11 @@ public class UserOrderController extends BaseController {
         List<OperatingHours> hourslist = operatingHoursService.list(wrapper);
         // 031 支付方式闸门:整单支付方式唯一,涉及每个门店(主账号)都必须满足
         // 平台开关 ∩ 商家接受 ∩ 就绪度,堵住绕过界面直传被禁方式下单的漏洞(spec FR-004)。
+        // US7:带整单用餐类型,现金仅自取/堂食可用且需商家勾选,外送直传现金被拒。
         for (PosStore store : storeList) {
-            paymentMethodGateService.assertUsable(input.getPaymentMethod(),
+            paymentMethodGateService.assertUsableForOrder(input.getPaymentMethod(),
                     PaymentMethodGateService.GateScope.MERCHANT,
-                    store.getId().longValue(), store.getUserId());
+                    store.getId().longValue(), store.getUserId(), input.getType());
         }
         boolean isMultiStore = input.getItems().size() > 1;
         // 循环items,为每个item创建一条PosOrder
@@ -289,9 +290,12 @@ public class UserOrderController extends BaseController {
             }
             posOrder.setAfterSaleStatus(0L);
             posOrder.setDeliveryStatus(Long.valueOf(0L).equals(item.getType()) ? 0L : null);
-            // 外送到付(payType=1):payStatus=1;其他:payStatus=0
+            // 外送到付(payType=1):payStatus=1;现金(4)自取/堂食同到付线下收款语义:payStatus=1;其他:payStatus=0
             if (item.getType() != null && item.getType() == 0 && "1".equals(input.getPaymentMethod())) {
                 posOrder.setPayStatus(1L);
+            } else if ("4".equals(input.getPaymentMethod())
+                    && (item.getType() != null && (item.getType() == 1 || item.getType() == 2))) {
+                posOrder.setPayStatus(1L);
             } else {
                 posOrder.setPayStatus(0L);
             }
@@ -576,6 +580,29 @@ public class UserOrderController extends BaseController {
             wrapper.eq(PosOrder::getType, Long.valueOf(type));
         }
 
+        applyTabFilter(wrapper, tab);
+        wrapper.orderByDesc(PosOrder::getCretim);
+
+        // 分页
+        com.baomidou.mybatisplus.extension.plugins.pagination.Page<PosOrder> pageParam =
+                new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
+        com.baomidou.mybatisplus.extension.plugins.pagination.Page<PosOrder> result = posOrderService.page(pageParam, wrapper);
+
+        // 附加关联数据
+        for (PosOrder order : result.getRecords()) {
+            enrichOrderData(order);
+        }
+
+        java.util.Map<String, Object> data = new java.util.LinkedHashMap<>();
+        data.put("records", result.getRecords());
+        data.put("total", result.getTotal());
+        data.put("page", page);
+        data.put("size", size);
+        return success(result);
+    }
+
+    /** 订单列表页签过滤条件(031 US7 抽取便于单测):unpaid 仅在线支付;active 放行到付与现金自取/堂食。 */
+    static void applyTabFilter(LambdaQueryWrapper<PosOrder> wrapper, String tab) {
         switch (tab) {
             case "unpaid":
                 // 在线支付订单不区分配送类型;自取/堂食未付款也必须先留在待付款。
@@ -587,9 +614,9 @@ public class UserOrderController extends BaseController {
             case "active":
                 wrapper.in(PosOrder::getState, 0L, 1L, 2L)
                        .eq(PosOrder::getAfterSaleStatus, 0L)
-                       // 现金自取/堂食可以线下收款;OMG、LINE Pay 等在线订单必须已付款才能履约。
+                       // 到付/现金自取堂食可以线下收款;OMG、LINE Pay 等在线订单必须已付款才能履约。
                        .and(w -> w.eq(PosOrder::getPayStatus, 1L)
-                               .or(cash -> cash.eq(PosOrder::getPayType, "1")
+                               .or(cod -> cod.in(PosOrder::getPayType, "1", "4")
                                        .in(PosOrder::getType, 1L, 2L)));
                 break;
             case "completed":
@@ -604,24 +631,6 @@ public class UserOrderController extends BaseController {
             default:
                break;
         }
-        wrapper.orderByDesc(PosOrder::getCretim);
-
-        // 分页
-        com.baomidou.mybatisplus.extension.plugins.pagination.Page<PosOrder> pageParam =
-                new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
-        com.baomidou.mybatisplus.extension.plugins.pagination.Page<PosOrder> result = posOrderService.page(pageParam, wrapper);
-
-        // 附加关联数据
-        for (PosOrder order : result.getRecords()) {
-            enrichOrderData(order);
-        }
-
-        java.util.Map<String, Object> data = new java.util.LinkedHashMap<>();
-        data.put("records", result.getRecords());
-        data.put("total", result.getTotal());
-        data.put("page", page);
-        data.put("size", size);
-        return success(result);
     }
 
     private void enrichOrderData(PosOrder order) {

+ 3 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/pay/MerchantPayMethodController.java

@@ -49,7 +49,8 @@ public class MerchantPayMethodController extends BaseController {
     }
 
     /**
-     * 设置页数据:available=平台商家维度开放的组(不带门店级就绪度,设置页只关心平台开关),
+     * 设置页数据:available=平台商家维度开放的组(不带门店级就绪度,设置页只关心平台开关)
+     * 追加现金项(031 US7:orderType 传 1 按自取/堂食口径返回含 CASH 的全集),
      * selected=商家当前选择(NULL 视为全部,返回空数组由前端展示为全选)。
      */
     @Auth(session = true)
@@ -58,7 +59,7 @@ public class MerchantPayMethodController extends BaseController {
         Long ownerUserId = resolveOwner(token);
         InfoUser owner = userMapper.selectById(ownerUserId);
         List<PaymentMethodGateService.PayMethodOption> available =
-                gateService.listAvailable(PaymentMethodGateService.GateScope.MERCHANT, null, null);
+                gateService.listAvailable(PaymentMethodGateService.GateScope.MERCHANT, null, null, 1L);
         List<String> selected = new ArrayList<>();
         String csv = owner == null ? null : owner.getPayMethods();
         if (csv != null && !csv.trim().isEmpty()) {

+ 4 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/pay/PayMethodConfigController.java

@@ -56,7 +56,8 @@ public class PayMethodConfigController extends BaseController {
         }
         List<PaymentMethodConfig> matrix = new ArrayList<>();
         for (PaymentMethodGateService.GateScope scope : PaymentMethodGateService.GateScope.values()) {
-            for (String group : PaymentMethodGateService.groupsOf(scope)) {
+            // 现金无平台开关行(031 US7/FR-006),矩阵只覆盖平台可管控组
+            for (String group : PaymentMethodGateService.platformGroupsOf(scope)) {
                 PaymentMethodConfig row = existing.get(group + "@" + scope.name());
                 if (row == null) {
                     // 缺行=开的语义直接以补齐行返回,避免前端各自理解空行
@@ -84,11 +85,11 @@ public class PayMethodConfigController extends BaseController {
             throw new ServiceException(MessageUtils.message("pay.method.config.invalid"));
         }
         for (PayMethodConfigItemDto item : dto.getItems()) {
-            // 组必须属于该维度的可管控集合(同时校验组代码与维度取值合法)
+            // 组必须属于该维度的平台可管控集合(现金无平台开关行,CASH 提交在此拒绝)
             if (item == null || item.getEnabled() == null
                     || (item.getEnabled() != 0 && item.getEnabled() != 1)
                     || !isValidScope(item.getScope())
-                    || !PaymentMethodGateService.groupsOf(scopeOf(item.getScope())).contains(item.getMethodCode())) {
+                    || !PaymentMethodGateService.platformGroupsOf(scopeOf(item.getScope())).contains(item.getMethodCode())) {
                 throw new ServiceException(MessageUtils.message("pay.method.config.invalid"));
             }
         }

+ 8 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/pay/StorePayMethodController.java

@@ -31,16 +31,21 @@ public class StorePayMethodController extends BaseController {
         this.storeMapper = storeMapper;
     }
 
-    /** 门店 → 主账号 → 三层闸门可用方式(含就绪度),门店不存在返回空列表。 */
+    /**
+     * 门店 → 主账号 → 三层闸门可用方式(含就绪度),门店不存在返回空列表。
+     * type(031 US7,可选):1自取/2堂食时结果追加现金项(仍受商家勾选约束);
+     * 不传或 type=0 外送不返回现金,老客户端零变化。
+     */
     @GetMapping
-    public AjaxResult list(@RequestParam Integer storeId) {
+    public AjaxResult list(@RequestParam Integer storeId,
+                           @RequestParam(required = false) Long type) {
         PosStore store = storeId == null ? null : storeMapper.selectById(storeId);
         if (store == null) {
             return success(List.of());
         }
         List<PaymentMethodGateService.PayMethodOption> options =
                 gateService.listAvailable(PaymentMethodGateService.GateScope.MERCHANT,
-                        store.getId().longValue(), store.getUserId());
+                        store.getId().longValue(), store.getUserId(), type);
         return success(options);
     }
 }

+ 54 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/UserOrderTabFilterTest.java

@@ -0,0 +1,54 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.ruoyi.system.domain.PosOrder;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collection;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** 订单列表页签过滤单测(031 US7 T027):现金自取/堂食进 active、不进 unpaid。 */
+class UserOrderTabFilterTest {
+
+    @BeforeAll
+    static void initTableInfo() {
+        // Lambda 方法引用依赖 PosOrder 的 TableInfo 缓存
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), PosOrder.class);
+    }
+
+    private static LambdaQueryWrapper<PosOrder> wrapperOf(String tab) {
+        LambdaQueryWrapper<PosOrder> wrapper = new LambdaQueryWrapper<>();
+        UserOrderController.applyTabFilter(wrapper, tab);
+        return wrapper;
+    }
+
+    @Test
+    void activeTabIncludesCashSelfPickupAndDineIn() {
+        LambdaQueryWrapper<PosOrder> wrapper = wrapperOf("active");
+        String sql = wrapper.getSqlSegment().toLowerCase();
+        Collection<Object> params = wrapper.getParamNameValuePairs().values();
+        assertTrue(sql.contains("pay_type in"));
+        assertTrue(params.contains("1"));
+        assertTrue(params.contains("4"));
+        assertTrue(sql.contains("type in"));
+        assertTrue(params.contains(1L));
+        assertTrue(params.contains(2L));
+    }
+
+    @Test
+    void unpaidTabExcludesCashOrders() {
+        LambdaQueryWrapper<PosOrder> wrapper = wrapperOf("unpaid");
+        String sql = wrapper.getSqlSegment().toLowerCase();
+        Collection<Object> params = wrapper.getParamNameValuePairs().values();
+        assertTrue(sql.contains("pay_type in"));
+        assertTrue(params.contains("2"));
+        assertTrue(params.contains("3"));
+        assertFalse(params.contains("4"), "待付款页签不得出现现金订单");
+    }
+}

+ 2 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/pay/MerchantPayMethodControllerTest.java

@@ -67,7 +67,7 @@ class MerchantPayMethodControllerTest {
         userMapper = mock(InfoUserMapper.class);
         merchantStoreAccessService = mock(MerchantStoreAccessService.class);
         controller = new MerchantPayMethodController(gateService, userMapper, merchantStoreAccessService);
-        when(gateService.listAvailable(any(), any(), any())).thenReturn(List.of());
+        when(gateService.listAvailable(any(), any(), any(), any())).thenReturn(List.of());
         // 子账号 9 定位主账号 7
         MerchantAccessContext context = mock(MerchantAccessContext.class);
         when(context.ownerUserId()).thenReturn(7L);
@@ -97,7 +97,7 @@ class MerchantPayMethodControllerTest {
         Map<String, Object> data = (Map<String, Object>) result.get(AjaxResult.DATA_TAG);
         assertEquals(List.of(), data.get("selected"));
         // available 用平台层(不带门店/收款方),设置页只关心平台开关
-        verify(gateService).listAvailable(PaymentMethodGateService.GateScope.MERCHANT, null, null);
+        verify(gateService).listAvailable(PaymentMethodGateService.GateScope.MERCHANT, null, null, 1L);
     }
 
     @Test

+ 18 - 5
ruoyi-admin/src/test/java/com/ruoyi/app/pay/StorePayMethodControllerTest.java

@@ -55,7 +55,7 @@ class StorePayMethodControllerTest {
         gateService = mock(PaymentMethodGateService.class);
         storeMapper = mock(PosStoreMapper.class);
         controller = new StorePayMethodController(gateService, storeMapper);
-        when(gateService.listAvailable(any(), any(), any())).thenReturn(List.of());
+        when(gateService.listAvailable(any(), any(), any(), any())).thenReturn(List.of());
     }
 
     @Test
@@ -65,17 +65,30 @@ class StorePayMethodControllerTest {
         store.setUserId(7L);
         when(storeMapper.selectById(11)).thenReturn(store);
 
-        AjaxResult result = controller.list(11);
+        AjaxResult result = controller.list(11, null);
 
         assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
-        verify(gateService).listAvailable(PaymentMethodGateService.GateScope.MERCHANT, 11L, 7L);
+        // 老客户端不传 type:orderType=null 口径不含现金,零变化
+        verify(gateService).listAvailable(PaymentMethodGateService.GateScope.MERCHANT, 11L, 7L, null);
+    }
+
+    @Test
+    void diningOrderTypePassesContextForCash() {
+        PosStore store = new PosStore();
+        store.setId(11);
+        store.setUserId(7L);
+        when(storeMapper.selectById(11)).thenReturn(store);
+
+        controller.list(11, 2L);
+
+        verify(gateService).listAvailable(PaymentMethodGateService.GateScope.MERCHANT, 11L, 7L, 2L);
     }
 
     @Test
     void unknownStoreReturnsEmpty() {
         when(storeMapper.selectById(99)).thenReturn(null);
-        AjaxResult result = controller.list(99);
+        AjaxResult result = controller.list(99, null);
         assertEquals(List.of(), result.get(AjaxResult.DATA_TAG));
-        verify(gateService, org.mockito.Mockito.never()).listAvailable(any(), any(), any());
+        verify(gateService, org.mockito.Mockito.never()).listAvailable(any(), any(), any(), any());
     }
 }

+ 52 - 5
ruoyi-system/src/main/java/com/ruoyi/system/service/PaymentMethodGateService.java

@@ -46,11 +46,13 @@ public class PaymentMethodGateService {
     public static final String GROUP_LINE_PAY = "LINE_PAY";
     /** 支付方式组代码:线下转账(payType=6)。 */
     public static final String GROUP_OFFLINE = "OFFLINE_TRANSFER";
+    /** 支付方式组代码:现金(payType=4);仅商家勾选管控,无平台开关行与就绪度(031 US7/FR-006)。 */
+    public static final String GROUP_CASH = "CASH";
 
-    /** 现金 payType=4:仅商家建单场景,明确不纳入闸门管控,直接放行(spec FR-006)。 */
+    /** 现金 payType=4:原 assertUsable 路径(商家建单/闪送缺省现金)直接放行;用户单经 assertUsableForOrder 按订单类型管控。 */
     public static final String PAY_TYPE_CASH = "4";
 
-    /** payType 数值 → 组代码;不在表内的值视为非法。 */
+    /** payType 数值 → 组代码;不在表内的值视为非法(现金"4"仅走专门分支,不进映射)。 */
     private static final Map<String, String> PAY_TYPE_TO_GROUP = Map.of(
             "1", GROUP_COD,
             "2", GROUP_CARD_OMG,
@@ -58,9 +60,9 @@ public class PaymentMethodGateService {
             "5", GROUP_CARD_OMG,
             "6", GROUP_OFFLINE);
 
-    /** 各维度可管控的组集合;RIDER_FLASH 无线上卡支付(闪送资金直达骑手)。 */
+    /** 各维度可管控的组集合;RIDER_FLASH 无线上卡支付(闪送资金直达骑手)且不含现金(缺省支付不经勾选)。 */
     private static final Map<GateScope, List<String>> SCOPE_GROUPS = Map.of(
-            GateScope.MERCHANT, List.of(GROUP_COD, GROUP_CARD_OMG, GROUP_LINE_PAY, GROUP_OFFLINE),
+            GateScope.MERCHANT, List.of(GROUP_COD, GROUP_CARD_OMG, GROUP_LINE_PAY, GROUP_OFFLINE, GROUP_CASH),
             GateScope.RIDER_FLASH, List.of(GROUP_COD, GROUP_LINE_PAY, GROUP_OFFLINE));
 
     /** 闸门维度:商家餐饮单(用户向商家付款)与骑手闪送(用户直达骑手)独立开关。 */
@@ -123,16 +125,51 @@ public class PaymentMethodGateService {
         }
     }
 
+    /**
+     * 带订单类型的校验重载(031 US7):用户餐饮下单统一调用。
+     * 现金在此口径下按订单类型与商家勾选管控:自取/堂食查商家选择,外送拒绝,
+     * orderType 缺失(商家建单/闪送缺省现金)与原 {@link #assertUsable} 口径一致放行。
+     *
+     * @param orderType 订单类型(PosOrder.type:0外送 1自取 2堂食);无类型上下文传 null
+     */
+    public void assertUsableForOrder(String payType, GateScope scope, Long storeId, Long payeeUserId, Long orderType) {
+        if (!isUsableForOrder(payType, scope, storeId, payeeUserId, orderType)) {
+            throw new ServiceException(MessageUtils.message("pay.method.not.available"));
+        }
+    }
+
+    /** 现金订单类型口径的单一判定;非现金支付方式与原 isUsable 完全同路径。 */
+    private boolean isUsableForOrder(String payType, GateScope scope, Long storeId, Long payeeUserId, Long orderType) {
+        if (PAY_TYPE_CASH.equals(payType)) {
+            if (orderType == null || scope == GateScope.RIDER_FLASH) return true;
+            if (orderType != 1L && orderType != 2L) return false;
+            Set<String> selected = selectedGroups(scope, payeeUserId);
+            return selected == null || selected.contains(GROUP_CASH);
+        }
+        return isUsable(payType, scope, storeId, payeeUserId);
+    }
+
     /**
      * 列出某维度对收款方当前可用的支付方式(结算页可选项、骑手端可选集共用)。
      * 返回平台开关 ∩ 收款方选择的全部项并附就绪度;RIDER_FLASH 维度就绪度恒 true
      * (收款方未定或即骑手本人,线下卡是否就绪由骑手自选方式时自行把握,抢单过滤兜底)。
      */
     public List<PayMethodOption> listAvailable(GateScope scope, Long storeId, Long payeeUserId) {
+        return listAvailable(scope, storeId, payeeUserId, null);
+    }
+
+    /**
+     * orderType 感知重载(031 US7):MERCHANT 维度且 orderType∈{1自取,2堂食} 时结果含现金项
+     * (仍受商家勾选约束);其余 orderType 或原签名口径不含现金,老客户端零变化。
+     */
+    public List<PayMethodOption> listAvailable(GateScope scope, Long storeId, Long payeeUserId, Long orderType) {
         Map<String, Boolean> disabled = platformDisabledMap(scope);
         Set<String> selected = selectedGroups(scope, payeeUserId);
+        boolean cashVisible = scope == GateScope.MERCHANT && orderType != null
+                && (orderType == 1L || orderType == 2L);
         List<PayMethodOption> result = new ArrayList<>();
         for (String group : SCOPE_GROUPS.get(scope)) {
+            if (GROUP_CASH.equals(group) && !cashVisible) continue;
             if (disabled.containsKey(group)) continue;
             if (selected != null && !selected.contains(group)) continue;
             boolean ready = isReady(group, scope, storeId, payeeUserId);
@@ -188,7 +225,7 @@ public class PaymentMethodGateService {
      * RIDER_FLASH 维度恒满足(收款方未定,由骑手选择层与抢单过滤兜底)。
      */
     private boolean isReady(String group, GateScope scope, Long storeId, Long payeeUserId) {
-        if (group.equals(GROUP_COD)) return true;
+        if (group.equals(GROUP_COD) || group.equals(GROUP_CASH)) return true;
         if (scope == GateScope.RIDER_FLASH) return true;
         switch (group) {
             case GROUP_CARD_OMG:
@@ -212,6 +249,7 @@ public class PaymentMethodGateService {
             case GROUP_CARD_OMG: return List.of("2", "5");
             case GROUP_LINE_PAY: return List.of("3");
             case GROUP_OFFLINE: return List.of("6");
+            case GROUP_CASH: return List.of(PAY_TYPE_CASH);
             default: return List.of();
         }
     }
@@ -226,6 +264,15 @@ public class PaymentMethodGateService {
         return SCOPE_GROUPS.get(scope);
     }
 
+    /** 平台开关矩阵的组集合:值域剔除 CASH——现金无平台开关行(spec FR-006/US7);选择值域仍用 {@link #groupsOf}。 */
+    public static List<String> platformGroupsOf(GateScope scope) {
+        List<String> result = new ArrayList<>();
+        for (String group : groupsOf(scope)) {
+            if (!GROUP_CASH.equals(group)) result.add(group);
+        }
+        return result;
+    }
+
     /** payType → 组代码映射查询(保存/展示场景用);未知返回 null。 */
     public static String groupOfPayType(String payType) {
         return PAY_TYPE_TO_GROUP.get(payType);

+ 67 - 1
ruoyi-system/src/test/java/com/ruoyi/system/service/PaymentMethodGateServiceTest.java

@@ -158,9 +158,75 @@ class PaymentMethodGateServiceTest {
 
     @Test
     void cashBypassesGateAlways() {
-        // 现金仅商家建单场景,不纳入管控:任何维度无任何依赖直接放行
+        // 现金经原 assertUsable(商家建单/闪送缺省现金路径):不受管控直接放行(现状不变)
         assertDoesNotThrow(() ->
                 service.assertUsable("4", PaymentMethodGateService.GateScope.MERCHANT, null, null));
+        assertDoesNotThrow(() ->
+                service.assertUsable("4", PaymentMethodGateService.GateScope.RIDER_FLASH, null, null));
+    }
+
+    @Test
+    void cashForDiningOrdersRequiresMerchantSelection() {
+        // US7:自取/堂食现金 = 商家勾选层(NULL=全部接受);未勾选 CASH 拒绝
+        when(userMapper.selectById(7L)).thenReturn(merchant("COD"));
+        assertThrows(ServiceException.class, () ->
+                service.assertUsableForOrder("4", PaymentMethodGateService.GateScope.MERCHANT, 1L, 7L, 1L));
+        when(userMapper.selectById(7L)).thenReturn(merchant("COD,CASH"));
+        assertDoesNotThrow(() ->
+                service.assertUsableForOrder("4", PaymentMethodGateService.GateScope.MERCHANT, 1L, 7L, 1L));
+        when(userMapper.selectById(7L)).thenReturn(merchant(null));
+        assertDoesNotThrow(() ->
+                service.assertUsableForOrder("4", PaymentMethodGateService.GateScope.MERCHANT, 1L, 7L, 2L));
+    }
+
+    @Test
+    void cashRejectedForDeliveryOrderType() {
+        // 外送订单(type=0)直传现金必拒,即使商家勾选了 CASH
+        when(userMapper.selectById(7L)).thenReturn(merchant("CASH"));
+        ServiceException exception = assertThrows(ServiceException.class, () ->
+                service.assertUsableForOrder("4", PaymentMethodGateService.GateScope.MERCHANT, 1L, 7L, 0L));
+        assertEquals("该支付方式当前不可用,请更换支付方式", exception.getMessage());
+    }
+
+    @Test
+    void cashWithoutOrderTypeKeepsLegacyPass() {
+        // orderType 缺失(商家建单/闪送缺省现金)保持现状放行,与原 assertUsable 口径一致
+        when(userMapper.selectById(7L)).thenReturn(merchant("COD"));
+        assertDoesNotThrow(() ->
+                service.assertUsableForOrder("4", PaymentMethodGateService.GateScope.MERCHANT, 1L, 7L, null));
+    }
+
+    @Test
+    void listAvailableWithDiningOrderTypeIncludesCash() {
+        when(userMapper.selectById(7L)).thenReturn(merchant(null));
+        List<PaymentMethodGateService.PayMethodOption> dining =
+                service.listAvailable(PaymentMethodGateService.GateScope.MERCHANT, 1L, 7L, 1L);
+        // 自取/堂食口径:5 个线上 payType 项 + 现金 1 项
+        assertEquals(6, dining.size());
+        PaymentMethodGateService.PayMethodOption cash = dining.stream()
+                .filter(o -> "4".equals(o.getPayType())).findFirst().orElseThrow();
+        assertTrue(cash.isReady(), "现金无就绪度要求");
+        // 老口径(不传 type)与外送口径均不含现金
+        assertEquals(5, service.listAvailable(PaymentMethodGateService.GateScope.MERCHANT, 1L, 7L).size());
+        assertEquals(5, service.listAvailable(PaymentMethodGateService.GateScope.MERCHANT, 1L, 7L, 0L).size());
+    }
+
+    @Test
+    void listAvailableCashRespectsMerchantSelection() {
+        when(userMapper.selectById(7L)).thenReturn(merchant("COD"));
+        List<PaymentMethodGateService.PayMethodOption> options =
+                service.listAvailable(PaymentMethodGateService.GateScope.MERCHANT, 1L, 7L, 1L);
+        assertTrue(options.stream().noneMatch(o -> "4".equals(o.getPayType())), "商家未勾选现金时不返回现金项");
+    }
+
+    @Test
+    void cashGroupOnlyInMerchantScopeDomains() {
+        // 值域:商家勾选可选 CASH;平台开关矩阵与骑手闪送维度不含 CASH
+        assertTrue(PaymentMethodGateService.groupsOf(PaymentMethodGateService.GateScope.MERCHANT)
+                .contains(PaymentMethodGateService.GROUP_CASH));
+        assertTrue(!PaymentMethodGateService.groupsOf(PaymentMethodGateService.GateScope.RIDER_FLASH)
+                .contains(PaymentMethodGateService.GROUP_CASH));
+        assertTrue(!PaymentMethodGateService.allGroups().contains(PaymentMethodGateService.GROUP_CASH));
     }
 
     @Test

+ 9 - 0
specs/031-pay-method-config/quickstart.md

@@ -63,3 +63,12 @@ WHERE bank_name<>'' AND bank_account_no<>'' AND bank_account_name<>''
 
 - admin-vue 支付方式设置页:矩阵渲染、开关切换、@Log 入库、四语言切换无 key 裸奔
 - foodie-store 支付设置页:勾选保存→再进回显;银行卡增删改启用;四语言
+
+## 7. 自取/堂食现金支付(SC-007,2026-09-20 新增)
+
+1. foodie-store 支付设置页:方式勾选区出现「现金」项(后端 available 已含 CASH);勾选并保存 → `info_user.pay_methods` 含 CASH
+2. 用户创建自取或堂食订单(type=1/2)选现金:下单成功,`pay_status=1`,订单列表直接在"进行中",不出现在"待付款"
+3. `GET /system/storePayMethods?storeId=X&type=1` 返回含 `{methodCode:"CASH", payType:"4", ready:true}`;不传 type 或 type=0 不含现金项
+4. 外送订单(type=0)直传 paymentMethod=4 下单 → 拒绝,返回国际化"该支付方式当前不可用"
+5. 商家取消勾选现金后:结算页(type=1/2)不再返回现金项;绕过界面直传现金下单同样被拒
+6. 未做任何设置的存量商家(pay_methods 为 NULL):自取/堂食现金默认可选(等于"全部接受"),其余行为零变化

+ 5 - 5
specs/031-pay-method-config/tasks.md

@@ -132,11 +132,11 @@ description: "Task list for 031-pay-method-config"
 
 ## Phase 10: 自取/堂食现金支付(US7,2026-09-20 新增,2026-09-20 起基于 test-202609v2)
 
-- [ ] T026 闸门改造:`PaymentMethodGateService` 增 `assertUsableForOrder(payType, scope, storeId, payeeUserId, orderType)` 重载与 `GROUP_CASH`/"CASH" 常量;现金判定=商家勾选(pay_methods 含 CASH 或 NULL=全部),无平台开关无就绪度;`orderType=0` 拒(复用 `pay.method.not.available`);`groupsOf(MERCHANT)` 含 CASH、`allGroups()` 不含;原 `assertUsable` 四处调用点行为不变(商家建单/闪送/bankInfo 现状放行);单测(勾选含/不含 CASH、NULL 默认、type 0 拒、type 1/2 过、原入口零变化)
-- [ ] T027 用户下单接入:`UserOrderController.createOrder` 改调 `assertUsableForOrder`(传 `item.getType()`);现金自取/堂食单 payStatus 置 1(同到付语义,不进待付款);订单列表 `active` 页签过滤放行 `payType=4 + type∈{1,2}`(`unpaid` 过滤已天然不含 4,核对即可);取消/售后路径与到付对齐核对;单测(现金自取创建、外送直传 4 拒、列表页签归属)
-- [ ] T028 结算页接口:`GET /system/storePayMethods` 增可选 `@RequestParam(required=false) Long type`;`type∈{1,2}` 且商家接受时追加 `{methodCode:"CASH", payType:"4", ready:true}`;不传/`type=0` 不追加(老客户端零变化);契约单测
-- [ ] T029 商家端 PC(foodie-store,test-202609v2):`PayMethodSettings.vue` 勾选列表加「现金」项(值域含 CASH,i18n 四语言文件 key 如 `payMethodCash`,写法遵循 `$t()` 层级规范);admin-vue 平台矩阵**不**加现金行(无平台开关);提交前跑 admin-vue `node tests/flash-delivery.test.cjs` 防误伤
-- [ ] T030 回归与核对:`mvn compile` + 闪送/订单相关定向测试不回归;grep 确认 `assertUsable` 白名单调用点更新无遗漏;quickstart 场景补充现金用例(商家勾选→自取下单选现金→进行中可见→取消行为同到付)
+- [x] T026 闸门改造:`PaymentMethodGateService` 增 `assertUsableForOrder(payType, scope, storeId, payeeUserId, orderType)` 重载与 `GROUP_CASH`/"CASH" 常量;现金判定=商家勾选(pay_methods 含 CASH 或 NULL=全部),无平台开关无就绪度;`orderType=0` 拒(复用 `pay.method.not.available`);`groupsOf(MERCHANT)` 含 CASH、`allGroups()` 不含;原 `assertUsable` 四处调用点行为不变(商家建单/闪送/bankInfo 现状放行);单测(勾选含/不含 CASH、NULL 默认、type 0 拒、type 1/2 过、原入口零变化)
+- [x] T027 用户下单接入:`UserOrderController.createOrder` 改调 `assertUsableForOrder`(传 `item.getType()`);现金自取/堂食单 payStatus 置 1(同到付语义,不进待付款);订单列表 `active` 页签过滤放行 `payType=4 + type∈{1,2}`(`unpaid` 过滤已天然不含 4,核对即可);取消/售后路径与到付对齐核对;单测(现金自取创建、外送直传 4 拒、列表页签归属)
+- [x] T028 结算页接口:`GET /system/storePayMethods` 增可选 `@RequestParam(required=false) Long type`;`type∈{1,2}` 且商家接受时追加 `{methodCode:"CASH", payType:"4", ready:true}`;不传/`type=0` 不追加(老客户端零变化);契约单测
+- [x] T029 商家端 PC(foodie-store,test-202609v2):`PayMethodSettings.vue` 勾选列表加「现金」项(值域含 CASH,i18n 四语言文件 key 如 `payMethodCash`,写法遵循 `$t()` 层级规范);admin-vue 平台矩阵**不**加现金行(无平台开关);提交前跑 admin-vue `node tests/flash-delivery.test.cjs` 防误伤
+- [x] T030 回归与核对:`mvn compile` + 闪送/订单相关定向测试不回归;grep 确认 `assertUsable` 白名单调用点更新无遗漏;quickstart 场景补充现金用例(商家勾选→自取下单选现金→进行中可见→取消行为同到付)
 
 ---