fix(merchant): 行业以系统档案为准,商户在进件表单选的行业只作兜底

业务口径(2026-09-16):行业改不了,只有我们系统动态地去匹配行业。据此复核发现 MCC 的取值
优先级反了——此前是「商户在个人进件表单第一步选的行业类型」优先于商家档案,也就是一次表单
选择能盖掉我们自己维护的经营类型。

改为系统档案优先:
- MerchantSettlementService.mcc 先取商家档案(merchant_type_code / merchant_type)算 MCC;
档案给不出**具体**行业(未配、或配成「其它」未识别 → 兜底的 7399)时,才用进件表单上申报的
行业类型兜底,避免档案缺数据时一律落「其它」。
- 优先级规则抽成可静态断言的 preferConfiguredMcc,与 mccForIndustry 一样便于直接钉住;
兜底值提成常量 FALLBACK_MCC,三处取值不再散落字面量 "7399"。

对已联调的商户无影响:真实商户 6666000241804122 的档案经营类型是 food,两种优先级下都是 5812。

已进件商户不提供补改行业的入口——本次只改取值来源,不加任何修改入口,与上述口径一致。

测试:MerchantOnboardingIndustryMccTests 由 3 项增至 5 项,新增「档案能给出具体行业时以档案为准,
哪怕表单选的是别的行业」与「档案落在兜底值时才用申报行业,两边都给不出时保持兜底值」。
全量 245 项,仅 docs/agents/testing.md 所列 3 项基线失败,无新增。

文档:audit.md 补记这批的两条口径(行业由系统动态匹配、重试入口暂不做),并把「仍需官方确认」
收敛到唯一一条未被答复的项(业务开通的 micro_biz_type 是否与独立实名接口重复);先前记的
「452 申报行业与实际不一致」这条顾虑随新口径消解——取值以系统档案为准,申报值本来只是兜底。
3个文件已修改
91 ■■■■■ 已修改文件
.scratch/merchant-individual-onboarding/audit.md 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantOnboardingIndustryMccTests.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/merchant-individual-onboarding/audit.md
@@ -209,21 +209,38 @@
| 4 | `store_address_code` 传 6 位地市级行政区划编码(省 2 位 + 市 4 位) | **无需改动**:本来就取商户端"经营地区"选择器返回的 `registered_city_code`,形如 `510100`,正是省 2 位 + 市 4 位 |
| 5 | 三种小微经营类型的 `store_header_pic`(F22)在进件时**都必填**,只是图片内容不同:线上=店铺首页截图、流动经营/便民服务=经营现场照、门店场所=实体门头照 | ①个人进件请求补上 `store_header_pic`(原先只有业务开通的 `extended_material_list`,进件侧完全没传);②商户端该字段改名「店铺照片」,提示按三种情况分别说明;③业务开通侧继续带 F22/F24 到 `extended_material_list` |
### 2026-09-16 追加两条口径(第二批复核时确认)
| # | 口径 | 落地情况 |
| --- | --- | --- |
| 6 | **行业由系统动态匹配,商户改不了**(已进件商户也不提供补改入口) | 改的是 MCC 的**取值优先级**:此前是"商户在进件表单选的行业类型"优先于商家档案,与这条口径相反。改为**系统档案优先**——经营类型是我们自己维护的、也是匹配微信行业类目与费率的依据,不该被表单上的一次选择盖掉;商户申报的行业类型只在档案给不出具体行业(未配或配成「其它」)时兜底,避免档案缺数据时一律落 7399。见 `MerchantSettlementService.preferConfiguredMcc` 与 `MerchantOnboardingIndustryMccTests` |
| 7 | 「只认延迟失败的重试入口」这件事**暂时不做** | 不改代码。现状:`retryable` 仍等于「延迟入账失败」,业务开通/微信配置失败时两端没有专门的重试按钮,靠「刷新进度」隐式重试(刷新本身会重发失败的步骤) |
第 5 条是这一轮里唯一**真实漏发**的字段:`store_header_pic` 在进件的官方说明里是必填,
而项目此前一条都没传过。三处消费同一张图的路径现在都齐了——进件 `store_header_pic`、
业务开通 `extended_material_list` 的 F22、实名 `store_header_copy`。
原有测试全部随之复跑:新增 `HuifuIndividualOnboardingRequiredFieldsTests` 5 项钉住上面第 2/3/5 条。
原有测试全部随之复跑;第 2/3/5 条由 `HuifuIndividualOnboardingRequiredFieldsTests` 5 项钉住,
第 6 条由 `MerchantOnboardingIndustryMccTests` 5 项钉住。
---
## 七、仍需官方确认
只剩一条,且不阻断开通:
**只剩这一条**(其余此前列过的都已由业务答复并落地,见第六节的两张表):
- 业务开通的 `wx_real_name_info.micro_biz_type` 与独立实名接口的 `micro_biz_type` 是否都需要:
  本项目 `is_check_real_name=N`(不在业务开通里发起实名),却仍在 `wx_conf_list` 里带 `micro_biz_type`。
  两处都不缺材料,保留即可;若确认业务开通侧不需要,去掉的是冗余而不是能力。
