fix(settlement): 结算记录按商户号去重,共用收款主体不再把同一条流水印多遍
根因:结算流水属于**商户号**(一个收款主体),而这条查询是**按商家**逐个去问通道再合并结果的。
连锁各门店是各自独立的商家、共用同一个商户号,于是同一个商户号被问了 N 次,同一条流水被印 N 遍
(7 家共用就是 7 行一模一样的流水号/金额/到账卡,看起来像结了 7 笔钱)。更隐蔽的是每家的查询
各自带 pageSize 上限,截断之后还会把别的流水整个挤掉——重复和漏行是同一条错。
改法:
- 先按商户号聚合,一个商户号只问一次通道;失败提示同样按商户号归并,不再一个商户号报 N 条。
- 共用时那一行的商家名把共用它的商家都列出来并注明「(共用收款主体)」——流水确实不属于其中
任何一家,只说第一家会误导,只留一个名字又像把别家漏了;只有一家时照常显示那一家。
- 筛选语义不变(按商家筛的是"看谁名下的收款主体"),因此筛成一家时会只显示那一家。
回归测试先写、先看它红:两家商家共用一个商户号、通道回两条流水,修复前返回 4 行(每条重复一次),
现在返回 2 行且只问通道一次。ADR-0006 补了这条领域事实:共用收款主体后,读通道的地方要按商户号
聚合,不能按商家。
| | |
| | | 记录没了权限也就没了),但**不回退通道侧的任何改动**——微信口径、人才端 AppID 绑定、延迟入账、实名都不是 |
| | | 纳管改的,解绑也不会把它们撤销。该商家名下还有在途订单或待结算资金时拒绝解绑,避免切断正在进行的对账。 |
| | | |
| | | **共用同一个收款主体意味着:读通道的地方要按商户号聚合,不能按商家。** 结算流水属于**商户号** |
| | | (一个收款主体),不属于商家——连锁各门店是各自独立的商家、共用它,通道里仍然只有一条流水。 |
| | | 按商家逐家去问会把同一条流水按共用它的商家数重复印出来(7 家共用就是 7 行一模一样的流水号), |
| | | 而每家的查询各自带条数上限,截断之后还会把别的流水挤掉:**重复与漏行是同一条错**。 |
| | | 见 `MerchantSettlementAdminService.settlements`(按商户号去重后查询;共用时那一行的商家名列出全部共用者, |
| | | 因为流水确实不属于其中任何一家)。 |
| | | |
| | | ## 与 ADR-0001 的关系 |
| | | |
| | | ADR-0001 定的执行方式是「刷新进度会主动查询审核及业务结果,并**继续已满足前置条件的开通步骤**」。本条是 |
| | |
| | | /** |
| | | * 结算记录:钱按结算周期自动划到结算卡之后,「每天结了多少」只能问通道。 |
| | | * |
| | | * <p>按门店取该商户当前生效的斗拱子商户号,逐商户查询结算记录后合并成一条时间倒序的列表。 |
| | | * 通道查询失败只影响这一条商户,不会让整页失败——监控页最重要的是「看得到」。 |
| | | * <p>取各商家当前生效的斗拱子商户号,**按商户号去重后逐个查询**、合并成一条时间倒序的列表。 |
| | | * 通道查询失败只影响这一个商户号,不会让整页失败——监控页最重要的是「看得到」。 |
| | | * |
| | | * <p><b>为什么按商户号而不是按商家去问</b>:结算流水属于商户号(一个收款主体),不属于商家。 |
| | | * 连锁各门店是各自独立的商家、共用同一个收款主体,通道里就是一条流水;按商家逐家去问会把同一条 |
| | | * 流水按共用它的商家数重复印出来(7 家共用就是 7 行一模一样的流水号/金额/到账卡),而每家的查询 |
| | | * 各自带 {@code limit},截断后又会把别的流水挤掉——重复与漏行是同一条错。共用时那一行的商家名 |
| | | * 把所有共用的商家都列出来(见 {@link #sharedMerchantLabel}),因为流水确实不属于其中任何一家。 |
| | | * |
| | | * <p>只查「通道侧收款能力已核实且正常」的商户({@link MerchantPaymentReadinessService#channelCapabilityNormal}): |
| | | * 没开通好、或在汇付被关停的商户,查过去也会被通道拒(实测被关闭的子商户回「客户与产品关系已关闭」), |
| | |
| | | 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")) |
| | |
| | | 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; |
| | |
| | | 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")); |
| | |
| | | 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; |
| | | |
| | |
| | | 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())) |
| | |
| | | /** 建一家已进件通过的商户并写下通道能力快照,返回它的斗拱子商户号。 */ |
| | | 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, |