Ver Fonte

feat(进件): 三-B 必填矩阵与七类商户组合(Task 2)

把上游两张表变成可执行判据:§0.2 的「27 个必填」与 §0.3 的「7 类商户附表」。
新增三个类 + 一个测试类,零依赖、零网络。

LsyMerchantCategory —— 七个常量,各自携带 (merchant_type, account_type) + 中文标签。
  键取「七个组合」而不是「三个 merchant_type」:附表自己就有两对同 merchant_type
  而要求不同的列(picture_unincorporated 只在非法人那两列打 √),三值作键撞不开。
  account_type 取值 1|2|3|1|2|3|4 —— 附表那一行写成 1|2|2|1|2|2|2,七列塌成五个键;
  参数表第 49 行说明栏是 4 个值、且与附表七个列名逐字同名 ⇒ 以参数表为准,附表记为缺陷。
  of(String) 照同包 LsyReviewStatus 先例(精确匹配、匹配不到返回 null、不抛),
  且两种写法都认(常量名与中文列名):只认一种的话,另一种会变成「匹配不到 ⇒ null」,
  而它的下游是一条必填提示 —— 报「商户类型不能为空」却明明填了,是最难查的那种。

LsyMerchantRequiredFields —— 纯静态、表驱动(不用 if/else 长链,附表才能逐格核对)。
  UNCONDITIONAL = §0.2 那 18 个;BY_CATEGORY 按附表 √ 逐格填 12/14/17/9/11/17/8,
  刻意不含 UNCONDITIONAL 里那 18 个(两表不重叠,每个字段一个出处,实测交集 0)。
  missingRequiredFields 返回两者并集(实测 36 个字段,VALUES 登记 36 + 2 个派生)。
  ★ 本类判的不止那 18 个 —— license_*/legal_*/picture_* 一个都不在 §0.2 的 27 个里,
  它们只出现在附表,进 BY_CATEGORY。四层 27 = 信封4 + 配置3 + 类别派生2 + DTO 18
  写进类 javadoc 并有一条用例钉着。
  LABELS 覆盖创建页全部 62 个参数,标签逐字取「说明」列(三处用途共用一份)。
  category 为 null 时返回 UNCONDITIONAL ∪ {merchant_type, account_type}:
  HTTP 上够得着「没有类别」的报文,不能让它 NPE 成 500。

LsyMerchantCreateDTO —— 55 个字段 = category(枚举)+ 54 个驼峰 String
  (实测 54 = 进矩阵的 36 + 只带不判的 18,互补无缺)。
  上游下划线名由组装层常量映射承担,照本包既有写法。
  §0.3c/§0.3d 那些不收的字段(reg_capital / fSettle_account_type / actual_addr /
  business_scope / check_code / *_stt / share_* / p_cashier / 四个自由 picture_*)
  照常进 DTO、照常上送 —— 不收 ≠ 放行。

变异实测(两向都做,读数与字节级还原证据见 task-2-report.md):
  七类码值 MICRO(3,4)→(3,2)                    ⇒ 2 红
  附表逐格:对私法人多要 picture_unincorporated   ⇒ 3 红
  小微不该要 license_type                       ⇒ 2 红
  UNCONDITIONAL 少一个 account_bank_code         ⇒ 2 红
  LABELS 少一个键                               ⇒ 3 红
  取值器 picture_idcard_a 接到 getPictureIdcardB  ⇒ 1 红(★ 首轮实测是 0 红,见下)
  无害对照:改一个没被点名的标签                  ⇒ 0 红(刻意钝感,证明钉子不过敏)
★ 第 6 条首轮跑是 0 红(21 个用例全绿)—— 暴露了真缺口:「全空」与「全填」两种夹具下,
  每个取值器读到的值都一样,接错字段看不出来。补一条逐字段钉子
  (missingRequiredFields_reportsExactlyTheBlankedField_forEachMatrixField:
  每次只留一个字段为空、其余填满,逐类别断言缺失清单恰是被留空的那个),
  再测即 1 红,且红的就是这条新用例。用例数 21 → 22。

测试:全量 504 → 526(本类 22,新增),0 fail / 0 error / 0 skipped,BUILD SUCCESS。
alphaH há 3 semanas atrás
pai
commit
3e1bc3b907

+ 308 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyMerchantCreateDTO.java

