fix(merchant): 开户行按银行名与支行名分别匹配,城市降级为本地优先排序

商户在进件里搜不到「农行邻水丰禾支行」,而这条网点在目录里是存在的
(103676368390 中国农业银行股份有限公司邻水丰禾支行)。搜不到是三层原因叠加,
每一层单独都足以把它挡掉:

- 城市是硬过滤:前端传了开户城市就追加 `and branch_name like '%城市%'`,而目录里的支行名
由县区名构成(「邻水丰禾支行」里没有「广安」),整批县区级网点被筛掉。开户城市选攀枝花时
结果集被锁死在 98 条里,把官方全名一字不差地输进去同样是 0 条。
- 简称不是子串:「农行」不是「中国农业银行」的子串,全库命中 0 条。
- 整串不连续:「农行邻水丰禾支行」在官方全名里中间隔着「股份有限公司」,子串匹配对不上。

改法:新增 BankBranchKeyword 把关键词拆成银行名与支行名两段,分别对 bank_name 与 branch_name
匹配,并把常见银行简称归一(农行、工行、建行、邮储、农商行、浦发等);空格一律去掉,所以
「农行 邻水 丰禾」也能命中;纯数字按联行号片段匹配,只记得后几位也搜得到。城市不再作为过滤
条件,改为本地网点优先排序,跨市开户的商户仍能搜到自己的开户行。

前端空关键词不再发请求(否则会拿到一份按行名排序的全国网点名单),补未搜索与未匹配两种空态,
并修掉「请尝试输入银行简称」这句已经不成立的提示。

回归:BankBranchKeywordTests(9 项解析用例)、BankBranchSearchTests(5 项对真实 MySQL 走完整
服务调用的检索用例,覆盖组合谓词与参数顺序)。
3个文件已添加
2个文件已修改
344 ■■■■■ 已修改文件
rencaika_merchant/pages/bank-branch-select/bank-branch-select.uvue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/BankBranchKeyword.java 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/BankBranchKeywordTests.java 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/BankBranchSearchTests.java 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/bank-branch-select/bank-branch-select.uvue
@@ -6,8 +6,8 @@
        </view>
        <view class="search-section">
            <view class="search-box"><input class="search-input" v-model="searchKeyword" maxlength="30" placeholder="搜索银行或支行名称" placeholder-class="search-placeholder" /><image class="search-icon" src="/static/icons/lucide/search.svg" mode="aspectFit"></image></view>
            <text class="city-tip">当前开户城市:{{cityName}}</text>
            <view class="search-box"><input class="search-input" v-model="searchKeyword" maxlength="30" placeholder="输入支行名称关键字或联行号" placeholder-class="search-placeholder" /><image class="search-icon" src="/static/icons/lucide/search.svg" mode="aspectFit"></image></view>
            <text class="city-tip">开户城市:{{cityName}}({{cityName}}的网点优先,开户行不限城市)</text>
        </view>
        <scroll-view class="branch-scroll" scroll-y="true" :show-scrollbar="false" :enhanced="false">
@@ -20,7 +20,8 @@
            </view>
            <view v-if="loading" class="empty-state"><text class="empty-title">正在加载开户行</text></view>
            <view v-else-if="loadError" class="empty-state" @tap="loadBranches"><text class="empty-title">开户行加载失败</text><text class="empty-tip">点击重新加载</text></view>
            <view v-else-if="branches.length == 0" class="empty-state"><text class="empty-title">未找到匹配的开户行</text><text class="empty-tip">请尝试输入银行简称或支行名称</text></view>
            <view v-else-if="!searched" class="empty-state"><text class="empty-title">输入支行名称或联行号搜索</text><text class="empty-tip">开户行不限开户城市;也可输入 12 位联行号</text></view>
            <view v-else-if="branches.length == 0" class="empty-state" @tap="loadBranches"><text class="empty-title">未找到匹配的开户行</text><text class="empty-tip">可换用支行名称中的关键字(如"邻水丰禾"),或输入联行号;点击重试</text></view>
            <view class="safe-space"></view>
        </scroll-view>
    </view>
