feat: Excel 导入预检与动态问答,AI 聊天/文件解析体验修复
- imports: 新增 preflight_import 预检报告(判定/分阶段统计/阻断归因/问题/下一步/错误示例),导入任务 settings 落库(映射/校区/更新/重复/未匹配策略),预览应用策略,向导提交写操作日志 - ai-chat: 新增 excel_analyze(ExcelJS)工具,移除附件/上下文截断,start_import_wizard 支持确认参数,ui.import_preflight SSE,预览确认写操作日志 - admin: ImportPreflightCard 渲染与持久化,聊天抽屉布局/侧边栏修复,考勤页 CSS 引入,费用/学生页接口 schema 校验修复
This commit is contained in:
@@ -175,7 +175,7 @@ describe('AiChatService', () => {
|
||||
expect(summary.length).toBeLessThanOrEqual(2000);
|
||||
});
|
||||
|
||||
it('超大附件文本在进入模型前被截断并提示', async () => {
|
||||
it('超大附件文本完整进入模型,不截断', async () => {
|
||||
const { service } = createService();
|
||||
(service as unknown as { attachmentService: { toModelParts: jest.Mock } }).attachmentService = {
|
||||
toModelParts: jest
|
||||
@@ -195,17 +195,13 @@ describe('AiChatService', () => {
|
||||
).buildUserContent.bind(service);
|
||||
const result = await build('请看这个文件', [{ id: 1 }], false);
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result as string).toContain('内容过长');
|
||||
expect((result as string).length).toBeLessThan(50000);
|
||||
expect(result as string).toContain('附件:big.xlsx');
|
||||
expect((result as string).length).toBeGreaterThan(120000);
|
||||
});
|
||||
|
||||
it('大 Excel 附件在进入模型前生成概览并提示可动态读取', async () => {
|
||||
it('大 Excel 附件全文进入模型,不再生成概览', async () => {
|
||||
const { service } = createService();
|
||||
(service as unknown as { excelReader: { overview: jest.Mock } }).excelReader = {
|
||||
overview: jest.fn().mockResolvedValue({ text: '# 名单(共 100 行)\n表头\t列2' }),
|
||||
};
|
||||
(service as unknown as { attachmentService: unknown }).attachmentService = {
|
||||
readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')),
|
||||
toModelParts: jest.fn().mockResolvedValue([
|
||||
{
|
||||
attachment: {
|
||||
@@ -227,8 +223,8 @@ describe('AiChatService', () => {
|
||||
}
|
||||
).buildUserContent.bind(service);
|
||||
const result = await build('请看这个文件', [{ id: 1 }], false);
|
||||
expect(result as string).toContain('# 名单(共 100 行)');
|
||||
expect(result as string).toContain('office_analyze');
|
||||
expect(result as string).toContain('附件:big.xlsx');
|
||||
expect(result as string).toContain('x'.repeat(30000));
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -1396,6 +1392,58 @@ describe('AiChatService', () => {
|
||||
expect(data).toMatchObject({ id: 'review-1' });
|
||||
});
|
||||
|
||||
it('confirmReviewStep 确认导入后写入操作日志', async () => {
|
||||
const { service, reviewService } = createService();
|
||||
const review = {
|
||||
id: 'review-1',
|
||||
conversationId: 3,
|
||||
userId: 7,
|
||||
assistantMessageId: 12,
|
||||
title: '批量导入',
|
||||
summary: null,
|
||||
sectionsJson: JSON.stringify([{ key: 'students', title: '学生' }]),
|
||||
status: 'pending',
|
||||
resultSummary: null,
|
||||
submittedAt: null,
|
||||
};
|
||||
const updated = {
|
||||
...review,
|
||||
sectionsJson: JSON.stringify([
|
||||
{ key: 'students', title: '学生', status: 'submitted' },
|
||||
]),
|
||||
};
|
||||
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
(service as unknown as { opLog: unknown }).opLog = opLog;
|
||||
(service as unknown as { messages: unknown }).messages = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 12,
|
||||
conversationId: 3,
|
||||
metadata: { a2uiReview: { id: 'review-1', status: 'pending' } },
|
||||
}),
|
||||
save: jest.fn(async (value) => value),
|
||||
};
|
||||
reviewService.findOwned.mockResolvedValue(review);
|
||||
reviewService.submitSection.mockResolvedValue({
|
||||
review: updated,
|
||||
result: { created: 1, skipped: 0, issues: [] },
|
||||
message: '成功导入学生 1 人,跳过 0 条',
|
||||
});
|
||||
|
||||
await service.confirmReviewStep(authenticatedUser as never, 'review-1', 'students');
|
||||
|
||||
expect(opLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 7,
|
||||
username: 'tester',
|
||||
module: '批量导入',
|
||||
action: '确认导入分表',
|
||||
detail: expect.stringContaining('成功导入学生 1 人'),
|
||||
targetType: 'ai_review',
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('confirmReviewStep / confirmReviewGroup 对已失效预览返回 409', async () => {
|
||||
const { service, reviewService } = createService();
|
||||
const review = {
|
||||
@@ -1871,4 +1919,284 @@ describe('AiChatService', () => {
|
||||
expect(importsService.createRun).not.toHaveBeenCalled();
|
||||
expect(emitted.some(({ event }) => event === 'tool.failed')).toBe(true);
|
||||
});
|
||||
|
||||
it('start_import_wizard 接收确认参数并写入导入任务', async () => {
|
||||
const { service } = createService();
|
||||
const toolRun = { id: 1, status: 'running' };
|
||||
const toolRuns = {
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => ({ ...toolRun, ...value })),
|
||||
};
|
||||
const messages = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, metadata: null }),
|
||||
save: jest.fn(async (value) => value),
|
||||
};
|
||||
const attachmentService = {
|
||||
requireReadyOwned: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 9,
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
originalName: 'students.xlsx',
|
||||
size: 10,
|
||||
},
|
||||
]),
|
||||
readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')),
|
||||
};
|
||||
const importsService = {
|
||||
createRun: jest.fn().mockResolvedValue({
|
||||
id: 'run-9',
|
||||
fileName: 'students.xlsx',
|
||||
sheets: [],
|
||||
steps: [
|
||||
{ stepKey: 'students', label: '学生档案', sheets: ['学生'], status: 'pending' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
(service as unknown as { toolRuns: unknown }).toolRuns = toolRuns;
|
||||
(service as unknown as { messages: unknown }).messages = messages;
|
||||
(service as unknown as { attachmentService: unknown }).attachmentService = attachmentService;
|
||||
(service as unknown as { importsService: unknown }).importsService = importsService;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
executeStartImportWizard(
|
||||
messageId: number,
|
||||
call: { id: string; name: string; arguments: string },
|
||||
context: { userId: number; permissions: string[]; isSuperAdmin: boolean },
|
||||
emit: (event: string, data?: unknown) => void,
|
||||
): Promise<string>;
|
||||
}
|
||||
).executeStartImportWizard(
|
||||
42,
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'start_import_wizard',
|
||||
arguments: JSON.stringify({
|
||||
attachmentId: 9,
|
||||
stages: [{ stepKey: 'students', sheet: '学生' }],
|
||||
mapping: { students: { name: '姓名', studentNo: '学号' } },
|
||||
organization: '主校区',
|
||||
updateExisting: false,
|
||||
duplicatePolicy: 'skip',
|
||||
skipUnmatched: true,
|
||||
}),
|
||||
},
|
||||
{ userId: 7, permissions: [], isSuperAdmin: false },
|
||||
jest.fn(),
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result) as { status: string };
|
||||
expect(parsed.status).toBe('success');
|
||||
expect(importsService.createRun).toHaveBeenCalledWith(
|
||||
{ id: 7, permissions: [], isSuperAdmin: false },
|
||||
'ai',
|
||||
expect.objectContaining({ originalName: 'students.xlsx' }),
|
||||
3,
|
||||
[{ stepKey: 'students', sheet: '学生' }],
|
||||
{ students: { name: '姓名', studentNo: '学号' } },
|
||||
{
|
||||
organization: '主校区',
|
||||
updateExisting: false,
|
||||
duplicatePolicy: 'skip',
|
||||
skipUnmatched: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('preflight_import 生成预检报告并通过 ui.import_preflight 推送', async () => {
|
||||
const { service } = createService();
|
||||
const toolRun = { id: 1, status: 'running' };
|
||||
const toolRuns = {
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => ({ ...toolRun, ...value })),
|
||||
};
|
||||
const messages = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, metadata: null }),
|
||||
save: jest.fn(async (value) => value),
|
||||
};
|
||||
const attachmentService = {
|
||||
requireReadyOwned: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 9,
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
originalName: 'students.xlsx',
|
||||
size: 10,
|
||||
},
|
||||
]),
|
||||
readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')),
|
||||
};
|
||||
const report = {
|
||||
verdict: 'ready',
|
||||
stages: [
|
||||
{
|
||||
stepKey: 'students',
|
||||
label: '学生档案',
|
||||
sheetNames: ['学生'],
|
||||
total: 2,
|
||||
create: 2,
|
||||
update: 0,
|
||||
error: 0,
|
||||
skip: 0,
|
||||
mapping: { name: '姓名' },
|
||||
missingRequired: [],
|
||||
},
|
||||
],
|
||||
blocks: [],
|
||||
questions: [],
|
||||
nextSteps: [],
|
||||
};
|
||||
const importsService = { preflightFile: jest.fn().mockResolvedValue(report) };
|
||||
(service as unknown as { toolRuns: unknown }).toolRuns = toolRuns;
|
||||
(service as unknown as { messages: unknown }).messages = messages;
|
||||
(service as unknown as { attachmentService: unknown }).attachmentService = attachmentService;
|
||||
(service as unknown as { importsService: unknown }).importsService = importsService;
|
||||
|
||||
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
executePreflightImport(
|
||||
messageId: number,
|
||||
call: { id: string; name: string; arguments: string },
|
||||
userId: number,
|
||||
emit: (event: string, data?: unknown) => void,
|
||||
): Promise<string>;
|
||||
}
|
||||
).executePreflightImport(
|
||||
42,
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'preflight_import',
|
||||
arguments: JSON.stringify({ attachmentId: 9 }),
|
||||
},
|
||||
7,
|
||||
(event, data) => emitted.push({ event, data: (data ?? {}) as Record<string, unknown> }),
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result) as { status: string; report: unknown };
|
||||
expect(parsed.status).toBe('success');
|
||||
expect(parsed.report).toEqual(report);
|
||||
expect(importsService.preflightFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ originalName: 'students.xlsx' }),
|
||||
);
|
||||
expect(emitted.some(({ event }) => event === 'ui.import_preflight')).toBe(true);
|
||||
expect(messages.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({ a2uiImportPreflight: report }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('excel_analyze 用 ExcelJS 读取概览并返回给模型', async () => {
|
||||
const { service } = createService();
|
||||
const toolRun = { id: 1, status: 'running' };
|
||||
const toolRuns = {
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => ({ ...toolRun, ...value })),
|
||||
};
|
||||
const attachmentService = {
|
||||
requireReadyOwned: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 9,
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
originalName: 'students.xlsx',
|
||||
size: 10,
|
||||
},
|
||||
]),
|
||||
readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')),
|
||||
};
|
||||
const excelReader = {
|
||||
overview: jest.fn().mockResolvedValue({
|
||||
sheets: [{ name: '学生', rowCount: 2, columns: ['姓名'] }],
|
||||
text: '# 学生(共 2 行)\n姓名\n张三',
|
||||
}),
|
||||
readRows: jest.fn(),
|
||||
};
|
||||
(service as unknown as { toolRuns: unknown }).toolRuns = toolRuns;
|
||||
(service as unknown as { attachmentService: unknown }).attachmentService = attachmentService;
|
||||
(service as unknown as { excelReader: unknown }).excelReader = excelReader;
|
||||
|
||||
const emitted: Array<{ event: string }> = [];
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
executeExcelAnalyze(
|
||||
messageId: number,
|
||||
call: { id: string; name: string; arguments: string },
|
||||
userId: number,
|
||||
emit: (event: string, data?: unknown) => void,
|
||||
): Promise<string>;
|
||||
}
|
||||
).executeExcelAnalyze(
|
||||
42,
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'excel_analyze',
|
||||
arguments: JSON.stringify({ attachmentId: 9, action: 'overview' }),
|
||||
},
|
||||
7,
|
||||
(event) => emitted.push({ event }),
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result) as { status: string; data: { sheets: unknown[] } };
|
||||
expect(parsed.status).toBe('success');
|
||||
expect(parsed.data.sheets).toHaveLength(1);
|
||||
expect(excelReader.overview).toHaveBeenCalledWith(expect.any(Buffer));
|
||||
expect(emitted.some(({ event }) => event === 'tool.completed')).toBe(true);
|
||||
});
|
||||
|
||||
it('start_import_wizard 拒绝非法的确认参数', async () => {
|
||||
const { service } = createService();
|
||||
const toolRun = { id: 1, status: 'running' };
|
||||
const toolRuns = {
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => ({ ...toolRun, ...value })),
|
||||
};
|
||||
const messages = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, metadata: null }),
|
||||
save: jest.fn(async (value) => value),
|
||||
};
|
||||
const attachmentService = {
|
||||
requireReadyOwned: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 9,
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
originalName: 'students.xlsx',
|
||||
},
|
||||
]),
|
||||
readStoredBuffer: jest.fn(),
|
||||
};
|
||||
const importsService = { createRun: jest.fn() };
|
||||
(service as unknown as { toolRuns: unknown }).toolRuns = toolRuns;
|
||||
(service as unknown as { messages: unknown }).messages = messages;
|
||||
(service as unknown as { attachmentService: unknown }).attachmentService = attachmentService;
|
||||
(service as unknown as { importsService: unknown }).importsService = importsService;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
executeStartImportWizard(
|
||||
messageId: number,
|
||||
call: { id: string; name: string; arguments: string },
|
||||
context: { userId: number; permissions: string[]; isSuperAdmin: boolean },
|
||||
emit: (event: string, data?: unknown) => void,
|
||||
): Promise<string>;
|
||||
}
|
||||
).executeStartImportWizard(
|
||||
42,
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'start_import_wizard',
|
||||
arguments: JSON.stringify({
|
||||
attachmentId: 9,
|
||||
stages: [{ stepKey: 'students', sheet: '学生' }],
|
||||
duplicatePolicy: 'bogus',
|
||||
}),
|
||||
},
|
||||
{ userId: 7, permissions: [], isSuperAdmin: false },
|
||||
jest.fn(),
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result) as { status: string; error: string };
|
||||
expect(parsed.status).toBe('failed');
|
||||
expect(parsed.error).toContain('duplicatePolicy');
|
||||
expect(importsService.createRun).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user