@@ -0,0 +1,308 @@
+package com.payment.platform.module.payment.leshouying.dto;
+
+import com.payment.platform.module.payment.leshouying.enums.LsyMerchantCategory;
+import lombok.Data;
+
+/**
+ * 创建商户入参 —— 上游「创建商户」接口(page 196236555,路径 {@code /api/merchant/create})。
+ *
+ * <p><b>★ 命名裁定:Java 侧一律驼峰,上游下划线键名由组装层的常量映射承担</b>(照三-A
+ * {@code LsyOnboardingService} 里 {@code KEY_MANAGER_NAME} 那一族常量的既有写法)。两条理由:
+ * ① 本模块既有代码是「驼峰字段 + 常量映射」,下划线字段名会成为这个包里唯一的例外;
+ * ② 上游键名里有两处<b>大小写敏感</b>(本页的 {@code fSettle_account_type},Task 4 两条腿的
+ * {@code store_Loca}/{@code mcht_Landuse}),把它们写成 Java 字段名迟早被人「顺手规整」成
+ * {@code fSettleAccountType} 之外的形状 —— 而键名进<b>签名原文</b>,改一个字母就换签名。
+ *
+ * <p><b>字段名 = 上游键名的机械驼峰</b>(按下划线切段、首段原样、后续段首字母大写),
+ * 逐条列出来免得下次去猜:
+ * <pre>
+ *   merchantName                       → merchant_name
+ *   merchantNameShort                  → merchant_name_short
+ *   merchantNameBusiness               → merchant_name_business
+ *   merchantMccCode                    → merchant_mcc_code
+ *   contactName                        → contact_name
+ *   contactPhone                       → contact_phone
+ *   contactEmail                       → contact_email
+ *   contactService                     → contact_service
+ *   addressProvince                    → address_province
+ *   addressCity                        → address_city
+ *   addressCounty                      → address_county
+ *   addressStreet                      → address_street
+ *   licenseType                        → license_type
+ *   licenseNo                          → license_no
+ *   licenseStt                         → license_stt
+ *   licenseExpire                      → license_expire
+ *   legalName                          → legal_name
+ *   legalCertNo                        → legal_cert_no
+ *   legalCertStt                       → legal_cert_stt
+ *   legalCertExpire                    → legal_cert_expire
+ *   accountIdcardNum                   → account_idcard_num
+ *   accountIdcardStt                   → account_idcard_stt
+ *   accountIdcardExpire                → account_idcard_expire
+ *   accountCardName                    → account_card_name
+ *   accountCardNum                     → account_card_num
+ *   accountCardPhone                   → account_card_phone
+ *   accountBankCode                    → account_bank_code
+ *   regCapital                         → reg_capital
+ *   fSettleAccountType                 → fSettle_account_type
+ *   actualAddr                         → actual_addr
+ *   businessScope                      → business_scope
+ *   checkCode                          → check_code
+ *   rateCode                           → rate_code
+ *   pictureLicense                     → picture_license
+ *   pictureIdcardA                     → picture_idcard_a
+ *   pictureIdcardB                     → picture_idcard_b
+ *   pictureBankcardA                   → picture_bankcard_a
+ *   pictureBankcardB                   → picture_bankcard_b
+ *   pictureOutside                     → picture_outside
+ *   pictureInside                      → picture_inside
+ *   leaseContract                      → lease_contract
+ *   pictureTripartite                  → picture_tripartite
+ *   pictureIdcardHander                → picture_idcard_hander
+ *   pictureOpenAccount                 → picture_open_account
+ *   pictureOrganization                → picture_organization
+ *   pictureTaxRegistration             → picture_tax_registration
+ *   pictureUnincorporated              → picture_unincorporated
+ *   pictureBankIdcardA                 → picture_bank_idcard_a
+ *   pictureBankIdcardB                 → picture_bank_idcard_b
+ *   pictureOther                       → picture_other
+ *   shareScene                         → share_scene
+ *   shareCertificate                   → share_certificate
+ *   tripartiteFlowCertificateUrl       → tripartite_flow_certificate_url
+ *   pCashier                           → p_cashier
+ * </pre>
+ *
+ * <p><b>不在这 54 个里、也不该由调用方给的</b>(它们同样是该页的请求参数):
+ * <ul>
+ *   <li>{@code inst_no} / {@code trace_no} / {@code sign} —— <b>信封</b>,由
+ *       {@code LeshouyingClient} 在出站链路上补,服务层与调用方都不该碰;</li>
+ *   <li>{@code notify_url} —— <b>配置</b>,取 {@code LeshouyingProperties.onboardingNotifyUrl}
+ *       (<b>不是</b> {@code notifyUrl},两个地址不同);</li>
+ *   <li>{@code channel_id} / {@code bd_id} —— <b>配置</b>,由客户经理提供;</li>
+ *   <li>{@code merchant_type} / {@code account_type} —— 由 {@link #category} <b>派生</b>,
+ *       不是入参。DTO 上不各存一个可自由组合的字段,于是「文档未列的组合」在类型层面不可表达。</li>
+ * </ul>
+ *
+ * <p><b>★ 本类不做校验</b>:空白一律由组装层在<b>组报文之前</b>按
+ * {@code LsyMerchantRequiredFields.missingRequiredFields} 一次列全(400)。校验点只有一处 ——
+ * 放到 DTO 的注解上会变成两份口径,而两份口径分叉的症状是「某个字段有时拦、有时不拦」。
+ *
+ * <p>★ <b>可选字段照常收下(「不收 ≠ 放行」)</b>:本类带上了必填矩阵<b>不管</b>的那些字段
+ * ({@code license_stt} 一族、{@code lease_contract}、{@code p_cashier}、{@code share_*}、
+ * 以及说明栏写「平安收银宝必填 / 星驿付必填」的 {@code reg_capital} 一族)—— 矩阵不管它们,
+ * 是因为条件是「按通道」而通道此刻未知,<b>不是因为它们可以不上送</b>。字段照常进 DTO、照常上送。
+ */
+@Data
+public class LsyMerchantCreateDTO {
+
+    /**
+     * 商户类别 —— 七个 {@code (merchant_type, account_type)} 组合之一。
+     *
+     * <p>★ 调用方给的是<b>类别</b>,不是两个码值:{@code merchant_type}/{@code account_type}
+     * 由它派生,DTO 上不存。依据是两条签名同时只认 DTO ——
+     * {@code LsyMerchantRequiredFields.missingRequiredFields(dto)} 与
+     * {@code LsyOnboardingService.createMerchant(dto)} 都要按类别分支。
+     */
+    private LsyMerchantCategory category;
+
+    // ===== 商户基础信息 =====
+
+    /** 商户名称(上游 {@code merchant_name})。 */
+    private String merchantName;
+
+    /** 商户简称(上游 {@code merchant_name_short})。 */
+    private String merchantNameShort;
+
+    /**
+     * 商户注册名称(上游 {@code merchant_name_business})。
+     * 上游说明栏逐字:「商户注册名称 小微填姓名,个体企业填营业执照」。
+     */
+    private String merchantNameBusiness;
+
+    /** 商户行业类目 code(上游 {@code merchant_mcc_code}),取值来自「获取行业类目」接口。 */
+    private String merchantMccCode;
+
+    /** 商户签约负责人(上游 {@code contact_name})。 */
+    private String contactName;
+
+    /** 商户签约负责人电话(上游 {@code contact_phone})。 */
+    private String contactPhone;
+
+    /** 商户签约负责人邮箱(上游 {@code contact_email})。 */
+    private String contactEmail;
+
+    /** 商户客服电话(上游 {@code contact_service})。 */
+    private String contactService;
+
+    /**
+     * 商户地址省 / 市 / 区县 code(上游 {@code address_province} / {@code address_city} /
+     * {@code address_county})。
+     *
+     * <p>★ 取值来源是「获取省市区」接口返回的 {@code province_code} / {@code city_code} /
+     * {@code county_code},<b>不是</b> {@code area_code},也不是 {@code parent_code}
+     * (三级联动里 {@code parent_code} 填的是上级的 {@code area_code})。
+     */
+    private String addressProvince;
+    private String addressCity;
+    private String addressCounty;
+
+    /** 商户地址具体街道门牌号(上游 {@code address_street})。 */
+    private String addressStreet;
+
+    // ===== 营业执照 / 法人(是否必填按类别分档,见 LsyMerchantRequiredFields.BY_CATEGORY)=====
+
+    /** 营业执照类型(上游 {@code license_type})。上游说明栏:{@code 1} 三证合一。 */
+    private String licenseType;
+
+    /** 营业执照编号(上游 {@code license_no})。 */
+    private String licenseNo;
+
+    /** 营业执照开始(上游 {@code license_stt},格式 {@code YYYYMMDD})。★ 必填矩阵不管它(附表没有这一行)。 */
+    private String licenseStt;
+
+    /**
+     * 营业执照结束(上游 {@code license_expire},格式 {@code YYYYMMDD})。
+     * 上游说明栏另有逐字要求:<b>长期传 {@code 20991231}</b>。
+     */
+    private String licenseExpire;
+
+    /** 法人名称(上游 {@code legal_name})。 */
+    private String legalName;
+
+    /** 法人证件号(上游 {@code legal_cert_no})。 */
+    private String legalCertNo;
+
+    /** 法人证件开始时间(上游 {@code legal_cert_stt})。★ 必填矩阵不管它。 */
+    private String legalCertStt;
+
+    /** 法人证件结束时间(上游 {@code legal_cert_expire},格式 {@code YYYYMMDD});上游要求<b>长期传 {@code 20991231}</b>。 */
+    private String legalCertExpire;
+
+    // ===== 结算信息 =====
+
+    /** 持卡人身份证号(上游 {@code account_idcard_num})。<b>PII</b>:不得进日志(Global Constraint 5)。 */
+    private String accountIdcardNum;
+
+    /** 持卡人身份证开始时间(上游 {@code account_idcard_stt})。★ 必填矩阵不管它。 */
+    private String accountIdcardStt;
+
+    /** 持卡人身份证结束时间(上游 {@code account_idcard_expire},格式 {@code YYYYMMDD});上游要求<b>长期传 {@code 20991231}</b>。 */
+    private String accountIdcardExpire;
+
+    /** 结算卡账户名(上游 {@code account_card_name})。 */
+    private String accountCardName;
+
+    /** 结算卡卡号(上游 {@code account_card_num})。<b>PII</b>:不得进日志(Global Constraint 5)。 */
+    private String accountCardNum;
+
+    /** 结算卡预留手机号(上游 {@code account_card_phone})。 */
+    private String accountCardPhone;
+
+    /**
+     * 结算银行支行联行号(上游 {@code account_bank_code})。
+     * 取值来自「获取银行」接口 {@code level=2} 返回的 {@code bank_code}(本批次不实现那个选择器,可手工录入)。
+     */
+    private String accountBankCode;
+
+    /**
+     * 注册资本(上游 {@code reg_capital})。上游「必填」列是 {@code C},说明栏写「平安收银宝必填」。
+     * ★ 通道是配置项、此刻未知 ⇒ <b>不进必填矩阵</b>,但字段照常上送。
+     */
+    private String regCapital;
+
+    /**
+     * 结算账户类型(上游 {@code fSettle_account_type})。上游 {@code C},条件同上。
+     *
+     * <p>★ <b>本类唯一一个「键名不是机械驼峰」的字段</b>:上游键的首段 {@code fSettle} 自带大写,
+     * 写成 {@code fSettle_account_type} 才是签名原文。别把它「规整」成别的形状。
+     */
+    private String fSettleAccountType;
+
+    /** 营业地址(上游 {@code actual_addr})。上游 {@code C},说明栏写「平安收银宝必填」。 */
+    private String actualAddr;
+
+    /** 营业范围(上游 {@code business_scope})。上游 {@code C},说明栏写「平安收银宝必填」。 */
+    private String businessScope;
+
+    /** 安心签验证码(上游 {@code check_code})。上游 {@code C},说明栏写「星驿付必填」。 */
+    private String checkCode;
+
+    /**
+     * 费率模板(上游 {@code rate_code})。格式规则见文档:万分之四十五填 {@code RT0045}。
+     *
+     * <p>★ <b>归属未定</b>(计划 §0.8):它可能是平台级、也可能是逐商户的。本批次按平台级实现
+     * (配置项 {@code leshouying.rate-code}),DTO 这个字段是<b>可选的覆盖</b> —— DTO 有值时以 DTO 为准。
+     */
+    private String rateCode;
+
+    // ===== 资质图片(全部是 URL)=====
+
+    /** 营业执照(上游 {@code picture_license})。 */
+    private String pictureLicense;
+
+    /** 负责人身份证正面(上游 {@code picture_idcard_a})。 */
+    private String pictureIdcardA;
+
+    /** 负责人身份证反面(上游 {@code picture_idcard_b})。 */
+    private String pictureIdcardB;
+
+    /** 银行卡正面(上游 {@code picture_bankcard_a})。 */
+    private String pictureBankcardA;
+
+    /** 银行卡反面(上游 {@code picture_bankcard_b})。 */
+    private String pictureBankcardB;
+
+    /** 门头照片(上游 {@code picture_outside})。上游说明栏另要求含「业务员站在门头且露出门牌号」。 */
+    private String pictureOutside;
+
+    /** 内部前台照片(上游 {@code picture_inside})。 */
+    private String pictureInside;
+
+    /** 租赁合同(上游 {@code lease_contract})。说明栏写「平安收银宝必填」⇒ 不进必填矩阵。 */
+    private String leaseContract;
+
+    /** 三联单合同照片(上游 {@code picture_tripartite})。附表七列全空 ⇒ 不必填。 */
+    private String pictureTripartite;
+
+    /**
+     * 手持身份证(上游 {@code picture_idcard_hander})。
+     * ★ 上游键名里的 {@code hander} 是<b>错拼</b>(handler)—— <b>照抄</b>,别顺手改,
+     * 键名进签名原文。Java 字段名同步保留错拼,好让「字段名 = 键名机械驼峰」这条规则无例外。
+     */
+    private String pictureIdcardHander;
+
+    /** 开户许可证照片(上游 {@code picture_open_account})。 */
+    private String pictureOpenAccount;
+
+    /** 组织机构代码证照片(上游 {@code picture_organization})。附表是 {@code -}(当且仅当 {@code license_type=0})⇒ 不必填。 */
+    private String pictureOrganization;
+
+    /** 税务登记证照片(上游 {@code picture_tax_registration})。附表是 {@code -}(同上一格)⇒ 不必填。 */
+    private String pictureTaxRegistration;
+
+    /** 入账非法人证明照片(上游 {@code picture_unincorporated})。 */
+    private String pictureUnincorporated;
+
+    /** 对私账户身份证正面照片(上游 {@code picture_bank_idcard_a})。 */
+    private String pictureBankIdcardA;
+
+    /** 对私账户身份证反面照片(上游 {@code picture_bank_idcard_b})。 */
+    private String pictureBankIdcardB;
+
+    /** 其他证明材料(上游 {@code picture_other},说明栏自带「(选填)」)。 */
+    private String pictureOther;
+
+    // ===== 分账 / 通道条件(附表都没有行 ⇒ 不进必填矩阵)=====
+
+    /** 分账场景(上游 {@code share_scene})。说明栏:选填,需要开通资金分账必传。 */
+    private String shareScene;
+
+    /** 分账证明(上游 {@code share_certificate})。说明栏:选填,需要开通资金分账必传。 */
+    private String shareCertificate;
+
+    /** 三方流量证明(上游 {@code tripartite_flow_certificate_url})。说明栏:选填,需要开通电商平台分账必传。 */
+    private String tripartiteFlowCertificateUrl;
+
+    /** 店铺收银台(上游 {@code p_cashier})。说明栏写「平安收银宝必填,星驿付必填」⇒ 不进必填矩阵。 */
+    private String pCashier;
+}

+ 117 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/enums/LsyMerchantCategory.java

