feat(merchant): 进件完成条件补出结算卡核实与通道商户状态
进件页会出现「标题说待办理、六项阶段却全绿」:complete() 还要求 bank(原结算银行卡核实)与
merchantNormal(通道商户状态)为真,但这两项既不在 LABELS 里、view() 也不返回,商户看不到
究竟缺什么。
- 视图新增 completionChecks(key/label/status/reason),status 由那两个布尔值派生,全是字符串。
- stages 保持六项、complete() 与申请状态写入逻辑一字不动:LABELS 同时是完成判定的键集,
且两项是布尔值而其余六项必须是 "confirmed",加进去会让进件永远无法 approved。
- stages_json 里补存 merchantStatus 原始值,异常原因才能写出「斗拱子商户状态异常(CLOSED)」。
- pending 的原因分支修正:六项全绿但两项未通过时,指向具体未通过的项,不再说「按待办理项补齐」。
| | |
| | | jdbc.update("update merchant_onboarding_progress set stages_json=?,last_synced_at=now(),sync_error='' where application_id=? and sync_owner=?",JSON.toJSONString(stages),applicationId,owner); |
| | | Map<String,Object> capabilities=gateway.queryCapabilities(text(application.get("providerHuifuId"))); |
| | | if (capabilities != null && "00000000".equals(text(capabilities.get("resp_code")))) { |
| | | if (capabilities.containsKey("mer_stat")) stages.put("merchantNormal", "NORMAL".equals(text(capabilities.get("mer_stat")))); |
| | | if (capabilities.containsKey("mer_stat")) { |
| | | stages.put("merchantNormal", "NORMAL".equals(text(capabilities.get("mer_stat")))); |
| | | // 原始值一起存下来,只是为了在完成判定里说清「状态异常(CLOSED)」这种话。 |
| | | // 它不属于六项阶段,不会进 stages 列表。 |
| | | stages.put("merchantStatus", text(capabilities.get("mer_stat"))); |
| | | } |
| | | if ("S".equals(text(response.get("bind_card_status"))) && capabilities.containsKey("qry_cash_card_info_list") && capabilities.containsKey("qry_settle_config_list")) { |
| | | stages.put("bank", confirmOriginalCard(application,capabilities,owner)); |
| | | } |
| | |
| | | // 被通道明确驳回基本资料的申请仍显示 rejected(并带通道给的驳回原因),不能被阶段计算 |
| | | // 盖成 pending——那样商户端看不到「重新提交」。 |
| | | boolean rejected = "rejected".equals(text(application.get("status"))); |
| | | boolean completed = complete(stages); |
| | | List<Map<String,Object>> checks = completionChecks(stages, application); |
| | | result.put("stages",progress); result.put("reason","请刷新进度,核实原申请与各项能力"); |
| | | result.put("completionChecks",checks); |
| | | result.put("retryable", "failed".equals(stages.get("delay"))); |
| | | result.put("status",rejected ? "rejected" : complete(stages) ? "approved" : "pending"); |
| | | result.put("status",rejected ? "rejected" : completed ? "approved" : "pending"); |
| | | result.put("reason",rejected ? text(application.get("rejectReason")) |
| | | : complete(stages) ? "收款能力已核实,沿用原商户号和结算银行卡" : "请刷新进度核实各项能力,并按待办理项补齐缺失能力"); |
| | | : completed ? "收款能力已核实,沿用原商户号和结算银行卡" : incompleteReason(progress, checks)); |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 完成判定里不在六项阶段中的另外两项核实结果。 |
| | | * |
| | | * <p>{@code complete()} 还要求结算银行卡核实({@code bank})与通道商户状态 |
| | | * ({@code merchantNormal}),但这两项原先既不进 {@code LABELS}、{@code view()} 也不返回: |
| | | * 一旦某次刷新发现通道把商户停用了,申请会掉回 {@code pending},页面于是出现 |
| | | * 「标题说待办理、六项却全绿」——商户看不到究竟缺什么,只会以为页面出错。 |
| | | * |
| | | * <p>它们**不能**加进 {@code LABELS}:那里同时是完成判定的键集,而且这两项是布尔值、 |
| | | * 其余六项必须是字符串 {@code "confirmed"},加进去会让进件永远无法 approved。 |
| | | */ |
| | | private static List<Map<String,Object>> completionChecks(Map<String,Object> stages, Map<String,Object> application) { |
| | | String merchantStatus = text(stages.get("merchantStatus")); |
| | | if (merchantStatus.isBlank()) merchantStatus = text(application.get("merchantStat")); |
| | | List<Map<String,Object>> checks=new ArrayList<>(); |
| | | checks.add(completionCheck("bank","结算银行卡核实",stages.get("bank"), |
| | | "结算银行卡尚未核实:通道没有这张原卡的可用记录,请确认结算卡后刷新")); |
| | | checks.add(completionCheck("merchantNormal","通道商户状态",stages.get("merchantNormal"), |
| | | merchantStatus.isBlank() |
| | | ? "斗拱子商户状态异常,请联系汇付或后台处理" |
| | | : "斗拱子商户状态异常(" + merchantStatus + "),请联系汇付或后台处理")); |
| | | return checks; |
| | | } |
| | | |
| | | private static Map<String,Object> completionCheck(String key, String label, Object value, String failedReason) { |
| | | String status="unknown", reason="尚未核实,请刷新进度"; |
| | | if (value instanceof Boolean confirmed) { |
| | | status = confirmed ? "confirmed" : "failed"; |
| | | reason = confirmed ? "已核实通过" : failedReason; |
| | | } |
| | | return Map.of("key",key,"label",label,"status",status,"reason",reason); |
| | | } |
| | | |
| | | /** |
| | | * pending 的原因。六项阶段全绿却还没完成时,必须点出另外两项完成条件——否则那句话会变成 |
| | | * 「请按待办理项补齐缺失能力」,而页面上一个待办理项都没有。 |
| | | */ |
| | | private static String incompleteReason(List<Map<String,Object>> progress, List<Map<String,Object>> checks) { |
| | | boolean stagesAllConfirmed = progress.stream().allMatch(stage -> "confirmed".equals(stage.get("status"))); |
| | | if (!stagesAllConfirmed) return "请刷新进度核实各项能力,并按待办理项补齐缺失能力"; |
| | | String failed = checks.stream().filter(check -> !"confirmed".equals(check.get("status"))) |
| | | .map(check -> String.valueOf(check.get("label"))).collect(java.util.stream.Collectors.joining("、")); |
| | | return failed.isBlank() ? "请刷新进度核实各项能力,并按待办理项补齐缺失能力" |
| | | : "进件阶段已全部确认,但" + failed + "未通过核实,见下方核实结果"; |
| | | } |
| | | |
| | | /** Called only after the existing HTTP callback boundary has verified Huifu's signature. */ |
| | | public Long acceptDelayCallback(Map<String,Object> data) { |
| | | // 用回调自带的 huifu_id 去匹配它自己的申请单,不再按固定商户号过滤。 |
| New file |
| | |
| | | package com.talentcard.server.business.merchant; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | 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; |
| | | |
| | | /** |
| | | * 进件完成判定里不在六项阶段中的另外两项:结算银行卡核实与通道商户状态。 |
| | | * |
| | | * <p>它们原先既不进 {@code LABELS} 也不出现在响应里,商户看到的是「标题说待办理、六项却全绿」。 |
| | | * 这里锁定两件事:两项现在能说清是哪一项没通过、为什么;以及 {@code stages} 仍然是六项 |
| | | * (把它们塞进 {@code LABELS} 会连完成判定一起改坏)。 |
| | | */ |
| | | @SpringBootTest |
| | | @ActiveProfiles("test") |
| | | class MerchantOnboardingCompletionChecksTests { |
| | | |
| | | /** 六项阶段全部拿到通道证据。 */ |
| | | private static final String SIX_CONFIRMED = "{\"basic\":\"confirmed\",\"wechat\":\"confirmed\",\"appId\":\"confirmed\"," |
| | | + "\"realname\":\"confirmed\",\"delay\":\"confirmed\",\"settlement\":\"confirmed\"}"; |
| | | |
| | | @Autowired |
| | | private JdbcTemplate jdbc; |
| | | |
| | | @Autowired |
| | | private MerchantOnboardingProgressService progressService; |
| | | |
| | | private Long applicationId; |
| | | |
| | | @BeforeEach |
| | | void prepareApplication() { |
| | | String suffix = String.valueOf(System.nanoTime()); |
| | | jdbc.update("insert into merchant(merchant_name, status, deleted) values (?, 1, 0)", "Checks merchant " + suffix); |
| | | 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, "Checks store " + suffix); |
| | | 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) |
| | | values (?, ?, ?, 'individual', 'Checks owner', 'Checks owner', '13800000000', ?, 'pending', ?) |
| | | """, merchantId, storeId, "MO-CHK-" + suffix, "checks-onboarding-" + suffix, shortReference("H", suffix)); |
| | | applicationId = jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class); |
| | | jdbc.update(""" |
| | | insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail, card_masked, |
| | | card_hash, status, idempotency_key) |
| | | values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '6666', '**** **** **** 6666', ?, 'pending', ?) |
| | | """, merchantId, storeId, "hash-checks-" + suffix, "checks-card-" + suffix); |
| | | Long bankCardId = jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class); |
| | | jdbc.update("update merchant_onboarding_application set initial_bank_card_id=? where id=?", bankCardId, applicationId); |
| | | } |
| | | |
| | | /** |
| | | * 通道把商户停用后:六项阶段仍然全绿,但完成判定不成立。页面必须说清是通道商户状态异常, |
| | | * 而不是让商户对着「待办理」找不到任何待办理项。 |
| | | */ |
| | | @Test |
| | | void explainsTheAbnormalChannelMerchantWhileEveryStageStaysConfirmed() { |
| | | stages(SIX_CONFIRMED.replace("}", ",\"bank\":true,\"merchantNormal\":false,\"merchantStatus\":\"CLOSED\"}")); |
| | | |
| | | Map<String, Object> view = progressService.view(applicationId); |
| | | |
| | | assertThat(view).containsEntry("status", "pending"); |
| | | assertThat(check(view, "merchantNormal")).containsEntry("status", "failed"); |
| | | assertThat(String.valueOf(check(view, "merchantNormal").get("reason"))).contains("CLOSED").contains("联系汇付"); |
| | | assertThat(String.valueOf(view.get("reason"))).contains("通道商户状态").doesNotContain("按待办理项补齐缺失能力"); |
| | | } |
| | | |
| | | /** 结算银行卡没核实同样会让进件停在 pending,之前也看不见。 */ |
| | | @Test |
| | | void explainsTheUnverifiedSettlementCard() { |
| | | stages(SIX_CONFIRMED.replace("}", ",\"bank\":false,\"merchantNormal\":true,\"merchantStatus\":\"NORMAL\"}")); |
| | | |
| | | Map<String, Object> view = progressService.view(applicationId); |
| | | |
| | | assertThat(view).containsEntry("status", "pending"); |
| | | assertThat(check(view, "bank")).containsEntry("status", "failed"); |
| | | assertThat(String.valueOf(check(view, "bank").get("reason"))).contains("结算银行卡"); |
| | | assertThat(String.valueOf(view.get("reason"))).contains("结算银行卡核实"); |
| | | } |
| | | |
| | | /** 两项都核实通过、六项阶段也齐了,才是完成态。 */ |
| | | @Test |
| | | void completesOnlyAfterBothChecksAndEveryStageAreConfirmed() { |
| | | stages(SIX_CONFIRMED.replace("}", ",\"bank\":true,\"merchantNormal\":true,\"merchantStatus\":\"NORMAL\"}")); |
| | | |
| | | Map<String, Object> view = progressService.view(applicationId); |
| | | |
| | | assertThat(view).containsEntry("status", "approved"); |
| | | assertThat(check(view, "bank")).containsEntry("status", "confirmed"); |
| | | assertThat(check(view, "merchantNormal")).containsEntry("status", "confirmed"); |
| | | assertThat(String.valueOf(view.get("reason"))).contains("结算银行卡"); |
| | | } |
| | | |
| | | /** 阶段本身还没走完时,仍然是原来那句「按待办理项补齐」;两次核实还没做过就报未核实。 */ |
| | | @Test |
| | | void keepsTheStageBasedReasonWhileStagesAreIncomplete() { |
| | | stages("{\"basic\":\"confirmed\"}"); |
| | | |
| | | Map<String, Object> view = progressService.view(applicationId); |
| | | |
| | | assertThat(view).containsEntry("status", "pending"); |
| | | assertThat(String.valueOf(view.get("reason"))).isEqualTo("请刷新进度核实各项能力,并按待办理项补齐缺失能力"); |
| | | assertThat(check(view, "bank")).containsEntry("status", "unknown"); |
| | | assertThat(check(view, "merchantNormal")).containsEntry("status", "unknown"); |
| | | } |
| | | |
| | | /** 两项完成条件不能混进阶段列表:那是完成判定的键集,混进去进件永远无法 approved。 */ |
| | | @SuppressWarnings("unchecked") |
| | | @Test |
| | | void keepsTheStageListAtSixEntries() { |
| | | stages(SIX_CONFIRMED.replace("}", ",\"bank\":true,\"merchantNormal\":true,\"merchantStatus\":\"NORMAL\"}")); |
| | | |
| | | List<Map<String, Object>> stages = (List<Map<String, Object>>) progressService.view(applicationId).get("stages"); |
| | | |
| | | assertThat(stages).hasSize(6); |
| | | assertThat(stages.stream().map(stage -> String.valueOf(stage.get("key"))).toList()) |
| | | .containsExactly("basic", "wechat", "appId", "realname", "delay", "settlement"); |
| | | } |
| | | |
| | | private void stages(String json) { |
| | | jdbc.update(""" |
| | | insert into merchant_onboarding_progress(application_id, stages_json) values (?, ?) |
| | | on duplicate key update stages_json = values(stages_json) |
| | | """, applicationId, json); |
| | | } |
| | | |
| | | @SuppressWarnings("unchecked") |
| | | private static Map<String, Object> check(Map<String, Object> view, String key) { |
| | | return ((List<Map<String, Object>>) view.get("completionChecks")).stream() |
| | | .filter(check -> key.equals(check.get("key"))).findFirst().orElseThrow(); |
| | | } |
| | | |
| | | /** Provider identifiers are limited to 32 characters and huifu ids to 18, so tests keep them short. */ |
| | | private String shortReference(String prefix, String suffix) { |
| | | return prefix + suffix.substring(Math.max(0, suffix.length() - 8)); |
| | | } |
| | | } |