From 02df6417d400aabb559d095e5f7cd63c63b86fb1 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期四, 17 九月 2026 15:57:20 +0800
Subject: [PATCH] fix(merchant): 开户行按银行名与支行名分别匹配,城市降级为本地优先排序

---
 rencaika_merchant/pages/bank-branch-select/bank-branch-select.uvue                                      |   24 +++
 talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java |   56 ++++++--
 talent-card-server/src/test/java/com/talentcard/server/business/merchant/BankBranchKeywordTests.java    |   89 ++++++++++++++
 talent-card-server/src/test/java/com/talentcard/server/business/merchant/BankBranchSearchTests.java     |   97 ++++++++++++++++
 talent-card-server/src/main/java/com/talentcard/server/business/merchant/BankBranchKeyword.java         |   78 +++++++++++++
 5 files changed, 322 insertions(+), 22 deletions(-)

diff --git a/rencaika_merchant/pages/bank-branch-select/bank-branch-select.uvue b/rencaika_merchant/pages/bank-branch-select/bank-branch-select.uvue
index 6357dad..37abaad 100644
--- a/rencaika_merchant/pages/bank-branch-select/bank-branch-select.uvue
+++ b/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>
 
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/BankBranchKeyword.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/BankBranchKeyword.java
new file mode 100644
index 0000000..dcce522
--- /dev/null
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/BankBranchKeyword.java
@@ -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, "");
+    }
+}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
index abc076c..73f3a6f 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
+++ b/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());
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/BankBranchKeywordTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/BankBranchKeywordTests.java
new file mode 100644
index 0000000..ef2f640
--- /dev/null
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/BankBranchKeywordTests.java
@@ -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("", "", ""));
+    }
+}
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/BankBranchSearchTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/BankBranchSearchTests.java
new file mode 100644
index 0000000..7c27770
--- /dev/null
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/BankBranchSearchTests.java
@@ -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("浙江泰隆商业银行股份有限公司台州分行");
+    }
+}

--
Gitblit v1.7.1