talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -37,6 +37,8 @@
@Service
public class MerchantSettlementService {
    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(MerchantSettlementService.class);
    /** 商家档案给不出可识别经营类型时的兜底 MCC(「其它」)。 */
    private static final String FALLBACK_MCC = "7399";
    private static final Map<String, String> MCC_BY_MERCHANT_TYPE = Map.ofEntries(
            Map.entry("food", "5812"), Map.entry("scenic", "7991"), Map.entry("hotel", "7011"), Map.entry("apartment", "7011"),
            Map.entry("cinema", "7832"), Map.entry("fuel", "5541"), Map.entry("parking", "7523"), Map.entry("carWash", "7542"),
@@ -90,6 +92,29 @@
        List<Map<String,Object>> records=rows("select * from merchant_onboarding_application where merchant_id=? and store_id=? and initial_bank_card_id is not null order by id desc limit 1",principal.merchantId(),principal.storeId());
        if(records.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND,"Onboarding application not found");
        return realnameService.submit(((Number)records.get(0).get("id")).longValue(),retry);
    }
    /**
     * 商户端「收款能力」自查:能不能收款、为什么不能、最近什么时候核实过。
     *
     * <p>判定与下单闸门同源同文案,只读不查通道——商户在页面上看到的失败原因,必须和下单被拒时
     * 看到的是同一句话,否则同一个事实会有两套口径。
     */
    @Transactional(readOnly = true)
    public Map<String, Object> paymentReadiness(MerchantPrincipal principal) {
        store(principal);
        return paymentReadinessService.merchantView(principal.merchantId(), principal.storeId());
    }
    /**
     * 商户端「立即核实」:主动查一次通道刷新能力快照,受冷却保护。
     *
     * <p>这里刻意不加 {@code @Transactional}:冷却窗口要在通道查询之前落库,而查询本身不能在持有
     * 该行锁的事务里进行,见 {@code MerchantPaymentReadinessService.merchantVerify}。
     */
    public Map<String, Object> verifyPaymentReadiness(MerchantPrincipal principal) {
        store(principal);
        return paymentReadinessService.merchantVerify(principal.merchantId(), principal.storeId());
    }
    public MerchantSettlementService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, HuifuIndividualOnboardingGateway individualOnboardingGateway, HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway, HuifuMerchantWechatConfigGateway merchantWechatConfigGateway, HuifuMerchantBusinessOpenGateway merchantBusinessOpenGateway, HuifuUserBusinessOpenGateway userBusinessOpenGateway, HuifuPaymentProperties huifuPaymentProperties, HuifuPictureUploadGateway pictureUploadGateway, MerchantPaymentReadinessService paymentReadinessService) {
@@ -264,7 +289,31 @@
    @Transactional(readOnly = true)
    public List<Map<String, Object>> bankCards(MerchantPrincipal principal) {
        store(principal);
        convergePendingCards(principal);
        return rows("select * from merchant_bank_card where merchant_id = ? and store_id = ? order by id desc", principal.merchantId(), principal.storeId()).stream().map(this::bankCard).toList();
    }
    /**
     * 打开银行卡列表时顺手把仍在「处理中」的卡拉一次通道。
     *
     * <p>新增结算卡的状态原先只有绑定回调能推进,而回调会丢(开发与内网环境根本收不到),商户盯着
     * 这一页就是在等它变成已批准。这里只在**确实有待处理的卡**时才去查通道,所以它不是「每次读都打
     * 通道」的轮询;通道那边的查询结果与刷新进度走的是同一条路径(都不新增定时任务)。
     */
    private void convergePendingCards(MerchantPrincipal principal) {
        if (paymentReadinessService == null) return;
        Integer pending = jdbc.queryForObject("""
                select count(*) from merchant_bank_card
                where merchant_id = ? and status = 'pending' and (store_id = ? or store_id is null)
                """, Integer.class, principal.merchantId(), principal.storeId());
        if (pending == null || pending == 0) return;
        List<Map<String, Object>> applications = rows("""
                select id from merchant_onboarding_application
                where merchant_id = ? and store_id = ? and status = 'approved' and provider_huifu_id is not null
                order by id desc limit 1
                """, principal.merchantId(), principal.storeId());
        if (applications.isEmpty()) return;
        paymentReadinessService.refreshQuietly(((Number) applications.get(0).get("id")).longValue());
    }
    @Transactional
@@ -301,27 +350,49 @@
        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, "商户进件尚未审核通过"); }
