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:
@@ -165,7 +165,7 @@ export const studentSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
studentNo: z.string().optional(),
|
||||
studentNo: z.string().nullable().optional(),
|
||||
status: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
@@ -180,7 +180,7 @@ export const depositSchema = z
|
||||
export const depositsSchema = z.array(depositSchema);
|
||||
|
||||
export const depositStudentLookupSchema = z
|
||||
.object({ studentId: z.number(), name: z.string().optional() })
|
||||
.object({ studentId: z.number(), name: z.string().nullable().optional() })
|
||||
.passthrough();
|
||||
|
||||
export const depositStudentLookupsSchema = z.array(depositStudentLookupSchema);
|
||||
@@ -217,6 +217,18 @@ export const expenseRecordSchema = z
|
||||
|
||||
export const expenseRecordsSchema = z.array(expenseRecordSchema);
|
||||
|
||||
export const expenseStudentLookupSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
name: z.string().nullable().optional(),
|
||||
studentNo: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const expenseStudentLookupsSchema = z.array(expenseStudentLookupSchema);
|
||||
|
||||
export const expenseRoomsListSchema = z.array(z.record(z.string(), z.unknown()));
|
||||
|
||||
export const expenseLookupsSchema = z
|
||||
.object({
|
||||
rooms: z.array(z.record(z.string(), z.unknown())),
|
||||
|
||||
@@ -34,6 +34,18 @@ export const importStepDetailSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const importRunSettingsSchema = z
|
||||
.object({
|
||||
mapping: z.record(z.string(), z.record(z.string(), z.string())).optional(),
|
||||
organization: z.string().nullable().optional(),
|
||||
updateExisting: z.boolean().optional(),
|
||||
duplicatePolicy: z.enum(['error', 'skip']).optional(),
|
||||
skipUnmatched: z.boolean().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
export const importRunDetailSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -44,6 +56,7 @@ export const importRunDetailSchema = z
|
||||
createdAt: z.string(),
|
||||
sheets: z.array(importSheetMetaSchema),
|
||||
steps: z.array(importStepDetailSchema),
|
||||
settings: importRunSettingsSchema,
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
|
||||
@@ -54,7 +54,9 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.sm;
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(!isMobile);
|
||||
// 断点首帧可能尚未解析(isMobile 误判为 true),桌面端默认展开会话侧边栏,
|
||||
// 移动端通过 effectiveSidebarOpen 统一隐藏。
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const effectiveSidebarOpen = isMobile ? false : sidebarOpen;
|
||||
const [skills, setSkills] = useState<AiSkill[]>([]);
|
||||
const [conversationStatus, setConversationStatus] = useState<
|
||||
@@ -228,7 +230,7 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
},
|
||||
});
|
||||
},
|
||||
[setConversation],
|
||||
[setConversation, modal],
|
||||
);
|
||||
|
||||
/** 删除单个会话时中止请求并清理会话运行时状态 */
|
||||
@@ -269,13 +271,14 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
activeId,
|
||||
conversations,
|
||||
switchConversation,
|
||||
removeConversation,
|
||||
removeConversationEntry,
|
||||
],
|
||||
[
|
||||
activeId,
|
||||
conversations,
|
||||
switchConversation,
|
||||
removeConversation,
|
||||
removeConversationEntry,
|
||||
modal,
|
||||
],
|
||||
);
|
||||
|
||||
const enterSelectionMode = useCallback(() => {
|
||||
@@ -358,11 +361,12 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
activeId,
|
||||
conversations,
|
||||
removeConversation,
|
||||
removeConversationEntry,
|
||||
selectedKeys,
|
||||
switchConversation,
|
||||
setConversations,
|
||||
]);
|
||||
removeConversationEntry,
|
||||
selectedKeys,
|
||||
switchConversation,
|
||||
setConversations,
|
||||
modal,
|
||||
]);
|
||||
|
||||
const conversationMenu = useCallback(
|
||||
(item: ConversationItemType): MenuProps => ({
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useUserStore } from '../../store/user/userStore';
|
||||
import { DynamicChart } from './DynamicChart';
|
||||
import { DynamicForm } from './DynamicForm';
|
||||
import { DynamicReview } from './DynamicReview';
|
||||
import { ImportPreflightCard } from './ImportPreflightCard';
|
||||
import { LiteCodeHighlighter } from './LiteCodeHighlighter';
|
||||
import { LiteMermaid } from './LiteMermaid';
|
||||
import type {
|
||||
@@ -24,6 +25,7 @@ import type {
|
||||
AiChatMessageStatus,
|
||||
AiChartSchema,
|
||||
AiFormSchema,
|
||||
AiImportPreflight,
|
||||
AiImportWizard,
|
||||
AiReviewSection,
|
||||
AiReviewSchema,
|
||||
@@ -43,6 +45,7 @@ const toolLabels: Record<string, string> = {
|
||||
render_form: '生成表单',
|
||||
render_review: '生成导入预览',
|
||||
render_chart: '生成图表',
|
||||
preflight_import: '导入预检',
|
||||
start_import_wizard: '生成导入向导',
|
||||
create_student: '创建学生',
|
||||
search_exams: '查询考试',
|
||||
@@ -311,6 +314,11 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||
{attachmentCards}
|
||||
</Flex>
|
||||
)}
|
||||
{(() => {
|
||||
const preflight = message.metadata?.a2uiImportPreflight;
|
||||
if (!preflight || typeof preflight !== 'object' || Array.isArray(preflight)) return null;
|
||||
return <ImportPreflightCard preflight={preflight as AiImportPreflight} />;
|
||||
})()}
|
||||
{(() => {
|
||||
const wizard = message.metadata?.a2uiImportWizard as AiImportWizard | undefined;
|
||||
if (!wizard || !onOpenImportWizard) return null;
|
||||
|
||||
115
apps/admin/src/components/AiChat/ImportPreflightCard.tsx
Normal file
115
apps/admin/src/components/AiChat/ImportPreflightCard.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { TableOutlined } from '@ant-design/icons';
|
||||
import { Card, Flex, Space, Tag, Typography } from 'antd';
|
||||
import type {
|
||||
AiImportPreflight,
|
||||
AiImportPreflightVerdict,
|
||||
} from './types';
|
||||
|
||||
const VERDICT_META: Record<
|
||||
AiImportPreflightVerdict,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
ready: { label: '可导入', color: 'success' },
|
||||
needs_input: { label: '需要确认', color: 'warning' },
|
||||
blocked: { label: '暂无法导入', color: 'error' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 上传 Excel 后的“可插入性预检报告”卡片:展示判定结论、
|
||||
* 分阶段统计、阻断原因、待确认问题与下一步建议。
|
||||
*/
|
||||
export const ImportPreflightCard: React.FC<{ preflight: AiImportPreflight }> = ({
|
||||
preflight,
|
||||
}) => {
|
||||
const verdict = VERDICT_META[preflight.verdict] ?? VERDICT_META.needs_input;
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
className="ai-chat-preflight-card"
|
||||
title={
|
||||
<Flex gap={8} align="center" wrap>
|
||||
<TableOutlined />
|
||||
<Typography.Text strong>Excel 导入预检</Typography.Text>
|
||||
<Tag color={verdict.color}>{verdict.label}</Tag>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
||||
{preflight.stages.length > 0 ? (
|
||||
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
|
||||
{preflight.stages.map((stage) => (
|
||||
<Flex key={stage.stepKey} vertical gap={4}>
|
||||
<Flex gap={8} align="center" wrap>
|
||||
<Typography.Text strong>{stage.label}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{stage.sheetNames.join('、')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
<Space size={4} wrap>
|
||||
<Tag color="blue">共 {stage.total} 行</Tag>
|
||||
<Tag color="green">新建 {stage.create}</Tag>
|
||||
<Tag color="orange">更新 {stage.update}</Tag>
|
||||
<Tag color="red">错误 {stage.error}</Tag>
|
||||
<Tag>跳过 {stage.skip}</Tag>
|
||||
</Space>
|
||||
</Flex>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">未识别到可导入的工作表</Typography.Text>
|
||||
)}
|
||||
|
||||
{preflight.blocks.length > 0 && (
|
||||
<Space orientation="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
阻断原因
|
||||
</Typography.Text>
|
||||
{preflight.blocks.map((block) => (
|
||||
<Flex key={block.code} gap={8} align="baseline" wrap>
|
||||
<Tag color="red">{block.label}</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{block.message}({block.count} 行)
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{preflight.questions.length > 0 && (
|
||||
<Space orientation="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
需要确认
|
||||
</Typography.Text>
|
||||
{preflight.questions.map((question) => (
|
||||
<Flex key={question.key} gap={8} align="baseline" wrap>
|
||||
<Tag color="gold">{question.label}</Tag>
|
||||
{question.description && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{question.description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Flex>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{preflight.nextSteps.length > 0 && (
|
||||
<Space orientation="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
下一步建议
|
||||
</Typography.Text>
|
||||
{preflight.nextSteps.map((step) => (
|
||||
<Flex key={step.key} gap={8} align="baseline" wrap>
|
||||
<Tag color="blue">{step.label}</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{step.description}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -396,6 +396,142 @@ describe('AI chat bubble rendering', () => {
|
||||
expect(container.textContent).toContain('26暑期文化课宿舍.xlsx');
|
||||
});
|
||||
|
||||
it('renders an import preflight card from assistant message metadata', async () => {
|
||||
const message: AiChatMessage = {
|
||||
role: 'assistant',
|
||||
content: '这是预检结果',
|
||||
reasoningContent: '',
|
||||
toolRuns: [],
|
||||
attachments: [],
|
||||
metadata: {
|
||||
a2uiImportPreflight: {
|
||||
verdict: 'needs_input',
|
||||
stages: [
|
||||
{
|
||||
stepKey: 'students',
|
||||
label: '学生档案',
|
||||
sheetNames: ['学生'],
|
||||
total: 2,
|
||||
create: 1,
|
||||
update: 1,
|
||||
error: 0,
|
||||
skip: 0,
|
||||
mapping: { name: '姓名' },
|
||||
missingRequired: [],
|
||||
},
|
||||
],
|
||||
blocks: [{ code: 'unknown_organization', label: '未知校区', stepKeys: ['students'], message: '校区不存在', count: 1 }],
|
||||
questions: [{ key: 'update', type: 'update', label: '文件中有 1 行已匹配现有记录' }],
|
||||
nextSteps: [
|
||||
{
|
||||
key: 'students-next',
|
||||
label: '分班 / 排课 / 入住',
|
||||
description: '学生档案导入完成后可继续分班、排课或入住。',
|
||||
after: ['students'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<AiMessageContent message={message} />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Excel 导入预检');
|
||||
expect(container.textContent).toContain('需要确认');
|
||||
expect(container.textContent).toContain('学生档案');
|
||||
expect(container.textContent).toContain('新建 1');
|
||||
expect(container.textContent).toContain('更新 1');
|
||||
expect(container.textContent).toContain('未知校区');
|
||||
expect(container.textContent).toContain('分班 / 排课 / 入住');
|
||||
});
|
||||
|
||||
it('renders preflight card, form Q&A and opens the import wizard', async () => {
|
||||
let openedRunId: string | null = null;
|
||||
const message: AiChatMessage = {
|
||||
role: 'assistant',
|
||||
content: '请先确认导入策略,再打开向导。',
|
||||
reasoningContent: '',
|
||||
toolRuns: [],
|
||||
attachments: [],
|
||||
metadata: {
|
||||
a2uiImportPreflight: {
|
||||
verdict: 'ready',
|
||||
stages: [
|
||||
{
|
||||
stepKey: 'students',
|
||||
label: '学生档案',
|
||||
sheetNames: ['学生'],
|
||||
total: 1,
|
||||
create: 1,
|
||||
update: 0,
|
||||
error: 0,
|
||||
skip: 0,
|
||||
mapping: { name: '姓名' },
|
||||
missingRequired: [],
|
||||
},
|
||||
],
|
||||
blocks: [],
|
||||
questions: [],
|
||||
nextSteps: [],
|
||||
},
|
||||
a2uiImportWizard: {
|
||||
runId: 'run-1',
|
||||
fileName: 'students.xlsx',
|
||||
sheets: [{ name: '学生', headers: ['姓名'], rowCount: 1 }],
|
||||
steps: [{ stepKey: 'students', label: '学生档案', sheets: ['学生'], status: 'pending' }],
|
||||
},
|
||||
},
|
||||
forms: [
|
||||
{
|
||||
id: 'form-1',
|
||||
title: '确认导入策略',
|
||||
submitLabel: '确认',
|
||||
fields: [
|
||||
{
|
||||
name: 'duplicatePolicy',
|
||||
label: '重复行处理',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '标记为错误', value: 'error' },
|
||||
{ label: '跳过重复行', value: 'skip' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<AiMessageContent
|
||||
message={message}
|
||||
onOpenImportWizard={(runId) => {
|
||||
openedRunId = runId;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Excel 导入预检');
|
||||
expect(container.textContent).toContain('确认导入策略');
|
||||
const wizardButton = Array.from(container.querySelectorAll('button')).find((item) =>
|
||||
item.textContent?.includes('打开导入向导'),
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(wizardButton).toBeDefined();
|
||||
await act(async () => {
|
||||
wizardButton?.click();
|
||||
});
|
||||
expect(openedRunId).toBe('run-1');
|
||||
});
|
||||
|
||||
it('renders model retrying hint while waiting for the upstream retry', async () => {
|
||||
const message: AiChatMessage = {
|
||||
role: 'assistant',
|
||||
|
||||
@@ -139,4 +139,26 @@ describe('AI chat history mapper', () => {
|
||||
expect(mapped.message.charts).toHaveLength(1);
|
||||
expect(mapped.message.charts?.[0]).toMatchObject({ id: 'chart-9', chartType: 'bar' });
|
||||
});
|
||||
|
||||
it('keeps import preflight metadata on history messages', () => {
|
||||
const preflight = {
|
||||
verdict: 'needs_input',
|
||||
stages: [],
|
||||
blocks: [],
|
||||
questions: [{ key: 'update', type: 'update', label: '文件中有 1 行已匹配现有记录' }],
|
||||
nextSteps: [],
|
||||
};
|
||||
const mapped = mapHistoryMessage({
|
||||
id: 8,
|
||||
role: 'assistant',
|
||||
content: '预检完成',
|
||||
reasoningContent: null,
|
||||
status: 'completed',
|
||||
errorCode: null,
|
||||
createdAt: '2026-07-23T00:00:00.000Z',
|
||||
metadata: { a2uiImportPreflight: preflight },
|
||||
});
|
||||
|
||||
expect(mapped.message.metadata?.a2uiImportPreflight).toEqual(preflight);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,6 +154,47 @@ describe('AI chat SSE message reducer', () => {
|
||||
expect(message.forms?.[1]).toMatchObject({ id: 'form-2' });
|
||||
});
|
||||
|
||||
it('stores ui.import_preflight in message metadata and restores it from completed message', () => {
|
||||
const preflight = {
|
||||
verdict: 'needs_input',
|
||||
stages: [
|
||||
{
|
||||
stepKey: 'students',
|
||||
label: '学生档案',
|
||||
sheetNames: ['学生'],
|
||||
total: 2,
|
||||
create: 1,
|
||||
update: 1,
|
||||
error: 0,
|
||||
skip: 0,
|
||||
mapping: { name: '姓名' },
|
||||
missingRequired: [],
|
||||
},
|
||||
],
|
||||
blocks: [],
|
||||
questions: [{ key: 'update', type: 'update', label: '文件中有 1 行已匹配现有记录' }],
|
||||
nextSteps: [{ key: 'students-next', label: '分班 / 排课 / 入住', description: '建议', after: ['students'] }],
|
||||
};
|
||||
let message = reduceAiSseMessage(undefined, {
|
||||
event: 'ui.import_preflight',
|
||||
data: JSON.stringify({ messageId: 8, preflight }),
|
||||
});
|
||||
expect(message.metadata?.a2uiImportPreflight).toEqual(preflight);
|
||||
|
||||
message = reduceAiSseMessage(message, {
|
||||
event: 'message.completed',
|
||||
data: JSON.stringify({
|
||||
message: {
|
||||
id: 8,
|
||||
content: '预检完成',
|
||||
status: 'completed',
|
||||
metadata: { a2uiImportPreflight: preflight },
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(message.metadata?.a2uiImportPreflight).toEqual(preflight);
|
||||
});
|
||||
|
||||
it('restores a persisted form from message.completed metadata', () => {
|
||||
const message = reduceAiSseMessage(undefined, {
|
||||
event: 'message.completed',
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
AiChatMessage,
|
||||
AiChartSchema,
|
||||
AiFormSchema,
|
||||
AiImportPreflight,
|
||||
AiModelRetryInfo,
|
||||
AiReviewSchema,
|
||||
AiSseChunk,
|
||||
@@ -35,6 +36,7 @@ interface AiSsePayload {
|
||||
form?: AiFormSchema;
|
||||
review?: AiReviewSchema;
|
||||
chart?: AiChartSchema;
|
||||
preflight?: AiImportPreflight;
|
||||
wizard?: unknown;
|
||||
retry?: AiModelRetryInfo;
|
||||
message?:
|
||||
@@ -196,6 +198,8 @@ export function reduceAiSseMessage(
|
||||
message.reviews = mergeById<AiReviewSchema>(message.reviews, payload.review);
|
||||
} else if (event === 'ui.chart' && payload.chart) {
|
||||
message.charts = mergeById<AiChartSchema>(message.charts, payload.chart);
|
||||
} else if (event === 'ui.import_preflight' && payload.preflight) {
|
||||
message.metadata = { ...message.metadata, a2uiImportPreflight: payload.preflight };
|
||||
} else if (event === 'ui.import_wizard' && payload.wizard) {
|
||||
message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard };
|
||||
} else if (event === 'tool.started') {
|
||||
|
||||
@@ -165,6 +165,18 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ai-chat-main .ai-chat-toolbar {
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.ai-chat-main .ai-chat-messages {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.ai-chat-main .ai-chat-composer {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.ai-chat-toolbar {
|
||||
display: flex;
|
||||
flex: 0 0 48px;
|
||||
|
||||
@@ -115,6 +115,61 @@ export interface AiImportWizard {
|
||||
}>;
|
||||
}
|
||||
|
||||
export type AiImportPreflightVerdict = 'ready' | 'needs_input' | 'blocked';
|
||||
|
||||
export interface AiImportPreflightStage {
|
||||
stepKey: AiReviewSectionType;
|
||||
label: string;
|
||||
sheetNames: string[];
|
||||
total: number;
|
||||
create: number;
|
||||
update: number;
|
||||
error: number;
|
||||
skip: number;
|
||||
mapping: Record<string, string>;
|
||||
missingRequired: string[];
|
||||
}
|
||||
|
||||
export interface AiImportPreflightBlock {
|
||||
code: string;
|
||||
label: string;
|
||||
stepKeys: AiReviewSectionType[];
|
||||
message: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AiImportPreflightQuestion {
|
||||
key: string;
|
||||
type: 'mapping' | 'organization' | 'update' | 'duplicate' | 'reference';
|
||||
label: string;
|
||||
description?: string;
|
||||
stepKey?: AiReviewSectionType;
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
default?: string | boolean;
|
||||
}
|
||||
|
||||
export interface AiImportPreflightNextStep {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
after: AiReviewSectionType[];
|
||||
}
|
||||
|
||||
export interface AiImportPreflight {
|
||||
verdict: AiImportPreflightVerdict;
|
||||
stages: AiImportPreflightStage[];
|
||||
blocks: AiImportPreflightBlock[];
|
||||
questions: AiImportPreflightQuestion[];
|
||||
nextSteps: AiImportPreflightNextStep[];
|
||||
errorSamples?: Array<{
|
||||
code: string;
|
||||
stepKey: AiReviewSectionType;
|
||||
sheet: string;
|
||||
rowNumber: number;
|
||||
errors: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export type AiToolRunStatus =
|
||||
| 'running'
|
||||
| 'success'
|
||||
|
||||
@@ -36,6 +36,13 @@ export interface ImportRunDetail {
|
||||
createdAt: string;
|
||||
sheets: ImportSheetMeta[];
|
||||
steps: ImportStepDetail[];
|
||||
settings?: {
|
||||
mapping?: Partial<Record<ImportStepKey, Record<string, string>>>;
|
||||
organization?: string | null;
|
||||
updateExisting?: boolean;
|
||||
duplicatePolicy?: 'error' | 'skip';
|
||||
skipUnmatched?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImportStageRequest {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useUserStore } from '../../store/user/userStore';
|
||||
import { getAttendanceExperience } from './attendance-workspace';
|
||||
import { TeacherAttendanceWorkspace } from './teacher';
|
||||
import { AdminAttendanceArchive } from './admin';
|
||||
import './attendance.css';
|
||||
|
||||
function readCurrentRoles(): string[] {
|
||||
const roles = useUserStore.getState().user?.roles;
|
||||
|
||||
@@ -9,7 +9,12 @@ import { message } from '../../ui/app-message';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||
import { validateResponse } from '../../utils/validate';
|
||||
import { expenseLookupsSchema, expenseRecordsSchema } from '../../api/schemas';
|
||||
import {
|
||||
expenseLookupsSchema,
|
||||
expenseRecordsSchema,
|
||||
expenseRoomsListSchema,
|
||||
expenseStudentLookupsSchema,
|
||||
} from '../../api/schemas';
|
||||
import { archiveViewPolicy, expenseStatusForView } from '../archive-view';
|
||||
import { ExpenseTablePanel } from './ExpenseTablePanel';
|
||||
import { PersonalExpenseModal, RoomExpenseModal, UtilityModal } from './ExpenseModals';
|
||||
@@ -70,10 +75,10 @@ const ExpensesPage: React.FC = () => {
|
||||
try {
|
||||
const [rooms, personal, students, roomsList] = await Promise.all([
|
||||
api.get('/expenses/room', {
|
||||
params: expenseStatusForView(showArchived ? 'archived' : 'active'),
|
||||
params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') },
|
||||
}),
|
||||
api.get('/expenses/personal', {
|
||||
params: expenseStatusForView(showArchived ? 'archived' : 'active'),
|
||||
params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') },
|
||||
}),
|
||||
api.get('/expenses/student-lookups'),
|
||||
api.get('/rooms'),
|
||||
@@ -81,8 +86,8 @@ const ExpensesPage: React.FC = () => {
|
||||
return {
|
||||
rooms: validateResponse(expenseRecordsSchema, rooms),
|
||||
personal: validateResponse(expenseRecordsSchema, personal),
|
||||
students: validateResponse(expenseRecordsSchema, students),
|
||||
roomsList: validateResponse(expenseRecordsSchema, roomsList),
|
||||
students: validateResponse(expenseStudentLookupsSchema, students),
|
||||
roomsList: validateResponse(expenseRoomsListSchema, roomsList),
|
||||
};
|
||||
} catch {
|
||||
message.error('加载费用数据失败');
|
||||
|
||||
@@ -16,7 +16,6 @@ import { OfficeCliService } from './office-cli.service';
|
||||
import { AiAttachment } from './entities';
|
||||
|
||||
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_EXTRACTED_CHARS = 48 * 1024;
|
||||
const MAX_MODEL_IMAGE_BYTES = 20 * 1024 * 1024;
|
||||
const ACCEPTED_MIME_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
@@ -165,7 +164,7 @@ export class AiAttachmentService {
|
||||
} else {
|
||||
parts.push({
|
||||
attachment,
|
||||
text: attachment.extractedText?.slice(0, MAX_EXTRACTED_CHARS) || '',
|
||||
text: attachment.extractedText || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -290,7 +289,7 @@ export class AiAttachmentService {
|
||||
}
|
||||
|
||||
private normalizeExtractedText(value: string): string {
|
||||
return value.split('\u0000').join('').replace(/\r\n/g, '\n').trim().slice(0, MAX_EXTRACTED_CHARS);
|
||||
return value.split('\u0000').join('').replace(/\r\n/g, '\n').trim();
|
||||
}
|
||||
|
||||
private assertDeclaredType(declared: string, detected: string): void {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
export const MAX_HISTORY_MESSAGES = 30;
|
||||
export const MAX_CONTEXT_CHARS = 64 * 1024;
|
||||
export const MAX_TOOL_CALLS_PER_ROUND = 50;
|
||||
export const MAX_TOOL_ROUNDS = 90;
|
||||
export const MAX_SUMMARY_CHARS = 2000;
|
||||
export const MAX_GENERATED_CHARS = 256 * 1024;
|
||||
export const MAX_ATTACHMENT_TEXT_CHARS = 20000;
|
||||
export const MAX_FOCUS_CONTENT_CHARS = 40000;
|
||||
export const DEFAULT_TITLE = '新对话';
|
||||
|
||||
const CELL_VALUE_ANY_OF = [
|
||||
@@ -16,12 +13,75 @@ const CELL_VALUE_ANY_OF = [
|
||||
];
|
||||
|
||||
export const A2UI_TOOL_SCHEMAS = [
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'preflight_import',
|
||||
description:
|
||||
'对上传的 Excel 进行导入预检并生成“可插入性报告”:分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题与导入后的下一步建议。当用户上传 Excel 并疑似需要批量导入学生、宿舍、换宿或入住数据时先调用;根据报告向用户确认后,再调用 start_import_wizard。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
attachmentId: {
|
||||
type: 'integer',
|
||||
description: '上传的 Excel 附件 ID。系统直接从文件读取行数据,无需(也不要)在参数里抄录数据。',
|
||||
},
|
||||
},
|
||||
required: ['attachmentId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'excel_analyze',
|
||||
description:
|
||||
'用 ExcelJS 直接解析上传的 Excel(.xlsx/.csv):overview 查看工作表概览(表名、行数、前几行样本),rows 按工作表/行范围读取具体行。适合核对表头、抽查数据行、确认预检报告里的错误原因;Word/PPT 请用 office_analyze。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
attachmentId: {
|
||||
type: 'integer',
|
||||
description: '上传的 Excel 附件 ID',
|
||||
},
|
||||
action: {
|
||||
type: 'string',
|
||||
description: 'overview 概览 / rows 读取行',
|
||||
enum: ['overview', 'rows'],
|
||||
},
|
||||
sheet: {
|
||||
type: 'string',
|
||||
description: '工作表名称(rows 时可选,默认第一个表)',
|
||||
maxLength: 200,
|
||||
},
|
||||
startRow: {
|
||||
type: 'integer',
|
||||
description: '起始行(含表头,从 1 开始,默认 1)',
|
||||
minimum: 1,
|
||||
},
|
||||
maxRows: {
|
||||
type: 'integer',
|
||||
description: '读取行数(默认 20;传大值可读取更多/全部行)',
|
||||
minimum: 1,
|
||||
},
|
||||
maxColumns: {
|
||||
type: 'integer',
|
||||
description: '读取列数(默认 30;传大值可读取更多/全部列)',
|
||||
minimum: 1,
|
||||
},
|
||||
},
|
||||
required: ['attachmentId', 'action'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'start_import_wizard',
|
||||
description:
|
||||
'生成一个“批量导入向导”显示给用户。当用户上传 Excel 并需要批量导入学生、宿舍、换宿或入住数据时调用;传入 attachmentId 与 stages(业务类型 + 工作表名),系统直接解析文件、自动识别列映射并按依赖顺序分阶段预览,用户确认后才会入库。每个回答回合最多调用一次,生成成功后提示用户打开向导逐阶段确认,不要重复调用,也不要代替用户调用任何写工具直接插入。',
|
||||
'生成一个“批量导入向导”显示给用户。当用户上传 Excel 并需要批量导入学生、宿舍、换宿或入住数据时调用;传入 attachmentId 与 stages(业务类型 + 工作表名),并把用户确认的 mapping/organization/updateExisting/duplicatePolicy/skipUnmatched 一并传入。系统直接解析文件、应用确认策略并按依赖顺序分阶段预览,用户确认后才会入库。每个回答回合最多调用一次,生成成功后提示用户打开向导逐阶段确认,不要重复调用,也不要代替用户调用任何写工具直接插入。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -46,6 +106,34 @@ export const A2UI_TOOL_SCHEMAS = [
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
mapping: {
|
||||
type: 'object',
|
||||
description:
|
||||
'列映射确认结果:阶段 stepKey -> { 字段名: 工作表表头 }(字段名如 students.name/rooms.roomNumber/checkins.checkInDate/transfers.newRoom)。来自 preflight_import 报告的映射确认;未确认时省略,系统自动识别。',
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
description: '字段名 -> 工作表表头',
|
||||
additionalProperties: { type: 'string', maxLength: 200 },
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
type: 'string',
|
||||
description: '确认后的校区名称(预检报告出现未知校区时由用户确认)',
|
||||
maxLength: 100,
|
||||
},
|
||||
updateExisting: {
|
||||
type: 'boolean',
|
||||
description: '是否更新已匹配的现有记录;默认 true,false 时已匹配行跳过',
|
||||
},
|
||||
duplicatePolicy: {
|
||||
type: 'string',
|
||||
description: '文件内重复行策略:error 标记错误 / skip 跳过重复行;默认 error',
|
||||
enum: ['error', 'skip'],
|
||||
},
|
||||
skipUnmatched: {
|
||||
type: 'boolean',
|
||||
description: '关系表(入住/换宿)找不到学生或宿舍时是否跳过该行;默认 false',
|
||||
},
|
||||
},
|
||||
required: ['attachmentId', 'stages'],
|
||||
additionalProperties: false,
|
||||
@@ -214,13 +302,19 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须
|
||||
当用户需要录入或修改业务数据时,先调用 render_form 生成确认表单,提示用户填写并提交;只有在用户通过表单提交确认后,才能执行写操作工具(如 create_student、update_students)。
|
||||
新增学生示例:render_form 的 fields 使用 name/phone/gender/studentNo。
|
||||
修改学生示例:批量修改姓名/档案时,render_form 的字段可用 name_<学生ID> 等命名展示待修改内容,用户提交后再调用 update_students,每条更新必须带学生 id。
|
||||
当用户上传 Excel 并需要批量导入(学生、宿舍、换宿、入住记录)时,先调用 start_import_wizard 生成“导入向导”:必须传入 attachmentId(上传附件的 ID)和 stages(声明业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全;生成向导后提示用户打开,按“基础档案(学生/宿舍)→ 关系(入住/换宿)”的顺序逐阶段预览,人工确认后系统才会入库;不要代替用户调用任何写工具直接插入。每个回答回合最多调用一次 start_import_wizard。
|
||||
当用户上传 Excel 并需要批量导入(学生、宿舍、换宿、入住记录)时,按以下固定流程执行:
|
||||
1. 先调用 preflight_import(传入 attachmentId)生成“可插入性预检报告”:报告给出分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题和导入后的下一步建议。
|
||||
2. 报告为 blocked 时,向用户说明阻断原因并建议修正文件后重传,不要生成向导;报告为 needs_input 时,按报告中的 questions 向用户确认:选项型问题用 render_form 生成表单(如更新策略、重复策略、校区、未匹配行处理),列映射类问题用聊天文本确认;报告为 ready 时可直接进入下一步,如需列映射确认也可先问。不要替用户默认做出影响数据的决定。
|
||||
报告只给汇总统计时,可用 excel_analyze 读取报告 errorSamples 对应的工作表与行号,向用户解释具体错误原因(如某行缺少手机号、姓名带日期后缀、宿舍未建档等)。
|
||||
3. 用户确认后调用 start_import_wizard:必须传入 attachmentId 和 stages(业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),并把确认结果一并传入(mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。
|
||||
4. 生成向导后提示用户打开,按“基础档案(学生/宿舍)→ 关系(入住/换宿)”的顺序逐阶段预览,人工确认后系统才会入库;不要代替用户调用任何写工具直接插入。
|
||||
每个回答回合最多调用一次 preflight_import 和一次 start_import_wizard;报告与导入完成后由你给出下一步建议,不要自动执行后续写操作。
|
||||
当用户需要可视化数据(趋势、占比、对比、多维、完成率等)时,调用 render_chart 生成图表卡片(chartType 支持 line 折线/bar 柱状/pie 饼图/area 面积/scatter 散点/radar 雷达/gauge 仪表盘/funnel 漏斗,columns+rows 表格数据)。
|
||||
上传的 Office 附件(Excel/Word/PPT)可用 office_analyze 查看结构(stats/outline)确认表名与表头;批量导入前如不确定列名,可用 get/query 只读少量单元格核对,不要读取整表。
|
||||
上传的 Office 附件:Excel(.xlsx/.csv)优先用 excel_analyze 查看概览(overview)或按行读取(rows)核对表头与数据;Word/PPT 用 office_analyze 查看结构(stats/outline)。批量导入前如不确定列名,可先预检(preflight_import)再用 excel_analyze 抽查具体行,不要读取整表。
|
||||
业务工作流引导(重要):
|
||||
- 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织。常见闭环:学生、宿舍、教室、组织等基础档案先行;再建立分班、入住、租赁等关系;之后才有考勤、费用等运行数据;最后生成账单、押金等结算。
|
||||
- 执行任何写入或导入前,先判断该操作依赖的前置数据是否已存在(可用查询工具核实):入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。
|
||||
- 导入或录入完成后,主动给出下一步建议(例如:入住导入完成 → 建议录入本月公共费用 → 生成并确认账单;学生导入完成 → 建议分班或排课)。
|
||||
- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么,再生成预览卡;多业务分表合并到同一张预览卡,并按依赖顺序执行。
|
||||
- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;疑似导入时先调用 preflight_import 生成预检报告,再按报告提问并生成导入向导,按依赖顺序执行。
|
||||
- 只引导当前角色权限范围内可执行的下一步,不得建议或执行用户无权操作。
|
||||
不得扩大用户权限或猜测不可见数据。回答使用简洁中文 Markdown。`;
|
||||
|
||||
@@ -73,7 +73,8 @@ export async function executeGeneration(
|
||||
tool.function.name !== 'create_student' &&
|
||||
tool.function.name !== 'update_students' &&
|
||||
tool.function.name !== 'render_form' &&
|
||||
tool.function.name !== 'start_import_wizard',
|
||||
tool.function.name !== 'start_import_wizard' &&
|
||||
tool.function.name !== 'preflight_import',
|
||||
);
|
||||
}
|
||||
tools.push(...A2UI_TOOL_SCHEMAS);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AiFormService } from './ai-form.service';
|
||||
import { AiReviewService } from './ai-review.service';
|
||||
import { AiModelStreamService } from './ai-model-stream.service';
|
||||
import { OfficeCliService } from './office-cli.service';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import {
|
||||
AiConversation,
|
||||
AiMessage,
|
||||
@@ -72,7 +73,11 @@ import {
|
||||
markReviewSubmittedOnMessage,
|
||||
} from './ai-chat.submissions';
|
||||
import { denyWriteTool, executeTool } from './ai-chat.tools';
|
||||
import { executeStartImportWizard } from './ai-chat.tool-actions';
|
||||
import {
|
||||
executeExcelAnalyze,
|
||||
executePreflightImport,
|
||||
executeStartImportWizard,
|
||||
} from './ai-chat.tool-actions';
|
||||
import { executeGeneration } from './ai-chat.generation';
|
||||
import {
|
||||
assertGeneratedLength,
|
||||
@@ -108,6 +113,7 @@ export class AiChatService implements AiChatServiceContext {
|
||||
readonly excelReader?: AiExcelReaderService,
|
||||
readonly officeCli?: OfficeCliService,
|
||||
readonly importsService?: ImportsService,
|
||||
readonly opLog?: OperationLogsService,
|
||||
) {}
|
||||
|
||||
listSkills(user: AuthenticatedUser): AgentSkillDescriptor[] {
|
||||
@@ -291,6 +297,24 @@ export class AiChatService implements AiChatServiceContext {
|
||||
return executeStartImportWizard(this, messageId, call, context, emit);
|
||||
}
|
||||
|
||||
executePreflightImport(
|
||||
messageId: number,
|
||||
call: ModelToolCall,
|
||||
userId: number,
|
||||
emit: AiSseEmitter,
|
||||
): Promise<string> {
|
||||
return executePreflightImport(this, messageId, call, userId, emit);
|
||||
}
|
||||
|
||||
executeExcelAnalyze(
|
||||
messageId: number,
|
||||
call: ModelToolCall,
|
||||
userId: number,
|
||||
emit: AiSseEmitter,
|
||||
): Promise<string> {
|
||||
return executeExcelAnalyze(this, messageId, call, userId, emit);
|
||||
}
|
||||
|
||||
executeGeneration(input: GenerationInput): Promise<void> {
|
||||
return executeGeneration(this, input);
|
||||
}
|
||||
|
||||
@@ -12,9 +12,6 @@ import type {
|
||||
} from './ai-chat.types';
|
||||
import {
|
||||
DEFAULT_TITLE,
|
||||
MAX_ATTACHMENT_TEXT_CHARS,
|
||||
MAX_CONTEXT_CHARS,
|
||||
MAX_FOCUS_CONTENT_CHARS,
|
||||
MAX_HISTORY_MESSAGES,
|
||||
SYSTEM_PROMPT,
|
||||
} from './ai-chat.types';
|
||||
@@ -301,7 +298,6 @@ export async function buildContext(
|
||||
? `${SYSTEM_PROMPT}\n当前会话已锁定技能:${skillKey}。只能调用该技能内的工具。`
|
||||
: SYSTEM_PROMPT;
|
||||
const selected: ModelMessage[] = [];
|
||||
let chars = systemPrompt.length;
|
||||
for (const message of history) {
|
||||
if (message.status !== 'completed') continue;
|
||||
const content =
|
||||
@@ -310,15 +306,6 @@ export async function buildContext(
|
||||
: message.role === 'user' && message.attachments?.length
|
||||
? await context.buildUserContent(message.content, message.attachments, supportsVision)
|
||||
: message.content;
|
||||
const contentChars =
|
||||
typeof content === 'string'
|
||||
? content.length
|
||||
: content.reduce(
|
||||
(total, part) => total + (part.type === 'text' ? part.text.length : 1024),
|
||||
0,
|
||||
);
|
||||
if (chars + contentChars > MAX_CONTEXT_CHARS) break;
|
||||
chars += contentChars;
|
||||
selected.push({ role: message.role, content } as ModelMessage);
|
||||
if (selected.length >= MAX_HISTORY_MESSAGES) break;
|
||||
}
|
||||
@@ -337,38 +324,15 @@ export async function buildUserContent(
|
||||
const contentParts: ModelContentPart[] = [];
|
||||
for (const part of parts) {
|
||||
if (part.text !== undefined) {
|
||||
const isSpreadsheet = (part.attachment.mimeType ?? '').includes('spreadsheetml');
|
||||
const isLarge = part.text.length > MAX_ATTACHMENT_TEXT_CHARS;
|
||||
if (isSpreadsheet && isLarge && context.excelReader) {
|
||||
let overview: string | null = null;
|
||||
try {
|
||||
const buffer = await context.attachmentService.readStoredBuffer(part.attachment);
|
||||
overview = (await context.excelReader.overview(buffer, 12)).text;
|
||||
} catch {
|
||||
overview = null;
|
||||
}
|
||||
const content = overview ?? context.truncateText(part.text, MAX_ATTACHMENT_TEXT_CHARS);
|
||||
textSections.push(
|
||||
`\n\n[附件:${part.attachment.originalName}(附件ID=${part.attachment.id})]\n${content}\n\n[提示:以上仅为文件概览(工作表、行数与前几行样本)。文件较大,需要具体数据时请调用 office_analyze 工具(outline/get/query/text)按需读取,attachmentId 使用上面的附件ID。]`,
|
||||
);
|
||||
} else {
|
||||
textSections.push(
|
||||
`\n\n[附件:${part.attachment.originalName}(附件ID=${part.attachment.id})]\n${context.truncateText(
|
||||
part.text,
|
||||
MAX_ATTACHMENT_TEXT_CHARS,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
textSections.push(
|
||||
`\n\n[附件:${part.attachment.originalName}(附件ID=${part.attachment.id})]\n${part.text}`,
|
||||
);
|
||||
} else if (part.imageDataUrl) {
|
||||
textSections.push(`\n\n[图片附件:${part.attachment.originalName}]`);
|
||||
contentParts.push({ type: 'image_url', image_url: { url: part.imageDataUrl } });
|
||||
}
|
||||
}
|
||||
const combinedText = textSections.join('');
|
||||
const boundedText =
|
||||
combinedText.length > MAX_FOCUS_CONTENT_CHARS
|
||||
? context.truncateText(combinedText, MAX_FOCUS_CONTENT_CHARS)
|
||||
: combinedText;
|
||||
if (!contentParts.length) return boundedText;
|
||||
return [{ type: 'text', text: boundedText }, ...contentParts];
|
||||
if (!contentParts.length) return combinedText;
|
||||
return [{ type: 'text', text: combinedText }, ...contentParts];
|
||||
}
|
||||
|
||||
@@ -138,6 +138,15 @@ export async function submitReview(
|
||||
user.id,
|
||||
);
|
||||
const summary = `已确认导入「${review.title}」:${result.message}`;
|
||||
await context.opLog?.log({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
module: '批量导入',
|
||||
action: '确认导入全部',
|
||||
detail: `「${review.title}」${result.message}`,
|
||||
targetType: 'ai_review',
|
||||
status: 'success',
|
||||
});
|
||||
const saved = await context.dataSource.transaction(async (manager) => {
|
||||
const exchange = await persistExchange(
|
||||
context,
|
||||
@@ -204,11 +213,20 @@ export async function confirmReviewStep(
|
||||
throw new ConflictException('导入预览已失效,请重新生成预览');
|
||||
}
|
||||
assertReviewImportPermissions(context, user, review, sectionKey);
|
||||
const { review: updated } = await context.reviewService.submitSection(
|
||||
const { review: updated, message } = await context.reviewService.submitSection(
|
||||
review.id,
|
||||
user.id,
|
||||
sectionKey,
|
||||
);
|
||||
await context.opLog?.log({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
module: '批量导入',
|
||||
action: '确认导入分表',
|
||||
detail: `「${review.title}」分表「${sectionKey}」:${message}`,
|
||||
targetType: 'ai_review',
|
||||
status: 'success',
|
||||
});
|
||||
await context.markReviewSubmittedOnMessage(
|
||||
updated.assistantMessageId,
|
||||
updated.conversationId,
|
||||
@@ -235,6 +253,20 @@ export async function confirmReviewGroup(
|
||||
}
|
||||
assertReviewImportPermissions(context, user, review, undefined, type);
|
||||
const { review: updated } = await context.reviewService.submitGroup(review.id, user.id, type);
|
||||
const sectionTitles = context.reviewService
|
||||
.parseSections(updated.sectionsJson)
|
||||
.filter((section) => section.type === type)
|
||||
.map((section) => section.title)
|
||||
.join('、');
|
||||
await context.opLog?.log({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
module: '批量导入',
|
||||
action: '确认导入分组',
|
||||
detail: `「${review.title}」分组「${type}」:${sectionTitles}`,
|
||||
targetType: 'ai_review',
|
||||
status: 'success',
|
||||
});
|
||||
await context.markReviewSubmittedOnMessage(
|
||||
updated.assistantMessageId,
|
||||
updated.conversationId,
|
||||
|
||||
@@ -1,10 +1,173 @@
|
||||
import { AiReview } from './entities/ai-review.entity';
|
||||
import { IMPORT_STEP_KEYS, type ImportStageRequest } from '../imports/imports.types';
|
||||
import {
|
||||
IMPORT_STEP_KEYS,
|
||||
type ColumnMapping,
|
||||
type ImportRunSettings,
|
||||
type ImportStageRequest,
|
||||
type ImportStepKey,
|
||||
type PreflightReport,
|
||||
} from '../imports/imports.types';
|
||||
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
||||
import type { AgentToolContext } from './ai-chat.tools';
|
||||
import { finishToolRun, startToolRun } from './ai-chat.tools';
|
||||
export { executeOfficeAnalyze, buildOfficeCliArgs } from './ai-chat.tool-office';
|
||||
|
||||
function isExcelAttachment(attachment: {
|
||||
mimeType: string;
|
||||
originalName: string;
|
||||
}): boolean {
|
||||
return (
|
||||
attachment.mimeType.includes('spreadsheetml') ||
|
||||
attachment.mimeType.includes('excel') ||
|
||||
attachment.mimeType.includes('csv') ||
|
||||
/\.(xlsx|csv)$/i.test(attachment.originalName)
|
||||
);
|
||||
}
|
||||
|
||||
export async function executePreflightImport(
|
||||
context: AiChatServiceContext,
|
||||
messageId: number,
|
||||
call: ModelToolCall,
|
||||
userId: number,
|
||||
emit: AiSseEmitter,
|
||||
): Promise<string> {
|
||||
const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, {
|
||||
toolName: 'preflight_import',
|
||||
skillKey: null,
|
||||
argumentsData: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const assistant = await context.messages.findOne({ where: { id: messageId } });
|
||||
if (!assistant) throw new Error('assistant message missing');
|
||||
const parsedRecord =
|
||||
parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs)
|
||||
? (parsedArgs as Record<string, unknown>)
|
||||
: {};
|
||||
const attachmentId =
|
||||
typeof parsedRecord.attachmentId === 'number' ? parsedRecord.attachmentId : undefined;
|
||||
if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) {
|
||||
throw new Error('缺少附件 attachmentId');
|
||||
}
|
||||
const [attachment] = await context.attachmentService.requireReadyOwned(userId, [
|
||||
attachmentId as number,
|
||||
]);
|
||||
if (!isExcelAttachment(attachment)) {
|
||||
throw new Error('附件不是 Excel 文件,无法预检导入');
|
||||
}
|
||||
if (!context.importsService) throw new Error('导入预检服务未配置');
|
||||
const buffer = await context.attachmentService.readStoredBuffer(attachment);
|
||||
const preflight: PreflightReport = await context.importsService.preflightFile({
|
||||
originalName: attachment.originalName,
|
||||
mimeType: attachment.mimeType,
|
||||
size: attachment.size,
|
||||
buffer,
|
||||
});
|
||||
assistant.metadata = {
|
||||
...assistant.metadata,
|
||||
a2uiImportPreflight: preflight,
|
||||
};
|
||||
await context.messages.save(assistant);
|
||||
|
||||
await finishToolRun(context, run, call, startedAt, {
|
||||
status: 'success',
|
||||
summary: `已完成导入预检:${preflight.stages
|
||||
.map((stage) => `${stage.label} ${stage.total} 行`)
|
||||
.join('、') || '未识别到可导入阶段'}`,
|
||||
}, emit);
|
||||
emit('ui.import_preflight', { messageId, preflight });
|
||||
return JSON.stringify({
|
||||
status: 'success',
|
||||
report: preflight,
|
||||
message: '预检报告已生成,请按报告中的 questions 向用户确认后,再调用 start_import_wizard',
|
||||
});
|
||||
} catch (error) {
|
||||
const summary =
|
||||
error instanceof Error ? error.message.slice(0, 100) : '导入预检失败';
|
||||
await finishToolRun(context, run, call, startedAt, {
|
||||
status: 'failed',
|
||||
summary,
|
||||
error: summary,
|
||||
}, emit);
|
||||
return JSON.stringify({ status: 'failed', error: run.resultSummary });
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeExcelAnalyze(
|
||||
context: AiChatServiceContext,
|
||||
messageId: number,
|
||||
call: ModelToolCall,
|
||||
userId: number,
|
||||
emit: AiSseEmitter,
|
||||
): Promise<string> {
|
||||
const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, {
|
||||
toolName: 'excel_analyze',
|
||||
skillKey: null,
|
||||
argumentsData: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const parsedRecord =
|
||||
parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs)
|
||||
? (parsedArgs as Record<string, unknown>)
|
||||
: {};
|
||||
const attachmentId =
|
||||
typeof parsedRecord.attachmentId === 'number' ? parsedRecord.attachmentId : undefined;
|
||||
if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) {
|
||||
throw new Error('缺少附件 attachmentId');
|
||||
}
|
||||
const action = typeof parsedRecord.action === 'string' ? parsedRecord.action : '';
|
||||
if (action !== 'overview' && action !== 'rows') {
|
||||
throw new Error('action 只能是 overview 或 rows');
|
||||
}
|
||||
const [attachment] = await context.attachmentService.requireReadyOwned(userId, [
|
||||
attachmentId as number,
|
||||
]);
|
||||
if (!isExcelAttachment(attachment)) {
|
||||
throw new Error('附件不是 Excel 文件,无法解析');
|
||||
}
|
||||
if (!context.excelReader) throw new Error('Excel 解析器未配置');
|
||||
const buffer = await context.attachmentService.readStoredBuffer(attachment);
|
||||
|
||||
let data: unknown;
|
||||
let summary: string;
|
||||
if (action === 'overview') {
|
||||
const overview = await context.excelReader.overview(buffer);
|
||||
data = { sheets: overview.sheets, text: overview.text };
|
||||
summary = `已解析 ${overview.sheets.length} 个工作表`;
|
||||
} else {
|
||||
const sheet = typeof parsedRecord.sheet === 'string' ? parsedRecord.sheet : undefined;
|
||||
const startRow = Number(parsedRecord.startRow ?? 1);
|
||||
const maxRows = Number(parsedRecord.maxRows ?? 20);
|
||||
const maxColumns = Number(parsedRecord.maxColumns ?? 30);
|
||||
if (!Number.isInteger(startRow) || startRow < 1) throw new Error('startRow 必须是 >=1 的整数');
|
||||
if (!Number.isInteger(maxRows) || maxRows < 1) {
|
||||
throw new Error('maxRows 必须是 >=1 的整数');
|
||||
}
|
||||
if (!Number.isInteger(maxColumns) || maxColumns < 1) {
|
||||
throw new Error('maxColumns 必须是 >=1 的整数');
|
||||
}
|
||||
data = await context.excelReader.readRows(buffer, sheet, startRow, maxRows, maxColumns);
|
||||
summary = `已读取工作表「${(data as { sheet: string }).sheet}」${(data as { rows: unknown[] }).rows.length} 行`;
|
||||
}
|
||||
|
||||
await finishToolRun(context, run, call, startedAt, {
|
||||
status: 'success',
|
||||
summary,
|
||||
}, emit);
|
||||
return JSON.stringify({ status: 'success', data });
|
||||
} catch (error) {
|
||||
const summary =
|
||||
error instanceof Error ? error.message.slice(0, 100) : 'Excel 解析失败';
|
||||
await finishToolRun(context, run, call, startedAt, {
|
||||
status: 'failed',
|
||||
summary,
|
||||
error: summary,
|
||||
}, emit);
|
||||
return JSON.stringify({ status: 'failed', error: run.resultSummary });
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeStartImportWizard(
|
||||
context: AiChatServiceContext,
|
||||
messageId: number,
|
||||
@@ -33,12 +196,7 @@ export async function executeStartImportWizard(
|
||||
const [attachment] = await context.attachmentService.requireReadyOwned(agentContext.userId, [
|
||||
attachmentId as number,
|
||||
]);
|
||||
const isExcel =
|
||||
attachment.mimeType.includes('spreadsheetml') ||
|
||||
attachment.mimeType.includes('excel') ||
|
||||
attachment.mimeType.includes('csv') ||
|
||||
/\.(xlsx|csv)$/i.test(attachment.originalName);
|
||||
if (!isExcel) throw new Error('附件不是 Excel 文件,无法生成导入向导');
|
||||
if (!isExcelAttachment(attachment)) throw new Error('附件不是 Excel 文件,无法生成导入向导');
|
||||
const stages = Array.isArray(parsedRecord.stages)
|
||||
? (parsedRecord.stages as ImportStageRequest[])
|
||||
: [];
|
||||
@@ -51,6 +209,8 @@ export async function executeStartImportWizard(
|
||||
throw new Error(`stages 中「${stage.stepKey}」缺少工作表 sheet,请指定 Excel 中对应的 sheet 名`);
|
||||
}
|
||||
}
|
||||
const mapping = parseConfirmedMapping(parsedRecord.mapping);
|
||||
const settings = parseConfirmedSettings(parsedRecord);
|
||||
if (!context.importsService) throw new Error('导入向导服务未配置');
|
||||
const buffer = await context.attachmentService.readStoredBuffer(attachment);
|
||||
const detail = await context.importsService.createRun(
|
||||
@@ -68,6 +228,8 @@ export async function executeStartImportWizard(
|
||||
},
|
||||
assistant.conversationId,
|
||||
stages,
|
||||
mapping,
|
||||
settings,
|
||||
);
|
||||
const wizard = compactImportWizard(detail);
|
||||
assistant.metadata = {
|
||||
@@ -99,6 +261,59 @@ export async function executeStartImportWizard(
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfirmedMapping(raw: unknown): Partial<Record<ImportStepKey, ColumnMapping>> | undefined {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('mapping 参数格式错误');
|
||||
const mapping: Partial<Record<ImportStepKey, ColumnMapping>> = {};
|
||||
for (const [stepKey, fields] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (!(IMPORT_STEP_KEYS as readonly string[]).includes(stepKey)) {
|
||||
throw new Error(`mapping 包含未知业务类型:${stepKey}`);
|
||||
}
|
||||
if (fields === undefined || fields === null) continue;
|
||||
if (typeof fields !== 'object' || Array.isArray(fields)) {
|
||||
throw new Error(`mapping 中「${stepKey}」的列映射格式错误`);
|
||||
}
|
||||
const columnMapping: ColumnMapping = {};
|
||||
for (const [field, header] of Object.entries(fields as Record<string, unknown>)) {
|
||||
if (typeof field !== 'string' || !field.trim() || field.length > 50) continue;
|
||||
if (typeof header !== 'string' || !header.trim()) continue;
|
||||
columnMapping[field] = header.slice(0, 200);
|
||||
}
|
||||
mapping[stepKey as ImportStepKey] = columnMapping;
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
function parseConfirmedSettings(parsedRecord: Record<string, unknown>): ImportRunSettings {
|
||||
const settings: ImportRunSettings = {};
|
||||
if (parsedRecord.organization !== undefined && parsedRecord.organization !== null) {
|
||||
if (typeof parsedRecord.organization !== 'string') {
|
||||
throw new Error('organization 必须是字符串');
|
||||
}
|
||||
const organization = parsedRecord.organization.trim().slice(0, 100);
|
||||
if (organization) settings.organization = organization;
|
||||
}
|
||||
if (parsedRecord.updateExisting !== undefined) {
|
||||
if (typeof parsedRecord.updateExisting !== 'boolean') {
|
||||
throw new Error('updateExisting 必须是布尔值');
|
||||
}
|
||||
settings.updateExisting = parsedRecord.updateExisting;
|
||||
}
|
||||
if (parsedRecord.duplicatePolicy !== undefined) {
|
||||
if (parsedRecord.duplicatePolicy !== 'error' && parsedRecord.duplicatePolicy !== 'skip') {
|
||||
throw new Error('duplicatePolicy 只能是 error 或 skip');
|
||||
}
|
||||
settings.duplicatePolicy = parsedRecord.duplicatePolicy;
|
||||
}
|
||||
if (parsedRecord.skipUnmatched !== undefined) {
|
||||
if (typeof parsedRecord.skipUnmatched !== 'boolean') {
|
||||
throw new Error('skipUnmatched 必须是布尔值');
|
||||
}
|
||||
settings.skipUnmatched = parsedRecord.skipUnmatched;
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
export function compactImportWizard(detail: any): {
|
||||
runId: string;
|
||||
fileName: string;
|
||||
|
||||
@@ -73,12 +73,6 @@ export async function executeOfficeAnalyze(
|
||||
} catch {
|
||||
payload = '{}';
|
||||
}
|
||||
const MAX_OFFICE_RESULT_CHARS = 96 * 1024;
|
||||
let truncated = false;
|
||||
if (payload.length > MAX_OFFICE_RESULT_CHARS) {
|
||||
truncated = true;
|
||||
payload = `${payload.slice(0, MAX_OFFICE_RESULT_CHARS)}\n\n[结果过大已截断,请缩小读取范围]`;
|
||||
}
|
||||
let parsedData: unknown;
|
||||
try {
|
||||
parsedData = JSON.parse(payload);
|
||||
@@ -87,7 +81,7 @@ export async function executeOfficeAnalyze(
|
||||
}
|
||||
|
||||
await finishToolRun(context, run, call, startedAt, { status: 'success', summary: context.summarize(result.data) }, emit);
|
||||
return JSON.stringify({ status: 'success', data: parsedData, truncated });
|
||||
return JSON.stringify({ status: 'success', data: parsedData });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const failureSummary = context.redactText(errorMessage).slice(0, MAX_SUMMARY_CHARS);
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-cha
|
||||
import type { AiToolRun } from './entities';
|
||||
import {
|
||||
executeOfficeAnalyze,
|
||||
executeExcelAnalyze,
|
||||
executePreflightImport,
|
||||
executeRenderChart,
|
||||
executeRenderForm,
|
||||
executeRenderReview,
|
||||
@@ -90,6 +92,12 @@ export async function executeTool(
|
||||
if (call.name === 'render_form') {
|
||||
return executeRenderForm(context, messageId, call, userId, emit);
|
||||
}
|
||||
if (call.name === 'preflight_import') {
|
||||
return executePreflightImport(context, messageId, call, userId, emit);
|
||||
}
|
||||
if (call.name === 'excel_analyze') {
|
||||
return executeExcelAnalyze(context, messageId, call, userId, emit);
|
||||
}
|
||||
if (call.name === 'start_import_wizard') {
|
||||
return executeStartImportWizard(context, messageId, call, agentContext, emit);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AiFormService } from './ai-form.service';
|
||||
import { AiReviewService } from './ai-review.service';
|
||||
import { AiModelStreamService } from './ai-model-stream.service';
|
||||
import { OfficeCliService } from './office-cli.service';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import {
|
||||
AiConversation,
|
||||
AiMessage,
|
||||
@@ -23,9 +24,6 @@ import {
|
||||
|
||||
export {
|
||||
DEFAULT_TITLE,
|
||||
MAX_ATTACHMENT_TEXT_CHARS,
|
||||
MAX_CONTEXT_CHARS,
|
||||
MAX_FOCUS_CONTENT_CHARS,
|
||||
MAX_GENERATED_CHARS,
|
||||
MAX_HISTORY_MESSAGES,
|
||||
MAX_SUMMARY_CHARS,
|
||||
@@ -98,6 +96,7 @@ export interface AiChatServiceContext {
|
||||
readonly excelReader?: AiExcelReaderService;
|
||||
readonly officeCli?: OfficeCliService;
|
||||
readonly importsService?: ImportsService;
|
||||
readonly opLog?: OperationLogsService;
|
||||
listSkills(user: AuthenticatedUser): ReturnType<AgentToolExecutor['listSkills']>;
|
||||
serializeMessage(message: AiMessage): Record<string, unknown>;
|
||||
redactText(value: string): string;
|
||||
@@ -178,6 +177,7 @@ export type AiSseEventName =
|
||||
| 'ui.form'
|
||||
| 'ui.review'
|
||||
| 'ui.chart'
|
||||
| 'ui.import_preflight'
|
||||
| 'ui.import_wizard'
|
||||
| 'attachment.processed'
|
||||
| 'message.completed'
|
||||
|
||||
@@ -38,10 +38,7 @@ export class AiExcelReaderService {
|
||||
}
|
||||
|
||||
/** Sheet list + row counts + a short sample, small enough for prompts. */
|
||||
async overview(
|
||||
buffer: Buffer,
|
||||
sampleRows = 12,
|
||||
): Promise<{ sheets: ExcelSheetInfo[]; text: string }> {
|
||||
async overview(buffer: Buffer): Promise<{ sheets: ExcelSheetInfo[]; text: string }> {
|
||||
const sheets = await this.loadSheets(buffer);
|
||||
const info = sheets.map((sheet) => ({
|
||||
name: sheet.name,
|
||||
@@ -51,12 +48,7 @@ export class AiExcelReaderService {
|
||||
const lines: string[] = [];
|
||||
for (const sheet of sheets) {
|
||||
lines.push(`# ${sheet.name}(共 ${sheet.rows.length} 行)`);
|
||||
for (const row of sheet.rows.slice(0, sampleRows)) {
|
||||
lines.push(row.join('\t'));
|
||||
}
|
||||
if (sheet.rows.length > sampleRows) {
|
||||
lines.push(`…(其余 ${sheet.rows.length - sampleRows} 行未显示)`);
|
||||
}
|
||||
for (const row of sheet.rows) lines.push(row.join('\t'));
|
||||
}
|
||||
return { sheets: info, text: lines.join('\n') };
|
||||
}
|
||||
@@ -80,9 +72,9 @@ export class AiExcelReaderService {
|
||||
return { sheet: sheetName ?? '', rowCount: 0, startRow, rows: [], truncated: false };
|
||||
}
|
||||
const from = Math.max(0, startRow - 1);
|
||||
const limit = Math.min(rowCount, 200);
|
||||
const limit = rowCount;
|
||||
const slice = sheet.rows.slice(from, from + limit);
|
||||
const rows = slice.map((row) => row.slice(0, Math.min(maxColumns, 50)));
|
||||
const rows = slice.map((row) => row.slice(0, maxColumns));
|
||||
return {
|
||||
sheet: sheet.name,
|
||||
rowCount: sheet.rows.length,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { AddA2UiForms1784870000000 } from './migrations/1784870000000-AddA2UiFor
|
||||
import { AddA2UiReviews1784880000000 } from './migrations/1784880000000-AddA2UiReviews';
|
||||
import { AddImportRuns1784910000000 } from './migrations/1784910000000-AddImportRuns';
|
||||
import { DropAiMessageFeedback1784920000000 } from './migrations/1784920000000-DropAiMessageFeedback';
|
||||
import { AddImportRunSettings1784930000000 } from './migrations/1784930000000-AddImportRunSettings';
|
||||
const allMigrations = [
|
||||
InitialSchema1784520727860,
|
||||
AddExamManagement1784600000000,
|
||||
@@ -29,6 +30,7 @@ const allMigrations = [
|
||||
AddA2UiReviews1784880000000,
|
||||
AddImportRuns1784910000000,
|
||||
DropAiMessageFeedback1784920000000,
|
||||
AddImportRunSettings1784930000000,
|
||||
];
|
||||
import { AuthorizationModule } from './authorization';
|
||||
import { RbacModule } from './rbac/rbac.module';
|
||||
|
||||
@@ -87,6 +87,12 @@ export class ExpensesController {
|
||||
return this.service.getFormLookups();
|
||||
}
|
||||
|
||||
@Get('student-lookups')
|
||||
@RequirePermission('expense:view')
|
||||
getStudentLookups() {
|
||||
return this.service.getStudentLookups();
|
||||
}
|
||||
|
||||
@Post('student-utility')
|
||||
@RequirePermission('expense:create')
|
||||
async createStudentUtilityBill(@Body() dto: CreateStudentUtilityBillDto, @Request() req: any) {
|
||||
|
||||
@@ -22,4 +22,30 @@ describe('ExpensesService permission-scoped lookups', () => {
|
||||
expect(roomRepo.find).toHaveBeenCalledWith(expect.objectContaining({ select: ['id', 'roomNumber', 'building'] }));
|
||||
expect(studentRepo.find).toHaveBeenCalledWith(expect.objectContaining({ select: ['id', 'name', 'studentNo'] }));
|
||||
});
|
||||
|
||||
it('returns active students for expense form student lookups', async () => {
|
||||
const studentRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{ id: 2, name: '张三', studentNo: 'S2' },
|
||||
{ id: 3, name: '李四', studentNo: 'S3' },
|
||||
]),
|
||||
};
|
||||
const service = new ExpensesService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
studentRepo as never,
|
||||
);
|
||||
|
||||
await expect(service.getStudentLookups()).resolves.toEqual([
|
||||
{ id: 2, name: '张三', studentNo: 'S2' },
|
||||
{ id: 3, name: '李四', studentNo: 'S3' },
|
||||
]);
|
||||
expect(studentRepo.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
select: ['id', 'name', 'studentNo'],
|
||||
where: { status: 'active' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,15 @@ export class ExpensesService {
|
||||
return { rooms, students };
|
||||
}
|
||||
|
||||
/** 费用录入/编辑表单需要的在读学生下拉项。 */
|
||||
async getStudentLookups() {
|
||||
return this.studentRepo.find({
|
||||
select: ['id', 'name', 'studentNo'],
|
||||
where: { status: 'active' },
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
// 宿舍费用
|
||||
async createRoomExpense(dto: CreateRoomExpenseDto, userId?: number) {
|
||||
this.assertValidPeriod(dto.periodStart, dto.periodEnd);
|
||||
|
||||
@@ -23,6 +23,10 @@ export class ImportRun {
|
||||
@Column({ name: 'sheets_json', type: 'text' })
|
||||
sheetsJson: string;
|
||||
|
||||
/** Serialized ImportRunSettings — confirmed mapping/policies from AI preflight. */
|
||||
@Column({ name: 'settings_json', type: 'text', nullable: true })
|
||||
settingsJson: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'preparing' })
|
||||
status: ImportRunStatus;
|
||||
|
||||
|
||||
74
apps/server/src/imports/imports.controller.spec.ts
Normal file
74
apps/server/src/imports/imports.controller.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { ImportsController } from './imports.controller';
|
||||
|
||||
describe('ImportsController', () => {
|
||||
const principalRequest = {
|
||||
user: { id: 7, username: 'admin', permissions: [], isSuperAdmin: true },
|
||||
headers: { 'user-agent': 'jest-agent' },
|
||||
connection: { remoteAddress: '127.0.0.1' },
|
||||
};
|
||||
|
||||
it('提交导入阶段成功后写入操作日志', async () => {
|
||||
const commitStep = jest.fn().mockResolvedValue({
|
||||
runId: 'run-1',
|
||||
stepKey: 'students',
|
||||
status: 'committed',
|
||||
created: 2,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
total: 2,
|
||||
nextStepKey: null,
|
||||
runStatus: 'committed',
|
||||
message: '阶段「学生档案」提交完成:新建 2、更新 0、跳过 0、失败 0;全部阶段已完成',
|
||||
});
|
||||
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
const controller = new ImportsController({ commitStep } as never, opLog as never);
|
||||
|
||||
await controller.commit(
|
||||
principalRequest as never,
|
||||
'run-1',
|
||||
'students',
|
||||
{ decisions: [] },
|
||||
);
|
||||
|
||||
expect(opLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 7,
|
||||
username: 'admin',
|
||||
module: '批量导入',
|
||||
action: '提交导入阶段',
|
||||
detail: expect.stringContaining('提交完成'),
|
||||
targetType: 'import_run',
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'jest-agent',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('非 committed 回执(conflict/already_committed)不重复写日志', async () => {
|
||||
const commitStep = jest.fn().mockResolvedValue({
|
||||
runId: 'run-1',
|
||||
stepKey: 'students',
|
||||
status: 'conflict',
|
||||
created: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
total: 0,
|
||||
nextStepKey: null,
|
||||
runStatus: 'ready',
|
||||
message: '请先完成前置阶段',
|
||||
});
|
||||
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
const controller = new ImportsController({ commitStep } as never, opLog as never);
|
||||
|
||||
await controller.commit(
|
||||
principalRequest as never,
|
||||
'run-1',
|
||||
'transfers',
|
||||
{ decisions: [] },
|
||||
);
|
||||
|
||||
expect(opLog.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Request, Response } from 'express';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import type { AuthenticatedUser } from '../authorization';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import {
|
||||
IMPORT_STEP_KEYS,
|
||||
type ImportRowDecision,
|
||||
@@ -38,7 +40,10 @@ const IMPORT_GATE_PERMISSIONS = [
|
||||
@Controller('imports')
|
||||
@RequirePermission(...IMPORT_GATE_PERMISSIONS)
|
||||
export class ImportsController {
|
||||
constructor(private readonly importsService: ImportsService) {}
|
||||
constructor(
|
||||
private readonly importsService: ImportsService,
|
||||
private readonly opLog: OperationLogsService,
|
||||
) {}
|
||||
|
||||
@Post('runs')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))
|
||||
@@ -69,6 +74,17 @@ export class ImportsController {
|
||||
throw new BadRequestException('mapping 参数格式错误');
|
||||
}
|
||||
}
|
||||
let settings: Record<string, unknown> | undefined;
|
||||
if (typeof body.settings === 'string' && body.settings.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(body.settings) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
settings = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
throw new BadRequestException('settings 参数格式错误');
|
||||
}
|
||||
}
|
||||
const conversationId =
|
||||
body.conversationId !== undefined ? Number(body.conversationId) : undefined;
|
||||
const source = body.source === 'ai' ? 'ai' : 'manual';
|
||||
@@ -84,6 +100,7 @@ export class ImportsController {
|
||||
Number.isFinite(conversationId) ? conversationId : undefined,
|
||||
stages,
|
||||
mapping,
|
||||
settings,
|
||||
);
|
||||
return { success: true, data };
|
||||
}
|
||||
@@ -122,6 +139,19 @@ export class ImportsController {
|
||||
this.parseStepKey(stepKey),
|
||||
Array.isArray(body?.decisions) ? body.decisions : [],
|
||||
);
|
||||
if (data.status === 'committed') {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.opLog.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '批量导入',
|
||||
action: '提交导入阶段',
|
||||
detail: data.message,
|
||||
targetType: 'import_run',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
}
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
|
||||
72
apps/server/src/imports/imports.policies.ts
Normal file
72
apps/server/src/imports/imports.policies.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type {
|
||||
ImportRowAction,
|
||||
ImportRowStatus,
|
||||
ImportRunSettings,
|
||||
} from './imports.types';
|
||||
import type { ValidatedRow } from './imports.rows';
|
||||
|
||||
export interface PolicyPreviewResult {
|
||||
errors: string[];
|
||||
action: ImportRowAction | null;
|
||||
status: ImportRowStatus;
|
||||
}
|
||||
|
||||
function isDuplicateError(error: string): boolean {
|
||||
return (
|
||||
error.includes('请勿重复导入') ||
|
||||
error.includes('请勿重复换宿') ||
|
||||
error.includes('本次文件中已有')
|
||||
);
|
||||
}
|
||||
|
||||
function isReferenceError(error: string): boolean {
|
||||
return (
|
||||
error.includes('未找到匹配学生') ||
|
||||
error.includes('缺少学生标识') ||
|
||||
error.includes('未找到宿舍') ||
|
||||
error.includes('未找到原宿舍') ||
|
||||
error.includes('未找到新宿舍') ||
|
||||
error.includes('未找到该学生在原宿舍的在住记录')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 AI 预检确认的策略应用到预览行:
|
||||
* - updateExisting=false:已匹配行改为跳过;
|
||||
* - duplicatePolicy=skip:文件内重复行跳过并保留提示;
|
||||
* - skipUnmatched=true:关系表找不到学生/宿舍的行跳过并保留提示。
|
||||
*/
|
||||
export function applyPreviewPolicies(
|
||||
result: ValidatedRow,
|
||||
settings: ImportRunSettings | null,
|
||||
): PolicyPreviewResult {
|
||||
const errors = [...result.errors];
|
||||
let action = result.action;
|
||||
let status: ImportRowStatus = errors.length > 0 ? 'error' : 'valid';
|
||||
|
||||
const duplicatePolicy = settings?.duplicatePolicy ?? 'error';
|
||||
const updateExisting = settings?.updateExisting ?? true;
|
||||
const skipUnmatched = settings?.skipUnmatched ?? false;
|
||||
|
||||
if (duplicatePolicy === 'skip' && errors.some(isDuplicateError)) {
|
||||
action = 'skip';
|
||||
status = 'valid';
|
||||
const keptErrors = errors.filter((error) => !isDuplicateError(error));
|
||||
keptErrors.push('文件内重复行,已按策略跳过');
|
||||
errors.splice(0, errors.length, ...keptErrors);
|
||||
}
|
||||
|
||||
if (!updateExisting && errors.length === 0 && action === 'update') {
|
||||
action = 'skip';
|
||||
status = 'valid';
|
||||
errors.push('已匹配现有记录,按策略跳过更新');
|
||||
}
|
||||
|
||||
if (skipUnmatched && status === 'error' && errors.every(isReferenceError)) {
|
||||
action = 'skip';
|
||||
status = 'valid';
|
||||
errors.push('未匹配学生/宿舍,按策略跳过');
|
||||
}
|
||||
|
||||
return { errors, action, status };
|
||||
}
|
||||
195
apps/server/src/imports/imports.preflight.spec.ts
Normal file
195
apps/server/src/imports/imports.preflight.spec.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
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,
|
||||
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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
417
apps/server/src/imports/imports.preflight.ts
Normal file
417
apps/server/src/imports/imports.preflight.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Organization } from '../entities/organization.entity';
|
||||
import { buildLookups } from './imports.lookups';
|
||||
import { 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 firstSheet = sheets[0];
|
||||
const mapping: ColumnMapping = suggestMapping(firstSheet.headers, stepKey);
|
||||
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 lookups = await buildLookups(dataSource, stepKey, sheet.headers, sheet.rows, mapping);
|
||||
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(mapping)) {
|
||||
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: i + 2,
|
||||
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),
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { IMPORT_STEP_LABELS } from './imports.types';
|
||||
import type {
|
||||
CellValue,
|
||||
ColumnMapping,
|
||||
ImportRunSettings,
|
||||
ImportStepKey,
|
||||
StepPreviewSummary,
|
||||
} from './imports.types';
|
||||
@@ -16,6 +17,7 @@ import { assertMapping, suggestMapping } from './imports.mapping';
|
||||
import { buildLookups } from './imports.lookups';
|
||||
import { validateRow } from './imports.rows';
|
||||
import type { ImportBatchState } from './imports.rows';
|
||||
import { applyPreviewPolicies } from './imports.policies';
|
||||
import { findOwnedRun, findStep } from './imports.access';
|
||||
import type { ImportPrincipal } from './imports.access';
|
||||
|
||||
@@ -55,6 +57,7 @@ export class ImportPreviewService {
|
||||
const sheetsData =
|
||||
parseJson<Array<{ name: string; headers: string[]; rows: CellValue[][] }>>(run.sheetsJson) ??
|
||||
[];
|
||||
const settings = parseJson<ImportRunSettings>(run.settingsJson) ?? {};
|
||||
const sheetNames = body.sheets?.length
|
||||
? body.sheets
|
||||
: (parseJson<string[]>(step.sheetsJson) ?? []);
|
||||
@@ -106,20 +109,22 @@ export class ImportPreviewService {
|
||||
fields[field] = rawValues[sheet.headers.indexOf(header)] ?? null;
|
||||
}
|
||||
const result = validateRow(stepKey, fields, lookups, batchState);
|
||||
const policy = applyPreviewPolicies(result, settings);
|
||||
const normalized = { ...result.normalized, ...result.resolvedIds };
|
||||
summary.total += 1;
|
||||
if (result.errors.length > 0) {
|
||||
if (policy.status === 'error') {
|
||||
summary.error += 1;
|
||||
} else {
|
||||
summary.valid += 1;
|
||||
if (result.action === 'create') summary.create += 1;
|
||||
if (result.action === 'update') summary.update += 1;
|
||||
if (result.action === 'create') {
|
||||
const studentId = result.resolvedIds._studentId;
|
||||
if (studentId !== undefined) {
|
||||
if (stepKey === 'checkins') batchState.checkinStudentIds.add(studentId);
|
||||
if (stepKey === 'transfers') batchState.transferStudentIds.add(studentId);
|
||||
}
|
||||
if (policy.action === 'create') summary.create += 1;
|
||||
if (policy.action === 'update') summary.update += 1;
|
||||
if (policy.action === 'skip') summary.skip += 1;
|
||||
}
|
||||
if (policy.status === 'valid' && policy.action === 'create') {
|
||||
const studentId = result.resolvedIds._studentId;
|
||||
if (studentId !== undefined) {
|
||||
if (stepKey === 'checkins') batchState.checkinStudentIds.add(studentId);
|
||||
if (stepKey === 'transfers') batchState.transferStudentIds.add(studentId);
|
||||
}
|
||||
}
|
||||
rowEntities.push(
|
||||
@@ -131,9 +136,9 @@ export class ImportPreviewService {
|
||||
rawJson: JSON.stringify(raw),
|
||||
normalizedJson: JSON.stringify(normalized),
|
||||
matchKey: result.matchKey,
|
||||
action: result.action,
|
||||
status: result.errors.length > 0 ? 'error' : 'valid',
|
||||
errorsJson: result.errors.length > 0 ? JSON.stringify(result.errors) : null,
|
||||
action: policy.action,
|
||||
status: policy.status,
|
||||
errorsJson: policy.errors.length > 0 ? JSON.stringify(policy.errors) : null,
|
||||
targetId: result.targetId ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import { ImportRun } from './entities/import-run.entity';
|
||||
import { ImportStep } from './entities/import-step.entity';
|
||||
import {
|
||||
IMPORT_STEP_LABELS,
|
||||
IMPORT_STEP_ORDER,
|
||||
} from './imports.types';
|
||||
import { IMPORT_STEP_LABELS, IMPORT_STEP_ORDER } from './imports.types';
|
||||
import type {
|
||||
CellValue,
|
||||
ColumnMapping,
|
||||
ImportRunSettings,
|
||||
ImportRunSource,
|
||||
ImportStageRequest,
|
||||
ImportStepKey,
|
||||
@@ -20,8 +16,7 @@ import type {
|
||||
StepPreviewSummary,
|
||||
} from './imports.types';
|
||||
import { parseJson } from './imports.helpers';
|
||||
import { extractSheets } from './imports.workbook';
|
||||
import type { ImportSheetData } from './imports.workbook';
|
||||
import { parseSheets } from './imports.workbook';
|
||||
import { autoAssignedSheets, resolveAssignedSheets, suggestMapping, suggestStep } from './imports.mapping';
|
||||
import { findOwnedRun } from './imports.access';
|
||||
import type { ImportPrincipal } from './imports.access';
|
||||
@@ -42,40 +37,12 @@ export class ImportRunService {
|
||||
conversationId?: number | null,
|
||||
stages?: ImportStageRequest[],
|
||||
mappingByStep?: Partial<Record<ImportStepKey, ColumnMapping>>,
|
||||
settings?: ImportRunSettings,
|
||||
) {
|
||||
if (!file.buffer || file.buffer.length === 0) {
|
||||
throw new BadRequestException('上传文件为空');
|
||||
}
|
||||
const isCsv =
|
||||
/\.csv$/i.test(file.originalName) ||
|
||||
/csv/i.test(file.mimeType) ||
|
||||
/text\/(csv|plain)/i.test(file.mimeType);
|
||||
const isXlsx =
|
||||
/\.xlsx$/i.test(file.originalName) ||
|
||||
/spreadsheetml/i.test(file.mimeType) ||
|
||||
/excel/i.test(file.mimeType);
|
||||
if (!isCsv && !isXlsx) {
|
||||
throw new BadRequestException('仅支持 .xlsx / .csv 文件');
|
||||
}
|
||||
if (/\.xls$/i.test(file.originalName) && !/\.xlsx$/i.test(file.originalName)) {
|
||||
throw new BadRequestException('暂不支持 .xls,请另存为 .xlsx 或 .csv 后重试');
|
||||
}
|
||||
|
||||
let sheets: ImportSheetData[];
|
||||
try {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
if (isCsv) {
|
||||
await workbook.csv.read(Readable.from(Buffer.from(file.buffer)));
|
||||
} else {
|
||||
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
|
||||
}
|
||||
sheets = extractSheets(workbook);
|
||||
} catch {
|
||||
throw new BadRequestException('Excel 文件解析失败,请检查文件格式');
|
||||
}
|
||||
if (!sheets.length) {
|
||||
throw new BadRequestException('文件中没有可用的工作表数据');
|
||||
}
|
||||
const sheets = await parseSheets(file.buffer, file.originalName, file.mimeType);
|
||||
|
||||
const runId = randomUUID();
|
||||
const run = this.runs.create({
|
||||
@@ -85,6 +52,7 @@ export class ImportRunService {
|
||||
source,
|
||||
fileName: file.originalName.slice(0, 255),
|
||||
sheetsJson: JSON.stringify(sheets),
|
||||
settingsJson: settings ? JSON.stringify(settings) : null,
|
||||
status: 'ready',
|
||||
currentStepKey: null,
|
||||
error: null,
|
||||
@@ -155,6 +123,7 @@ export class ImportRunService {
|
||||
rowCount: sheet.rows.length,
|
||||
suggestedStepKey: suggestStep(sheet.headers)?.stepKey ?? null,
|
||||
})),
|
||||
settings: parseJson<ImportRunSettings>(run.settingsJson) ?? {},
|
||||
steps: stepRecords.map((step) => ({
|
||||
id: step.id,
|
||||
stepKey: step.stepKey,
|
||||
|
||||
@@ -603,4 +603,211 @@ describe('ImportsService', () => {
|
||||
expect(result.rows[1].status).toBe('error');
|
||||
expect(result.rows[1].errors.join(';')).toContain('请勿重复换宿');
|
||||
});
|
||||
|
||||
it('预览学生阶段:updateExisting=false 时已匹配行改为跳过', async () => {
|
||||
const existing = {
|
||||
id: 88,
|
||||
name: '张三',
|
||||
studentNo: '2024001',
|
||||
phone: '13800138000',
|
||||
} as Student;
|
||||
const run = {
|
||||
id: 'run-1',
|
||||
userId: 7,
|
||||
source: 'ai',
|
||||
fileName: 'students.xlsx',
|
||||
sheetsJson: JSON.stringify([studentSheet()]),
|
||||
settingsJson: JSON.stringify({ updateExisting: false }),
|
||||
status: 'ready',
|
||||
currentStepKey: 'students',
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as ImportRun;
|
||||
const step = {
|
||||
id: 1,
|
||||
runId: 'run-1',
|
||||
stepKey: 'students',
|
||||
sheetsJson: '["学生"]',
|
||||
mappingJson: null,
|
||||
status: 'pending',
|
||||
} as ImportStep;
|
||||
const rowsRepo = makeRowsRepo();
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
|
||||
if (entity === Room) return { find: jest.fn().mockResolvedValue([]) };
|
||||
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
|
||||
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
};
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo(run) as never,
|
||||
makeStepsRepo(step) as never,
|
||||
rowsRepo as never,
|
||||
dataSource as never,
|
||||
);
|
||||
|
||||
const result = await service.previewStep(principal, 'run-1', 'students', {
|
||||
sheets: ['学生'],
|
||||
mapping: { name: '姓名', studentNo: '学号', phone: '手机号' },
|
||||
});
|
||||
expect(result.summary).toMatchObject({
|
||||
total: 1,
|
||||
valid: 1,
|
||||
create: 0,
|
||||
update: 0,
|
||||
skip: 1,
|
||||
error: 0,
|
||||
});
|
||||
expect(result.rows[0]).toMatchObject({ action: 'skip', status: 'valid' });
|
||||
expect(result.rows[0].errors.join(';')).toContain('按策略跳过更新');
|
||||
});
|
||||
|
||||
it('预览入住阶段:duplicatePolicy=skip 时文件内重复行跳过并保留提示', async () => {
|
||||
const existing = {
|
||||
id: 88,
|
||||
name: '张三',
|
||||
studentNo: '2024001',
|
||||
phone: '13800138000',
|
||||
} as Student;
|
||||
const room = { id: 5, roomNumber: 'A101' } as Room;
|
||||
const run = {
|
||||
id: 'run-2',
|
||||
userId: 7,
|
||||
source: 'ai',
|
||||
fileName: 'checkins.xlsx',
|
||||
sheetsJson: JSON.stringify([
|
||||
{
|
||||
name: '入住',
|
||||
headers: ['姓名', '手机号', '宿舍号', '入住日期'],
|
||||
rows: [
|
||||
['张三', '13800138000', 'A101', '2026-09-01'],
|
||||
['张三', '13800138000', 'A101', '2026-09-02'],
|
||||
],
|
||||
},
|
||||
]),
|
||||
settingsJson: JSON.stringify({ duplicatePolicy: 'skip' }),
|
||||
status: 'ready',
|
||||
currentStepKey: 'checkins',
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as ImportRun;
|
||||
const step = {
|
||||
id: 3,
|
||||
runId: 'run-2',
|
||||
stepKey: 'checkins',
|
||||
sheetsJson: '["入住"]',
|
||||
mappingJson: null,
|
||||
status: 'pending',
|
||||
} as ImportStep;
|
||||
const rowsRepo = makeRowsRepo();
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
|
||||
if (entity === Room) return { find: jest.fn().mockResolvedValue([room]) };
|
||||
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
|
||||
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
};
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo(run) as never,
|
||||
makeStepsRepo(step) as never,
|
||||
rowsRepo as never,
|
||||
dataSource as never,
|
||||
);
|
||||
|
||||
const result = await service.previewStep(principal, 'run-2', 'checkins', {
|
||||
sheets: ['入住'],
|
||||
mapping: {
|
||||
name: '姓名',
|
||||
phone: '手机号',
|
||||
roomNumber: '宿舍号',
|
||||
checkInDate: '入住日期',
|
||||
},
|
||||
});
|
||||
expect(result.summary).toMatchObject({
|
||||
total: 2,
|
||||
valid: 2,
|
||||
error: 0,
|
||||
create: 1,
|
||||
skip: 1,
|
||||
});
|
||||
expect(result.rows[1]).toMatchObject({ action: 'skip', status: 'valid' });
|
||||
expect(result.rows[1].errors.join(';')).toContain('已按策略跳过');
|
||||
});
|
||||
|
||||
it('预览入住阶段:skipUnmatched=true 时找不到宿舍的行跳过并保留提示', async () => {
|
||||
const existing = {
|
||||
id: 88,
|
||||
name: '张三',
|
||||
studentNo: '2024001',
|
||||
phone: '13800138000',
|
||||
} as Student;
|
||||
const run = {
|
||||
id: 'run-2',
|
||||
userId: 7,
|
||||
source: 'ai',
|
||||
fileName: 'checkins.xlsx',
|
||||
sheetsJson: JSON.stringify([
|
||||
{
|
||||
name: '入住',
|
||||
headers: ['姓名', '手机号', '宿舍号', '入住日期'],
|
||||
rows: [['张三', '13800138000', 'A101', '2026-09-01']],
|
||||
},
|
||||
]),
|
||||
settingsJson: JSON.stringify({ skipUnmatched: true }),
|
||||
status: 'ready',
|
||||
currentStepKey: 'checkins',
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as ImportRun;
|
||||
const step = {
|
||||
id: 3,
|
||||
runId: 'run-2',
|
||||
stepKey: 'checkins',
|
||||
sheetsJson: '["入住"]',
|
||||
mappingJson: null,
|
||||
status: 'pending',
|
||||
} as ImportStep;
|
||||
const rowsRepo = makeRowsRepo();
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
|
||||
if (entity === Room) return { find: jest.fn().mockResolvedValue([]) };
|
||||
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
|
||||
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
};
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo(run) as never,
|
||||
makeStepsRepo(step) as never,
|
||||
rowsRepo as never,
|
||||
dataSource as never,
|
||||
);
|
||||
|
||||
const result = await service.previewStep(principal, 'run-2', 'checkins', {
|
||||
sheets: ['入住'],
|
||||
mapping: {
|
||||
name: '姓名',
|
||||
phone: '手机号',
|
||||
roomNumber: '宿舍号',
|
||||
checkInDate: '入住日期',
|
||||
},
|
||||
});
|
||||
expect(result.summary).toMatchObject({
|
||||
total: 1,
|
||||
valid: 1,
|
||||
error: 0,
|
||||
create: 0,
|
||||
skip: 1,
|
||||
});
|
||||
expect(result.rows[0]).toMatchObject({ action: 'skip', status: 'valid' });
|
||||
expect(result.rows[0].errors.join(';')).toContain('按策略跳过');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { ImportRun } from './entities/import-run.entity';
|
||||
@@ -7,11 +7,16 @@ 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';
|
||||
|
||||
@@ -66,6 +71,15 @@ export class ImportsService {
|
||||
return this.runsSvc.createRun(...args);
|
||||
}
|
||||
|
||||
/** 上传后的只读预检:解析文件并生成可插入性报告,不写库。 */
|
||||
async preflightFile(file: ParsedImportFile): 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);
|
||||
return buildPreflightReport(this.dataSource, sheets);
|
||||
}
|
||||
|
||||
async getRun(...args: Parameters<ImportRunService['getRun']>) {
|
||||
return this.runsSvc.getRun(...args);
|
||||
}
|
||||
|
||||
@@ -130,6 +130,101 @@ export interface ImportRunDetail {
|
||||
createdAt: string;
|
||||
sheets: ImportSheetMeta[];
|
||||
steps: ImportStepDetail[];
|
||||
settings: ImportRunSettings;
|
||||
}
|
||||
|
||||
/** 用户确认后写入导入任务的策略与映射。 */
|
||||
export interface ImportRunSettings {
|
||||
/** 按阶段字段 -> 表头 的确认映射。 */
|
||||
mapping?: Partial<Record<ImportStepKey, ColumnMapping>>;
|
||||
/** 学生校区归属(在检测到校区问题时由用户确认)。 */
|
||||
organization?: string | null;
|
||||
/** 已匹配记录是否更新;默认 true。 */
|
||||
updateExisting?: boolean;
|
||||
/** 文件内重复行策略:error 报错 / skip 跳过;默认 error。 */
|
||||
duplicatePolicy?: 'error' | 'skip';
|
||||
/** 关系表找不到学生/宿舍时是否跳过;默认 false。 */
|
||||
skipUnmatched?: boolean;
|
||||
}
|
||||
|
||||
export type PreflightVerdict = 'ready' | 'needs_input' | 'blocked';
|
||||
|
||||
export interface PreflightStageStat {
|
||||
stepKey: ImportStepKey;
|
||||
label: string;
|
||||
sheetNames: 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[];
|
||||
}
|
||||
|
||||
export interface StepPreviewResult {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import { Readable } from 'node:stream';
|
||||
import { cellValue, textValue } from './imports.helpers';
|
||||
import type { CellValue } from './imports.types';
|
||||
|
||||
@@ -37,3 +39,50 @@ export function extractSheets(workbook: ExcelJS.Workbook): ImportSheetData[] {
|
||||
}
|
||||
return sheets;
|
||||
}
|
||||
|
||||
export type WorkbookKind = 'csv' | 'xlsx';
|
||||
|
||||
export function detectWorkbookKind(originalName: string, mimeType: string): WorkbookKind | null {
|
||||
const isCsv =
|
||||
/\.csv$/i.test(originalName) ||
|
||||
/csv/i.test(mimeType) ||
|
||||
/text\/(csv|plain)/i.test(mimeType);
|
||||
const isXlsx =
|
||||
/\.xlsx$/i.test(originalName) ||
|
||||
/spreadsheetml/i.test(mimeType) ||
|
||||
/excel/i.test(mimeType);
|
||||
if (isCsv) return 'csv';
|
||||
if (isXlsx) return 'xlsx';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 校验文件类型并解析为工作表数据;解析失败抛出可读错误。 */
|
||||
export async function parseSheets(
|
||||
buffer: Buffer,
|
||||
originalName: string,
|
||||
mimeType: string,
|
||||
): Promise<ImportSheetData[]> {
|
||||
const kind = detectWorkbookKind(originalName, mimeType);
|
||||
if (!kind) {
|
||||
throw new BadRequestException('仅支持 .xlsx / .csv 文件');
|
||||
}
|
||||
if (/\.xls$/i.test(originalName) && !/\.xlsx$/i.test(originalName)) {
|
||||
throw new BadRequestException('暂不支持 .xls,请另存为 .xlsx 或 .csv 后重试');
|
||||
}
|
||||
try {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
if (kind === 'csv') {
|
||||
await workbook.csv.read(Readable.from(Buffer.from(buffer)));
|
||||
} else {
|
||||
await workbook.xlsx.load(buffer.buffer as ArrayBuffer);
|
||||
}
|
||||
const sheets = extractSheets(workbook);
|
||||
if (sheets.length === 0) {
|
||||
throw new BadRequestException('文件中没有可用的工作表数据');
|
||||
}
|
||||
return sheets;
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestException) throw error;
|
||||
throw new BadRequestException('Excel 文件解析失败,请检查文件格式');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AddA2UiReviews1784880000000 } from './migrations/1784880000000-AddA2UiR
|
||||
import { EnlargeAiReviewSections1784900000000 } from './migrations/1784900000000-EnlargeAiReviewSections';
|
||||
import { AddImportRuns1784910000000 } from './migrations/1784910000000-AddImportRuns';
|
||||
import { DropAiMessageFeedback1784920000000 } from './migrations/1784920000000-DropAiMessageFeedback';
|
||||
import { AddImportRunSettings1784930000000 } from './migrations/1784930000000-AddImportRunSettings';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
config();
|
||||
@@ -35,6 +36,7 @@ export async function runMigrationsOnStartup(): Promise<void> {
|
||||
EnlargeAiReviewSections1784900000000,
|
||||
AddImportRuns1784910000000,
|
||||
DropAiMessageFeedback1784920000000,
|
||||
AddImportRunSettings1784930000000,
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* import_runs 增加 settings_json:保存 AI 预检确认后的
|
||||
* 列映射、校区归属、更新/重复/未匹配策略。
|
||||
*/
|
||||
export class AddImportRunSettings1784930000000 implements MigrationInterface {
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasColumn('import_runs', 'settings_json'))) {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE import_runs ADD COLUMN settings_json text NULL',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasColumn('import_runs', 'settings_json')) {
|
||||
await queryRunner.query('ALTER TABLE import_runs DROP COLUMN settings_json');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user