Преглед изворни кода

feat: 商家支付设置页补未就绪标记——闸门新增 listAvailableForMerchantSettings 按名下门店聚合就绪度(任一门店启用凭证即就绪,线下转账看主账号启用卡),前端对 ready=false 打标

qmj пре 2 недеља
родитељ
комит
72198d5d9f

+ 16 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/pay/MerchantPayMethodController.java

@@ -1,5 +1,6 @@
 package com.ruoyi.app.pay;
 
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.ruoyi.app.pay.dto.MerchantPayMethodSaveDto;
 import com.ruoyi.common.annotation.Anonymous;
@@ -8,7 +9,9 @@ import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosStore;
 import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.service.MerchantStoreAccessService;
 import com.ruoyi.system.service.PaymentMethodGateService;
 import com.ruoyi.system.utils.Auth;
@@ -38,27 +41,35 @@ public class MerchantPayMethodController extends BaseController {
     private final PaymentMethodGateService gateService;
     private final InfoUserMapper userMapper;
     private final MerchantStoreAccessService merchantStoreAccessService;
+    private final PosStoreMapper storeMapper;
 
     public MerchantPayMethodController(PaymentMethodGateService gateService,
                                        InfoUserMapper userMapper,
-                                       MerchantStoreAccessService merchantStoreAccessService) {
+                                       MerchantStoreAccessService merchantStoreAccessService,
+                                       PosStoreMapper storeMapper) {
         this.gateService = gateService;
         this.userMapper = userMapper;
         this.merchantStoreAccessService = merchantStoreAccessService;
+        this.storeMapper = storeMapper;
     }
 
     /**
-     * 设置页数据:available=平台商家维度开放的组(不带门店级就绪度,设置页只关心平台开关)
-     * 追加现金项(031 US7:orderType 传 1 按自取/堂食口径返回含 CASH 的全集),
-     * selected=商家当前选择(NULL 视为全部,返回空数组由前端展示为全选)。
+     * 设置页数据:available=平台商家维度开放的全集,附按名下门店聚合的就绪度
+     * (线上渠道任一门店有启用凭证即算就绪;线下转账看主账号启用卡),
+     * 未就绪项由前端加「未就绪」标记提示;selected=商家当前选择(NULL 视为全部,返回空数组由前端展示为全选)。
      */
     @Auth
     @GetMapping
     public AjaxResult get(@RequestHeader String token) {
         Long ownerUserId = resolveOwner(token);
         InfoUser owner = userMapper.selectById(ownerUserId);
+        List<Long> storeIds = new ArrayList<>();
+        for (PosStore store : storeMapper.selectList(new QueryWrapper<PosStore>()
+                .eq("user_id", ownerUserId).select("id"))) {
+            storeIds.add(store.getId().longValue());
+        }
         List<PaymentMethodGateService.PayMethodOption> available =
-                gateService.listAvailable(PaymentMethodGateService.GateScope.MERCHANT, null, null, 1L);
+                gateService.listAvailableForMerchantSettings(ownerUserId, storeIds);
         List<String> selected = new ArrayList<>();
         String csv = owner == null ? null : owner.getPayMethods();
         if (csv != null && !csv.trim().isEmpty()) {

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

@@ -41,6 +41,7 @@ class MerchantPayMethodControllerTest {
     private PaymentMethodGateService gateService;
     private InfoUserMapper userMapper;
     private MerchantStoreAccessService merchantStoreAccessService;
+    private com.ruoyi.system.mapper.PosStoreMapper storeMapper;
     private com.ruoyi.system.utils.JwtUtil jwtUtil;
     private static ConfigurableListableBeanFactory originalBeanFactory;
 
@@ -66,8 +67,9 @@ class MerchantPayMethodControllerTest {
         gateService = mock(PaymentMethodGateService.class);
         userMapper = mock(InfoUserMapper.class);
         merchantStoreAccessService = mock(MerchantStoreAccessService.class);
-        controller = new MerchantPayMethodController(gateService, userMapper, merchantStoreAccessService);
-        when(gateService.listAvailable(any(), any(), any(), any())).thenReturn(List.of());
+        storeMapper = mock(com.ruoyi.system.mapper.PosStoreMapper.class);
+        controller = new MerchantPayMethodController(gateService, userMapper, merchantStoreAccessService, storeMapper);
+        when(gateService.listAvailableForMerchantSettings(any(), any())).thenReturn(List.of());
         // 子账号 9 定位主账号 7
         MerchantAccessContext context = mock(MerchantAccessContext.class);
         when(context.ownerUserId()).thenReturn(7L);
@@ -96,8 +98,24 @@ class MerchantPayMethodControllerTest {
         @SuppressWarnings("unchecked")
         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, 1L);
+        // available 带按名下门店聚合的就绪度;无门店时传空列表(未就绪由前端标记展示)
+        verify(gateService).listAvailableForMerchantSettings(7L, List.of());
+        verify(storeMapper).selectList(any());
+    }
+
+    @Test
+    void getPassesAggregatedStoreIdsToGate() {
+        InfoUser owner = new InfoUser();
+        owner.setUserId(7L);
+        when(userMapper.selectById(7L)).thenReturn(owner);
+        com.ruoyi.system.domain.PosStore store = new com.ruoyi.system.domain.PosStore();
+        store.setId(11);
+        when(storeMapper.selectList(any())).thenReturn(List.of(store));
+
+        controller.get(tokenFor(9L));
+
+        // Integer 主键转 Long 门店 ID 传闸门
+        verify(gateService).listAvailableForMerchantSettings(7L, List.of(11L));
     }
 
     @Test

+ 130 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/PaymentMethodGateSettingsReadyTest.java

@@ -0,0 +1,130 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.system.domain.PaymentMethodConfig;
+import com.ruoyi.system.mapper.InfoBankCardMapper;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.PaymentMethodConfigMapper;
+import com.ruoyi.system.mapper.PosStoreLinePayMapper;
+import com.ruoyi.system.mapper.PosStoreOmgMapper;
+import com.ruoyi.system.service.PaymentMethodGateService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 商家支付设置页就绪度聚合单测(031):available=平台开放全集,
+ * 线上渠道任一门店有启用凭证即就绪,线下转账看主账号启用卡,COD/现金恒就绪。
+ */
+class PaymentMethodGateSettingsReadyTest {
+
+    private PaymentMethodGateService gateService;
+    private PaymentMethodConfigMapper configMapper;
+    private InfoUserMapper userMapper;
+    private InfoBankCardMapper bankCardMapper;
+    private PosStoreOmgMapper omgMapper;
+    private PosStoreLinePayMapper linePayMapper;
+
+    @BeforeEach
+    void setUp() {
+        configMapper = mock(PaymentMethodConfigMapper.class);
+        userMapper = mock(InfoUserMapper.class);
+        bankCardMapper = mock(InfoBankCardMapper.class);
+        omgMapper = mock(PosStoreOmgMapper.class);
+        linePayMapper = mock(PosStoreLinePayMapper.class);
+        gateService = new PaymentMethodGateService(
+                configMapper, userMapper, bankCardMapper, omgMapper, linePayMapper);
+        // 平台缺行=开;selectCount 缺省返回 null 会拆箱 NPE,统一补 0
+        when(configMapper.selectList(any())).thenReturn(List.of());
+        when(bankCardMapper.selectCount(any())).thenReturn(0L);
+        when(omgMapper.selectCount(any())).thenReturn(0L);
+        when(linePayMapper.selectCount(any())).thenReturn(0L);
+    }
+
+    private PaymentMethodGateService.PayMethodOption option(
+            List<PaymentMethodGateService.PayMethodOption> options, String payType) {
+        return options.stream().filter(item -> payType.equals(item.getPayType()))
+                .findFirst().orElseThrow(() -> new AssertionError("缺少 payType " + payType));
+    }
+
+    @Test
+    void credentialMissingMarksCardAndLineNotReadyWhileCodCashReady() {
+        List<PaymentMethodGateService.PayMethodOption> options =
+                gateService.listAvailableForMerchantSettings(7L, List.of(11L));
+
+        assertFalse(option(options, "2").isReady());
+        assertFalse(option(options, "5").isReady());
+        assertFalse(option(options, "3").isReady());
+        assertFalse(option(options, "6").isReady());
+        assertTrue(option(options, "1").isReady());
+        assertTrue(option(options, "4").isReady());
+    }
+
+    @Test
+    void anyStoreCredentialMakesWholeGroupReady() {
+        // 两家门店:第一查无凭证、第二有 → CARD_OMG(2/5)整体就绪;组内就绪度一致
+        when(omgMapper.selectCount(any())).thenReturn(0L, 1L);
+
+        List<PaymentMethodGateService.PayMethodOption> options =
+                gateService.listAvailableForMerchantSettings(7L, List.of(11L, 12L));
+
+        assertTrue(option(options, "2").isReady());
+        assertTrue(option(options, "5").isReady());
+        assertFalse(option(options, "3").isReady());
+    }
+
+    @Test
+    void linePayReadyFollowsLinePayCredentialOnly() {
+        when(linePayMapper.selectCount(any())).thenReturn(1L);
+
+        List<PaymentMethodGateService.PayMethodOption> options =
+                gateService.listAvailableForMerchantSettings(7L, List.of(11L));
+
+        assertTrue(option(options, "3").isReady());
+        assertFalse(option(options, "2").isReady());
+    }
+
+    @Test
+    void offlineReadyRequiresOwnerActiveCard() {
+        List<PaymentMethodGateService.PayMethodOption> withoutCard =
+                gateService.listAvailableForMerchantSettings(7L, List.of(11L));
+        assertFalse(option(withoutCard, "6").isReady());
+
+        when(bankCardMapper.selectCount(any())).thenReturn(1L);
+        List<PaymentMethodGateService.PayMethodOption> withCard =
+                gateService.listAvailableForMerchantSettings(7L, List.of(11L));
+        assertTrue(option(withCard, "6").isReady());
+    }
+
+    @Test
+    void platformDisabledGroupExcludedFromSettingsAvailable() {
+        PaymentMethodConfig disabled = new PaymentMethodConfig();
+        disabled.setMethodCode("CARD_OMG");
+        disabled.setScope("MERCHANT");
+        disabled.setEnabled(0);
+        when(configMapper.selectList(any())).thenReturn(List.of(disabled));
+
+        List<PaymentMethodGateService.PayMethodOption> options =
+                gateService.listAvailableForMerchantSettings(7L, List.of(11L));
+
+        assertFalse(options.stream().anyMatch(item -> "CARD_OMG".equals(item.getMethodCode())));
+        assertEquals("1", option(options, "1").getPayType());
+    }
+
+    @Test
+    void cardOmgExpandsToTwoPayTypesWithSameReady() {
+        List<PaymentMethodGateService.PayMethodOption> options =
+                gateService.listAvailableForMerchantSettings(7L, List.of(11L));
+
+        assertEquals(2, options.stream().filter(item -> "CARD_OMG".equals(item.getMethodCode())).count());
+        assertEquals("2", option(options, "2").getPayType());
+        assertEquals("5", option(options, "5").getPayType());
+    }
+}

+ 42 - 4
ruoyi-system/src/main/java/com/ruoyi/system/service/PaymentMethodGateService.java

@@ -221,6 +221,37 @@ public class PaymentMethodGateService {
         return groups.isEmpty() ? null : groups;
     }
 
+    /**
+     * 商家支付设置页可选集:平台开放全集(不按商家已有选择过滤——页面本身用于修改选择),
+     * 就绪度按名下门店聚合——线上渠道任一门店有启用凭证即算就绪,线下转账看主账号启用卡。
+     * 供 GET /system/merchantPayMethods 使用;结算页口径见 {@link #listAvailable}。
+     */
+    public List<PayMethodOption> listAvailableForMerchantSettings(Long ownerUserId, List<Long> storeIds) {
+        Map<String, Boolean> disabled = platformDisabledMap(GateScope.MERCHANT);
+        List<PayMethodOption> result = new ArrayList<>();
+        for (String group : SCOPE_GROUPS.get(GateScope.MERCHANT)) {
+            if (disabled.containsKey(group)) continue;
+            boolean ready;
+            if (GROUP_CARD_OMG.equals(group) || GROUP_LINE_PAY.equals(group)) {
+                ready = false;
+                if (storeIds != null) {
+                    for (Long storeId : storeIds) {
+                        if (storeCredentialReady(group, storeId)) { ready = true; break; }
+                    }
+                }
+            } else if (GROUP_OFFLINE.equals(group)) {
+                ready = ownerUserId != null && bankCardMapper.selectCount(
+                        new QueryWrapper<InfoBankCard>().eq("user_id", ownerUserId).eq("is_active", 1)) > 0;
+            } else {
+                ready = true;
+            }
+            for (String payType : payTypesOf(group)) {
+                result.add(new PayMethodOption(group, payType, ready));
+            }
+        }
+        return result;
+    }
+
     /**
      * 就绪度判定:MERCHANT 维度线上渠道看门店级凭证、线下转账看商家启用卡;
      * RIDER_FLASH 维度仅线下转账在骑手身份已知时要求启用卡。
@@ -234,11 +265,8 @@ public class PaymentMethodGateService {
         }
         switch (group) {
             case GROUP_CARD_OMG:
-                return storeId != null && omgMapper.selectCount(
-                        new QueryWrapper<PosStoreOmg>().eq("store_id", storeId).eq("is_enabled", 1)) > 0;
             case GROUP_LINE_PAY:
-                return storeId != null && linePayMapper.selectCount(
-                        new QueryWrapper<PosStoreLinePay>().eq("store_id", storeId).eq("is_enabled", 1)) > 0;
+                return storeId != null && storeCredentialReady(group, storeId);
             case GROUP_OFFLINE:
                 return payeeUserId != null && bankCardMapper.selectCount(
                         new QueryWrapper<InfoBankCard>().eq("user_id", payeeUserId).eq("is_active", 1)) > 0;
@@ -247,6 +275,16 @@ public class PaymentMethodGateService {
         }
     }
 
+    /** 门店级线上渠道凭证就绪:存在 is_enabled=1 的 OMG/LINE Pay 配置行。 */
+    private boolean storeCredentialReady(String group, Long storeId) {
+        if (GROUP_CARD_OMG.equals(group)) {
+            return omgMapper.selectCount(
+                    new QueryWrapper<PosStoreOmg>().eq("store_id", storeId).eq("is_enabled", 1)) > 0;
+        }
+        return linePayMapper.selectCount(
+                new QueryWrapper<PosStoreLinePay>().eq("store_id", storeId).eq("is_enabled", 1)) > 0;
+    }
+
     /** 组代码 → 对外 payType 展开顺序(保持稳定展示顺序);供抢单过滤等调用方展开。 */
     public static List<String> payTypesOf(String group) {
         switch (group) {

+ 1 - 1
specs/031-pay-method-config/contracts/api.md

@@ -69,7 +69,7 @@ Body: { items: [ { methodCode, scope, enabled } ] }
 GET /system/merchantPayMethods
 ```
 
-返回:`{ available: [ {methodCode, methodName, ready} ], selected: ["COD", ...] }`——available = 平台商家维度开放集合(附就绪度标记)**追加 CASH 项(2026-09-20 变更:无平台开关、ready 恒 true,是否可用由商家勾选决定)**,selected = 商家当前选择(NULL 视为全部,返回时展开为全部可用项含 CASH)。
+返回:`{ available: [ {methodCode, methodName, ready} ], selected: ["COD", ...] }`——available = 平台商家维度开放集合(附就绪度标记)**追加 CASH 项(2026-09-20 变更:无平台开关、ready 恒 true,是否可用由商家勾选决定)**,selected = 商家当前选择(NULL 视为全部,返回时展开为全部可用项含 CASH)。**就绪度口径(2026-09-29 变更)**:不再传 null(此前 CARD_OMG/LINE_PAY 恒 false),改为 `listAvailableForMerchantSettings(ownerUserId, 门店IDs)`——线上渠道按商家名下门店聚合(任一门店有启用凭证即 ready=true)、线下转账看主账号启用卡、COD/现金恒 true;前端对 ready=false 项渲染「未就绪」标签。
 
 ### 2.2 保存选择
 

+ 1 - 0
specs/031-pay-method-config/tasks.md

@@ -144,6 +144,7 @@ description: "Task list for 031-pay-method-config"
 - [x] T032 未配置商家默认全选展示:`PayMethodSettings.vue` `loadMethods()` 在 `selected` 为空(NULL/[])时用 available 全集填充展示,对齐后端「NULL=接受全部」口径(保存空数组仍回落全部)(9fb13cf)
 - [x] T033 银行名称下拉数据源修正:`api/payMethod.js` `listTaiwanBanks()` 由不存在的 `/system/bankList` 改调已有 `GET /infouser/user/bankList`(@Anonymous,字典 taiwan_bank_list,dictLabel/dictValue 与下拉字段匹配,后端零改动)(a5b5857)
 - [x] T034 保存 SQL 修复:`MerchantPayMethodController`/`RiderFlashPayMethodController` 的 UpdateWrapper 去掉 `set("update_time",...)`(info_user 无该列,联调保存报 Unknown column),顺带清理孤儿 Date import(afc8998)
+- [x] T035 设置页未就绪标记(2026-09-29):`MerchantPayMethodController.get` 查名下门店 ID 调闸门新方法 `listAvailableForMerchantSettings`(线上渠道任一门店有启用凭证即就绪、线下转账看主账号启用卡、COD/现金恒就绪);foodie-store `PayMethodSettings.vue` 对 `ready=false` 项加「未就绪」标签 + 警示条(i18n 四语言 `payMethod.notReadyTag`/`notReadyTip`);闸门级单测 `PaymentMethodGateSettingsReadyTest`(6 用例)
 
 ---