feat(ai): 移除 Excel 导入预检链路
- 删除 preflight_import 工具、预检卡、resolve 接口与 ui.import_preflight 事件 - 删除 imports.preflight 解析器与 PreflightReport 类型 - SYSTEM_PROMPT 改为上传 Excel 后直接确认列映射/策略并调用 start_import_wizard - 前端同步移除预检类型/组件/测试,保留导入向导
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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(/^入住日期|日期$/),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user