@@ -876,20 +947,30 @@
    /**
     * 进件主体所属行业的 MCC。
     *
     * <p>个人(小微)主体在进件表单第一步单独选过「行业类型」(餐饮服务/酒店服务/景区服务…),
     * 它跟着企业主体的公司类型一起落在 {@code company_type} 列上。原先个人进件的 MCC 只认商家档案上的
     * 经营类型,等于把商户自己在进件时选的行业**静默丢掉**:档案没配好就一律落 7399(其它),
     * 通道侧的微信行业类目和费率跟着一起错。所以个人主体优先用进件时选的行业,
     * 行业识别不出来(选了「其它」)再退回商家档案。
     * <p><b>行业以系统里的商家档案为准</b>(2026-09-16 业务口径:「行业改不了,只有我们系统动态地去匹配
     * 行业」)。商家档案上的经营类型是我们自己维护的,也是匹配微信行业类目与费率的依据,
     * 不该被进件表单上的一次选择盖掉;已进件商户也不提供补改行业的入口。
     *
     * <p>商户在个人进件表单第一步选的「行业类型」只在**档案给不出具体行业时**兜底
     * (档案未配、或配成「其它」未识别 → {@link #FALLBACK_MCC}),避免档案缺数据时一律落「其它」。
     *
     * <p>{@code mcc} 是进件的必填项;业务开通只在没有 {@code fee_rule_id} 时才用它兜底。
     */
    private String mcc(Long merchantId, String subjectType, String industryType) {
        if ("individual".equalsIgnoreCase(clean(subjectType))) {
            String industry = mccForIndustry(industryType);
            if (!industry.isBlank()) return industry;
        }
        return mcc(merchantId);
        String configured = mcc(merchantId);
        // 个体工商户走企业进件接口,表单上没有「行业类型」这一项,只有个人主体才有申报值。
        if (!"individual".equalsIgnoreCase(clean(subjectType))) return configured;
        return preferConfiguredMcc(configured, industryType);
    }
    /**
     * 系统档案优先、商户申报兜底。取值为静态形式,便于直接断言这条规则本身
     * (见 {@code MerchantOnboardingIndustryMccTests})。
     */
    static String preferConfiguredMcc(String configuredMcc, String declaredIndustry) {
        if (!FALLBACK_MCC.equals(cleanStatus(configuredMcc))) return configuredMcc == null ? "" : configuredMcc.trim();
        String declared = mccForIndustry(declaredIndustry);
        return declared.isBlank() ? FALLBACK_MCC : declared;
    }
    /**
@@ -898,7 +979,7 @@
     */
    static String mccForIndustry(String industryType) {
        String industry = MerchantTypeCatalog.canonicalCode(cleanStatus(industryType));
        return "other".equals(industry) ? "" : MCC_BY_MERCHANT_TYPE.getOrDefault(industry, "7399");
        return "other".equals(industry) ? "" : MCC_BY_MERCHANT_TYPE.getOrDefault(industry, FALLBACK_MCC);
    }
    private String mcc(Long merchantId) {
@@ -906,7 +987,7 @@
        if (merchants.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商家不存在");
        String code = clean(merchants.get(0).get("merchantTypeCode"));
        if (code.isBlank()) code = MerchantTypeCatalog.canonicalCode(clean(merchants.get(0).get("merchantType")));
        return MCC_BY_MERCHANT_TYPE.getOrDefault(code, "7399");
        return MCC_BY_MERCHANT_TYPE.getOrDefault(code, FALLBACK_MCC);
    }
    private String normalizeRegisteredCapital(String companyType, String value) {
        String text = clean(value); boolean required = List.of("国营企业", "私营企业", "外资企业", "其它组织", "事业单位").contains(clean(companyType));