@@ -0,0 +1,117 @@
+package com.payment.platform.module.payment.leshouying.enums;
+
+import cn.hutool.core.util.StrUtil;
+
+/**
+ * 「**创建商户**」页末附表那七类商户 —— 七个 {@code (merchant_type, account_type)} 组合。
+ *
+ * <p>★ <b>为什么是「七个组合」而不是「三个 merchant_type」</b>:必填差异发生在<b>七个组合</b>
+ * 之间,而 {@code merchant_type} 只有三个值 —— 用「三选一」作键会撞。附表自己就撞了两对:
+ * {@code 企业对私法人} 与 {@code 企业对私非法人} 都被写成 {@code merchant_type=1},
+ * 而它们在附表里<b>要求不同</b>({@code picture_unincorporated} 只有非法人那列打 √)⇒
+ * 照抄附表的话,这个必填差异<b>在代码里无法表达</b>。所以每个常量各自携带两个码值,
+ * 「文档未列的组合」在类型层面就不可表达。
+ *
+ * <p><b>① 码值的依据(§0.3b)——附表那一行 {@code account_type} 是错的</b>
+ * <ol>
+ *   <li>附表把 {@code account_type} 写成 {@code 1|2|2|1|2|2|2}(七列只有两个值),于是
+ *       <b>七列塌成五个键</b>:{@code 企业对私法人}/{@code 企业对私非法人} 同键,
+ *       {@code 个体工商户对私法人}/{@code 个体工商户对私非法人} 同键。</li>
+ *   <li><b>决定性依据是参数表第 49 行</b>({@code account_type} 的说明栏,逐字):
+ *       「{@code 1对公结算(个体不支持对公) 2法人对私结算 3非法人对私结算 4 小微对私}」
+ *       —— 值域有 <b>4</b> 个值,不是 2 个。</li>
+ *   <li>而附表那七个列名与这四个值是<b>逐字同名</b>的:{@code 企业对公}→{@code 1对公结算};
+ *       {@code 企业对私法人}→{@code 2法人对私结算};{@code 企业对私非法人}→{@code 3非法人对私结算};
+ *       {@code 个体工商户对公法人}→{@code 1对公结算};{@code 个体工商户对私法人}→{@code 2法人对私结算};
+ *       {@code 个体工商户对私非法人}→{@code 3非法人对私结算};{@code 小微商户}→{@code 4 小微对私}。</li>
+ * </ol>
+ * ⇒ <b>本枚举的 {@code accountType} 取值是 {@code 1|2|3|1|2|3|4}</b>,附表的 {@code 1|2|2|1|2|2|2}
+ * 记为文档缺陷。这是<b>两表同名项逐字对照</b>读出来的,不是推断、不是补全;七个键重排后
+ * <b>唯一且不撞</b>。本条有测试钉着({@code category_sevenConstants_allPairsUnique} 里那条
+ * 「照抄附表会塌成五个键」的反事实断言)。
+ *
+ * <p><b>② {@code merchant_type} 值域(spec §6.2(a):四处里一处矛盾)</b>:创建商户的参数表、
+ * 创建商户的附表、获取商户信息的返回参数三处一致为
+ * {@code 1企业,2个体工商户,3小微商户};只有「获取行业类目」那一页写成
+ * {@code 1个体工商户 2企业 3政府组织及事业单位4小微商户5其他机构组织},把 1 和 2 对调。
+ * <b>裁定:以三处一致者为准,行业类目页记为文档缺陷。</b>理由不止「少数服从多数」——
+ * 那一页的接口描述是从「获取省市区」页<b>整段复制</b>过来的(原文写着「通过此接口可获取进件时的
+ * 省市区列表信息。此接口为三级联动设计…」),是明显的复制粘贴残留。
+ *
+ * <p>★ <b>③ 行业类目页那一套 {@code 1个体工商户 2企业 …} 不是本枚举的值域。</b>本类只服务
+ * 创建商户;行业类目接口的 {@code merchant_type} <b>本地不校验、原样透传</b>(见 §0.5,
+ * 那条腿归 Task 5)。
+ *
+ * <p>★ <b>{@link #of(String)} 的入参是「类别名」,不是码值、也不是码值对。</b>调用方(前端七选一)
+ * 给的是七个名字之一;{@code merchant_type}/{@code account_type} 由类别<b>派生</b>,
+ * 不是入参 —— 于是「文档未列的组合」在类型层面不可表达。
+ */
+public enum LsyMerchantCategory {
+
+    /** (1,1) 附表第 1 列。 */
+    ENTERPRISE_PUBLIC("1", "1", "企业对公"),
+    /** (1,2) 附表第 2 列。 */
+    ENTERPRISE_PRIVATE_LEGAL("1", "2", "企业对私法人"),
+    /** (1,3) 附表第 3 列。★ 附表把这一列的 {@code account_type} 也写成 2(错),真值是 3。 */
+    ENTERPRISE_PRIVATE_NON_LEGAL("1", "3", "企业对私非法人"),
+    /** (2,1) 附表第 4 列。 */
+    INDIVIDUAL_PUBLIC_LEGAL("2", "1", "个体工商户对公法人"),
+    /** (2,2) 附表第 5 列。 */
+    INDIVIDUAL_PRIVATE_LEGAL("2", "2", "个体工商户对私法人"),
+    /** (2,3) 附表第 6 列。★ 与第 5 列在附表里同键(都写 2),真值差一个值。 */
+    INDIVIDUAL_PRIVATE_NON_LEGAL("2", "3", "个体工商户对私非法人"),
+    /** (3,4) 附表第 7 列。★ 唯一 {@code account_type=4} 的一类。 */
+    MICRO("3", "4", "小微商户");
+
+    private final String merchantType;
+    private final String accountType;
+    private final String label;
+
+    LsyMerchantCategory(String merchantType, String accountType, String label) {
+        this.merchantType = merchantType;
+        this.accountType = accountType;
+        this.label = label;
+    }
+
+    /** 上游 {@code merchant_type}({@code String(2)}):{@code 1} 企业 / {@code 2} 个体工商户 / {@code 3} 小微商户。 */
+    public String getMerchantType() { return merchantType; }
+
+    /** 上游 {@code account_type}({@code String(4)}):{@code 1} 对公 / {@code 2} 法人对私 / {@code 3} 非法人对私 / {@code 4} 小微对私。 */
+    public String getAccountType() { return accountType; }
+
+    /** 中文标签 —— <b>逐字等于附表那一列的列名</b>(就是文档给这七类起的名字)。 */
+    public String getLabel() { return label; }
+
+    /**
+     * 类别名 → 枚举,**精确字符串匹配**。匹配不到返回 {@code null},**不抛**。
+     *
+     * <p>两种写法都认,因为它们都是「类别名」:
+     * <ul>
+     *   <li>枚举常量名({@code "ENTERPRISE_PUBLIC"})—— 代码侧、表单里当值用的那种;</li>
+     *   <li>附表列名的中文({@code "企业对公"}、{@code "小微商户"})—— 文档给这七类起的名字,
+     *       与上游 {@code account_type} 说明栏里的名字<b>逐字同名</b>。</li>
+     * </ul>
+     * 两种都是<b>整串精确</b>比对(不 trim、不忽略大小写、不做前缀匹配)。只认一种的话,
+     * 另一种写法会变成「匹配不到 ⇒ null」,而它的下游是一条必填提示 —— 报「商户类型不能为空」
+     * 却明明填了,是最难查的那种。七类名字两两不同、也不与常量名相撞,所以并集不引入歧义。
+     *
+     * <p>★ <b>不要按方法名与本包另一个 {@code of} 互抄</b>:{@link LsyPayType#of(String)}
+     * 的语义与这里<b>相反</b> —— 它对空白返回 {@code AUTO}、对未知码抛
+     * {@code IllegalArgumentException}。那边的码域封闭且有业务默认值;这边的入参是人工选择,
+     * 空白与未知一律 {@code null},与本包 {@link LsyReviewStatus#of(String)} 同一先例。
+     *
+     * <p>★ <b>码值不是入参</b>:{@code "1"} 解不出任何类别 —— {@code merchant_type=1}
+     * 同时是三类商户,用它当类别名会静默丢信息。码值对({@code "1-1"})也不是本方法认的格式。
+     */
+    public static LsyMerchantCategory of(String name) {
+        if (StrUtil.isBlank(name)) {
+            return null;
+        }
+        for (LsyMerchantCategory c : values()) {
+            if (c.name().equals(name) || c.label.equals(name)) {
+                return c;
+            }
+        }
+        return null;
+    }
+}

+ 343 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantRequiredFields.java

