From 0432a1255300575656746997799e5cb35e88c8c5 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期一, 12 十月 2026 17:15:08 +0800
Subject: [PATCH] fix(settlement): 结算记录按商户号去重,共用收款主体不再把同一条流水印多遍
---
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java | 48 +++++++++++++++++------
docs/adr/0006-managed-onboarding-is-query-only.md | 7 +++
talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantSettlementAdminServiceTests.java | 32 +++++++++++++++
3 files changed, 73 insertions(+), 14 deletions(-)
diff --git a/docs/adr/0006-managed-onboarding-is-query-only.md b/docs/adr/0006-managed-onboarding-is-query-only.md
index 6113f62..fde85f5 100644
--- a/docs/adr/0006-managed-onboarding-is-query-only.md
+++ b/docs/adr/0006-managed-onboarding-is-query-only.md
@@ -34,6 +34,13 @@
记录没了权限也就没了),但**不回退通道侧的任何改动**——微信口径、人才端 AppID 绑定、延迟入账、实名都不是
纳管改的,解绑也不会把它们撤销。该商家名下还有在途订单或待结算资金时拒绝解绑,避免切断正在进行的对账。
+**共用同一个收款主体意味着:读通道的地方要按商户号聚合,不能按商家。** 结算流水属于**商户号**
+(一个收款主体),不属于商家——连锁各门店是各自独立的商家、共用它,通道里仍然只有一条流水。
+按商家逐家去问会把同一条流水按共用它的商家数重复印出来(7 家共用就是 7 行一模一样的流水号),
+而每家的查询各自带条数上限,截断之后还会把别的流水挤掉:**重复与漏行是同一条错**。
+见 `MerchantSettlementAdminService.settlements`(按商户号去重后查询;共用时那一行的商家名列出全部共用者,
+因为流水确实不属于其中任何一家)。
+
## 与 ADR-0001 的关系
ADR-0001 定的执行方式是「刷新进度会主动查询审核及业务结果,并**继续已满足前置条件的开通步骤**」。本条是
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java b/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
index dd48b64..ae38d1f 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
+++ b/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"));
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantSettlementAdminServiceTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantSettlementAdminServiceTests.java
index 7959841..1d98ace 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantSettlementAdminServiceTests.java
+++ b/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,
--
Gitblit v1.7.1