yeg
2 天以前 f2cca8fe463efe96ad8d949a40bbee52baf89d8c
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
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
package com.talentcard.server.business.admin;
 
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
 
import com.talentcard.server.business.merchant.MerchantPaymentReadinessService;
import com.talentcard.server.business.merchant.MerchantSettlementService;
import com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway;
import com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway;
import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway;
import com.talentcard.server.business.payment.huifu.HuifuPictureUploadGateway;
import com.talentcard.server.common.BusinessException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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>断言只落在对外可见的行为上:纳管与解绑的输入校验与结果、纳管后该商家能通过下单闸门、
 * 共用同一商户号的多个商家各自可用、重复纳管幂等、解绑的在途资金拦截,以及「纳管全程只查询不下发」。
 * 不测内部 SQL 与私有方法调用顺序。
 */
@SpringBootTest
@ActiveProfiles("test")
class MerchantManagedOnboardingTests {
    private static final String APPLY_NO = "HF-EXTERNAL-APPLY-001";
 
    @Autowired JdbcTemplate jdbc;
    @Autowired MerchantManagedOnboardingService service;
    @Autowired MerchantSettlementService settlementService;
    @Autowired MerchantPaymentReadinessService readinessService;
    @Autowired MerchantSettlementAdminService adminSettlementService;
    @MockitoBean HuifuOnboardingProgressGateway gateway;
    // 纳管全程只查询不下发:这三个下发网关全部挡在这里,任何一次调用都会被断言抓住。
    @MockitoBean HuifuMerchantWechatConfigGateway wechatConfigGateway;
    @MockitoBean HuifuMerchantBusinessOpenGateway businessOpenGateway;
    @MockitoBean HuifuPictureUploadGateway pictureUploadGateway;
 
    @Test
    void adoptingAChannelMerchantLetsTheStoreCollectThroughTheOrderGate() {
        String huifuId = uniqueHuifuId();
        Fixture fixture = merchantFixture("纳管成功");
        stubConvergedChannel(huifuId, "622200001234", "external-card-token");
 
        Map<String, Object> adopted = service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
 
        assertThat(adopted).containsEntry("status", "approved");
        assertThat(jdbc.queryForObject("""
                select source from merchant_onboarding_application where merchant_id = ? and provider_huifu_id = ?
                """, String.class, fixture.merchantId(), huifuId)).isEqualTo("managed");
        assertThat(jdbc.queryForObject("""
                select provider_apply_no from merchant_onboarding_application where merchant_id = ? and provider_huifu_id = ?
                """, String.class, fixture.merchantId(), huifuId)).isEqualTo(APPLY_NO);
        // 结算卡是硬前提:卡与绑卡序列号都从通道查回来,闸门才放行。
        assertThat(jdbc.queryForObject("""
                select provider_token_no from merchant_bank_card where merchant_id = ? and status = 'approved'
                """, String.class, fixture.merchantId())).isEqualTo("external-card-token");
        // 与自助进件走完全相同的收款就绪口径。
        assertThat(readinessService.requireCollectingMerchant(fixture.merchantId(), fixture.storeId()).huifuId())
                .isEqualTo(huifuId);
    }
 
    /**
     * 进度刷新要能吃下「只有汇付侧入驻申请单号」的进件。
     *
     * <p>通道的申请单状态查询把 {@code apply_no} 列为必填,外部进件商户没有我方发起过的申请单号;
     * 纳管时把汇付侧那个号一起录入,刷新流程无需改动即可跑通,不会永远停在未确认。
     */
    @Test
    void progressConvergesFromTheChannelApplyNumberAlone() {
        String huifuId = uniqueHuifuId();
        Fixture fixture = merchantFixture("通道申请单号");
        stubConvergedChannel(huifuId, "622200001234", "external-card-token");
 
        service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
 
        verify(gateway).queryApplication(eq(huifuId), eq(APPLY_NO));
        assertThat(adoptedApplicationId(fixture.merchantId(), huifuId)).isNotNull();
    }
 
