Bläddra i källkod

feat(aiclean): 表头打分加入同义词/形状/必填覆盖,收紧出口 A 闸门

- 打分改为 表头集合 55% + 字段名 15% + 列序 10% + 形状 10% + 必填覆盖 10%
- 同义词归一(户名/账户名称、发生额/交易金额、交易日期/日期、卡号/账号、电话/手机等),
  只用于评分,不参与指纹,避免指纹碰撞
- 短模板(≤3 列)要求 ≥0.90;出口 A 必须确定性分数、必填覆盖、形状不矛盾同时通过,
  模型自评置信度不再能单独把低分候选写进用户既有业务表
- 修复 Jaccard 重复列撑大分母的问题

测试:aiclean + dm 191 个用例通过(5 个 live 跳过)
cc 2 dagar sedan
förälder
incheckning
a05ac3461c

+ 21 - 9
ai-server/src/main/java/com/zsjz/ai/module/aiclean/match/AiTemplateMatcher.java

@@ -8,6 +8,7 @@ import com.zsjz.ai.module.aiclean.enums.AiMatchTierEnum;
 import com.zsjz.ai.module.aiclean.index.AiCleanTemplateVectorIndex;
 import com.zsjz.ai.module.aiclean.model.AiMatchOutcome;
 import com.zsjz.ai.module.aiclean.model.AiRuleDraft;
+import com.zsjz.ai.module.aiclean.model.ColStat;
 import com.zsjz.ai.module.aiclean.model.RuleBind;
 import com.zsjz.ai.module.aiclean.model.SheetEvidence;
 import com.zsjz.ai.module.aiclean.model.TemplateCandidate;
@@ -116,12 +117,13 @@ public class AiTemplateMatcher {
         }
         // record 不可变,重打分必须整表重建 —— 在 for 里给循环变量赋值不会写回列表
         List<TemplateCandidate> scored = candidates.stream()
-                .map(c -> withScore(c, HeaderScorer.score(headers, c)))
+                .map(c -> withScore(c, HeaderScorer.score(headers, c, ev.colStats())))
                 .sorted(Comparator.comparingDouble(TemplateCandidate::score).reversed()).toList();
         TemplateCandidate best = scored.isEmpty() ? null : scored.getFirst();