@@ -0,0 +1,343 @@
+package com.payment.platform.module.payment.leshouying.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.payment.platform.module.payment.leshouying.dto.LsyMerchantCreateDTO;
+import com.payment.platform.module.payment.leshouying.enums.LsyMerchantCategory;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+/**
+ * 创建商户的**必填矩阵** —— 把上游 page 196236555 的两张表(§0.2 的 27 个必填、页末 7 类商户附表)
+ * 变成可执行判据。**纯静态**:无依赖、无网络、不碰容器。
+ *
+ * <p><b>★ 返回的是上游参数名,不是中文</b>:{@link #missingRequiredFields} 返回
+ * {@code merchant_name} 这种键名(机器可读,好让测试能用同一套词汇做集合断言)。
+ * **渲染成对外文案时**过 {@link #LABELS} 取中文标签 —— 两件事分开,各取所需。本模块既有约定
+ * 是报中文标签而不是键名(见 {@code LsyOnboardingService.requireText} 的 javadoc)。
+ *
+ * <h2>§0.2 那 27 个怎么分(27 = 4 + 3 + 2 + 18,无重叠无遗漏)</h2>
+ * <table border="1">
+ *   <caption>四层分工</caption>
+ *   <tr><th>归属</th><th>个数</th><th>字段</th><th>谁负责</th></tr>
+ *   <tr><td>信封</td><td>4</td><td>{@code inst_no} / {@code trace_no} / {@code sign} /
+ *       {@code notify_url}</td><td>{@code LeshouyingClient} 出站补 + 配置</td></tr>
+ *   <tr><td>配置</td><td>3</td><td>{@code channel_id} / {@code bd_id} / {@code rate_code}</td>
+ *       <td>{@code LeshouyingProperties.missingOnboardingCredentials()}</td></tr>
+ *   <tr><td>类别派生</td><td>2</td><td>{@code merchant_type} / {@code account_type}</td>
+ *       <td>{@link LsyMerchantCategory}(由类别推出,不可自由组合)</td></tr>
+ *   <tr><td>DTO 承担</td><td>18</td><td>见 {@link #UNCONDITIONAL}</td><td>本类</td></tr>
+ * </table>
+ * 这个加法本身有测试钉着({@code requiredFieldAccounting_isExactly27}):有人在组装层多查一个
+ * 或少查一个,它会红。
+ *
+ * <p><b>★ 但本类判的<b>不止</b>那 18 个</b>:{@link #missingRequiredFields} 返回的是
+ * <b>{@code UNCONDITIONAL ∪ BY_CATEGORY[该类别]}</b>。{@code BY_CATEGORY} 里的
+ * {@code license_*} / {@code legal_*} / {@code picture_*} <b>一个都不在那 18 个里</b>
+ * (它们根本不在 §0.2 的 27 个之内),只查 18 个的话,附表的按类别差异根本没有落点。
+ *
+ * <h2>值从哪来(本类**不做**的判断)</h2>
+ * <ul>
+ *   <li><b>以附表为准,不以参数表「必填」列为准</b>(§0.3):参数表把
+ *       {@code license_*} / {@code legal_*} 全部留空,而页末附表对除小微外全部 6 类标 √、
+ *       对法人三件套按列标 √。<b>裁定:以附表为准。</b>依据有二 —— ① 创建商户页的接口描述
+ *       自己写着「各类型商户进件所需的资料,详见[商户进件所需资料]」,即该页<b>指名</b>附表为
+ *       分档必填的权威;② 参数表的「必填」列只有「是 / 空 / C」三态,<b>没有「按类型」这一态</b>,
+ *       其「空」只能读成「不无条件必填」,读不出「永远不必填」。
+ *       <b>代价若判错</b>:某些类型的商户进件时少送 {@code license_no} 等 ⇒ 上游拒,
+ *       报错点在上游不在我们 —— 请求体在日志里,可当场比对附表改回。</li>
+ *   <li><b>通道条件一概不收</b>(§0.3d):{@code reg_capital} / {@code fSettle_account_type} /
+ *       {@code actual_addr} / {@code business_scope}(「平安收银宝必填」)、{@code check_code}
+ *       (「星驿付必填」)、{@code lease_contract} / {@code p_cashier} 这些「按通道」的条件,
+ *       在 <b>{@code channel_id} 未知时无法求值</b> —— 通道是配置项、此刻未知。
+ *       ⇒ 本矩阵<b>只收附表那套「按商户类型」的 √</b>。
+ *       ★ <b>不收 ≠ 放行</b>:字段照常进 {@link LsyMerchantCreateDTO}、照常上送。
+ *       <b>代价若判错</b>:通道落定后操作者漏填 {@code reg_capital} 之类 ⇒ 上游拒 ——
+ *       报错点在上游,请求体在日志里,可当场比对说明栏补。</li>
+ * </ul>
+ *
+ * <p>★ <b>附表自己有两处不对称,照抄不修</b>:{@code picture_idcard_a} 对小微标 √ 而
+ * {@code picture_idcard_b} 对小微留空;{@code picture_license} 对小微留空。
+ * <b>不要「顺手补全」——补全就是发明原文没有的规则。</b>
+ * 附表里 {@code ~~settlement_type~~} 被划掉且参数表根本没有这个字段 ⇒ 同样不实现。
+ */
+public final class LsyMerchantRequiredFields {
+
+    private LsyMerchantRequiredFields() {
+    }
+
+    /**
+     * **与类别无关**的必填字段 —— §0.2 那 27 个里由 DTO 承担的 **18** 个(不是 23、不是 27)。
+     *
+     * <p>顺序照 §0.2 原文。它是 {@link #missingRequiredFields} 返回值的前半段,也是四层分工里
+     * 测试断言的那一层({@code 信封4 ∪ 配置3 ∪ 类别派生2 ∪ UNCONDITIONAL} 恰为 27 个)。
+     */
+    public static final Set<String> UNCONDITIONAL = set(
+            "merchant_name",
+            "merchant_name_short",
+            "merchant_name_business",
+            "merchant_mcc_code",
+            "contact_name",
+            "contact_phone",
+            "contact_email",
+            "contact_service",
+            "address_province",
+            "address_city",
+            "address_county",
+            "address_street",
+            "account_idcard_num",
+            "account_idcard_expire",
+            "account_card_name",
+            "account_card_num",
+            "account_card_phone",
+            "account_bank_code");
+
+    /**
+     * 类别 → 该类别**额外**必填的字段。键是七个类别,值照 §0.3 附表的 √ <b>逐格填</b>。
+     *
+     * <p>★ <b>表里只有「不在 {@link #UNCONDITIONAL} 里」的那些字段</b>:附表对
+     * {@code account_idcard_num} / {@code account_card_*} / {@code account_bank_code} 这 8 个
+     * 也是七列全 √,但它们已由 {@code UNCONDITIONAL} 承担,再抄一遍就成了<b>两个真值来源</b>
+     * (从 {@code UNCONDITIONAL} 里摘掉一个,它仍会被这一层悄悄要求着)。
+     * ⇒ 两张表<b>不相交</b>,{@code UNCONDITIONAL ∪ BY_CATEGORY[该类别]} 才是该类别完整的必填集;
+     * 附表那七列的全量抄本在测试里另有一份(逐类别集合相等),所以「抄错一格」照样会红。
+     *
+     * <p>★ 附表那一行 {@code account_type} 的错值<b>不影响 √ 的落格</b> —— √ 是按七列读的;
+     * 七个键的取值修正见 {@link LsyMerchantCategory}。
+     */
+    public static final Map<LsyMerchantCategory, Set<String>> BY_CATEGORY;
+
+    /**
+     * 上游参数名 → 中文标签,标签**逐字**取创建商户页请求参数表的「说明」列。覆盖该表的全部 62 个参数。
+     *
+     * <p><b>三个用途共用这一份</b>:① 组装层拼缺失清单的人话文案;② 前端进件页的表单字段标签;
+     * ③ 组装层 {@code requireText(value, label)} 的 {@code label} 参数。<b>不要另写第二张表。</b>
+     *
+     * <p>★ <b>标签里带格式提示是照抄的,别裁</b>:例如
+     * {@code account_idcard_expire → "持卡人身份证结束时间(格式YYYYMMDD)长期传20991231"}、
+     * {@code merchant_name_business → "商户注册名称 小微填姓名,个体企业填营业执照"} ——
+     * 上游把操作提示写在同一格里,裁掉就是发明原文没有的简写。
+     *
+     * <p>★ <b>本表的键集是「创建商户页的全部参数」,不只是必填那些</b>:上游「说明」列只有这里有,
+     * 而前端表单要覆盖整页参数。必填矩阵报得出的每个字段当然都在其中
+     * (有测试钉着「矩阵可能报出的字段一个不缺」)。
+     */
+    public static final Map<String, String> LABELS;
+
+    /**
+     * 参数名 → 从 DTO 取值。**本表只覆盖矩阵可能报出的字段**(36 个),外加两个派生字段。
+     *
+     * <p>表驱动而不是 if/else 长链:这样「附表这一格」在代码里能逐格核对。
+     * 取值统一走 {@link StrUtil#isBlank},与组装层 {@code requireText} 的空白判据同一口径 ——
+     * 两处口径分叉的症状是「校验说齐了、组装层说没填」。
+     */
+    private static final Map<String, Function<LsyMerchantCreateDTO, String>> VALUES;
+
+    /** 由类别派生、DTO 上不存的那两个。类别为空时它们会被报成缺失(见 {@link #missingRequiredFields})。 */
+    private static final String P_MERCHANT_TYPE = "merchant_type";
+    private static final String P_ACCOUNT_TYPE = "account_type";
+
+    static {
+        Map<LsyMerchantCategory, Set<String>> byCategory = new LinkedHashMap<>();
+        byCategory.put(LsyMerchantCategory.ENTERPRISE_PUBLIC, set(
+                "license_type", "license_no", "license_expire", "legal_name",
+                "legal_cert_no", "legal_cert_expire", "picture_license", "picture_idcard_a",
+                "picture_idcard_b", "picture_outside", "picture_inside", "picture_open_account"));
+        byCategory.put(LsyMerchantCategory.ENTERPRISE_PRIVATE_LEGAL, set(
+                "license_type", "license_no", "license_expire", "legal_name",
+                "legal_cert_no", "legal_cert_expire", "picture_license", "picture_bankcard_a",
+                "picture_bankcard_b", "picture_outside", "picture_inside", "picture_open_account",
+                "picture_bank_idcard_a", "picture_bank_idcard_b"));
+        byCategory.put(LsyMerchantCategory.ENTERPRISE_PRIVATE_NON_LEGAL, set(
+                "license_type", "license_no", "license_expire", "legal_name",
+                "legal_cert_no", "legal_cert_expire", "picture_license", "picture_idcard_a",
+                "picture_idcard_b", "picture_bankcard_a", "picture_bankcard_b", "picture_outside",
+                "picture_inside", "picture_open_account", "picture_unincorporated", "picture_bank_idcard_a",
+                "picture_bank_idcard_b"));
+        byCategory.put(LsyMerchantCategory.INDIVIDUAL_PUBLIC_LEGAL, set(
+                "license_type", "license_no", "license_expire", "picture_license",
+                "picture_idcard_a", "picture_idcard_b", "picture_outside", "picture_inside",
+                "picture_open_account"));
+        byCategory.put(LsyMerchantCategory.INDIVIDUAL_PRIVATE_LEGAL, set(
+                "license_type", "license_no", "license_expire", "picture_license",
+                "picture_bankcard_a", "picture_bankcard_b", "picture_outside", "picture_inside",
+                "picture_open_account", "picture_bank_idcard_a", "picture_bank_idcard_b"));
+        byCategory.put(LsyMerchantCategory.INDIVIDUAL_PRIVATE_NON_LEGAL, set(
+                "license_type", "license_no", "license_expire", "legal_name",
+                "legal_cert_no", "legal_cert_expire", "picture_license", "picture_idcard_a",
+                "picture_idcard_b", "picture_bankcard_a", "picture_bankcard_b", "picture_outside",
+                "picture_inside", "picture_open_account", "picture_unincorporated", "picture_bank_idcard_a",
+                "picture_bank_idcard_b"));
+        byCategory.put(LsyMerchantCategory.MICRO, set(
+                "picture_idcard_a", "picture_bankcard_a", "picture_bankcard_b", "picture_outside",
+                "picture_inside", "picture_idcard_hander", "picture_bank_idcard_a", "picture_bank_idcard_b"));
+        BY_CATEGORY = Collections.unmodifiableMap(byCategory);
+
+        Map<String, String> labels = new LinkedHashMap<>();
+        labels.put("inst_no", "合作机构号");
+        labels.put("trace_no", "请求流水号");
+        labels.put("sign", "签名字符串");
+        labels.put("notify_url", "进件审核通知地址");
+        labels.put("merchant_name", "商户名称");
+        labels.put("merchant_name_short", "商户简称");
+        labels.put("merchant_name_business", "商户注册名称 小微填姓名,个体企业填营业执照");
+        labels.put("merchant_mcc_code", "商户行业类目code");
+        labels.put("channel_id", "通道id");
+        labels.put("bd_id", "业务经理id");
+        labels.put("contact_name", "商户签约负责人");
+        labels.put("contact_phone", "商户签约负责人电话");
+        labels.put("contact_email", "商户签约负责人邮箱");
+        labels.put("contact_service", "商户客服电话");
+        labels.put("address_province", "商户地址省code");
+        labels.put("address_city", "商户地址市code");
+        labels.put("address_county", "商户地址区县code");
+        labels.put("address_street", "商户地址具体街道门牌号");
+        labels.put("merchant_type", "商户类型");
+        labels.put("license_type", "营业执照类型");
+        labels.put("license_no", "营业执照编号");
+        labels.put("license_stt", "营业执照开始(格式YYYYMMDD)");
+        labels.put("license_expire", "营业执照结束(格式YYYYMMDD)长期传20991231");
+        labels.put("account_type", "账户类型");
+        labels.put("legal_name", "法人名称");
+        labels.put("legal_cert_no", "法人证件号");
+        labels.put("legal_cert_stt", "法人证件开始时间");
+        labels.put("legal_cert_expire", "法人证件结束时间(格式YYYYMMDD)长期传20991231");
+        labels.put("account_idcard_num", "持卡人身份证号");
+        labels.put("account_idcard_stt", "持卡人身份证开始时间");
+        labels.put("account_idcard_expire", "持卡人身份证结束时间(格式YYYYMMDD)长期传20991231");
+        labels.put("account_card_name", "结算卡账户名");
+        labels.put("account_card_num", "结算卡卡号");
+        labels.put("account_card_phone", "结算卡预留手机号");
+        labels.put("account_bank_code", "结算银行支行联行号");
+        labels.put("reg_capital", "注册资本");
+        labels.put("fSettle_account_type", "结算账户类型");
+        labels.put("actual_addr", "营业地址");
+        labels.put("business_scope", "营业范围");
+        labels.put("check_code", "安心签验证码");
+        labels.put("rate_code", "费率模板");
+        labels.put("picture_license", "营业执照");
+        labels.put("picture_idcard_a", "负责人身份证正面");
+        labels.put("picture_idcard_b", "负责人身份证反面");
+        labels.put("picture_bankcard_a", "银行卡正面");
+        labels.put("picture_bankcard_b", "银行卡反面");
+        labels.put("picture_outside", "门头照片");
+        labels.put("picture_inside", "内部前台照片");
+        labels.put("lease_contract", "租赁合同");
+        labels.put("picture_tripartite", "三联单合同照片");
+        labels.put("picture_idcard_hander", "手持身份证");
+        labels.put("picture_open_account", "开户许可证照片");
+        labels.put("picture_organization", "组织机构代码证照片");
+        labels.put("picture_tax_registration", "税务登记证照片");
+        labels.put("picture_unincorporated", "入账非法人证明照片");
+        labels.put("picture_bank_idcard_a", "对私账户身份证正面照片");
+        labels.put("picture_bank_idcard_b", "对私账户身份证反面照片");
+        labels.put("picture_other", "其他证明材料(选填)");
+        labels.put("share_scene", "分账场景(选填)需要开通资金分账必传");
+        labels.put("share_certificate", "分账证明(选填)需要开通资金分账必传");
+        labels.put("tripartite_flow_certificate_url", "三方流量证明(选填)需要开通电商平台分账必传");
+        labels.put("p_cashier", "店铺收银台");
+        LABELS = Collections.unmodifiableMap(labels);
+
+        Map<String, Function<LsyMerchantCreateDTO, String>> values = new LinkedHashMap<>();
+        v(values, "merchant_name", LsyMerchantCreateDTO::getMerchantName);
+        v(values, "merchant_name_short", LsyMerchantCreateDTO::getMerchantNameShort);
+        v(values, "merchant_name_business", LsyMerchantCreateDTO::getMerchantNameBusiness);
+        v(values, "merchant_mcc_code", LsyMerchantCreateDTO::getMerchantMccCode);
+        v(values, "contact_name", LsyMerchantCreateDTO::getContactName);
+        v(values, "contact_phone", LsyMerchantCreateDTO::getContactPhone);
+        v(values, "contact_email", LsyMerchantCreateDTO::getContactEmail);
+        v(values, "contact_service", LsyMerchantCreateDTO::getContactService);
+        v(values, "address_province", LsyMerchantCreateDTO::getAddressProvince);
+        v(values, "address_city", LsyMerchantCreateDTO::getAddressCity);
+        v(values, "address_county", LsyMerchantCreateDTO::getAddressCounty);
+        v(values, "address_street", LsyMerchantCreateDTO::getAddressStreet);
+        v(values, "license_type", LsyMerchantCreateDTO::getLicenseType);
+        v(values, "license_no", LsyMerchantCreateDTO::getLicenseNo);
+        v(values, "license_expire", LsyMerchantCreateDTO::getLicenseExpire);
+        v(values, "legal_name", LsyMerchantCreateDTO::getLegalName);
+        v(values, "legal_cert_no", LsyMerchantCreateDTO::getLegalCertNo);
+        v(values, "legal_cert_expire", LsyMerchantCreateDTO::getLegalCertExpire);
+        v(values, "account_idcard_num", LsyMerchantCreateDTO::getAccountIdcardNum);
+        v(values, "account_idcard_expire", LsyMerchantCreateDTO::getAccountIdcardExpire);
+        v(values, "account_card_name", LsyMerchantCreateDTO::getAccountCardName);
+        v(values, "account_card_num", LsyMerchantCreateDTO::getAccountCardNum);
+        v(values, "account_card_phone", LsyMerchantCreateDTO::getAccountCardPhone);
+        v(values, "account_bank_code", LsyMerchantCreateDTO::getAccountBankCode);
+        v(values, "picture_license", LsyMerchantCreateDTO::getPictureLicense);
+        v(values, "picture_idcard_a", LsyMerchantCreateDTO::getPictureIdcardA);
+        v(values, "picture_idcard_b", LsyMerchantCreateDTO::getPictureIdcardB);
+        v(values, "picture_bankcard_a", LsyMerchantCreateDTO::getPictureBankcardA);
+        v(values, "picture_bankcard_b", LsyMerchantCreateDTO::getPictureBankcardB);
+        v(values, "picture_outside", LsyMerchantCreateDTO::getPictureOutside);
+        v(values, "picture_inside", LsyMerchantCreateDTO::getPictureInside);
+        v(values, "picture_idcard_hander", LsyMerchantCreateDTO::getPictureIdcardHander);
+        v(values, "picture_open_account", LsyMerchantCreateDTO::getPictureOpenAccount);
+        v(values, "picture_unincorporated", LsyMerchantCreateDTO::getPictureUnincorporated);
+        v(values, "picture_bank_idcard_a", LsyMerchantCreateDTO::getPictureBankIdcardA);
+        v(values, "picture_bank_idcard_b", LsyMerchantCreateDTO::getPictureBankIdcardB);
+        // 这两个**不在 DTO 上**(由 LsyMerchantCategory 派生)⇒ 取值器恒为 null。
+        // 它们只在「类别为空」这一条路径上被读到:那时它们确实填不出来。
+        v(values, P_MERCHANT_TYPE, d -> null);
+        v(values, P_ACCOUNT_TYPE, d -> null);
+        VALUES = Collections.unmodifiableMap(values);
+    }
+
+    /**
+     * 缺失字段清单(**上游参数名**形式);齐备时返回空列表。
+     *
+     * <p>缺失集合 = {@code UNCONDITIONAL ∪ BY_CATEGORY[类别]},逐个过 {@link StrUtil#isBlank}
+     * ({@code null} / 空串 / 全空格都算缺)。**一次性列全**,理由同
+     * {@code LeshouyingProperties.validate()}:「逐个抛会让人改一个重启一次」——
+     * 进件当天每轮改一个字段再提交一次,成本是分钟级。
+     *
+     * <p>顺序是确定的({@code UNCONDITIONAL} 在前、该类别独有的在后,各自照表的声明顺序),
+     * 所以同一次输入给出同一份清单,测试与页面都不抖。
+     *
+     * <p>★ <b>类别为 {@code null} 时不抛</b>:请求体里没带类别是**可达的**(前端漏选、直接打接口),
+     * 那时 {@code BY_CATEGORY.get(null)} 会是 {@code null},而「按类别分档的那一层」无从求值
+     * —— 能确定的只有「{@code merchant_type}/{@code account_type} 这两个派生字段填不出来」。
+     * 于是清单 = 18 个无条件 + 这两个派生字段。若在类别上抛异常,这条本该是 400 的必填提示
+     * 会变成 500。
+     *
+     * @return 缺失的**上游参数名**;渲染成人话请过 {@link #LABELS}
+     */
+    public static List<String> missingRequiredFields(LsyMerchantCreateDTO dto) {
+        Set<String> required = new LinkedHashSet<>(UNCONDITIONAL);
+        LsyMerchantCategory category = dto.getCategory();
+        if (category == null) {
+            required.add(P_MERCHANT_TYPE);
+            required.add(P_ACCOUNT_TYPE);
+        } else {
+            required.addAll(BY_CATEGORY.get(category));
+        }
+
+        List<String> missing = new ArrayList<>();
+        for (String name : required) {
+            Function<LsyMerchantCreateDTO, String> value = VALUES.get(name);
+            if (value == null) {
+                throw new IllegalStateException(
+                        "必填矩阵里有取值器没覆盖的字段: " + name + " —— 加字段时忘了往 VALUES 里登记");
+            }
+            if (StrUtil.isBlank(value.apply(dto))) {
+                missing.add(name);
+            }
+        }
+        return List.copyOf(missing);
+    }
+
+    /** 不可变、**保序**的字符串集合。保序不是为了好看:缺失清单的顺序由它决定。 */
+    private static Set<String> set(String... names) {
+        return Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList(names)));
+    }
+
+    private static <T> void v(Map<String, T> map, String key, T value) {
+        map.put(key, value);
+    }
+}