    /** 通道查不到 = 该商户不在我们渠道商的管辖范围内,纳管必须明确拒绝并给出原因。 */
    @Test
    void aMerchantTheChannelCannotQueryIsRejectedWithItsReason() {
        String huifuId = uniqueHuifuId();
        Fixture fixture = merchantFixture("通道查不到");
        when(gateway.queryCapabilities(huifuId)).thenReturn(Map.of("resp_code", "20002", "resp_desc", "数据权限认证失败"));
 
        assertThatThrownBy(() -> service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("通道查不到该商户")
                .hasMessageContaining("数据权限认证失败");
        assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where merchant_id = ?",
                Integer.class, fixture.merchantId())).isZero();
    }
 
    /** 没勾确认框不受理:名称核对是唯一防绑错的那道人工闸门。 */
    @Test
    void adoptingWithoutTheManualConfirmationIsRejected() {
        String huifuId = uniqueHuifuId();
        Fixture fixture = merchantFixture("未确认");
        stubConvergedChannel(huifuId, "622200001234", "external-card-token");
 
        assertThatThrownBy(() -> service.adopt(fixture.merchantId(), huifuId, APPLY_NO, false))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("勾选确认");
    }
 
    /** 入驻申请单号必填:取不到就不纳管(不做不依赖申请单号的备用判定分支)。 */
    @Test
    void adoptingWithoutTheChannelApplyNumberIsRejected() {
        String huifuId = uniqueHuifuId();
        Fixture fixture = merchantFixture("缺申请单号");
 
        assertThatThrownBy(() -> service.adopt(fixture.merchantId(), huifuId, "  ", true))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("入驻申请单号");
    }
 
    /**
     * 主体名不符**只警示不硬拒**:我们这边只有一个商家名(常是简称或服务点名),通道那边是工商全称,
     * 形态不对应,规则化匹配必然误判——误拒会把运营卡死且无路可绕。
     */
    @Test
    void aMerchantNameSharingNotASingleCharacterWarnsButStillAdopts() {
        String huifuId = uniqueHuifuId();
        Fixture fixture = merchantFixture("A甲乙丙丁戊己");
        stubConvergedChannel(huifuId, "622200001234", "external-card-token", "上海汇付支付服务有限公司", "汇付支付");
 
        Map<String, Object> preview = service.preview(fixture.merchantId(), huifuId);
 
        assertThat(String.valueOf(preview.get("nameWarning"))).contains("没有任何相同字符");
        assertThat(preview).containsEntry("channelMerchantName", "上海汇付支付服务有限公司");
        // 警示不阻挡提交。
        assertThat(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true)).containsEntry("status", "approved");
    }
 
    /** 名称有重合字符时不提示,避免天天喊狼来了。 */
    @Test
    void overlappingNamesProduceNoWarning() {
        assertThat(MerchantManagedOnboardingService.nameWarning("汇付餐饮店", "上海汇付支付服务有限公司", "汇付支付")).isEmpty();
        assertThat(MerchantManagedOnboardingService.nameWarning("测试商家", "上海汇付支付服务有限公司", "汇付支付")).isNotEmpty();
    }
 
    /** 同一个汇付商户号可以被多个商家分别纳管(连锁各门店共用一个收款主体),只提示不拦截。 */
    @Test
    void aHuifuIdAlreadyBoundToAnotherMerchantIsReportedButNotBlocked() {
        String huifuId = uniqueHuifuId();
        Fixture first = merchantFixture("连锁A店");
        Fixture second = merchantFixture("连锁B店");
        stubConvergedChannel(huifuId, "622200001234", "external-card-token");
 
        service.adopt(first.merchantId(), huifuId, APPLY_NO, true);
        // 门店名与商家名一样时不重复印一遍——多数门店就是这样,列表里不该出现「某某商户(某某商户)」。
        jdbc.update("update merchant_store set store_name = ? where id = ?", first.key(), first.storeId());
        Map<String, Object> preview = service.preview(second.merchantId(), huifuId);
 
        assertThat(preview.get("boundMerchants"))
                .asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.list(String.class))
                .containsExactly(first.key());
        assertThat((List<?>) preview.get("blockers")).as("已被其他商家绑定只是提示,不能拦").isEmpty();
        assertThat(service.adopt(second.merchantId(), huifuId, APPLY_NO, true)).containsEntry("status", "approved");
        assertThat(readinessService.requireCollectingMerchant(second.merchantId(), second.storeId()).huifuId()).isEqualTo(huifuId);
    }
 
    /** 重复纳管同一商家是幂等的,不会产生第二条进件记录。 */
    @Test
    void adoptingTheSameMerchantTwiceIsIdempotent() {
        String huifuId = uniqueHuifuId();
        Fixture fixture = merchantFixture("幂等");
        stubConvergedChannel(huifuId, "622200001234", "external-card-token");
 
        Map<String, Object> first = service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
        Map<String, Object> second = service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
 
        assertThat(second.get("id")).isEqualTo(first.get("id"));
        assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where merchant_id = ?",
                Integer.class, fixture.merchantId())).isEqualTo(1);
        assertThat(jdbc.queryForObject("select count(*) from merchant_bank_card where merchant_id = ?",
                Integer.class, fixture.merchantId())).isEqualTo(1);
        // 已经纳管过的商户号在核验里只说明会复用原记录,不能报成「门店已有进件」这种硬拦截。
        Map<String, Object> preview = service.preview(fixture.merchantId(), huifuId);
        assertThat(preview).containsEntry("alreadyManaged", true);
        assertThat((List<?>) preview.get("blockers")).isEmpty();
    }
 
    /**
     * 命中幂等时不能报一句「纳管成功」却什么都没发生。
     *
     * <p>被通道驳回的记录刷新救不回来({@code supports()} 对 rejected 返回 false),所以必须明确让运营
     * 去解绑后重来;否则运营会以为纳管生效了,而这条记录永远收不了款。
     */
    @Test
    void readoptingAChannelRejectedApplicationAsksTheOperatorToUnbindFirst() {
        String huifuId = uniqueHuifuId();
        Fixture fixture = merchantFixture("已驳回重纳管");
        stubConvergedChannel(huifuId, "622200001234", "external-card-token");
        service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
        jdbc.update("update merchant_onboarding_application set status = 'rejected' where merchant_id = ?", fixture.merchantId());
 
        assertThatThrownBy(() -> service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("已被通道驳回")
                .hasMessageContaining("解绑");
        assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where merchant_id = ?",
                Integer.class, fixture.merchantId())).isEqualTo(1);
    }
 
    /** 同一门店已经有进行中或已通过的进件时拒绝:两条收款主体会在下单时打架。 */
    @Test
    void adoptingASecondSubMerchantForTheSameStoreIsRejected() {
        String firstHuifuId = uniqueHuifuId();
        String secondHuifuId = uniqueHuifuId();
        Fixture fixture = merchantFixture("门店冲突");
        stubConvergedChannel(firstHuifuId, "622200001234", "external-card-token");
        stubConvergedChannel(secondHuifuId, "622200001235", "second-card-token");
 
        service.adopt(fixture.merchantId(), firstHuifuId, APPLY_NO, true);
 
        assertThat(service.preview(fixture.merchantId(), secondHuifuId).get("blockers"))
                .asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.list(String.class))
                .anySatisfy(blocker -> assertThat(blocker).contains("同一门店出现两个收款主体"));
        assertThatThrownBy(() -> service.adopt(fixture.merchantId(), secondHuifuId, APPLY_NO, true))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("同一门店出现两个收款主体");
    }
 
    /**
     * 纳管全程只查询、不下发:通道侧延迟入账没配好时,纳管**不会**替它配一次。
     *
     * <p>下发类操作不幂等(改支付场景会重建微信子商户号),且连锁多店共用同一个汇付商户号时各自
     * 下发会重复下发。缺什么由界面按阶段如实显示,处理另走自助进件的既有路径。
     */
    @Test
    void adoptionNeverSendsAnyProviderChange() {
        String huifuId = uniqueHuifuId();
        Fixture fixture = merchantFixture("只查询不下发");
        // 通道侧其他能力都正常,只有延迟入账没开——自助进件在这一点上会下发一次 delay 配置。
        stubConvergedChannel(huifuId, "622200001234", "external-card-token");
        when(gateway.queryCapabilities(huifuId)).thenReturn(channelDetail(huifuId, "622200001234", "external-card-token", "N", false));
 
        Map<String, Object> adopted = service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
 
        verify(gateway, never()).configureDelay(anyString(), anyString(), anyString());
        verify(wechatConfigGateway, never()).configure(anyString(), anyString());
        verify(businessOpenGateway, never()).open(anyString(), anyString(), anyString(), anyString(), anyBoolean(), anyBoolean(), any(), any(), any());
        verifyNoInteractions(pictureUploadGateway);
        // 如实反映「这一步还没配好」,而不是伪造成功状态。
        assertThat(adopted).containsEntry("status", "pending");
        assertThat((List<Map<String, Object>>) adopted.get("stages"))
                .anySatisfy(stage -> assertThat(stage).containsEntry("key", "delay").containsEntry("status", "missing"));
    }
 
    /** 后台/商户端点「刷新进度」对纳管记录同样是只查询:不会走开通步骤的那条下发链路。 */
    @Test
    void refreshingAManagedApplicationStaysQueryOnly() {
        String huifuId = uniqueHuifuId();
        Fixture fixture = merchantFixture("刷新只查询");
        stubConvergedChannel(huifuId, "622200001234", "external-card-token");
        Map<String, Object> adopted = service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
 
        settlementService.refreshProgress(Long.valueOf(String.valueOf(adopted.get("id"))), false);
 
        verify(wechatConfigGateway, never()).configure(anyString(), anyString());
        verify(businessOpenGateway, never()).open(anyString(), anyString(), anyString(), anyString(), anyBoolean(), anyBoolean(), any(), any(), any());
        verify(gateway, never()).configureDelay(anyString(), anyString(), anyString());
        // 通道返回的一切都按原样落库,进度仍是已通过。
        assertThat(settlementService.refreshProgress(Long.valueOf(String.valueOf(adopted.get("id"))), false))
                .containsEntry("status", "approved");
    }
 
    /** 解绑清理进件记录与结算卡记录,两者都从后台列表消失。 */
    @Test
    void unbindingRemovesTheOnboardingAndItsSettlementCard() {
        String huifuId = uniqueHuifuId();
        Fixture fixture = merchantFixture("解绑");
        stubConvergedChannel(huifuId, "622200001234", "external-card-token");
        Long applicationId = Long.valueOf(String.valueOf(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true).get("id")));
        assertThat(adminSettlementService.bankCards(1, 20, fixture.key(), null, null, null, null, null).records()).hasSize(1);
 
        Map<String, Object> result = service.unbind(applicationId);
 
        assertThat(result).containsEntry("unbound", true);
        assertThat(adminSettlementService.onboardings(1, 20, fixture.key(), null, null, null, null, null).records()).isEmpty();
        assertThat(adminSettlementService.bankCards(1, 20, fixture.key(), null, null, null, null, null).records()).isEmpty();
        assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_progress where application_id = ?",
                Integer.class, applicationId)).isZero();
    }
 
    /** 该商户号上还有已收款未结算的订单时拒绝解绑:那是正在进行的对账,不能切断。 */
    @Test
    void unbindingIsRefusedWhileMoneyIsStillUnsettled() {
        String huifuId = uniqueHuifuId();
        Fixture fixture = merchantFixture("待结算");
        stubConvergedChannel(huifuId, "622200001234", "external-card-token");
        Long applicationId = Long.valueOf(String.valueOf(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true).get("id")));
        insertOrder(fixture, applicationId, huifuId, /*payAmount*/ 1000, /*orderStatus*/ 1, /*refundStatus*/ 0);
 
        assertThatThrownBy(() -> service.unbind(applicationId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("已收款未结算");
        assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where id = ?",
                Integer.class, applicationId)).isEqualTo(1);
    }
 
    @Test
    void unbindingIsRefusedWhileOrdersAreStillInFlight() {
        String huifuId = uniqueHuifuId();
        Fixture fixture = merchantFixture("在途订单");
        stubConvergedChannel(huifuId, "622200001234", "external-card-token");
        Long applicationId = Long.valueOf(String.valueOf(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true).get("id")));
        insertOrder(fixture, applicationId, huifuId, /*payAmount*/ 1000, /*orderStatus*/ 0, /*refundStatus*/ 0);
 
        assertThatThrownBy(() -> service.unbind(applicationId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("在途订单");
    }
 
    /** 已取消/已退款的终态订单不拦解绑:那不是在途资金。 */
    @Test
    void settledOrRefundedOrdersDoNotBlockUnbinding() {
        String huifuId = uniqueHuifuId();
        Fixture fixture = merchantFixture("终态订单");
        stubConvergedChannel(huifuId, "622200001234", "external-card-token");
        Long applicationId = Long.valueOf(String.valueOf(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true).get("id")));
        insertOrder(fixture, applicationId, huifuId, /*payAmount*/ 1000, /*orderStatus*/ 2, /*refundStatus*/ 0);
        insertOrder(fixture, applicationId, huifuId, /*payAmount*/ 1000, /*orderStatus*/ 1, /*refundStatus*/ 2);
 
        assertThat(service.unbind(applicationId)).containsEntry("unbound", true);
    }
 
    /** 自助进件的记录不支持解绑:那是商户自己提交的资料,删掉它是另一个性质的操作。 */
    @Test
    void aSelfOnboardedApplicationCannotBeUnbound() {
        Fixture fixture = merchantFixture("自助进件");
        jdbc.update("""
                insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type,
                    subject_name, contact_name, contact_mobile, idempotency_key, status, source)
                values (?, ?, ?, 'enterprise', '自助主体', '自助联系人', '13700000000', ?, 'pending', 'self')
                """, fixture.merchantId(), fixture.storeId(), "MO-SELF-" + fixture.key(), fixture.key());
        Long applicationId = jdbc.queryForObject("select id from merchant_onboarding_application where merchant_id = ?",
                Long.class, fixture.merchantId());
 
        assertThatThrownBy(() -> service.unbind(applicationId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("不支持解绑");
    }
 
    /** 解绑只解除我方绑定:商户端登录账号与门店资料都不动,之后可以重新纳管。 */
    @Test
    void unbindingKeepsTheMerchantAccountAndAllowsAdoptingAgain() {
        String huifuId = uniqueHuifuId();
        Fixture fixture = merchantFixture("可重新纳管");
        jdbc.update("insert into merchant_operator(merchant_id, account, password_hash, display_name, role_code, status, deleted) values (?, ?, 'x', '店主', 'owner', 1, 0)",
                fixture.merchantId(), "t01op" + Math.abs(System.nanoTime()));
        stubConvergedChannel(huifuId, "622200001234", "external-card-token");
        Long applicationId = Long.valueOf(String.valueOf(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true).get("id")));
 
        service.unbind(applicationId);
        Map<String, Object> again = service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
 
        assertThat(again).containsEntry("status", "approved");
        assertThat(again.get("id")).isNotEqualTo(String.valueOf(applicationId));
        assertThat(jdbc.queryForObject("select count(*) from merchant_operator where merchant_id = ? and status = 1 and deleted = 0",
                Integer.class, fixture.merchantId())).isEqualTo(1);
    }
 
    /** 该商家还没有商户端登录账号时纳管照做,但要提示运营,否则商户登不进来。 */
    @Test
    void adoptionWarnsWhenTheMerchantCannotSignInYet() {
        String huifuId = uniqueHuifuId();
        Fixture fixture = merchantFixture("未开登录");
        jdbc.update("update merchant set can_login_merchant_app = 0 where id = ?", fixture.merchantId());
        stubConvergedChannel(huifuId, "622200001234", "external-card-token");
 
        Map<String, Object> preview = service.preview(fixture.merchantId(), huifuId);
 
        assertThat(String.valueOf(preview.get("loginNotice"))).contains("未开启商户端登录");
        // 提示不阻挡纳管:纳管只管收款绑定,账号由「商家查询」负责。
        assertThat(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true)).containsEntry("status", "approved");
    }
 
    /** 通道没给出可用结算卡时不纳管:没有结算卡,进度不会推进、闸门也不会放行。 */
    @Test
    void adoptingIsRejectedWhenTheChannelReturnsNoUsableCard() {
        String huifuId = uniqueHuifuId();
        Fixture fixture = merchantFixture("无结算卡");
        Map<String, Object> detail = channelDetail(huifuId, "622200001234", "token", "Y", true);
        detail.put("qry_cash_card_info_list", List.of(Map.of("card_no", "6222****1234", "token_no", "masked-token")));
        when(gateway.queryCapabilities(huifuId)).thenReturn(detail);
 
        assertThat(service.preview(fixture.merchantId(), huifuId).get("blockers"))
                .asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.list(String.class))
                .anySatisfy(blocker -> assertThat(blocker).contains("结算银行卡"));
        assertThatThrownBy(() -> service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("结算银行卡");
    }
 
    private Long adoptedApplicationId(Long merchantId, String huifuId) {
        return jdbc.queryForObject("select id from merchant_onboarding_application where merchant_id = ? and provider_huifu_id = ?",
                Long.class, merchantId, huifuId);
    }
 
    /** 通道侧一切就绪(六阶段 + 结算卡 + 商户状态正常)的商户详情回执。 */
    private void stubConvergedChannel(String huifuId, String cardNumber, String token) {
        stubConvergedChannel(huifuId, cardNumber, token, "攀枝花测试餐饮有限公司", "测试餐饮");
    }
 
    private void stubConvergedChannel(String huifuId, String cardNumber, String token, String registeredName, String shortName) {
        when(gateway.queryCapabilities(huifuId)).thenReturn(channelDetail(huifuId, cardNumber, token, "Y", true, registeredName, shortName));
        when(gateway.queryApplication(anyString(), anyString())).thenReturn(Map.of(
                "resp_code", "00000000", "apply_status", "Y", "reg_status", "S", "bind_card_status", "S"));
        when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code", "00000000", "atu_config_bo_list",
                List.of(Map.of("fee_type", "02", "wx_config_list",
                        List.of(Map.of("config_type", "1", "config_value", "wxcfee477acb54d179"))))));
        when(gateway.queryRealname(anyString())).thenReturn(Map.of(
                "resp_code", "00000000", "applyment_stat", "APPLYMENT_STATE_PASSED", "authorize_stat", "1"));
    }
 
    /**
     * 商户详细信息查询的回执:名称、主体类型、结算卡、微信场景、结算配置与延迟配置。
     *
     * <p>{@code delayFlag} 传 {@code N} 就是「通道侧还没开延迟入账」——自助进件在这一点上会下发一次
     * 配置,纳管不允许下发。
     */
    private Map<String, Object> channelDetail(String huifuId, String cardNumber, String token, String delayFlag, boolean settlementConfigured) {
        return channelDetail(huifuId, cardNumber, token, delayFlag, settlementConfigured, "攀枝花测试餐饮有限公司", "测试餐饮");
    }
 
    private Map<String, Object> channelDetail(String huifuId, String cardNumber, String token, String delayFlag,
                                             boolean settlementConfigured, String registeredName, String shortName) {
        Map<String, Object> detail = new LinkedHashMap<>();
        detail.put("resp_code", "00000000");
        detail.put("huifu_id", huifuId);
        detail.put("reg_name", registeredName);
        detail.put("short_name", shortName);
        detail.put("cust_type", "1");
        detail.put("ent_type", "3");
        detail.put("legal_name", "测试法人");
        detail.put("legal_mobile_no", "13700000009");
        detail.put("contact_name", "测试联系人");
        detail.put("contact_mobile_no", "13700000008");
        detail.put("mer_stat", "NORMAL");
        detail.put("delay_flag", delayFlag);
        detail.put("forced_delay_flag", "N");
        detail.put("qry_cash_card_info_list", List.of(Map.of(
                "card_no", cardNumber, "token_no", token, "status", "N", "is_settle_default", "Y",
                "bank_name", "中国工商银行", "branch_name", "中国工商银行攀枝花东区支行", "branch_code", "102290026507",
                "area_id", "510400", "card_name", registeredName)));
        List<Map<String, Object>> settleConfigs = settlementConfigured
                ? List.of(Map.of("settle_status", "1", "settle_cycle", "D1", "settle_pattern", "P0",
                        "min_amt", "0", "remained_amt", "0", "token_no", token))
                : List.of();
        detail.put("qry_settle_config_list", settleConfigs);
        detail.put("qry_wx_conf_list", wechatScenes());
        return detail;
    }
 
    private static List<Map<String, Object>> wechatScenes() {
        return List.of("1", "2", "3").stream().map(scene -> Map.of(
                "pay_scene", scene, "fee_rate", "0.30", "fee_min_amt", "0.00",
                "fee_charge_type", "1", "out_fee_flag", "2",
                "wx_mer_infos", List.of(Map.of("bank_mer_code", "922609119", "pay_channel_id", "00016904", "is_default", "1"))
        )).toList();
    }
 
    /** 一笔处于某种状态的订单;纳管记录会被订单上的收款绑定指回来。 */
    private void insertOrder(Fixture fixture, Long applicationId, String huifuId, long payAmount, int orderStatus, int refundStatus) {
        jdbc.update("""
                insert into benefit_order(order_no, talent_id, merchant_id, store_id, order_type, pay_amount,
                                          order_status, verify_status, refund_status, collect_onboarding_id, collect_huifu_id)
                values (?, 1, ?, ?, 'STORE_CONSUME', ?, ?, 0, ?, ?, ?)
                """, "T01O-" + fixture.key() + "-" + payAmount + orderStatus + refundStatus, fixture.merchantId(),
                fixture.storeId(), payAmount, orderStatus, refundStatus, applicationId, huifuId);
    }
 
    private Fixture merchantFixture(String label) {
        String key = "T01" + label + System.nanoTime();
        jdbc.update("""
                insert into merchant(merchant_name, merchant_type, contact_name, contact_mobile, can_login_merchant_app, status, deleted)
                values (?, '餐饮服务', '联系人', '13700000001', 1, 1, 0)
                """, key);
        Long merchantId = jdbc.queryForObject("select id from merchant where merchant_name = ?", Long.class, key);
        jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", merchantId, key + "门店");
        Long storeId = jdbc.queryForObject("select id from merchant_store where merchant_id = ?", Long.class, merchantId);
        return new Fixture(key, merchantId, storeId);
    }
 
    /** 每个用例用自己的商户号,避免共享测试库上互相串。 */
    private static String uniqueHuifuId() {
        return "6666" + String.format("%014d", Math.abs(System.nanoTime() % 100_000_000_000_000L));
    }
 
    private record Fixture(String key, Long merchantId, Long storeId) { }
}