feat(settlement): 结算记录只查通道侧收款能力正常的商户,并支持按商家/电话/类型/状态筛选

结算记录页逐商户向斗拱查询,此前把「进件通过且拿到子商户号」的商户全都问一遍:被关停的
(mer_stat=CANCEL,实测通道回「客户与产品关系已关闭」)和从没核实过通道状态的,问过去只会被拒,
页面每次打开都白多一条失败提示。改为只查通道侧收款能力已核实且正常的商户——判定复用
MerchantPaymentReadinessService.channelCapabilityNormal,与下单闸门同一套取值,不另立一套;
本地的微信/实名进度记录不参与判定,否则通道侧正常、仍在结算的存量商户会被摘出监控。

同一个接口此前忽略页面上的查询条件(商家、联系电话、商家类型、状态四项完全无效:前端不发、
后端也不收)。商家/联系电话/商家类型是本地商户字段,进 SQL 先筛掉不匹配的商户,顺带少打几次通道;
状态是通道流水的结算状态(S/F/P),只能等通道把流水给回来之后再筛。
1个文件已添加
3个文件已修改
261 ■■■■■ 已修改文件
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantSettlementAdminServiceTests.java 192 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
@@ -40,12 +40,14 @@
  /**
   * 结算记录:向斗拱查询指定区间的真实结算流水,用于监控每天自动结算了多少到银行卡。
   * 日期格式 yyyyMMdd,默认最近 30 天;通道单页上限 50,默认值必须落在上限内。
   * 商家、联系电话、商家类型按本地商户字段先筛掉不匹配的商户(顺带少打几次通道),
   * 状态是通道的流水结算状态(S 已结算 / F 失败 / P 处理中)。
   */
  @GetMapping("/merchant-settlement/settlements") public ApiResponse<Map<String,Object>> settlements(@RequestParam(required = false) String beginDate, @RequestParam(required = false) String endDate, @RequestParam(defaultValue = "50") int size) {
  @GetMapping("/merchant-settlement/settlements") public ApiResponse<Map<String,Object>> settlements(@RequestParam(required = false) String beginDate, @RequestParam(required = false) String endDate, @RequestParam(defaultValue = "50") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status) {
    java.time.LocalDate today = java.time.LocalDate.now();
    String end = (endDate == null || endDate.isBlank()) ? today.format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE) : endDate.trim();
    String begin = (beginDate == null || beginDate.isBlank()) ? today.minusDays(30).format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE) : beginDate.trim();
    return ApiResponse.success(settlementService.settlements(begin, end, size));
    return ApiResponse.success(settlementService.settlements(begin, end, size, merchantName, contactMobile, merchantType, status));
  }
  @GetMapping("/payment-reconciliation/issues") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> reconciliationIssues(@RequestParam(defaultValue = "open") String status, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return ApiResponse.success(reconciliationService.issues(status, page, size)); }
  @PatchMapping("/payment-reconciliation/issues/{id}") public ApiResponse<Map<String,Object>> closeReconciliationIssue(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(reconciliationService.close(id, body.get("status"), body.get("operator"))); }
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
@@ -50,20 +50,40 @@
     * <p>按门店取该商户当前生效的斗拱子商户号,逐商户查询结算记录后合并成一条时间倒序的列表。
     * 通道查询失败只影响这一条商户,不会让整页失败——监控页最重要的是「看得到」。
     *
     * <p>只查「通道侧收款能力已核实且正常」的商户({@link MerchantPaymentReadinessService#channelCapabilityNormal}):
     * 没开通好、或在汇付被关停的商户,查过去也会被通道拒(实测被关闭的子商户回「客户与产品关系已关闭」),
     * 只会让页面每次打开都多一条失败提示。判定共用收款能力那套取值,不在这里另立一套。
     *
     * <p>筛选分两处,因为两类条件属于两边:商家、联系电话、商家类型是**本地商户字段**,先筛掉不匹配的
     * 商户再去问通道——既让查询生效,也顺带少打几次通道;状态是**通道流水的结算状态**({@code S}/{@code F}/{@code P}),
     * 只能等通道把流水给回来之后再过滤。{@code beginDate}/{@code endDate} 是结算日期的区间(不是商户申请时间)。
     *
     * <p>条数上限跟着通道走:这个接口的 {@code pageSize} 超过 50 会被判为基础参数校验失败,
     * 传 100 时整页一条流水都查不出来。上限在网关里还有一道兜底,这里只是不要再主动越界。
     */
    public Map<String, Object> settlements(String beginDate, String endDate, int size) {
        List<Map<String, Object>> 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
    public Map<String, Object> settlements(String beginDate, String endDate, int size,
                                           String merchantName, String contactMobile, String merchantType, String status) {
        StringBuilder where = new StringBuilder("""
                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<Object> args = new ArrayList<>();
        addLike(where, args, "m.merchant_name", merchantName);
        addLike(where, args, "m.contact_mobile", contactMobile);
        addEqual(where, args, "m.merchant_type", merchantType);
        List<Map<String, Object>> candidates = rows("""
                select a.id as application_id, 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 + " order by a.merchant_id asc", args.toArray());
        List<Map<String, Object>> merchants = new ArrayList<>();
        for (Map<String, Object> candidate : candidates) {
            if (readinessService.channelCapabilityNormal(((Number) candidate.get("applicationId")).longValue())) {
                merchants.add(candidate);
            }
        }
        List<Map<String, Object>> records = new ArrayList<>();
        List<String> failures = new ArrayList<>();
        int limit = Math.min(Math.max(size, 1), HuifuSettlementRecordGateway.PROVIDER_MAX_PAGE_SIZE);
@@ -76,7 +96,9 @@
                    failures.add(String.valueOf(merchant.get("merchantName")) + ":" + response.message());
                    continue;
                }
                records.addAll(decode(response.rawRecords(), merchant));
                for (Map<String, Object> record : decode(response.rawRecords(), merchant)) {
                    if (matchesStatus(record, status)) records.add(record);
                }
            } catch (RuntimeException exception) {
                failures.add(String.valueOf(merchant.get("merchantName")) + ":查询失败");
            }
@@ -92,6 +114,12 @@
        return view;
    }
    /** 结算状态筛选:值是通道自己的流水状态({@code S} 已结算 / {@code F} 失败 / {@code P} 处理中),空则全要。 */
    private boolean matchesStatus(Map<String, Object> record, String status) {
        if (status == null || status.isBlank()) return true;
        return status.trim().equalsIgnoreCase(String.valueOf(record.get("status")));
    }
    /** 把通道返回的结算记录 JSON 转成后台要展示的字段;只读展示,不做金额换算以外的加工。 */
    private List<Map<String, Object>> decode(String rawRecords, Map<String, Object> merchant) {
        List<Map<String, Object>> decoded = new ArrayList<>();
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java
@@ -80,6 +80,25 @@
    }
    /**
     * 通道侧收款能力是否已核实且正常:快照核实过、子商户状态正常、取现可用。
     *
     * <p>它只回答「通道那边这家商户开通正常吗」,不含 {@link #readiness(Long, Long)} 额外要求的
     * 微信/实名与绑卡前置(那一条才回答「现在能不能收款」)。只读的监控页面用它决定「该不该去问
     * 通道」:通道侧没开通或被关停的商户问过去也会被拒(实测被关闭的子商户回「客户与产品关系已关闭」),
     * 而本地缺进度记录、通道侧却正常且在结算的存量商户不该因此被摘出监控。
     */
    @Transactional(readOnly = true)
    public boolean channelCapabilityNormal(Long applicationId) {
        List<Map<String, Object>> rows = rows("select * from merchant_onboarding_application where id = ? limit 1", applicationId);
        if (rows.isEmpty()) return false;
        Application application = application(rows.get(0));
        if (!"approved".equals(application.status()) || application.huifuId().isBlank()) return false;
        // 快照没核实过就不算正常:那意味着「通道侧到底是什么状态」还没有答案。
        if (clean(application.capabilityVerifiedAt()).isBlank()) return false;
        return isMerchantNormal(application.merchantStatus()) && isCashAvailable(application.cashSwitchStatus());
    }
    /**
     * Returns the collecting sub-merchant an order must be bound to, or throws with the first unmet
     * precondition. Callers persist the returned application, store and Huifu id on the order so the
     * payment request cannot drift to another sub-merchant later.
talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantSettlementAdminServiceTests.java
New file
@@ -0,0 +1,192 @@
package com.talentcard.server.business.admin;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import com.talentcard.server.business.payment.huifu.HuifuSettlementRecordGateway;
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;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
/**
 * 「结算记录」页的选择与筛选:哪些商户会被拿去问通道,以及查询条件对结果的约束。
 *
 * <p>只用通道侧收款能力已核实且正常的商户去问通道:被关停({@code mer_stat=CANCEL})、
 * 快照从没核实过、取现不可用的商户问过去只会被拒(实测被关闭的子商户回「客户与产品关系已关闭」),
 * 页面每次打开都白多一条失败提示。反过来,本地没有微信/实名进度记录、通道侧却已核实且正常的商户
 * 必须照查——存量商户就长这样,它仍在真实结算;只看本地进度会把它从监控里摘掉,等于看不见钱。
 *
 * <p>商家、联系电话、商家类型是本地商户字段,先筛掉不匹配的商户再去问通道;状态是通道流水的结算状态,
 * 只能等通道把流水给回来之后再筛。
 */
@SpringBootTest
@ActiveProfiles("test")
class MerchantSettlementAdminServiceTests {
    @Autowired
    private JdbcTemplate jdbc;
    @Autowired
    private MerchantSettlementAdminService settlementAdminService;
    @MockitoBean
    private HuifuSettlementRecordGateway settlementRecordGateway;
    private String suffix;
    private String openMerchantName;
    private String openHuifuId;
    private String closedHuifuId;
    private String unverifiedHuifuId;
    private String noCashHuifuId;
    @BeforeEach
    void prepareMerchants() {
        suffix = String.valueOf(System.nanoTime());
        // 四家商户都不写 merchant_onboarding_progress:通道侧是否正常只由能力快照回答。
        openMerchantName = "Settlement open merchant " + suffix;
        openHuifuId = approveMerchant(openMerchantName, "NORMAL", "1", true, "OPEN", "餐饮服务", "13800000001");
        closedHuifuId = approveMerchant("Settlement closed merchant " + suffix, "CANCEL", "1", true, "CLOSED", "住宿服务", "13800000002");
        unverifiedHuifuId = approveMerchant("Settlement unverified merchant " + suffix, "NORMAL", "1", false, "UNVERIFIED", "购车服务", "13800000003");
        noCashHuifuId = approveMerchant("Settlement no-cash merchant " + suffix, "NORMAL", "0", true, "NOCASH", "金融服务", "13800000004");
        stubProvider();
    }
    @Test
    void onlyMerchantsWithAVerifiedNormalProviderCapabilityAreQueried() {
        Map<String, Object> view = fetch(null, null, null, null);
        verify(settlementRecordGateway).query(eq(openHuifuId), eq("20260801"), eq("20260918"), anyInt());
        verify(settlementRecordGateway, never()).query(eq(closedHuifuId), any(), any(), anyInt());
        verify(settlementRecordGateway, never()).query(eq(unverifiedHuifuId), any(), any(), anyInt());
        verify(settlementRecordGateway, never()).query(eq(noCashHuifuId), any(), any(), anyInt());
        // 没问通道就不会有失败提示——被关停的商户正是原来那条警告的来源。
        assertThat(failuresOf(view)).isEmpty();
    }
    @Test
    void aQueriedMerchantKeepsItsSettlementRows() {
        List<Map<String, Object>> rows = recordsOf(fetch(null, null, null, null));
        // 桩给自己的商户各回两条流水:一条已结算、一条失败,按结算日期倒序排在最前。
        assertThat(rows).hasSize(2);
        assertThat(rows.get(0))
                .containsEntry("merchantName", openMerchantName)
                .containsEntry("providerTransactionNo", "TX-S-" + openHuifuId)
                .containsEntry("settleDate", "20260918")
                .containsEntry("settleAmountCents", 10L)
                .containsEntry("status", "S");
        assertThat(rows.get(1)).containsEntry("providerTransactionNo", "TX-F-" + openHuifuId).containsEntry("status", "F");
    }
    @Test
    void theMerchantNameFilterKeepsOnlyMatchingMerchantsOutOfTheChannelQuery() {
        fetch(openMerchantName, null, null, null);
        verify(settlementRecordGateway).query(eq(openHuifuId), any(), any(), anyInt());
        verify(settlementRecordGateway, never()).query(eq(closedHuifuId), any(), any(), anyInt());
        verify(settlementRecordGateway, never()).query(eq(unverifiedHuifuId), any(), any(), anyInt());
        verify(settlementRecordGateway, never()).query(eq(noCashHuifuId), any(), any(), anyInt());
    }
    @Test
    void theMerchantTypeFilterKeepsOnlyMatchingMerchantsOutOfTheChannelQuery() {
        fetch(null, null, "餐饮服务", null);
        verify(settlementRecordGateway).query(eq(openHuifuId), any(), any(), anyInt());
        verify(settlementRecordGateway, never()).query(eq(closedHuifuId), any(), any(), anyInt());
        verify(settlementRecordGateway, never()).query(eq(noCashHuifuId), any(), any(), anyInt());
    }
    @Test
    void theContactMobileFilterKeepsOnlyMatchingMerchantsOutOfTheChannelQuery() {
        fetch(null, "13800000001", null, null);
        verify(settlementRecordGateway).query(eq(openHuifuId), any(), any(), anyInt());
        verify(settlementRecordGateway, never()).query(eq(closedHuifuId), any(), any(), anyInt());
        verify(settlementRecordGateway, never()).query(eq(noCashHuifuId), any(), any(), anyInt());
    }
    @Test
    void theStatusFilterKeepsOnlyMatchingSettlementRows() {
        assertThat(recordsOf(fetch(null, null, null, "S")))
                .singleElement()
                .satisfies(record -> assertThat(record).containsEntry("providerTransactionNo", "TX-S-" + openHuifuId));
        assertThat(recordsOf(fetch(null, null, null, "F")))
                .singleElement()
                .satisfies(record -> assertThat(record).containsEntry("providerTransactionNo", "TX-F-" + openHuifuId));
        // 通道里没有的状态就不该留下任何流水,而不是退化成「不过滤」。
        assertThat(recordsOf(fetch(null, null, null, "P"))).isEmpty();
    }
    /** 关闭的子商户回的是通道原话;它只在真的被查到时才会成为一条失败提示。 */
    private void stubProvider() {
        when(settlementRecordGateway.query(anyString(), anyString(), anyString(), anyInt()))
                .thenAnswer(invocation -> {
                    String huifuId = invocation.getArgument(0);
                    if (closedHuifuId.equals(huifuId)) {
                        return new HuifuSettlementRecordGateway.SettlementRecords(false, "90000001", "客户与产品关系已关闭", 0, "");
                    }
                    // 测试库里还有别的用例留下的商户,它们也会通过过滤被查到;只给自己的商户造流水,
                    // 断言才不受这些历史行影响。
                    if (!huifuId.startsWith("TF" + suffix)) {
                        return new HuifuSettlementRecordGateway.SettlementRecords(true, "00000000", "成功", 0, "");
                    }
                    String records = "[{\"trans_id\":\"TX-S-" + huifuId + "\",\"trans_date\":\"20260918\",\"trans_amt\":\"0.10\","
                            + "\"fee_amt\":\"0.00\",\"settle_cycle\":\"T1\",\"trans_stat\":\"S\","
                            + "\"card_no\":\"6217********0924\",\"card_name\":\"阳野\"},"
                            + "{\"trans_id\":\"TX-F-" + huifuId + "\",\"trans_date\":\"20260917\",\"trans_amt\":\"0.20\","
                            + "\"fee_amt\":\"0.00\",\"settle_cycle\":\"T1\",\"trans_stat\":\"F\","
                            + "\"card_no\":\"6217********0924\",\"card_name\":\"阳野\"}]";
                    return new HuifuSettlementRecordGateway.SettlementRecords(true, "00000000", "成功", 2, records);
                });
    }
    private Map<String, Object> fetch(String merchantName, String contactMobile, String merchantType, String status) {
        return settlementAdminService.settlements("20260801", "20260918", 50, merchantName, contactMobile, merchantType, status);
    }
    /** 建一家已进件通过的商户并写下通道能力快照,返回它的斗拱子商户号。 */
    private String approveMerchant(String merchantName, String merchantStat, String cashSwitch, boolean verified,
                                   String tag, String merchantType, String contactMobile) {
        jdbc.update("insert into merchant(merchant_name, merchant_type, contact_mobile, status, deleted) values (?, ?, ?, 1, 0)",
                merchantName, merchantType, contactMobile);
        Long merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
        jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", merchantId, merchantName);
        Long storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class);
        String huifuId = "TF" + suffix + tag;
        jdbc.update("""
                insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name,
                                                            contact_name, contact_mobile, idempotency_key, status, provider_huifu_id,
                                                            merchant_stat, auto_settle_status, cash_switch_status)
                values (?, ?, ?, 'individual', 'Settlement owner', 'Settlement owner', ?, ?, 'approved', ?, ?, '0', ?)
                """, merchantId, storeId, "MO-SET-" + suffix + tag, contactMobile, "settlement-admin-" + suffix + tag,
                huifuId, merchantStat, cashSwitch);
        Long applicationId = jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class);
        jdbc.update("update merchant_onboarding_application set capability_verified_at = case when ? then now() else null end where id = ?",
                verified, applicationId);
        return huifuId;
    }
    @SuppressWarnings("unchecked")
    private List<Map<String, Object>> recordsOf(Map<String, Object> view) {
        return (List<Map<String, Object>>) view.get("records");
    }
    @SuppressWarnings("unchecked")
    private List<String> failuresOf(Map<String, Object> view) {
        return (List<String>) view.get("failures");
    }
}