| | |
| | | @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"), |
| | |
| | | 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) { |
| | |
| | | @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 |
| | |
| | | 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, "商户进件尚未审核通过"); } |
| | |
| | | /** |
| | | * 进件主体所属行业的 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; |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | 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) { |
| | |
| | | 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)); |