fix(settlement): 结算记录按商户号去重,共用收款主体不再把同一条流水印多遍

根因:结算流水属于**商户号**(一个收款主体),而这条查询是**按商家**逐个去问通道再合并结果的。
连锁各门店是各自独立的商家、共用同一个商户号,于是同一个商户号被问了 N 次,同一条流水被印 N 遍
(7 家共用就是 7 行一模一样的流水号/金额/到账卡,看起来像结了 7 笔钱)。更隐蔽的是每家的查询
各自带 pageSize 上限,截断之后还会把别的流水整个挤掉——重复和漏行是同一条错。

改法:
- 先按商户号聚合,一个商户号只问一次通道;失败提示同样按商户号归并,不再一个商户号报 N 条。
- 共用时那一行的商家名把共用它的商家都列出来并注明「(共用收款主体)」——流水确实不属于其中
任何一家,只说第一家会误导,只留一个名字又像把别家漏了;只有一家时照常显示那一家。
- 筛选语义不变(按商家筛的是"看谁名下的收款主体"),因此筛成一家时会只显示那一家。

回归测试先写、先看它红:两家商家共用一个商户号、通道回两条流水,修复前返回 4 行(每条重复一次),
现在返回 2 行且只问通道一次。ADR-0006 补了这条领域事实:共用收款主体后,读通道的地方要按商户号
聚合,不能按商家。
3个文件已修改
87 ■■■■■ 已修改文件
docs/adr/0006-managed-onboarding-is-query-only.md 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantSettlementAdminServiceTests.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/adr/0006-managed-onboarding-is-query-only.md
@@ -34,6 +34,13 @@
记录没了权限也就没了),但**不回退通道侧的任何改动**——微信口径、人才端 AppID 绑定、延迟入账、实名都不是
纳管改的,解绑也不会把它们撤销。该商家名下还有在途订单或待结算资金时拒绝解绑,避免切断正在进行的对账。
**共用同一个收款主体意味着:读通道的地方要按商户号聚合,不能按商家。** 结算流水属于**商户号**
(一个收款主体),不属于商家——连锁各门店是各自独立的商家、共用它,通道里仍然只有一条流水。
按商家逐家去问会把同一条流水按共用它的商家数重复印出来(7 家共用就是 7 行一模一样的流水号),
而每家的查询各自带条数上限,截断之后还会把别的流水挤掉:**重复与漏行是同一条错**。
见 `MerchantSettlementAdminService.settlements`(按商户号去重后查询;共用时那一行的商家名列出全部共用者,
因为流水确实不属于其中任何一家)。
## 与 ADR-0001 的关系
ADR-0001 定的执行方式是「刷新进度会主动查询审核及业务结果,并**继续已满足前置条件的开通步骤**」。本条是
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
@@ -61,8 +61,14 @@
    /**
     * 结算记录:钱按结算周期自动划到结算卡之后,「每天结了多少」只能问通道。
     *
     * <p>按门店取该商户当前生效的斗拱子商户号,逐商户查询结算记录后合并成一条时间倒序的列表。
     * 通道查询失败只影响这一条商户,不会让整页失败——监控页最重要的是「看得到」。
     * <p>取各商家当前生效的斗拱子商户号,**按商户号去重后逐个查询**、合并成一条时间倒序的列表。
     * 通道查询失败只影响这一个商户号,不会让整页失败——监控页最重要的是「看得到」。
     *
     * <p><b>为什么按商户号而不是按商家去问</b>:结算流水属于商户号(一个收款主体),不属于商家。
     * 连锁各门店是各自独立的商家、共用同一个收款主体,通道里就是一条流水;按商家逐家去问会把同一条
     * 流水按共用它的商家数重复印出来(7 家共用就是 7 行一模一样的流水号/金额/到账卡),而每家的查询
     * 各自带 {@code limit},截断后又会把别的流水挤掉——重复与漏行是同一条错。共用时那一行的商家名
     * 把所有共用的商家都列出来(见 {@link #sharedMerchantLabel}),因为流水确实不属于其中任何一家。
     *
     * <p>只查「通道侧收款能力已核实且正常」的商户({@link MerchantPaymentReadinessService#channelCapabilityNormal}):
     * 没开通好、或在汇付被关停的商户,查过去也会被通道拒(实测被关闭的子商户回「客户与产品关系已关闭」),
@@ -92,29 +98,31 @@
                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<>();
        // 关键:按**商户号**聚合,而不是按商家。一个商户号就是一个收款主体,结算流水只挂在它上面。
        Map<String, List<Map<String, Object>>> merchantsByHuifuId = new LinkedHashMap<>();
        for (Map<String, Object> candidate : candidates) {
            if (readinessService.channelCapabilityNormal(((Number) candidate.get("applicationId")).longValue())) {
                merchants.add(candidate);
            }
            if (!readinessService.channelCapabilityNormal(((Number) candidate.get("applicationId")).longValue())) continue;
            merchantsByHuifuId.computeIfAbsent(String.valueOf(candidate.get("providerHuifuId")), huifuId -> new ArrayList<>())
                    .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);
        for (Map<String, Object> merchant : merchants) {
            String huifuId = String.valueOf(merchant.get("providerHuifuId"));
        for (Map.Entry<String, List<Map<String, Object>>> entry : merchantsByHuifuId.entrySet()) {
            String huifuId = entry.getKey();
            String label = sharedMerchantLabel(entry.getValue());
            try {
                HuifuSettlementRecordGateway.SettlementRecords response =
                        settlementRecordGateway.query(huifuId, beginDate, endDate, limit);
                if (!response.succeeded()) {
                    failures.add(String.valueOf(merchant.get("merchantName")) + ":" + response.message());
                    failures.add(label + ":" + response.message());
                    continue;
                }
                for (Map<String, Object> record : decode(response.rawRecords(), merchant)) {
                for (Map<String, Object> record : decode(response.rawRecords(), entry.getValue().get(0), label)) {
                    if (matchesStatus(record, status)) records.add(record);
                }
            } catch (RuntimeException exception) {
                failures.add(String.valueOf(merchant.get("merchantName")) + ":查询失败");
                failures.add(label + ":查询失败");
            }
        }
        records.sort((left, right) -> String.valueOf(right.get("settleDate"))
@@ -134,8 +142,21 @@
        return status.trim().equalsIgnoreCase(String.valueOf(record.get("status")));
    }
    /**
     * 这一行显示成哪家的。
     *
     * <p>一个商户号可以被多家商家共用(连锁各门店各是一个独立商家,共用一个收款主体),而流水属于
     * 商户号、不属于其中任何一家。只说第一家会误导,只留一个名字又像把别家漏了,所以共用时把商家名
     * 都列出来并注明共用;只有一家时就照常显示那一家。
     */
    private static String sharedMerchantLabel(List<Map<String, Object>> merchants) {
        List<String> names = merchants.stream().map(merchant -> String.valueOf(merchant.get("merchantName")))
                .distinct().toList();
        return names.size() == 1 ? names.get(0) : String.join("、", names) + "(共用收款主体)";
    }
    /** 把通道返回的结算记录 JSON 转成后台要展示的字段;只读展示,不做金额换算以外的加工。 */
    private List<Map<String, Object>> decode(String rawRecords, Map<String, Object> merchant) {
    private List<Map<String, Object>> decode(String rawRecords, Map<String, Object> merchant, String merchantLabel) {
        List<Map<String, Object>> decoded = new ArrayList<>();
        if (rawRecords == null || rawRecords.isBlank()) return decoded;
        List<Map<String, Object>> parsed;
@@ -148,7 +169,8 @@
        for (Map<String, Object> record : parsed) {
            Map<String, Object> row = new LinkedHashMap<>();
            row.put("merchantId", merchant.get("merchantId"));
            row.put("merchantName", merchant.get("merchantName"));
            // 商家名取聚合出来的那个标签:几家共用同一个商户号时,这一条流水是所有共用商家一起的。
            row.put("merchantName", merchantLabel);
            row.put("storeName", merchant.get("storeName"));
            row.put("providerTransactionNo", record.get("trans_id"));
            row.put("settleDate", record.get("trans_date"));
talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantSettlementAdminServiceTests.java
@@ -6,6 +6,7 @@
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -131,6 +132,30 @@
        assertThat(recordsOf(fetch(null, null, null, "P"))).isEmpty();
    }
    /**
     * 多家商家共用同一个汇付商户号时,同一条结算流水只能出现一次。
     *
     * <p>结算流水属于**商户号**(一个收款主体),不属于商家:连锁各门店是独立商家、共用同一个收款主体,
     * 通道里就是一条流水。按商家逐家去问通道,会把同一条流水按共用它的商家数重复印出来——页面上看起来
     * 像结了 N 笔钱(7 家共用就是 7 行一模一样的流水号/金额/到账卡)。
     */
    @Test
    void oneSettlementFlowIsNotRepeatedForEveryMerchantThatSharesTheHuifuId() {
        String sharedHuifuId = "TF" + suffix + "SHARED";
        approveMerchantOn(sharedHuifuId, "Settlement chain A " + suffix, "NORMAL", "1", true, "SHARED-A", "餐饮服务", "13800000005");
        approveMerchantOn(sharedHuifuId, "Settlement chain B " + suffix, "NORMAL", "1", true, "SHARED-B", "餐饮服务", "13800000006");
        List<String> sharedFlows = recordsOf(fetch(null, null, null, null)).stream()
                .map(record -> String.valueOf(record.get("providerTransactionNo")))
                .filter(flow -> flow.endsWith(sharedHuifuId))
                .toList();
        assertThat(sharedFlows)
                .as("一条流水只该出现一次,不能按共用它的商家数重复")
                .containsExactlyInAnyOrder("TX-S-" + sharedHuifuId, "TX-F-" + sharedHuifuId);
        verify(settlementRecordGateway, times(1)).query(eq(sharedHuifuId), any(), any(), anyInt());
    }
    /** 关闭的子商户回的是通道原话;它只在真的被查到时才会成为一条失败提示。 */
    private void stubProvider() {
        when(settlementRecordGateway.query(anyString(), anyString(), anyString(), anyInt()))
@@ -161,12 +186,17 @@
    /** 建一家已进件通过的商户并写下通道能力快照,返回它的斗拱子商户号。 */
    private String approveMerchant(String merchantName, String merchantStat, String cashSwitch, boolean verified,
                                   String tag, String merchantType, String contactMobile) {
        return approveMerchantOn("TF" + suffix + tag, merchantName, merchantStat, cashSwitch, verified, tag, merchantType, contactMobile);
    }
    /** 同一个商户号可以被多家商家共用(连锁各门店各是一个独立商家),所以商户号由调用方给。 */
    private String approveMerchantOn(String huifuId, 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,