1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
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.times;
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();
    }
 
    /**
     * 多家商家共用同一个汇付商户号时,同一条结算流水只能出现一次。
     *
     * <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());
        // 格子放不下几家商家的名字(7 家就 50 多个字符),所以显示成「共用收款主体(N 家)」,
        // 名单随行带给前端做悬停提示。
        assertThat(recordsOf(fetch(null, null, null, null)))
                .filteredOn(record -> String.valueOf(record.get("providerTransactionNo")).endsWith(sharedHuifuId))
                .isNotEmpty()
                .allSatisfy(record -> {
                    assertThat(record).containsEntry("merchantName", "共用收款主体(2 家)");
                    assertThat(record.get("merchantNames"))
                            .asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.list(String.class))
                            .containsExactly("Settlement chain A " + suffix, "Settlement chain B " + suffix);
                });
    }
 
    /**
     * 按商家搜索照样能搜到共用的那条流水。
     *
     * <p>筛选是在**数据库那一层**做的(先定"查哪些商家的收款主体",再去问通道),不是拿格子里的显示
     * 文本去匹配;所以格子改成「共用收款主体(N 家)」不会让搜索失效。而且筛到只剩一家时,那一格就
     * 显示这一家的名字——运营按商家查的时候,看到的就是自己查的那家。
     */
    @Test
    void searchingByOneOfTheSharingMerchantsStillFindsTheSharedFlow() {
        String sharedHuifuId = "TF" + suffix + "SEARCH";
        approveMerchantOn(sharedHuifuId, "Settlement search A " + suffix, "NORMAL", "1", true, "SEARCH-A", "餐饮服务", "13800000007");
        approveMerchantOn(sharedHuifuId, "Settlement search B " + suffix, "NORMAL", "1", true, "SEARCH-B", "餐饮服务", "13800000008");
 
        List<Map<String, Object>> rows = recordsOf(fetch("Settlement search A " + suffix, null, null, null));
 
        assertThat(rows)
                .as("按商家筛的是「看谁名下的收款主体」,共用主体下也能搜到")
                .filteredOn(record -> String.valueOf(record.get("providerTransactionNo")).endsWith(sharedHuifuId))
                .isNotEmpty()
                .allSatisfy(record -> assertThat(record).containsEntry("merchantName", "Settlement search A " + suffix));
        verify(settlementRecordGateway).query(eq(sharedHuifuId), any(), any(), anyInt());
    }
 
    /** 关闭的子商户回的是通道原话;它只在真的被查到时才会成为一条失败提示。 */
    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) {
        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);
        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");
    }
}