-        if (best != null && best.score() >= HeaderScorer.STRONG) {
+        if (best != null && HeaderScorer.passesStrong(headers, best, best.score(), ev.colStats())) {
             // 这一腿是确定性名字匹配,不掺模型猜测,所以允许直接出口 A
-            return outcome(AiMatchTierEnum.STRONG, exitOf(AiMatchTierEnum.STRONG, best),
+            return outcome(AiMatchTierEnum.STRONG,
+                    exitOf(AiMatchTierEnum.STRONG, best, best.score(), headers, ev.colStats()),
                     best, scored, AiMatchOutcome.BY_SCORE, md5, false, null, null);
         }
 
@@ -162,10 +164,12 @@ public class AiTemplateMatcher {
         double conf = draft.getConfidence() == null ? 0d : draft.getConfidence();
         // 模型自己承认在猜(低置信)时不许升级成 STRONG,也就进不了出口 A
         AiMatchTierEnum tier = conf >= LLM_MIN_CONFIDENCE ? AiMatchTierEnum.STRONG : AiMatchTierEnum.WEAK;
-        log.info("AI 匹配第三腿:选中 #{} {},自评置信 {},判档 {}",
-                draft.getTemplateNo(), picked.nameCn(), conf, tier);
-        return outcome(tier, exitOf(tier, picked), picked, ranked, AiMatchOutcome.BY_LLM, md5, true,
-                draft, answer.usage());
+        // 出口 A 仍然必须过确定性分数 + 必填覆盖:模型自评高不能把 0 分候选写进用户业务表
+        double deterministic = HeaderScorer.score(headers, picked, ev.colStats());
+        log.info("AI 匹配第三腿:选中 #{} {},自评置信 {},确定性分 {},判档 {}",
+                draft.getTemplateNo(), picked.nameCn(), conf, deterministic, tier);
+        return outcome(tier, exitOf(tier, picked, deterministic, headers, ev.colStats()), picked, ranked,
+                AiMatchOutcome.BY_LLM, md5, true, draft, answer.usage());
     }
 
     /**
@@ -324,11 +328,19 @@ public class AiTemplateMatcher {
      * 而既有清理只能按文件维度物理删除,判错一次的代价远高于让 AI 自己建表。
      * 模型失焦时确实发生过它挑了不相干模板还自评 0.3 的情况,这条规则就是为那次准备的。
      */
-    private static AiCleanExitEnum exitOf(AiMatchTierEnum tier, TemplateCandidate c) {
+    private static AiCleanExitEnum exitOf(AiMatchTierEnum tier, TemplateCandidate c,
+                                          double deterministicScore, List<String> headers,
+                                          List<ColStat> colStats) {
         if (c.fromAiLib()) {
             return AiCleanExitEnum.B;
         }
-        return tier == AiMatchTierEnum.STRONG ? AiCleanExitEnum.A : AiCleanExitEnum.B;
+        // 出口 A 会直接写用户既有业务表,必须同时满足:档位 STRONG、确定性分数过线、
+        // 必填覆盖、形状不矛盾。
+        boolean strong = tier == AiMatchTierEnum.STRONG
+                && deterministicScore >= HeaderScorer.STRONG
+                && HeaderScorer.requiredCovered(headers, c)
+                && HeaderScorer.shapeSafe(headers, c, colStats);
+        return strong ? AiCleanExitEnum.A : AiCleanExitEnum.B;
     }
 
     private static AiMatchOutcome outcome(AiMatchTierEnum tier, AiCleanExitEnum exit, TemplateCandidate cand,

+ 243 - 23
ai-server/src/main/java/com/zsjz/ai/module/aiclean/match/HeaderScorer.java

@@ -1,64 +1,284 @@
 package com.zsjz.ai.module.aiclean.match;
 
+import cn.hutool.core.util.StrUtil;
+import com.zsjz.ai.module.aiclean.model.ColStat;
 import com.zsjz.ai.module.aiclean.model.TemplateCandidate;
 import com.zsjz.ai.module.aiclean.support.HeaderFingerprint;
 import com.zsjz.ai.module.aiclean.support.HeaderNormalizer;
 
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
 
 /**
  * 归一化后的表头打分。
  *
  * <p>与既有打分的区别:既有是「交集数 + 必填数」的<b>绝对值</b>(PreDataListener:386),
  * 模板字段越多越容易得高分,且没有阈值概念 —— 分不清「像」和「就是」。
- * 这里换成两个归一化比值相加:
+ *
+ * <p>2026-10-22 起不再只看表头集合:
  * <ul>
- *   <li>覆盖率:命中了模板里多少比例的字段(防漏);</li>
- *   <li>Jaccard:两边列名整体有多重合(防串)。</li>
+ *   <li>表头集合:覆盖率 + Jaccard(修复重复列把分母撑大的问题);</li>
+ *   <li>字段名:候选的字段中文名/英文名是否出现在文件表头里(同义改写场景);</li>
+ *   <li>列序:同序加分,乱序只给弱分(但不会因此丢掉候选);</li>
+ *   <li>形状:{@link ColStat#shape()} 与候选字段类型是否矛盾;</li>
+ *   <li>必填覆盖:候选的 required 字段是否都能在文件表头里找到。</li>
  * </ul>
- * 于是分数落在 0~1,档位阈值才有意义。
+ * 分数落在 0~1,出口 A 还必须额外通过「确定性分数 + 必填覆盖」闸门,
+ * 模型自评置信度不能单独把候选升进出口 A。
  *
  * @author cc
  * @since 2026/9/21
  */
 public final class HeaderScorer {
 
-    /** 强匹配线:必填全覆盖之外,整体分数还要过这道 */
-    public static final double STRONG = 0.90;
+    /** 强匹配线:确定性分数过这道,且必填覆盖通过,才允许出口 A */
+    public static final double STRONG = 0.85;
+
+    /** 弱匹配线:低于它说明表头几乎没有共同点 */
+    public static final double WEAK = 0.55;
 
-    /** 弱匹配线:低于它直接不匹配(走出口 B) */
-    public static final double WEAK = 0.50;
+    /** 常见同义表头(只做评分归一,不参与指纹,避免指纹碰撞) */
+    private static final Map<String, String> SYNONYMS = Map.ofEntries(
+            Map.entry("账户名称", "户名"),
+            Map.entry("客户名称", "户名"),
+            Map.entry("户名", "户名"),
+            Map.entry("姓名", "户名"),
+            Map.entry("发生额", "交易金额"),
+            Map.entry("交易额", "交易金额"),
+            Map.entry("金额", "交易金额"),
+            Map.entry("交易日期", "日期"),
+            Map.entry("日期", "日期"),
+            Map.entry("交易时间", "时间"),
+            Map.entry("时间", "时间"),
+            Map.entry("卡号", "账号"),
+            Map.entry("银行账号", "账号"),
+            Map.entry("账号", "账号"),
+            Map.entry("账户", "账号"),
+            Map.entry("手机号", "电话"),
+            Map.entry("手机", "电话"),
+            Map.entry("联系电话", "电话"),
+            Map.entry("电话", "电话"),
+            Map.entry("身份证", "身份证号"),
+            Map.entry("身份证号码", "身份证号"),
+            Map.entry("证件号码", "身份证号"),
+            Map.entry("身份证号", "身份证号"));
 
     private HeaderScorer() {
     }
 
+    public static double score(List<String> headers, TemplateCandidate cand) {
+        return score(headers, cand, List.of());
+    }
+
     /**
-     * @param headers 文件表头(原始)
-     * @param cand    候选模板
+     * @param headers  文件表头(原始)
+     * @param cand     候选模板
+     * @param colStats 文件列统计(主块),可为空;用于形状兼容性
      * @return 0~1 的分数
      */
-    public static double score(List<String> headers, TemplateCandidate cand) {
-        List<String> fileCols = HeaderNormalizer.normalizeAll(nonBlank(headers));
-        List<String> tplCols = HeaderNormalizer.normalizeAll(nonBlank(cand.headers()));
-        if (fileCols.isEmpty() || tplCols.isEmpty()) {
+    public static double score(List<String> headers, TemplateCandidate cand, List<ColStat> colStats) {
+        List<String> fileNames = canonicalNames(headers);
+        List<String> tplNames = canonicalNames(cand.headers());
+        if (fileNames.isEmpty() || tplNames.isEmpty()) {
             return 0d;
         }
-        long hit = fileCols.stream().distinct().filter(tplCols::contains).count();
-        double coverage = (double) hit / tplCols.size();
-        double jaccard = HeaderFingerprint.jaccard(fileCols, tplCols);
-        return 0.7 * Math.min(1d, coverage) + 0.3 * jaccard;
+        Set<String> fileSet = new HashSet<>(fileNames);
+        Set<String> tplSet = new HashSet<>(tplNames);
+        long hit = fileSet.stream().filter(tplSet::contains).count();
+        double coverage = (double) hit / tplSet.size();
+        double jaccard = HeaderFingerprint.jaccard(fileSet, tplSet);
+        double header = 0.7 * Math.min(1d, coverage) + 0.3 * jaccard;
+
+        double field = fieldCoverage(fileSet, cand.fields());
+        double order = orderScore(fileNames, tplNames);
+        double shape = shapeScore(headers, cand.fields(), colStats);
+        double required = requiredCoverage(fileSet, cand.fields());
+        return 0.55 * header + 0.15 * field + 0.10 * order + 0.10 * shape + 0.10 * required;
+    }
+
+    /**
+     * 出口 A 的确定性闸门:短模板要求更高分,且必填字段必须都能在文件表头里找到;
+     * 明显的类型矛盾(如 DATE 字段对上一列日期形状为金额)也不许出口 A。
+     */
+    public static boolean passesStrong(List<String> headers, TemplateCandidate cand, double score,
+                                       List<ColStat> colStats) {
+        if (!requiredCovered(headers, cand)) {
+            return false;
+        }
+        if (!shapeSafe(headers, cand, colStats)) {
+            return false;
+        }
+        int cols = canonicalNames(cand.headers()).size();
+        return cols <= 3 ? score >= 0.90 : score >= STRONG;
+    }
+
+    /** 形状与字段类型没有明显矛盾;没有样本/形状为 TEXT 时视为安全 */
+    public static boolean shapeSafe(List<String> headers, TemplateCandidate cand, List<ColStat> colStats) {
+        return shapeScore(headers, cand.fields(), colStats) >= 0.5;
+    }
+
+    /** 候选里所有 required 字段是否都能在文件表头中找到 */
+    public static boolean requiredCovered(List<String> headers, TemplateCandidate cand) {
+        Set<String> fileSet = new HashSet<>(canonicalNames(headers));
+        return requiredCoverage(fileSet, cand.fields()) >= 1d;
     }
 
     /**
      * 模板里有多少列在文件表头中出现了(用于「必填全覆盖」判断)。
      */
     public static long matchedCount(List<String> headers, List<String> needCols) {
-        List<String> fileCols = HeaderNormalizer.normalizeAll(nonBlank(headers));
-        return nonBlank(needCols).stream().map(HeaderNormalizer::normalize)
-                .filter(fileCols::contains).count();
+        Set<String> fileSet = new HashSet<>(canonicalNames(headers));
+        return canonicalNames(needCols).stream().filter(fileSet::contains).count();
+    }
+
+    /**
+     * 评分归一:去括号/空白/后缀 + 同义词映射。指纹仍用 {@link HeaderNormalizer#normalize},
+     * 不受同义词影响,避免不同表头被折叠成同一个 md5。
+     */
+    static String canonicalForScore(String raw) {
+        String n = HeaderNormalizer.normalize(raw);
+        return SYNONYMS.getOrDefault(n, n);
     }
 
-    private static List<String> nonBlank(List<String> in) {
-        return in == null ? List.of() : in.stream().filter(s -> s != null && !s.isBlank()).toList();
+    private static List<String> canonicalNames(List<String> headers) {
+        if (headers == null) {
+            return List.of();
+        }
+        List<String> out = new ArrayList<>(headers.size());
+        for (String h : headers) {
+            String n = canonicalForScore(h);
+            if (!n.isEmpty()) {
+                out.add(n);
+            }
+        }
+        return out;
+    }
+
+    private static double fieldCoverage(Set<String> fileSet, List<TemplateCandidate.FieldBrief> fields) {
+        if (fields == null || fields.isEmpty()) {
+            // 历史无字段模板:不给正向加分,也不重罚
+            return 0.5d;
+        }
+        int hit = 0;
+        for (TemplateCandidate.FieldBrief f : fields) {
+            if (f == null) {
+                continue;
+            }
+            String cn = canonicalForScore(f.cn());
+            String en = canonicalForScore(f.en());
+            if ((!cn.isEmpty() && fileSet.contains(cn)) || (!en.isEmpty() && fileSet.contains(en))) {
+                hit++;
+            }
+        }
+        return (double) hit / fields.size();
+    }
+
+    private static double orderScore(List<String> fileName, List<String> tplNames) {
+        int last = -1;
+        int found = 0;
+        boolean ordered = true;
+        for (String t : tplNames) {
+            int idx = fileName.indexOf(t);
+            if (idx < 0) {
+                ordered = false;
+                continue;
+            }
+            found++;
+            if (idx <= last) {
+                ordered = false;
+            }
+            last = idx;
+        }
+        if (found == 0) {
+            return 0d;
+        }
+        return ordered ? 1d : 0.4d;
+    }
+
+    static double shapeScore(List<String> headers,
+                             List<TemplateCandidate.FieldBrief> fields, List<ColStat> colStats) {
+        if (fields == null || fields.isEmpty() || colStats == null || colStats.isEmpty()) {
+            return 0.5d;
+        }
+        Map<Integer, ColStat> statByIndex = new HashMap<>();
+        for (ColStat s : colStats) {
+            if (s != null) {
+                statByIndex.put(s.idx(), s);
+            }
+        }
+        int known = 0;
+        int compatible = 0;
+        for (TemplateCandidate.FieldBrief f : fields) {
+            if (f == null) {
+                continue;
+            }
+            int pos = indexOfHeader(headers, f);
+            if (pos < 0) {
+                continue;
+            }
+            ColStat stat = statByIndex.get(pos);
+            String shape = stat == null ? "" : StrUtil.nullToEmpty(stat.shape()).toUpperCase(Locale.ROOT);
+            if (shape.isEmpty() || "TEXT".equals(shape) || "EMPTY".equals(shape)) {
+                continue;
+            }
+            known++;
+            if (compatible(f.type(), shape)) {
+                compatible++;
+            }
+        }
+        return known == 0 ? 0.5d : (double) compatible / known;
+    }
+
+    private static int indexOfHeader(List<String> headers, TemplateCandidate.FieldBrief field) {
+        String cn = canonicalForScore(field.cn());
+        String en = canonicalForScore(field.en());
+        for (int i = 0; i < headers.size(); i++) {
+            String n = canonicalForScore(headers.get(i));
+            if (!n.isEmpty() && ((!cn.isEmpty() && n.equals(cn)) || (!en.isEmpty() && n.equals(en)))) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    private static double requiredCoverage(Set<String> fileSet, List<TemplateCandidate.FieldBrief> fields) {
+        if (fields == null || fields.isEmpty()) {
+            return 1d;
+        }
+        int required = 0;
+        int hit = 0;
+        for (TemplateCandidate.FieldBrief f : fields) {
+            if (f == null || !f.required()) {
+                continue;
+            }
+            required++;
+            String cn = canonicalForScore(f.cn());
+            String en = canonicalForScore(f.en());
+            if ((!cn.isEmpty() && fileSet.contains(cn)) || (!en.isEmpty() && fileSet.contains(en))) {
+                hit++;
+            }
+        }
+        return required == 0 ? 1d : (double) hit / required;
+    }
+
+    /** 形状与字段类型是否明显矛盾;不确定(TEXT/空)一律视为兼容 */
+    private static boolean compatible(String fieldType, String shape) {
+        String type = StrUtil.nullToEmpty(fieldType).trim().toUpperCase(Locale.ROOT);
+        if (type.isEmpty() || "TEXT".equals(type)) {
+            return true;
+        }
+        return switch (type) {
+            case "DATE", "DATE_TIME", "TIME" -> !Set.of("MONEY", "PHONE", "IDCARD", "ACCT").contains(shape);
+            case "MONEY", "NUMBER" -> !Set.of("DATE", "DATETIME", "TIME", "PHONE", "IDCARD", "ACCT").contains(shape);
+            // 电话/卡号/身份证可能被 Excel 存成数字,NUMBER 形状不判矛盾
+            case "PHONE", "ACCT_CARD_NO", "ID_CARD_NUMBER" ->
+                    !Set.of("MONEY", "DATE", "DATETIME", "TIME").contains(shape);
+            default -> true;
+        };
     }
 }

+ 4 - 3
ai-server/src/test/java/com/zsjz/ai/module/aiclean/match/AiTemplateMatcherTest.java

@@ -177,8 +177,8 @@ class AiTemplateMatcherTest {
     }
 
     @Test
-    @DisplayName("模型高置信命中既有模板 → STRONG + 出口 A")
-    void highConfidenceTakesExistingTable() {
+    @DisplayName("模型高置信但确定性分数为 0:不许出口 A,只走出口 B")
+    void highConfidenceCannotForceExistingTable() {
         stubOneCandidateLibrary();
         AiRuleDraft draft = new AiRuleDraft();
         draft.setTemplateNo(1);
@@ -187,7 +187,8 @@ class AiTemplateMatcherTest {
 
         var outcome = matcher.match(evidenceOf(List.of("字段甲", "字段乙")), List.of(), true);
         assertEquals(AiMatchTierEnum.STRONG, outcome.tier());
-        assertEquals(AiCleanExitEnum.A, outcome.exit());
+        assertEquals(AiCleanExitEnum.B, outcome.exit(),
+                "模型自评高不能单独决定出口 A;必须确定性分数 + 必填覆盖都过线");
     }
 
     @Test

+ 110 - 0
ai-server/src/test/java/com/zsjz/ai/module/aiclean/match/HeaderScorerTest.java

@@ -0,0 +1,110 @@
+package com.zsjz.ai.module.aiclean.match;
+
+import com.zsjz.ai.module.aiclean.model.ColStat;
+import com.zsjz.ai.module.aiclean.model.TemplateCandidate;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 表头打分与出口 A 闸门:同义词、列序、单位、形状、必填覆盖。
+ *
+ * <p>这些是确定性判据,模型判错的代价(写进用户既有业务表)不可逆,
+ * 所以阈值和组合条件必须用测试钉住。
+ *
+ * @author cc
+ * @since 2026/10/22
+ */
+class HeaderScorerTest {
+
+    private static TemplateCandidate.FieldBrief field(String cn, String en, String type, boolean required) {
+        return new TemplateCandidate.FieldBrief(cn, en, type, required, 0, null);
+    }
+
+    private static TemplateCandidate cand(List<String> headers, List<TemplateCandidate.FieldBrief> fields) {
+        return new TemplateCandidate(1, false, 77, null, 2, "测试模板", "t",
+                headers, fields, 0d);
+    }
+
+    private static ColStat stat(int idx, String shape) {
+        return new ColStat(idx, 1d, 10, 20, shape, List.of());
+    }
+
+    @Test
+    @DisplayName("完全同表头:高分且短模板也过 STRONG")
+    void exactHeaders() {
+        List<String> headers = List.of("交易日期", "金额", "账号");
+        TemplateCandidate c = cand(headers, List.of(
+                field("交易日期", "transDate", "DATE", false),
+                field("金额", "transAmount", "MONEY", false),
+                field("账号", "accountNo", "ACCT_CARD_NO", false)));
+        double score = HeaderScorer.score(headers, c);
+        assertTrue(score >= 0.90, "同表头应接近 1.0,实际 " + score);
+        assertTrue(HeaderScorer.passesStrong(headers, c, score, List.of()));
+    }
+
+    @Test
+    @DisplayName("同义表头:户名/账户名称、发生额/交易金额、交易日期/日期")
+    void synonyms() {
+        List<String> file = List.of("户名", "发生额", "交易日期");
+        TemplateCandidate c = cand(List.of("账户名称", "交易金额", "日期"), List.of(
+                field("账户名称", "ownerName", "TEXT", false),
+                field("交易金额", "transAmount", "MONEY", false),
+                field("日期", "transDate", "DATE", false)));
+        double score = HeaderScorer.score(file, c);
+        assertTrue(score >= 0.90, "同义表头应过短模板强匹配线,实际 " + score);
+        assertTrue(HeaderScorer.passesStrong(file, c, score, List.of()));
+    }
+
+    @Test
+    @DisplayName("列序变化:仍能排进候选,但短模板不直接判 STRONG")
+    void reordered() {
+        List<String> file = List.of("金额", "交易日期", "账号");
+        TemplateCandidate c = cand(List.of("交易日期", "金额", "账号"), List.of(
+                field("交易日期", "transDate", "DATE", false),
+                field("金额", "transAmount", "MONEY", false),
+                field("账号", "accountNo", "ACCT_CARD_NO", false)));
+        double score = HeaderScorer.score(file, c);
+        assertTrue(score > HeaderScorer.WEAK, "列序变化不应被直接丢弃,实际 " + score);
+        assertFalse(HeaderScorer.passesStrong(file, c, score, List.of()),
+                "3 列模板乱序不能直接出口 A,必须走重新绑定");
+    }
+
+    @Test
+    @DisplayName("单位不同:金额(元) 与 金额(万元) 不能被当成同一列")
+    void unitDifference() {
+        List<String> file = List.of("金额");
+        TemplateCandidate c = cand(List.of("金额(元)"), List.of(
+                field("金额(元)", "amountYuan", "MONEY", false)));
+        double score = HeaderScorer.score(file, c);
+        assertTrue(score < HeaderScorer.WEAK, "单位不同却高分会导致金额被放大/缩小,实际 " + score);
+    }
+
+    @Test
+    @DisplayName("形状矛盾:金额字段对上一列日期,禁止出口 A")
+    void shapeMismatchBlocksStrong() {
+        List<String> file = List.of("金额");
+        TemplateCandidate c = cand(List.of("金额"), List.of(
+                field("金额", "transAmount", "MONEY", false)));
+        double score = HeaderScorer.score(file, c, List.of(stat(0, "DATE")));
+        assertFalse(HeaderScorer.shapeSafe(file, c, List.of(stat(0, "DATE"))));
+        assertFalse(HeaderScorer.passesStrong(file, c, score, List.of(stat(0, "DATE"))),
+                "表头分再高,类型明显矛盾也不能写进既有业务表");
+    }
+
+    @Test
+    @DisplayName("必填字段缺失:不能出口 A")
+    void requiredMissingBlocksStrong() {
+        List<String> file = List.of("金额");
+        TemplateCandidate c = cand(List.of("金额", "账号"), List.of(
+                field("金额", "transAmount", "MONEY", true),
+                field("账号", "accountNo", "ACCT_CARD_NO", true)));
+        double score = HeaderScorer.score(file, c);
+        assertFalse(HeaderScorer.requiredCovered(file, c));
+        assertFalse(HeaderScorer.passesStrong(file, c, score, List.of()));
+    }
+}