package com.talentcard.server.business.admin; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import com.alibaba.fastjson.JSON; import com.talentcard.server.business.DatabaseRows; import com.talentcard.server.business.PageResult; import com.talentcard.server.business.payment.huifu.HuifuSettlementRecordGateway; import com.talentcard.server.common.BusinessException; import com.talentcard.server.common.ErrorCode; import com.talentcard.server.common.SensitiveDataCrypto; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.talentcard.server.business.merchant.MerchantPaymentReadinessService; /** * 后台结算管理。 * *

出款由斗拱按结算周期自动完成(核销确认后的钱进商户基本户,次日自动结算到结算卡), * 平台不再产生提现记录。后台保留进件、银行卡查询、收款能力快照刷新,以及向通道查询 * 真实结算记录用于监控每天结了多少到银行卡。 */ @Service public class MerchantSettlementAdminService { private final JdbcTemplate jdbc; private final SensitiveDataCrypto sensitiveDataCrypto; private final MerchantPaymentReadinessService readinessService; private final HuifuSettlementRecordGateway settlementRecordGateway; public MerchantSettlementAdminService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, MerchantPaymentReadinessService readinessService, HuifuSettlementRecordGateway settlementRecordGateway) { this.jdbc = jdbc; this.sensitiveDataCrypto = sensitiveDataCrypto; this.readinessService = readinessService; this.settlementRecordGateway = settlementRecordGateway; } /** * 结算记录:钱按结算周期自动划到结算卡之后,「每天结了多少」只能问通道。 * *

按门店取该商户当前生效的斗拱子商户号,逐商户查询结算记录后合并成一条时间倒序的列表。 * 通道查询失败只影响这一条商户,不会让整页失败——监控页最重要的是「看得到」。 * *

条数上限跟着通道走:这个接口的 {@code pageSize} 超过 50 会被判为基础参数校验失败, * 传 100 时整页一条流水都查不出来。上限在网关里还有一道兜底,这里只是不要再主动越界。 */ public Map settlements(String beginDate, String endDate, int size) { List> merchants = rows(""" select a.merchant_id, a.provider_huifu_id, m.merchant_name, s.store_name from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id where a.status = 'approved' and a.provider_huifu_id is not null and a.provider_huifu_id <> '' and a.id = (select max(newest.id) from merchant_onboarding_application newest where newest.merchant_id = a.merchant_id) order by a.merchant_id asc """); List> records = new ArrayList<>(); List failures = new ArrayList<>(); int limit = Math.min(Math.max(size, 1), HuifuSettlementRecordGateway.PROVIDER_MAX_PAGE_SIZE); for (Map merchant : merchants) { String huifuId = String.valueOf(merchant.get("providerHuifuId")); try { HuifuSettlementRecordGateway.SettlementRecords response = settlementRecordGateway.query(huifuId, beginDate, endDate, limit); if (!response.succeeded()) { failures.add(String.valueOf(merchant.get("merchantName")) + ":" + response.message()); continue; } records.addAll(decode(response.rawRecords(), merchant)); } catch (RuntimeException exception) { failures.add(String.valueOf(merchant.get("merchantName")) + ":查询失败"); } } records.sort((left, right) -> String.valueOf(right.get("settleDate")) .compareTo(String.valueOf(left.get("settleDate")))); Map view = new LinkedHashMap<>(); view.put("beginDate", beginDate); view.put("endDate", endDate); view.put("records", records.size() > limit ? records.subList(0, limit) : records); view.put("total", records.size()); view.put("failures", failures); return view; } /** 把通道返回的结算记录 JSON 转成后台要展示的字段;只读展示,不做金额换算以外的加工。 */ private List> decode(String rawRecords, Map merchant) { List> decoded = new ArrayList<>(); if (rawRecords == null || rawRecords.isBlank()) return decoded; List> parsed; try { parsed = JSON.parseObject(rawRecords, new com.alibaba.fastjson.TypeReference<>() { }); } catch (RuntimeException exception) { return decoded; } if (parsed == null) return decoded; for (Map record : parsed) { Map row = new LinkedHashMap<>(); row.put("merchantId", merchant.get("merchantId")); row.put("merchantName", merchant.get("merchantName")); row.put("storeName", merchant.get("storeName")); row.put("providerTransactionNo", record.get("trans_id")); row.put("settleDate", record.get("trans_date")); row.put("settleAmountCents", cents(record.get("trans_amt"))); row.put("feeAmountCents", cents(record.get("fee_amt"))); row.put("settleCycle", record.get("settle_cycle")); row.put("status", record.get("trans_stat")); row.put("cardMasked", record.get("card_no")); row.put("cardHolder", record.get("card_name")); decoded.add(row); } return decoded; } private Long cents(Object yuan) { if (yuan == null) return null; return com.talentcard.server.business.payment.huifu.HuifuPayload.amountInCent(String.valueOf(yuan)); } @Transactional(readOnly = true) public PageResult> onboardings(int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) { return page("select a.*, m.merchant_name, m.merchant_type, m.contact_mobile, s.store_name from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id", "select count(*) from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id", "a", page, size, merchantName, contactMobile, merchantType, status, startDate, endDate); } @Transactional(readOnly = true) public Map onboarding(Long id) { List> rows = rows("select a.*, a.merchant_name as onboarding_merchant_name, m.merchant_name, m.merchant_type, m.contact_mobile, s.store_name from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id where a.id = ?", id); if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商户进件不存在"); Map result = rows.get(0); // 个人主体的历史记录可能使用 individual、personal 或中文类型值; // 只要已保存加密身份证号,后台详情均应解密后完整展示。 String encrypted = String.valueOf(result.get("subjectIdNoEncrypted")); if (!encrypted.isBlank() && !"null".equals(encrypted)) result.put("subjectIdNo", sensitiveDataCrypto.decrypt(encrypted)); return result; } @Transactional(readOnly = true) public PageResult> bankCards(int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) { PageResult> result = page("select c.*, m.merchant_name, m.merchant_type, m.contact_mobile, s.store_name from merchant_bank_card c join merchant m on m.id = c.merchant_id join merchant_store s on s.id = c.store_id", "select count(*) from merchant_bank_card c join merchant m on m.id = c.merchant_id join merchant_store s on s.id = c.store_id", "c", page, size, merchantName, contactMobile, merchantType, status, startDate, endDate); result.records().forEach(row -> { String encrypted = String.valueOf(row.remove("cardNoEncrypted")); if (!encrypted.isBlank() && !"null".equals(encrypted)) row.put("cardNo", sensitiveDataCrypto.decrypt(encrypted)); else row.put("cardNo", row.get("cardMasked")); row.put("cityName", bankCardCityName(row.get("cityCode"))); }); return result; } /** Provider-confirmed settlement and encashment capability of one approved sub-merchant. */ public Map paymentCapability(Long applicationId) { return readinessService.capability(applicationId); } /** Re-reads the provider configuration and stores a fresh capability snapshot. */ public Map refreshPaymentCapability(Long applicationId) { return readinessService.refresh(applicationId); } private PageResult> page(String query, String count, String tableAlias, int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) { return page(query, count, tableAlias, page, size, merchantName, contactMobile, merchantType, status, startDate, endDate, ""); } private PageResult> page(String query, String count, String tableAlias, int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate, String extraWhere) { int current = Math.max(1, page); int limit = Math.min(Math.max(1, size), 100); StringBuilder where = new StringBuilder(" where 1 = 1" + extraWhere); List args = new ArrayList<>(); addLike(where, args, "m.merchant_name", merchantName); addLike(where, args, "m.contact_mobile", contactMobile); addEqual(where, args, "m.merchant_type", merchantType); addEqual(where, args, tableAlias + ".status", status); addDate(where, args, tableAlias + ".created_at", startDate, true); addDate(where, args, tableAlias + ".created_at", endDate, false); List rowArgs = new ArrayList<>(args); rowArgs.add(limit); rowArgs.add((current - 1) * limit); List> values = rows(query + where + " order by " + tableAlias + ".created_at desc, " + tableAlias + ".id desc limit ? offset ?", rowArgs.toArray()); Long total = jdbc.queryForObject(count + where, Long.class, args.toArray()); return new PageResult<>(values, current, limit, total == null ? 0 : total); } private void addLike(StringBuilder where, List args, String column, String value) { if (value != null && !value.trim().isEmpty()) { where.append(" and ").append(column).append(" like ?"); args.add("%" + value.trim() + "%"); } } private void addEqual(StringBuilder where, List args, String column, String value) { if (value != null && !value.trim().isEmpty()) { where.append(" and ").append(column).append(" = ?"); args.add(value.trim()); } } private void addDate(StringBuilder where, List args, String column, String value, boolean start) { if (value != null && value.matches("\\d{4}-\\d{2}-\\d{2}")) { where.append(" and ").append(column).append(start ? " >= ?" : " < date_add(?, interval 1 day)"); args.add(value); } } private String bankCardCityName(Object cityCode) { return switch (cityCode == null ? "" : String.valueOf(cityCode).trim()) { case "510100" -> "四川省|成都市"; case "510300" -> "四川省|自贡市"; case "510400" -> "四川省|攀枝花市"; case "510500" -> "四川省|泸州市"; case "510600" -> "四川省|德阳市"; case "510700" -> "四川省|绵阳市"; case "510800" -> "四川省|广元市"; case "510900" -> "四川省|遂宁市"; case "511000" -> "四川省|内江市"; case "511100" -> "四川省|乐山市"; case "511300" -> "四川省|南充市"; case "511400" -> "四川省|眉山市"; case "511500" -> "四川省|宜宾市"; case "511600" -> "四川省|广安市"; case "511700" -> "四川省|达州市"; case "511800" -> "四川省|雅安市"; case "511900" -> "四川省|巴中市"; case "512000" -> "四川省|资阳市"; case "513200" -> "四川省|阿坝藏族羌族自治州"; case "513300" -> "四川省|甘孜藏族自治州"; case "513400" -> "四川省|凉山彝族自治州"; default -> cityCode == null ? "" : String.valueOf(cityCode).trim(); }; } private List> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); } }