diff --git a/apps/admin/src/api/schemas/core.ts b/apps/admin/src/api/schemas/core.ts index 38295e9..5339f16 100644 --- a/apps/admin/src/api/schemas/core.ts +++ b/apps/admin/src/api/schemas/core.ts @@ -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())), diff --git a/apps/admin/src/api/schemas/import-run.ts b/apps/admin/src/api/schemas/import-run.ts index 4ada227..2b2155e 100644 --- a/apps/admin/src/api/schemas/import-run.ts +++ b/apps/admin/src/api/schemas/import-run.ts @@ -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(); diff --git a/apps/admin/src/components/AiChat/AiChatDrawer.tsx b/apps/admin/src/components/AiChat/AiChatDrawer.tsx index 44101e4..c179270 100644 --- a/apps/admin/src/components/AiChat/AiChatDrawer.tsx +++ b/apps/admin/src/components/AiChat/AiChatDrawer.tsx @@ -54,7 +54,9 @@ const AiChatDrawer: React.FC = ({ 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([]); const [conversationStatus, setConversationStatus] = useState< @@ -228,7 +230,7 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting }, }); }, - [setConversation], + [setConversation, modal], ); /** 删除单个会话时中止请求并清理会话运行时状态 */ @@ -269,13 +271,14 @@ const AiChatDrawer: React.FC = ({ 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 = ({ open, onClose, onRequesting activeId, conversations, removeConversation, - removeConversationEntry, - selectedKeys, - switchConversation, - setConversations, - ]); + removeConversationEntry, + selectedKeys, + switchConversation, + setConversations, + modal, + ]); const conversationMenu = useCallback( (item: ConversationItemType): MenuProps => ({ diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index d5b5346..e9f4756 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -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 = { render_form: '生成表单', render_review: '生成导入预览', render_chart: '生成图表', + preflight_import: '导入预检', start_import_wizard: '生成导入向导', create_student: '创建学生', search_exams: '查询考试', @@ -311,6 +314,11 @@ export const AiMessageContent: React.FC = ({ {attachmentCards} )} + {(() => { + const preflight = message.metadata?.a2uiImportPreflight; + if (!preflight || typeof preflight !== 'object' || Array.isArray(preflight)) return null; + return ; + })()} {(() => { const wizard = message.metadata?.a2uiImportWizard as AiImportWizard | undefined; if (!wizard || !onOpenImportWizard) return null; diff --git a/apps/admin/src/components/AiChat/ImportPreflightCard.tsx b/apps/admin/src/components/AiChat/ImportPreflightCard.tsx new file mode 100644 index 0000000..2bfce5b --- /dev/null +++ b/apps/admin/src/components/AiChat/ImportPreflightCard.tsx @@ -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 ( + + + Excel 导入预检 + {verdict.label} + + } + > + + {preflight.stages.length > 0 ? ( + + {preflight.stages.map((stage) => ( + + + {stage.label} + + {stage.sheetNames.join('、')} + + + + 共 {stage.total} 行 + 新建 {stage.create} + 更新 {stage.update} + 错误 {stage.error} + 跳过 {stage.skip} + + + ))} + + ) : ( + 未识别到可导入的工作表 + )} + + {preflight.blocks.length > 0 && ( + + + 阻断原因 + + {preflight.blocks.map((block) => ( + + {block.label} + + {block.message}({block.count} 行) + + + ))} + + )} + + {preflight.questions.length > 0 && ( + + + 需要确认 + + {preflight.questions.map((question) => ( + + {question.label} + {question.description && ( + + {question.description} + + )} + + ))} + + )} + + {preflight.nextSteps.length > 0 && ( + + + 下一步建议 + + {preflight.nextSteps.map((step) => ( + + {step.label} + + {step.description} + + + ))} + + )} + + + ); +}; diff --git a/apps/admin/src/components/AiChat/bubble.integration.test.tsx b/apps/admin/src/components/AiChat/bubble.integration.test.tsx index 1ae84ac..5b67c9d 100644 --- a/apps/admin/src/components/AiChat/bubble.integration.test.tsx +++ b/apps/admin/src/components/AiChat/bubble.integration.test.tsx @@ -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(); + }); + + 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( + { + 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', diff --git a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts index 0aa813a..60ea6ee 100644 --- a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts +++ b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts @@ -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); + }); }); diff --git a/apps/admin/src/components/AiChat/provider.integration.test.ts b/apps/admin/src/components/AiChat/provider.integration.test.ts index 7429bda..d1f1f43 100644 --- a/apps/admin/src/components/AiChat/provider.integration.test.ts +++ b/apps/admin/src/components/AiChat/provider.integration.test.ts @@ -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', diff --git a/apps/admin/src/components/AiChat/provider.ts b/apps/admin/src/components/AiChat/provider.ts index ad93b6d..e8f7309 100644 --- a/apps/admin/src/components/AiChat/provider.ts +++ b/apps/admin/src/components/AiChat/provider.ts @@ -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(message.reviews, payload.review); } else if (event === 'ui.chart' && payload.chart) { message.charts = mergeById(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') { diff --git a/apps/admin/src/components/AiChat/style.css b/apps/admin/src/components/AiChat/style.css index 14a1e44..ab1ac75 100644 --- a/apps/admin/src/components/AiChat/style.css +++ b/apps/admin/src/components/AiChat/style.css @@ -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; diff --git a/apps/admin/src/components/AiChat/types.ts b/apps/admin/src/components/AiChat/types.ts index a0964b1..8987091 100644 --- a/apps/admin/src/components/AiChat/types.ts +++ b/apps/admin/src/components/AiChat/types.ts @@ -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; + 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' diff --git a/apps/admin/src/components/ImportWizard/types.ts b/apps/admin/src/components/ImportWizard/types.ts index 097b361..a2e2350 100644 --- a/apps/admin/src/components/ImportWizard/types.ts +++ b/apps/admin/src/components/ImportWizard/types.ts @@ -36,6 +36,13 @@ export interface ImportRunDetail { createdAt: string; sheets: ImportSheetMeta[]; steps: ImportStepDetail[]; + settings?: { + mapping?: Partial>>; + organization?: string | null; + updateExisting?: boolean; + duplicatePolicy?: 'error' | 'skip'; + skipUnmatched?: boolean; + }; } export interface ImportStageRequest { diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx index a2ece16..7253958 100644 --- a/apps/admin/src/pages/Attendance/index.tsx +++ b/apps/admin/src/pages/Attendance/index.tsx @@ -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; diff --git a/apps/admin/src/pages/Expenses/index.tsx b/apps/admin/src/pages/Expenses/index.tsx index 53aad44..967d928 100644 --- a/apps/admin/src/pages/Expenses/index.tsx +++ b/apps/admin/src/pages/Expenses/index.tsx @@ -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('加载费用数据失败'); diff --git a/apps/server/src/ai-chat/ai-attachment.service.ts b/apps/server/src/ai-chat/ai-attachment.service.ts index e725b9a..db53b51 100644 --- a/apps/server/src/ai-chat/ai-attachment.service.ts +++ b/apps/server/src/ai-chat/ai-attachment.service.ts @@ -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 { diff --git a/apps/server/src/ai-chat/ai-chat.constants.ts b/apps/server/src/ai-chat/ai-chat.constants.ts index 4f728c6..8395aba 100644 --- a/apps/server/src/ai-chat/ai-chat.constants.ts +++ b/apps/server/src/ai-chat/ai-chat.constants.ts @@ -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。`; diff --git a/apps/server/src/ai-chat/ai-chat.generation.ts b/apps/server/src/ai-chat/ai-chat.generation.ts index c323aba..272a944 100644 --- a/apps/server/src/ai-chat/ai-chat.generation.ts +++ b/apps/server/src/ai-chat/ai-chat.generation.ts @@ -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); diff --git a/apps/server/src/ai-chat/ai-chat.service.spec.ts b/apps/server/src/ai-chat/ai-chat.service.spec.ts index 01fd0e6..eff1cd5 100644 --- a/apps/server/src/ai-chat/ai-chat.service.spec.ts +++ b/apps/server/src/ai-chat/ai-chat.service.spec.ts @@ -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; + } + ).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 }> = []; + 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; + } + ).executePreflightImport( + 42, + { + id: 'call-1', + name: 'preflight_import', + arguments: JSON.stringify({ attachmentId: 9 }), + }, + 7, + (event, data) => emitted.push({ event, data: (data ?? {}) as Record }), + ); + + 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; + } + ).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; + } + ).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(); + }); }); diff --git a/apps/server/src/ai-chat/ai-chat.service.ts b/apps/server/src/ai-chat/ai-chat.service.ts index f81981f..55294ee 100644 --- a/apps/server/src/ai-chat/ai-chat.service.ts +++ b/apps/server/src/ai-chat/ai-chat.service.ts @@ -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 { + return executePreflightImport(this, messageId, call, userId, emit); + } + + executeExcelAnalyze( + messageId: number, + call: ModelToolCall, + userId: number, + emit: AiSseEmitter, + ): Promise { + return executeExcelAnalyze(this, messageId, call, userId, emit); + } + executeGeneration(input: GenerationInput): Promise { return executeGeneration(this, input); } diff --git a/apps/server/src/ai-chat/ai-chat.streaming.ts b/apps/server/src/ai-chat/ai-chat.streaming.ts index a83f11c..ef01afe 100644 --- a/apps/server/src/ai-chat/ai-chat.streaming.ts +++ b/apps/server/src/ai-chat/ai-chat.streaming.ts @@ -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]; } diff --git a/apps/server/src/ai-chat/ai-chat.submissions.ts b/apps/server/src/ai-chat/ai-chat.submissions.ts index 889cb9f..be942da 100644 --- a/apps/server/src/ai-chat/ai-chat.submissions.ts +++ b/apps/server/src/ai-chat/ai-chat.submissions.ts @@ -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, diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.ts index 71e9b63..2123165 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.ts @@ -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 { + 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) + : {}; + 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 { + 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) + : {}; + 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> | undefined { + if (raw === undefined || raw === null) return undefined; + if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('mapping 参数格式错误'); + const mapping: Partial> = {}; + for (const [stepKey, fields] of Object.entries(raw as Record)) { + 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)) { + 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): 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; diff --git a/apps/server/src/ai-chat/ai-chat.tool-office.ts b/apps/server/src/ai-chat/ai-chat.tool-office.ts index baea5eb..16ccf23 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-office.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-office.ts @@ -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); diff --git a/apps/server/src/ai-chat/ai-chat.tools.ts b/apps/server/src/ai-chat/ai-chat.tools.ts index af483e5..597b205 100644 --- a/apps/server/src/ai-chat/ai-chat.tools.ts +++ b/apps/server/src/ai-chat/ai-chat.tools.ts @@ -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); } diff --git a/apps/server/src/ai-chat/ai-chat.types.ts b/apps/server/src/ai-chat/ai-chat.types.ts index f007ee2..07b4dc5 100644 --- a/apps/server/src/ai-chat/ai-chat.types.ts +++ b/apps/server/src/ai-chat/ai-chat.types.ts @@ -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; serializeMessage(message: AiMessage): Record; 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' diff --git a/apps/server/src/ai-chat/ai-excel-reader.service.ts b/apps/server/src/ai-chat/ai-excel-reader.service.ts index 0589b7b..a970a23 100644 --- a/apps/server/src/ai-chat/ai-excel-reader.service.ts +++ b/apps/server/src/ai-chat/ai-excel-reader.service.ts @@ -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, diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 2423e75..8816293 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -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'; diff --git a/apps/server/src/expenses/expenses.controller.ts b/apps/server/src/expenses/expenses.controller.ts index d9dcd6b..011a93b 100644 --- a/apps/server/src/expenses/expenses.controller.ts +++ b/apps/server/src/expenses/expenses.controller.ts @@ -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) { diff --git a/apps/server/src/expenses/expenses.lookups.spec.ts b/apps/server/src/expenses/expenses.lookups.spec.ts index 25d2d9f..0ca2164 100644 --- a/apps/server/src/expenses/expenses.lookups.spec.ts +++ b/apps/server/src/expenses/expenses.lookups.spec.ts @@ -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' }, + }), + ); + }); }); diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts index df8e329..0a77692 100644 --- a/apps/server/src/expenses/expenses.service.ts +++ b/apps/server/src/expenses/expenses.service.ts @@ -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); diff --git a/apps/server/src/imports/entities/import-run.entity.ts b/apps/server/src/imports/entities/import-run.entity.ts index dba89fe..d7d03c7 100644 --- a/apps/server/src/imports/entities/import-run.entity.ts +++ b/apps/server/src/imports/entities/import-run.entity.ts @@ -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; diff --git a/apps/server/src/imports/imports.controller.spec.ts b/apps/server/src/imports/imports.controller.spec.ts new file mode 100644 index 0000000..baf1ccc --- /dev/null +++ b/apps/server/src/imports/imports.controller.spec.ts @@ -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(); + }); +}); diff --git a/apps/server/src/imports/imports.controller.ts b/apps/server/src/imports/imports.controller.ts index 2edf23a..75110a0 100644 --- a/apps/server/src/imports/imports.controller.ts +++ b/apps/server/src/imports/imports.controller.ts @@ -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 | 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; + } + } 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 }; } diff --git a/apps/server/src/imports/imports.policies.ts b/apps/server/src/imports/imports.policies.ts new file mode 100644 index 0000000..941af67 --- /dev/null +++ b/apps/server/src/imports/imports.policies.ts @@ -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 }; +} diff --git a/apps/server/src/imports/imports.preflight.spec.ts b/apps/server/src/imports/imports.preflight.spec.ts new file mode 100644 index 0000000..9345fd8 --- /dev/null +++ b/apps/server/src/imports/imports.preflight.spec.ts @@ -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, + }), + ); + }); +}); diff --git a/apps/server/src/imports/imports.preflight.ts b/apps/server/src/imports/imports.preflight.ts new file mode 100644 index 0000000..a9884dc --- /dev/null +++ b/apps/server/src/imports/imports.preflight.ts @@ -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 = { + 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 = { + name: '姓名', + roomNumber: '宿舍号', + capacity: '容量', + checkInDate: '入住日期', + oldRoom: '原宿舍', + newRoom: '新宿舍', + transferDate: '换宿日期', + identity: '学号或手机号', +}; + +const NEXT_STEP_DEFS: Array = [ + { + 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(); + 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 { + 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(); + const unknownOrgs = new Set(); + const batchState: ImportBatchState = { + checkinStudentIds: new Set(), + transferStudentIds: new Set(), + }; + + 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 = {}; + 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(); + const stepKeys = new Map>(); + const add = (code: PreflightBlockCode, stepKey: ImportStepKey, count: number) => { + counts.set(code, (counts.get(code) ?? 0) + count); + const keys = stepKeys.get(code) ?? new Set(); + 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 { + const grouped = new Map(); + 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(); + 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), + }; +} diff --git a/apps/server/src/imports/imports.preview.service.ts b/apps/server/src/imports/imports.preview.service.ts index dc71118..ef9ba19 100644 --- a/apps/server/src/imports/imports.preview.service.ts +++ b/apps/server/src/imports/imports.preview.service.ts @@ -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>(run.sheetsJson) ?? []; + const settings = parseJson(run.settingsJson) ?? {}; const sheetNames = body.sheets?.length ? body.sheets : (parseJson(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, }), ); diff --git a/apps/server/src/imports/imports.run.service.ts b/apps/server/src/imports/imports.run.service.ts index 9d341cb..2d7f1a1 100644 --- a/apps/server/src/imports/imports.run.service.ts +++ b/apps/server/src/imports/imports.run.service.ts @@ -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>, + 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(run.settingsJson) ?? {}, steps: stepRecords.map((step) => ({ id: step.id, stepKey: step.stepKey, diff --git a/apps/server/src/imports/imports.service.spec.ts b/apps/server/src/imports/imports.service.spec.ts index bdf9ba9..ea26275 100644 --- a/apps/server/src/imports/imports.service.spec.ts +++ b/apps/server/src/imports/imports.service.spec.ts @@ -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('按策略跳过'); + }); }); diff --git a/apps/server/src/imports/imports.service.ts b/apps/server/src/imports/imports.service.ts index e50ac90..89ca550 100644 --- a/apps/server/src/imports/imports.service.ts +++ b/apps/server/src/imports/imports.service.ts @@ -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 { + 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) { return this.runsSvc.getRun(...args); } diff --git a/apps/server/src/imports/imports.types.ts b/apps/server/src/imports/imports.types.ts index 8eeee84..eeb38cb 100644 --- a/apps/server/src/imports/imports.types.ts +++ b/apps/server/src/imports/imports.types.ts @@ -130,6 +130,101 @@ export interface ImportRunDetail { createdAt: string; sheets: ImportSheetMeta[]; steps: ImportStepDetail[]; + settings: ImportRunSettings; +} + +/** 用户确认后写入导入任务的策略与映射。 */ +export interface ImportRunSettings { + /** 按阶段字段 -> 表头 的确认映射。 */ + mapping?: Partial>; + /** 学生校区归属(在检测到校区问题时由用户确认)。 */ + 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 { diff --git a/apps/server/src/imports/imports.workbook.ts b/apps/server/src/imports/imports.workbook.ts index 9a5ac41..f633427 100644 --- a/apps/server/src/imports/imports.workbook.ts +++ b/apps/server/src/imports/imports.workbook.ts @@ -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 { + 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 文件解析失败,请检查文件格式'); + } +} diff --git a/apps/server/src/migration-runner.ts b/apps/server/src/migration-runner.ts index f59f649..ad624ab 100644 --- a/apps/server/src/migration-runner.ts +++ b/apps/server/src/migration-runner.ts @@ -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 { EnlargeAiReviewSections1784900000000, AddImportRuns1784910000000, DropAiMessageFeedback1784920000000, + AddImportRunSettings1784930000000, ], }); diff --git a/apps/server/src/migrations/1784930000000-AddImportRunSettings.ts b/apps/server/src/migrations/1784930000000-AddImportRunSettings.ts new file mode 100644 index 0000000..c3ad701 --- /dev/null +++ b/apps/server/src/migrations/1784930000000-AddImportRunSettings.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * import_runs 增加 settings_json:保存 AI 预检确认后的 + * 列映射、校区归属、更新/重复/未匹配策略。 + */ +export class AddImportRunSettings1784930000000 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + 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 { + if (await queryRunner.hasColumn('import_runs', 'settings_json')) { + await queryRunner.query('ALTER TABLE import_runs DROP COLUMN settings_json'); + } + } +}