Browse Source

feat(aiclean): 行级失败诊断与日期/金额类型化

- 行级失败落库 ai_clean_reject:每行记录阻塞列、原因、脱敏原值与全部列错误;
  应用日志按 sheet 采样前 N 条并汇总,完整明细经 /aiClean/job/rejections 分页查询
- AiValueCoercer:DATE/TIME/DATE_TIME 输出 LocalDate/LocalTime/LocalDateTime;
  MONEY 支持负数/括号负数/币种/千分位/科学计数法;NUMBER 改 DECIMAL(38,6);
  电话/卡号/身份证校验;必填列任何错误整行拒绝,非必填置空并计数
- 类型来源改为 模板元数据 > ColStat 形状 > 表头关键词 > TEXT;自建表不再全 TEXT;
  typeOf 支持中文/下划线/时间戳,修 FunMultiplier 的整数 NPE 与只乘整数部分
- verify 使用与 load 相同的计划;requiredFill 改为逐行;必填列被拒转 NeedReview;
  复用 AI 模板也抽样校验
- 成功/失败/警告/未处理行数写入 job/batch 并在终态、getFiles 中按真实口径统计;
  未清洗不再冒充成功,自建表单列计数
- 入库改为先写后删旧行;异常/零行清理本次 job 的半截行;注册失败删新表
- 新增迁移脚本 sql/ai_clean_row_stats.sql
- 前端「导入失败」对 AI 清洗打开行级失败明细弹窗

测试:aiclean + dm 183 个用例通过(5 个 live 跳过)
cc 1 day ago
parent
commit
c232884d28
41 changed files with 2881 additions and 261 deletions
  1. 13 0
      ai-electron/frontend/src/case/api/govern/governApi.ts
  2. 214 0
      ai-electron/frontend/src/case/views/data/components/AiRejectModal.vue
  3. 22 0
      ai-electron/frontend/src/case/views/data/importList.vue
  4. 2 0
      ai-electron/frontend/src/types/api.d.ts
  5. 10 0
      ai-server/src/main/java/com/zsjz/ai/common/cache/GlobalCache.java
  6. 7 0
      ai-server/src/main/java/com/zsjz/ai/common/model/dm/entity/FileInfo.java
  7. 14 0
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/api/AiCleanController.java
  8. 16 0
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/entity/AiCleanBatch.java
  9. 12 0
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/entity/AiCleanJob.java
  10. 35 0
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/enums/AiRejectReason.java
  11. 183 0
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/AiDuckDb.java
  12. 155 76
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/AiRecordCleaner.java
  13. 188 0
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/AiRejectCollector.java
  14. 468 0
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/AiValueCoercer.java
  15. 13 10
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/DuckTypeMapper.java
  16. 2 1
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/match/AiTemplateMatcher.java
  17. 4 2
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/match/TemplateCandidateLoader.java
  18. 2 0
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/model/AiMatchOutcome.java
  19. 33 0
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/model/AiRejectRecord.java
  20. 47 0
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/model/AiRowCleanResult.java
  21. 21 0
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/model/ColumnError.java
  22. 4 1
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/model/TemplateCandidate.java
  23. 53 14
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/probe/ColStatBuilder.java
  24. 241 29
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/rule/AiRulePlan.java
  25. 212 47
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiCleanService.java
  26. 106 27
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiSmartCleanService.java
  27. 11 4
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/verify/AiRuleVerifier.java
  28. 13 26
      ai-server/src/main/java/com/zsjz/ai/module/dm/clean/func/FunMultiplier.java
  29. 52 13
      ai-server/src/main/java/com/zsjz/ai/module/dm/service/DmService.java
  30. 3 1
      ai-server/src/test/java/com/zsjz/ai/module/aiclean/AiCleanCompileTest.java
  31. 102 0
      ai-server/src/test/java/com/zsjz/ai/module/aiclean/exec/AiDuckDbRejectTest.java
  32. 69 5
      ai-server/src/test/java/com/zsjz/ai/module/aiclean/exec/AiRecordCleanerTest.java
  33. 98 0
      ai-server/src/test/java/com/zsjz/ai/module/aiclean/exec/AiTypedLoadTest.java
  34. 141 0
      ai-server/src/test/java/com/zsjz/ai/module/aiclean/exec/AiValueCoercerTest.java
  35. 1 1
      ai-server/src/test/java/com/zsjz/ai/module/aiclean/match/AiTemplateMatcherTest.java
  36. 112 0
      ai-server/src/test/java/com/zsjz/ai/module/aiclean/rule/AiRulePlanTypeTest.java
  37. 158 0
      ai-server/src/test/java/com/zsjz/ai/module/aiclean/service/AiRejectCollectorTest.java
  38. 4 4
      ai-server/src/test/java/com/zsjz/ai/module/aiclean/service/SelfHeaderPlanTest.java
  39. 12 0
      ai-server/src/test/java/com/zsjz/ai/module/aiclean/verify/AiRuleVerifierTest.java
  40. 7 0
      sql/ai_clean.sql
  41. 21 0
      sql/ai_clean_row_stats.sql

+ 13 - 0
ai-electron/frontend/src/case/api/govern/governApi.ts

@@ -188,6 +188,19 @@ export const aiCleanDataPage = (params: {
   size?: number;
 }) => defHttp.get<DataTableDTO>({ url: adminPath + '/aiClean/data/page', params });
 
+/**
+ * 某个 AI 清洗任务的行级失败明细(分页)
+ *
+ * 每条记录包含 row_no / blocking_col_name / reason / errors_json(该行全部列错误,
+ * 值为脱敏后的预览)。「导入失败」数字点击后用它解释「哪一行、哪一列、为什么没进去」。
+ */
+export const aiCleanRejections = (params: {
+  jobId: ApiId | number;
+  reason?: string;
+  page?: number;
+  size?: number;
+}) => defHttp.get<Recordable>({ url: adminPath + '/aiClean/job/rejections', params });
+
 /**
  * 文件预处理(Electron 本地路径方式,保留兼容)
  */

+ 214 - 0
ai-electron/frontend/src/case/views/data/components/AiRejectModal.vue

@@ -0,0 +1,214 @@
+<template>
+  <BasicModal
+    :open="open"
+    title="AI 清洗失败明细"
+    :width="1080"
+    :showOkBtn="false"
+    cancelText="关闭"
+    wrapClassName="case-ai-reject-modal"
+    @update:open="handleOpenChange"
+    @cancel="handleOpenChange(false)"
+  >
+    <div class="ai-reject-summary">
+      <span class="ai-reject-summary__file">
+        {{ fileName || '未命名文件' }}<template v-if="sheetName"> / {{ sheetName }}</template>
+      </span>
+      <span class="ai-reject-summary__ok">成功 {{ successNum }}</span>
+      <span class="ai-reject-summary__fail">失败 {{ failNum }}</span>
+      <span class="ai-reject-summary__hint">每一行失败都记录了阻塞列、原因与脱敏后的原值</span>
+    </div>
+
+    <Table
+      row-key="rowKey"
+      size="small"
+      :columns="columns"
+      :data-source="rows"
+      :loading="loading"
+      :pagination="pagination"
+      :scroll="{ y: 420 }"
+      @change="handleTableChange"
+    />
+  </BasicModal>
+</template>
+
+<script lang="ts" setup name="ViewsDataAiRejectModal">
+  import { computed, ref, watch } from 'vue';
+  import { Table } from 'ant-design-vue';
+  import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue';
+  import { BasicModal } from '@/core/components/Modal';
+  import { useMessage } from '@/core/hooks/web/useMessage';
+  import { aiCleanRejections } from '@/case/api/govern/governApi';
+
+  interface RejectRow {
+    rowKey: string;
+    rowNo: number;
+    column: string;
+    reason: string;
+    value: string;
+    others: string;
+  }
+
+  interface RawRejectRecord {
+    row_no?: number | string | null;
+    blocking_col_index?: number | string | null;
+    blocking_col_name?: string | null;
+    reason?: string | null;
+    errors_json?: string | null;
+  }
+
+  const props = withDefaults(
+    defineProps<{
+      open: boolean;
+      jobId?: string | number | null;
+      fileName?: string;
+      sheetName?: string;
+      successNum?: number;
+      failNum?: number;
+    }>(),
+    {
+      jobId: null,
+      fileName: '',
+      sheetName: '',
+      successNum: 0,
+      failNum: 0,
+    },
+  );
+
+  const emit = defineEmits<{ 'update:open': [value: boolean] }>();
+  const { createMessage } = useMessage();
+
+  const rows = ref<RejectRow[]>([]);
+  const loading = ref(false);
+  const total = ref(0);
+  const page = ref(1);
+  const size = ref(20);
+
+  const REASON_LABELS: Record<string, string> = {
+    REQUIRED_EMPTY: '必填列为空',
+    REQUIRED_TYPE_INVALID: '必填列类型不合法',
+    REQUIRED_FUNC_ERROR: '必填列函数执行异常',
+    REQUIRED_LENGTH_OVERFLOW: '必填列超长/超精度',
+    OPTIONAL_TYPE_INVALID: '非必填列类型不合法(单元格置空)',
+    OPTIONAL_FUNC_ERROR: '非必填列函数执行异常(单元格置空)',
+    OPTIONAL_LENGTH_OVERFLOW: '非必填列超长/超精度(单元格置空)',
+    WRITER_ERROR: '写入数据库失败',
+  };
+
+  const reasonLabel = (reason?: string | null) => (reason ? REASON_LABELS[reason] || reason : '-');
+
+  const columns: TableColumnsType = [
+    { title: '行号', dataIndex: 'rowNo', key: 'rowNo', width: 90 },
+    { title: '阻塞列', dataIndex: 'column', key: 'column', width: 180, ellipsis: true },
+    { title: '原因', dataIndex: 'reason', key: 'reason', width: 200 },
+    { title: '原值预览(脱敏)', dataIndex: 'value', key: 'value', ellipsis: true },
+    { title: '该行其他错误', dataIndex: 'others', key: 'others', ellipsis: true },
+  ];
+
+  const pagination = computed<TablePaginationConfig>(() => ({
+    current: page.value,
+    pageSize: size.value,
+    total: total.value,
+    showSizeChanger: true,
+    pageSizeOptions: ['20', '50', '100'],
+    showTotal: (count: number) => `共 ${count} 行`,
+  }));
+
+  const parseErrors = (raw: RawRejectRecord) => {
+    try {
+      const list = JSON.parse(String(raw.errors_json || '[]'));
+      return Array.isArray(list) ? list : [];
+    } catch {
+      return [];
+    }
+  };
+
+  const toRow = (raw: RawRejectRecord): RejectRow => {
+    const errors = parseErrors(raw);
+    const blocking = errors.find((e: any) => e?.blocking) || errors[0] || {};
+    const others = errors
+      .filter((e: any) => e !== blocking)
+      .map((e: any) => `${e?.column || '?'}:${reasonLabel(e?.reason)}`)
+      .join(';');
+    const col = raw.blocking_col_name || blocking?.column || '';
+    const idx = raw.blocking_col_index ?? blocking?.colIndex;
+    return {
+      rowKey: `${raw.row_no ?? ''}-${idx ?? ''}-${raw.reason ?? ''}`,
+      rowNo: Number(raw.row_no ?? 0),
+      column: col ? `${col}${idx != null && idx !== '' ? `(列 ${idx})` : ''}` : '-',
+      reason: reasonLabel(raw.reason || blocking?.reason),
+      value: String(blocking?.value ?? '-'),
+      others: others || '-',
+    };
+  };
+
+  const load = async () => {
+    if (!props.jobId) {
+      rows.value = [];
+      total.value = 0;
+      return;
+    }
+    loading.value = true;
+    try {
+      const res = await aiCleanRejections({ jobId: props.jobId, page: page.value, size: size.value });
+      const records = Array.isArray(res?.records) ? (res.records as RawRejectRecord[]) : [];
+      rows.value = records.map(toRow);
+      total.value = Number(res?.total ?? 0);
+    } catch (error: any) {
+      createMessage.error(error?.message || '失败明细加载失败');
+      rows.value = [];
+      total.value = 0;
+    } finally {
+      loading.value = false;
+    }
+  };
+
+  const handleTableChange = (p: TablePaginationConfig) => {
+    page.value = Number(p.current || 1);
+    size.value = Number(p.pageSize || 20);
+    load();
+  };
+
+  const handleOpenChange = (value: boolean) => emit('update:open', value);
+
+  watch(
+    () => props.open,
+    (open) => {
+      if (open) {
+        page.value = 1;
+        size.value = 20;
+        load();
+      }
+    },
+  );
+</script>
+
+<style lang="less">
+  .case-ai-reject-modal {
+    .ai-reject-summary {
+      display: flex;
+      align-items: center;
+      gap: 16px;
+      flex-wrap: wrap;
+      margin-bottom: 12px;
+      color: #4e5969;
+
+      &__file {
+        font-weight: 600;
+        color: #1d2129;
+      }
+
+      &__ok {
+        color: #00a870;
+      }
+
+      &__fail {
+        color: #e34d59;
+      }
+
+      &__hint {
+        color: #86909c;
+        font-size: 12px;
+      }
+    }
+  }
+</style>

+ 22 - 0
ai-electron/frontend/src/case/views/data/importList.vue

@@ -41,6 +41,15 @@
       :initial-sheet-id="currentLogSheetId"
     />
 
+    <AiRejectModal
+      v-model:open="aiRejectOpen"
+      :job-id="aiRejectRecord?.aiJobId ?? null"
+      :file-name="String(aiRejectRecord?.fileName ?? '')"
+      :sheet-name="String(aiRejectRecord?.sheetName ?? '')"
+      :success-num="Number(aiRejectRecord?.successNum ?? 0)"
+      :fail-num="Number(aiRejectRecord?.failNum ?? 0)"
+    />
+
     <TaskLogDrawer
       v-model:open="taskLogDrawerOpen"
       :logs="taskLogSnapshot?.logs || []"
@@ -69,6 +78,7 @@
   import { FileStatusEnum } from '@/case/types/enum';
   import componentSetting from '@/core/settings/componentSetting';
   import CleanLogModal from './components/CleanLogModal.vue';
