yeg
2026-09-17 865346eeb1293be7156e98ce091d36b5a4a270c7
feat(merchant): 进件完成条件补出结算卡核实与通道商户状态

进件页会出现「标题说待办理、六项阶段却全绿」:complete() 还要求 bank(原结算银行卡核实)与
merchantNormal(通道商户状态)为真,但这两项既不在 LABELS 里、view() 也不返回,商户看不到
究竟缺什么。

- 视图新增 completionChecks(key/label/status/reason),status 由那两个布尔值派生,全是字符串。
- stages 保持六项、complete() 与申请状态写入逻辑一字不动:LABELS 同时是完成判定的键集,
且两项是布尔值而其余六项必须是 "confirmed",加进去会让进件永远无法 approved。
- stages_json 里补存 merchantStatus 原始值,异常原因才能写出「斗拱子商户状态异常(CLOSED)」。
- pending 的原因分支修正:六项全绿但两项未通过时,指向具体未通过的项,不再说「按待办理项补齐」。
1个文件已添加
1个文件已修改
205 ■■■■■ 已修改文件
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantOnboardingCompletionChecksTests.java 145 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
@@ -65,7 +65,12 @@
                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));
                    }
@@ -247,14 +252,63 @@
        // 被通道明确驳回基本资料的申请仍显示 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 去匹配它自己的申请单,不再按固定商户号过滤。
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantOnboardingCompletionChecksTests.java
New file
@@ -0,0 +1,145 @@
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));
    }
}