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; /** * 「结算记录」页的选择与筛选:哪些商户会被拿去问通道,以及查询条件对结果的约束。 * *

只用通道侧收款能力已核实且正常的商户去问通道:被关停({@code mer_stat=CANCEL})、 * 快照从没核实过、取现不可用的商户问过去只会被拒(实测被关闭的子商户回「客户与产品关系已关闭」), * 页面每次打开都白多一条失败提示。反过来,本地没有微信/实名进度记录、通道侧却已核实且正常的商户 * 必须照查——存量商户就长这样,它仍在真实结算;只看本地进度会把它从监控里摘掉,等于看不见钱。 * *

商家、联系电话、商家类型是本地商户字段,先筛掉不匹配的商户再去问通道;状态是通道流水的结算状态, * 只能等通道把流水给回来之后再筛。 */ @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 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> 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(); } /** * 多家商家共用同一个汇付商户号时,同一条结算流水只能出现一次。 * *

结算流水属于**商户号**(一个收款主体),不属于商家:连锁各门店是独立商家、共用同一个收款主体, * 通道里就是一条流水。按商家逐家去问通道,会把同一条流水按共用它的商家数重复印出来——页面上看起来 * 像结了 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 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())) .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 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> recordsOf(Map view) { return (List>) view.get("records"); } @SuppressWarnings("unchecked") private List failuresOf(Map view) { return (List) view.get("failures"); } }