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");
|
}
|
}
|