feat(ai): 移除 Excel 导入预检链路

- 删除 preflight_import 工具、预检卡、resolve 接口与 ui.import_preflight 事件
- 删除 imports.preflight 解析器与 PreflightReport 类型
- SYSTEM_PROMPT 改为上传 Excel 后直接确认列映射/策略并调用 start_import_wizard
- 前端同步移除预检类型/组件/测试,保留导入向导
This commit is contained in:
2026-08-06 15:50:38 +08:00
parent 14db28afc6
commit 9048816abc
29 changed files with 14 additions and 2914 deletions

View File

@@ -23,7 +23,7 @@ export class ImportRun {
@Column({ name: 'sheets_json', type: 'mediumtext' })
sheetsJson: string;
/** Serialized ImportRunSettings — confirmed mapping/policies from AI preflight. */
/** Serialized ImportRunSettings — mapping/policies confirmed by the user. */
@Column({ name: 'settings_json', type: 'text', nullable: true })
settingsJson: string | null;

View File

@@ -1,232 +0,0 @@
import { Organization } from '../entities/organization.entity';
import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { buildPreflightReport } from './imports.preflight';
import type { ImportSheetData } from './imports.workbook';
function sheet(name: string, headers: string[], rows: unknown[][]): ImportSheetData {
return { name, headers, rows: rows as ImportSheetData['rows'] };
}
function dataSourceOf(options: {
students?: Student[];
rooms?: Room[];
organizations?: Organization[];
occupancies?: Occupancy[];
} = {}) {
return {
getRepository: jest.fn((entity: unknown) => {
if (entity === Student) return { find: jest.fn().mockResolvedValue(options.students ?? []) };
if (entity === Room) return { find: jest.fn().mockResolvedValue(options.rooms ?? []) };
if (entity === Organization) {
return { find: jest.fn().mockResolvedValue(options.organizations ?? []) };
}
if (entity === Occupancy) {
return { find: jest.fn().mockResolvedValue(options.occupancies ?? []) };
}
return { find: jest.fn().mockResolvedValue([]) };
}),
};
}
describe('buildPreflightReport', () => {
it('全新学生表判定为 ready给出分阶段统计与下一步建议', async () => {
const report = await buildPreflightReport(
dataSourceOf({ organizations: [{ id: 1, name: '主校区' }] }) as never,
[
sheet('学生', ['姓名', '学号', '手机号'], [
['张三', '2024001', '13800138000'],
['李四', '2024002', '13900139000'],
]),
],
);
expect(report.verdict).toBe('ready');
expect(report.questions).toEqual([]);
expect(report.stages).toHaveLength(1);
expect(report.stages[0]).toMatchObject({
stepKey: 'students',
total: 2,
create: 2,
update: 0,
error: 0,
skip: 0,
headers: ['姓名', '学号', '手机号'],
mapping: { name: '姓名', studentNo: '学号', phone: '手机号' },
});
expect(report.blocks).toEqual([]);
expect(report.nextSteps.some((step) => step.key === 'students-next')).toBe(true);
});
it('已匹配记录时判定为 needs_input 并提出更新策略问题', async () => {
const existing = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const report = await buildPreflightReport(
dataSourceOf({ students: [existing] }) as never,
[sheet('学生', ['姓名', '学号'], [['张三', '2024001']])],
);
expect(report.verdict).toBe('needs_input');
expect(report.stages[0]).toMatchObject({ total: 1, create: 0, update: 1 });
expect(report.questions.some((question) => question.type === 'update')).toBe(true);
});
it('缺少必填列时判定为 blocked 并归因 missing_columns', async () => {
const report = await buildPreflightReport(
dataSourceOf() as never,
[sheet('宿舍', ['宿舍号', '楼栋'], [['A101', '1号楼']])],
);
expect(report.verdict).toBe('blocked');
expect(report.blocks).toContainEqual(
expect.objectContaining({ code: 'missing_columns', count: 1, stepKeys: ['rooms'] }),
);
expect(report.stages[0].missingRequired).toContain('容量');
expect(report.questions.some((question) => question.type === 'mapping')).toBe(true);
});
it('无法识别任何业务表时判定为 blocked', async () => {
const report = await buildPreflightReport(
dataSourceOf() as never,
[sheet('杂项', ['A', 'B'], [['x', 'y']])],
);
expect(report.verdict).toBe('blocked');
expect(report.blocks).toContainEqual(expect.objectContaining({ code: 'no_stages' }));
expect(report.stages).toEqual([]);
});
it('文件内重复入住归因 duplicate_in_file 并提出重复策略问题', async () => {
const student = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const room = { id: 5, roomNumber: 'A101' } as Room;
const report = await buildPreflightReport(
dataSourceOf({ students: [student], rooms: [room] }) as never,
[
sheet('入住', ['姓名', '学号', '房间号', '入住日期'], [
['张三', '2024001', 'A101', '2026-09-01'],
['张三', '2024001', 'A101', '2026-09-02'],
]),
],
);
expect(report.verdict).toBe('needs_input');
expect(report.stages[0]).toMatchObject({ stepKey: 'checkins', total: 2, create: 1, error: 1 });
expect(report.blocks).toContainEqual(
expect.objectContaining({ code: 'duplicate_in_file', count: 1, stepKeys: ['checkins'] }),
);
expect(report.questions.some((question) => question.type === 'duplicate')).toBe(true);
expect(report.errorSamples).toContainEqual(
expect.objectContaining({
code: 'duplicate_in_file',
stepKey: 'checkins',
sheet: '入住',
rowNumber: 3,
errors: expect.arrayContaining([expect.stringContaining('请勿重复导入')]),
}),
);
});
it('未知校区归因 unknown_organization 并提出校区归属问题', async () => {
const report = await buildPreflightReport(
dataSourceOf({ organizations: [{ id: 1, name: '主校区' }] }) as never,
[sheet('学生', ['姓名', '学号', '校区'], [['张三', '2024001', '东校区']])],
);
expect(report.verdict).toBe('needs_input');
expect(report.blocks).toContainEqual(
expect.objectContaining({ code: 'unknown_organization', count: 1 }),
);
const orgQuestion = report.questions.find((question) => question.type === 'organization');
expect(orgQuestion).toBeDefined();
expect(orgQuestion?.options?.map((option) => option.value)).toContain('主校区');
});
it('入住找不到学生/宿舍归因引用缺失并提出未匹配处理问题', async () => {
const student = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const report = await buildPreflightReport(
dataSourceOf({ students: [student] }) as never,
[
sheet('入住', ['姓名', '学号', '房间号', '入住日期'], [
['张三', '2024001', 'A101', '2026-09-01'],
]),
],
);
expect(report.verdict).toBe('needs_input');
expect(report.blocks).toContainEqual(
expect.objectContaining({ code: 'room_not_found', count: 1, stepKeys: ['checkins'] }),
);
expect(report.questions.some((question) => question.type === 'reference')).toBe(true);
});
it('格式错误归因 format_error', async () => {
const report = await buildPreflightReport(
dataSourceOf() as never,
[sheet('学生', ['姓名', '手机号'], [['张三', '123']])],
);
expect(report.blocks).toContainEqual(
expect.objectContaining({ code: 'format_error', count: 1, stepKeys: ['students'] }),
);
expect(report.verdict).toBe('blocked');
expect(report.errorSamples).toContainEqual(
expect.objectContaining({
code: 'format_error',
stepKey: 'students',
sheet: '学生',
rowNumber: 2,
}),
);
});
it('同一阶段多张工作表且表头不一致时按表解析列映射', async () => {
const students = [
{ id: 88, name: '张三', studentNo: '2024001', phone: '13800138000' } as Student,
{ id: 89, name: '李四', studentNo: '2024002', phone: '13900139000' } as Student,
];
const room = { id: 5, roomNumber: 'A101' } as Room;
const report = await buildPreflightReport(
dataSourceOf({ students, rooms: [room] }) as never,
[
sheet('四人间女', ['姓名', '学号', '宿舍号', '入住日期'], [
['张三', '2024001', 'A101', '2026-09-01'],
]),
sheet('四人间男', ['学生姓名', '学号', '房号', '日期'], [
['李四', '2024002', 'A101', '2026-09-02'],
]),
],
);
expect(report.verdict).toBe('ready');
const stage = report.stages.find((item) => item.stepKey === 'checkins');
expect(stage).toBeDefined();
expect(stage).toMatchObject({
sheetNames: ['四人间女', '四人间男'],
total: 2,
create: 2,
update: 0,
error: 0,
});
expect(stage?.mapping).toEqual({
name: expect.stringMatching(/^姓名|学生姓名$/),
studentNo: '学号',
roomNumber: expect.stringMatching(/^宿舍号|房号$/),
checkInDate: expect.stringMatching(/^入住日期|日期$/),
});
});
});

View File

@@ -1,425 +0,0 @@
import { DataSource } from 'typeorm';
import { Organization } from '../entities/organization.entity';
import { buildLookups } from './imports.lookups';
import { resolveSheetMapping, suggestMapping, suggestStep } from './imports.mapping';
import { validateRow, type ImportBatchState } from './imports.rows';
import {
IMPORT_STEP_IDENTITY_FIELDS,
IMPORT_STEP_LABELS,
IMPORT_STEP_ORDER,
IMPORT_STEP_REQUIRED_FIELDS,
} from './imports.types';
import type {
CellValue,
ColumnMapping,
ImportStepKey,
PreflightBlock,
PreflightBlockCode,
PreflightErrorSample,
PreflightNextStep,
PreflightQuestion,
PreflightReport,
PreflightStageStat,
} from './imports.types';
import type { ImportSheetData } from './imports.workbook';
const BLOCK_META: Record<PreflightBlockCode, { label: string; message: string }> = {
no_stages: {
label: '未识别工作表',
message: '没有识别到可导入的学生、宿舍、入住或换宿工作表,请检查表头',
},
missing_columns: {
label: '缺少必填列',
message: '阶段缺少必需列映射,无法自动导入',
},
student_not_found: {
label: '未找到学生',
message: '部分行找不到匹配学生,需先完成学生档案或核对学号/手机号',
},
room_not_found: {
label: '未找到宿舍',
message: '部分行找不到匹配宿舍,需先完成宿舍档案或核对宿舍号',
},
duplicate_in_file: {
label: '文件内重复',
message: '同一文件内存在重复在住/换宿记录',
},
already_checked_in: {
label: '已有在住',
message: '学生已有在住记录,重复入住会被拦截',
},
format_error: {
label: '格式错误',
message: '部分行存在格式或取值错误(日期、手机号、容量等)',
},
unknown_organization: {
label: '未知校区',
message: '部分行填写的校区不存在,需要确认归属',
},
};
const REQUIRED_FIELD_LABELS: Record<string, string> = {
name: '姓名',
roomNumber: '宿舍号',
capacity: '容量',
checkInDate: '入住日期',
oldRoom: '原宿舍',
newRoom: '新宿舍',
transferDate: '换宿日期',
identity: '学号或手机号',
};
const NEXT_STEP_DEFS: Array<PreflightNextStep> = [
{
key: 'students-next',
label: '分班 / 排课 / 入住',
description: '学生档案导入完成后,可继续分班、排课或录入入住记录。',
after: ['students'],
},
{
key: 'rooms-next',
label: '入住 / 费用',
description: '宿舍档案导入完成后,可录入入住记录并维护宿舍费用。',
after: ['rooms'],
},
{
key: 'checkins-next',
label: '费用 / 账单',
description: '入住记录导入完成后,可录入公共费用并生成账单。',
after: ['checkins'],
},
{
key: 'transfers-next',
label: '账单核对',
description: '换宿完成后建议核对在住记录与账单,避免计费偏差。',
after: ['transfers'],
},
];
interface StageAnalysis extends PreflightStageStat {
rowErrorCodes: PreflightBlockCode[];
unknownOrgs: string[];
errorSamples: PreflightErrorSample[];
}
function classifyErrors(errors: string[]): PreflightBlockCode[] {
const codes = new Set<PreflightBlockCode>();
for (const error of errors) {
if (
error.includes('未找到匹配学生') ||
error.includes('缺少学生标识') ||
error.includes('未找到该学生在原宿舍的在住记录')
) {
codes.add('student_not_found');
} else if (
error.includes('未找到宿舍') ||
error.includes('未找到原宿舍') ||
error.includes('未找到新宿舍')
) {
codes.add('room_not_found');
} else if (
error.includes('请勿重复导入') ||
error.includes('请勿重复换宿') ||
error.includes('本次文件中已有')
) {
codes.add('duplicate_in_file');
} else if (error.includes('已有在住记录')) {
codes.add('already_checked_in');
} else if (error.includes('未找到校区')) {
codes.add('unknown_organization');
} else {
codes.add('format_error');
}
}
return [...codes];
}
async function analyzeStage(
dataSource: DataSource,
stepKey: ImportStepKey,
sheets: ImportSheetData[],
): Promise<StageAnalysis> {
// 阶段级映射取各表建议的并集,供预检卡预填;实际按表解析在下方逐表进行。
const mapping: ColumnMapping = {};
for (const sheet of sheets) {
const suggested = suggestMapping(sheet.headers, stepKey);
for (const [field, header] of Object.entries(suggested)) {
if (!mapping[field]) mapping[field] = header;
}
}
const required = IMPORT_STEP_REQUIRED_FIELDS[stepKey];
const missingRequired = required
.filter((field) => !mapping[field])
.map((field) => REQUIRED_FIELD_LABELS[field] ?? field);
const identityFields = IMPORT_STEP_IDENTITY_FIELDS[stepKey];
const hasIdentity = identityFields.some((field) => mapping[field]);
let total = 0;
let create = 0;
let update = 0;
let error = 0;
const rowErrorCodes: PreflightBlockCode[] = [];
const errorSamples: PreflightErrorSample[] = [];
const sampleCounts = new Map<PreflightBlockCode, number>();
const unknownOrgs = new Set<string>();
const batchState: ImportBatchState = {
checkinStudentIds: new Set<number>(),
transferStudentIds: new Set<number>(),
};
for (const sheet of sheets) {
const sheetMapping = resolveSheetMapping(mapping, sheet.headers, stepKey);
const lookups = await buildLookups(dataSource, stepKey, sheet.headers, sheet.rows, sheetMapping);
for (let i = 0; i < sheet.rows.length; i += 1) {
const rawValues = sheet.rows[i];
const fields: Record<string, CellValue> = {};
for (const [field, header] of Object.entries(sheetMapping)) {
fields[field] = rawValues[sheet.headers.indexOf(header)] ?? null;
}
const result = validateRow(stepKey, fields, lookups, batchState);
total += 1;
if (result.errors.length > 0) {
error += 1;
const codes = classifyErrors(result.errors);
rowErrorCodes.push(...codes);
for (const code of codes) {
const count = sampleCounts.get(code) ?? 0;
if (count < 2) {
sampleCounts.set(code, count + 1);
errorSamples.push({
code,
stepKey,
sheet: sheet.name,
rowNumber: sheet.rowNumbers?.[i] ?? (sheet.headerRow ?? 1) + i + 1,
errors: result.errors,
});
}
}
if (stepKey === 'students' && result.errors.some((item) => item.includes('未找到校区'))) {
const org = String(fields.organization ?? '');
if (org) unknownOrgs.add(org);
}
} else if (result.action === 'create') {
create += 1;
const studentId = result.resolvedIds._studentId;
if (studentId !== undefined) {
if (stepKey === 'checkins') batchState.checkinStudentIds.add(studentId);
if (stepKey === 'transfers') batchState.transferStudentIds.add(studentId);
}
} else if (result.action === 'update') {
update += 1;
}
}
}
return {
stepKey,
label: IMPORT_STEP_LABELS[stepKey],
sheetNames: sheets.map((sheet) => sheet.name),
headers: [...new Set(sheets.flatMap((sheet) => sheet.headers))],
total,
create,
update,
error,
skip: 0,
mapping,
missingRequired: hasIdentity
? missingRequired
: [...new Set([...missingRequired, REQUIRED_FIELD_LABELS.identity])],
rowErrorCodes,
unknownOrgs: [...unknownOrgs],
errorSamples,
};
}
function aggregateBlocks(stages: StageAnalysis[]): PreflightBlock[] {
const counts = new Map<PreflightBlockCode, number>();
const stepKeys = new Map<PreflightBlockCode, Set<ImportStepKey>>();
const add = (code: PreflightBlockCode, stepKey: ImportStepKey, count: number) => {
counts.set(code, (counts.get(code) ?? 0) + count);
const keys = stepKeys.get(code) ?? new Set<ImportStepKey>();
keys.add(stepKey);
stepKeys.set(code, keys);
};
for (const stage of stages) {
if (stage.missingRequired.length > 0) {
add('missing_columns', stage.stepKey, stage.total);
}
for (const code of stage.rowErrorCodes) {
add(code, stage.stepKey, 1);
}
}
return [...counts.entries()]
.map(([code, count]) => ({
code,
label: BLOCK_META[code].label,
stepKeys: [...(stepKeys.get(code) ?? [])],
message: BLOCK_META[code].message,
count,
}))
.sort((a, b) => b.count - a.count);
}
function buildQuestions(
stages: StageAnalysis[],
existingOrganizations: string[],
): PreflightQuestion[] {
const questions: PreflightQuestion[] = [];
for (const stage of stages) {
if (stage.missingRequired.length > 0) {
questions.push({
key: `mapping_${stage.stepKey}`,
type: 'mapping',
label: `确认「${stage.label}」列映射`,
description: `缺少必需列映射:${stage.missingRequired.join('、')};请确认工作表中对应的列名`,
stepKey: stage.stepKey,
});
}
}
const totalUpdates = stages.reduce((sum, stage) => sum + stage.update, 0);
if (totalUpdates > 0) {
questions.push({
key: 'update',
type: 'update',
label: `文件中有 ${totalUpdates} 行已匹配现有记录`,
description: '选择更新已有记录,或跳过已匹配的行(仅新建)',
options: [
{ label: '更新已有记录', value: 'true' },
{ label: '跳过已有记录', value: 'false' },
],
default: true,
});
}
const unknownOrgs = [...new Set(stages.flatMap((stage) => stage.unknownOrgs))];
if (unknownOrgs.length > 0) {
const options = [
...existingOrganizations.slice(0, 19).map((name) => ({ label: name, value: name })),
{ label: '忽略校区', value: '' },
];
questions.push({
key: 'organization',
type: 'organization',
label: '确认校区归属',
description: `文件中存在未匹配的校区:${unknownOrgs.join('、')},请选择实际归属校区`,
options,
});
}
if (stages.some((stage) => stage.rowErrorCodes.includes('duplicate_in_file'))) {
questions.push({
key: 'duplicate',
type: 'duplicate',
label: '文件内存在重复在住/换宿记录',
description: '选择将重复行标记为错误,或按策略跳过重复行',
options: [
{ label: '标记为错误', value: 'error' },
{ label: '跳过重复行', value: 'skip' },
],
default: 'error',
});
}
if (
stages.some((stage) =>
stage.rowErrorCodes.some(
(code) => code === 'student_not_found' || code === 'room_not_found',
),
)
) {
questions.push({
key: 'reference',
type: 'reference',
label: '存在未匹配的学生或宿舍',
description: '选择保留错误提示,或跳过找不到学生/宿舍的行继续导入',
options: [
{ label: '保留错误提示', value: 'false' },
{ label: '跳过未匹配行', value: 'true' },
],
default: false,
});
}
return questions;
}
function decideVerdict(
stages: StageAnalysis[],
questions: PreflightQuestion[],
hasStages: boolean,
): PreflightReport['verdict'] {
if (!hasStages) return 'blocked';
if (stages.some((stage) => stage.missingRequired.length > 0)) return 'blocked';
if (
stages.some(
(stage) =>
stage.total > 0 &&
stage.total === stage.error &&
stage.rowErrorCodes.length > 0 &&
stage.rowErrorCodes.every((code) => code === 'format_error'),
)
) {
return 'blocked';
}
if (questions.length > 0) return 'needs_input';
return 'ready';
}
/**
* 生成“可插入性预检报告”:按业务依赖分阶段统计,归类阻断原因,
* 给出需要用户确认的问题与导入后的下一步建议。纯读操作,不写库。
*/
export async function buildPreflightReport(
dataSource: DataSource,
sheets: ImportSheetData[],
): Promise<PreflightReport> {
const grouped = new Map<ImportStepKey, ImportSheetData[]>();
for (const sheet of sheets) {
const suggestion = suggestStep(sheet.headers);
if (!suggestion) continue;
const list = grouped.get(suggestion.stepKey) ?? [];
list.push(sheet);
grouped.set(suggestion.stepKey, list);
}
const stageKeys = IMPORT_STEP_ORDER.filter((stepKey) => grouped.has(stepKey));
const hasStages = stageKeys.length > 0;
const stages: StageAnalysis[] = [];
const existingOrganizations = new Set<string>();
if (hasStages) {
for (const stepKey of stageKeys) {
const analysis = await analyzeStage(dataSource, stepKey, grouped.get(stepKey) ?? []);
stages.push(analysis);
}
const organizations = await dataSource
.getRepository(Organization)
.find({ select: { name: true } });
for (const org of organizations) existingOrganizations.add(org.name);
}
const blocks = aggregateBlocks(stages);
if (!hasStages) {
blocks.push({
code: 'no_stages',
label: BLOCK_META.no_stages.label,
stepKeys: [],
message: BLOCK_META.no_stages.message,
count: sheets.length,
});
}
const questions = buildQuestions(stages, [...existingOrganizations]);
const detectedKeys = new Set(stages.map((stage) => stage.stepKey));
const nextSteps = NEXT_STEP_DEFS.filter((step) => step.after.some((key) => detectedKeys.has(key)));
return {
verdict: decideVerdict(stages, questions, hasStages),
stages: stages.map(
({
rowErrorCodes: _rowErrorCodes,
unknownOrgs: _unknownOrgs,
errorSamples: _errorSamples,
...stat
}) => stat,
),
blocks,
questions,
nextSteps,
errorSamples: stages.flatMap((stage) => stage.errorSamples),
};
}

View File

@@ -867,29 +867,6 @@ describe('ImportsService', () => {
expect(result.rows[0].errors.join('')).toContain('按策略跳过');
});
it('preflightFile 透传 headerRow 到解析层', async () => {
const parseSpy = jest
.spyOn(workbookModule, 'parseSheets')
.mockResolvedValue([]);
try {
const service = new ImportsService(
makeRunsRepo({} as ImportRun) as never,
makeStepsRepo({} as ImportStep) as never,
makeRowsRepo() as never,
{} as never,
);
const report = await service.preflightFile(fileOf('students.xlsx', Buffer.from('x')), 3);
expect(parseSpy).toHaveBeenCalledWith(
expect.any(Buffer),
'students.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
3,
);
expect(report.verdict).toBe('blocked');
} finally {
parseSpy.mockRestore();
}
});
it('createRun 按 stages 的 headerRow 生成对应工作表视图并写入 sheetsJson', async () => {
const workbook = new ExcelJS.Workbook();

View File

@@ -7,16 +7,12 @@ import { ImportRow } from './entities/import-row.entity';
import { ImportRunService } from './imports.run.service';
import { ImportPreviewService } from './imports.preview.service';
import { ImportCommitService } from './imports.commit.service';
import { buildPreflightReport } from './imports.preflight';
import { parseSheets } from './imports.workbook';
import type { ParsedImportFile } from './imports.types';
export type {
ImportSheetMeta,
ImportStepDetail,
ImportRunDetail,
ImportRunSettings,
PreflightReport,
StepPreviewResult,
} from './imports.types';
@@ -71,18 +67,6 @@ export class ImportsService {
return this.runsSvc.createRun(...args);
}
/** 上传后的只读预检:解析文件并生成可插入性报告,不写库。 */
async preflightFile(
file: ParsedImportFile,
headerRow = 1,
): Promise<import('./imports.types').PreflightReport> {
if (!file.buffer || file.buffer.length === 0) {
throw new BadRequestException('上传文件为空');
}
const sheets = await parseSheets(file.buffer, file.originalName, file.mimeType, headerRow);
return buildPreflightReport(this.dataSource, sheets);
}
async getRun(...args: Parameters<ImportRunService['getRun']>) {
return this.runsSvc.getRun(...args);
}

View File

@@ -150,94 +150,6 @@ export interface ImportRunSettings {
skipUnmatched?: boolean;
}
export type PreflightVerdict = 'ready' | 'needs_input' | 'blocked';
export interface PreflightStageStat {
stepKey: ImportStepKey;
label: string;
sheetNames: string[];
/** 该阶段所有工作表的表头并集,供前端预检卡渲染列映射选项。 */
headers: string[];
total: number;
create: number;
update: number;
error: number;
skip: number;
mapping: ColumnMapping;
missingRequired: string[];
}
export type PreflightBlockCode =
| 'no_stages'
| 'missing_columns'
| 'student_not_found'
| 'room_not_found'
| 'duplicate_in_file'
| 'already_checked_in'
| 'format_error'
| 'unknown_organization';
export interface PreflightBlock {
code: PreflightBlockCode;
label: string;
stepKeys: ImportStepKey[];
message: string;
count: number;
}
export type PreflightQuestionType =
| 'mapping'
| 'organization'
| 'update'
| 'duplicate'
| 'reference';
export interface PreflightQuestionOption {
label: string;
value: string;
}
export interface PreflightQuestion {
key: string;
type: PreflightQuestionType;
label: string;
description?: string;
stepKey?: ImportStepKey;
options?: PreflightQuestionOption[];
default?: string | boolean;
}
export interface PreflightNextStep {
key: string;
label: string;
description: string;
after: ImportStepKey[];
}
/** 预检报告中的错误示例(仅工作表、行号与错误信息,不含原始行数据)。 */
export interface PreflightErrorSample {
code: PreflightBlockCode;
stepKey: ImportStepKey;
sheet: string;
rowNumber: number;
errors: string[];
}
export interface PreflightReport {
verdict: PreflightVerdict;
stages: PreflightStageStat[];
blocks: PreflightBlock[];
questions: PreflightQuestion[];
nextSteps: PreflightNextStep[];
errorSamples: PreflightErrorSample[];
/** 以下字段由 AI 预检卡使用,普通预检报告生成时不设置。 */
attachmentId?: number;
headerRow?: number;
permittedSteps?: ImportStepKey[];
resolved?: boolean;
runId?: string | null;
}
export interface StepPreviewResult {
stepKey: ImportStepKey;
sheetNames: string[];