> 这一条**未被业务答复过**,只是不阻断开通,所以留在这里。
### 已定的两条(原「剩余待讨论」,2026-09-16 关闭)
- ~~两端的「重试」入口只认延迟失败~~ → **暂时不做**(见第六节第 7 条)。
- ~~已进件商户能否补改经营行业~~ → **不提供补改入口,行业由系统动态匹配**(见第六节第 6 条)。
  先前记的「452 进件时选的「其它」与实际行业不一致」这条顾虑也随之消解:按新口径,取值以系统档案为准,
  商户表单上的申报值只是档案缺数据时的兜底,本来就不该作为口径依据。
---
@@ -491,10 +508,7 @@
### 剩余待讨论
- **两端的「重试」入口只认延迟失败**(`retryable = delay == 'failed'`),"业务开通失败"或
  "微信配置失败"没有对应的重试动作,只能靠「刷新进度」隐式重试——商户看到失败原因却没有可点的操作。
- **已进件商户能否补改经营行业**:452 进件时选的是「其它」(MCC 退回商家档案后是 `5812` 餐饮,
  值是对的,但商户申报的行业与实际不一致)。要补救这类数据得先定这条路径。
(无。原两条已于 2026-09-16 关闭:重试入口暂不做,行业不提供补改入口、由系统动态匹配,见第六节。)
---
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -37,6 +37,8 @@
@Service
public class MerchantSettlementService {
    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(MerchantSettlementService.class);
    /** 商家档案给不出可识别经营类型时的兜底 MCC(「其它」)。 */
    private static final String FALLBACK_MCC = "7399";
    private static final Map<String, String> MCC_BY_MERCHANT_TYPE = Map.ofEntries(
            Map.entry("food", "5812"), Map.entry("scenic", "7991"), Map.entry("hotel", "7011"), Map.entry("apartment", "7011"),
            Map.entry("cinema", "7832"), Map.entry("fuel", "5541"), Map.entry("parking", "7523"), Map.entry("carWash", "7542"),
@@ -876,20 +878,30 @@
    /**
     * 进件主体所属行业的 MCC。
     *
     * <p>个人(小微)主体在进件表单第一步单独选过「行业类型」(餐饮服务/酒店服务/景区服务…),
     * 它跟着企业主体的公司类型一起落在 {@code company_type} 列上。原先个人进件的 MCC 只认商家档案上的
     * 经营类型,等于把商户自己在进件时选的行业**静默丢掉**:档案没配好就一律落 7399(其它),
     * 通道侧的微信行业类目和费率跟着一起错。所以个人主体优先用进件时选的行业,
     * 行业识别不出来(选了「其它」)再退回商家档案。
     * <p><b>行业以系统里的商家档案为准</b>(2026-09-16 业务口径:「行业改不了,只有我们系统动态地去匹配
     * 行业」)。商家档案上的经营类型是我们自己维护的,也是匹配微信行业类目与费率的依据,
     * 不该被进件表单上的一次选择盖掉;已进件商户也不提供补改行业的入口。
     *
     * <p>商户在个人进件表单第一步选的「行业类型」只在**档案给不出具体行业时**兜底
     * (档案未配、或配成「其它」未识别 → {@link #FALLBACK_MCC}),避免档案缺数据时一律落「其它」。
     *
     * <p>{@code mcc} 是进件的必填项;业务开通只在没有 {@code fee_rule_id} 时才用它兜底。
     */
    private String mcc(Long merchantId, String subjectType, String industryType) {
        if ("individual".equalsIgnoreCase(clean(subjectType))) {
            String industry = mccForIndustry(industryType);
            if (!industry.isBlank()) return industry;
        String configured = mcc(merchantId);
        // 个体工商户走企业进件接口,表单上没有「行业类型」这一项,只有个人主体才有申报值。
        if (!"individual".equalsIgnoreCase(clean(subjectType))) return configured;
        return preferConfiguredMcc(configured, industryType);
        }
        return mcc(merchantId);
    /**
     * 系统档案优先、商户申报兜底。取值为静态形式,便于直接断言这条规则本身
     * (见 {@code MerchantOnboardingIndustryMccTests})。
     */
    static String preferConfiguredMcc(String configuredMcc, String declaredIndustry) {
        if (!FALLBACK_MCC.equals(cleanStatus(configuredMcc))) return configuredMcc == null ? "" : configuredMcc.trim();
        String declared = mccForIndustry(declaredIndustry);
        return declared.isBlank() ? FALLBACK_MCC : declared;
    }
    /**
@@ -898,7 +910,7 @@
     */
    static String mccForIndustry(String industryType) {
        String industry = MerchantTypeCatalog.canonicalCode(cleanStatus(industryType));
        return "other".equals(industry) ? "" : MCC_BY_MERCHANT_TYPE.getOrDefault(industry, "7399");
        return "other".equals(industry) ? "" : MCC_BY_MERCHANT_TYPE.getOrDefault(industry, FALLBACK_MCC);
    }
    private String mcc(Long merchantId) {
@@ -906,7 +918,7 @@
        if (merchants.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商家不存在");
        String code = clean(merchants.get(0).get("merchantTypeCode"));
        if (code.isBlank()) code = MerchantTypeCatalog.canonicalCode(clean(merchants.get(0).get("merchantType")));
        return MCC_BY_MERCHANT_TYPE.getOrDefault(code, "7399");
        return MCC_BY_MERCHANT_TYPE.getOrDefault(code, FALLBACK_MCC);
    }
    private String normalizeRegisteredCapital(String companyType, String value) {
        String text = clean(value); boolean required = List.of("国营企业", "私营企业", "外资企业", "其它组织", "事业单位").contains(clean(companyType));
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantOnboardingIndustryMccTests.java
@@ -5,18 +5,35 @@
import org.junit.jupiter.api.Test;
/**
 * 个人(小微)主体的 MCC 取的是**商户在进件表单里选的行业**。
 * 个人(小微)主体上报给汇付的 MCC 怎么取。
 *
 * <p>进件第一步让个人商户选「行业类型」(餐饮服务/酒店服务/景区服务/加油服务/停车服务/其它),
 * 它跟着企业主体的公司类型一起落在 {@code company_type} 列上。原先上报汇付的 {@code mcc}
 * 只认商家档案上的经营类型,等于把这一项**静默丢掉**:档案没配好就一律落 7399(其它),
 * 通道侧的微信行业类目和费率跟着一起错,而商户页面上明明选过行业。
 * <p>业务口径(2026-09-16):**行业由系统动态匹配,商户改不了**。所以系统里的商家档案优先——
 * 经营类型是我们自己维护的,也是匹配微信行业类目与费率的依据,不该被进件表单上的一次选择盖掉;
 * 已进件商户也不提供补改行业的入口。商户在进件表单第一步选的「行业类型」只在档案给不出具体
 * 行业时兜底(未配、或配成「其它」未识别),避免档案缺数据时一律落「其它」。
 *
 * <p>「其它」与识别不出来的取值返回空串,交给商家档案兜底——不要在这里拍一个 7399,
 * 那会把「档案里配了准确行业」的商户也降级成「其它」。
 * <p>「其它」与识别不出来的取值返回空串(见 {@link #anUnrecognisedIndustryFallsBackToTheMerchantProfile}),
 * 正是这个兜底生效的触发条件。
 */
class MerchantOnboardingIndustryMccTests {
    /** 系统档案能给出具体行业时,以档案为准——哪怕进件表单上选的是别的行业。 */
    @Test
    void theMerchantProfileWinsOverWhatWasPickedOnTheForm() {
        assertThat(MerchantSettlementService.preferConfiguredMcc("7011", "餐饮服务")).isEqualTo("7011");
        assertThat(MerchantSettlementService.preferConfiguredMcc("5812", "其它")).isEqualTo("5812");
    }
    /** 档案给不出具体行业(落兜底的「其它」)时,才用进件表单上申报的行业。 */
    @Test
    void theDeclaredIndustryIsOnlyAFallbackForAMissingProfileIndustry() {
        assertThat(MerchantSettlementService.preferConfiguredMcc("7399", "餐饮服务")).isEqualTo("5812");
        assertThat(MerchantSettlementService.preferConfiguredMcc("7399", "停车服务")).isEqualTo("7523");
        // 两边都给不出具体行业时保持兜底值,不会变成空串。
        assertThat(MerchantSettlementService.preferConfiguredMcc("7399", "其它")).isEqualTo("7399");
        assertThat(MerchantSettlementService.preferConfiguredMcc("7399", "")).isEqualTo("7399");
    }
    @Test
    void theIndustryPickedOnTheFormBecomesTheMcc() {
        assertThat(MerchantSettlementService.mccForIndustry("餐饮服务")).isEqualTo("5812");