feat(settlement): 结算记录只查通道侧收款能力正常的商户,并支持按商家/电话/类型/状态筛选
结算记录页逐商户向斗拱查询,此前把「进件通过且拿到子商户号」的商户全都问一遍:被关停的
(mer_stat=CANCEL,实测通道回「客户与产品关系已关闭」)和从没核实过通道状态的,问过去只会被拒,
页面每次打开都白多一条失败提示。改为只查通道侧收款能力已核实且正常的商户——判定复用
MerchantPaymentReadinessService.channelCapabilityNormal,与下单闸门同一套取值,不另立一套;
本地的微信/实名进度记录不参与判定,否则通道侧正常、仍在结算的存量商户会被摘出监控。
同一个接口此前忽略页面上的查询条件(商家、联系电话、商家类型、状态四项完全无效:前端不发、
后端也不收)。商家/联系电话/商家类型是本地商户字段,进 SQL 先筛掉不匹配的商户,顺带少打几次通道;
状态是通道流水的结算状态(S/F/P),只能等通道把流水给回来之后再筛。
| | |
| | | /** |
| | | * 结算记录:向斗拱查询指定区间的真实结算流水,用于监控每天自动结算了多少到银行卡。 |
| | | * 日期格式 yyyyMMdd,默认最近 30 天;通道单页上限 50,默认值必须落在上限内。 |
| | | * 商家、联系电话、商家类型按本地商户字段先筛掉不匹配的商户(顺带少打几次通道), |
| | | * 状态是通道的流水结算状态(S 已结算 / F 失败 / P 处理中)。 |
| | | */ |
| | | @GetMapping("/merchant-settlement/settlements") public ApiResponse<Map<String,Object>> settlements(@RequestParam(required = false) String beginDate, @RequestParam(required = false) String endDate, @RequestParam(defaultValue = "50") int size) { |
| | | @GetMapping("/merchant-settlement/settlements") public ApiResponse<Map<String,Object>> settlements(@RequestParam(required = false) String beginDate, @RequestParam(required = false) String endDate, @RequestParam(defaultValue = "50") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status) { |
| | | java.time.LocalDate today = java.time.LocalDate.now(); |
| | | String end = (endDate == null || endDate.isBlank()) ? today.format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE) : endDate.trim(); |
| | | String begin = (beginDate == null || beginDate.isBlank()) ? today.minusDays(30).format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE) : beginDate.trim(); |
| | | return ApiResponse.success(settlementService.settlements(begin, end, size)); |
| | | return ApiResponse.success(settlementService.settlements(begin, end, size, merchantName, contactMobile, merchantType, status)); |
| | | } |
| | | @GetMapping("/payment-reconciliation/issues") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> reconciliationIssues(@RequestParam(defaultValue = "open") String status, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return ApiResponse.success(reconciliationService.issues(status, page, size)); } |
| | | @PatchMapping("/payment-reconciliation/issues/{id}") public ApiResponse<Map<String,Object>> closeReconciliationIssue(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(reconciliationService.close(id, body.get("status"), body.get("operator"))); } |
| | |
| | | * <p>按门店取该商户当前生效的斗拱子商户号,逐商户查询结算记录后合并成一条时间倒序的列表。 |
| | | * 通道查询失败只影响这一条商户,不会让整页失败——监控页最重要的是「看得到」。 |
| | | * |
| | | * <p>只查「通道侧收款能力已核实且正常」的商户({@link MerchantPaymentReadinessService#channelCapabilityNormal}): |
| | | * 没开通好、或在汇付被关停的商户,查过去也会被通道拒(实测被关闭的子商户回「客户与产品关系已关闭」), |
| | | * 只会让页面每次打开都多一条失败提示。判定共用收款能力那套取值,不在这里另立一套。 |
| | | * |
| | | * <p>筛选分两处,因为两类条件属于两边:商家、联系电话、商家类型是**本地商户字段**,先筛掉不匹配的 |
| | | * 商户再去问通道——既让查询生效,也顺带少打几次通道;状态是**通道流水的结算状态**({@code S}/{@code F}/{@code P}), |
| | | * 只能等通道把流水给回来之后再过滤。{@code beginDate}/{@code endDate} 是结算日期的区间(不是商户申请时间)。 |
| | | * |
| | | * <p>条数上限跟着通道走:这个接口的 {@code pageSize} 超过 50 会被判为基础参数校验失败, |
| | | * 传 100 时整页一条流水都查不出来。上限在网关里还有一道兜底,这里只是不要再主动越界。 |
| | | */ |
| | | public Map<String, Object> settlements(String beginDate, String endDate, int size) { |
| | | List<Map<String, Object>> merchants = rows(""" |
| | | select a.merchant_id, a.provider_huifu_id, m.merchant_name, s.store_name |
| | | from merchant_onboarding_application a |
| | | join merchant m on m.id = a.merchant_id |
| | | join merchant_store s on s.id = a.store_id |
| | | public Map<String, Object> settlements(String beginDate, String endDate, int size, |
| | | String merchantName, String contactMobile, String merchantType, String status) { |
| | | StringBuilder where = new StringBuilder(""" |
| | | where a.status = 'approved' and a.provider_huifu_id is not null and a.provider_huifu_id <> '' |
| | | and a.id = (select max(newest.id) from merchant_onboarding_application newest |
| | | where newest.merchant_id = a.merchant_id) |
| | | order by a.merchant_id asc |
| | | """); |
| | | List<Object> args = new ArrayList<>(); |
| | | addLike(where, args, "m.merchant_name", merchantName); |
| | | addLike(where, args, "m.contact_mobile", contactMobile); |
| | | addEqual(where, args, "m.merchant_type", merchantType); |
| | | List<Map<String, Object>> candidates = rows(""" |
| | | select a.id as application_id, a.merchant_id, a.provider_huifu_id, m.merchant_name, s.store_name |
| | | from merchant_onboarding_application a |
| | | 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<>(); |
| | | for (Map<String, Object> candidate : candidates) { |
| | | if (readinessService.channelCapabilityNormal(((Number) candidate.get("applicationId")).longValue())) { |
| | | merchants.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); |
| | |
| | | failures.add(String.valueOf(merchant.get("merchantName")) + ":" + response.message()); |
| | | continue; |
| | | } |
| | | records.addAll(decode(response.rawRecords(), merchant)); |
| | | for (Map<String, Object> record : decode(response.rawRecords(), merchant)) { |
| | | if (matchesStatus(record, status)) records.add(record); |
| | | } |
| | | } catch (RuntimeException exception) { |
| | | failures.add(String.valueOf(merchant.get("merchantName")) + ":查询失败"); |
| | | } |
| | |
| | | return view; |
| | | } |
| | | |
| | | /** 结算状态筛选:值是通道自己的流水状态({@code S} 已结算 / {@code F} 失败 / {@code P} 处理中),空则全要。 */ |
| | | private boolean matchesStatus(Map<String, Object> record, String status) { |
| | | if (status == null || status.isBlank()) return true; |
| | | return status.trim().equalsIgnoreCase(String.valueOf(record.get("status"))); |
| | | } |
| | | |
| | | /** 把通道返回的结算记录 JSON 转成后台要展示的字段;只读展示,不做金额换算以外的加工。 */ |
| | | private List<Map<String, Object>> decode(String rawRecords, Map<String, Object> merchant) { |
| | | List<Map<String, Object>> decoded = new ArrayList<>(); |
| | |
| | | } |
| | | |
| | | /** |
| | | * 通道侧收款能力是否已核实且正常:快照核实过、子商户状态正常、取现可用。 |
| | | * |
| | | * <p>它只回答「通道那边这家商户开通正常吗」,不含 {@link #readiness(Long, Long)} 额外要求的 |
| | | * 微信/实名与绑卡前置(那一条才回答「现在能不能收款」)。只读的监控页面用它决定「该不该去问 |
| | | * 通道」:通道侧没开通或被关停的商户问过去也会被拒(实测被关闭的子商户回「客户与产品关系已关闭」), |
| | | * 而本地缺进度记录、通道侧却正常且在结算的存量商户不该因此被摘出监控。 |
| | | */ |
| | | @Transactional(readOnly = true) |
| | | public boolean channelCapabilityNormal(Long applicationId) { |
| | | List<Map<String, Object>> rows = rows("select * from merchant_onboarding_application where id = ? limit 1", applicationId); |
| | | if (rows.isEmpty()) return false; |
| | | Application application = application(rows.get(0)); |
| | | if (!"approved".equals(application.status()) || application.huifuId().isBlank()) return false; |
| | | // 快照没核实过就不算正常:那意味着「通道侧到底是什么状态」还没有答案。 |
| | | if (clean(application.capabilityVerifiedAt()).isBlank()) return false; |
| | | return isMerchantNormal(application.merchantStatus()) && isCashAvailable(application.cashSwitchStatus()); |
| | | } |
| | | |
| | | /** |
| | | * Returns the collecting sub-merchant an order must be bound to, or throws with the first unmet |
| | | * precondition. Callers persist the returned application, store and Huifu id on the order so the |
| | | * payment request cannot drift to another sub-merchant later. |
| New file |
| | |
| | | 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.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(); |
| | | } |
| | | |
| | | /** 关闭的子商户回的是通道原话;它只在真的被查到时才会成为一条失败提示。 */ |
| | | 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) { |
| | | 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, |
| | | 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"); |
| | | } |
| | | } |