+ 619 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantRequiredFieldsTest.java

@@ -0,0 +1,619 @@
+package com.payment.platform.module.payment.leshouying.service;
+
+import com.payment.platform.module.payment.leshouying.dto.LsyMerchantCreateDTO;
+import com.payment.platform.module.payment.leshouying.enums.LsyMerchantCategory;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+import static com.payment.platform.module.payment.leshouying.enums.LsyMerchantCategory.ENTERPRISE_PRIVATE_LEGAL;
+import static com.payment.platform.module.payment.leshouying.enums.LsyMerchantCategory.ENTERPRISE_PRIVATE_NON_LEGAL;
+import static com.payment.platform.module.payment.leshouying.enums.LsyMerchantCategory.ENTERPRISE_PUBLIC;
+import static com.payment.platform.module.payment.leshouying.enums.LsyMerchantCategory.INDIVIDUAL_PRIVATE_LEGAL;
+import static com.payment.platform.module.payment.leshouying.enums.LsyMerchantCategory.INDIVIDUAL_PRIVATE_NON_LEGAL;
+import static com.payment.platform.module.payment.leshouying.enums.LsyMerchantCategory.INDIVIDUAL_PUBLIC_LEGAL;
+import static com.payment.platform.module.payment.leshouying.enums.LsyMerchantCategory.MICRO;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 创建商户的**必填矩阵**(上游 page 196236555 的 §0.2 27 个 + 页末 7 类商户附表)逐格钉死。
+ *
+ * <p>【为什么期望值写成**字面量**而不是从生产常量取】两个方向都要能红:
+ * <ul>
+ *   <li>生产表少抄一格 ⇒ 字面量仍在,红;</li>
+ *   <li>生产表多抄一格 ⇒ 集合相等仍是红。</li>
+ * </ul>
+ * 若期望值也取自 {@code BY_CATEGORY},则「两边一起错」会互相抵消 —— 而这张表恰恰是从
+ * **两张上游表格**抄来的,抄错是首要风险。
+ *
+ * <p>【为什么不断言个数】必填集合**随类别变**(九到十七个不等)。写死个数会与按类别断言的用例
+ * 互相打架,且上游加一格时读数没有意义。集合相等比个数强:个数相等挡不住「漏一个、多一个」。
+ *
+ * <p>【本类钉的四层】{@code 信封4 ∪ 配置3 ∪ 类别派生2 ∪ UNCONDITIONAL(18)} 恰为 §0.2 的 27 个,
+ * 四层两两不相交。这条钉的是**分工会不会随后续任务漂移** —— 有人在组装层多查一个或少查一个,
+ * {@link #requiredFieldAccounting_isExactly27()} 会红。
+ *
+ * <p>【附表里两处不对称是**照抄**的,不是漏的】小微要 {@code picture_idcard_a} 却不要
+ * {@code picture_idcard_b};小微不要 {@code picture_license}。见
+ * {@link #missingRequiredFields_requiresPictureIdcardAForXiaowei_butNotB()}。
+ */
+class LsyMerchantRequiredFieldsTest {
+
+    // ==================================================================================
+    // 夹具:全部取自上游 page 196236555 的原文,逐字
+    // ==================================================================================
+
+    /** §0.2 的 27 个必填,顺序照原文。本表**只**用于 {@link #requiredFieldAccounting_isExactly27()}。 */
+    private static final Set<String> ALL_27 = Set.of(
+            "inst_no", "trace_no", "sign", "notify_url",
+            "merchant_name", "merchant_name_short", "merchant_name_business", "merchant_mcc_code",
+            "channel_id", "bd_id",
+            "contact_name", "contact_phone", "contact_email", "contact_service",
+            "address_province", "address_city", "address_county", "address_street",
+            "merchant_type", "account_type",
+            "account_idcard_num", "account_idcard_expire",
+            "account_card_name", "account_card_num", "account_card_phone", "account_bank_code",
+            "rate_code");
+
+    /** 那 27 个里「信封」那一层 —— {@code LeshouyingClient} 出站时补({@code inst_no}/{@code sign})+ 配置({@code notify_url})。 */
+    private static final Set<String> ENVELOPE_4 = Set.of("inst_no", "trace_no", "sign", "notify_url");
+
+    /** 「配置」那一层 —— Task 1 的 {@code LeshouyingProperties.missingOnboardingCredentials()} 查这三个。 */
+    private static final Set<String> CONFIG_3 = Set.of("channel_id", "bd_id", "rate_code");
+
+    /** 「类别派生」那一层 —— 由 {@link LsyMerchantCategory} 的 {@code merchantType}/{@code accountType} 推出。 */
+    private static final Set<String> DERIVED_2 = Set.of("merchant_type", "account_type");
+
+    /** §0.2 那 27 个里「DTO 承担」的 18 个(brief 的归属表最后一行),顺序照原文。 */
+    private static final List<String> DTO_18 = List.of(
+            "merchant_name", "merchant_name_short", "merchant_name_business", "merchant_mcc_code",
+            "contact_name", "contact_phone", "contact_email", "contact_service",
+            "address_province", "address_city", "address_county", "address_street",
+            "account_idcard_num", "account_idcard_expire",
+            "account_card_name", "account_card_num", "account_card_phone", "account_bank_code");
+
+    /**
+     * §0.3 附表**每列**的 √,**减去**上面那 18 个(那 18 个七列全 √,由 {@code UNCONDITIONAL} 承担)。
+     *
+     * <p>本表逐字抄自 page 196236555 页末附表(2026-09-23 用脚本从页面原文重抽一遍核对过:
+     * 七列 √ 总数 31/33/36/28/30/36/27,减去 {@code inst_no} 再减去那 18 个,正是下面这七行)。
+     */
+    private static final Map<LsyMerchantCategory, Set<String>> APPENDIX_EXTRA = new LinkedHashMap<>();
+
+    static {
+        APPENDIX_EXTRA.put(ENTERPRISE_PUBLIC, Set.of(
+                "license_type", "license_no", "license_expire",
+                "legal_name", "legal_cert_no", "legal_cert_expire",
+                "picture_license", "picture_idcard_a", "picture_idcard_b",
+                "picture_outside", "picture_inside", "picture_open_account"));
+        APPENDIX_EXTRA.put(ENTERPRISE_PRIVATE_LEGAL, Set.of(
+                "license_type", "license_no", "license_expire",
+                "legal_name", "legal_cert_no", "legal_cert_expire",
+                "picture_license", "picture_bankcard_a", "picture_bankcard_b",
+                "picture_outside", "picture_inside", "picture_open_account",
+                "picture_bank_idcard_a", "picture_bank_idcard_b"));
+        APPENDIX_EXTRA.put(ENTERPRISE_PRIVATE_NON_LEGAL, Set.of(
+                "license_type", "license_no", "license_expire",
+                "legal_name", "legal_cert_no", "legal_cert_expire",
+                "picture_license", "picture_idcard_a", "picture_idcard_b",
+                "picture_bankcard_a", "picture_bankcard_b",
+                "picture_outside", "picture_inside", "picture_open_account",
+                "picture_unincorporated", "picture_bank_idcard_a", "picture_bank_idcard_b"));
+        APPENDIX_EXTRA.put(INDIVIDUAL_PUBLIC_LEGAL, Set.of(
+                "license_type", "license_no", "license_expire",
+                "picture_license", "picture_idcard_a", "picture_idcard_b",
+                "picture_outside", "picture_inside", "picture_open_account"));
+        APPENDIX_EXTRA.put(INDIVIDUAL_PRIVATE_LEGAL, Set.of(
+                "license_type", "license_no", "license_expire",
+                "picture_license", "picture_bankcard_a", "picture_bankcard_b",
+                "picture_outside", "picture_inside", "picture_open_account",
+                "picture_bank_idcard_a", "picture_bank_idcard_b"));
+        APPENDIX_EXTRA.put(INDIVIDUAL_PRIVATE_NON_LEGAL, Set.of(
+                "license_type", "license_no", "license_expire",
+                "legal_name", "legal_cert_no", "legal_cert_expire",
+                "picture_license", "picture_idcard_a", "picture_idcard_b",
+                "picture_bankcard_a", "picture_bankcard_b",
+                "picture_outside", "picture_inside", "picture_open_account",
+                "picture_unincorporated", "picture_bank_idcard_a", "picture_bank_idcard_b"));
+        APPENDIX_EXTRA.put(MICRO, Set.of(
+                "picture_idcard_a", "picture_bankcard_a", "picture_bankcard_b",
+                "picture_outside", "picture_inside", "picture_idcard_hander",
+                "picture_bank_idcard_a", "picture_bank_idcard_b"));
+    }
+
+    /** §0.3b 那七个组合,**按类别名**(不按 {@code values()} 的下标,免得声明顺序一改就错位)。 */
+    private static final Map<LsyMerchantCategory, String> EXPECTED_PAIRS = Map.of(
+            ENTERPRISE_PUBLIC, "1-1",
+            ENTERPRISE_PRIVATE_LEGAL, "1-2",
+            ENTERPRISE_PRIVATE_NON_LEGAL, "1-3",
+            INDIVIDUAL_PUBLIC_LEGAL, "2-1",
+            INDIVIDUAL_PRIVATE_LEGAL, "2-2",
+            INDIVIDUAL_PRIVATE_NON_LEGAL, "2-3",
+            MICRO, "3-4");
+
+    /**
+     * 附表那一行 {@code account_type} 的**错误值**({@code 1|2|2|1|2|2|2})。
+     * 只有 {@link #category_sevenConstants_allPairsUnique()} 用 —— 用来把「照抄附表会塌成五个键」
+     * 这个**读数**固定住,而不是嘴上说说。
+     */
+    private static final Map<LsyMerchantCategory, String> APPENDIX_WRONG_ACCOUNT_TYPE = Map.of(
+            ENTERPRISE_PUBLIC, "1",
+            ENTERPRISE_PRIVATE_LEGAL, "2",
+            ENTERPRISE_PRIVATE_NON_LEGAL, "2",
+            INDIVIDUAL_PUBLIC_LEGAL, "1",
+            INDIVIDUAL_PRIVATE_LEGAL, "2",
+            INDIVIDUAL_PRIVATE_NON_LEGAL, "2",
+            MICRO, "2");
+
+    // ==================================================================================
+    // 枚举:七类组合
+    // ==================================================================================
+
+    /**
+     * ★★ 本任务最该有的那条:七个常量的 {@code (merchantType, accountType)} 两两不重复,
+     * 且 {@code accountType} 值域恰为 {@code {1,2,3,4}}。
+     *
+     * <p>**照抄附表那一行会让本用例变红** —— 附表写 {@code 1|2|2|1|2|2|2},只有两个值,
+     * 且七列塌成五个键。修正依据是参数表第 49 行 {@code account_type} 的说明栏
+     * (「1对公结算(个体不支持对公) 2法人对私结算 3非法人对私结算 4 小微对私」),
+     * 与附表七个列名**逐字同名**。
+     */
+    @Test
+    void category_sevenConstants_allPairsUnique() {
+        Set<String> pairs = new HashSet<>();
+        Set<String> accountTypes = new TreeSet<>();
+        Set<String> merchantTypes = new TreeSet<>();
+        for (LsyMerchantCategory c : LsyMerchantCategory.values()) {
+            pairs.add(c.getMerchantType() + "-" + c.getAccountType());
+            accountTypes.add(c.getAccountType());
+            merchantTypes.add(c.getMerchantType());
+        }
+        assertEquals(LsyMerchantCategory.values().length, pairs.size(),
+                "七类的 (merchant_type, account_type) 必须两两不重复 —— 撞了就意味着这个必填差异"
+                        + "在代码里无法表达(附表那一行正是这么撞的)。实测组合: " + pairs);
+        assertEquals(Set.of("1", "2", "3", "4"), accountTypes,
+                "account_type 值域是 4 个值(参数表第 49 行);只得到 {1,2} 就是照抄了附表那一行的错值");
+        assertEquals(Set.of("1", "2", "3"), merchantTypes, "merchant_type 值域 3 个值(spec §6.2(a) 裁定)");
+
+        // ★ 反事实:若把附表那一行的 account_type 换成它的错值,七个组合会塌成五个键。
+        //   这条断言的作用是**记录那个塌陷读数**(也让「附表被上游修正了」这件事有一天会红)。
+        Set<String> asAppendix = new HashSet<>();
+        for (LsyMerchantCategory c : LsyMerchantCategory.values()) {
+            asAppendix.add(c.getMerchantType() + "-" + APPENDIX_WRONG_ACCOUNT_TYPE.get(c));
+        }
+        assertEquals(5, asAppendix.size(),
+                "附表的 account_type 一行本应把七列塌成五个键(企业对私法人/企业对私非法人 同键,"
+                        + "个体对私法人/个体对私非法人 同键)。实测塌成 " + asAppendix.size()
+                        + " 个 —— 若变成 7,说明上游改了附表,本类的裁定要重看: " + asAppendix);
+    }
+
+    /** 七个类别派生出的组合与 §0.3b 表逐条相等({@code 1-1 / 1-2 / 1-3 / 2-1 / 2-2 / 2-3 / 3-4})。 */
+    @Test
+    void category_neverProducesUnlistedCombination() {
+        for (LsyMerchantCategory c : LsyMerchantCategory.values()) {
+            assertEquals(EXPECTED_PAIRS.get(c), c.getMerchantType() + "-" + c.getAccountType(),
+                    c + " 派生出的组合与 §0.3b 的修正表不符");
+        }
+    }
+
+    /** {@code of} 精确匹配两种「类别名」,匹配不到(含空白、码值、大小写不符)返回 {@code null}、不抛。 */
+    @Test
+    void category_of_acceptsTheTwoDocumentedNames_elseNull() {
+        for (LsyMerchantCategory c : LsyMerchantCategory.values()) {
+            assertSame(c, LsyMerchantCategory.of(c.name()), "枚举常量名应当能解出 " + c);
+            assertSame(c, LsyMerchantCategory.of(c.getLabel()), "附表列名的中文应当能解出 " + c);
+        }
+        assertNull(LsyMerchantCategory.of(null), "null 返回 null,不抛");
+        assertNull(LsyMerchantCategory.of(""), "空串返回 null,不抛");
+        assertNull(LsyMerchantCategory.of("   "), "全空白返回 null(判空白照 StrUtil.isBlank)");
+        assertNull(LsyMerchantCategory.of("enterprise_public"), "大小写敏感,精确匹配");
+        assertNull(LsyMerchantCategory.of("企业对公 "), "不做 trim —— 精确匹配,带空格就是匹配不到");
+        assertNull(LsyMerchantCategory.of("1"), "两个码值不是类别名:merchant_type=1 同时是三类商户");
+        assertNull(LsyMerchantCategory.of("1-1"), "不是「码值对」的格式,本类不认这种入参");
+    }
+
+    // ==================================================================================
+    // 必填矩阵
+    // ==================================================================================
+
+    /**
+     * 空白 DTO 下,缺失清单**集合相等**于该类别在附表里那一列(十八个无条件 + 该列独有的 √)。
+     *
+     * <p>逐类别跑,不写个数。
+     */
+    @Test
+    void missingRequiredFields_listsExactSet_whenAllBlank() {
+        for (LsyMerchantCategory c : LsyMerchantCategory.values()) {
+            Set<String> expected = expectedFor(c);
+            List<String> missing = LsyMerchantRequiredFields.missingRequiredFields(blank(c));
+
+            assertEquals(expected.size(), missing.size(),
+                    c + ":缺失清单有重复项(清单是「每个字段最多一次」的)。实测: " + missing);
+            assertEquals(expected, new HashSet<>(missing),
+                    c + ":缺失清单与附表该列的 √ 不一致。差集(期望−实测)="
+                            + minus(expected, missing) + " 差集(实测−期望)=" + minus(new HashSet<>(missing), expected));
+
+            // 同一期望的另一种表述:brief 说的 UNCONDITIONAL ∪ BY_CATEGORY[该类别]。
+            // 两个方向都断言,是为了让「生产表 vs 附表抄本」的差异一定落在某一条上。
+            Set<String> fromConstants = new LinkedHashSet<>(LsyMerchantRequiredFields.UNCONDITIONAL);
+            fromConstants.addAll(LsyMerchantRequiredFields.BY_CATEGORY.get(c));
+            assertEquals(expected, fromConstants,
+                    c + ":UNCONDITIONAL ∪ BY_CATEGORY[该类别] 与附表逐格抄本不一致 —— 差异处就是抄错的那一格");
+        }
+    }
+
+    /** 齐备时为空。★ 先把**矩阵可能报出的每一个**字段都填上,再对七类各断一次。 */
+    @Test
+    void missingRequiredFields_isEmpty_whenAllRequiredSet() throws Exception {
+        for (LsyMerchantCategory c : LsyMerchantCategory.values()) {
+            LsyMerchantCreateDTO dto = blank(c);
+            for (String p : everyMatrixField()) {
+                setter(p).invoke(dto, "填了");
+            }
+            assertEquals(List.of(), LsyMerchantRequiredFields.missingRequiredFields(dto),
+                    c + ":三十六个字段全填了,缺失清单必须为空");
+        }
+    }
+
+    /**
+     * ★ 把**每一个**矩阵字段单独留空(其余全填),缺失清单必须恰好是它(该类别要它)或空(不要它)。
+     *
+     * <p>【为什么非有这条不可 —— 它是实测出来的缺口,不是预防性加的】把「全空」与「全填」两条
+     * 摆在一起看:全空时每个取值器都读到 {@code null}、全填时每个取值器都读到非空,
+     * <b>两种读法下,把一个字段的取值器接到另一个字段上都照样是绿的</b>(实测:把
+     * {@code picture_idcard_a} 的取值器改指 {@code getPictureIdcardB},其余 21 条全绿)。
+     * 而那个缺陷的形态正是最难查的一种:操作者填了 A、系统报 B 缺,A 的缺席被放行 ——
+     * 键名与中文标签都对得上,只有取值那一步接错了。
+     */
+    @Test
+    void missingRequiredFields_reportsExactlyTheBlankedField_forEachMatrixField() throws Exception {
+        for (String blanked : everyMatrixField()) {
+            for (LsyMerchantCategory c : LsyMerchantCategory.values()) {
+                LsyMerchantCreateDTO dto = blank(c);
+                for (String other : everyMatrixField()) {
+                    if (!other.equals(blanked)) {
+                        setter(other).invoke(dto, "填了");
+                    }
+                }
+                List<String> expected = expectedFor(c).contains(blanked) ? List.of(blanked) : List.of();
+                assertEquals(expected, LsyMerchantRequiredFields.missingRequiredFields(dto),
+                        c + ":只把 " + blanked + " 留空时,缺失清单应当恰好是它(或空)。"
+                                + "拿到别的字段名,就说明这个字段的取值器接到了别的字段上");
+            }
+        }
+    }
+
+    /** ★ 四层分工恰好覆盖 §0.2 的 27 个,且两两不相交(「无重叠无遗漏」)。 */
+    @Test
+    void requiredFieldAccounting_isExactly27() {
+        Set<String> union = new LinkedHashSet<>();
+        union.addAll(ENVELOPE_4);
+        union.addAll(CONFIG_3);
+        union.addAll(DERIVED_2);
+        union.addAll(LsyMerchantRequiredFields.UNCONDITIONAL);
+
+        assertEquals(ALL_27, union,
+                "信封4 ∪ 配置3 ∪ 类别派生2 ∪ UNCONDITIONAL 必须恰好等于 §0.2 的 27 个。"
+                        + "少=" + minus(ALL_27, union) + " 多=" + minus(union, ALL_27));
+
+        List<Set<String>> layers = new ArrayList<>(List.of(
+                ENVELOPE_4, CONFIG_3, DERIVED_2, LsyMerchantRequiredFields.UNCONDITIONAL));
+        for (int i = 0; i < layers.size(); i++) {
+            for (int j = i + 1; j < layers.size(); j++) {
+                Set<String> overlap = new HashSet<>(layers.get(i));
+                overlap.retainAll(layers.get(j));
+                assertTrue(overlap.isEmpty(),
+                        "第 " + (i + 1) + " 层与第 " + (j + 1) + " 层重叠: " + overlap
+                                + " —— 重叠会让「四层加起来正好 27」在漏掉别处一格时照样成立");
+            }
+        }
+        assertEquals(18, LsyMerchantRequiredFields.UNCONDITIONAL.size(),
+                "UNCONDITIONAL 是 §0.2 那 27 个里 DTO 承担的 18 个(不是 23、不是 27)");
+    }
+
+    // ---- §0.3 附表逐格 ----
+
+    /** 小微不要求营业执照与法人三件套(附表对小微那三列留空)。 */
+    @Test
+    void missingRequiredFields_doesNotRequireLicenseFields_forXiaowei() {
+        for (String p : List.of("license_type", "license_no", "license_expire",
+                "legal_name", "legal_cert_no", "legal_cert_expire")) {
+            assertFalse(requires(MICRO, p), "小微不该要求 " + p + "(附表「小微商户」那一列留空)");
+        }
+    }
+
+    /** 企业对公要求 {@code license_type}/{@code license_no}/{@code license_expire}:参数表留空、**附表要求**。 */
+    @Test
+    void missingRequiredFields_requiresLicenseFields_forEnterprise() {
+        for (String p : List.of("license_type", "license_no", "license_expire")) {
+            assertTrue(requires(ENTERPRISE_PUBLIC, p),
+                    "企业对公必须要求 " + p + " —— 参数表「必填」列只有 是/空/C 三态,"
+                            + "没有「按类型」这一态,其「空」只能读成「不无条件必填」");
+        }
+    }
+
+    /** 要法人三件套的**恰是** {@code (1,1)/(1,2)/(1,3)} 与 {@code (2,3)}。 */
+    @Test
+    void missingRequiredFields_requiresLegalFields_forLegalPersonCategoriesOnly() {
+        Set<LsyMerchantCategory> legalPerson = Set.of(
+                ENTERPRISE_PUBLIC, ENTERPRISE_PRIVATE_LEGAL, ENTERPRISE_PRIVATE_NON_LEGAL,
+                INDIVIDUAL_PRIVATE_NON_LEGAL);
+        for (LsyMerchantCategory c : LsyMerchantCategory.values()) {
+            for (String p : List.of("legal_name", "legal_cert_no", "legal_cert_expire")) {
+                assertEquals(legalPerson.contains(c), requires(c, p),
+                        c + " 对 " + p + " 的要求与附表 178-180 行不符(个体对私法人那一列留空、"
+                                + "个体对私非法人打 √ —— 两列在附表里要求不同,靠 §0.3b 的修正键才分得开)");
+            }
+        }
+    }
+
+    /** ★ 附表的不对称照抄不修:小微要 {@code picture_idcard_a}、不要 {@code picture_idcard_b}。 */
+    @Test
+    void missingRequiredFields_requiresPictureIdcardAForXiaowei_butNotB() {
+        assertTrue(requires(MICRO, "picture_idcard_a"), "附表对小微这一格打了 √");
+        assertFalse(requires(MICRO, "picture_idcard_b"),
+                "附表对小微这一格留空。★ 这处不对称是**照抄**的,不要「顺手补全」—— 补全就是发明原文没有的规则");
+    }
+
+    /** 七类都要 {@code picture_outside}/{@code picture_inside}。 */
+    @Test
+    void missingRequiredFields_requiresPictureOutsideAndInsideForAllSeven() {
+        for (LsyMerchantCategory c : LsyMerchantCategory.values()) {
+            assertTrue(requires(c, "picture_outside"), c + " 要 picture_outside(附表该行七列全 √)");
+            assertTrue(requires(c, "picture_inside"), c + " 要 picture_inside(附表该行七列全 √)");
+        }
+    }
+
+    /** 要 {@code picture_unincorporated} 的**恰是** {@code accountType == 3} 那两类。 */
+    @Test
+    void missingRequiredFields_requiresPictureUnincorporated_forAccountType3Only() {
+        for (LsyMerchantCategory c : LsyMerchantCategory.values()) {
+            assertEquals("3".equals(c.getAccountType()), requires(c, "picture_unincorporated"),
+                    c + "(accountType=" + c.getAccountType() + ")对 picture_unincorporated 的要求与附表不符"
+                            + " —— 这一格正是「照抄附表就表达不出来」的那一格");
+        }
+    }
+
+    // ---- 「不收」的几族:逐个方向都要有钉子 ----
+
+    /** 附表里被划掉的 {@code settlement_type} **不在**必填集合里(不实现作废字段)。 */
+    @Test
+    void missingRequiredFields_ignoresSettlementType() {
+        assertNotRequiredByAnyCategory("settlement_type",
+                "附表里 settlement_type 被划掉(作废),且参数表根本没有这个字段 ⇒ 不实现");
+    }
+
+    /** 三个 {@code *_stt}:参数表「必填」列留空、附表完全没有、说明栏也没写通道条件 ⇒ 不必填。 */
+    @Test
+    void missingRequiredFields_ignoresSttFields() {
+        for (String p : List.of("license_stt", "legal_cert_stt", "account_idcard_stt")) {
+            assertNotRequiredByAnyCategory(p, "§0.3c:" + p + " 在附表里没有行,参数表必填列也是空的");
+        }
+    }
+
+    /** ★ §0.3c:附表没列的那 8 个**全部不在**必填集合里(三个 {@code *_stt} + 五个其他)。 */
+    @Test
+    void missingRequiredFields_ignoresTheEightAppendixMissingFields() {
+        for (String p : List.of("license_stt", "legal_cert_stt", "account_idcard_stt",
+                "lease_contract", "p_cashier",
+                "share_scene", "share_certificate", "tripartite_flow_certificate_url")) {
+            assertNotRequiredByAnyCategory(p,
+                    "§0.3c:附表没有这一行 ⇒ 不是「按商户类型」的条件;它们仍进 DTO(要能上送)");
+        }
+    }
+
+    /**
+     * ★ §0.3d:五个 {@code 必填=C} 的字段,以及附表里从未打 √ 的那几个 {@code picture_*},
+     * 都**不进**必填集合。
+     *
+     * <p>条件写的是「平安收银宝必填」「星驿付必填」这类**按通道**的话,而通道是配置项、
+     * 此刻未知 ⇒ 条件无法求值。**不收 ≠ 放行**:字段照常进 DTO、照常上送。
+     */
+    @Test
+    void missingRequiredFields_ignoresChannelConditionalFields() {
+        for (String p : List.of("reg_capital", "fSettle_account_type", "actual_addr",
+                "business_scope", "check_code")) {
+            assertNotRequiredByAnyCategory(p,
+                    "§0.3d:必填=C,条件是「按通道」而通道此刻未知 ⇒ 不收(字段仍进 DTO、仍上送)");
+        }
+        for (String p : List.of("picture_tripartite", "picture_organization",
+                "picture_tax_registration", "picture_other")) {
+            assertNotRequiredByAnyCategory(p,
+                    "这几个 picture_* 在附表里七列全空(picture_organization/tax_registration 是 "
+                            + "「当且仅当 license_type=0」的 '-',不是 √)⇒ 不进矩阵");
+        }
+    }
+
+    // ---- 类别为空 ----
+
+    /**
+     * 类别为空时,两个**由类别派生**的必填项({@code merchant_type}/{@code account_type})填不出来
+     * ⇒ 直接进缺失清单;与类别无关的那 18 个照常逐个体检(一次列全,理由同
+     * {@code LeshouyingProperties.validate})。
+     *
+     * <p>不这样处理的话,{@code BY_CATEGORY.get(null)} 会把一个**可达的**请求(请求体里没带类别)
+     * 变成 500,而它应当是一条 400 的必填提示。
+     */
+    @Test
+    void missingRequiredFields_categoryNull_reportsTheTwoDerivedFields() {
+        LsyMerchantCreateDTO dto = new LsyMerchantCreateDTO();
+        assertNull(dto.getCategory(), "本用例的前提:类别未设");
+
+        Set<String> expected = new LinkedHashSet<>(LsyMerchantRequiredFields.UNCONDITIONAL);
+        expected.add("merchant_type");
+        expected.add("account_type");
+
+        List<String> missing = LsyMerchantRequiredFields.missingRequiredFields(dto);
+        assertEquals(expected.size(), missing.size(), "不该有重复项。实测: " + missing);
+        assertEquals(expected, new HashSet<>(missing),
+                "类别为空时缺失清单应为「18 个无条件 + 两个派生」");
+    }
+
+    // ==================================================================================
+    // DTO:字段名与承重面
+    // ==================================================================================
+
+    /**
+     * DTO 的 Java 字段名是上游参数名的**机械驼峰**(按下划线切段、后续段首字母大写),
+     * 且矩阵可能报出的**每一个**字段在 DTO 上都有读写口。
+     *
+     * <p>这条钉的是组装层能照同一条规则写键名常量,不必逐个人肉对照 —— 唯一的例外
+     * ({@code fSettle_account_type},段内自带大写)不在矩阵里,见 DTO 类 javadoc。
+     */
+    @Test
+    void dtoCarriesEveryFieldTheMatrixCanReport_underMechanicalCamelCaseName() {
+        for (String p : everyMatrixField()) {
+            assertNotNull(getter(p), "LsyMerchantCreateDTO 缺少 " + camel(p) + "() 的 getter"
+                    + "(上游 " + p + ")—— 缺失清单要读它");
+            assertNotNull(setter(p), "LsyMerchantCreateDTO 缺少 " + camel(p) + " 的 setter"
+                    + "(上游 " + p + ")—— 调用方要填它");
+        }
+    }
+
+    // ==================================================================================
+    // LABELS
+    // ==================================================================================
+
+    /** 矩阵可能报出的每个字段都必须有中文标签 —— 少一个,Task 3 就会把 {@code null} 拼进对外文案。 */
+    @Test
+    void LABELS_definesEveryFieldTheMatrixCanReport() {
+        for (String p : everyMatrixField()) {
+            String label = LsyMerchantRequiredFields.LABELS.get(p);
+            assertNotNull(label, "LABELS 缺少 " + p + " —— 缺了它,缺失清单里这一项会渲染成 null");
+            assertFalse(label.isBlank(), "LABELS[" + p + "] 是空白标签");
+        }
+        for (String p : List.of("merchant_type", "account_type")) {
+            assertNotNull(LsyMerchantRequiredFields.LABELS.get(p),
+                    "LABELS 缺少 " + p + " —— 类别为空时缺失清单里会有它");
+        }
+    }
+
+    /** LABELS 覆盖 page 196236555 请求参数表的**全部 62 个**参数(不多不少)。 */
+    @Test
+    void LABELS_transcribesEveryParameterOfTheCreatePage() {
+        assertEquals(PARAM_TABLE_62, LsyMerchantRequiredFields.LABELS.keySet(),
+                "LABELS 的键集应当是创建商户页请求参数表的全部参数名。少="
+                        + minus(PARAM_TABLE_62, LsyMerchantRequiredFields.LABELS.keySet())
+                        + " 多=" + minus(LsyMerchantRequiredFields.LABELS.keySet(), PARAM_TABLE_62));
+    }
+
+    /** 标签**逐字**取参数表的「说明」列,不做改写、不裁掉上游自带的格式提示。 */
+    @Test
+    void LABELS_matchesTheDescriptionColumn_forSpotCheckedFields() {
+        assertLabel("merchant_name", "商户名称");
+        assertLabel("account_bank_code", "结算银行支行联行号");
+        assertLabel("fSettle_account_type", "结算账户类型");
+        assertLabel("picture_bank_idcard_a", "对私账户身份证正面照片");
+        assertLabel("merchant_name_business", "商户注册名称 小微填姓名,个体企业填营业执照");
+        assertLabel("account_idcard_expire", "持卡人身份证结束时间(格式YYYYMMDD)长期传20991231");
+    }
+
+    // ==================================================================================
+    // 工具
+    // ==================================================================================
+
+    private static LsyMerchantCreateDTO blank(LsyMerchantCategory category) {
+        LsyMerchantCreateDTO dto = new LsyMerchantCreateDTO();
+        dto.setCategory(category);
+        return dto;
+    }
+
+    /** 附表该列的 √:十八个无条件 ∪ 该列独有的 √。 */
+    private static Set<String> expectedFor(LsyMerchantCategory c) {
+        Set<String> s = new LinkedHashSet<>(DTO_18);
+        s.addAll(APPENDIX_EXTRA.get(c));
+        return s;
+    }
+
+    /** 矩阵**可能报出**的全部字段(36 个),按参数表顺序。 */
+    private static Set<String> everyMatrixField() {
+        Set<String> s = new LinkedHashSet<>(DTO_18);
+        for (Set<String> extra : APPENDIX_EXTRA.values()) {
+            s.addAll(extra);
+        }
+        return s;
+    }
+
+    private static boolean requires(LsyMerchantCategory c, String param) {
+        return LsyMerchantRequiredFields.missingRequiredFields(blank(c)).contains(param);
+    }
+
+    private static void assertNotRequiredByAnyCategory(String param, String why) {
+        for (LsyMerchantCategory c : LsyMerchantCategory.values()) {
+            assertFalse(requires(c, param), c + " 不该要求 " + param + "。" + why);
+        }
+    }
+
+    private static void assertLabel(String param, String expected) {
+        assertEquals(expected, LsyMerchantRequiredFields.LABELS.get(param),
+                "LABELS[" + param + "] 与参数表「说明」列不逐字相等");
+    }
+
+    /** 上游参数名 → DTO 的 Java 字段名:按下划线切段,首段原样、后续段首字母大写。 */
+    private static String camel(String param) {
+        String[] segs = param.split("_");
+        StringBuilder b = new StringBuilder(segs[0]);
+        for (int i = 1; i < segs.length; i++) {
+            b.append(Character.toUpperCase(segs[i].charAt(0))).append(segs[i].substring(1));
+        }
+        return b.toString();
+    }
+
+    private static Method getter(String param) {
+        String n = camel(param);
+        return method("get" + Character.toUpperCase(n.charAt(0)) + n.substring(1));
+    }
+
+    private static Method setter(String param) {
+        String n = camel(param);
+        return method("set" + Character.toUpperCase(n.charAt(0)) + n.substring(1), String.class);
+    }
+
+    private static Method method(String name, Class<?>... params) {
+        try {
+            return LsyMerchantCreateDTO.class.getMethod(name, params);
+        } catch (NoSuchMethodException e) {
+            return null;
+        }
+    }
+
+    private static <T> Set<T> minus(Set<T> a, java.util.Collection<T> b) {
+        Set<T> s = new LinkedHashSet<>(a);
+        s.removeAll(new HashSet<>(b));
+        return s;
+    }
+
+    /** page 196236555 请求参数表的全部 62 个参数名,逐字。 */
+    private static final Set<String> PARAM_TABLE_62 = Set.of(
+            "inst_no", "trace_no", "sign", "notify_url",
+            "merchant_name", "merchant_name_short", "merchant_name_business", "merchant_mcc_code",
+            "channel_id", "bd_id",
+            "contact_name", "contact_phone", "contact_email", "contact_service",
+            "address_province", "address_city", "address_county", "address_street",
+            "merchant_type", "license_type", "license_no", "license_stt", "license_expire",
+            "account_type", "legal_name", "legal_cert_no", "legal_cert_stt", "legal_cert_expire",
+            "account_idcard_num", "account_idcard_stt", "account_idcard_expire",
+            "account_card_name", "account_card_num", "account_card_phone", "account_bank_code",
+            "reg_capital", "fSettle_account_type", "actual_addr", "business_scope", "check_code",
+            "rate_code",
+            "picture_license", "picture_idcard_a", "picture_idcard_b",
+            "picture_bankcard_a", "picture_bankcard_b", "picture_outside", "picture_inside",
+            "lease_contract", "picture_tripartite", "picture_idcard_hander",
+            "picture_open_account", "picture_organization", "picture_tax_registration",
+            "picture_unincorporated", "picture_bank_idcard_a", "picture_bank_idcard_b",
+            "picture_other", "share_scene", "share_certificate",
+            "tripartite_flow_certificate_url", "p_cashier");
+}