@@ -36,21 +37,35 @@
    const branches = ref<any[]>([])
    const loading = ref(false)
    const loadError = ref(false)
    const searched = ref(false)
    let searchTimer : number | null = null
    const goBack = () => {
        uni.navigateBack()
    }
    // 开户城市只作为「本地网点优先」的排序提示传给后端,不是过滤条件:后端按城市名子串匹配支行名,
    // 会把「邻水丰禾支行」这类以县区命名的网点整批筛掉(支行名里只有县区名,没有地市名)。
    // 空关键词不发请求:后端在没有关键词时返回的是按行名排序的网点名单,没有意义。
    const loadBranches = () => {
        const keyword = searchKeyword.value.trim()
        if (keyword.length == 0) {
            branches.value = []
            loading.value = false
            loadError.value = false
            searched.value = false
            return
        }
        loading.value = true
        loadError.value = false
        loadBankBranches(cityCode.value, cityName.value, searchKeyword.value.trim(), (items: any[]) => {
        loadBankBranches(cityCode.value, cityName.value, keyword, (items: any[]) => {
            branches.value = items
            loading.value = false
            searched.value = true
        }, () => {
            loading.value = false
            loadError.value = true
            searched.value = true
        })
    }
@@ -67,7 +82,6 @@
    onLoad((options: any) => {
        if (options.city != null && options.city != '') cityName.value = decodeURIComponent(options.city as string)
        if (options.cityCode != null && options.cityCode != '') cityCode.value = decodeURIComponent(options.cityCode as string)
        loadBranches()
    })
</script>
talent-card-server/src/main/java/com/talentcard/server/business/merchant/BankBranchKeyword.java
New file
@@ -0,0 +1,78 @@
package com.talentcard.server.business.merchant;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
/**
 * 开户行搜索关键词的解析结果,以及把用户写法映射到目录写法的银行名表。
 *
 * <p>为什么要拆:目录里的 {@code branch_name} 是「中国农业银行股份有限公司邻水丰禾支行」这样的全称,
 * 而用户输入的是「农行邻水丰禾支行」——既用了简称,又在银行名与支行名之间少了「股份有限公司」,
 * 整串并不连续,所以 {@code like '%农行邻水丰禾支行%'} 永远匹配不到。这里把关键词拆成「银行名」和
 * 「支行名」两段,分别对 {@code bank_name} 与 {@code branch_name} 匹配,两段各自允许不完整。
 *
 * <p>{@code clearingCode} 非空表示用户输入的是联行号(或其片段),此时按联行号匹配、不再猜名字。
 *
 * <p>解析是纯函数,不查库。
 */
record BankBranchKeyword(String bankName, String branchName, String clearingCode) {
    /**
     * 用户可能的银行写法(含常见简称)→ 目录 {@code bank_name} 里确实存在的片段。
     *
     * <p>值刻意取不含「中国」前缀的片段:目录里既有「中国农业银行」也有「交通银行」「招商银行」
     * 两种写法,用片段匹配可以同时覆盖。
     */
    private static final Map<String, String> BANK_SPELLINGS = Map.ofEntries(
            Map.entry("中国农业银行", "农业银行"), Map.entry("农业银行", "农业银行"), Map.entry("农行", "农业银行"),
            Map.entry("中国工商银行", "工商银行"), Map.entry("工商银行", "工商银行"), Map.entry("工行", "工商银行"),
            Map.entry("中国建设银行", "建设银行"), Map.entry("建设银行", "建设银行"), Map.entry("建行", "建设银行"),
            Map.entry("中国银行", "中国银行"), Map.entry("中行", "中国银行"),
            Map.entry("中国邮政储蓄银行", "邮政储蓄银行"), Map.entry("邮政储蓄银行", "邮政储蓄银行"),
            Map.entry("邮政储蓄", "邮政储蓄银行"), Map.entry("邮储银行", "邮政储蓄银行"), Map.entry("邮储", "邮政储蓄银行"),
            Map.entry("中国农业发展银行", "农业发展银行"), Map.entry("农业发展银行", "农业发展银行"),
            Map.entry("农发行", "农业发展银行"),
            Map.entry("中国人民银行", "中国人民银行"), Map.entry("人民银行", "中国人民银行"), Map.entry("人行", "中国人民银行"),
            Map.entry("交通银行", "交通银行"), Map.entry("交行", "交通银行"),
            Map.entry("招商银行", "招商银行"), Map.entry("招行", "招商银行"),
            Map.entry("中信银行", "中信银行"), Map.entry("中信", "中信银行"),
            Map.entry("上海浦东发展银行", "浦东发展银行"), Map.entry("浦东发展银行", "浦东发展银行"),
            Map.entry("浦发银行", "浦东发展银行"), Map.entry("浦发", "浦东发展银行"),
            Map.entry("兴业银行", "兴业银行"), Map.entry("兴业", "兴业银行"),
            Map.entry("中国民生银行", "民生银行"), Map.entry("民生银行", "民生银行"), Map.entry("民生", "民生银行"),
            Map.entry("中国光大银行", "光大银行"), Map.entry("光大银行", "光大银行"), Map.entry("光大", "光大银行"),
            Map.entry("华夏银行", "华夏银行"), Map.entry("华夏", "华夏银行"),
            Map.entry("平安银行", "平安银行"), Map.entry("平安", "平安银行"),
            Map.entry("广发银行", "广发银行"), Map.entry("广发", "广发银行"),
            Map.entry("恒丰银行", "恒丰银行"), Map.entry("恒丰", "恒丰银行"),
            Map.entry("渤海银行", "渤海银行"), Map.entry("渤海", "渤海银行"),
            Map.entry("浙商银行", "浙商银行"), Map.entry("浙商", "浙商银行"),
            Map.entry("农村商业银行", "农村商业银行"), Map.entry("农商银行", "农村商业银行"),
            Map.entry("农商行", "农村商业银行"), Map.entry("农商", "农村商业银行"),
            Map.entry("农村信用社", "农村信用"), Map.entry("农信社", "农村信用"),
            Map.entry("农信", "农村信用"), Map.entry("信用社", "农村信用"),
            Map.entry("村镇银行", "村镇银行"));
    /** 长的写法优先:否则「中国农业银行」会先被「农业银行」命中,留下一个「中国」残片匹配不到任何支行。 */
    private static final List<String> SPELLINGS_LONGEST_FIRST = BANK_SPELLINGS.keySet().stream()
            .sorted(Comparator.comparingInt(String::length).reversed())
            .toList();
    static BankBranchKeyword parse(String rawKeyword) {
        // 空格一律去掉:「农行 邻水 丰禾」去掉后剩下的「邻水丰禾」正好是目录里的连续片段。
        String keyword = rawKeyword == null ? "" : rawKeyword.replaceAll("\\s+", "");
        if (keyword.isEmpty()) return new BankBranchKeyword("", "", "");
        // 联行号按片段匹配而不是补齐后精确匹配:用户常见的是只记得后几位,补齐前导零那种猜测
        // 一次都命中不了,片段匹配则 12 位全码与后几位都能搜到。
        if (keyword.matches("\\d{1,12}")) return new BankBranchKeyword("", "", keyword);
        for (String spelling : SPELLINGS_LONGEST_FIRST) {
            int position = keyword.indexOf(spelling);
            if (position < 0) continue;
            // 银行写法可能出现在任意位置(「攀枝花农行」与「农行邻水」都有),所以两头都要切。
            String remainder = keyword.substring(0, position) + keyword.substring(position + spelling.length());
            return new BankBranchKeyword(BANK_SPELLINGS.get(spelling), remainder, "");
        }
        return new BankBranchKeyword("", keyword, "");
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -350,29 +350,51 @@
        int safePage = Math.max(page, 1);
        int safeSize = Math.min(Math.max(size, 1), 50);
        StringBuilder where = new StringBuilder(" where 1=1");
        List<Object> params = new ArrayList<>();
        String cityFilter = blank(cityName) ? clean(cityCode) : clean(cityName);
        int citySeparator = cityFilter.lastIndexOf(' ');
        if (citySeparator >= 0 && citySeparator + 1 < cityFilter.length()) cityFilter = cityFilter.substring(citySeparator + 1).trim();
        cityFilter = cityKeyword(cityFilter);
        if (!blank(cityFilter)) {
        List<Object> whereParams = new ArrayList<>();
        BankBranchKeyword parsed = BankBranchKeyword.parse(keyword);
        if (parsed.clearingCode().length() > 0) {
            where.append(" and clearing_code like ?");
            whereParams.add("%" + parsed.clearingCode() + "%");
        } else if (parsed.bankName().length() > 0) {
            appendLikeColumn(where, whereParams, "bank_name", parsed.bankName());
            // 总行名已经限定过,剩下的片段只比支行名,不再拿它去匹配总行名。
            if (parsed.branchName().length() > 0) appendLikeColumn(where, whereParams, "branch_name", parsed.branchName());
        } else if (parsed.branchName().length() > 0) {
            // 没识别出银行名时两列都试:「泰隆」这类不在表里的银行只能由 bank_name 命中。
            where.append(" and (branch_name like ? or bank_name like ?)");
            String match = "%" + cityFilter + "%";
            params.add(match);
            params.add(match);
            whereParams.add("%" + parsed.branchName() + "%");
            whereParams.add("%" + parsed.branchName() + "%");
        }
        if (!blank(keyword)) {
            where.append(" and (branch_name like ? or bank_name like ? or clearing_code = ?)");
            String match = "%" + clean(keyword) + "%";
            params.add(match);
            params.add(match);
            params.add(clean(keyword));
        Long total = jdbc.queryForObject("select count(*) from bank_branch_directory" + where, Long.class, whereParams.toArray());
        // 开户城市只用于把本地网点排在前面,不再作为过滤条件:目录里的支行名由县区名构成
        // (「…邻水丰禾支行」里没有「广安」),按地市名过滤会把整批县区级网点筛掉,跨市开户的
        // 商户就永远搜不到自己的开户行。排序兼顾两点:常见关键词先给就近网点,跨市网点也还搜得到。
        String cityHint = cityHint(cityCode, cityName);
        List<Object> pageParams = new ArrayList<>(whereParams);
        String orderBy = " order by branch_name";
        if (!blank(cityHint)) {
            orderBy = " order by case when branch_name like ? or bank_name like ? then 0 else 1 end, branch_name";
            pageParams.add("%" + cityHint + "%");
            pageParams.add("%" + cityHint + "%");
        }
        Long total = jdbc.queryForObject("select count(*) from bank_branch_directory" + where, Long.class, params.toArray());
        List<Map<String, Object>> values = rows("select clearing_code as id, clearing_code as branch_code, branch_name, head_bank_code, bank_name from bank_branch_directory" + where + " order by branch_name limit ? offset ?", appendPaging(params, safeSize, (safePage - 1) * safeSize));
        List<Map<String, Object>> values = rows("select clearing_code as id, clearing_code as branch_code, branch_name, head_bank_code, bank_name from bank_branch_directory" + where + orderBy + " limit ? offset ?", appendPaging(pageParams, safeSize, (safePage - 1) * safeSize));
        return new PageResult<>(values, safePage, safeSize, total == null ? 0 : total);
    }
    private void appendLikeColumn(StringBuilder where, List<Object> params, String column, String value) {
        where.append(" and ").append(column).append(" like ?");
        params.add("%" + value + "%");
    }
    /** 从「四川省 攀枝花市」这类选择器取值里取出用作排序提示的地市名。 */
    private String cityHint(String cityCode, String cityName) {
        String filter = blank(cityName) ? clean(cityCode) : clean(cityName);
        int separator = filter.lastIndexOf(' ');
        if (separator >= 0 && separator + 1 < filter.length()) filter = filter.substring(separator + 1).trim();
        return cityKeyword(filter);
    }
    private void requireApproved(MerchantPrincipal principal) { if (!"approved".equals(onboardingStatus(principal).get("status"))) throw new BusinessException(ErrorCode.CONFLICT, "商户进件尚未审核通过"); }
    private void submitBankCardToHuifu(MerchantPrincipal principal, Map<String, Object> card, String cardNumber, Map<String, Object> branch) {
        List<Map<String, Object>> onboarding = rows("select * from merchant_onboarding_application where merchant_id=? and store_id=? and initial_bank_card_id is not null and status='approved' and provider_huifu_id is not null order by id desc limit 1", principal.merchantId(), principal.storeId());
talent-card-server/src/test/java/com/talentcard/server/business/merchant/BankBranchKeywordTests.java
New file
@@ -0,0 +1,89 @@
package com.talentcard.server.business.merchant;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
/**
 * 关键词解析是纯函数,这里覆盖的是「用户输入的写法与目录里的写法不一致」这一类真实的搜索失败:
 * 目录是「中国农业银行股份有限公司邻水丰禾支行」,用户输入「农行邻水丰禾支行」搜不到。
 */
class BankBranchKeywordTests {
    @Test
    void splitsTheUsersHabitualSpellingIntoBankAndBranchParts() {
        BankBranchKeyword parsed = BankBranchKeyword.parse("农行邻水丰禾支行");
        assertThat(parsed.bankName()).isEqualTo("农业银行");
        assertThat(parsed.branchName()).isEqualTo("邻水丰禾支行");
        assertThat(parsed.clearingCode()).isEmpty();
    }
    @Test
    void normalizesCommonBankAbbreviations() {
        assertThat(BankBranchKeyword.parse("工行").bankName()).isEqualTo("工商银行");
        assertThat(BankBranchKeyword.parse("建行").bankName()).isEqualTo("建设银行");
        assertThat(BankBranchKeyword.parse("中行").bankName()).isEqualTo("中国银行");
        assertThat(BankBranchKeyword.parse("邮储").bankName()).isEqualTo("邮政储蓄银行");
        assertThat(BankBranchKeyword.parse("农商行").bankName()).isEqualTo("农村商业银行");
        assertThat(BankBranchKeyword.parse("浦发").bankName()).isEqualTo("浦东发展银行");
    }
    @Test
    void prefersTheLongestBankNameSoNoStrayPrefixIsLeft() {
        assertThat(BankBranchKeyword.parse("中国农业银行").bankName()).isEqualTo("农业银行");
        assertThat(BankBranchKeyword.parse("中国农业银行").branchName()).isEmpty();
        assertThat(BankBranchKeyword.parse("中国银行").bankName()).isEqualTo("中国银行");
        assertThat(BankBranchKeyword.parse("中国银行").branchName()).isEmpty();
        // 农业发展银行的名称里也含「农业」,不能和农业银行混为一谈。
        assertThat(BankBranchKeyword.parse("中国农业发展银行").bankName()).isEqualTo("农业发展银行");
    }
    @Test
    void keepsTheBranchFragmentWhenTheUserTypesTheOfficialFullName() {
        BankBranchKeyword parsed = BankBranchKeyword.parse("中国农业银行股份有限公司邻水丰禾支行");
        assertThat(parsed.bankName()).isEqualTo("农业银行");
        assertThat(parsed.branchName()).isEqualTo("股份有限公司邻水丰禾支行");
    }
    @Test
    void findsTheBankSpellingWhereverItAppears() {
        BankBranchKeyword parsed = BankBranchKeyword.parse("攀枝花农行");
        assertThat(parsed.bankName()).isEqualTo("农业银行");
        assertThat(parsed.branchName()).isEqualTo("攀枝花");
    }
    @Test
    void dropsSpacesSoTheBranchFragmentsBecomeContiguous() {
        BankBranchKeyword parsed = BankBranchKeyword.parse("农行 邻水 丰禾");
        assertThat(parsed.bankName()).isEqualTo("农业银行");
        assertThat(parsed.branchName()).isEqualTo("邻水丰禾");
    }
    @Test
    void treatsDigitsAsAClearingCodeFragment() {
        assertThat(BankBranchKeyword.parse("103676368390").clearingCode()).isEqualTo("103676368390");
        assertThat(BankBranchKeyword.parse("103676368390").branchName()).isEmpty();
        // 只记得后几位也要能搜到,所以按片段匹配而不是补齐前导零。
        assertThat(BankBranchKeyword.parse(" 676368390 ").clearingCode()).isEqualTo("676368390");
    }
    @Test
    void fallsBackToTheWholeKeywordWhenNoBankNameIsRecognized() {
        BankBranchKeyword parsed = BankBranchKeyword.parse("邻水丰禾");
        assertThat(parsed.bankName()).isEmpty();
        assertThat(parsed.branchName()).isEqualTo("邻水丰禾");
        // 表里没有的银行(如浙江泰隆商业银行)走这条路径,由 bank_name 那一侧命中。
        assertThat(BankBranchKeyword.parse("泰隆").branchName()).isEqualTo("泰隆");
    }
    @Test
    void blankKeywordsProduceNoPredicates() {
        assertThat(BankBranchKeyword.parse(null)).isEqualTo(new BankBranchKeyword("", "", ""));
        assertThat(BankBranchKeyword.parse("   ")).isEqualTo(new BankBranchKeyword("", "", ""));
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/merchant/BankBranchSearchTests.java
New file
@@ -0,0 +1,97 @@
package com.talentcard.server.business.merchant;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
/**
 * 开户行检索走真实 SQL,这里覆盖「关键词解析 + 组合谓词 + 排序」拼起来之后的行为:解析本身由
 * {@link BankBranchKeywordTests} 覆盖,但参数顺序与 like 语义只有真跑一遍才看得出来。
 *
 * <p>网点用固定联行号写入,重跑时按主键更新,避免第二次运行撞主键。
 */
@SpringBootTest
@ActiveProfiles("test")
class BankBranchSearchTests {
    private static final String AGRICULTURAL_LINSHUI = "900000000001";
    private static final String AGRICULTURAL_PANZHIHUA = "900000000002";
    private static final String INDUSTRIAL_PANZHIHUA = "900000000003";
    private static final String TAILONG_TAIZHOU = "900000000004";
    private static final String LINSHUI_FENGHE = "中国农业银行股份有限公司邻水丰禾支行";
    private static final String PANZHIHUA_BINGCAOGANG = "中国农业银行攀枝花炳草岗支行";
    @Autowired
    private JdbcTemplate jdbc;
    @Autowired
    private MerchantSettlementService settlementService;
    @BeforeEach
    void prepareDirectory() {
        upsert(AGRICULTURAL_LINSHUI, LINSHUI_FENGHE, "中国农业银行", "01030000");
        upsert(AGRICULTURAL_PANZHIHUA, PANZHIHUA_BINGCAOGANG, "中国农业银行", "01030000");
        upsert(INDUSTRIAL_PANZHIHUA, "中国工商银行股份有限公司攀枝花东区支行", "中国工商银行", "01020000");
        upsert(TAILONG_TAIZHOU, "浙江泰隆商业银行股份有限公司台州分行", "浙江泰隆商业银行", "31334500");
    }
    private void upsert(String clearingCode, String branchName, String bankName, String headBankCode) {
        jdbc.update("""
                insert into bank_branch_directory(clearing_code, branch_name, head_bank_code, bank_name)
                values (?, ?, ?, ?)
                on duplicate key update branch_name = values(branch_name),
                                        head_bank_code = values(head_bank_code),
                                        bank_name = values(bank_name)
                """, clearingCode, branchName, headBankCode, bankName);
    }
    /** 只保留本用例写入的网点,测试库是共享的。 */
    private List<String> search(String cityName, String keyword) {
        return settlementService.bankBranches("", cityName, keyword, 1, 30).records().stream()
                .filter(row -> String.valueOf(row.get("branchCode")).startsWith("9000000000"))
                .map(row -> String.valueOf(row.get("branchName")))
                .toList();
    }
    @Test
    void findsTheBranchWhenTheUserTypesTheAbbreviationAndDropsTheCorporateSuffix() {
        // 目录里是「中国农业银行股份有限公司邻水丰禾支行」,用户输入「农行邻水丰禾支行」:
        // 既用了简称,又在银行名与支行名之间少了「股份有限公司」,整串并不连续。
        assertThat(search("四川省 攀枝花市", "农行邻水丰禾支行")).containsExactly(LINSHUI_FENGHE);
    }
    @Test
    void ranksLocalBranchesFirstWithoutHidingAnotherCity() {
        // 开户城市只影响排序。跨市开户的商户必须还能在结果里看到自己的开户行,
        // 同时就近网点排在前面——这是把城市从过滤条件降级成排序提示的原因。
        assertThat(search("四川省 攀枝花市", "农行"))
                .containsExactly(PANZHIHUA_BINGCAOGANG, LINSHUI_FENGHE);
    }
    @Test
    void matchesTheOfficialFullNameTypedVerbatim() {
        assertThat(search("四川省 攀枝花市", "中国农业银行股份有限公司邻水丰禾支行")).containsExactly(LINSHUI_FENGHE);
        assertThat(search("四川省 攀枝花市", "邻水丰禾")).containsExactly(LINSHUI_FENGHE);
    }
    @Test
    void matchesAClearingCodeFragmentAsWellAsTheFullCode() {
        assertThat(search("四川省 攀枝花市", "900000000001")).containsExactly(LINSHUI_FENGHE);
        // 只记得后几位也要能搜到。
        assertThat(search("四川省 攀枝花市", "0000000001")).containsExactly(LINSHUI_FENGHE);
    }
    @Test
    void fallsBackToTheBankNameColumnForBanksOutsideTheSpellingTable() {
        // 「泰隆」不在银行写法表里,只能由 bank_name 那一侧命中。
        assertThat(search("", "泰隆")).containsExactly("浙江泰隆商业银行股份有限公司台州分行");
    }
}