|
|
@@ -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");
|
|
|
+}
|