+  import AiRejectModal from './components/AiRejectModal.vue';
   import TaskLogDrawer from './components/TaskLogDrawer.vue';
   import {
     getTaskLogSnapshot,
@@ -102,6 +112,9 @@
   const logModalOpen = ref(false);
   const currentLogFileInfos = ref<FileInfo[]>([]);
   const currentLogSheetId = ref<string>('');
+  /** AI 清洗失败明细:按 sheet 最新 job 查 ai_clean_reject */
+  const aiRejectOpen = ref(false);
+  const aiRejectRecord = ref<Partial<FileInfo> | null>(null);
   /** 最近一次清洗任务日志(进度页在任务结束时落盘,结果页只读) */
   const taskLogDrawerOpen = ref(false);
   const taskLogSnapshot = ref<TaskLogSnapshot | null>(null);
@@ -524,6 +537,15 @@
   function handleViewFailLog(event: MouseEvent, record: Partial<FileInfo>) {
     event.preventDefault();
     event.stopPropagation();
+
+    // AI 清洗的失败原因在 ai_clean_reject(逐行:哪一列、什么原因、脱敏原值),
+    // 不再走 clean_error_log;即使失败数为 0 也允许打开看汇总。
+    if (record.cleanMode === 'AI清洗' && record.aiJobId != null) {
+      aiRejectRecord.value = record;
+      aiRejectOpen.value = true;
+      return;
+    }
+
     const failCount = Number(record.failNum ?? 0);
     if (!Number.isFinite(failCount) || failCount <= 0) {
       createMessage.info('当前文件暂无失败记录');

+ 2 - 0
ai-electron/frontend/src/types/api.d.ts

@@ -41,6 +41,8 @@ export interface FileInfo {
   cleanMode?: ApiText | null;
   /** 清洗备注(仅 sheet 行有值):AI 匹配结果(模板名/新建表名)或失败原因 */
   cleanNote?: ApiText | null;
+  /** 该 sheet 最新一条 AI 任务 id(仅 sheet 行有值):失败明细查询用 */
+  aiJobId?: ApiId | number | null;
 }
 
 /**

+ 10 - 0
ai-server/src/main/java/com/zsjz/ai/common/cache/GlobalCache.java

@@ -64,6 +64,16 @@ public class GlobalCache {
 
     public static final Map<String, Pattern> FUN_REGULAR_MAP = new HashMap<>();
 
+    static {
+        // FunRegular(EXTRACT_NAMED)的数据源。以前这个 map 从未初始化,
+        // AI 模型给了 EXTRACT_NAMED 提示也会在运行时 NPE,被异常处理静默吞掉。
+        FUN_REGULAR_MAP.put("EMAIL", PatternPool.EMAIL);
+        FUN_REGULAR_MAP.put("MOBILE_CN", PatternPool.MOBILE);
+        FUN_REGULAR_MAP.put("CITIZEN_ID", PatternPool.CITIZEN_ID);
+        FUN_REGULAR_MAP.put("MONEY", PatternPool.MONEY);
+        FUN_REGULAR_MAP.put("PLATE_NUMBER", PatternPool.PLATE_NUMBER);
+    }
+
     public static final LocalDateTime DEFAULT_START_DATE = LocalDateTime.of(2000, 1, 1, 0, 0, 0);
     public static final LocalDateTime DEFAULT_END_DATE = LocalDateTime.of(2035, 12, 31, 23, 59, 59);
 

+ 7 - 0
ai-server/src/main/java/com/zsjz/ai/common/model/dm/entity/FileInfo.java

@@ -193,4 +193,11 @@ public class FileInfo implements Serializable {
      */
     @TableField(exist = false)
     private String cleanNote;
+
+    /**
+     * 该 sheet 最新一条 AI 任务的 id(仅 sheet 记录):供「导入失败」明细查询
+     * {@code /aiClean/job/rejections} 使用;无 AI 任务时为空。
+     */
+    @TableField(exist = false)
+    private Long aiJobId;
 }

+ 14 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/api/AiCleanController.java

@@ -81,6 +81,20 @@ public class AiCleanController {
         return Result.succeed(aiCleanService.revert(jobId));
     }
 
+    /**
+     * 某个 job 的行级失败明细(分页)。
+     *
+     * <p>返回 {@code {records,total,size,current}};每条包含 row_no / 阻塞列 / 原因 /
+     * 脱敏后的值预览 / 该行全部列错误的 JSON。跨案件查询会被拒绝。
+     */
+    @GetMapping("/job/rejections")
+    public Result<Map<String, Object>> rejections(@RequestParam Long jobId,
+                                                  @RequestParam(required = false) String reason,
+                                                  @RequestParam(defaultValue = "1") long page,
+                                                  @RequestParam(defaultValue = "20") long size) {
+        return Result.succeed(aiCleanService.rejectPage(jobId, page, size, reason));
+    }
+
     /**
      * AI 动态表的翻页取数(治理树上 id&lt;0 的节点走这里)。
      */

+ 16 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/entity/AiCleanBatch.java

@@ -62,6 +62,22 @@ public class AiCleanBatch implements Serializable {
     @TableField("ai_load_count")
     private Integer aiLoadCount;
 
+    /** 出口 B 实际入库行数(所有 sheet 累加) */
+    @TableField("loaded_rows")
+    private Long loadedRows;
+
+    /** 出口 B 被拒绝的行数(逐行失败记录的总数) */
+    @TableField("rejected_rows")
+    private Long rejectedRows;
+
+    /** 未清洗/未处理到的行数(待人工、跳过、失败任务的剩余行) */
+    @TableField("unprocessed_rows")
+    private Long unprocessedRows;
+
+    /** 其中无模板、按文件表头自建表的 sheet 数(不计入模板匹配成功) */
+    @TableField("self_built_count")
+    private Integer selfBuiltCount;
+
     @TableField("need_review_count")
     private Integer needReviewCount;
 

+ 12 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/entity/AiCleanJob.java

@@ -142,6 +142,18 @@ public class AiCleanJob implements Serializable {
     @TableField("row_loaded")
     private Integer rowLoaded;
 
+    /** 被拒绝的行数(必填列空值/类型错误/函数异常/超长溢出) */
+    @TableField("row_rejected")
+    private Integer rowRejected;
+
+    /** 已入库但含非必填错误的行数(单元格已置空,不计失败) */
+    @TableField("row_warned")
+    private Integer rowWarned;
+
+    /** 失败任务里还没处理到的行数(成功任务为 0) */
+    @TableField("row_unprocessed")
+    private Integer rowUnprocessed;
+
     @TableField("fail_reason")
     private String failReason;
 

+ 35 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/enums/AiRejectReason.java

@@ -0,0 +1,35 @@
+package com.zsjz.ai.module.aiclean.enums;
+
+/**
+ * 行级清洗失败原因。
+ *
+ * <p>区分「必填/非必填」与「空值/类型不合法/函数异常/超长溢出」:
+ * 必填列的任何一种错误都会让整行拒绝;非必填只把该单元格置空,行继续入库。
+ * 这些原因会原样写进 {@code ai_clean_reject},用于回答「这一行因为哪一列没进去」。
+ *
+ * @author cc
+ * @since 2026/10/22
+ */
+public enum AiRejectReason {
+    /** 必填列为空 */
+    REQUIRED_EMPTY,
+    /** 必填列类型解析失败(日期/金额/数字/身份证/电话/卡号等) */
+    REQUIRED_TYPE_INVALID,
+    /** 必填列清洗函数执行异常 */
+    REQUIRED_FUNC_ERROR,
+    /** 必填列超出目标类型长度/精度 */
+    REQUIRED_LENGTH_OVERFLOW,
+    /** 非必填列类型解析失败:单元格置空,行照常入库 */
+    OPTIONAL_TYPE_INVALID,
+    /** 非必填列清洗函数执行异常:单元格置空,行照常入库 */
+    OPTIONAL_FUNC_ERROR,
+    /** 非必填列超出目标类型长度/精度:单元格置空,行照常入库 */
+    OPTIONAL_LENGTH_OVERFLOW,
+    /** 写入数据库失败 */
+    WRITER_ERROR;
+
+    /** 是否导致整行拒绝 */
+    public boolean blocking() {
+        return this == WRITER_ERROR || name().startsWith("REQUIRED_");
+    }
+}

+ 183 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/AiDuckDb.java

@@ -1,9 +1,11 @@
 package com.zsjz.ai.module.aiclean.exec;
 
+import cn.hutool.core.util.IdUtil;
 import cn.hutool.extra.spring.SpringUtil;
 import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
 import com.zsjz.ai.common.constants.StrConsts;
 import com.zsjz.ai.module.aiclean.entity.AiCleanField;
+import com.zsjz.ai.module.aiclean.model.AiRejectRecord;
 import com.zsjz.ai.module.aiclean.support.AiColumnNames;
 import lombok.extern.slf4j.Slf4j;
 
@@ -297,6 +299,187 @@ public final class AiDuckDb {
         }
     }
 
+    /**
+     * 写入成功后清理同一文件/sheet 的旧行(保留本次 job 的行)。
+     *
+     * <p><b>为什么改成「先写后删」</b>:旧实现先 purge 再写,写入中途失败时
+     * 该 sheet 的旧数据已经被删掉且不会恢复。现在先写新 job 的行,成功后再删
+     * {@code ai_job_id != 当前} 的旧行;失败时旧行原样保留,半截新行按 job 清掉。
+     */
+    public static long deleteByFileSheetExcludingJob(String table, Long fileId, Long sheetId, Long jobId) {
+        if (fileId == null || sheetId == null || jobId == null) {
+            return 0L;
+        }
+        try (Connection c = caseDb().getConnection(); PreparedStatement ps =
+                     c.prepareStatement("DELETE FROM " + table
+                             + " WHERE file_id = ? AND sheet_id = ? AND (ai_job_id IS NULL OR ai_job_id <> ?)")) {
+            ps.setLong(1, fileId);
+            ps.setLong(2, sheetId);
+            ps.setLong(3, jobId);
+            return ps.executeUpdate();
+        } catch (Exception e) {
+            log.error("AI 重清洗后清理旧行失败: table={}, fileId={}, sheetId={}, jobId={}, err={}",
+                    table, fileId, sheetId, jobId, e.getMessage());
+            return -1L;
+        }
+    }
+
+    /**
+     * 懒建行级失败表。表在案件 DuckDB 里,随案件库备份/删除一起走。
+     */
+    private static void ensureRejectTable(Connection c) throws Exception {
+        try (Statement st = c.createStatement()) {
+            st.executeUpdate("CREATE TABLE IF NOT EXISTS ai_clean_reject ("
+                    + "id BIGINT PRIMARY KEY, job_id BIGINT, case_id BIGINT, file_id BIGINT, sheet_id BIGINT,"
+                    + "block_no INTEGER, row_no INTEGER, blocking_col_index INTEGER, blocking_col_name VARCHAR,"
+                    + "reason VARCHAR(50), errors_json VARCHAR, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP)");
+            st.executeUpdate("CREATE INDEX IF NOT EXISTS idx_ai_clean_reject_job "
+                    + "ON ai_clean_reject (job_id, row_no)");
+        }
+    }
+
+    /**
+     * 批量写入被拒绝的行。
+     *
+     * <p><b>fail-open</b>:诊断数据写不进去不能反过来把清洗主流程带崩;
+     * 失败只记日志(应用日志里仍有抽样明细与汇总)。每行一条,不逐行 SQL。
+     */
+    public static void insertRejects(List<AiRejectRecord> records) {
+        if (records == null || records.isEmpty()) {
+            return;
+        }
+        String sql = "INSERT INTO ai_clean_reject (id, job_id, case_id, file_id, sheet_id, block_no, row_no,"
+                + " blocking_col_index, blocking_col_name, reason, errors_json) VALUES (?,?,?,?,?,?,?,?,?,?,?)";
+        try (Connection c = caseDb().getConnection()) {
+            ensureRejectTable(c);
+            try (PreparedStatement ps = c.prepareStatement(sql)) {
+                for (AiRejectRecord r : records) {
+                    ps.setLong(1, IdUtil.getSnowflakeNextId());
+                    ps.setLong(2, r.jobId());
+                    ps.setLong(3, r.caseId());
+                    ps.setLong(4, r.fileId());
+                    ps.setLong(5, r.sheetId());
+                    ps.setInt(6, r.blockNo() == null ? 0 : r.blockNo());
+                    ps.setInt(7, r.rowNo() == null ? 0 : r.rowNo());
+                    if (r.blockingColIndex() == null) {
+                        ps.setNull(8, java.sql.Types.INTEGER);
+                    } else {
+                        ps.setInt(8, r.blockingColIndex());
+                    }
+                    ps.setString(9, r.blockingColName());
+                    ps.setString(10, r.reason());
+                    String json = r.errorsJson();
+                    ps.setString(11, json == null || json.length() <= 8000 ? json : json.substring(0, 8000));
+                    ps.addBatch();
+                }
+                ps.executeBatch();
+            }
+        } catch (Exception e) {
+            log.error("AI 清洗失败明细写入失败(不影响清洗主流程): jobId={}, rows={}, err={}",
+                    records.getFirst().jobId(), records.size(), e.getMessage());
+        }
+    }
+
+    /**
+     * 分页查询某个 job 的失败行。
+     *
+     * @param reason 可选,按原因过滤({@code AiRejectReason#name()})
+     * @return {@code {records,total,size,current}};表不存在时返回空页
+     */
+    public static Map<String, Object> rejectPage(Long jobId, String reason, long page, long size) {
+        long current = Math.max(1, page);
+        long limit = Math.min(200, Math.max(1, size));
+        String where = " WHERE job_id = ?"
+                + (reason == null || reason.isBlank() ? "" : " AND reason = ?");
+        List<Map<String, Object>> records = new ArrayList<>();
+        long total = 0;
+        try (Connection c = caseDb().getConnection()) {
+            ensureRejectTable(c);
+            try (PreparedStatement ps = c.prepareStatement(
+                    "SELECT row_no, blocking_col_index, blocking_col_name, reason, errors_json, create_time"
+                            + " FROM ai_clean_reject" + where + " ORDER BY row_no, blocking_col_index"
+                            + " LIMIT ? OFFSET ?")) {
+                int idx = 1;
+                ps.setLong(idx++, jobId);
+                if (!where.equals(" WHERE job_id = ?")) {
+                    ps.setString(idx++, reason);
+                }
+                ps.setLong(idx++, limit);
+                ps.setLong(idx, (current - 1) * limit);
+                try (ResultSet rs = ps.executeQuery()) {
+                    ResultSetMetaData md = rs.getMetaData();
+                    while (rs.next()) {
+                        Map<String, Object> row = new LinkedHashMap<>();
+                        for (int i = 1; i <= md.getColumnCount(); i++) {
+                            row.put(md.getColumnLabel(i), rs.getObject(i));
+                        }
+                        records.add(row);
+                    }
+                }
+            }
+            try (PreparedStatement ps = c.prepareStatement(
+                    "SELECT count(*) FROM ai_clean_reject" + where)) {
+                int idx = 1;
+                ps.setLong(idx++, jobId);
+                if (!where.equals(" WHERE job_id = ?")) {
+                    ps.setString(idx, reason);
+                }
+                try (ResultSet rs = ps.executeQuery()) {
+                    if (rs.next()) {
+                        total = rs.getLong(1);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.info("AI 清洗失败明细分页查询失败(可能还没建表): jobId={}, err={}", jobId, e.getMessage());
+        }
+        Map<String, Object> out = new LinkedHashMap<>();
+        out.put("records", records);
+        out.put("total", total);
+        out.put("size", limit);
+        out.put("current", current);
+        return out;
+    }
+
+    /** 撤销 job 时清理它的失败明细 */
+    public static long deleteRejectsByJob(Long jobId) {
+        if (jobId == null) {
+            return 0L;
+        }
+        return deleteRejects("DELETE FROM ai_clean_reject WHERE job_id = ?", List.of(jobId));
+    }
+
+    /** 删除导入文件时清理失败明细 */
+    public static long deleteRejectsByFileIds(List<Long> fileIds) {
+        if (fileIds == null || fileIds.isEmpty()) {
+            return 0L;
+        }
+        String marks = String.join(",", java.util.Collections.nCopies(fileIds.size(), "?"));
+        return deleteRejects("DELETE FROM ai_clean_reject WHERE file_id IN (" + marks + ")", fileIds);
+    }
+
+    /** 按文件+sheet 清理失败明细 */
+    public static long deleteRejectsByFileSheet(Long fileId, Long sheetId) {
+        if (fileId == null || sheetId == null) {
+            return 0L;
+        }
+        return deleteRejects("DELETE FROM ai_clean_reject WHERE file_id = ? AND sheet_id = ?",
+                List.of(fileId, sheetId));
+    }
+
+    private static long deleteRejects(String sql, List<Long> args) {
+        try (Connection c = caseDb().getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
+            for (int i = 0; i < args.size(); i++) {
+                ps.setLong(i + 1, args.get(i));
+            }
+            return ps.executeUpdate();
+        } catch (Exception e) {
+            log.info("清理 AI 清洗失败明细失败(可能还没建表): err={}", e.getMessage());
+            return 0L;
+        }
+    }
+
+
     /**
      * 按文件 ID 批量删除 AI 动态表里的行:删除导入文件时用。
      *

+ 155 - 76
ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/AiRecordCleaner.java

@@ -1,21 +1,25 @@
 package com.zsjz.ai.module.aiclean.exec;
 
-import cn.hutool.core.lang.PatternPool;
 import cn.hutool.core.util.ReUtil;
 import cn.hutool.core.util.StrUtil;
 import com.zsjz.ai.common.cache.GlobalCache;
+import com.zsjz.ai.module.aiclean.enums.AiRejectReason;
+import com.zsjz.ai.module.aiclean.model.AiRowCleanResult;
+import com.zsjz.ai.module.aiclean.model.ColumnError;
 import com.zsjz.ai.module.aiclean.rule.CompiledColumn;
-import com.zsjz.ai.module.dm.clean.func.FunProcess;
+import com.zsjz.ai.module.aiclean.support.HalfWidth;
+import com.zsjz.ai.module.dm.clean.func.Fun;
 import lombok.extern.slf4j.Slf4j;
 
-import java.math.BigDecimal;
-import java.math.RoundingMode;
+import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
 
 /**
- * 逐行清洗:原始行 → 物理列值。
+ * 逐行清洗:原始行 → 物理列值 + 逐列错误。
  *
  * <p><b>为什么把 {@code TransStdDataCleaner} 的逻辑照一份过来而不是复用它的类</b>:
  * 它继承 {@code AbstractDataCleaner},产出的是<b>具体实体对象</b>并靠反射填字段,
@@ -24,103 +28,185 @@ import java.util.Map;
  * 输出改成 {@code Map<列名,值>}。<b>函数实例是共用的一批,复制的只是编排</b>,
  * 所以不会出现「两套清洗逻辑各自漂移」的问题。
  *
+ * <p><b>行级诊断</b>:{@link #cleanRowDetailed} 会遍历所有列并收集全部错误,
+ * 不再遇到第一个必填错误就返回。必填列的空值/类型错误/函数异常/超长溢出都会让整行拒绝;
+ * 非必填列的同类错误只把该单元格置空,行继续入库。调用方据此写 {@code ai_clean_reject},
+ * 回答「这一行为什么没进去、因为哪一列」。
+ *
  * @author cc
  * @since 2026/9/21
  */
 @Slf4j
 public final class AiRecordCleaner {
 
+    /** 类型化列的值清洗:只去控制字符,保留 + - . 供数字/日期解析 */
+    private static final Pattern VALUE_JUNK = Pattern.compile("[\\t\\r\\n'’‘\\u3000]+");
+
+    /** 需要脱敏后再进日志/失败记录的类型 */
+    private static final Set<String> SENSITIVE = Set.of("PHONE", "ACCT_CARD_NO", "ID_CARD_NUMBER");
+
+    /** 失败记录里的值预览长度 */
+    private static final int PREVIEW_LEN = 80;
+
     private AiRecordCleaner() {
     }
 
     /**
-     * 清洗一行。
-     *
-     * @param rowData  原始行,键为列序号
-     * @param columns  编译后的列计划
-     * @param errors   输出参数:列名 → 错误说明(供逐列错误率统计与 repair 回灌)
-     * @return 列名 → 值;必填缺失时返回 null 表示该行无效
+     * 兼容旧调用:返回列名 → 错误说明;必填错误时整行返回 null。
      */
     public static Map<String, Object> cleanRow(Map<Integer, String> rowData,
                                                List<CompiledColumn> columns,
                                                Map<String, String> errors) {
-        Map<String, Object> out = new LinkedHashMap<>();
+        AiRowCleanResult result = cleanRowDetailed(rowData, columns);
+        if (errors != null) {
+            for (ColumnError error : result.errors()) {
+                errors.putIfAbsent(error.columnName(),
+                        error.reason().name() + (error.valuePreview() == null ? "" : " 值=" + error.valuePreview()));
+            }
+        }
+        return result.rejected() ? null : result.values();
+    }
+
+    /**
+     * 行级清洗(带完整错误列表)。
+     *
+     * @param rowData 原始行,键为列序号
+     * @param columns 编译后的列计划
+     * @return 清洗后的值、全部列错误、是否整行拒绝
+     */
+    public static AiRowCleanResult cleanRowDetailed(Map<Integer, String> rowData,
+                                                    List<CompiledColumn> columns) {
+        Map<String, Object> values = new LinkedHashMap<>();
+        List<ColumnError> errors = new ArrayList<>();
         for (CompiledColumn col : columns) {
             String raw = rowData.get(col.colIndex());
+            String type = normalizeType(col.fieldType());
+            boolean required = col.required();
+
             if (StrUtil.isBlank(raw)) {
-                if (col.required()) {
-                    if (errors != null) {
-                        errors.put(col.columnName(), "必填列为空");
-                    }
-                    return null;
+                if (required) {
+                    errors.add(error(col, AiRejectReason.REQUIRED_EMPTY, null));
+                }
+                values.put(col.columnName(), null);
+                continue;
+            }
+
+            String content = preClean(raw, type);
+            // 函数链:异常必须被捕获成列错误,不能再被 onException 静默吞掉
+            String funError = null;
+            for (Fun fun : col.funs()) {
+                try {
+                    content = fun.apply(content);
+                } catch (Exception e) {
+                    funError = e.getMessage();
+                    break;
                 }
-                out.put(col.columnName(), null);
+            }
+            if (funError != null) {
+                errors.add(error(col, required ? AiRejectReason.REQUIRED_FUNC_ERROR
+                        : AiRejectReason.OPTIONAL_FUNC_ERROR, raw));
+                values.put(col.columnName(), null);
                 continue;
             }
-            String content = ReUtil.replaceAll(raw.trim(), GlobalCache.SPECIAL_CHARS_PATTERN, "");
-            if (!col.funs().isEmpty()) {
-                content = FunProcess.create().addAll(col.funs())
-                        .onException(e -> log.debug("AI 清洗函数异常: col={}, v={}", col.columnName(), raw))
-                        .process(content);
+            if (StrUtil.isBlank(content)) {
+                // 函数链把值清空:必填列按空值处理;非必填列静默置空(与原语义一致)
+                if (required) {
+                    errors.add(error(col, AiRejectReason.REQUIRED_EMPTY, raw));
+                }
+                values.put(col.columnName(), null);
+                continue;
             }
-            Object value = toTypedValue(col, content, errors);
-            if (value == null && col.required()) {
-                return null;
+
+            if (isDictionary(type)) {
+                values.put(col.columnName(), translateDirection(col, content));
+                continue;
             }
-            out.put(col.columnName(), value);
+
+            AiValueCoercer.Result coerced = AiValueCoercer.coerce(type, content);
+            if (!coerced.isOk()) {
+                AiRejectReason reason = rejectReason(coerced.failure(), required);
+                if (reason != null) {
+                    errors.add(error(col, reason, raw));
+                }
+                values.put(col.columnName(), null);
+                continue;
+            }
+            values.put(col.columnName(), coerced.value());
         }
-        return out;
+        boolean rejected = errors.stream().anyMatch(ColumnError::blocking);
+        return new AiRowCleanResult(values, errors, rejected);
     }
 
     /**
-     * 按字段类型规整成可入库的值;不合法返回 null 并记错误。
+     * 保留旧签名供测试/校验使用:按字段类型规整成可入库的值;不合法返回 null 并记错误。
      */
-    static Object toTypedValue(CompiledColumn col, String content, Map<String, String> errors) {
+    public static Object toTypedValue(CompiledColumn col, String content, Map<String, String> errors) {
         String v = StrUtil.isBlank(content) ? null : content.trim();
         if (v == null) {
             return null;
         }
-        try {
-            String type = StrUtil.nullToEmpty(col.fieldType());
-            // 千分位必须先去掉再判格式:PatternPool.MONEY 不接受逗号,
-            // 而 Excel 金额列带逗号是常态,否则「2,300.50」会被判成脏值
-            switch (type) {
-                case "MONEY" -> {
-                    String plain = v.replace(",", "");
-                    if (!ReUtil.isMatch(PatternPool.MONEY, plain)) {
-                        return fail(col, v, "金额格式", errors);
-                    }
-                    return new BigDecimal(plain).setScale(2, RoundingMode.HALF_UP);
-                }
-                case "NUMBER" -> {
-                    String plain = v.replace(",", "");
-                    if (!ReUtil.isMatch("-?\\d+(\\.\\d+)?", plain)) {
-                        return fail(col, v, "数字格式", errors);
-                    }
-                    return new BigDecimal(plain);
-                }
-                case "PHONE" -> {
-                    return ReUtil.replaceAll(v, GlobalCache.CHINA_PREFIX_PATTERN, "");
-                }
-                case "ACCT_CARD_NO" -> {
-                    return ReUtil.replaceAll(v, GlobalCache.CLEANED_NOT_DIGITS_PATTERN, "");
-                }
-                case "ID_CARD_NUMBER" -> {
-                    if (!ReUtil.isMatch(PatternPool.CITIZEN_ID, v)) {
-                        return fail(col, v, "身份证格式", errors);
-                    }
-                    return v;
-                }
-                case "DEBIT_CREDIT_FLAG" -> {
-                    return translateDirection(col, v);
-                }
-                default -> {
-                    return v;
-                }
+        String type = normalizeType(col.fieldType());
+        if (isDictionary(type)) {
+            return translateDirection(col, v);
+        }
+        AiValueCoercer.Result coerced = AiValueCoercer.coerce(type, v);
+        if (!coerced.isOk()) {
+            if (errors != null) {
+                errors.putIfAbsent(col.columnName(),
+                        "类型转换失败(" + type + ") 值=" + preview(v, type));
             }
-        } catch (Exception e) {
-            return fail(col, v, "值转换异常", errors);
+            return null;
         }
+        return coerced.value();
+    }
+
+    private static String preClean(String raw, String type) {
+        String value = raw.trim();
+        if (isDictionary(type) || "TEXT".equals(type) || type.isEmpty()) {
+            // TEXT 保持既有行为:制表/换行/引号/加号等一并剔除
+            return ReUtil.replaceAll(value, GlobalCache.SPECIAL_CHARS_PATTERN, "");
+        }
+        // 类型化列保留 + - . 等符号,交给解析器判断;全角先转半角
+        return ReUtil.replaceAll(HalfWidth.to(value), VALUE_JUNK, "");
+    }
+
+    private static String normalizeType(String fieldType) {
+        return StrUtil.nullToEmpty(fieldType).trim().toUpperCase();
+    }
+
+    private static boolean isDictionary(String type) {
+        return "DEBIT_CREDIT_FLAG".equals(type) || "CALL_DIRECTION".equals(type);
+    }
+
+    private static AiRejectReason rejectReason(AiValueCoercer.Failure failure, boolean required) {
+        if (failure == null || failure == AiValueCoercer.Failure.EMPTY) {
+            return required ? AiRejectReason.REQUIRED_EMPTY : null;
+        }
+        return switch (failure) {
+            case TYPE_INVALID -> required ? AiRejectReason.REQUIRED_TYPE_INVALID
+                    : AiRejectReason.OPTIONAL_TYPE_INVALID;
+            case LENGTH_OVERFLOW -> required ? AiRejectReason.REQUIRED_LENGTH_OVERFLOW
+                    : AiRejectReason.OPTIONAL_LENGTH_OVERFLOW;
+            case EMPTY -> required ? AiRejectReason.REQUIRED_EMPTY : null;
+        };
+    }
+
+    private static ColumnError error(CompiledColumn col, AiRejectReason reason, String raw) {
+        return new ColumnError(col.colIndex(), col.columnName(), reason,
+                preview(raw, normalizeType(col.fieldType())), reason.blocking());
+    }
+
+    /** 失败记录/日志里的原值预览:敏感类型掩码,其余截断 */
+    static String preview(String raw, String type) {
+        if (raw == null) {
+            return null;
+        }
+        String value = raw.trim();
+        if (SENSITIVE.contains(type) && value.length() > 6) {
+            value = value.substring(0, 3) + "***" + value.substring(value.length() - 2);
+        }
+        return value.length() <= PREVIEW_LEN ? value
+                : StrUtil.subPre(value, PREVIEW_LEN) + "...";
     }
 
     /**
@@ -146,11 +232,4 @@ public final class AiRecordCleaner {
             return v;
         }
     }
-
-    private static Object fail(CompiledColumn col, String v, String why, Map<String, String> errors) {
-        if (errors != null) {
-            errors.putIfAbsent(col.columnName(), why + "不合法,示例值:" + StrUtil.maxLength(v, 30));
-        }
-        return null;
-    }
 }

+ 188 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/AiRejectCollector.java

@@ -0,0 +1,188 @@
+package com.zsjz.ai.module.aiclean.exec;
+
+import com.zsjz.ai.common.utils.Json;
+import com.zsjz.ai.module.aiclean.enums.AiRejectReason;
+import com.zsjz.ai.module.aiclean.model.AiRejectRecord;
+import com.zsjz.ai.module.aiclean.model.AiRowCleanResult;
+import com.zsjz.ai.module.aiclean.model.ColumnError;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 行级失败收集器:逐行记录「为什么没进去」,并按原因/列聚合。
+ *
+ * <p><b>为什么不是每行打一条应用日志</b>:十万行文件里坏行可能上万,
+ * 全量打日志会把磁盘和排障视野一起淹掉。这里把<b>每一行</b>写进
+ * {@code ai_clean_reject}(可查询、可分页),应用日志只抽样前 N 条,
+ * 每个 sheet 结束再打一条汇总。
+ *
+ * @author cc
+ * @since 2026/10/22
+ */
+@Slf4j
+public final class AiRejectCollector {
+
+    private static final int FLUSH_BATCH = 500;
+    private static final int MAX_SAMPLES = 5;
+
+    private final Long jobId;
+    private final Long caseId;
+    private final Long fileId;
+    private final Long sheetId;
+    private final Integer blockNo;
+    private final int sampleLimit;
+    private final boolean logAll;
+
+    private long source;
+    private long rejected;
+    private long warned;
+
+    private final List<AiRejectRecord> pending = new ArrayList<>();
+    private final Map<String, Integer> reasonCounts = new LinkedHashMap<>();
+    private final Map<String, Integer> columnCounts = new LinkedHashMap<>();
+    private final Map<String, Integer> optionalCounts = new LinkedHashMap<>();
+    private final List<Map<String, Object>> samples = new ArrayList<>();
+
+    public AiRejectCollector(Long jobId, Long caseId, Long fileId, Long sheetId, Integer blockNo,
+                             int sampleLimit, boolean logAll) {
+        this.jobId = jobId;
+        this.caseId = caseId;
+        this.fileId = fileId;
+        this.sheetId = sheetId;
+        this.blockNo = blockNo;
+        this.sampleLimit = Math.max(0, sampleLimit);
+        this.logAll = logAll;
+    }
+
+    /** 每读到一个数据行(无论是否成功)都调一次,作为总行数口径 */
+    public void countSource() {
+        source++;
+    }
+
+    /**
+     * 记录一行清洗结果。
+     *
+     * @param rowNo  原文件物理行号
+     * @param result 清洗结果;被拒绝时落一条 {@code ai_clean_reject}
+     */
+    public void record(long rowNo, AiRowCleanResult result) {
+        if (result == null) {
+            return;
+        }
+        if (result.rejected()) {
+            rejected++;
+            ColumnError first = result.firstBlocking();
+            AiRejectReason reason = first == null ? AiRejectReason.REQUIRED_EMPTY : first.reason();
+            String column = first == null ? null : first.columnName();
+            reasonCounts.merge(reason.name(), 1, Integer::sum);
+            if (column != null) {
+                columnCounts.merge(column, 1, Integer::sum);
+            }
+            pending.add(new AiRejectRecord(jobId, caseId, fileId, sheetId, blockNo, (int) rowNo,
+                    first == null ? null : first.colIndex(), column, reason.name(),
+                    errorsJson(result.errors())));
+            if (samples.size() < MAX_SAMPLES) {
+                samples.add(sample(rowNo, first));
+            }
+            if (logAll || rejected <= sampleLimit) {
+                log.warn("AI 清洗拒绝: jobId={}, fileId={}, sheetId={}, rowNo={}, col={}({}), reason={}, value={}",
+                        jobId, fileId, sheetId, rowNo,
+                        first == null ? null : first.columnName(),
+                        first == null ? null : first.colIndex(),
+                        reason.name(),
+                        first == null ? null : first.valuePreview());
+            }
+            if (pending.size() >= FLUSH_BATCH) {
+                flush();
+            }
+            return;
+        }
+        if (result.hasOptionalError()) {
+            warned++;
+            for (ColumnError e : result.errors()) {
+                if (!e.blocking()) {
+                    optionalCounts.merge(e.columnName() + ":" + e.reason().name(), 1, Integer::sum);
+                }
+            }
+        }
+    }
+
+    /** 写入失败:当前行单独记一条,便于定位是哪个 sheet/row 触发的 */
+    public void recordWriterError(long rowNo, String message) {
+        rejected++;
+        reasonCounts.merge(AiRejectReason.WRITER_ERROR.name(), 1, Integer::sum);
+        String detail = Json.toStr(List.of(Map.of(
+                "column", "",
+                "reason", AiRejectReason.WRITER_ERROR.name(),
+                "value", message == null ? "" : message)));
+        pending.add(new AiRejectRecord(jobId, caseId, fileId, sheetId, blockNo, (int) rowNo,
+                null, null, AiRejectReason.WRITER_ERROR.name(), detail));
+        flush();
+    }
+
+    public void flush() {
+        if (pending.isEmpty()) {
+            return;
+        }
+        AiDuckDb.insertRejects(pending);
+        pending.clear();
+    }
+
+    public long source() {
+        return source;
+    }
+
+    public long rejected() {
+        return rejected;
+    }
+
+    public long warned() {
+        return warned;
+    }
+
+    /** 汇总 JSON,写进 {@code ai_clean_job.detail.load} */
+    public String summaryJson(long loaded, long unprocessed) {
+        Map<String, Object> out = new LinkedHashMap<>();
+        // 用 int 而不是 long:项目全局把 Long 序列化成字符串,行数没必要走那个精度保护
+        out.put("source", (int) Math.min(source, Integer.MAX_VALUE));
+        out.put("loaded", (int) Math.min(Math.max(0, loaded), Integer.MAX_VALUE));
+        out.put("rejected", (int) Math.min(rejected, Integer.MAX_VALUE));
+        out.put("warned", (int) Math.min(warned, Integer.MAX_VALUE));
+        out.put("unprocessed", (int) Math.min(Math.max(0, unprocessed), Integer.MAX_VALUE));
+        out.put("reasons", reasonCounts);
+        out.put("columns", columnCounts);
+        out.put("optional", optionalCounts);
+        out.put("samples", samples);
+        return Json.toStr(out);
+    }
+
+    private static String errorsJson(List<ColumnError> errors) {
+        List<Map<String, Object>> list = new ArrayList<>();
+        if (errors != null) {
+            for (ColumnError e : errors) {
+                Map<String, Object> m = new LinkedHashMap<>();
+                m.put("colIndex", e.colIndex());
+                m.put("column", e.columnName());
+                m.put("reason", e.reason() == null ? null : e.reason().name());
+                m.put("value", e.valuePreview());
+                m.put("blocking", e.blocking());
+                list.add(m);
+            }
+        }
+        return Json.toStr(list);
+    }
+
+    private static Map<String, Object> sample(long rowNo, ColumnError first) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("rowNo", rowNo);
+        m.put("column", first == null ? null : first.columnName());
+        m.put("colIndex", first == null ? null : first.colIndex());
+        m.put("reason", first == null ? null : first.reason().name());
+        m.put("value", first == null ? null : first.valuePreview());
+        return m;
+    }
+}

+ 468 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/AiValueCoercer.java

@@ -0,0 +1,468 @@
+package com.zsjz.ai.module.aiclean.exec;
+
+import cn.hutool.core.util.StrUtil;
+import com.zsjz.ai.module.aiclean.support.HalfWidth;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.ResolverStyle;
+import java.util.List;
+import java.util.regex.Pattern;
+
+/**
+ * 字段值的类型化转换。
+ *
+ * <p><b>为什么必须在这里转成 Java 类型</b>:动态表的列是 DATE / TIMESTAMP / DECIMAL,
+ * 如果把「2026年1月5日」「-1,234.50」这类字符串直接交给 JDBC,DuckDB 会在
+ * {@code executeBatch} 时抛转换错误 —— 一行坏值就会让整个 sheet 失败。
+ * 这里先解析成 {@code LocalDate / LocalDateTime / BigDecimal}:
+ * 成功就是类型安全的参数,失败就按必填/非必填决定整行拒绝还是单元格置空。
+ *
+ * <p>解析规则按目标类型收敛,而不是「猜」:
+ * <ul>
+ *   <li>DATE 收到 datetime 串 → 取日期部分;</li>
+ *   <li>DATE_TIME 收到纯日期 → 补 00:00:00;</li>
+ *   <li>TIME 收到 datetime 串 → 取时间部分;</li>
+ *   <li>MONEY/NUMBER 支持千分位、正负号、括号负数、币种符号、科学计数法;</li>
+ *   <li>PHONE/卡号/身份证做长度与格式校验,失败返回 null 而不是空串。</li>
+ * </ul>
+ *
+ * @author cc
+ * @since 2026/10/22
+ */
+public final class AiValueCoercer {
+
+    /** 转换失败类别;必填/非必填由调用方映射成具体的 {@code AiRejectReason} */
+    public enum Failure {
+        /** 原值为空 */
+        EMPTY,
+        /** 格式不合法 */
+        TYPE_INVALID,
+        /** 超出目标类型长度/精度 */
+        LENGTH_OVERFLOW
+    }
+
+    /**
+     * @param value   转换后的 Java 值(失败为 null)
+     * @param failure 失败类别(成功为 null)
+     */
+    public record Result(Object value, Failure failure) {
+
+        public static Result ok(Object value) {
+            return new Result(value, null);
+        }
+
+        public static Result fail(Failure failure) {
+            return new Result(null, failure);
+        }
+
+        public boolean isOk() {
+            return failure == null;
+        }
+    }
+
+    /** 千分位数字:1,234 / -1,234.50 */
+    private static final Pattern GROUPED_NUMBER = Pattern.compile("[+-]?\\d{1,3}(,\\d{3})+(\\.\\d+)?");
+
+    /** DECIMAL(18,2) 的整数位上限 */
+    private static final int MONEY_INTEGER_DIGITS = 16;
+
+    /** DECIMAL(38,6) 的整数位/小数位上限 */
+    private static final int NUMBER_INTEGER_DIGITS = 32;
+    private static final int NUMBER_SCALE = 6;
+
+    /** epoch 时间戳的合理区间:2000–2100,区间外不猜 */
+    private static final long EPOCH_MIN_SECOND = Instant.parse("2000-01-01T00:00:00Z").getEpochSecond();
+    private static final long EPOCH_MAX_SECOND = Instant.parse("2100-01-01T00:00:00Z").getEpochSecond();
+
+    private static final DateTimeFormatter DATE_TIME_WITH_SECONDS = strict("uuuu-M-d H:m:s");
+    private static final DateTimeFormatter DATE_TIME_WITHOUT_SECONDS = strict("uuuu-M-d H:m");
+    private static final DateTimeFormatter DATE_TIME_WITH_MILLIS = strict("uuuu-M-d H:m:s.SSS");
+    private static final DateTimeFormatter DATE_TIME_SLASH = strict("uuuu/M/d H:m:s");
+    private static final DateTimeFormatter DATE_TIME_SLASH_NO_SECONDS = strict("uuuu/M/d H:m");
+    private static final DateTimeFormatter DATE_TIME_DOT = strict("uuuu.M.d H:m:s");
+    private static final DateTimeFormatter DATE_TIME_DOT_NO_SECONDS = strict("uuuu.M.d H:m");
+
+    private static final DateTimeFormatter DATE_DASH = strict("uuuu-M-d");
+    private static final DateTimeFormatter DATE_SLASH = strict("uuuu/M/d");
+    private static final DateTimeFormatter DATE_DOT = strict("uuuu.M.d");
+    private static final DateTimeFormatter DATE_COMPACT = strict("uuuuMMdd");
+    private static final DateTimeFormatter DATE_TIME_COMPACT = strict("uuuuMMddHHmmss");
+    private static final DateTimeFormatter DATE_TIME_COMPACT_MINUTE = strict("uuuuMMddHHmm");
+
+    private static final DateTimeFormatter TIME_WITH_SECONDS = strict("H:m:s");
+    private static final DateTimeFormatter TIME_WITHOUT_SECONDS = strict("H:m");
+
+    private static final List<DateTimeFormatter> DATE_TIME_FORMATS = List.of(
+            DATE_TIME_WITH_SECONDS, DATE_TIME_WITHOUT_SECONDS, DATE_TIME_WITH_MILLIS,
+            DATE_TIME_SLASH, DATE_TIME_SLASH_NO_SECONDS, DATE_TIME_DOT, DATE_TIME_DOT_NO_SECONDS);
+
+    private static final List<DateTimeFormatter> DATE_FORMATS = List.of(
+            DATE_DASH, DATE_SLASH, DATE_DOT, DATE_COMPACT);
+
+    private static final List<DateTimeFormatter> TIME_FORMATS = List.of(
+            TIME_WITH_SECONDS, TIME_WITHOUT_SECONDS);
+
+    private AiValueCoercer() {
+    }
+
+    private static DateTimeFormatter strict(String pattern) {
+        return DateTimeFormatter.ofPattern(pattern).withResolverStyle(ResolverStyle.STRICT);
+    }
+
+    /**
+     * 按字段类型转换一个非空字符串。
+     *
+     * @param fieldType {@code FieldTypeEnum#name()}(大小写不敏感)
+     * @param raw       已经过基础清洗(去特殊字符/函数链)的字符串
+     */
+    public static Result coerce(String fieldType, String raw) {
+        if (StrUtil.isBlank(raw)) {
+            return Result.fail(Failure.EMPTY);
+        }
+        String type = StrUtil.nullToEmpty(fieldType).trim().toUpperCase();
+        String value = HalfWidth.to(raw.trim()).replace('−', '-');
+        return switch (type) {
+            case "DATE" -> toDate(value);
+            case "TIME" -> toTime(value);
+            case "DATE_TIME" -> toDateTime(value);
+            case "MONEY" -> toMoney(value);
+            case "NUMBER" -> toNumber(value);
+            case "PHONE" -> toPhone(value);
+            case "ACCT_CARD_NO" -> toCardNo(value);
+            case "ID_CARD_NUMBER" -> toIdCard(value);
+            // TEXT / DEBIT_CREDIT_FLAG / CALL_DIRECTION / 未知类型:由调用方处理字典或原样保留
+            default -> Result.ok(raw);
+        };
+    }
+
+    // ==================== 日期 / 时间 ====================
+
+    private static Result toDate(String value) {
+        LocalDateTime epoch = epochDateTime(value);
+        if (epoch != null) {
+            return Result.ok(epoch.toLocalDate());
+        }
+        Parsed p = parse(value);
+        if (p == null) {
+            return Result.fail(Failure.TYPE_INVALID);
+        }
+        LocalDate d = parsedDate(p);
+        if (d == null) {
+            return Result.fail(Failure.TYPE_INVALID);
+        }
+        return Result.ok(d);
+    }
+
+    private static Result toTime(String value) {
+        Parsed p = parse(value);
+        if (p == null || p.time == null) {
+            return Result.fail(Failure.TYPE_INVALID);
+        }
+        return Result.ok(p.time);
+    }
+
+    private static Result toDateTime(String value) {
+        LocalDateTime epoch = epochDateTime(value);
+        if (epoch != null) {
+            return Result.ok(epoch);
+        }
+        Parsed p = parse(value);
+        if (p == null) {
+            return Result.fail(Failure.TYPE_INVALID);
+        }
+        LocalDate d = parsedDate(p);
+        if (d == null) {
+            // 纯时间不是合法日期时间
+            return Result.fail(Failure.TYPE_INVALID);
+        }
+        LocalDateTime dt = p.time == null ? d.atStartOfDay() : LocalDateTime.of(d, p.time);
+        return Result.ok(dt);
+    }
+
+    /**
+     * 10/13 位 epoch 时间戳(系统导出常见)。
+     *
+     * <p>只在 2000–2100 的合理区间内解析;落在区间外的 10/13 位数字仍按普通数字/文本处理,
+     * 避免把订单号、卡号误转成 1970 年。
+     */
+    private static LocalDateTime epochDateTime(String value) {
+        String v = value.trim();
+        try {
+            if (v.matches("\\d{10}")) {
+                long seconds = Long.parseLong(v);
+                if (seconds >= EPOCH_MIN_SECOND && seconds <= EPOCH_MAX_SECOND) {
+                    return LocalDateTime.ofInstant(Instant.ofEpochSecond(seconds), ZoneId.systemDefault());
+                }
+            } else if (v.matches("\\d{13}")) {
+                long millis = Long.parseLong(v);
+                long seconds = millis / 1000;
+                if (seconds >= EPOCH_MIN_SECOND && seconds <= EPOCH_MAX_SECOND) {
+                    return LocalDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneId.systemDefault());
+                }
+            }
+        } catch (Exception ignore) {
+            // 超出 long 或非法数字:按普通值处理
+        }
+        return null;
+    }
+
+    /**
+     * 解析结果:日期/时间至少有一个;Excel 序列号单独用 {@link #serial} 表示。
+     */
+    private static final class Parsed {
+        private final LocalDate date;
+        private final LocalTime time;
+        private final Long serial;
+
+        private Parsed(LocalDate date, LocalTime time) {
+            this(date, time, null);
+        }
+
+        private Parsed(LocalDate date, LocalTime time, Long serial) {
+            this.date = date;
+            this.time = time;
+            this.serial = serial;
+        }
+
+        /** 把 Excel 序列号落成日期(1900 日期系统,与 DateNumberConverter 默认窗口一致) */
+        private LocalDate serialDate() {
+            return serial == null ? null : LocalDate.of(1899, 12, 30).plusDays(serial);
+        }
+    }
+
+    private static Parsed parse(String input) {
+        String s = input.trim();
+        if (s.isEmpty()) {
+            return null;
+        }
+        // ISO 的 T 分隔与业务里常见的空格等价
+        s = s.replace('T', ' ').replace('t', ' ');
+        // 中文日期/时间单位:2026年1月5日 10时30分 -> 2026-1-5 10:30
+        String normalized = s.replace("年", "-").replace("月", "-").replace("日", " ")
+                .replace("时", ":").replace("分", ":").replace("秒", "")
+                .replaceAll("\\s+", " ")
+                // 「10时30分」会留下尾部冒号,去掉后再交给标准格式解析
+                .replaceAll(":+$", "")
+                .trim();
+        for (String candidate : new String[]{s, normalized}) {
+            Parsed parsed = tryParse(candidate);
+            if (parsed != null) {
+                return parsed;
+            }
+        }
+        return null;
+    }
+
+    private static Parsed tryParse(String input) {
+        String s = input == null ? "" : input.trim();
+        if (StrUtil.isBlank(s)) {
+            return null;
+        }
+        for (DateTimeFormatter fmt : DATE_TIME_FORMATS) {
+            try {
+                LocalDateTime dt = LocalDateTime.parse(s, fmt);
+                return new Parsed(dt.toLocalDate(), dt.toLocalTime());
+            } catch (Exception ignore) {
+                // 继续尝试下一种格式
+            }
+        }
+        for (DateTimeFormatter fmt : DATE_FORMATS) {
+            try {
+                return new Parsed(LocalDate.parse(s, fmt), null);
+            } catch (Exception ignore) {
+                // 继续
+            }
+        }
+        for (DateTimeFormatter fmt : TIME_FORMATS) {
+            try {
+                return new Parsed(null, LocalTime.parse(s, fmt));
+            } catch (Exception ignore) {
+                // 继续
+            }
+        }
+        if (s.matches("\\d{14}")) {
+            try {
+                LocalDateTime dt = LocalDateTime.parse(s, DATE_TIME_COMPACT);
+                return new Parsed(dt.toLocalDate(), dt.toLocalTime());
+            } catch (Exception ignore) {
+                // 继续
+            }
+        }
+        if (s.matches("\\d{12}")) {
+            try {
+                LocalDateTime dt = LocalDateTime.parse(s, DATE_TIME_COMPACT_MINUTE);
+                return new Parsed(dt.toLocalDate(), dt.toLocalTime());
+            } catch (Exception ignore) {
+                // 继续
+            }
+        }
+        if (s.matches("\\d{8}")) {
+            try {
+                return new Parsed(LocalDate.parse(s, DATE_COMPACT), null);
+            } catch (Exception ignore) {
+                // 落到 Excel 序列号判断
+            }
+        }
+        // 纯数字且长度不超过 6:Excel 日期序列号(文本存储的日期列兜底)
+        if (s.matches("\\d{1,6}")) {
+            try {
+                long serial = Long.parseLong(s);
+                if (serial >= 1 && serial <= 2_958_465L) {
+                    return new Parsed(null, null, serial);
+                }
+            } catch (NumberFormatException ignore) {
+                // 超出 long
+            }
+        }
+        return null;
+    }
+
+    private static LocalDate parsedDate(Parsed p) {
+        if (p == null) {
+            return null;
+        }
+        return p.date != null ? p.date : p.serialDate();
+    }
+
+    // ==================== 金额 / 数字 ====================
+
+    private static Result toMoney(String value) {
+        String s = value.replace(" ", "").replace("\u3000", "");
+        boolean negative = false;
+        if (s.startsWith("(") && s.endsWith(")")) {
+            negative = true;
+            s = s.substring(1, s.length() - 1);
+        }
+        // 币种符号可能在括号内/外,统一去掉
+        s = s.replace("¥", "").replace("¥", "").replace("$", "")
+                .replace("€", "").replace("£", "").replace("元", "");
+        if (s.isEmpty() || s.contains("%")) {
+            return Result.fail(Failure.TYPE_INVALID);
+        }
+        if (s.contains(",")) {
+            if (!GROUPED_NUMBER.matcher(s).matches()) {
+                return Result.fail(Failure.TYPE_INVALID);
+            }
+            s = s.replace(",", "");
+        }
+        try {
+            BigDecimal d = new BigDecimal(s);
+            if (negative) {
+                // 括号负数:即使括号内还带了负号,也统一取绝对值再加负,避免负负得正
+                d = d.abs().negate();
+            }
+            d = d.setScale(2, RoundingMode.HALF_UP);
+            if (d.precision() - d.scale() > MONEY_INTEGER_DIGITS) {
+                return Result.fail(Failure.LENGTH_OVERFLOW);
+            }
+            return Result.ok(d);
+        } catch (NumberFormatException e) {
+            return Result.fail(Failure.TYPE_INVALID);
+        }
+    }
+
+    private static Result toNumber(String value) {
+        String s = value.replace(" ", "").replace("\u3000", "");
+        if (s.isEmpty()) {
+            return Result.fail(Failure.TYPE_INVALID);
+        }
+        if (s.contains(",")) {
+            if (!GROUPED_NUMBER.matcher(s).matches()) {
+                return Result.fail(Failure.TYPE_INVALID);
+            }
+            s = s.replace(",", "");
+        }
+        try {
+            BigDecimal d = new BigDecimal(s);
+            if (d.scale() > NUMBER_SCALE) {
+                d = d.setScale(NUMBER_SCALE, RoundingMode.HALF_UP);
+            }
+            if (d.precision() - d.scale() > NUMBER_INTEGER_DIGITS) {
+                return Result.fail(Failure.LENGTH_OVERFLOW);
+            }
+            return Result.ok(d);
+        } catch (NumberFormatException e) {
+            return Result.fail(Failure.TYPE_INVALID);
+        }
+    }
+
+    // ==================== 电话 / 卡号 / 身份证 ====================
+
+    private static Result toPhone(String value) {
+        String s = value.replaceAll("[\\s\\-()()]", "");
+        if (s.startsWith("+86")) {
+            s = s.substring(3);
+        } else if (s.startsWith("0086")) {
+            s = s.substring(4);
+        } else if (s.startsWith("+")) {
+            s = s.substring(1);
+        } else if (s.startsWith("86") && s.length() == 13) {
+            s = s.substring(2);
+        }
+        if (!s.matches("\\d{7,15}")) {
+            return Result.fail(Failure.TYPE_INVALID);
+        }
+        return Result.ok(s);
+    }
+
+    private static Result toCardNo(String value) {
+        // 科学计数法必须先拒绝:否则删掉非数字后会把指数位拼进卡号
+        if (value.contains("E") || value.contains("e")) {
+            return Result.fail(Failure.TYPE_INVALID);
+        }
+        String s = value.replaceAll("[\\s\\-()()]", "");
+        // 账号/卡号长度差异很大(对公账号可短至 8 位,卡号可到 19-24 位),这里只做宽松边界
+        if (!s.matches("\\d{8,24}")) {
+            return Result.fail(Failure.TYPE_INVALID);
+        }
+        return Result.ok(s);
+    }
+
+    private static Result toIdCard(String value) {
+        String s = value.replaceAll("\\s", "");
+        if (s.matches("\\d{15}")) {
+            LocalDate birth = compactBirth("19" + s.substring(6, 12));
+            if (birth == null) {
+                return Result.fail(Failure.TYPE_INVALID);
+            }
+            return Result.ok(s);
+        }
+        if (s.matches("\\d{17}[\\dXx]")) {
+            LocalDate birth = compactBirth(s.substring(6, 14));
+            if (birth == null) {
+                return Result.fail(Failure.TYPE_INVALID);
+            }
+            if (!validIdChecksum(s)) {
+                return Result.fail(Failure.TYPE_INVALID);
+            }
+            return Result.ok(s.toUpperCase());
+        }
+        return Result.fail(Failure.TYPE_INVALID);
+    }
+
+    private static LocalDate compactBirth(String yyyyMMdd) {
+        try {
+            return LocalDate.parse(yyyyMMdd, DATE_COMPACT);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /** 18 位身份证校验位:加权因子 + 11 取模映射 */
+    private static boolean validIdChecksum(String s) {
+        int[] weights = {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2};
+        char[] checks = {'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'};
+        int sum = 0;
+        for (int i = 0; i < 17; i++) {
+            sum += (s.charAt(i) - '0') * weights[i];
+        }
+        return Character.toUpperCase(s.charAt(17)) == checks[sum % 11];
+    }
+}

+ 13 - 10
ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/DuckTypeMapper.java

@@ -5,9 +5,13 @@ import cn.hutool.core.util.StrUtil;
 /**
  * FieldTypeEnum → DuckDB 列类型。
  *
- * 动态表必须有真类型,否则下游治理与问数拿到的全是 VARCHAR,
+ * <p>动态表必须有真类型,否则下游治理与问数拿到的全是 VARCHAR,
  * 「按节点查询对应表的数据」就退化成一堆字符串。
  *
+ * <p><b>为什么 AI 表不用 VARCHAR(n)</b>:长度声明来自模板历史配置,
+ * 一旦实际值超长,DuckDB 会拒绝整批写入 —— 一个超长备注就能让整个 sheet 失败。
+ * AI 动态表统一 TEXT(无界),长度只做展示/校验;类型化列见 DATE/TIME/DECIMAL。
+ *
  * @author cc
  * @since 2026/9/21
  */
@@ -18,24 +22,23 @@ public final class DuckTypeMapper {
 
     /**
      * @param fieldType FieldTypeEnum 的 name,未知/空按 TEXT
-     * @param fieldLen  声明长度,>0 时用于 VARCHAR(n)
+     * @param fieldLen  声明长度(AI 表忽略,保留参数兼容)
      */
     public static String toDuckType(String fieldType, Integer fieldLen) {
         if (StrUtil.isBlank(fieldType)) {
-            return varchar(fieldLen);
+            return "TEXT";
         }
         return switch (fieldType.trim().toUpperCase()) {
             case "MONEY" -> "DECIMAL(18,2)";
-            case "NUMBER" -> "INTEGER";
+            // INTEGER 会溢出、丢小数;DECIMAL(38,6) 覆盖整数与常见小数
+            case "NUMBER" -> "DECIMAL(38,6)";
             case "DATE" -> "DATE";
             case "TIME" -> "TIME";
             case "DATE_TIME" -> "TIMESTAMP";
-            default -> varchar(fieldLen);
+            // 这些类型靠 cleaner 的语义校验,物理列保持文本(前导零、遮挡字符不能丢)
+            case "PHONE", "ACCT_CARD_NO", "ID_CARD_NUMBER",
+                 "DEBIT_CREDIT_FLAG", "CALL_DIRECTION", "TEXT" -> "TEXT";
+            default -> "TEXT";
         };
     }
-
-    private static String varchar(Integer len) {
-        // Excel 单元格常有超长备注,len 缺失或过小时一律 TEXT(DuckDB 的 VARCHAR 即无界)
-        return len == null || len <= 0 || len > 1000 ? "TEXT" : "VARCHAR(" + len + ")";
-    }
 }

+ 2 - 1
ai-server/src/main/java/com/zsjz/ai/module/aiclean/match/AiTemplateMatcher.java

@@ -343,7 +343,8 @@ public class AiTemplateMatcher {
                 StrUtil.splitTrim(StrUtil.nullToEmpty(t.getHeaders()), ','),
                 awf.fields().stream().map(f -> new TemplateCandidate.FieldBrief(f.getFieldNameCn(),
                         f.getFieldNameEn(), StrUtil.nullToEmpty(f.getFieldType()),
-                        f.getRequired() != null && f.getRequired() == 1)).toList(),
+                        f.getRequired() != null && f.getRequired() == 1,
+                        f.getFieldLen(), f.getDirectionConf())).toList(),
                 score);
     }
 

+ 4 - 2
ai-server/src/main/java/com/zsjz/ai/module/aiclean/match/TemplateCandidateLoader.java

@@ -42,7 +42,8 @@ public final class TemplateCandidateLoader {
                     fields.stream().map(f -> new TemplateCandidate.FieldBrief(
                             f.getFieldNameCn(), f.getFieldNameEn(),
                             f.getFieldType() == null ? "TEXT" : f.getFieldType().name(),
-                            f.getRequired() != null && f.getRequired() == 1)).toList(),
+                            f.getRequired() != null && f.getRequired() == 1,
+                            f.getFieldLen(), f.getDirectionConf())).toList(),
                     0d));
         }
         return out;
@@ -61,7 +62,8 @@ public final class TemplateCandidateLoader {
                     fields.stream().filter(f -> f.getAiTemplateId().equals(t.getId()))
                             .map(f -> new TemplateCandidate.FieldBrief(f.getFieldNameCn(),
                                     f.getFieldNameEn(), StrUtil.blankToDefault(f.getFieldType(), "TEXT"),
-                                    f.getRequired() != null && f.getRequired() == 1)).toList(),
+                                    f.getRequired() != null && f.getRequired() == 1,
+                                    f.getFieldLen(), f.getDirectionConf())).toList(),
                     0d));
         }
         return out;

+ 2 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/model/AiMatchOutcome.java

@@ -40,6 +40,8 @@ public record AiMatchOutcome(AiMatchTierEnum tier,
     public static final String BY_LLM = "LLM";
     /** 没有候选 */
     public static final String BY_NONE = "NONE";
+    /** 无候选,按文件自身表头建动态表(不计入模板匹配成功) */
+    public static final String BY_SELF_HEADER = "SELF_HEADER";
 
     /**
      * 没有任何候选时的结论。

+ 33 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/model/AiRejectRecord.java

@@ -0,0 +1,33 @@
+package com.zsjz.ai.module.aiclean.model;
+
+/**
+ * 一行被拒绝的持久化记录(案件 DuckDB 的 {@code ai_clean_reject})。
+ *
+ * <p>每个被拒绝的行一条:{@code blockingCol*} 是第一个阻塞列,
+ * {@code errorsJson} 保留该行<b>所有列</b>的错误(列名/原因/脱敏值预览),
+ * 既能按行回答「为什么没进去」,也能按列聚合出「哪一列错得最多」。
+ *
+ * @param jobId             AI 清洗任务 id
+ * @param caseId            案件 id
+ * @param fileId            文件根 id
+ * @param sheetId           sheet id
+ * @param blockNo           表块序号
+ * @param rowNo             原文件物理行号(1 基)
+ * @param blockingColIndex  第一个阻塞列的文件列序号(0 基)
+ * @param blockingColName   第一个阻塞列的物理列名
+ * @param reason            阻塞原因({@code AiRejectReason#name()})
+ * @param errorsJson        该行全部列错误的 JSON(已脱敏)
+ * @author cc
+ * @since 2026/10/22
+ */
+public record AiRejectRecord(Long jobId,
+                             Long caseId,
+                             Long fileId,
+                             Long sheetId,
+                             Integer blockNo,
+                             Integer rowNo,
+                             Integer blockingColIndex,
+                             String blockingColName,
+                             String reason,
+                             String errorsJson) {
+}

+ 47 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/model/AiRowCleanResult.java

@@ -0,0 +1,47 @@
+package com.zsjz.ai.module.aiclean.model;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 一行数据的清洗结果。
+ *
+ * <p>{@link #rejected} 为 true 时该行不能入库;{@link #errors} 仍然保留该行
+ * <b>所有列</b>的错误(不只第一个阻塞列),用于逐行失败日志与逐列错误统计。
+ *
+ * @param values   列名 → 清洗后的值(被拒绝的行也保留可用的部分,便于排障)
+ * @param errors   本行所有列错误
+ * @param rejected 是否整行拒绝
+ * @author cc
+ * @since 2026/10/22
+ */
+public record AiRowCleanResult(Map<String, Object> values,
+                               List<ColumnError> errors,
+                               boolean rejected) {
+
+    /** 第一个阻塞列;没有阻塞错误时返回 null */
+    public ColumnError firstBlocking() {
+        if (errors == null) {
+            return null;
+        }
+        for (ColumnError e : errors) {
+            if (e.blocking()) {
+                return e;
+            }
+        }
+        return null;
+    }
+
+    /** 是否存在非阻塞错误(行入库但某些非必填列为空) */
+    public boolean hasOptionalError() {
+        if (errors == null) {
+            return false;
+        }
+        for (ColumnError e : errors) {
+            if (!e.blocking()) {
+                return true;
+            }
+        }
+        return false;
+    }
+}

+ 21 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/model/ColumnError.java

@@ -0,0 +1,21 @@
+package com.zsjz.ai.module.aiclean.model;
+
+import com.zsjz.ai.module.aiclean.enums.AiRejectReason;
+
+/**
+ * 单列清洗错误。
+ *
+ * @param colIndex     文件列序号(0 基)
+ * @param columnName   动态表物理列名
+ * @param reason       失败原因
+ * @param valuePreview 原值预览(敏感类型已脱敏、长度已截断)
+ * @param blocking     是否导致整行拒绝
+ * @author cc
+ * @since 2026/10/22
+ */
+public record ColumnError(int colIndex,
+                          String columnName,
+                          AiRejectReason reason,
+                          String valuePreview,
+                          boolean blocking) {
+}

+ 4 - 1
ai-server/src/main/java/com/zsjz/ai/module/aiclean/model/TemplateCandidate.java

@@ -42,8 +42,11 @@ public record TemplateCandidate(int no,
      * @param en       字段英文名,是模型绑定时的<b>唯一合法取值域</b>
      * @param type     {@code FieldTypeEnum#name()}
      * @param required 是否必填
+     * @param fieldLen 声明长度(AI 动态表统一 TEXT,长度只做展示/校验)
+     * @param directionConf 借贷/通话方向字典 JSON,可空
      */
-    public record FieldBrief(String cn, String en, String type, boolean required) {
+    public record FieldBrief(String cn, String en, String type, boolean required,
+                             Integer fieldLen, String directionConf) {
     }
 
     /**

+ 53 - 14
ai-server/src/main/java/com/zsjz/ai/module/aiclean/probe/ColStatBuilder.java

@@ -9,6 +9,7 @@ import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.regex.Pattern;
 
 /**
  * 逐列统计与形状识别。
@@ -16,6 +17,11 @@ import java.util.Set;
  * <p>形状在 Java 侧用正则判,不交给模型猜:这几类的判定是确定性的,
  * 正则一次遍历就够,省 token 且不会判错。模型真正的活是「这列语义上对应哪个字段」。
  *
+ * <p>形状是<b>类型推断的证据之一</b>({@code AiRulePlan.resolveType}):
+ * DATE/DATETIME/TIME/PHONE/IDCARD/ACCT/MONEY 能直接定类型;整数金额靠表头关键词补判。
+ * 因此这里必须用锚定的整串匹配,不能像旧实现那样依赖 {@code ReUtil.isMatch} 的全串语义
+ * 却写出只覆盖纯日期的正则。
+ *
  * @author cc
  * @since 2026/9/21
  */
@@ -27,6 +33,15 @@ public final class ColStatBuilder {
     /** 参与统计的最大行数:300 行足够定形状与非空率,再多只是白扫 */
     private static final int MAX_SCAN_ROWS = 300;
 
+    private static final Pattern DATE_DASH = Pattern.compile("\\d{4}[-/.]\\d{1,2}[-/.]\\d{1,2}");
+    private static final Pattern DATE_CN = Pattern.compile("\\d{4}年\\d{1,2}月\\d{1,2}日");
+    private static final Pattern DATETIME = Pattern.compile(
+            "\\d{4}[-/.]\\d{1,2}[-/.]\\d{1,2}[ T]\\d{1,2}:\\d{2}(:\\d{2})?");
+    private static final Pattern TIME = Pattern.compile("\\d{1,2}:\\d{2}(:\\d{2})?");
+    private static final Pattern MONEY = Pattern.compile(
+            "[((]?[-+]?[¥¥$€£]?\\d{1,3}(,\\d{3})*(\\.\\d+)?[))]?\\s*元?");
+    private static final Pattern NUMBER = Pattern.compile("-?\\d+(\\.\\d+)?");
+
     private ColStatBuilder() {
     }
 
@@ -69,54 +84,78 @@ public final class ColStatBuilder {
     }
 
     /**
-     * 由示例值推断形状。命中越具体的规则越先判,卡号与身份证长度接近,靠校验位规则区分。
+     * 由示例值推断形状。命中越具体的规则越先判,卡号与身份证长度接近,靠长度/前缀区分。
      */
     public static String shape(List<String> samples) {
-        if (samples.isEmpty()) {
+        if (samples == null || samples.isEmpty()) {
             return "EMPTY";
         }
         int phone = 0;
         int idCard = 0;
+        int acct = 0;
         int money = 0;
         int number = 0;
         int date = 0;
+        int dateTime = 0;
+        int time = 0;
         int plate = 0;
         int email = 0;
-        for (String s : samples) {
-            if (ReUtil.isMatch(PatternPool.MOBILE, s)) {
+        for (String raw : samples) {
+            String s = StrUtil.nullToEmpty(raw).trim();
+            if (s.isEmpty()) {
+                continue;
+            }
+            String digits = s.replaceAll("[\\s\\-()()+]", "");
+            if (digits.matches("^(86)?1[3-9]\\d{9}$")) {
                 phone++;
-            } else if (ReUtil.isMatch(PatternPool.CITIZEN_ID, s)) {
+            } else if (digits.matches("\\d{15}") || digits.matches("\\d{17}[\\dXx]")) {
                 idCard++;
+            } else if (digits.matches("\\d{13,24}")) {
+                // 长数字串优先按账号/卡号(纯文本保留前导零),不再判成 NUMBER
+                acct++;
             } else if (ReUtil.isMatch(PatternPool.EMAIL, s)) {
                 email++;
-            } else if (ReUtil.contains(s, "年") || ReUtil.isMatch("\\d{4}[-/年.]\\d{1,2}[-/月.]\\d{1,2}", s)) {
+            } else if (DATETIME.matcher(s).matches()) {
+                dateTime++;
+            } else if (DATE_DASH.matcher(s).matches() || DATE_CN.matcher(s).matches()) {
                 date++;
+            } else if (TIME.matcher(s).matches()) {
+                time++;
             } else if (ReUtil.isMatch(PatternPool.PLATE_NUMBER, s)) {
                 plate++;
-            } else if (ReUtil.isMatch("-?\\d{1,3}(,\\d{3})*(\\.\\d+)?", s) || ReUtil.isMatch("-?\\d+(\\.\\d+)?", s)) {
+            } else if (MONEY.matcher(s).matches() && (s.contains(".") || s.contains(",")
+                    || s.contains("¥") || s.contains("¥") || s.contains("$")
+                    || s.contains("元") || s.contains("(") || s.contains("("))) {
+                money++;
+            } else if (NUMBER.matcher(s).matches() && digits.length() <= 12) {
                 number++;
-                // 金额必须真带小数位:整数「1」「2」是 NUMBER,判成 MONEY 会让建表类型错
-                if (ReUtil.isMatch("-?\\d{1,3}(,\\d{3})*\\.\\d{1,2}", s) || ReUtil.isMatch("-?\\d+\\.\\d{1,2}", s)) {
-                    money++;
-                }
             }
         }
-        int n = samples.size();
+        int n = Math.max(1, samples.size());
         if (phone * 2 >= n) {
             return "PHONE";
         }
         if (idCard * 2 >= n) {
             return "IDCARD";
         }
+        if (acct * 2 >= n) {
+            return "ACCT";
+        }
         if (email * 2 >= n) {
             return "EMAIL";
         }
-        if (plate * 2 >= n) {
-            return "PLATE";
+        if (dateTime * 2 >= n) {
+            return "DATETIME";
         }
         if (date * 2 >= n) {
             return "DATE";
         }
+        if (time * 2 >= n) {
+            return "TIME";
+        }
+        if (plate * 2 >= n) {
+            return "PLATE";
+        }
         if (money * 2 >= n) {
             return "MONEY";
         }

+ 241 - 29
ai-server/src/main/java/com/zsjz/ai/module/aiclean/rule/AiRulePlan.java

@@ -5,6 +5,7 @@ import com.zsjz.ai.common.cache.GlobalCache;
 import com.zsjz.ai.common.model.dm.dto.TableRuleDTO;
 import com.zsjz.ai.common.model.plat.entity.TableField;
 import com.zsjz.ai.module.aiclean.entity.AiCleanField;
+import com.zsjz.ai.module.aiclean.model.ColStat;
 import com.zsjz.ai.module.aiclean.model.RuleBind;
 import com.zsjz.ai.module.aiclean.model.TemplateCandidate;
 
@@ -13,6 +14,7 @@ import java.util.Comparator;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 
 /**
@@ -23,14 +25,25 @@ import java.util.Map;
  * 两者共用同一批 {@code Fun} 实例,字段元数据也都取自模板,
  * 所以<b>不会出现两套清洗语义各自漂移</b>。
  *
- * <p>字段类型、必填、长度一律以模板的 {@code table_field} 为准,<b>不信模型给的</b>:
- * 模型只负责「哪列对应哪个字段」,属性的真源始终是配置。
+ * <p><b>类型来源优先级</b>(2026-10-22):模板字段(table_field / ai_clean_field)
+ * → 列形状({@link ColStat#shape()})→ 表头关键词 → TEXT。
+ * 不再只按英文字段名猜类型:自建表的中文表头、下划线命名的卡号/身份证都能落到正确类型。
  *
  * @author cc
  * @since 2026/9/21
  */
 public final class AiRulePlan {
 
+    /** 形状里能直接决定 FieldTypeEnum 的(NUMBER 除外:整数金额要看表头语义) */
+    private static final Map<String, String> SHAPE_TYPES = Map.of(
+            "PHONE", "PHONE",
+            "IDCARD", "ID_CARD_NUMBER",
+            "DATE", "DATE",
+            "DATETIME", "DATE_TIME",
+            "TIME", "TIME",
+            "MONEY", "MONEY",
+            "ACCT", "ACCT_CARD_NO");
+
     private AiRulePlan() {
     }
 
@@ -65,6 +78,9 @@ public final class AiRulePlan {
             dto.setFieldLen(meta.getFieldLen());
             dto.setFieldSort(meta.getFieldSort() == null ? sort : meta.getFieldSort());
             dto.setMatched(1);
+            // 借贷方向符号/字典:既有链路的 MONEY 分支依赖 zf,方向列依赖 directionConf
+            dto.setZf(meta.getZf());
+            dto.setDirectionConf(meta.getDirectionConf());
             // funList 是内存对象列表,随请求体传给既有链路;不落库、也不带 @class
             dto.setFunList(AiRuleCompiler.compileFuns(b));
             out.add(dto);
@@ -73,25 +89,67 @@ public final class AiRulePlan {
         return out;
     }
 
+    /**
+     * 出口 A 的校验计划:与真正执行的 {@link #forExisting} 使用同一份 table_field 元数据,
+     * 避免「按 forAi 的类型/必填抽样,按 table_field 执行」的校验失真。
+     */
+    public static List<CompiledColumn> forExistingColumns(List<RuleBind> binds, TemplateCandidate cand,
+                                                          List<String> headerNames) {
+        Map<String, TableField> byEn = new HashMap<>();
+        for (TableField f : GlobalCache.tableFieldCache().getOrDefault(cand.templateId(), List.of())) {
+            byEn.putIfAbsent(f.getFieldNameEn(), f);
+        }
+        List<CompiledColumn> out = new ArrayList<>();
+        for (RuleBind b : binds) {
+            TableField meta = byEn.get(b.getFieldNameEn());
+            if (meta == null) {
+                continue;
+            }
+            String type = meta.getFieldType() == null ? "TEXT" : meta.getFieldType().name();
+            out.add(new CompiledColumn(b.getFileColIndex(), meta.getFieldNameEn(), meta.getFieldNameEn(),
+                    type, meta.getRequired() != null && meta.getRequired() == 1,
+                    meta.getFieldLen(), AiRuleCompiler.compileFuns(b), meta.getDirectionConf()));
+        }
+        return out;
+    }
+
     /**
      * 出口 B:AI 侧清洗计划 + 动态表字段定义。
      *
      * <p><b>动态表要能还原整张 sheet</b>:AI 通常只绑上部分列(如车辆表只认出号牌/号牌种类/所有人),
-     * 其余列按原表头顺序、原值保留为 TEXT。否则 {@code ai_clean_field} 与治理树只会显示模型
-     * 命中的那几个字段,表就像被截断了一样(2026-10-18 需求)。
+     * 其余列按原表头顺序、原值保留。类型不再一律 TEXT:有模板字段就用模板类型,
+     * 没有就按列形状/表头关键词推断。
      *
      * <p><b>列名口径(2026-10-18 二次需求)</b>:物理列名固定用 {@code c0/c1/…},
      * 且 {@code field_name_en} 与 {@code column_name} 完全一致 ——
      * {@code ai_clean_field.column_name / field_name_en} 的列宽都只有 {@code varchar(5)}。
-     * AI 的语义英文名只留在 job 草稿/规则提示里,不再写进字段元数据。
      */
     public static Plan forAi(List<RuleBind> binds, List<String> headerNames) {
+        return forAi(binds, headerNames, List.of(), null);
+    }
+
+    /**
+     * 出口 B 的完整版本:带列统计与候选模板。
+     *
+     * @param colStats 文件列统计(主块),按文件列序号索引;可为空
+     * @param cand     命中的模板(既有或 AI);无候选(自建表)时传 null
+     */
+    public static Plan forAi(List<RuleBind> binds, List<String> headerNames,
+                             List<ColStat> colStats, TemplateCandidate cand) {
         Map<Integer, RuleBind> bindByIndex = new LinkedHashMap<>();
         for (RuleBind b : binds) {
             if (b != null && b.getFileColIndex() != null) {
                 bindByIndex.putIfAbsent(b.getFileColIndex(), b);
             }
         }
+        Map<Integer, ColStat> statByIndex = new HashMap<>();
+        for (ColStat s : colStats == null ? List.<ColStat>of() : colStats) {
+            if (s != null) {
+                statByIndex.put(s.idx(), s);
+            }
+        }
+        Map<String, TemplateCandidate.FieldBrief> candFields = indexCandidateFields(cand);
+
         // 列序严格按原表头顺序;空表头跳过(无法命名也没有语义)
         List<Integer> indexes = new ArrayList<>();
         List<String> names = new ArrayList<>();
@@ -114,30 +172,45 @@ public final class AiRulePlan {
             int fileColIndex = indexes.get(pos);
             RuleBind bind = bindByIndex.get(fileColIndex);
             String nameCn = names.get(pos);
-            // 列名固定 c{位置}:既保证长度 ≤5(ai_clean_field 两列都是 varchar(5)),
-            // 也保证 field_name_en 与 column_name 完全一致
+            // 列名固定 c{位置}:既保证长度 ≤5,也保证 field_name_en 与 column_name 完全一致
             String columnName = "c" + pos;
-            String type = bind == null ? "TEXT" : typeOf(bind);
-            boolean required = bind != null && Boolean.TRUE.equals(bind.getRequired());
+
+            TemplateCandidate.FieldBrief meta = bind == null ? null
+                    : candFields.get(normalizeKey(bind.getFieldNameEn()));
+            String type;
+            boolean required;
+            String directionConf;
+            if (meta != null) {
+                type = StrUtil.blankToDefault(meta.type(), "TEXT");
+                required = meta.required();
+                directionConf = meta.directionConf();
+            } else {
+                type = resolveType(nameCn, bind, statByIndex.get(fileColIndex));
+                // 没有模板元数据时不采信模型的 required(prompt 并不要求模型输出它;
+                // 一旦模型偶发给出 true,会把所有稀疏列变成必填、整行被丢)
+                required = false;
+                directionConf = null;
+            }
             AiCleanField f = new AiCleanField();
             f.setColumnName(columnName);
             f.setFieldNameCn(nameCn);
             f.setFieldNameEn(columnName);
             f.setFieldType(type);
             f.setRequired(required ? 1 : 0);
+            // AI 动态表统一 TEXT/VARCHAR 无界:fieldLen 只做展示/校验,不在 DDL 里用
             f.setFieldLen(0);
             f.setFieldSort(pos + 1);
             f.setColIndex(fileColIndex);
             f.setMatched(1);
             fields.add(f);
-            columns.add(CompiledColumn.of(fileColIndex, columnName, columnName, type, required, 0,
-                    bind == null ? List.of() : AiRuleCompiler.compileFuns(bind)));
+            columns.add(new CompiledColumn(fileColIndex, columnName, columnName, type, required, 0,
+                    bind == null ? List.of() : AiRuleCompiler.compileFuns(bind), directionConf));
         }
         return new Plan(columns, fields);
     }
 
     /**
-     * 复用已有 AI 模板时的执行计划:列名/类型一律以存档的 {@code ai_clean_field} 为准,
+     * 复用已有 AI 模板时的执行计划:列名/类型/必填一律以存档的 {@code ai_clean_field} 为准,
      * 物理表结构不会因为「这次 AI 多绑/少绑了列」而对不上。
      */
     public static Plan forStoredTemplate(List<AiCleanField> storedFields, List<RuleBind> binds) {
@@ -152,14 +225,16 @@ public final class AiRulePlan {
         List<CompiledColumn> columns = new ArrayList<>();
         for (AiCleanField f : sorted) {
             RuleBind bind = f.getColIndex() == null ? null : bindByIndex.get(f.getColIndex());
-            columns.add(CompiledColumn.of(
+            String type = StrUtil.blankToDefault(f.getFieldType(), "TEXT");
+            columns.add(new CompiledColumn(
                     f.getColIndex() == null ? 0 : f.getColIndex(),
                     f.getColumnName(),
                     f.getFieldNameEn(),
-                    f.getFieldType(),
+                    type,
                     Integer.valueOf(1).equals(f.getRequired()),
                     f.getFieldLen(),
-                    bind == null ? List.of() : AiRuleCompiler.compileFuns(bind)));
+                    bind == null ? List.of() : AiRuleCompiler.compileFuns(bind),
+                    f.getDirectionConf()));
         }
         return new Plan(columns, sorted);
     }
@@ -183,30 +258,167 @@ public final class AiRulePlan {
     }
 
     /**
-     * 没有模板元数据可依据时,按列名与形状猜类型;猜不准就 TEXT。
-     * TEXT 是安全兜底 —— 建表类型错了会让整列写入失败,宁可不转型。
+     * 类型解析:模板元数据由调用方优先处理;这里处理「没有模板字段」的列。
+     *
+     * <p>形状能确定日期/时间/电话/身份证/卡号/金额时直接采用;形状只是 NUMBER 时
+     * 再看表头语义(整数金额很常见,不能因为样本没有小数就判成 INTEGER)。
      */
-    private static String typeOf(RuleBind b) {
-        String en = StrUtil.nullToEmpty(b.getFieldNameEn()).toLowerCase();
-        if (en.contains("amount") || en.contains("money") || en.contains("balance")) {
-            return "MONEY";
-        }
-        if (en.contains("datetime") || en.contains("time")) {
+    static String resolveType(String header, RuleBind bind, ColStat stat) {
+        String shape = stat == null ? "" : StrUtil.nullToEmpty(stat.shape()).toUpperCase();
+        String headerType = typeOf(header);
+        // 13 位 epoch / 10 位秒级时间戳的形状会被判成长数字(ACCT)或 NUMBER,
+        // 表头明确是「时间/时间戳」时以时间类型为准,交给 coercer 按 epoch 解析
+        if (("ACCT".equals(shape) || "NUMBER".equals(shape)) && "DATE_TIME".equals(headerType)) {
             return "DATE_TIME";
         }
-        if (en.contains("date") || en.contains("day")) {
-            return "DATE";
+        String shapeType = SHAPE_TYPES.get(shape);
+        if (shapeType != null) {
+            return shapeType;
+        }
+        if ("NUMBER".equals(shape)) {
+            // 形状是整数/小数:表头明确说了金额/日期/电话等类型时以表头为准
+            if (!"TEXT".equals(headerType)) {
+                return headerType;
+            }
+            return "NUMBER";
+        }
+        if (!"TEXT".equals(headerType)) {
+            return headerType;
+        }
+        if (bind != null) {
+            return typeOf(bind.getFieldNameEn());
+        }
+        return "TEXT";
+    }
+
+    /**
+     * 表头/字段名 → 类型。中文按关键词,英文按分词(避免 update 命中 date、candidate 命中 date)。
+     * 只做兜底,模板元数据与列形状优先。
+     */
+    static String typeOf(String name) {
+        String raw = StrUtil.nullToEmpty(name);
+        if (raw.isBlank()) {
+            return "TEXT";
         }
-        if (en.contains("phone") || en.contains("mobile")) {
+        String lower = raw.toLowerCase(Locale.ROOT);
+        // 中文关键词(先判更具体的“日期时间”,再判日期/时间)
+        if (containsAny(lower, "身份证", "证件号码", "证件号", "公民身份号码")) {
+            return "ID_CARD_NUMBER";
+        }
+        if (containsAny(lower, "手机号", "手机", "电话", "联系方式", "联系电话")) {
             return "PHONE";
         }
-        if (en.contains("cardno") || en.contains("acct")) {
+        if (containsAny(lower, "卡号", "账号", "账户号", "银行账号", "银行卡")) {
             return "ACCT_CARD_NO";
         }
-        if (en.contains("idcard") || en.contains("certno")) {
+        if (containsAny(lower, "日期时间", "交易时间", "发生时间", "创建时间", "更新时间",
+                "时间戳", "时间", "时刻")) {
+            return "DATE_TIME";
+        }
+        if (containsAny(lower, "日期", "生日", "出生日期")) {
+            return "DATE";
+        }
+        if (containsAny(lower, "时长", "耗时", "秒数", "数量", "人数", "次数", "年龄")) {
+            return "NUMBER";
+        }
+        if (containsAny(lower, "金额", "余额", "发生额", "交易额", "资金", "单价", "价格", "费用",
+                "手续费", "收入", "支出", "应收", "应付", "合计", "总计", "工资", "税额")) {
+            return "MONEY";
+        }
+
+        // 英文紧凑串先判不可拆的复合词:id_card → idcard、card_no → cardno、
+        // transDateTime → transdatetime;不能拿 date 做子串判断(updateUser 含 date)
+        String compact = lower.replaceAll("[^a-z0-9]", "");
+        if (compact.contains("idcard") || compact.contains("identitycard") || compact.contains("certno")) {
             return "ID_CARD_NUMBER";
         }
-        return "TEXT";
+        if (compact.contains("cardno") || compact.contains("accountno")
+                || compact.contains("bankcard") || compact.contains("bankaccount")) {
+            return "ACCT_CARD_NO";
+        }
+        if (compact.contains("datetime") || compact.contains("timestamp")) {
+            return "DATE_TIME";
+        }
+
+        // 英文分词:update → [update] 不会命中 date;updateDate → [update, date]
+        String spaced = raw.replaceAll("([a-z0-9])([A-Z])", "$1 $2");
+        String[] tokens = spaced.toLowerCase(Locale.ROOT).split("[^a-z0-9]+");
+        // 第一轮:具体类型(日期/时间/电话/卡/证件)优先于 total 这类泛化词
+        for (String token : tokens) {
+            switch (token) {
+                case "datetime", "timestamp", "time", "hms" -> {
+                    return "DATE_TIME";
+                }
+                case "date", "day" -> {
+                    return "DATE";
+                }
+                case "phone", "mobile", "tel", "telephone", "contact" -> {
+                    return "PHONE";
+                }
+                case "cardno", "card", "account", "acct", "bankaccount" -> {
+                    return "ACCT_CARD_NO";
+                }
+                case "idcard", "identity", "certno", "certificate" -> {
+                    return "ID_CARD_NUMBER";
+                }
+                default -> {
+                    // 第二轮再看金额/数字
+                }
+            }
+        }
+        // 第二轮:金额优先于数字;total 放最后,避免 totalTime/totalAmount 被误判成 NUMBER
+        boolean genericNumber = false;
+        for (String token : tokens) {
+            switch (token) {
+                case "amount", "money", "balance", "fee", "price", "cost", "salary" -> {
+                    return "MONEY";
+                }
+                case "number", "num", "count", "qty", "quantity", "age" -> {
+                    return "NUMBER";
+                }
+                case "total" -> genericNumber = true;
+                default -> {
+                    // 继续
+                }
+            }
+        }
+        return genericNumber ? "NUMBER" : "TEXT";
+    }
+
+    private static Map<String, TemplateCandidate.FieldBrief> indexCandidateFields(TemplateCandidate cand) {
+        Map<String, TemplateCandidate.FieldBrief> out = new HashMap<>();
+        if (cand == null || cand.fields() == null) {
+            return out;
+        }
+        for (TemplateCandidate.FieldBrief f : cand.fields()) {
+            if (f == null) {
+                continue;
+            }
+            putIfAbsent(out, f.en(), f);
+            putIfAbsent(out, f.cn(), f);
+        }
+        return out;
+    }
+
+    private static void putIfAbsent(Map<String, TemplateCandidate.FieldBrief> map, String key,
+                                    TemplateCandidate.FieldBrief value) {
+        String normalized = normalizeKey(key);
+        if (!normalized.isEmpty()) {
+            map.putIfAbsent(normalized, value);
+        }
+    }
+
+    private static String normalizeKey(String key) {
+        return StrUtil.nullToEmpty(key).trim().toLowerCase(Locale.ROOT);
+    }
+
+    private static boolean containsAny(String text, String... needles) {
+        for (String needle : needles) {
+            if (text.contains(needle)) {
+                return true;
+            }
+        }
+        return false;
     }
 
     private static String colName(List<String> headers, Integer idx) {

+ 212 - 47
ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiCleanService.java

@@ -4,6 +4,7 @@ import cn.hutool.core.io.FileUtil;
 import cn.hutool.core.util.IdUtil;
 import cn.hutool.core.util.StrUtil;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.fasterxml.jackson.core.type.TypeReference;
 import com.zsjz.ai.common.base.BasicColumn;
 import com.zsjz.ai.common.cache.GlobalCache;
 import com.zsjz.ai.common.context.CaseAccessGuard;
@@ -20,12 +21,14 @@ import com.zsjz.ai.module.aiclean.enums.AiCleanExitEnum;
 import com.zsjz.ai.module.aiclean.enums.AiCleanStatusEnum;
 import com.zsjz.ai.module.aiclean.exec.AiDuckDb;
 import com.zsjz.ai.module.aiclean.exec.AiRecordCleaner;
+import com.zsjz.ai.module.aiclean.exec.AiRejectCollector;
 import com.zsjz.ai.module.aiclean.exec.ExistingTemplateDispatcher;
 import com.zsjz.ai.module.aiclean.mapper.AiCleanFieldMapper;
 import com.zsjz.ai.module.aiclean.mapper.AiCleanJobMapper;
 import com.zsjz.ai.module.aiclean.mapper.AiCleanTemplateMapper;
 import com.zsjz.ai.module.aiclean.match.AiTemplateMatcher;
 import com.zsjz.ai.module.aiclean.model.AiMatchOutcome;
+import com.zsjz.ai.module.aiclean.model.AiRowCleanResult;
 import com.zsjz.ai.module.aiclean.model.AiRuleDraft;
 import com.zsjz.ai.module.aiclean.model.RuleBind;
 import com.zsjz.ai.module.aiclean.model.SheetEvidence;
@@ -34,12 +37,14 @@ import com.zsjz.ai.module.aiclean.model.VerifyReport;
 import com.zsjz.ai.module.aiclean.probe.AiRowReader;
 import com.zsjz.ai.module.aiclean.probe.SheetProbe;
 import com.zsjz.ai.module.aiclean.rule.AiRulePlan;
+import com.zsjz.ai.module.aiclean.rule.CompiledColumn;
 import com.zsjz.ai.module.aiclean.store.AiCleanStore;
 import com.zsjz.ai.module.aiclean.verify.AiRuleVerifier;
 import com.zsjz.ai.module.dm.mapper.FileInfoMapper;
 import com.zsjz.ai.module.agent.llm.LlmResult;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 
 import java.io.File;
@@ -89,6 +94,14 @@ public class AiCleanService {
 
     private final CaseAccessGuard caseAccessGuard;
 
+    /** 每张表最多打多少条「拒绝行」应用日志(完整明细在 ai_clean_reject 表里) */
+    @Value("${zsjz.ai-clean.reject-log-sample:20}")
+    private int rejectLogSample;
+
+    /** 调试开关:每一行拒绝都打应用日志(默认只打前 N 条,避免刷爆) */
+    @Value("${zsjz.ai-clean.reject-log-all:false}")
+    private boolean rejectLogAll;
+
     /** 手动重建当前案件的 AI 治理节点(幂等;正常情况由治理任务与 apply 后自动触发) */
     public int refreshTree() {
         return aiGovernService.calcAiTree();
@@ -264,16 +277,46 @@ public class AiCleanService {
             if (selfBinds.isEmpty()) {
                 return new AiJudgeResult.NeedReview("表头为空,无法按文件自建表");
             }
+            // 自建表照常入库,但匹配率口径必须能把它与真实模板命中分开
+            job.setMatchedBy(AiMatchOutcome.BY_SELF_HEADER);
             return new AiJudgeResult.AiPlan(ev, selfBinds, null, null, headers);
         }
         // 指纹/打分命中 AI 模板库的表:复用存档规则与已有动态表,零模型调用 ——
         // header_md5 是唯一键,再建同指纹模板会撞 uk_ai_clean_tpl_md5(2026-10-11 实测)
         if (!outcome.exit().writesExistingTable() && outcome.candidate().aiTemplateId() != null) {
-            List<RuleBind> stored = store.loadBinds(outcome.candidate().aiTemplateId());
-            if (!stored.isEmpty()) {
-                return new AiJudgeResult.AiPlan(ev, stored, outcome.candidate(), null, headers);
+            AiCleanTemplate tpl = templateMapper.selectById(outcome.candidate().aiTemplateId());
+            if (tpl != null && !"ONLINE".equals(tpl.getStatus())) {
+                // OFFLINE/DRAFT 模板仍占着 header_md5 唯一键,继续新建会撞键并留下幽灵表,
+                // 明确转人工比静默失败好;Phase B 再做重新激活/晋升。
+                return new AiJudgeResult.NeedReview("命中 AI 模板但状态为 " + tpl.getStatus()
+                        + ",需人工确认后再清洗");
             }
-            // 存档规则缺失(历史异常数据)→ 落回原路:按本次表头问模型补绑定
+            if (tpl != null) {
+                List<RuleBind> stored = store.loadBinds(tpl.getId());
+                List<AiCleanField> storedFields = stored.isEmpty() ? List.of()
+                        : fieldMapper.selectList(Wrappers.<AiCleanField>lambdaQuery()
+                        .eq(AiCleanField::getAiTemplateId, tpl.getId())
+                        .orderByAsc(AiCleanField::getFieldSort));
+                if (!stored.isEmpty() && !storedFields.isEmpty()) {
+                    // 复用路径也要抽样校验:存档类型/必填可能已经不适配当前文件,
+                    // 不校验就直接写,会把错列/错类型的数据静默落库。
+                    mark(job, AiCleanStatusEnum.VERIFYING);
+                    List<CompiledColumn> columns = AiRulePlan.forStoredTemplate(storedFields, stored).columns();
+                    List<Map<Integer, String>> sample = loadSample(root, sheet, ev, job);
+                    VerifyReport report = verify(sample, columns, outcome.candidate(), outcome, ev);
+                    job.setConfidence(java.math.BigDecimal.valueOf(report.confidence().overall()));
+                    job.setDetail(detailJson(report));
+                    if (requiredRejected(columns, report)) {
+                        return new AiJudgeResult.NeedReview("复用模板的必填列抽样错误率过高,需人工确认");
+                    }
+                    if (!report.confidence().pass(AiCleanExitEnum.B)) {
+                        return new AiJudgeResult.NeedReview(StrUtil.format(
+                                "复用模板置信度未达出口 B 的放行线: {}", report.confidence()));
+                    }
+                    return new AiJudgeResult.AiPlan(ev, stored, outcome.candidate(), null, headers);
+                }
+            }
+            // 存档规则/字段缺失(历史异常数据)→ 落回原路:按本次表头问模型补绑定
         }
         // 选模板那次调用已经把列绑定一起返回了,复用它:单次模型调用实测就要几十秒,
         // 为了拿 binds 再打一次等于把延迟和 token 直接翻倍
@@ -305,19 +348,15 @@ public class AiCleanService {
         }
 
         mark(job, AiCleanStatusEnum.VERIFYING);
-        int headerRow = ev.primaryBlock().headerRow();
-        // 抽样优先读上传预览表 raw_{sheetId}:与源文件同一套读值口径(Fesod 同配置 + 同款转换器),
-        // 但不用再把整份文件重新解析一遍。老预览表 / 预览写入失败时回退读源文件。
-        List<Map<Integer, String>> sample = AiDuckDb.readRawSample(job.getSheetId(), headerRow, SAMPLE,
-                sheet.getDataNum() == null ? 0 : sheet.getDataNum());
-        if (sample == null) {
-            log.info("预览表不可用,AI 抽样回退读源文件: jobId={}, sheetId={}", job.getId(), job.getSheetId());
-            sample = AiRowReader.read(new File(root.getFilePath()), sheet.getSheetName(), headerRow, SAMPLE);
-        }
-        VerifyReport report = verify(binds, outcome.candidate(), headers, sample, outcome);
+        List<Map<Integer, String>> sample = loadSample(root, sheet, ev, job);
+        List<CompiledColumn> verifyColumns = resolveColumns(binds, ev, outcome.candidate(), outcome);
+        VerifyReport report = verify(sample, verifyColumns, outcome.candidate(), outcome, ev);
         job.setConfidence(java.math.BigDecimal.valueOf(report.confidence().overall()));
         // 抽样报告落库(预览行截到 5 行防膨胀):排障时能看到「哪个列错误率多少、为什么被拒」
         job.setDetail(detailJson(report));
+        if (requiredRejected(verifyColumns, report)) {
+            return new AiJudgeResult.NeedReview("必填列抽样错误率过高,整行拒绝比例过大,需人工确认");
+        }
         if (report.rejectedCols() != null && !report.rejectedCols().isEmpty()) {
             binds = binds.stream().filter(b -> !report.rejectedCols().contains(b.getFileColIndex())).toList();
         }
@@ -432,7 +471,7 @@ public class AiCleanService {
             aiPlan = AiRulePlan.forStoredTemplate(storedFields, binds);
             log.info("AI 表指纹命中,复用已有动态表: table={}, templateId={}", tpl.getTableNameEn(), tpl.getId());
         } else {
-            aiPlan = AiRulePlan.forAi(binds, headers);
+            aiPlan = AiRulePlan.forAi(binds, headers, ev.colStats(), plan.candidate());
             // 模板 id 先行:物理表名依赖它;元数据登记挪到写数成功之后
             tpl = new AiCleanTemplate();
             tpl.setId(IdUtil.getSnowflakeNextId());
@@ -470,35 +509,32 @@ public class AiCleanService {
                 ruleRows.add(r);
             }
         }
-        String err = reuse ? null : AiDuckDb.createAiTable(tpl.getTableNameEn(), aiPlan.fields());
+        // 复用模板时物理表可能在当前案件不存在(跨案件共享/被手工删过):
+        // CREATE TABLE IF NOT EXISTS 是幂等的,复用也走一次,避免 openWriter 直接失败
+        String err = AiDuckDb.createAiTable(tpl.getTableNameEn(), aiPlan.fields());
         if (err != null) {
             log.error("AI 动态表建表失败,跳过写入: table={}, err={}", tpl.getTableNameEn(), err);
             return -1L;
         }
-        // 同一份文件的同一 sheet 重新清洗:先清掉它上一轮写进这张动态表的行,
-        // 否则「重新清洗/改了模板再跑」会把整表数据重复追加(AI 表按 file_id+sheet_id 隔离,不会误删别的文件)
-        long purged = AiDuckDb.deleteByFileSheet(tpl.getTableNameEn(), job.getFileId(), job.getSheetId());
-        if (purged > 0) {
-            log.info("AI 重清洗,已清理该 sheet 旧行: table={}, fileId={}, sheetId={}, rows={}",
-                    tpl.getTableNameEn(), job.getFileId(), job.getSheetId(), purged);
-        }
-        // lambda 要求事实最终变量:tpl/reuse 在上面按复用/新建二选一赋值后不再变
+        // lambda 要求事实最终变量:tpl 在上面按复用/新建二选一赋值后不再变
         final AiCleanTemplate tplF = tpl;
-        final boolean reuseF = reuse;
         List<String> cols = aiPlan.fields().stream().map(AiCleanField::getColumnName).toList();
-        long written;
-        java.util.concurrent.atomic.AtomicLong total = new java.util.concurrent.atomic.AtomicLong();
         int headerRow = ev.primaryBlock().headerRow();
+        AiRejectCollector rejects = new AiRejectCollector(job.getId(), job.getCaseId(), job.getFileId(),
+                job.getSheetId(), ev.primaryBlock().blockNo(), rejectLogSample, rejectLogAll);
+        long written = -1L;
         try (AiDuckDb.BatchWriter writer = AiDuckDb.openWriter(tpl.getTableNameEn(), cols)) {
             // 行数据优先取自上传预览表:不用为了入库再把整份源文件重新解析一遍。
             // 注意这里仍然逐行走 AiRecordCleaner —— 预览表存的是原值(全 VARCHAR),
             // 特殊字符剔除、函数链、类型规整都还没做,纯 SQL 的 CREATE TABLE AS 会把这些全部丢掉。
             AiDuckDb.RawRowHandler handler = (row, lineNo) -> {
-                Map<String, String> errors = new LinkedHashMap<>();
-                Map<String, Object> cleaned = AiRecordCleaner.cleanRow(row, aiPlan.columns(), errors);
-                if (cleaned == null) {
+                rejects.countSource();
+                AiRowCleanResult result = AiRecordCleaner.cleanRowDetailed(row, aiPlan.columns());
+                rejects.record(lineNo, result);
+                if (result.rejected()) {
                     return;
                 }
+                Map<String, Object> cleaned = new LinkedHashMap<>(result.values());
                 cleaned.put("id", IdUtil.getSnowflakeNextId());
                 cleaned.put("case_id", job.getCaseId());
                 cleaned.put("file_id", job.getFileId());
@@ -510,9 +546,13 @@ public class AiCleanService {
                 cleaned.put("ai_job_id", job.getId());
                 cleaned.put("ai_rule_ver", tplF.getVer() == null ? 1 : tplF.getVer());
                 cleaned.put("category", tplF.getCategory());
-                // 写失败会在这里抛出,中断流式读取 —— 半截数据绝不能被当成成功
-                writer.add(cleaned);
-                total.incrementAndGet();
+                try {
+                    // 写失败会在这里抛出,中断流式读取 —— 半截数据绝不能被当成成功
+                    writer.add(cleaned);
+                } catch (RuntimeException e) {
+                    rejects.recordWriterError(lineNo, e.getMessage());
+                    throw e;
+                }
             };
             AiDuckDb.RawSheet raw = AiDuckDb.rawSheet(job.getSheetId(), expectedRawRows(sheet, job));
             boolean ok;
@@ -530,31 +570,115 @@ public class AiCleanService {
             written = ok ? writer.finish() : -1L;
         } catch (Exception e) {
             log.error("AI 流式入库失败: jobId={}, table={}, err={}", job.getId(), tpl.getTableNameEn(), e.getMessage());
+            // 半截新行按 job 清掉;旧行此时还没 purge,保持原样
+            AiDuckDb.deleteByJob(tpl.getTableNameEn(), job.getId());
+            if (!reuse) {
+                AiDuckDb.dropTable(tpl.getTableNameEn());
+            }
+            rejects.flush();
+            fillLoadStats(job, rejects, 0, true);
             return -1L;
         }
+        rejects.flush();
         if (written <= 0) {
             // 一行没落库不能算成功(含「读到 0 行」):全自动链路没有人看日志,
             // 写成 0 行会被上层当成清洗完成。新建表删掉不留幽灵;复用表里还有
-            // 此前批次的数据,绝不能连带删
+            // 此前批次的数据,只清本次 job 的半截行,绝不连带删旧数据
+            AiDuckDb.deleteByJob(tpl.getTableNameEn(), job.getId());
             if (!reuse) {
                 AiDuckDb.dropTable(tpl.getTableNameEn());
             }
+            fillLoadStats(job, rejects, 0, true);
             return -1L;
         }
-        // 数据已落盘,现在才登记元数据为 ONLINE 并刷新治理节点(写序收口);
-        // 复用模板只刷计数,不重复登记
-        if (!reuse) {
+        // 数据已写成功,复用表现在才清旧行:先写后删,写入失败不会丢旧数据。
+        if (reuse) {
+            long purged = AiDuckDb.deleteByFileSheetExcludingJob(tpl.getTableNameEn(),
+                    job.getFileId(), job.getSheetId(), job.getId());
+            if (purged < 0) {
+                // 旧行清不掉就回滚本次写入,避免同一 sheet 出现两份数据
+                AiDuckDb.deleteByJob(tpl.getTableNameEn(), job.getId());
+                fillLoadStats(job, rejects, 0, true);
+                return -1L;
+            }
+            if (purged > 0) {
+                log.info("AI 重清洗完成,已清理该 sheet 旧行: table={}, fileId={}, sheetId={}, rows={}",
+                        tpl.getTableNameEn(), job.getFileId(), job.getSheetId(), purged);
+            }
+        } else {
+            // 数据已落盘,现在才登记元数据为 ONLINE 并刷新治理节点(写序收口)
             tpl.setStatus("ONLINE");
-            store.register(tpl, aiPlan.fields(), ruleRows);
+            try {
+                store.register(tpl, aiPlan.fields(), ruleRows);
+            } catch (Exception e) {
+                // 登记失败不能留下「有物理表、没有元数据」的幽灵表
+                log.error("AI 模板登记失败,清理新建动态表: jobId={}, table={}, err={}",
+                        job.getId(), tpl.getTableNameEn(), e.getMessage());
+                AiDuckDb.dropTable(tpl.getTableNameEn());
+                fillLoadStats(job, rejects, 0, true);
+                return -1L;
+            }
+        }
+        try {
+            store.refreshTree();
+        } catch (Exception e) {
+            log.error("AI 清洗后刷新治理节点失败(数据已入库): {}", e.getMessage());
         }
-        store.refreshTree();
         job.setAiTemplateId(tpl.getId());
         job.setExitCode(AiCleanExitEnum.B.name());
-        job.setRowTotal((int) Math.min(total.get(), Integer.MAX_VALUE));
-        job.setRowLoaded((int) Math.min(written, Integer.MAX_VALUE));
+        fillLoadStats(job, rejects, written, false);
         return written;
     }
 
+    /**
+     * 把行级统计写回 job,并合并进 {@code detail.load}。
+     *
+     * <p>成功任务:{@code rowTotal = rowLoaded + rowRejected},{@code rowUnprocessed=0}。
+     * 失败任务:半截新行已按 job 清掉,最终表没有本次新增行,所以 {@code rowLoaded=0};
+     * 未被拒绝的行都算未完成(含已写但被回滚的行)。
+     */
+    static void fillLoadStats(AiCleanJob job, AiRejectCollector rejects, long written, boolean failed) {
+        int source = (int) Math.min(rejects.source(), Integer.MAX_VALUE);
+        int rejected = (int) Math.min(rejects.rejected(), Integer.MAX_VALUE);
+        int loaded = failed ? 0 : (int) Math.min(Math.max(0, written), Integer.MAX_VALUE);
+        int unprocessed = failed ? Math.max(0, source - rejected) : 0;
+        job.setRowTotal(source);
+        job.setRowLoaded(loaded);
+        job.setRowRejected(rejected);
+        job.setRowWarned((int) Math.min(rejects.warned(), Integer.MAX_VALUE));
+        job.setRowUnprocessed(unprocessed);
+        job.setDetail(mergeLoadDetail(job.getDetail(), rejects.summaryJson(loaded, unprocessed)));
+    }
+
+    /** 保留 verify 的 detail,同时把行级统计挂到 {@code load} 节点下 */
+    private static String mergeLoadDetail(String existing, String loadJson) {
+        String fallback = "{\"load\":" + loadJson + "}";
+        if (StrUtil.isBlank(existing)) {
+            return StrUtil.maxLength(fallback, 8000);
+        }
+        try {
+            Map<String, Object> root = Json.toCls(existing, new TypeReference<Map<String, Object>>() {
+            });
+            root.put("load", Json.toCls(loadJson, new TypeReference<Map<String, Object>>() {
+            }));
+            return StrUtil.maxLength(Json.toStr(root), 8000);
+        } catch (Exception e) {
+            return StrUtil.maxLength(fallback, 8000);
+        }
+    }
+
+    /**
+     * 分页查询某个 job 的行级失败明细;跨案件拒绝。
+     */
+    public Map<String, Object> rejectPage(Long jobId, long page, long size, String reason) {
+        AiCleanJob job = jobId == null ? null : jobMapper.selectById(jobId);
+        if (job == null) {
+            return Map.of("records", List.of(), "total", 0L, "size", size, "current", Math.max(1, page));
+        }
+        caseAccessGuard.requireOwnedCurrentCase(job.getCaseId());
+        return AiDuckDb.rejectPage(jobId, reason, page, size);
+    }
+
     /**
      * 预览表完整性校验用的行数:上传解析时 {@code FileInfo.dataNum} 记的就是写入 raw 的行数。
      *
@@ -655,6 +779,7 @@ public class AiCleanService {
             return 0L;
         }
         long n = store.revert(tpl.getTableNameEn(), jobId);
+        AiDuckDb.deleteRejectsByJob(jobId);
         job.setStatus(AiCleanStatusEnum.SKIP);
         job.setUpdateTime(LocalDateTime.now());
         job.setFailReason("已按 job 撤销,删除行数 " + n);
@@ -662,12 +787,52 @@ public class AiCleanService {
         return n;
     }
 
-    private VerifyReport verify(List<RuleBind> binds, TemplateCandidate cand, List<String> headers,
-                                List<Map<Integer, String>> sample, AiMatchOutcome outcome) {
-        AiRulePlan.Plan plan = AiRulePlan.forAi(binds, headers);
-        long total = sample.size();
-        return AiRuleVerifier.verify(sample, plan.columns(), outcome.candidate().score(),
-                cand.score(), total);
+    /**
+     * 抽样:优先后传预览表(不重读源文件),不可用时回退源文件。
+     */
+    private List<Map<Integer, String>> loadSample(FileInfo root, FileInfo sheet, SheetEvidence ev,
+                                                  AiCleanJob job) {
+        int headerRow = ev.primaryBlock().headerRow();
+        List<Map<Integer, String>> sample = AiDuckDb.readRawSample(job.getSheetId(), headerRow, SAMPLE,
+                sheet.getDataNum() == null ? 0 : sheet.getDataNum());
+        if (sample == null) {
+            log.info("预览表不可用,AI 抽样回退读源文件: jobId={}, sheetId={}", job.getId(), job.getSheetId());
+            sample = AiRowReader.read(new File(root.getFilePath()), sheet.getSheetName(), headerRow, SAMPLE);
+        }
+        return sample;
+    }
+
+    /**
+     * 与真正执行相同的列计划:出口 A 用 table_field,出口 B 用模板/形状推断。
+     * verify 与 load 共用这一处,避免「校验一套、执行另一套」。
+     */
+    private List<CompiledColumn> resolveColumns(List<RuleBind> binds, SheetEvidence ev,
+                                                TemplateCandidate cand, AiMatchOutcome outcome) {
+        List<String> headers = ev.primaryBlock().headers();
+        if (cand != null && !cand.fromAiLib() && outcome.exit() == AiCleanExitEnum.A) {
+            return AiRulePlan.forExistingColumns(binds, cand, headers);
+        }
+        return AiRulePlan.forAi(binds, headers, ev.colStats(), cand).columns();
+    }
+
+    private VerifyReport verify(List<Map<Integer, String>> sample, List<CompiledColumn> columns,
+                                TemplateCandidate cand, AiMatchOutcome outcome, SheetEvidence ev) {
+        return AiRuleVerifier.verify(sample, columns, cand == null ? 0d : cand.score(),
+                draftConfidence(outcome), ev.totalRows());
+    }
+
+    private static double draftConfidence(AiMatchOutcome outcome) {
+        return outcome.draft() != null && outcome.draft().getConfidence() != null
+                ? outcome.draft().getConfidence() : 0d;
+    }
+
+    /** 必填列被抽样拒绝时不能静默删列 —— 关键列缺失比整体转人工更糟 */
+    private static boolean requiredRejected(List<CompiledColumn> columns, VerifyReport report) {
+        if (report.rejectedCols() == null || report.rejectedCols().isEmpty()) {
+            return false;
+        }
+        return columns.stream().anyMatch(c -> c.required()
+                && report.rejectedCols().contains(c.colIndex()));
     }
 
     /**

+ 106 - 27
ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiSmartCleanService.java

@@ -172,7 +172,9 @@ public class AiSmartCleanService {
         List<NeedReviewItem> items = reviewItems.getOrDefault(batchId, parseReviews(batch));
         return new SmartCleanStatusVO(batch.getStage().name(), batch.getFileCount(), batch.getSheetTotal(),
                 batch.getMatchedCount(), batch.getAiJudgedCount(), batch.getExistingHitCount(),
-                batch.getAiLoadCount(), batch.getNeedReviewCount(), batch.getFailedCount(), items);
+                batch.getAiLoadCount(), nzLong(batch.getLoadedRows()), nzLong(batch.getRejectedRows()),
+                nzLong(batch.getUnprocessedRows()), nz(batch.getSelfBuiltCount()),
+                batch.getNeedReviewCount(), batch.getFailedCount(), items);
     }
 
     /**
@@ -223,37 +225,73 @@ public class AiSmartCleanService {
                     latestBySheet.putIfAbsent(job.getSheetId(), job);
                 }
             }
-            // AI 动态表(出口 B)的 sheet,file_info 里记的是「前置解析读到的原始行数」(含标题/表头行),
-            // 不能再计入系统清洗成功数,否则会与 AI 实际入库行数重复累加。
-            java.util.Set<Long> aiLoadedSheetIds = new java.util.HashSet<>();
-            long aiRows = 0;
-            long aiFailRows = 0;
-            for (AiCleanJob job : latestBySheet.values()) {
-                if (job.getAiTemplateId() != null) {
-                    aiLoadedSheetIds.add(job.getSheetId());
-                }
-                aiRows += java.util.Objects.requireNonNullElse(job.getRowLoaded(), 0);
-                int rowTotal = java.util.Objects.requireNonNullElse(job.getRowTotal(), 0);
-                int rowLoaded = java.util.Objects.requireNonNullElse(job.getRowLoaded(), 0);
-                aiFailRows += Math.max(0, rowTotal - rowLoaded);
-            }
-            int dataNum = 0;
-            int failNum = 0;
+            // AI 动态表(出口 B)的真实口径看 job:rowLoaded 成功、rowRejected 失败、
+            // rowUnprocessed 未处理。NEED_REVIEW/SKIP 的 sheet 不再拿预览行数冒充成功。
+            java.util.Set<Long> aiCountedSheetIds = new java.util.HashSet<>();
+            long aiLoadedRows = 0;
+            long aiRejectedRows = 0;
+            long aiUnprocessedRows = 0;
+            Map<Long, FileInfo> sheetRows = new java.util.LinkedHashMap<>();
             for (FileInfo row : fileInfoMapper.selectList(Wrappers.lambdaQuery(FileInfo.class)
                     .eq(FileInfo::getBatchId, batchId)
                     .isNull(FileInfo::getFileType))) {
-                if (row.getId() != null && aiLoadedSheetIds.contains(row.getId())) {
+                sheetRows.put(row.getId(), row);
+            }
+            for (AiCleanJob job : latestBySheet.values()) {
+                if (job.getSheetId() == null || job.getStatus() == null) {
+                    continue;
+                }
+                int total = nz(job.getRowTotal());
+                int loaded = nz(job.getRowLoaded());
+                int rejected = nz(job.getRowRejected());
+                int unprocessed = nz(job.getRowUnprocessed());
+                int previewRows = nz(sheetRows.get(job.getSheetId()) == null ? null
+                        : sheetRows.get(job.getSheetId()).getDataNum());
+                switch (job.getStatus()) {
+                    case SUCCESS -> {
+                        aiLoadedRows += loaded;
+                        aiRejectedRows += rejected;
+                        // 出口 B 的 sheet 已经由 AI 行数统计,不能再按 file_info 的原始行累加
+                        aiCountedSheetIds.add(job.getSheetId());
+                    }
+                    case FAIL -> {
+                        aiRejectedRows += rejected;
+                        aiUnprocessedRows += unprocessed > 0 ? unprocessed : Math.max(0, total - rejected);
+                        aiCountedSheetIds.add(job.getSheetId());
+                    }
+                    case NEED_REVIEW, SKIP -> {
+                        aiUnprocessedRows += total > 0 ? total : previewRows;
+                        aiCountedSheetIds.add(job.getSheetId());
+                    }
+                    case PENDING, PROBING, STRUCTURING, MATCHING, VERIFYING, LOADING -> {
+                        // 批次异常中断时可能停在中间态:这些行没有清洗结果,不能算成功
+                        aiUnprocessedRows += previewRows;
+                        aiCountedSheetIds.add(job.getSheetId());
+                    }
+                    default -> {
+                        // DISPATCHED_A:交既有链路按 file_info 统计
+                    }
+                }
+            }
+            long existingCompleted = 0;
+            long existingFailed = 0;
+            for (Map.Entry<Long, FileInfo> e : sheetRows.entrySet()) {
+                if (aiCountedSheetIds.contains(e.getKey())) {
                     continue;
                 }
-                dataNum += java.util.Objects.requireNonNullElse(row.getDataNum(), 0);
-                failNum += java.util.Objects.requireNonNullElse(row.getFailNum(), 0);
+                FileInfo row = e.getValue();
+                int dataNum = nz(row.getDataNum());
+                int failNum = nz(row.getFailNum());
+                existingCompleted += Math.max(0, dataNum - failNum);
+                existingFailed += failNum;
             }
-            // 成功条数 = 系统清洗入库行数(dataNum-failNum)+ AI 动态表实际入库行数;
-            // 失败条数 = 系统清洗失败行数 + AI 动态表未入库行数
-            int completed = (int) Math.max(0, dataNum - failNum + aiRows);
-            int failed = (int) Math.max(0, failNum + aiFailRows);
+            long completed = Math.max(0, existingCompleted + aiLoadedRows);
+            long failed = Math.max(0, existingFailed + aiRejectedRows);
+            long unprocessed = Math.max(0, aiUnprocessedRows);
+            log.info("AI 清洗行数统计: batchId={}, 成功={}, 失败={}, 未处理={}", batchId, completed, failed, unprocessed);
             sseService.sendSee(userId, new SseDTO(101, 100, "[数据清洗]所有文件清洗完成!",
-                    new SeeDataDTO(completed, failed)));
+                    new SeeDataDTO((int) Math.min(completed, Integer.MAX_VALUE),
+                            (int) Math.min(failed, Integer.MAX_VALUE))));
             SmartCleanStatusVO vo = status(caseId, batchId);
             if (vo != null) {
                 sseService.sendSee(userId, new SseDTO(103, 100, "AI 清洗流程完成",
@@ -318,6 +356,7 @@ public class AiSmartCleanService {
                             addReview(batchId, p, "AI token 预算已耗尽,转人工处理");
                             st.needReview.incrementAndGet();
                             bump(batchId, "needReview");
+                            bumpRows(batchId, 0, 0, nz(p.sheet().getDataNum()));
                             bump(batchId, "aiJudged");
                             pushProgress(caseId, batchId);
                             return;
@@ -421,6 +460,7 @@ public class AiSmartCleanService {
                 addReview(batchId, p, r.reason());
                 st.needReview.incrementAndGet();
                 bump(batchId, "needReview");
+                bumpRows(batchId, 0, 0, nz(p.sheet().getDataNum()));
             }
             case AiJudgeResult.Skip r -> {
                 aiCleanService.finish(p.job(), AiCleanStatusEnum.SKIP, r.reason(), System.currentTimeMillis());
@@ -429,6 +469,7 @@ public class AiSmartCleanService {
                 addReview(batchId, p, r.reason());
                 st.needReview.incrementAndGet();
                 bump(batchId, "needReview");
+                bumpRows(batchId, 0, 0, nz(p.sheet().getDataNum()));
             }
             case AiJudgeResult.Failed r -> fail(batchId, p, r.reason(), st);
             case null -> fail(batchId, p, "AI 判定无结论", st);
@@ -442,6 +483,7 @@ public class AiSmartCleanService {
         aiCleanService.finish(p.job(), AiCleanStatusEnum.FAIL, reason, System.currentTimeMillis());
         st.failed.incrementAndGet();
         bump(batchId, "failed");
+        bumpRows(batchId, 0, 0, nz(p.sheet().getDataNum()));
     }
 
     /**
@@ -543,16 +585,22 @@ public class AiSmartCleanService {
                 aiCleanService.finish(p.job(), AiCleanStatusEnum.FAIL, "AI 动态表建表或写入失败", start);
                 st.failed.incrementAndGet();
                 bump(batchId, "failed");
+                bumpRows(batchId, 0, nz(p.job().getRowRejected()), nz(p.job().getRowUnprocessed()));
                 return;
             }
             aiCleanService.finish(p.job(), AiCleanStatusEnum.SUCCESS, null, start);
             st.aiLoad.incrementAndGet();
             bump(batchId, "aiLoad");
+            if (com.zsjz.ai.module.aiclean.model.AiMatchOutcome.BY_SELF_HEADER.equals(p.job().getMatchedBy())) {
+                bump(batchId, "selfBuilt");
+            }
+            bumpRows(batchId, nz(p.job().getRowLoaded()), nz(p.job().getRowRejected()), 0);
         } catch (Exception e) {
             log.error("AI 入库异常: jobId={}", p.job().getId(), e);
             aiCleanService.finish(p.job(), AiCleanStatusEnum.FAIL, StrUtil.maxLength(e.getMessage(), 900), start);
             st.failed.incrementAndGet();
             bump(batchId, "failed");
+            bumpRows(batchId, 0, nz(p.job().getRowRejected()), nz(p.job().getRowUnprocessed()));
         }
     }
 
@@ -627,6 +675,10 @@ public class AiSmartCleanService {
         b.setAiJudgedCount(0);
         b.setExistingHitCount(0);
         b.setAiLoadCount(0);
+        b.setLoadedRows(0L);
+        b.setRejectedRows(0L);
+        b.setUnprocessedRows(0L);
+        b.setSelfBuiltCount(0);
         b.setNeedReviewCount(0);
         b.setFailedCount(0);
         // updateById 默认不写 null,留空串才能把上一轮的明细清掉
@@ -650,11 +702,28 @@ public class AiSmartCleanService {
             case "aiJudged" -> "ai_judged_count";
             case "aiLoad" -> "ai_load_count";
             case "needReview" -> "need_review_count";
+            case "selfBuilt" -> "self_built_count";
             default -> "failed_count";
         };
         batchMapper.update(null, Wrappers.<AiCleanBatch>lambdaUpdate()
                 .eq(AiCleanBatch::getBatchId, batchId)
-                .setSql(expr + " = " + expr + " + 1, update_time = CURRENT_TIMESTAMP"));
+                .setSql(expr + " = COALESCE(" + expr + ", 0) + 1, update_time = CURRENT_TIMESTAMP"));
+    }
+
+    /**
+     * 原子累加行数:loaded=真正入库,rejected=逐行拒绝,unprocessed=未清洗/未处理。
+     * 三者在成功任务上满足 {@code loaded + rejected = 源行数}。
+     */
+    private void bumpRows(Long batchId, long loaded, long rejected, long unprocessed) {
+        if (loaded == 0 && rejected == 0 && unprocessed == 0) {
+            return;
+        }
+        batchMapper.update(null, Wrappers.<AiCleanBatch>lambdaUpdate()
+                .eq(AiCleanBatch::getBatchId, batchId)
+                .setSql("loaded_rows = COALESCE(loaded_rows, 0) + " + loaded
+                        + ", rejected_rows = COALESCE(rejected_rows, 0) + " + rejected
+                        + ", unprocessed_rows = COALESCE(unprocessed_rows, 0) + " + unprocessed
+                        + ", update_time = CURRENT_TIMESTAMP"));
     }
 
     private void stage(Long batchId, AiCleanStageEnum stage) {
@@ -710,6 +779,14 @@ public class AiSmartCleanService {
         return v == null ? 0 : v;
     }
 
+    private static int nz(Integer v) {
+        return v == null ? 0 : v;
+    }
+
+    private static long nzLong(Long v) {
+        return v == null ? 0L : v;
+    }
+
     @PreDestroy
     public void shutdown() {
         executor.shutdownNow();
@@ -741,7 +818,9 @@ public class AiSmartCleanService {
      */
     public record SmartCleanStatusVO(String stage, int fileCount, int sheetTotal, int matchedCount,
                                      int aiJudgedCount, int existingHitCount, int aiLoadCount,
-                                     int needReviewCount, int failedCount, List<NeedReviewItem> needReview) {
+                                     long loadedRows, long rejectedRows, long unprocessedRows,
+                                     int selfBuiltCount, int needReviewCount, int failedCount,
+                                     List<NeedReviewItem> needReview) {
 
         public boolean finished() {
             return "DONE".equals(stage) || "PARTIAL".equals(stage) || "FAILED".equals(stage);

+ 11 - 4
ai-server/src/main/java/com/zsjz/ai/module/aiclean/verify/AiRuleVerifier.java

@@ -81,6 +81,12 @@ public final class AiRuleVerifier {
                 preview, confidence, firstError);
     }
 
+    /**
+     * 必填覆盖率 = 抽样行里「所有必填列都非空」的行占比。
+     *
+     * <p>旧实现是「某个必填列在样本里出现过非空值就算填满」,只要 1/300 行有值也是 1.0,
+     * 与「必填缺失整行拒绝」的执行语义完全对不上。
+     */
     private static double requiredFillRate(List<Map<Integer, String>> rows,
                                            List<CompiledColumn> columns, int sampled) {
         List<CompiledColumn> required = columns.stream().filter(CompiledColumn::required).toList();
@@ -88,16 +94,17 @@ public final class AiRuleVerifier {
             return 1d;
         }
         int ok = 0;
-        for (CompiledColumn c : required) {
-            boolean filled = rows.stream().limit(sampled).anyMatch(r -> {
-                String v = r.get(c.colIndex());
+        for (int i = 0; i < sampled; i++) {
+            Map<Integer, String> row = rows.get(i);
+            boolean filled = required.stream().allMatch(c -> {
+                String v = row.get(c.colIndex());
                 return v != null && !v.isBlank();
             });
             if (filled) {
                 ok++;
             }
         }
-        return (double) ok / required.size();
+        return (double) ok / sampled;
     }
 
     private static List<List<String>> preview(List<Map<Integer, String>> rows, int sampled,

+ 13 - 26
ai-server/src/main/java/com/zsjz/ai/module/dm/clean/func/FunMultiplier.java

@@ -1,15 +1,17 @@
 package com.zsjz.ai.module.dm.clean.func;
 
-import cn.hutool.core.convert.Convert;
 import cn.hutool.core.util.StrUtil;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 
-import java.util.List;
+import java.math.BigDecimal;
 
 /**
- * MultiplierRule 类实现了 Function<String, String> 接口,
- * 其作用是将输入的字符串转换为 Double 类型,乘以指定的系数后,再将结果转换回字符串。
+ * 数值放大(单位归一,如万元 → 元)。
+ *
+ * <p>旧实现把小数点前后的部分拆开、只乘整数部分,整数输入还会因为
+ * {@code Convert.toInt("")} 得到 null 而 NPE。这里改成 BigDecimal 整体相乘:
+ * {@code 1.50 × 10000 = 15000.00}、{@code 100 × 10000 = 1000000}。
  *
  * @author cc
  */
@@ -21,37 +23,22 @@ public class FunMultiplier extends FunAbstract {
      */
     private Integer factor = 1;
 
-    /**
-     * 对输入的字符串进行乘法运算,并返回结果。
-     * 该方法首先将输入的字符串转换为 Double 类型,然后乘以指定的系数,最后将结果转换回字符串。
-     *
-     * @param input 要进行乘法运算的输入字符串。
-     * @return 经过乘法运算后的结果字符串。
-     */
     @Override
     public String apply(String input) {
         if (StrUtil.isEmpty(input)) {
             return null;
         }
-        //如果有小数。吧小数先 保存起来。在转成整数计算后,在吧小数拼接上
-        String first = "";
-        String last = "";
-        if (input.contains(".")) {
-            List<String> split = StrUtil.split(input, ".");
-            first = split.getFirst();
-            last = split.getLast();
+        try {
+            BigDecimal value = new BigDecimal(input.trim());
+            return value.multiply(BigDecimal.valueOf(factor)).toPlainString();
+        } catch (NumberFormatException e) {
+            // 交给 FunProcess 的异常处理:AI 侧会把它记成 FUNC_ERROR
+            throw new IllegalArgumentException("MultiplierRule 输入不是数字: " + input, e);
         }
-        Integer convert = Convert.toInt(first);
-        convert = convert * factor;
-        if (last == null) {
-            return convert.toString();
-        }
-        return convert + "." + last;
     }
 
     @Override
     public boolean isValid() {
         return getFactor() != null;
     }
-
-}
+}

+ 52 - 13
ai-server/src/main/java/com/zsjz/ai/module/dm/service/DmService.java

@@ -945,20 +945,48 @@ public class DmService {
             // 查找当前节点的子节点并累加数据
             int dataNum = 0;
             int failNum = 0;
+            int successNum = 0;
             for (FileInfo child : childrens) {
                 com.zsjz.ai.module.aiclean.entity.AiCleanJob job = latestJobs.get(child.getId());
-                if (job != null && job.getAiTemplateId() != null) {
-                    // AI 动态表(出口 B)的 sheet:file_info 里是前置解析的原始行数(含标题/表头行),
-                    // 真实入库量看 AI 任务 —— 否则会重复累加,结果页数字虚高
-                    int rowTotal = Objects.requireNonNullElse(job.getRowTotal(), 0);
-                    int rowLoaded = Objects.requireNonNullElse(job.getRowLoaded(), 0);
-                    int aiFail = Math.max(0, rowTotal - rowLoaded);
-                    child.setDataNum(rowTotal);
-                    child.setFailNum(aiFail);
-                    child.setSuccessNum(rowLoaded);
-                    dataNum += rowTotal;
-                    failNum += aiFail;
-                    continue;
+                if (job != null && job.getStatus() != null) {
+                    int preview = Objects.requireNonNullElse(child.getDataNum(), 0);
+                    switch (job.getStatus()) {
+                        case SUCCESS -> {
+                            // AI 动态表(含自建表)按 job 的真实行数展示:
+                            // rowLoaded 成功、rowRejected 失败,未清洗的预览行不再冒充成功
+                            int rowTotal = Objects.requireNonNullElse(job.getRowTotal(), preview);
+                            int rowLoaded = Objects.requireNonNullElse(job.getRowLoaded(), 0);
+                            int rowRejected = Objects.requireNonNullElse(job.getRowRejected(), 0);
+                            child.setDataNum(rowTotal);
+                            child.setSuccessNum(rowLoaded);
+                            child.setFailNum(rowRejected);
+                            dataNum += rowTotal;
+                            failNum += rowRejected;
+                            successNum += rowLoaded;
+                            continue;
+                        }
+                        case FAIL -> {
+                            int rowTotal = Objects.requireNonNullElse(job.getRowTotal(), preview);
+                            int rowRejected = Objects.requireNonNullElse(job.getRowRejected(), 0);
+                            child.setDataNum(rowTotal);
+                            child.setSuccessNum(0);
+                            child.setFailNum(rowRejected);
+                            dataNum += rowTotal;
+                            failNum += rowRejected;
+                            continue;
+                        }
+                        case NEED_REVIEW, SKIP -> {
+                            // 没洗就不算成功:数据量保留(用户能看到涉及多少行),成功数记 0
+                            child.setDataNum(preview);
+                            child.setSuccessNum(0);
+                            child.setFailNum(0);
+                            dataNum += preview;
+                            continue;
+                        }
+                        default -> {
+                            // DISPATCHED_A 等:交既有链路按 file_info 统计
+                        }
+                    }
                 }
                 int childData = Objects.requireNonNullElse(child.getDataNum(), 0);
                 int childFail = Objects.requireNonNullElse(child.getFailNum(), 0);
@@ -967,10 +995,11 @@ public class DmService {
                 child.setSuccessNum(Math.max(0, childData - childFail));
                 dataNum += childData;
                 failNum += childFail;
+                successNum += Math.max(0, childData - childFail);
             }
             fileInfo.setDataNum(dataNum);
             fileInfo.setFailNum(failNum);
-            fileInfo.setSuccessNum(Math.max(0, dataNum - failNum));
+            fileInfo.setSuccessNum(successNum);
             // sheet 行展示「清洗方式 / 备注」:按 AI 任务记录回填瞬态字段
             fillSheetCleanInfo(childrens, latestJobs);
         }
@@ -1025,6 +1054,7 @@ public class DmService {
                 continue;
             }
             sheet.setCleanMode("AI清洗");
+            sheet.setAiJobId(job.getId());
             sheet.setCleanNote(cleanNoteOf(job, aiTableNames));
         }
     }
@@ -1151,6 +1181,15 @@ public class DmService {
             log.info("删除文件时清理 AI 动态表行: table={}, fileIds={}, sheetIds={}, rows={}",
                     tpl.getTableNameEn(), rootIds, sheetIds, deleted);
         }
+        // 行级失败明细随文件一起清理,避免删了文件还能查到「已删文件」的拒绝行
+        if (!rootIds.isEmpty()) {
+            com.zsjz.ai.module.aiclean.exec.AiDuckDb.deleteRejectsByFileIds(new java.util.ArrayList<>(rootIds));
+        }
+        for (FileInfo sheet : sheetTargets) {
+            if (sheet.getId() != null && sheet.getPid() != null && sheet.getPid() != 0L) {
+                com.zsjz.ai.module.aiclean.exec.AiDuckDb.deleteRejectsByFileSheet(sheet.getPid(), sheet.getId());
+            }
+        }
         // 任务行同步清掉,避免 job 指向已不存在的文件(模板共享,不删模板本身)
         aiCleanJobMapper.delete(aiJobScopeQuery(rootIds, sheetIds));
         // 行数变了要重建 AI 治理节点;失败只记日志,不影响文件删除结果

+ 3 - 1
ai-server/src/test/java/com/zsjz/ai/module/aiclean/AiCleanCompileTest.java

@@ -126,7 +126,9 @@ class AiCleanCompileTest {
         assertEquals("TIMESTAMP", DuckTypeMapper.toDuckType("DATE_TIME", null));
         assertEquals("DATE", DuckTypeMapper.toDuckType("DATE", 10));
         assertEquals("TEXT", DuckTypeMapper.toDuckType("TEXT", 0));
-        assertEquals("VARCHAR(64)", DuckTypeMapper.toDuckType("TEXT", 64));
+        // AI 表不使用 VARCHAR(n):超长值会拒绝整批写入
+        assertEquals("TEXT", DuckTypeMapper.toDuckType("TEXT", 64));
+        assertEquals("DECIMAL(38,6)", DuckTypeMapper.toDuckType("NUMBER", 0));
         // 未知类型不能建表失败,按文本兜住
         assertEquals("TEXT", DuckTypeMapper.toDuckType("WHATEVER", null));
     }

+ 102 - 0
ai-server/src/test/java/com/zsjz/ai/module/aiclean/exec/AiDuckDbRejectTest.java

@@ -0,0 +1,102 @@
+package com.zsjz.ai.module.aiclean.exec;
+
+import com.zsjz.ai.module.aiclean.model.AiRejectRecord;
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 行级失败表 {@code ai_clean_reject}:写入、分页、按原因过滤、按 job/file 清理。
+ *
+ * <p>这张表是「每一行为什么没进去」的权威记录;分页与过滤写错,
+ * 用户就看不到真正的原因列。跑真 DuckDB。
+ *
+ * @author cc
+ * @since 2026/10/22
+ */
+class AiDuckDbRejectTest {
+
+    @TempDir
+    Path dir;
+
+    private HikariDataSource ds;
+
+    @BeforeEach
+    void setUp() {
+        HikariConfig cfg = new HikariConfig();
+        cfg.setJdbcUrl("jdbc:duckdb:" + dir.resolve("case.duckdb"));
+        cfg.setDriverClassName("org.duckdb.DuckDBDriver");
+        cfg.setMaximumPoolSize(1);
+        ds = new HikariDataSource(cfg);
+        AiDuckDb.useDataSource(ds);
+    }
+
+    @AfterEach
+    void tearDown() {
+        AiDuckDb.useDataSource(null);
+        try {
+            ds.close();
+        } catch (Exception ignored) {
+            // 临时库释放失败不影响断言
+        }
+    }
+
+    private static AiRejectRecord record(long jobId, int rowNo, String reason) {
+        return new AiRejectRecord(jobId, 9L, 1001L, 2001L, 0, rowNo, 2, "c2", reason,
+                "[{\"column\":\"c2\",\"reason\":\"" + reason + "\"}]");
+    }
+
+    @Test
+    @DisplayName("写入后可按 job 分页,并可按原因过滤")
+    void insertAndPage() {
+        AiDuckDb.insertRejects(List.of(
+                record(1L, 4, "REQUIRED_TYPE_INVALID"),
+                record(1L, 6, "REQUIRED_EMPTY"),
+                record(1L, 8, "REQUIRED_TYPE_INVALID"),
+                record(2L, 10, "REQUIRED_EMPTY")));
+
+        Map<String, Object> page = AiDuckDb.rejectPage(1L, null, 1, 2);
+        assertEquals(3L, page.get("total"));
+        assertEquals(2, ((List<?>) page.get("records")).size());
+        assertEquals(2L, page.get("size"));
+        assertEquals(1L, page.get("current"));
+
+        Map<String, Object> filtered = AiDuckDb.rejectPage(1L, "REQUIRED_TYPE_INVALID", 1, 10);
+        assertEquals(2L, filtered.get("total"));
+        assertEquals(2, ((List<?>) filtered.get("records")).size());
+
+        @SuppressWarnings("unchecked")
+        Map<String, Object> first = (Map<String, Object>) ((List<?>) filtered.get("records")).getFirst();
+        assertEquals(4, ((Number) first.get("row_no")).intValue());
+        assertEquals("c2", first.get("blocking_col_name"));
+    }
+
+    @Test
+    @DisplayName("按 job / 文件 / 文件+sheet 清理")
+    void cleanup() {
+        AiDuckDb.insertRejects(List.of(
+                record(1L, 4, "REQUIRED_EMPTY"),
+                record(2L, 6, "REQUIRED_EMPTY"),
+                new AiRejectRecord(3L, 9L, 9999L, 8888L, 0, 7, 1, "c1", "REQUIRED_EMPTY", "[]")));
+
+        assertEquals(1L, AiDuckDb.deleteRejectsByJob(1L));
+        assertEquals(0L, ((Number) AiDuckDb.rejectPage(1L, null, 1, 10).get("total")).longValue());
+
+        assertEquals(1L, AiDuckDb.deleteRejectsByFileIds(List.of(1001L)));
+        assertEquals(0L, ((Number) AiDuckDb.rejectPage(2L, null, 1, 10).get("total")).longValue());
+
+        assertEquals(1L, AiDuckDb.deleteRejectsByFileSheet(9999L, 8888L));
+        assertTrue(((Number) AiDuckDb.rejectPage(3L, null, 1, 10).get("total")).longValue() == 0L);
+    }
+}

+ 69 - 5
ai-server/src/test/java/com/zsjz/ai/module/aiclean/exec/AiRecordCleanerTest.java

@@ -1,6 +1,8 @@
 package com.zsjz.ai.module.aiclean.exec;
 
-import com.zsjz.ai.module.aiclean.exec.AiRecordCleaner;
+import com.zsjz.ai.module.aiclean.enums.AiRejectReason;
+import com.zsjz.ai.module.aiclean.model.AiRowCleanResult;
+import com.zsjz.ai.module.aiclean.model.ColumnError;
 import com.zsjz.ai.module.aiclean.model.RuleBind;
 import com.zsjz.ai.module.aiclean.rule.AiRuleCompiler;
 import com.zsjz.ai.module.aiclean.rule.CompiledColumn;
@@ -8,11 +10,13 @@ import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
 
 import java.math.BigDecimal;
+import java.time.LocalDate;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -88,7 +92,7 @@ class AiRecordCleanerTest {
     }
 
     @Test
-    @DisplayName("函数链在类型转换之前执行;顺带钉住 FunMultiplier 只放大整数部分的既有行为")
+    @DisplayName("函数链在类型转换之前执行;SCALE 按整体数值放大")
     void funsRunBeforeTyping() {
         RuleBind bind = new RuleBind();
         bind.setFileColIndex(0);
@@ -97,9 +101,8 @@ class AiRecordCleanerTest {
         CompiledColumn c = new CompiledColumn(0, "amt", "amt", "MONEY", false, 0,
                 AiRuleCompiler.compileFuns(bind), null);
         Map<String, Object> out = AiRecordCleaner.cleanRow(row(0, "1.50"), List.of(c), null);
-        // 既有 FunMultiplier 把值按小数点拆开、只乘整数部分,所以是 10000.50 而不是 15000。
-        // 本方案不改既有函数,这条断言就是「单位归一必须走 UNIT_SCALE」的证据
-        assertEquals(new BigDecimal("10000.50"), out.get("amt"));
+        // FunMultiplier 用 BigDecimal 整体相乘:1.50 × 10000 = 15000.00
+        assertEquals(new BigDecimal("15000.00"), out.get("amt"));
     }
 
     @Test
@@ -114,4 +117,65 @@ class AiRecordCleanerTest {
                 List.of(), "{不是 json");
         assertEquals("贷", AiRecordCleaner.toTypedValue(broken, "贷", null));
     }
+
+    @Test
+    @DisplayName("必填类型错误:整行拒绝,错误带列名/原因/脱敏值")
+    void requiredTypeErrorRejectsWholeRow() {
+        AiRowCleanResult result = AiRecordCleaner.cleanRowDetailed(
+                row(0, "约100元"),
+                List.of(col(0, "amount", "MONEY", true)));
+        assertTrue(result.rejected());
+        ColumnError first = result.firstBlocking();
+        assertEquals(0, first.colIndex());
+        assertEquals("amount", first.columnName());
+        assertEquals(AiRejectReason.REQUIRED_TYPE_INVALID, first.reason());
+        assertEquals("约100元", first.valuePreview());
+    }
+
+    @Test
+    @DisplayName("非必填类型错误:行保留、单元格置空、错误非阻塞")
+    void optionalTypeErrorKeepsRow() {
+        AiRowCleanResult result = AiRecordCleaner.cleanRowDetailed(
+                row(0, "约100元", 1, "张三"),
+                List.of(col(0, "amount", "MONEY", false), col(1, "name", "TEXT", true)));
+        assertFalse(result.rejected());
+        assertNull(result.values().get("amount"));
+        assertEquals("张三", result.values().get("name"));
+        assertTrue(result.hasOptionalError());
+        assertEquals(AiRejectReason.OPTIONAL_TYPE_INVALID, result.errors().getFirst().reason());
+    }
+
+    @Test
+    @DisplayName("一行多列错误:全部收集,不再遇到第一个必填错误就返回")
+    void collectsAllErrors() {
+        AiRowCleanResult result = AiRecordCleaner.cleanRowDetailed(
+                row(0, "约100元", 1, "12345"),
+                List.of(col(0, "amount", "MONEY", true), col(1, "id", "ID_CARD_NUMBER", true)));
+        assertTrue(result.rejected());
+        assertEquals(2, result.errors().size(), "两列的必填错误都要记录,便于定位");
+        assertTrue(result.errors().stream().allMatch(ColumnError::blocking));
+        assertEquals(0, result.firstBlocking().colIndex());
+    }
+
+    @Test
+    @DisplayName("函数异常:必填列整行拒绝,记 REQUIRED_FUNC_ERROR")
+    void funcErrorRejectsRequiredRow() {
+        RuleBind bind = new RuleBind();
+        bind.setFileColIndex(0);
+        bind.setFieldNameEn("amount");
+        bind.setHints(List.of("SCALE:10000"));
+        CompiledColumn c = new CompiledColumn(0, "amount", "amount", "MONEY", true, 0,
+                AiRuleCompiler.compileFuns(bind), null);
+        AiRowCleanResult result = AiRecordCleaner.cleanRowDetailed(row(0, "abc"), List.of(c));
+        assertTrue(result.rejected());
+        assertEquals(AiRejectReason.REQUIRED_FUNC_ERROR, result.firstBlocking().reason());
+    }
+
+    @Test
+    @DisplayName("日期列按目标类型转成 LocalDate,而不是把字符串交给 DuckDB")
+    void dateTyped() {
+        Map<String, Object> out = AiRecordCleaner.cleanRow(row(0, "2026年1月5日"),
+                List.of(col(0, "d", "DATE", false)), null);
+        assertEquals(LocalDate.of(2026, 1, 5), out.get("d"));
+    }
 }

+ 98 - 0
ai-server/src/test/java/com/zsjz/ai/module/aiclean/exec/AiTypedLoadTest.java

@@ -0,0 +1,98 @@
+package com.zsjz.ai.module.aiclean.exec;
+
+import com.zsjz.ai.module.aiclean.model.AiRowCleanResult;
+import com.zsjz.ai.module.aiclean.model.RuleBind;
+import com.zsjz.ai.module.aiclean.rule.AiRulePlan;
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.math.BigDecimal;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * 类型推断 → 清洗 → 真 DuckDB 的端到端链路:
+ * 「金额」必须落 DECIMAL、「日期」必须落 DATE,不能像旧实现那样一律 TEXT。
+ *
+ * @author cc
+ * @since 2026/10/22
+ */
+class AiTypedLoadTest {
+
+    @TempDir
+    Path dir;
+
+    private HikariDataSource ds;
+
+    @BeforeEach
+    void setUp() {
+        HikariConfig cfg = new HikariConfig();
+        cfg.setJdbcUrl("jdbc:duckdb:" + dir.resolve("case.duckdb"));
+        cfg.setDriverClassName("org.duckdb.DuckDBDriver");
+        cfg.setMaximumPoolSize(1);
+        ds = new HikariDataSource(cfg);
+        AiDuckDb.useDataSource(ds);
+    }
+
+    @AfterEach
+    void tearDown() {
+        AiDuckDb.useDataSource(null);
+        try {
+            ds.close();
+        } catch (Exception ignored) {
+            // 临时库释放失败不影响断言
+        }
+    }
+
+    @Test
+    @DisplayName("自建表按表头推断类型,清洗后的 BigDecimal/LocalDate 真能写进 DuckDB")
+    void typedSelfBuiltTable() {
+        AiRulePlan.Plan plan = AiRulePlan.forAi(List.<RuleBind>of(),
+                List.of("姓名", "金额", "日期"));
+        assertEquals("TEXT", plan.columns().get(0).fieldType());
+        assertEquals("MONEY", plan.columns().get(1).fieldType());
+        assertEquals("DATE", plan.columns().get(2).fieldType());
+        assertNull(AiDuckDb.createAiTable("ai_clean_typed", plan.fields()));
+
+        Map<Integer, String> row = Map.of(0, "张三", 1, "-1,234.50", 2, "2026年1月5日");
+        AiRowCleanResult cleaned = AiRecordCleaner.cleanRowDetailed(row, plan.columns());
+        assertFalse(cleaned.rejected(), "合法的负数金额与中文日期不能被整行拒绝");
+        assertEquals(new BigDecimal("-1234.50"), cleaned.values().get("c1"));
+
+        Map<String, Object> toWrite = new LinkedHashMap<>(cleaned.values());
+        toWrite.put("id", 1L);
+        toWrite.put("case_id", 9L);
+        toWrite.put("file_id", 1001L);
+        toWrite.put("sheet_id", 2001L);
+        toWrite.put("block_no", 0);
+        toWrite.put("row_no", 2);
+        toWrite.put("ai_job_id", 3001L);
+        toWrite.put("ai_rule_ver", 1);
+        toWrite.put("category", "PERSON");
+        long written;
+        try (AiDuckDb.BatchWriter writer = AiDuckDb.openWriter("ai_clean_typed",
+                plan.fields().stream().map(f -> f.getColumnName()).toList())) {
+            writer.add(toWrite);
+            written = writer.finish();
+        }
+        assertEquals(1L, written);
+
+        List<Map<String, Object>> page = AiDuckDb.selectPage("ai_clean_typed",
+                List.of("c0", "c1", "c2"), "c0", true, 1, 10);
+        assertEquals(1, page.size());
+        assertEquals(0, new BigDecimal("-1234.50")
+                .compareTo((BigDecimal) page.getFirst().get("c1")));
+        assertEquals("2026-01-05", String.valueOf(page.getFirst().get("c2")));
+    }
+}

+ 141 - 0
ai-server/src/test/java/com/zsjz/ai/module/aiclean/exec/AiValueCoercerTest.java

@@ -0,0 +1,141 @@
+package com.zsjz.ai.module.aiclean.exec;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 类型化转换矩阵:日期/时间/金额/数字/电话/卡号/身份证。
+ *
+ * <p>这些值最终会以 DATE / TIMESTAMP / DECIMAL 参数写进 DuckDB,
+ * 任何一个解析漏判都会在正式入库时变成整批失败或静默空列。
+ *
+ * @author cc
+ * @since 2026/10/22
+ */
+class AiValueCoercerTest {
+
+    private static AiValueCoercer.Result coerce(String type, String raw) {
+        return AiValueCoercer.coerce(type, raw);
+    }
+
+    @Test
+    @DisplayName("日期:ISO/斜杠/紧凑/中文/带时间,DATE 取日期部分")
+    void dates() {
+        assertEquals(LocalDate.of(2026, 1, 5), coerce("DATE", "2026-01-05").value());
+        assertEquals(LocalDate.of(2026, 1, 5), coerce("DATE", "2026/1/5").value());
+        assertEquals(LocalDate.of(2026, 1, 5), coerce("DATE", "20260105").value());
+        assertEquals(LocalDate.of(2026, 1, 5), coerce("DATE", "2026年1月5日").value());
+        assertEquals(LocalDate.of(2026, 1, 5), coerce("DATE", "2026-01-05 10:30:00").value());
+        assertEquals(LocalDate.of(2026, 1, 5), coerce("DATE", "2026-01-05T10:30:00").value());
+        assertEquals(AiValueCoercer.Failure.TYPE_INVALID, coerce("DATE", "2026-02-30").failure());
+        assertEquals(AiValueCoercer.Failure.TYPE_INVALID, coerce("DATE", "不是日期").failure());
+    }
+
+    @Test
+    @DisplayName("日期时间:纯日期补 00:00,纯时间不合法")
+    void dateTimes() {
+        assertEquals(LocalDateTime.of(2026, 1, 5, 0, 0), coerce("DATE_TIME", "2026-01-05").value());
+        assertEquals(LocalDateTime.of(2026, 1, 5, 10, 30), coerce("DATE_TIME", "2026/1/5 10:30").value());
+        assertEquals(LocalDateTime.of(2026, 1, 5, 10, 30, 15),
+                coerce("DATE_TIME", "2026-01-05 10:30:15").value());
+        assertEquals(LocalDateTime.of(2026, 1, 5, 10, 30),
+                coerce("DATE_TIME", "2026年1月5日 10时30分").value());
+        assertEquals(AiValueCoercer.Failure.TYPE_INVALID, coerce("DATE_TIME", "10:30").failure());
+    }
+
+    @Test
+    @DisplayName("时间:纯时间与 datetime 取时间部分")
+    void times() {
+        assertEquals(LocalTime.of(10, 30), coerce("TIME", "10:30").value());
+        assertEquals(LocalTime.of(10, 30, 5), coerce("TIME", "10:30:05").value());
+        assertEquals(LocalTime.of(10, 30), coerce("TIME", "2026-01-05 10:30:00").value());
+        assertEquals(LocalTime.of(10, 30), coerce("TIME", "10时30分").value());
+        assertEquals(AiValueCoercer.Failure.TYPE_INVALID, coerce("TIME", "2026-01-05").failure());
+    }
+
+    @Test
+    @DisplayName("文本 Excel 序列号按日期兜底")
+    void excelSerial() {
+        LocalDate expected = LocalDate.of(1899, 12, 30).plusDays(45000);
+        assertEquals(expected, coerce("DATE", "45000").value());
+    }
+
+    @Test
+    @DisplayName("10/13 位 epoch 时间戳:合理区间内解析,区间外不猜")
+    void epochs() {
+        LocalDateTime expected = LocalDateTime.ofInstant(
+                java.time.Instant.ofEpochSecond(1704067200L), java.time.ZoneId.systemDefault());
+        assertEquals(expected, coerce("DATE_TIME", "1704067200").value());
+        assertEquals(expected, coerce("DATE_TIME", "1704067200000").value());
+        assertEquals(expected.toLocalDate(), coerce("DATE", "1704067200").value());
+        assertEquals(AiValueCoercer.Failure.TYPE_INVALID, coerce("DATE_TIME", "123456789").failure());
+    }
+
+    @Test
+    @DisplayName("金额:负数/括号负数/币种/千分位/科学计数法;百分号与错误分组拒绝")
+    void money() {
+        assertEquals(new BigDecimal("2300.50"), coerce("MONEY", "2,300.5").value());
+        assertEquals(new BigDecimal("-1234.50"), coerce("MONEY", "(1,234.50)").value());
+        assertEquals(new BigDecimal("-100.00"), coerce("MONEY", "-100").value());
+        assertEquals(new BigDecimal("2300.50"), coerce("MONEY", "¥2,300.5").value());
+        assertEquals(new BigDecimal("123000.00"), coerce("MONEY", "1.23E+5").value());
+        assertEquals(new BigDecimal("100.00"), coerce("MONEY", "100").value());
+        assertEquals(AiValueCoercer.Failure.TYPE_INVALID, coerce("MONEY", "12%").failure());
+        assertEquals(AiValueCoercer.Failure.TYPE_INVALID, coerce("MONEY", "12,34").failure());
+        assertEquals(AiValueCoercer.Failure.LENGTH_OVERFLOW,
+                coerce("MONEY", "99999999999999999.99").failure());
+    }
+
+    @Test
+    @DisplayName("数字:千分位/小数/科学计数法")
+    void numbers() {
+        assertEquals(new BigDecimal("1234.567"), coerce("NUMBER", "1,234.567").value());
+        assertEquals(0, new BigDecimal("123000")
+                .compareTo((BigDecimal) coerce("NUMBER", "1.23E+5").value()));
+        assertEquals(AiValueCoercer.Failure.TYPE_INVALID, coerce("NUMBER", "12a").failure());
+    }
+
+    @Test
+    @DisplayName("电话:支持 +86/空格/横线;非法长度拒绝")
+    void phones() {
+        assertEquals("13800000000", coerce("PHONE", "+86 138-0000-0000").value());
+        assertEquals("13800000000", coerce("PHONE", "86 13800000000").value());
+        assertEquals(AiValueCoercer.Failure.TYPE_INVALID, coerce("PHONE", "123").failure());
+    }
+
+    @Test
+    @DisplayName("卡号:拒绝科学计数法,8-24 位数字保留")
+    void cards() {
+        assertEquals("6222020200001234", coerce("ACCT_CARD_NO", "6222 0202 0000 1234").value());
+        assertEquals(AiValueCoercer.Failure.TYPE_INVALID, coerce("ACCT_CARD_NO", "6.22202E+15").failure());
+        assertEquals(AiValueCoercer.Failure.TYPE_INVALID, coerce("ACCT_CARD_NO", "123").failure());
+    }
+
+    @Test
+    @DisplayName("身份证:18 位校验位、15 位老证;非法拒绝")
+    void idCards() {
+        assertEquals("11010519491231002X", coerce("ID_CARD_NUMBER", "11010519491231002x").value());
+        assertEquals("110105491231002", coerce("ID_CARD_NUMBER", "110105491231002").value());
+        assertEquals(AiValueCoercer.Failure.TYPE_INVALID, coerce("ID_CARD_NUMBER", "12345").failure());
+        assertEquals(AiValueCoercer.Failure.TYPE_INVALID,
+                coerce("ID_CARD_NUMBER", "110105194912310021").failure());
+    }
+
+    @Test
+    @DisplayName("空值与非类型化列")
+    void blanksAndText() {
+        assertEquals(AiValueCoercer.Failure.EMPTY, coerce("TEXT", " ").failure());
+        assertEquals("任意文本", coerce("TEXT", "任意文本").value());
+        assertNull(coerce("TEXT", "任意文本").failure());
+        assertTrue(coerce("TEXT", "任意文本").isOk());
+    }
+}

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

@@ -147,7 +147,7 @@ class AiTemplateMatcherTest {
         var cand = new TemplateCandidate(1, false, 77, null, 2,
                 "交易流水", "trans_record", headers,
                 List.of(new TemplateCandidate.FieldBrief(
-                        "金额", "transAmount", "MONEY", true)), 0.9);
+                        "金额", "transAmount", "MONEY", true, 0, null)), 0.9);
         RuleBind ok = bind(1, "transAmount");
         RuleBind invented = bind(2, "不存在的字段");
         RuleBind outOfRange = bind(99, "transAmount");

+ 112 - 0
ai-server/src/test/java/com/zsjz/ai/module/aiclean/rule/AiRulePlanTypeTest.java

@@ -0,0 +1,112 @@
+package com.zsjz.ai.module.aiclean.rule;
+
+import com.zsjz.ai.module.aiclean.model.ColStat;
+import com.zsjz.ai.module.aiclean.model.RuleBind;
+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.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 类型推断:模板元数据 &gt; 列形状 &gt; 表头关键词 &gt; TEXT。
+ *
+ * <p>旧实现只按英文字段名关键词猜,中文自建表全 TEXT、`card_no`/`id_card`
+ * 这类下划线命名失效。这里钉住新的优先级与关键词分词。
+ *
+ * @author cc
+ * @since 2026/10/22
+ */
+class AiRulePlanTypeTest {
+
+    private static ColStat stat(int idx, String shape) {
+        return new ColStat(idx, 1d, 10, 20, shape, List.of());
+    }
+
+    @Test
+    @DisplayName("英文分词:update/candidate 不误判 date,card_no/id_card 不失效")
+    void englishTokens() {
+        assertEquals("TEXT", AiRulePlan.typeOf("updateUser"));
+        assertEquals("TEXT", AiRulePlan.typeOf("candidate"));
+        assertEquals("DATE", AiRulePlan.typeOf("transDate"));
+        assertEquals("DATE_TIME", AiRulePlan.typeOf("transDateTime"));
+        assertEquals("DATE_TIME", AiRulePlan.typeOf("totalTime"));
+        assertEquals("MONEY", AiRulePlan.typeOf("totalAmount"));
+        assertEquals("ACCT_CARD_NO", AiRulePlan.typeOf("card_no"));
+        assertEquals("ACCT_CARD_NO", AiRulePlan.typeOf("accountNo"));
+        assertEquals("ID_CARD_NUMBER", AiRulePlan.typeOf("id_card"));
+        assertEquals("ID_CARD_NUMBER", AiRulePlan.typeOf("certNo"));
+        assertEquals("PHONE", AiRulePlan.typeOf("mobilePhone"));
+        assertEquals("NUMBER", AiRulePlan.typeOf("total"));
+    }
+
+    @Test
+    @DisplayName("中文关键词:日期/金额/数量/证件/电话")
+    void chineseTokens() {
+        assertEquals("DATE", AiRulePlan.typeOf("出港日期"));
+        assertEquals("DATE_TIME", AiRulePlan.typeOf("交易时间"));
+        assertEquals("MONEY", AiRulePlan.typeOf("交易金额"));
+        assertEquals("MONEY", AiRulePlan.typeOf("账户余额"));
+        assertEquals("NUMBER", AiRulePlan.typeOf("通话时长"));
+        assertEquals("DATE_TIME", AiRulePlan.typeOf("时间戳"));
+        assertEquals("DATE_TIME", AiRulePlan.typeOf("通话时间"));
+        assertEquals("ID_CARD_NUMBER", AiRulePlan.typeOf("身份证号"));
+        assertEquals("PHONE", AiRulePlan.typeOf("联系电话"));
+        assertEquals("ACCT_CARD_NO", AiRulePlan.typeOf("银行账号"));
+        assertEquals("TEXT", AiRulePlan.typeOf("舰船编号"));
+    }
+
+    @Test
+    @DisplayName("形状优先于关键词;NUMBER 形状让位给金额/日期等明确表头")
+    void shapePriority() {
+        assertEquals("DATE", AiRulePlan.resolveType("某列", null, stat(0, "DATE")));
+        assertEquals("DATE_TIME", AiRulePlan.resolveType("某列", null, stat(0, "DATETIME")));
+        assertEquals("TIME", AiRulePlan.resolveType("某列", null, stat(0, "TIME")));
+        assertEquals("PHONE", AiRulePlan.resolveType("某列", null, stat(0, "PHONE")));
+        assertEquals("ID_CARD_NUMBER", AiRulePlan.resolveType("某列", null, stat(0, "IDCARD")));
+        assertEquals("ACCT_CARD_NO", AiRulePlan.resolveType("某列", null, stat(0, "ACCT")));
+        assertEquals("MONEY", AiRulePlan.resolveType("某列", null, stat(0, "MONEY")));
+        assertEquals("NUMBER", AiRulePlan.resolveType("某列", null, stat(0, "NUMBER")));
+        // 整数金额的形状是 NUMBER,但表头已明确是金额
+        assertEquals("MONEY", AiRulePlan.resolveType("金额", null, stat(0, "NUMBER")));
+        assertEquals("DATE", AiRulePlan.resolveType("日期", null, stat(0, "NUMBER")));
+        // 10/13 位 epoch 会被判成长数字/数字,表头是时间戳时以时间为准
+        assertEquals("DATE_TIME", AiRulePlan.resolveType("时间戳", null, stat(0, "ACCT")));
+        assertEquals("DATE_TIME", AiRulePlan.resolveType("时间戳", null, stat(0, "NUMBER")));
+        assertEquals("TEXT", AiRulePlan.resolveType("备注", null, stat(0, "TEXT")));
+    }
+
+    @Test
+    @DisplayName("模板字段元数据优先:类型/必填都不信模型自评")
+    void candidateMetadataWins() {
+        RuleBind bind = new RuleBind();
+        bind.setFileColIndex(0);
+        bind.setFieldNameEn("transAmount");
+        TemplateCandidate cand = new TemplateCandidate(1, false, 77, null, 2,
+                "交易流水", "trans_record", List.of("金额"),
+                List.of(new TemplateCandidate.FieldBrief(
+                        "金额", "transAmount", "MONEY", true, 32, null)), 0.9);
+
+        AiRulePlan.Plan plan = AiRulePlan.forAi(List.of(bind), List.of("金额"),
+                List.of(stat(0, "TEXT")), cand);
+        assertEquals("MONEY", plan.columns().getFirst().fieldType());
+        assertTrue(plan.columns().getFirst().required());
+        assertEquals("MONEY", plan.fields().getFirst().getFieldType());
+    }
+
+    @Test
+    @DisplayName("无模板时 required 一律 false,避免模型/历史脏字段误判必填")
+    void selfBuiltNeverRequired() {
+        RuleBind bind = new RuleBind();
+        bind.setFileColIndex(0);
+        bind.setFieldNameEn("备注");
+        bind.setRequired(true); // 即使绑定上带了 required,没有模板元数据也不采信
+
+        AiRulePlan.Plan plan = AiRulePlan.forAi(List.of(bind), List.of("备注"), List.of(), null);
+        assertEquals("TEXT", plan.columns().getFirst().fieldType());
+        assertEquals(false, plan.columns().getFirst().required());
+    }
+}

+ 158 - 0
ai-server/src/test/java/com/zsjz/ai/module/aiclean/service/AiRejectCollectorTest.java

@@ -0,0 +1,158 @@
+package com.zsjz.ai.module.aiclean.service;
+
+import com.zsjz.ai.module.aiclean.enums.AiRejectReason;
+import com.zsjz.ai.module.aiclean.exec.AiDuckDb;
+import com.zsjz.ai.module.aiclean.exec.AiRejectCollector;
+import com.zsjz.ai.module.aiclean.model.AiRowCleanResult;
+import com.zsjz.ai.module.aiclean.model.ColumnError;
+import com.zsjz.ai.module.aiclean.entity.AiCleanJob;
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 行级失败收集与成功/失败计数口径。
+ *
+ * <p>成功任务必须满足 {@code rowTotal = rowLoaded + rowRejected};
+ * 失败任务最终表没有本次新增行,{@code rowLoaded=0},剩余行计入未处理。
+ * 这些数字是结果页和批次终态的唯一事实来源。
+ *
+ * @author cc
+ * @since 2026/10/22
+ */
+class AiRejectCollectorTest {
+
+    @TempDir
+    Path dir;
+
+    private HikariDataSource ds;
+
+    @BeforeEach
+    void setUp() {
+        HikariConfig cfg = new HikariConfig();
+        cfg.setJdbcUrl("jdbc:duckdb:" + dir.resolve("case.duckdb"));
+        cfg.setDriverClassName("org.duckdb.DuckDBDriver");
+        cfg.setMaximumPoolSize(1);
+        ds = new HikariDataSource(cfg);
+        AiDuckDb.useDataSource(ds);
+    }
+
+    @AfterEach
+    void tearDown() {
+        AiDuckDb.useDataSource(null);
+        try {
+            ds.close();
+        } catch (Exception ignored) {
+            // 临时库释放失败不影响断言
+        }
+    }
+
+    private static AiRowCleanResult rejected(int col, String name, AiRejectReason reason) {
+        ColumnError e = new ColumnError(col, name, reason, "bad", true);
+        return new AiRowCleanResult(Map.of(), List.of(e), true);
+    }
+
+    private static AiRowCleanResult optionalError(int col, String name) {
+        ColumnError e = new ColumnError(col, name, AiRejectReason.OPTIONAL_TYPE_INVALID, "bad", false);
+        java.util.Map<String, Object> values = new java.util.LinkedHashMap<>();
+        values.put(name, null);
+        return new AiRowCleanResult(values, List.of(e), false);
+    }
+
+    private static AiRowCleanResult clean(String name) {
+        return new AiRowCleanResult(Map.of(name, "ok"), List.of(), false);
+    }
+
+    @Test
+    @DisplayName("逐行收集:拒绝/警告计数、失败表、汇总 JSON")
+    void collectAndSummarize() {
+        AiRejectCollector c = new AiRejectCollector(1L, 9L, 1001L, 2001L, 0, 20, false);
+        c.countSource();
+        c.record(2, rejected(1, "c1", AiRejectReason.REQUIRED_TYPE_INVALID));
+        c.countSource();
+        c.record(3, rejected(2, "c2", AiRejectReason.REQUIRED_EMPTY));
+        c.countSource();
+        c.record(4, optionalError(1, "c1"));
+        c.countSource();
+        c.record(5, clean("c1"));
+
+        assertEquals(4, c.source());
+        assertEquals(2, c.rejected());
+        assertEquals(1, c.warned());
+
+        c.flush();
+        Map<String, Object> page = AiDuckDb.rejectPage(1L, null, 1, 10);
+        assertEquals(2L, page.get("total"), "两个被拒绝的行各一条记录");
+
+        String summary = c.summaryJson(2, 0);
+        assertTrue(summary.contains("\"source\":4"));
+        assertTrue(summary.contains("\"rejected\":2"));
+        assertTrue(summary.contains("\"warned\":1"));
+    }
+
+    @Test
+    @DisplayName("成功任务行数守恒:total = loaded + rejected")
+    void successCounts() {
+        AiRejectCollector c = new AiRejectCollector(2L, 9L, 1001L, 2001L, 0, 20, false);
+        for (int i = 0; i < 10; i++) {
+            c.countSource();
+            if (i < 3) {
+                c.record(i + 2, rejected(0, "c0", AiRejectReason.REQUIRED_EMPTY));
+            } else {
+                c.record(i + 2, clean("c0"));
+            }
+        }
+        AiCleanJob job = new AiCleanJob();
+        AiCleanService.fillLoadStats(job, c, 7, false);
+
+        assertEquals(10, job.getRowTotal());
+        assertEquals(7, job.getRowLoaded());
+        assertEquals(3, job.getRowRejected());
+        assertEquals(0, job.getRowUnprocessed());
+        assertTrue(job.getDetail().contains("\"load\""));
+    }
+
+    @Test
+    @DisplayName("失败任务:最终表无新增行,剩余行计入未处理")
+    void failureCounts() {
+        AiRejectCollector c = new AiRejectCollector(3L, 9L, 1001L, 2001L, 0, 20, false);
+        for (int i = 0; i < 10; i++) {
+            c.countSource();
+            if (i < 3) {
+                c.record(i + 2, rejected(0, "c0", AiRejectReason.REQUIRED_EMPTY));
+            } else {
+                c.record(i + 2, clean("c0"));
+            }
+        }
+        AiCleanJob job = new AiCleanJob();
+        AiCleanService.fillLoadStats(job, c, 0, true);
+
+        assertEquals(10, job.getRowTotal());
+        assertEquals(0, job.getRowLoaded());
+        assertEquals(3, job.getRowRejected());
+        assertEquals(7, job.getRowUnprocessed());
+    }
+
+    @Test
+    @DisplayName("写入异常单独记一条 WRITER_ERROR")
+    void writerError() {
+        AiRejectCollector c = new AiRejectCollector(4L, 9L, 1001L, 2001L, 0, 20, false);
+        c.countSource();
+        c.recordWriterError(9, "connection reset");
+        c.flush();
+
+        Map<String, Object> page = AiDuckDb.rejectPage(4L, "WRITER_ERROR", 1, 10);
+        assertEquals(1L, page.get("total"));
+    }
+}

+ 4 - 4
ai-server/src/test/java/com/zsjz/ai/module/aiclean/service/SelfHeaderPlanTest.java

@@ -37,14 +37,14 @@ class SelfHeaderPlanTest {
     }
 
     @Test
-    @DisplayName("兜底绑定的执行计划:列名消毒、类型全 TEXT 兜底")
-    void planFallsBackToTextColumns() {
+    @DisplayName("兜底绑定的执行计划:中文表头能识别日期,其余仍 TEXT 兜底")
+    void planInfersTypeFromChineseHeader() {
         List<RuleBind> binds = AiCleanService.selfHeaderBinds(List.of("舰船编号", "出港日期"));
         AiRulePlan.Plan plan = AiRulePlan.forAi(binds, List.of("舰船编号", "出港日期"));
 
         assertEquals(2, plan.columns().size());
-        assertTrue(plan.columns().stream().allMatch(c -> "TEXT".equalsIgnoreCase(c.fieldType())),
-                "中文表头猜不出英文类型语义,必须落 TEXT,宁可不转型");
+        assertEquals("TEXT", plan.columns().get(0).fieldType(), "「编号」不做数字转型,避免丢前导零");
+        assertEquals("DATE", plan.columns().get(1).fieldType(), "中文「日期」应识别为 DATE");
         assertEquals(2, plan.fields().size());
         assertEquals("舰船编号", plan.fields().get(0).getFieldNameCn());
     }

+ 12 - 0
ai-server/src/test/java/com/zsjz/ai/module/aiclean/verify/AiRuleVerifierTest.java

@@ -91,6 +91,18 @@ class AiRuleVerifierTest {
         assertEquals(0, r.valid());
     }
 
+    @Test
+    @DisplayName("必填覆盖率按行算:300 行里 1 行必填为空 → 出口 A 不放行")
+    void requiredFillIsPerRow() {
+        List<Map<Integer, String>> data = new ArrayList<>(rows(299, "100.00", "张三"));
+        data.addAll(rows(1, "100.00", ""));
+        VerifyReport r = AiRuleVerifier.verify(data,
+                List.of(col(0, "amt", "MONEY", false), col(1, "name", "TEXT", true)),
+                0.95, 0.9, 300);
+        assertFalse(r.pass(AiCleanExitEnum.A), "299/300=0.9967 低于出口 A 的 0.999 必填线");
+        assertTrue(r.pass(AiCleanExitEnum.B));
+    }
+
     @Test
     @DisplayName("放行门是硬阈值,不随模型自报分变化")
     void modelConfidenceIsWeakSignal() {

+ 7 - 0
sql/ai_clean.sql

@@ -126,6 +126,9 @@ create table if not exists ai_clean_job
     cost_ms         bigint,
     row_total       integer,
     row_loaded      integer,
+    row_rejected    integer,                               -- 必填空值/类型错误/函数异常/超长导致的整行拒绝
+    row_warned      integer,                               -- 已入库但含非必填错误的行(单元格置空)
+    row_unprocessed integer,                               -- 失败任务里未处理到的行
     fail_reason     varchar(1000),
     create_time     timestamp default current_timestamp,
     update_time     timestamp
@@ -153,6 +156,10 @@ create table if not exists ai_clean_batch
     ai_judged_count     integer   default 0,                  -- AI 已判定完成的 sheet
     existing_hit_count  integer   default 0,                  -- 其中判为命中既有模板
     ai_load_count       integer   default 0,                  -- 其中走出口 B 成功入库
+    loaded_rows         bigint    default 0,                  -- 出口 B 实际入库行数
+    rejected_rows       bigint    default 0,                  -- 出口 B 逐行拒绝行数
+    unprocessed_rows    bigint    default 0,                  -- 待人工/跳过/失败任务剩余行
+    self_built_count    integer   default 0,                  -- 无模板自建表的 sheet 数(不计入匹配成功)
     need_review_count   integer   default 0,
     failed_count        integer   default 0,
     need_review_detail  text,                                 -- [{sheetId,fileName,sheetName,reason}]

+ 21 - 0
sql/ai_clean_row_stats.sql

@@ -0,0 +1,21 @@
+-- ============================================================================
+-- ai_clean_job / ai_clean_batch:行级失败与成功/失败统计(2026-10-22)
+--
+-- 新增:
+--   job.row_rejected    必填空值/类型错误/函数异常/超长导致整行拒绝的行数
+--   job.row_warned      已入库但含非必填错误的行数(单元格置空)
+--   job.row_unprocessed 失败任务里未处理到的行数
+--   batch.loaded_rows / rejected_rows / unprocessed_rows / self_built_count
+--
+-- 本脚本用于**已存在的** AI 元数据表;新环境直接按 ai_clean.sql 建表即可。
+-- 全部语句幂等,可重复执行。
+-- ============================================================================
+
+alter table ai_clean_job add column if not exists row_rejected integer;
+alter table ai_clean_job add column if not exists row_warned integer;
+alter table ai_clean_job add column if not exists row_unprocessed integer;
+
+alter table ai_clean_batch add column if not exists loaded_rows bigint default 0;
+alter table ai_clean_batch add column if not exists rejected_rows bigint default 0;
+alter table ai_clean_batch add column if not exists unprocessed_rows bigint default 0;
+alter table ai_clean_batch add column if not exists self_built_count integer default 0;