feat: 新增通用导入中心
This commit is contained in:
79
apps/admin/src/api/imports.ts
Normal file
79
apps/admin/src/api/imports.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import api from './index';
|
||||||
|
import { validateResponse } from '../utils/validate';
|
||||||
|
import { importRunEnvelopeSchema } from './schemas';
|
||||||
|
import type {
|
||||||
|
ImportPreviewResult,
|
||||||
|
ImportReceipt,
|
||||||
|
ImportRunDetail,
|
||||||
|
ImportStageRequest,
|
||||||
|
} from '../components/ImportWizard/types';
|
||||||
|
|
||||||
|
interface ApiEnvelope<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createImportRun(
|
||||||
|
file: File,
|
||||||
|
options: {
|
||||||
|
source: 'ai' | 'manual';
|
||||||
|
conversationId?: number;
|
||||||
|
stages?: ImportStageRequest[];
|
||||||
|
mapping?: Record<string, Record<string, string>>;
|
||||||
|
},
|
||||||
|
): Promise<ImportRunDetail> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('source', options.source);
|
||||||
|
if (options.conversationId) form.append('conversationId', String(options.conversationId));
|
||||||
|
if (options.stages?.length) form.append('stages', JSON.stringify(options.stages));
|
||||||
|
if (options.mapping && Object.keys(options.mapping).length > 0) {
|
||||||
|
form.append('mapping', JSON.stringify(options.mapping));
|
||||||
|
}
|
||||||
|
const res = await api.post<ApiEnvelope<ImportRunDetail>>('/imports/runs', form, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getImportRun(runId: string): Promise<ImportRunDetail> {
|
||||||
|
const res = await api.get<ApiEnvelope<ImportRunDetail>>(
|
||||||
|
`/imports/runs/${encodeURIComponent(runId)}`,
|
||||||
|
);
|
||||||
|
return validateResponse<ApiEnvelope<ImportRunDetail>>(importRunEnvelopeSchema, res).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewImportStep(
|
||||||
|
runId: string,
|
||||||
|
stepKey: string,
|
||||||
|
body: { sheets?: string[]; mapping?: Record<string, string> },
|
||||||
|
): Promise<ImportPreviewResult> {
|
||||||
|
const res = await api.post<ApiEnvelope<ImportPreviewResult>>(
|
||||||
|
`/imports/runs/${encodeURIComponent(runId)}/steps/${encodeURIComponent(stepKey)}/preview`,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function commitImportStep(
|
||||||
|
runId: string,
|
||||||
|
stepKey: string,
|
||||||
|
decisions: Array<{ rowId: number; action: 'create' | 'update' | 'skip' }>,
|
||||||
|
): Promise<ImportReceipt> {
|
||||||
|
const res = await api.post<ApiEnvelope<ImportReceipt>>(
|
||||||
|
`/imports/runs/${encodeURIComponent(runId)}/steps/${encodeURIComponent(stepKey)}/commit`,
|
||||||
|
{ decisions },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importErrorReportUrl(runId: string, stepKey?: string): string {
|
||||||
|
const base = import.meta.env.PROD
|
||||||
|
? '/api'
|
||||||
|
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (stepKey) params.set('stepKey', stepKey);
|
||||||
|
const query = params.toString();
|
||||||
|
return `${base}/imports/runs/${encodeURIComponent(runId)}/report${query ? `?${query}` : ''}`;
|
||||||
|
}
|
||||||
616
apps/admin/src/components/ImportWizard/ImportWizardModal.tsx
Normal file
616
apps/admin/src/components/ImportWizard/ImportWizardModal.tsx
Normal file
@@ -0,0 +1,616 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
StepForwardOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Descriptions,
|
||||||
|
Flex,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Steps,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
Upload,
|
||||||
|
} from 'antd';
|
||||||
|
import type { UploadProps } from 'antd';
|
||||||
|
import { message } from '../../ui/app-message';
|
||||||
|
import { useUserStore } from '../../store/user/userStore';
|
||||||
|
import {
|
||||||
|
commitImportStep,
|
||||||
|
createImportRun,
|
||||||
|
getImportRun,
|
||||||
|
importErrorReportUrl,
|
||||||
|
previewImportStep,
|
||||||
|
} from '../../api/imports';
|
||||||
|
import {
|
||||||
|
STEP_FIELDS,
|
||||||
|
type ImportPreviewResult,
|
||||||
|
type ImportReceipt,
|
||||||
|
type ImportRunDetail,
|
||||||
|
type ImportStepKey,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface ImportWizardModalProps {
|
||||||
|
open: boolean;
|
||||||
|
/** AI 对话生成的导入任务;为空时向导从上传文件开始。 */
|
||||||
|
runId?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RowAction = 'create' | 'update' | 'skip';
|
||||||
|
|
||||||
|
const ACTION_META: Record<RowAction, { label: string; color: string }> = {
|
||||||
|
create: { label: '新建', color: 'blue' },
|
||||||
|
update: { label: '更新', color: 'orange' },
|
||||||
|
skip: { label: '跳过', color: 'default' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function guessMapping(stepKey: ImportStepKey, headers: string[]): Record<string, string> {
|
||||||
|
const mapping: Record<string, string> = {};
|
||||||
|
for (const field of STEP_FIELDS[stepKey]) {
|
||||||
|
const hit = headers.find((header) => {
|
||||||
|
const normalizedHeader = header.replace(/[\s()()]/g, '').toLowerCase();
|
||||||
|
const normalizedLabel = field.label.replace(/[\s()()]/g, '').toLowerCase();
|
||||||
|
return (
|
||||||
|
normalizedHeader === normalizedLabel ||
|
||||||
|
normalizedHeader.includes(normalizedLabel) ||
|
||||||
|
normalizedLabel.includes(normalizedHeader)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (hit) mapping[field.key] = hit;
|
||||||
|
}
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyInfo(stepKey: ImportStepKey, row: ImportPreviewResult['rows'][number]): string {
|
||||||
|
const fields = row.fields;
|
||||||
|
if (stepKey === 'students') {
|
||||||
|
return [fields.name, fields.studentNo, fields.phone].filter(Boolean).join(' / ');
|
||||||
|
}
|
||||||
|
if (stepKey === 'rooms') {
|
||||||
|
return [fields.roomNumber, fields.building, fields.floor].filter(Boolean).join(' / ');
|
||||||
|
}
|
||||||
|
if (stepKey === 'checkins') {
|
||||||
|
return [fields.name, fields.studentNo, fields.phone, fields.roomNumber, fields.checkInDate]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' / ');
|
||||||
|
}
|
||||||
|
return [fields.studentNo, fields.phone, fields.oldRoom, fields.newRoom, fields.transferDate]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadErrorReport(runId: string, stepKey?: ImportStepKey): Promise<void> {
|
||||||
|
const token = useUserStore.getState().token;
|
||||||
|
const response = await fetch(importErrorReportUrl(runId, stepKey), {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('错误报告下载失败');
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `导入错误报告-${runId.slice(0, 8)}.csv`;
|
||||||
|
anchor.click();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
|
||||||
|
open,
|
||||||
|
runId: initialRunId,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [run, setRun] = useState<ImportRunDetail | null>(null);
|
||||||
|
const [loadingRun, setLoadingRun] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [activeStepKey, setActiveStepKey] = useState<ImportStepKey | null>(null);
|
||||||
|
const [sheetSelection, setSheetSelection] = useState<Record<string, string[]>>({});
|
||||||
|
const [mappingDraft, setMappingDraft] = useState<Record<string, Record<string, string>>>({});
|
||||||
|
const [previewByStep, setPreviewByStep] = useState<Record<string, ImportPreviewResult>>({});
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const [onlyErrors, setOnlyErrors] = useState(false);
|
||||||
|
const [rowActions, setRowActions] = useState<Record<number, RowAction>>({});
|
||||||
|
const [commitLoading, setCommitLoading] = useState(false);
|
||||||
|
const [receipt, setReceipt] = useState<ImportReceipt | null>(null);
|
||||||
|
const [reportDownloading, setReportDownloading] = useState(false);
|
||||||
|
const requestSeq = useRef(0);
|
||||||
|
|
||||||
|
const loadRun = useCallback(async (runId: string) => {
|
||||||
|
const seq = ++requestSeq.current;
|
||||||
|
setLoadingRun(true);
|
||||||
|
try {
|
||||||
|
const detail = await getImportRun(runId);
|
||||||
|
if (seq !== requestSeq.current) return;
|
||||||
|
setRun(detail);
|
||||||
|
const selections: Record<string, string[]> = {};
|
||||||
|
const mappings: Record<string, Record<string, string>> = {};
|
||||||
|
const sheetHeaders = new Map(detail.sheets.map((sheet) => [sheet.name, sheet.headers]));
|
||||||
|
for (const step of detail.steps) {
|
||||||
|
selections[step.stepKey] = step.sheets;
|
||||||
|
const mapped =
|
||||||
|
step.mapping && Object.keys(step.mapping).length > 0
|
||||||
|
? step.mapping
|
||||||
|
: guessMapping(step.stepKey, sheetHeaders.get(step.sheets[0] ?? '') ?? []);
|
||||||
|
mappings[step.stepKey] = mapped;
|
||||||
|
}
|
||||||
|
setSheetSelection(selections);
|
||||||
|
setMappingDraft(mappings);
|
||||||
|
setPreviewByStep({});
|
||||||
|
setRowActions({});
|
||||||
|
setReceipt(null);
|
||||||
|
const firstActive =
|
||||||
|
detail.steps.find((step) => step.status !== 'skipped' && step.status !== 'committed') ??
|
||||||
|
detail.steps.find((step) => step.status !== 'skipped');
|
||||||
|
setActiveStepKey(firstActive?.stepKey ?? detail.currentStepKey);
|
||||||
|
} catch (error) {
|
||||||
|
if (seq !== requestSeq.current) return;
|
||||||
|
message.error(error instanceof Error ? error.message : '导入任务加载失败');
|
||||||
|
setRun(null);
|
||||||
|
} finally {
|
||||||
|
if (seq === requestSeq.current) setLoadingRun(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !initialRunId) return;
|
||||||
|
void loadRun(initialRunId);
|
||||||
|
}, [open, initialRunId, loadRun]);
|
||||||
|
|
||||||
|
const activeStep = useMemo(
|
||||||
|
() => run?.steps.find((step) => step.stepKey === activeStepKey) ?? null,
|
||||||
|
[run, activeStepKey],
|
||||||
|
);
|
||||||
|
const preview = activeStepKey ? (previewByStep[activeStepKey] ?? null) : null;
|
||||||
|
|
||||||
|
const sheetOptions = useMemo(() => (run?.sheets ?? []).map((sheet) => sheet.name), [run]);
|
||||||
|
const headerOptions = useMemo(() => {
|
||||||
|
if (!run || !activeStepKey) return [];
|
||||||
|
const names = sheetSelection[activeStepKey] ?? [];
|
||||||
|
const headers = new Set<string>();
|
||||||
|
for (const sheet of run.sheets) {
|
||||||
|
if (names.includes(sheet.name)) sheet.headers.forEach((header) => headers.add(header));
|
||||||
|
}
|
||||||
|
return [...headers];
|
||||||
|
}, [run, activeStepKey, sheetSelection]);
|
||||||
|
|
||||||
|
const handleUpload: UploadProps['customRequest'] = async (options) => {
|
||||||
|
const file = options.file as File;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const detail = await createImportRun(file, { source: 'manual' });
|
||||||
|
await loadRun(detail.id);
|
||||||
|
message.success(`已识别 ${detail.sheets.length} 个工作表`);
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '文件上传失败');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreview = async () => {
|
||||||
|
if (!run || !activeStepKey || !activeStep) return;
|
||||||
|
const mapping = mappingDraft[activeStepKey] ?? {};
|
||||||
|
const required = STEP_FIELDS[activeStepKey]
|
||||||
|
.filter((field) => field.required)
|
||||||
|
.map((field) => field.key);
|
||||||
|
const missing = required.filter((key) => !mapping[key]);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
message.warning('请先完成必填列的映射');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await previewImportStep(run.id, activeStepKey, {
|
||||||
|
sheets: sheetSelection[activeStepKey] ?? [],
|
||||||
|
mapping,
|
||||||
|
});
|
||||||
|
setPreviewByStep((prev) => ({ ...prev, [activeStepKey]: result }));
|
||||||
|
setRowActions({});
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '预览失败');
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommit = async () => {
|
||||||
|
if (!run || !activeStepKey || !preview) return;
|
||||||
|
setCommitLoading(true);
|
||||||
|
try {
|
||||||
|
const decisions = preview.rows
|
||||||
|
.filter((row) => row.status === 'valid')
|
||||||
|
.map((row) => ({ rowId: row.id, action: rowActions[row.id] ?? row.action ?? 'create' }));
|
||||||
|
const result = await commitImportStep(run.id, activeStepKey, decisions);
|
||||||
|
setReceipt(result);
|
||||||
|
const refreshed = await getImportRun(run.id);
|
||||||
|
setRun(refreshed);
|
||||||
|
setPreviewByStep({});
|
||||||
|
setRowActions({});
|
||||||
|
if (result.nextStepKey) {
|
||||||
|
setActiveStepKey(result.nextStepKey);
|
||||||
|
const nextStep = refreshed.steps.find((step) => step.stepKey === result.nextStepKey);
|
||||||
|
setMappingDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[result.nextStepKey as string]: nextStep?.mapping ?? {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '提交失败');
|
||||||
|
} finally {
|
||||||
|
setCommitLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReupload = async (file: File) => {
|
||||||
|
if (!run || !activeStepKey) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const detail = await createImportRun(file, {
|
||||||
|
source: 'manual',
|
||||||
|
stages: [{ stepKey: activeStepKey, sheet: sheetSelection[activeStepKey]?.[0] }],
|
||||||
|
mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} },
|
||||||
|
});
|
||||||
|
await loadRun(detail.id);
|
||||||
|
message.success('已重新上传,并保留原列映射');
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '重新上传失败');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewRows = useMemo(() => {
|
||||||
|
if (!preview) return [];
|
||||||
|
return onlyErrors ? preview.rows.filter((row) => row.status === 'error') : preview.rows;
|
||||||
|
}, [preview, onlyErrors]);
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
if (!activeStepKey) return [];
|
||||||
|
return [
|
||||||
|
{ title: '行号', dataIndex: 'rowNumber', width: 70 },
|
||||||
|
{ title: '工作表', dataIndex: 'sheetName', width: 120, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '数据',
|
||||||
|
key: 'data',
|
||||||
|
render: (_: unknown, row: ImportPreviewResult['rows'][number]) =>
|
||||||
|
keyInfo(activeStepKey, row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '判定',
|
||||||
|
key: 'action',
|
||||||
|
width: 90,
|
||||||
|
render: (_: unknown, row: ImportPreviewResult['rows'][number]) => {
|
||||||
|
const action = rowActions[row.id] ?? row.action;
|
||||||
|
return action ? (
|
||||||
|
<Tag color={ACTION_META[action].color}>{ACTION_META[action].label}</Tag>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
key: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (_: unknown, row: ImportPreviewResult['rows'][number]) =>
|
||||||
|
row.status === 'error' ? (
|
||||||
|
<Tag color="red" icon={<CloseCircleOutlined />}>
|
||||||
|
错误
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color="green" icon={<CheckCircleOutlined />}>
|
||||||
|
有效
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '错误信息',
|
||||||
|
key: 'errors',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_: unknown, row: ImportPreviewResult['rows'][number]) =>
|
||||||
|
row.errors.length > 0 ? (
|
||||||
|
<Tooltip title={row.errors.join(';')}>
|
||||||
|
<Typography.Text type="danger" style={{ maxWidth: 320 }}>
|
||||||
|
{row.errors.join(';')}
|
||||||
|
</Typography.Text>
|
||||||
|
</Tooltip>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '处理方式',
|
||||||
|
key: 'decision',
|
||||||
|
width: 120,
|
||||||
|
render: (_: unknown, row: ImportPreviewResult['rows'][number]) =>
|
||||||
|
row.status === 'valid' ? (
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={rowActions[row.id] ?? row.action ?? 'create'}
|
||||||
|
options={
|
||||||
|
row.action === 'update'
|
||||||
|
? [
|
||||||
|
{ value: 'update', label: '更新' },
|
||||||
|
{ value: 'skip', label: '跳过' },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ value: 'create', label: '新建' },
|
||||||
|
{ value: 'skip', label: '跳过' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
onChange={(value: RowAction) =>
|
||||||
|
setRowActions((prev) => ({ ...prev, [row.id]: value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [activeStepKey, rowActions]);
|
||||||
|
|
||||||
|
const stageItems = useMemo(
|
||||||
|
() =>
|
||||||
|
(run?.steps ?? [])
|
||||||
|
.filter((step) => step.status !== 'skipped')
|
||||||
|
.map((step) => ({
|
||||||
|
key: step.stepKey,
|
||||||
|
title: step.label,
|
||||||
|
status:
|
||||||
|
step.status === 'committed'
|
||||||
|
? ('finish' as const)
|
||||||
|
: step.stepKey === activeStepKey
|
||||||
|
? ('process' as const)
|
||||||
|
: ('wait' as const),
|
||||||
|
})),
|
||||||
|
[run, activeStepKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const allCommitted = run?.status === 'committed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={980}
|
||||||
|
title="Excel 批量导入向导"
|
||||||
|
destroyOnHidden={false}
|
||||||
|
>
|
||||||
|
{!run && !loadingRun ? (
|
||||||
|
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="上传 Excel 后,系统会自动识别工作表并按业务依赖分阶段(学生/宿舍 → 入住/换宿)。每一阶段都需要先预览、再确认,确认后才会写入数据库。"
|
||||||
|
/>
|
||||||
|
<Upload.Dragger
|
||||||
|
accept=".xlsx,.csv"
|
||||||
|
maxCount={1}
|
||||||
|
showUploadList={false}
|
||||||
|
disabled={uploading}
|
||||||
|
customRequest={handleUpload}
|
||||||
|
>
|
||||||
|
<p className="ant-upload-drag-icon">
|
||||||
|
<InboxOutlined />
|
||||||
|
</p>
|
||||||
|
<p className="ant-upload-text">点击或拖拽 .xlsx / .csv 文件到此区域</p>
|
||||||
|
<p className="ant-upload-hint">单文件不超过 10MB;.xls 请先另存为 .xlsx</p>
|
||||||
|
</Upload.Dragger>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<Flex justify="space-between" align="center" wrap gap={8}>
|
||||||
|
<Space wrap>
|
||||||
|
<Typography.Text strong>{run?.fileName}</Typography.Text>
|
||||||
|
<Tag color={allCommitted ? 'success' : 'processing'}>
|
||||||
|
{allCommitted ? '已完成' : '待处理'}
|
||||||
|
</Tag>
|
||||||
|
<Tag>当前阶段:{activeStep?.label ?? '—'}</Tag>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
{preview && preview.summary.error > 0 && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
loading={reportDownloading}
|
||||||
|
onClick={() => {
|
||||||
|
setReportDownloading(true);
|
||||||
|
void downloadErrorReport(run?.id ?? '', activeStepKey ?? undefined)
|
||||||
|
.then(() => message.success('错误报告已下载'))
|
||||||
|
.catch((error: unknown) =>
|
||||||
|
message.error(error instanceof Error ? error.message : '下载失败'),
|
||||||
|
)
|
||||||
|
.finally(() => setReportDownloading(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
下载错误报告
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="small" onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Steps
|
||||||
|
size="small"
|
||||||
|
items={stageItems}
|
||||||
|
onChange={(index) => {
|
||||||
|
const step = (run?.steps ?? []).filter((s) => s.status !== 'skipped')[index];
|
||||||
|
if (step) setActiveStepKey(step.stepKey);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loadingRun ? (
|
||||||
|
<Flex justify="center" style={{ padding: 32 }}>
|
||||||
|
<Spin description="正在加载导入任务..." />
|
||||||
|
</Flex>
|
||||||
|
) : allCommitted ? (
|
||||||
|
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
||||||
|
<Alert type="success" showIcon message="全部阶段已提交完成" />
|
||||||
|
<Descriptions
|
||||||
|
bordered
|
||||||
|
size="small"
|
||||||
|
column={2}
|
||||||
|
items={(run?.steps ?? [])
|
||||||
|
.filter((step) => step.status !== 'skipped')
|
||||||
|
.map((step) => ({
|
||||||
|
key: step.stepKey,
|
||||||
|
label: step.label,
|
||||||
|
children: step.summary
|
||||||
|
? `新建 ${step.summary.create} / 更新 ${step.summary.update} / 跳过 ${step.summary.skip} / 失败 ${step.summary.error}`
|
||||||
|
: '—',
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<Button type="primary" onClick={onClose}>
|
||||||
|
完成
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : activeStep ? (
|
||||||
|
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
||||||
|
{receipt && (
|
||||||
|
<Alert
|
||||||
|
type={receipt.status === 'committed' ? 'success' : 'warning'}
|
||||||
|
showIcon
|
||||||
|
title={receipt.message}
|
||||||
|
closable
|
||||||
|
onClose={() => setReceipt(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeStep.status === 'committed' ? (
|
||||||
|
<Alert
|
||||||
|
type="success"
|
||||||
|
showIcon
|
||||||
|
title={`「${activeStep.label}」已提交`}
|
||||||
|
description={
|
||||||
|
activeStep.summary
|
||||||
|
? `新建 ${activeStep.summary.create} / 更新 ${activeStep.summary.update} / 跳过 ${activeStep.summary.skip} / 失败 ${activeStep.summary.error}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : preview ? (
|
||||||
|
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
||||||
|
<Flex wrap gap={12} align="center">
|
||||||
|
<Space size={4}>
|
||||||
|
<Tag color="blue">共 {preview.summary.total} 行</Tag>
|
||||||
|
<Tag color="green">有效 {preview.summary.valid}</Tag>
|
||||||
|
<Tag color="red">错误 {preview.summary.error}</Tag>
|
||||||
|
<Tag color="blue">新建 {preview.summary.create}</Tag>
|
||||||
|
<Tag color="orange">更新 {preview.summary.update}</Tag>
|
||||||
|
</Space>
|
||||||
|
<Checkbox
|
||||||
|
checked={onlyErrors}
|
||||||
|
onChange={(e) => setOnlyErrors(e.target.checked)}
|
||||||
|
>
|
||||||
|
只看错误行
|
||||||
|
</Checkbox>
|
||||||
|
</Flex>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={previewRows}
|
||||||
|
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||||
|
scroll={{ x: 900 }}
|
||||||
|
/>
|
||||||
|
<Flex justify="end" gap={8}>
|
||||||
|
<Upload
|
||||||
|
accept=".xlsx,.csv"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={(file) => {
|
||||||
|
void handleReupload(file);
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button icon={<ReloadOutlined />} loading={uploading}>
|
||||||
|
重新上传并保留映射
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<StepForwardOutlined />}
|
||||||
|
loading={commitLoading}
|
||||||
|
onClick={() => void handleCommit()}
|
||||||
|
>
|
||||||
|
确认提交本阶段
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
title={`配置「${activeStep.label}」阶段`}
|
||||||
|
description="选择该阶段使用的工作表,并确认列映射;系统会按“学号/手机号/宿舍号”自动区分新建或更新。"
|
||||||
|
/>
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<Typography.Text style={{ width: 120 }}>工作表</Typography.Text>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
style={{ minWidth: 320, flex: 1 }}
|
||||||
|
placeholder="选择该阶段的工作表"
|
||||||
|
value={sheetSelection[activeStepKey ?? ''] ?? []}
|
||||||
|
options={sheetOptions.map((name) => ({ value: name, label: name }))}
|
||||||
|
onChange={(values: string[]) =>
|
||||||
|
setSheetSelection((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[activeStepKey ?? '']: values,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
{STEP_FIELDS[activeStep.stepKey].map((field) => (
|
||||||
|
<Flex key={field.key} align="center" gap={8}>
|
||||||
|
<Typography.Text style={{ width: 120 }}>
|
||||||
|
{field.label}
|
||||||
|
{field.required ? <span style={{ color: '#ff4d4f' }}> *</span> : null}
|
||||||
|
{field.identity ? <Tag style={{ marginLeft: 4 }}>匹配键</Tag> : null}
|
||||||
|
</Typography.Text>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
style={{ minWidth: 320, flex: 1 }}
|
||||||
|
placeholder="选择对应列(留空则自动识别)"
|
||||||
|
value={mappingDraft[activeStepKey ?? '']?.[field.key]}
|
||||||
|
options={headerOptions.map((header) => ({ value: header, label: header }))}
|
||||||
|
onChange={(value?: string) =>
|
||||||
|
setMappingDraft((prev) => {
|
||||||
|
const current = { ...prev[activeStepKey ?? ''] };
|
||||||
|
if (value) current[field.key] = value;
|
||||||
|
else delete current[field.key];
|
||||||
|
return { ...prev, [activeStepKey ?? '']: current };
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
<Flex justify="end">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={previewLoading}
|
||||||
|
onClick={() => void handlePreview()}
|
||||||
|
>
|
||||||
|
开始校验预览
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Alert type="warning" showIcon message="当前没有可处理的阶段" />
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
122
apps/admin/src/components/ImportWizard/types.ts
Normal file
122
apps/admin/src/components/ImportWizard/types.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
export type ImportStepKey = 'students' | 'rooms' | 'checkins' | 'transfers';
|
||||||
|
|
||||||
|
export interface ImportSheetMeta {
|
||||||
|
name: string;
|
||||||
|
headers: string[];
|
||||||
|
rowCount: number;
|
||||||
|
suggestedStepKey: ImportStepKey | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportStepSummary {
|
||||||
|
total: number;
|
||||||
|
valid: number;
|
||||||
|
error: number;
|
||||||
|
create: number;
|
||||||
|
update: number;
|
||||||
|
skip: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportStepDetail {
|
||||||
|
id: number;
|
||||||
|
stepKey: ImportStepKey;
|
||||||
|
label: string;
|
||||||
|
sheets: string[];
|
||||||
|
status: 'pending' | 'ready' | 'committing' | 'committed' | 'failed' | 'skipped';
|
||||||
|
mapping: Record<string, string>;
|
||||||
|
summary: ImportStepSummary | null;
|
||||||
|
committedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportRunDetail {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
source: 'ai' | 'manual';
|
||||||
|
status: 'preparing' | 'ready' | 'committing' | 'committed' | 'failed' | 'expired';
|
||||||
|
currentStepKey: ImportStepKey | null;
|
||||||
|
createdAt: string;
|
||||||
|
sheets: ImportSheetMeta[];
|
||||||
|
steps: ImportStepDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportStageRequest {
|
||||||
|
stepKey: ImportStepKey;
|
||||||
|
sheet?: string;
|
||||||
|
headerRow?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportPreviewRow {
|
||||||
|
id: number;
|
||||||
|
rowNumber: number;
|
||||||
|
sheetName: string;
|
||||||
|
raw: Record<string, string | number | boolean | null>;
|
||||||
|
fields: Record<string, string | number | boolean | null>;
|
||||||
|
action: 'create' | 'update' | 'skip' | null;
|
||||||
|
status: 'pending' | 'valid' | 'error' | 'committed' | 'skipped';
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportPreviewResult {
|
||||||
|
stepKey: ImportStepKey;
|
||||||
|
sheetNames: string[];
|
||||||
|
headers: string[];
|
||||||
|
mapping: Record<string, string>;
|
||||||
|
rows: ImportPreviewRow[];
|
||||||
|
summary: ImportStepSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportReceipt {
|
||||||
|
runId: string;
|
||||||
|
stepKey: ImportStepKey;
|
||||||
|
status: 'committed' | 'already_committed' | 'conflict';
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
total: number;
|
||||||
|
nextStepKey: ImportStepKey | null;
|
||||||
|
runStatus: 'preparing' | 'ready' | 'committing' | 'committed' | 'failed' | 'expired';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STEP_FIELDS: Record<
|
||||||
|
ImportStepKey,
|
||||||
|
Array<{ key: string; label: string; required?: boolean; identity?: boolean }>
|
||||||
|
> = {
|
||||||
|
students: [
|
||||||
|
{ key: 'name', label: '姓名', required: true },
|
||||||
|
{ key: 'studentNo', label: '学号', identity: true },
|
||||||
|
{ key: 'phone', label: '手机号', identity: true },
|
||||||
|
{ key: 'gender', label: '性别' },
|
||||||
|
{ key: 'idNumber', label: '身份证号' },
|
||||||
|
{ key: 'ethnicity', label: '民族' },
|
||||||
|
{ key: 'emergencyContact', label: '紧急联系人' },
|
||||||
|
{ key: 'emergencyPhone', label: '紧急联系电话' },
|
||||||
|
{ key: 'organization', label: '校区' },
|
||||||
|
{ key: 'status', label: '状态' },
|
||||||
|
],
|
||||||
|
rooms: [
|
||||||
|
{ key: 'roomNumber', label: '宿舍号', required: true },
|
||||||
|
{ key: 'building', label: '楼栋' },
|
||||||
|
{ key: 'floor', label: '楼层' },
|
||||||
|
{ key: 'capacity', label: '容量', required: true },
|
||||||
|
{ key: 'roomType', label: '房型' },
|
||||||
|
{ key: 'rentalCategory', label: '租期类型' },
|
||||||
|
{ key: 'monthlyRate', label: '月租' },
|
||||||
|
],
|
||||||
|
checkins: [
|
||||||
|
{ key: 'name', label: '姓名' },
|
||||||
|
{ key: 'studentNo', label: '学号', identity: true },
|
||||||
|
{ key: 'phone', label: '手机号', identity: true },
|
||||||
|
{ key: 'roomNumber', label: '宿舍号', required: true },
|
||||||
|
{ key: 'checkInDate', label: '入住日期', required: true },
|
||||||
|
{ key: 'stayType', label: '住宿类型' },
|
||||||
|
],
|
||||||
|
transfers: [
|
||||||
|
{ key: 'studentNo', label: '学号', identity: true },
|
||||||
|
{ key: 'phone', label: '手机号', identity: true },
|
||||||
|
{ key: 'oldRoom', label: '原宿舍', required: true },
|
||||||
|
{ key: 'newRoom', label: '新宿舍', required: true },
|
||||||
|
{ key: 'transferDate', label: '换宿日期', required: true },
|
||||||
|
{ key: 'reason', label: '原因/备注' },
|
||||||
|
],
|
||||||
|
};
|
||||||
46
apps/server/src/imports/entities/import-row.entity.ts
Normal file
46
apps/server/src/imports/entities/import-row.entity.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
import type { ImportRowAction, ImportRowStatus } from '../imports.types';
|
||||||
|
|
||||||
|
@Entity('import_rows')
|
||||||
|
@Index('idx_import_rows_step', ['stepId'])
|
||||||
|
@Index('idx_import_rows_run_status', ['runId', 'status'])
|
||||||
|
export class ImportRow {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column({ name: 'run_id', type: 'varchar', length: 36 })
|
||||||
|
runId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'step_id', type: 'integer' })
|
||||||
|
stepId: number;
|
||||||
|
|
||||||
|
@Column({ name: 'sheet_name', type: 'varchar', length: 200 })
|
||||||
|
sheetName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'row_number', type: 'integer' })
|
||||||
|
rowNumber: number;
|
||||||
|
|
||||||
|
@Column({ name: 'raw_json', type: 'text' })
|
||||||
|
rawJson: string;
|
||||||
|
|
||||||
|
@Column({ name: 'normalized_json', type: 'text', nullable: true })
|
||||||
|
normalizedJson: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'match_key', type: 'varchar', length: 200, nullable: true })
|
||||||
|
matchKey: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'action', type: 'varchar', length: 10, nullable: true })
|
||||||
|
action: ImportRowAction | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'pending' })
|
||||||
|
status: ImportRowStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'errors_json', type: 'text', nullable: true })
|
||||||
|
errorsJson: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'target_id', type: 'integer', nullable: true })
|
||||||
|
targetId: number | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
40
apps/server/src/imports/entities/import-run.entity.ts
Normal file
40
apps/server/src/imports/entities/import-run.entity.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
import type { ImportRunSource, ImportRunStatus, ImportStepKey } from '../imports.types';
|
||||||
|
|
||||||
|
@Entity('import_runs')
|
||||||
|
@Index('idx_import_runs_user_created', ['userId', 'createdAt'])
|
||||||
|
export class ImportRun {
|
||||||
|
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_id', type: 'integer' })
|
||||||
|
userId: number;
|
||||||
|
|
||||||
|
@Column({ name: 'conversation_id', type: 'integer', nullable: true })
|
||||||
|
conversationId: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 10, default: 'manual' })
|
||||||
|
source: ImportRunSource;
|
||||||
|
|
||||||
|
@Column({ name: 'file_name', type: 'varchar', length: 255 })
|
||||||
|
fileName: string;
|
||||||
|
|
||||||
|
/** Serialized sheet data — parsed rows are kept here for v1. */
|
||||||
|
@Column({ name: 'sheets_json', type: 'text' })
|
||||||
|
sheetsJson: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'preparing' })
|
||||||
|
status: ImportRunStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'current_step_key', type: 'varchar', length: 20, nullable: true })
|
||||||
|
currentStepKey: ImportStepKey | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
41
apps/server/src/imports/entities/import-step.entity.ts
Normal file
41
apps/server/src/imports/entities/import-step.entity.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import type { ImportStepKey, ImportStepStatus } from '../imports.types';
|
||||||
|
|
||||||
|
@Entity('import_steps')
|
||||||
|
@Index('idx_import_steps_run_key', ['runId', 'stepKey'])
|
||||||
|
export class ImportStep {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column({ name: 'run_id', type: 'varchar', length: 36 })
|
||||||
|
runId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'step_key', type: 'varchar', length: 20 })
|
||||||
|
stepKey: ImportStepKey;
|
||||||
|
|
||||||
|
/** Serialized string[] of sheet names assigned to this stage. */
|
||||||
|
@Column({ name: 'sheets_json', type: 'text' })
|
||||||
|
sheetsJson: string;
|
||||||
|
|
||||||
|
@Column({ name: 'mapping_json', type: 'text', nullable: true })
|
||||||
|
mappingJson: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'pending' })
|
||||||
|
status: ImportStepStatus;
|
||||||
|
|
||||||
|
/** Serialized StepPreviewSummary of the last commit. */
|
||||||
|
@Column({ name: 'summary_json', type: 'text', nullable: true })
|
||||||
|
summaryJson: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'committed_at', type: 'datetime', nullable: true })
|
||||||
|
committedAt: Date | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
47
apps/server/src/imports/imports.access.ts
Normal file
47
apps/server/src/imports/imports.access.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ImportRun } from './entities/import-run.entity';
|
||||||
|
import { ImportStep } from './entities/import-step.entity';
|
||||||
|
import { IMPORT_STEP_LABELS } from './imports.types';
|
||||||
|
import type { ImportStepKey } from './imports.types';
|
||||||
|
|
||||||
|
export interface ImportPrincipal {
|
||||||
|
id: number;
|
||||||
|
permissions: string[];
|
||||||
|
isSuperAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_PERMISSIONS: Record<ImportStepKey, string[]> = {
|
||||||
|
students: ['student:import'],
|
||||||
|
rooms: ['room:create', 'room:edit'],
|
||||||
|
checkins: ['occupancy:checkin'],
|
||||||
|
transfers: ['occupancy:transfer'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function findOwnedRun(
|
||||||
|
runs: Repository<ImportRun>,
|
||||||
|
userId: number,
|
||||||
|
runId: string,
|
||||||
|
): Promise<ImportRun> {
|
||||||
|
const run = await runs.findOne({ where: { id: runId, userId } });
|
||||||
|
if (!run) throw new NotFoundException('导入任务不存在');
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findStep(
|
||||||
|
steps: Repository<ImportStep>,
|
||||||
|
runId: string,
|
||||||
|
stepKey: ImportStepKey,
|
||||||
|
): Promise<ImportStep | null> {
|
||||||
|
return steps.findOne({ where: { runId, stepKey } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertStepPermission(principal: ImportPrincipal, stepKey: ImportStepKey): void {
|
||||||
|
if (principal.isSuperAdmin) return;
|
||||||
|
const required = STEP_PERMISSIONS[stepKey];
|
||||||
|
if (!required.some((code) => principal.permissions.includes(code))) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`权限不足:提交「${IMPORT_STEP_LABELS[stepKey]}」需要 ${required.join(' 或 ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
247
apps/server/src/imports/imports.commit.service.ts
Normal file
247
apps/server/src/imports/imports.commit.service.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, In, Repository } from 'typeorm';
|
||||||
|
import { ImportRun } from './entities/import-run.entity';
|
||||||
|
import { ImportStep } from './entities/import-step.entity';
|
||||||
|
import { ImportRow } from './entities/import-row.entity';
|
||||||
|
import { IMPORT_ACTION_LABELS, IMPORT_STEP_LABELS, IMPORT_STEP_ORDER } from './imports.types';
|
||||||
|
import type {
|
||||||
|
CellValue,
|
||||||
|
ImportRowAction,
|
||||||
|
ImportRowDecision,
|
||||||
|
ImportStepKey,
|
||||||
|
StepCommitReceipt,
|
||||||
|
StepPreviewSummary,
|
||||||
|
} from './imports.types';
|
||||||
|
import { csvCell, parseJson, safeError } from './imports.helpers';
|
||||||
|
import { writeRow } from './imports.rows';
|
||||||
|
import { assertStepPermission, findOwnedRun, findStep } from './imports.access';
|
||||||
|
import type { ImportPrincipal } from './imports.access';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImportCommitService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ImportRun)
|
||||||
|
private readonly runs: Repository<ImportRun>,
|
||||||
|
@InjectRepository(ImportStep)
|
||||||
|
private readonly steps: Repository<ImportStep>,
|
||||||
|
@InjectRepository(ImportRow)
|
||||||
|
private readonly rows: Repository<ImportRow>,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async commitStep(
|
||||||
|
principal: ImportPrincipal,
|
||||||
|
runId: string,
|
||||||
|
stepKey: ImportStepKey,
|
||||||
|
decisions: ImportRowDecision[],
|
||||||
|
): Promise<StepCommitReceipt> {
|
||||||
|
const run = await findOwnedRun(this.runs, principal.id, runId);
|
||||||
|
if (run.status === 'committed') {
|
||||||
|
const receipt = await this.existingReceipt(run, stepKey);
|
||||||
|
return { ...receipt, status: 'already_committed' };
|
||||||
|
}
|
||||||
|
if (run.currentStepKey !== stepKey) {
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
stepKey,
|
||||||
|
status: 'conflict',
|
||||||
|
created: 0,
|
||||||
|
updated: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
total: 0,
|
||||||
|
nextStepKey: run.currentStepKey,
|
||||||
|
runStatus: run.status,
|
||||||
|
message: `请先完成「${run.currentStepKey ? IMPORT_STEP_LABELS[run.currentStepKey] : ''}」阶段`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const step = await findStep(this.steps, runId, stepKey);
|
||||||
|
if (!step || step.status === 'skipped') {
|
||||||
|
throw new BadRequestException(`阶段「${IMPORT_STEP_LABELS[stepKey]}」没有分配工作表`);
|
||||||
|
}
|
||||||
|
if (step.status === 'committed') {
|
||||||
|
const receipt = await this.existingReceipt(run, stepKey);
|
||||||
|
return { ...receipt, status: 'already_committed' };
|
||||||
|
}
|
||||||
|
if (step.status !== 'ready') {
|
||||||
|
throw new BadRequestException(`阶段「${IMPORT_STEP_LABELS[stepKey]}」尚未预览,请先预览确认`);
|
||||||
|
}
|
||||||
|
assertStepPermission(principal, stepKey);
|
||||||
|
|
||||||
|
const pendingRows = await this.rows.find({ where: { stepId: step.id, status: 'valid' } });
|
||||||
|
const decisionMap = new Map<number, ImportRowAction>();
|
||||||
|
for (const decision of decisions ?? []) {
|
||||||
|
if (
|
||||||
|
Number.isInteger(decision.rowId) &&
|
||||||
|
(decision.action === 'create' || decision.action === 'update' || decision.action === 'skip')
|
||||||
|
) {
|
||||||
|
decisionMap.set(decision.rowId, decision.action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pendingRows.length === 0) {
|
||||||
|
throw new BadRequestException('没有可提交的有效行,请检查预览结果');
|
||||||
|
}
|
||||||
|
const rowById = new Map(pendingRows.map((row) => [row.id, row]));
|
||||||
|
for (const [rowId, action] of decisionMap) {
|
||||||
|
const row = rowById.get(rowId);
|
||||||
|
if (!row) continue;
|
||||||
|
if (action === 'skip') continue;
|
||||||
|
if (!row.action) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`第 ${row.rowNumber} 行(${row.sheetName})没有预览判定,只能选择「跳过」`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (action !== row.action) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`第 ${row.rowNumber} 行(${row.sheetName})预览判定为「${IMPORT_ACTION_LABELS[row.action]}」,不能改为「${IMPORT_ACTION_LABELS[action]}」`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run.status = 'committing';
|
||||||
|
step.status = 'committing';
|
||||||
|
await this.runs.save(run);
|
||||||
|
await this.steps.save(step);
|
||||||
|
|
||||||
|
const counts = { created: 0, updated: 0, skipped: 0, failed: 0 };
|
||||||
|
try {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
for (const row of pendingRows) {
|
||||||
|
const action = decisionMap.get(row.id) ?? row.action ?? 'create';
|
||||||
|
if (action === 'skip') {
|
||||||
|
row.status = 'skipped';
|
||||||
|
row.action = 'skip';
|
||||||
|
counts.skipped += 1;
|
||||||
|
await manager.save(ImportRow, row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const fields = parseJson<Record<string, CellValue>>(row.normalizedJson) ?? {};
|
||||||
|
const targetId = await writeRow(manager, stepKey, action, fields, row.targetId);
|
||||||
|
row.status = 'committed';
|
||||||
|
row.action = action;
|
||||||
|
row.targetId = targetId ?? row.targetId;
|
||||||
|
if (action === 'create') counts.created += 1;
|
||||||
|
else counts.updated += 1;
|
||||||
|
} catch (error) {
|
||||||
|
row.status = 'error';
|
||||||
|
row.errorsJson = JSON.stringify([`写入失败:${safeError(error)}`]);
|
||||||
|
counts.failed += 1;
|
||||||
|
}
|
||||||
|
await manager.save(ImportRow, row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
run.status = 'failed';
|
||||||
|
run.error = safeError(error).slice(0, 500);
|
||||||
|
await this.runs.save(run);
|
||||||
|
throw new ConflictException(`提交失败:${safeError(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary: StepPreviewSummary = {
|
||||||
|
total: pendingRows.length,
|
||||||
|
valid: counts.created + counts.updated + counts.skipped,
|
||||||
|
error: counts.failed,
|
||||||
|
create: counts.created,
|
||||||
|
update: counts.updated,
|
||||||
|
skip: counts.skipped,
|
||||||
|
};
|
||||||
|
step.status = 'committed';
|
||||||
|
step.committedAt = new Date();
|
||||||
|
step.summaryJson = JSON.stringify(summary);
|
||||||
|
await this.steps.save(step);
|
||||||
|
|
||||||
|
const nextStepKey = await this.nextStepKey(runId, stepKey);
|
||||||
|
run.currentStepKey = nextStepKey;
|
||||||
|
run.status = nextStepKey ? 'ready' : 'committed';
|
||||||
|
await this.runs.save(run);
|
||||||
|
|
||||||
|
const message =
|
||||||
|
`阶段「${IMPORT_STEP_LABELS[stepKey]}」提交完成:新建 ${counts.created}、更新 ${counts.updated}、跳过 ${counts.skipped}、失败 ${counts.failed};` +
|
||||||
|
(nextStepKey ? `下一步:${IMPORT_STEP_LABELS[nextStepKey]}` : '全部阶段已完成');
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
stepKey,
|
||||||
|
status: 'committed',
|
||||||
|
created: counts.created,
|
||||||
|
updated: counts.updated,
|
||||||
|
skipped: counts.skipped,
|
||||||
|
failed: counts.failed,
|
||||||
|
total: pendingRows.length,
|
||||||
|
nextStepKey,
|
||||||
|
runStatus: run.status,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async errorReport(
|
||||||
|
userId: number,
|
||||||
|
runId: string,
|
||||||
|
stepKey?: ImportStepKey,
|
||||||
|
): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
|
const run = await findOwnedRun(this.runs, userId, runId);
|
||||||
|
const stepRecords = await this.steps.find({ where: { runId }, order: { id: 'ASC' } });
|
||||||
|
const stepIds = stepKey
|
||||||
|
? stepRecords.filter((s) => s.stepKey === stepKey).map((s) => s.id)
|
||||||
|
: stepRecords.map((s) => s.id);
|
||||||
|
if (stepIds.length === 0) return { filename: '', buffer: Buffer.from('') };
|
||||||
|
const rows = await this.rows.find({
|
||||||
|
where: { stepId: In(stepIds), status: 'error' },
|
||||||
|
order: { id: 'ASC' },
|
||||||
|
});
|
||||||
|
const lines: string[] = ['工作表,行号,原始数据,错误信息'];
|
||||||
|
for (const row of rows) {
|
||||||
|
const raw = parseJson<Record<string, CellValue>>(row.rawJson) ?? {};
|
||||||
|
const errors = parseJson<string[]>(row.errorsJson) ?? [];
|
||||||
|
lines.push(
|
||||||
|
[
|
||||||
|
csvCell(row.sheetName),
|
||||||
|
String(row.rowNumber),
|
||||||
|
csvCell(JSON.stringify(raw)),
|
||||||
|
csvCell(errors.join(';')),
|
||||||
|
].join(','),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
filename: `导入错误报告-${run.fileName.replace(/\.(xlsx|csv)$/i, '')}.csv`,
|
||||||
|
buffer: Buffer.from(`\uFEFF${lines.join('\n')}`, 'utf8'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async nextStepKey(
|
||||||
|
runId: string,
|
||||||
|
currentKey: ImportStepKey,
|
||||||
|
): Promise<ImportStepKey | null> {
|
||||||
|
const stepRecords = await this.steps.find({ where: { runId } });
|
||||||
|
const currentIndex = IMPORT_STEP_ORDER.indexOf(currentKey);
|
||||||
|
for (let i = currentIndex + 1; i < IMPORT_STEP_ORDER.length; i += 1) {
|
||||||
|
const candidate = IMPORT_STEP_ORDER[i];
|
||||||
|
const step = stepRecords.find((s) => s.stepKey === candidate);
|
||||||
|
if (step && step.status !== 'skipped' && step.status !== 'committed') {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async existingReceipt(
|
||||||
|
run: ImportRun,
|
||||||
|
stepKey: ImportStepKey,
|
||||||
|
): Promise<Omit<StepCommitReceipt, 'status'>> {
|
||||||
|
const step = await findStep(this.steps, run.id, stepKey);
|
||||||
|
const summary = parseJson<StepPreviewSummary>(step?.summaryJson);
|
||||||
|
return {
|
||||||
|
runId: run.id,
|
||||||
|
stepKey,
|
||||||
|
created: summary?.create ?? 0,
|
||||||
|
updated: summary?.update ?? 0,
|
||||||
|
skipped: summary?.skip ?? 0,
|
||||||
|
failed: summary?.error ?? 0,
|
||||||
|
total: summary?.total ?? 0,
|
||||||
|
nextStepKey: run.currentStepKey,
|
||||||
|
runStatus: run.status,
|
||||||
|
message: `阶段「${IMPORT_STEP_LABELS[stepKey]}」此前已提交`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
167
apps/server/src/imports/imports.controller.ts
Normal file
167
apps/server/src/imports/imports.controller.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
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 {
|
||||||
|
IMPORT_STEP_KEYS,
|
||||||
|
type ImportRowDecision,
|
||||||
|
type ImportStageRequest,
|
||||||
|
type ImportStepKey,
|
||||||
|
} from './imports.types';
|
||||||
|
import { ImportsService } from './imports.service';
|
||||||
|
|
||||||
|
interface AuthenticatedRequest extends Request {
|
||||||
|
user: AuthenticatedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMPORT_GATE_PERMISSIONS = [
|
||||||
|
'student:import',
|
||||||
|
'room:create',
|
||||||
|
'room:edit',
|
||||||
|
'occupancy:checkin',
|
||||||
|
'occupancy:transfer',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
@Controller('imports')
|
||||||
|
@RequirePermission(...IMPORT_GATE_PERMISSIONS)
|
||||||
|
export class ImportsController {
|
||||||
|
constructor(private readonly importsService: ImportsService) {}
|
||||||
|
|
||||||
|
@Post('runs')
|
||||||
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))
|
||||||
|
async create(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@UploadedFile() file: Express.Multer.File | undefined,
|
||||||
|
@Body() body: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
if (!file) throw new BadRequestException('缺少上传文件');
|
||||||
|
let stages: ImportStageRequest[] | undefined;
|
||||||
|
if (typeof body.stages === 'string' && body.stages.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body.stages) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) throw new Error('not array');
|
||||||
|
stages = parsed as ImportStageRequest[];
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('stages 参数格式错误');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mapping: Partial<Record<ImportStepKey, Record<string, string>>> | undefined;
|
||||||
|
if (typeof body.mapping === 'string' && body.mapping.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body.mapping) as unknown;
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
mapping = parsed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('mapping 参数格式错误');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const conversationId =
|
||||||
|
body.conversationId !== undefined ? Number(body.conversationId) : undefined;
|
||||||
|
const source = body.source === 'ai' ? 'ai' : 'manual';
|
||||||
|
const data = await this.importsService.createRun(
|
||||||
|
this.principal(req.user),
|
||||||
|
source,
|
||||||
|
{
|
||||||
|
originalName: file.originalname,
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
size: file.size,
|
||||||
|
buffer: file.buffer,
|
||||||
|
},
|
||||||
|
Number.isFinite(conversationId) ? conversationId : undefined,
|
||||||
|
stages,
|
||||||
|
mapping,
|
||||||
|
);
|
||||||
|
return { success: true, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('runs/:id')
|
||||||
|
async get(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
return { success: true, data: await this.importsService.getRun(req.user.id, id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('runs/:id/steps/:stepKey/preview')
|
||||||
|
async preview(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('stepKey') stepKey: string,
|
||||||
|
@Body() body: { sheets?: string[]; mapping?: Record<string, string> },
|
||||||
|
) {
|
||||||
|
const data = await this.importsService.previewStep(
|
||||||
|
this.principal(req.user),
|
||||||
|
id,
|
||||||
|
this.parseStepKey(stepKey),
|
||||||
|
body ?? {},
|
||||||
|
);
|
||||||
|
return { success: true, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('runs/:id/steps/:stepKey/commit')
|
||||||
|
async commit(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('stepKey') stepKey: string,
|
||||||
|
@Body() body: { decisions?: ImportRowDecision[] },
|
||||||
|
) {
|
||||||
|
const data = await this.importsService.commitStep(
|
||||||
|
this.principal(req.user),
|
||||||
|
id,
|
||||||
|
this.parseStepKey(stepKey),
|
||||||
|
Array.isArray(body?.decisions) ? body.decisions : [],
|
||||||
|
);
|
||||||
|
return { success: true, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('runs/:id/report')
|
||||||
|
async report(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Res() res: Response,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('stepKey') stepKey?: string,
|
||||||
|
) {
|
||||||
|
const { filename, buffer } = await this.importsService.errorReport(
|
||||||
|
req.user.id,
|
||||||
|
id,
|
||||||
|
stepKey ? this.parseStepKey(stepKey) : undefined,
|
||||||
|
);
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename*=UTF-8''${encodeURIComponent(filename || 'import-errors.csv')}`,
|
||||||
|
);
|
||||||
|
res.setHeader('Content-Length', String(buffer.length));
|
||||||
|
res.send(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseStepKey(value: string): ImportStepKey {
|
||||||
|
if ((IMPORT_STEP_KEYS as readonly string[]).includes(value)) {
|
||||||
|
return value as ImportStepKey;
|
||||||
|
}
|
||||||
|
throw new BadRequestException(`未知导入阶段:${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private principal(user: AuthenticatedUser): {
|
||||||
|
id: number;
|
||||||
|
permissions: string[];
|
||||||
|
isSuperAdmin: boolean;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
permissions: user.permissions,
|
||||||
|
isSuperAdmin: user.isSuperAdmin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
92
apps/server/src/imports/imports.helpers.ts
Normal file
92
apps/server/src/imports/imports.helpers.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as ExcelJS from 'exceljs';
|
||||||
|
import type { CellValue } from './imports.types';
|
||||||
|
|
||||||
|
export function parseJson<T>(raw: string | null | undefined): T | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function textValue(value: CellValue): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHeader(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s()()]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function headerMatches(header: string, alias: string): boolean {
|
||||||
|
const h = normalizeHeader(header);
|
||||||
|
const a = normalizeHeader(alias);
|
||||||
|
if (!h || !a) return false;
|
||||||
|
return h === a || h.includes(a) || a.includes(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cellValue(cell: ExcelJS.Cell | undefined): CellValue {
|
||||||
|
if (!cell) return null;
|
||||||
|
const value = cell.value;
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value instanceof Date) return value;
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const candidate = value as { text?: unknown; result?: unknown };
|
||||||
|
if (typeof candidate.text === 'string') return candidate.text;
|
||||||
|
if (typeof candidate.result === 'string' || typeof candidate.result === 'number') {
|
||||||
|
return candidate.result;
|
||||||
|
}
|
||||||
|
if (candidate.result instanceof Date) return candidate.result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDateValue(value: CellValue): string | null {
|
||||||
|
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
const raw = textValue(value);
|
||||||
|
if (!raw) return null;
|
||||||
|
const match = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/.exec(raw);
|
||||||
|
if (!match) return null;
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]);
|
||||||
|
const day = Number(match[3]);
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
if (
|
||||||
|
date.getUTCFullYear() !== year ||
|
||||||
|
date.getUTCMonth() !== month - 1 ||
|
||||||
|
date.getUTCDate() !== day
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeError(error: unknown): string {
|
||||||
|
if (error instanceof Error) return error.message.slice(0, 120);
|
||||||
|
return '未知错误';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyString(target: object, key: string, value: CellValue): void {
|
||||||
|
const text = textValue(value);
|
||||||
|
if (text) (target as Record<string, unknown>)[key] = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalNumber(value: CellValue): number | null {
|
||||||
|
const text = textValue(value);
|
||||||
|
if (!text) return null;
|
||||||
|
const parsed = Number(text);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function csvCell(value: string): string {
|
||||||
|
return `"${value.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
118
apps/server/src/imports/imports.lookups.ts
Normal file
118
apps/server/src/imports/imports.lookups.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { DataSource, In } from 'typeorm';
|
||||||
|
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 { textValue } from './imports.helpers';
|
||||||
|
import type { CellValue, ColumnMapping, ImportStepKey } from './imports.types';
|
||||||
|
|
||||||
|
export interface ImportLookups {
|
||||||
|
studentsByNo: Map<string, Student>;
|
||||||
|
studentsByPhone: Map<string, Student>;
|
||||||
|
roomsByNumber: Map<string, Room>;
|
||||||
|
activeOccupancies: Map<number, Occupancy[]>;
|
||||||
|
organizations: Map<string, Organization>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildLookups(
|
||||||
|
dataSource: DataSource,
|
||||||
|
stepKey: ImportStepKey,
|
||||||
|
headers: string[],
|
||||||
|
rows: CellValue[][],
|
||||||
|
mapping: ColumnMapping,
|
||||||
|
): Promise<ImportLookups> {
|
||||||
|
const studentNos = new Set<string>();
|
||||||
|
const phones = new Set<string>();
|
||||||
|
const roomNumbers = new Set<string>();
|
||||||
|
const organizationNames = new Set<string>();
|
||||||
|
const fieldIndex: Record<string, number> = {};
|
||||||
|
for (const [field, header] of Object.entries(mapping)) {
|
||||||
|
const index = headers.indexOf(header);
|
||||||
|
if (index >= 0) fieldIndex[field] = index;
|
||||||
|
}
|
||||||
|
const valueOf = (row: CellValue[], field: string): CellValue => {
|
||||||
|
const index = fieldIndex[field];
|
||||||
|
return index === undefined ? null : (row[index] ?? null);
|
||||||
|
};
|
||||||
|
for (const row of rows) {
|
||||||
|
if (stepKey === 'students' || stepKey === 'checkins' || stepKey === 'transfers') {
|
||||||
|
const no = textValue(valueOf(row, 'studentNo'));
|
||||||
|
if (no) studentNos.add(no);
|
||||||
|
const phone = textValue(valueOf(row, 'phone'));
|
||||||
|
if (phone) phones.add(phone);
|
||||||
|
}
|
||||||
|
if (stepKey === 'rooms' || stepKey === 'checkins' || stepKey === 'transfers') {
|
||||||
|
const roomField = stepKey === 'transfers' ? 'oldRoom' : 'roomNumber';
|
||||||
|
const newRoomField = stepKey === 'transfers' ? 'newRoom' : undefined;
|
||||||
|
const roomNo = textValue(valueOf(row, roomField));
|
||||||
|
if (roomNo) roomNumbers.add(roomNo);
|
||||||
|
if (newRoomField) {
|
||||||
|
const newRoomNo = textValue(valueOf(row, newRoomField));
|
||||||
|
if (newRoomNo) roomNumbers.add(newRoomNo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stepKey === 'students') {
|
||||||
|
const org = textValue(valueOf(row, 'organization'));
|
||||||
|
if (org) organizationNames.add(org);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const students: Student[] = [];
|
||||||
|
if (studentNos.size > 0) {
|
||||||
|
students.push(
|
||||||
|
...(await dataSource.getRepository(Student).find({
|
||||||
|
where: { studentNo: In([...studentNos]) },
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (phones.size > 0) {
|
||||||
|
students.push(
|
||||||
|
...(await dataSource.getRepository(Student).find({
|
||||||
|
where: { phone: In([...phones]) },
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const studentsByNo = new Map<string, Student>();
|
||||||
|
const studentsByPhone = new Map<string, Student>();
|
||||||
|
for (const student of students) {
|
||||||
|
if (student.studentNo) studentsByNo.set(student.studentNo, student);
|
||||||
|
if (student.phone) studentsByPhone.set(student.phone, student);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rooms =
|
||||||
|
roomNumbers.size > 0
|
||||||
|
? await dataSource.getRepository(Room).find({
|
||||||
|
where: { roomNumber: In([...roomNumbers]) },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const roomsByNumber = new Map<string, Room>();
|
||||||
|
for (const room of rooms) roomsByNumber.set(room.roomNumber, room);
|
||||||
|
|
||||||
|
const organizations =
|
||||||
|
organizationNames.size > 0 ? await dataSource.getRepository(Organization).find() : [];
|
||||||
|
const organizationsByName = new Map<string, Organization>();
|
||||||
|
for (const org of organizations) organizationsByName.set(org.name, org);
|
||||||
|
|
||||||
|
const activeOccupancies = new Map<number, Occupancy[]>();
|
||||||
|
if (stepKey === 'checkins' || stepKey === 'transfers') {
|
||||||
|
const studentIds = [...new Set(students.map((s) => s.id))];
|
||||||
|
if (studentIds.length > 0) {
|
||||||
|
const occupancies = await dataSource.getRepository(Occupancy).find({
|
||||||
|
where: { studentId: In(studentIds), status: 'active' },
|
||||||
|
});
|
||||||
|
for (const occupancy of occupancies) {
|
||||||
|
const list = activeOccupancies.get(occupancy.studentId) ?? [];
|
||||||
|
list.push(occupancy);
|
||||||
|
activeOccupancies.set(occupancy.studentId, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
studentsByNo,
|
||||||
|
studentsByPhone,
|
||||||
|
roomsByNumber,
|
||||||
|
activeOccupancies,
|
||||||
|
organizations: organizationsByName,
|
||||||
|
};
|
||||||
|
}
|
||||||
107
apps/server/src/imports/imports.mapping.ts
Normal file
107
apps/server/src/imports/imports.mapping.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
IMPORT_FIELD_ALIASES,
|
||||||
|
IMPORT_STEP_IDENTITY_FIELDS,
|
||||||
|
IMPORT_STEP_LABELS,
|
||||||
|
IMPORT_STEP_ORDER,
|
||||||
|
IMPORT_STEP_REQUIRED_FIELDS,
|
||||||
|
} from './imports.types';
|
||||||
|
import type {
|
||||||
|
ColumnMapping,
|
||||||
|
ImportStageRequest,
|
||||||
|
ImportStageSuggestion,
|
||||||
|
ImportStepKey,
|
||||||
|
} from './imports.types';
|
||||||
|
import { headerMatches } from './imports.helpers';
|
||||||
|
import type { ImportSheetData } from './imports.workbook';
|
||||||
|
|
||||||
|
export function suggestMapping(headers: string[], stepKey: ImportStepKey): ColumnMapping {
|
||||||
|
const mapping: ColumnMapping = {};
|
||||||
|
for (const [field, aliases] of Object.entries(IMPORT_FIELD_ALIASES[stepKey])) {
|
||||||
|
const found = headers.find((header) => aliases.some((alias) => headerMatches(header, alias)));
|
||||||
|
if (found) mapping[field] = found;
|
||||||
|
}
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function suggestStep(headers: string[]): ImportStageSuggestion | null {
|
||||||
|
let best: ImportStageSuggestion | null = null;
|
||||||
|
for (const stepKey of IMPORT_STEP_ORDER) {
|
||||||
|
const mapping = suggestMapping(headers, stepKey);
|
||||||
|
if (Object.keys(mapping).length === 0) continue;
|
||||||
|
const identity = IMPORT_STEP_IDENTITY_FIELDS[stepKey].filter(
|
||||||
|
(field) => mapping[field],
|
||||||
|
).length;
|
||||||
|
const required = IMPORT_STEP_REQUIRED_FIELDS[stepKey].filter(
|
||||||
|
(field) => mapping[field],
|
||||||
|
).length;
|
||||||
|
const score = Object.keys(mapping).length + identity * 3 + required * 2;
|
||||||
|
if (!best || score > best.matchedFields) {
|
||||||
|
best = { stepKey, mapping, matchedFields: score };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autoAssignedSheets(
|
||||||
|
sheets: ImportSheetData[],
|
||||||
|
stepKey: ImportStepKey,
|
||||||
|
): string[] {
|
||||||
|
return sheets
|
||||||
|
.filter((sheet) => suggestStep(sheet.headers)?.stepKey === stepKey)
|
||||||
|
.map((sheet) => sheet.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAssignedSheets(
|
||||||
|
stages: ImportStageRequest[],
|
||||||
|
stepKey: ImportStepKey,
|
||||||
|
available: string[],
|
||||||
|
): string[] {
|
||||||
|
const names = stages
|
||||||
|
.filter((stage) => stage.stepKey === stepKey && stage.sheet)
|
||||||
|
.map((stage) => stage.sheet as string);
|
||||||
|
const missing = names.filter((name) => !available.includes(name));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new BadRequestException(`工作表不存在:${missing.join('、')}`);
|
||||||
|
}
|
||||||
|
return [...new Set(names)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertMapping(
|
||||||
|
stepKey: ImportStepKey,
|
||||||
|
mapping: ColumnMapping,
|
||||||
|
sheetsData: ImportSheetData[],
|
||||||
|
usedSheets: string[],
|
||||||
|
): void {
|
||||||
|
const required = IMPORT_STEP_REQUIRED_FIELDS[stepKey];
|
||||||
|
const missingRequired = required.filter((field) => !mapping[field]);
|
||||||
|
if (missingRequired.length > 0) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
name: '姓名',
|
||||||
|
roomNumber: '宿舍号',
|
||||||
|
capacity: '容量',
|
||||||
|
checkInDate: '入住日期',
|
||||||
|
oldRoom: '原宿舍',
|
||||||
|
newRoom: '新宿舍',
|
||||||
|
transferDate: '换宿日期',
|
||||||
|
};
|
||||||
|
throw new BadRequestException(
|
||||||
|
`阶段「${IMPORT_STEP_LABELS[stepKey]}」缺少必需列映射:${missingRequired
|
||||||
|
.map((field) => labels[field] ?? field)
|
||||||
|
.join('、')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (stepKey === 'students' || stepKey === 'checkins' || stepKey === 'transfers') {
|
||||||
|
const hasIdentity = IMPORT_STEP_IDENTITY_FIELDS[stepKey].some((field) => mapping[field]);
|
||||||
|
if (!hasIdentity) {
|
||||||
|
throw new BadRequestException('请至少映射“学号”或“手机号”列用于匹配学生');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const usedHeaders = new Set(
|
||||||
|
usedSheets.flatMap((name) => sheetsData.find((s) => s.name === name)?.headers ?? []),
|
||||||
|
);
|
||||||
|
const missingHeaders = Object.values(mapping).filter((header) => !usedHeaders.has(header));
|
||||||
|
if (missingHeaders.length > 0) {
|
||||||
|
throw new BadRequestException(`映射的列不存在于所选工作表:${missingHeaders.join('、')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/server/src/imports/imports.module.ts
Normal file
20
apps/server/src/imports/imports.module.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Room } from '../entities/room.entity';
|
||||||
|
import { Student } from '../entities/student.entity';
|
||||||
|
import { Occupancy } from '../entities/occupancy.entity';
|
||||||
|
import { ImportRun } from './entities/import-run.entity';
|
||||||
|
import { ImportStep } from './entities/import-step.entity';
|
||||||
|
import { ImportRow } from './entities/import-row.entity';
|
||||||
|
import { ImportsController } from './imports.controller';
|
||||||
|
import { ImportsService } from './imports.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([ImportRun, ImportStep, ImportRow, Student, Room, Occupancy]),
|
||||||
|
],
|
||||||
|
controllers: [ImportsController],
|
||||||
|
providers: [ImportsService],
|
||||||
|
exports: [ImportsService],
|
||||||
|
})
|
||||||
|
export class ImportsModule {}
|
||||||
163
apps/server/src/imports/imports.preview.service.ts
Normal file
163
apps/server/src/imports/imports.preview.service.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
import { ImportRun } from './entities/import-run.entity';
|
||||||
|
import { ImportStep } from './entities/import-step.entity';
|
||||||
|
import { ImportRow } from './entities/import-row.entity';
|
||||||
|
import { IMPORT_STEP_LABELS } from './imports.types';
|
||||||
|
import type {
|
||||||
|
CellValue,
|
||||||
|
ColumnMapping,
|
||||||
|
ImportStepKey,
|
||||||
|
StepPreviewSummary,
|
||||||
|
} from './imports.types';
|
||||||
|
import { parseJson } from './imports.helpers';
|
||||||
|
import { assertMapping, suggestMapping } from './imports.mapping';
|
||||||
|
import { buildLookups } from './imports.lookups';
|
||||||
|
import { validateRow } from './imports.rows';
|
||||||
|
import type { ImportBatchState } from './imports.rows';
|
||||||
|
import { findOwnedRun, findStep } from './imports.access';
|
||||||
|
import type { ImportPrincipal } from './imports.access';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImportPreviewService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ImportRun)
|
||||||
|
private readonly runs: Repository<ImportRun>,
|
||||||
|
@InjectRepository(ImportStep)
|
||||||
|
private readonly steps: Repository<ImportStep>,
|
||||||
|
@InjectRepository(ImportRow)
|
||||||
|
private readonly rows: Repository<ImportRow>,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async previewStep(
|
||||||
|
principal: ImportPrincipal,
|
||||||
|
runId: string,
|
||||||
|
stepKey: ImportStepKey,
|
||||||
|
body: { sheets?: string[]; mapping?: ColumnMapping },
|
||||||
|
) {
|
||||||
|
const run = await findOwnedRun(this.runs, principal.id, runId);
|
||||||
|
if (run.status === 'committed') {
|
||||||
|
throw new BadRequestException('该导入任务已完成,无需再次预览');
|
||||||
|
}
|
||||||
|
if (run.status === 'committing') {
|
||||||
|
throw new ConflictException('导入正在提交中,请稍候');
|
||||||
|
}
|
||||||
|
const step = await findStep(this.steps, runId, stepKey);
|
||||||
|
if (!step || step.status === 'skipped') {
|
||||||
|
throw new BadRequestException(`阶段「${IMPORT_STEP_LABELS[stepKey]}」没有分配工作表`);
|
||||||
|
}
|
||||||
|
if (step.status === 'committed') {
|
||||||
|
throw new BadRequestException(`阶段「${IMPORT_STEP_LABELS[stepKey]}」已提交,不能重复预览`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sheetsData =
|
||||||
|
parseJson<Array<{ name: string; headers: string[]; rows: CellValue[][] }>>(run.sheetsJson) ??
|
||||||
|
[];
|
||||||
|
const sheetNames = body.sheets?.length
|
||||||
|
? body.sheets
|
||||||
|
: (parseJson<string[]>(step.sheetsJson) ?? []);
|
||||||
|
const usedSheets = sheetNames.filter((name) => sheetsData.some((s) => s.name === name));
|
||||||
|
if (usedSheets.length === 0) {
|
||||||
|
throw new BadRequestException('指定的工作表不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping =
|
||||||
|
body.mapping && Object.keys(body.mapping).length > 0
|
||||||
|
? body.mapping
|
||||||
|
: (parseJson<ColumnMapping>(step.mappingJson) ??
|
||||||
|
suggestMapping(sheetsData[0]?.headers ?? [], stepKey));
|
||||||
|
assertMapping(stepKey, mapping, sheetsData, usedSheets);
|
||||||
|
|
||||||
|
await this.rows.delete({ stepId: step.id });
|
||||||
|
const rowEntities: ImportRow[] = [];
|
||||||
|
const summary: StepPreviewSummary = {
|
||||||
|
total: 0,
|
||||||
|
valid: 0,
|
||||||
|
error: 0,
|
||||||
|
create: 0,
|
||||||
|
update: 0,
|
||||||
|
skip: 0,
|
||||||
|
};
|
||||||
|
const batchState: ImportBatchState = {
|
||||||
|
checkinStudentIds: new Set<number>(),
|
||||||
|
transferStudentIds: new Set<number>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const sheetName of usedSheets) {
|
||||||
|
const sheet = sheetsData.find((s) => s.name === sheetName);
|
||||||
|
if (!sheet) continue;
|
||||||
|
const lookups = await buildLookups(
|
||||||
|
this.dataSource,
|
||||||
|
stepKey,
|
||||||
|
sheet.headers,
|
||||||
|
sheet.rows,
|
||||||
|
mapping,
|
||||||
|
);
|
||||||
|
for (let i = 0; i < sheet.rows.length; i += 1) {
|
||||||
|
const rawValues = sheet.rows[i];
|
||||||
|
const raw: Record<string, CellValue> = {};
|
||||||
|
sheet.headers.forEach((header, index) => {
|
||||||
|
raw[header] = rawValues[index] ?? null;
|
||||||
|
});
|
||||||
|
const fields: Record<string, CellValue> = {};
|
||||||
|
for (const [field, header] of Object.entries(mapping)) {
|
||||||
|
fields[field] = rawValues[sheet.headers.indexOf(header)] ?? null;
|
||||||
|
}
|
||||||
|
const result = validateRow(stepKey, fields, lookups, batchState);
|
||||||
|
const normalized = { ...result.normalized, ...result.resolvedIds };
|
||||||
|
summary.total += 1;
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rowEntities.push(
|
||||||
|
this.rows.create({
|
||||||
|
runId,
|
||||||
|
stepId: step.id,
|
||||||
|
sheetName,
|
||||||
|
rowNumber: i + 2,
|
||||||
|
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,
|
||||||
|
targetId: result.targetId ?? null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.rows.save(rowEntities);
|
||||||
|
step.sheetsJson = JSON.stringify(usedSheets);
|
||||||
|
step.mappingJson = JSON.stringify(mapping);
|
||||||
|
step.status = 'ready';
|
||||||
|
step.summaryJson = JSON.stringify(summary);
|
||||||
|
await this.steps.save(step);
|
||||||
|
|
||||||
|
const headers = sheetsData.find((s) => s.name === usedSheets[0])?.headers ?? [];
|
||||||
|
const rows = rowEntities.map((entity) => ({
|
||||||
|
id: entity.id,
|
||||||
|
rowNumber: entity.rowNumber,
|
||||||
|
sheetName: entity.sheetName,
|
||||||
|
raw: parseJson<Record<string, CellValue>>(entity.rawJson) ?? {},
|
||||||
|
fields: parseJson<Record<string, CellValue>>(entity.normalizedJson) ?? {},
|
||||||
|
action: entity.action,
|
||||||
|
status: entity.status,
|
||||||
|
errors: parseJson<string[]>(entity.errorsJson) ?? [],
|
||||||
|
}));
|
||||||
|
return { stepKey, sheetNames: usedSheets, headers, mapping, rows, summary };
|
||||||
|
}
|
||||||
|
}
|
||||||
341
apps/server/src/imports/imports.rows.ts
Normal file
341
apps/server/src/imports/imports.rows.ts
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import { EntityManager } from 'typeorm';
|
||||||
|
import { Room } from '../entities/room.entity';
|
||||||
|
import { Student } from '../entities/student.entity';
|
||||||
|
import { Occupancy } from '../entities/occupancy.entity';
|
||||||
|
import { applyString, optionalNumber, parseDateValue, textValue } from './imports.helpers';
|
||||||
|
import type { ImportLookups } from './imports.lookups';
|
||||||
|
import type {
|
||||||
|
CellValue,
|
||||||
|
ImportRowAction,
|
||||||
|
ImportStepKey,
|
||||||
|
} from './imports.types';
|
||||||
|
|
||||||
|
const PHONE_RE = /^1[3-9]\d{9}$/;
|
||||||
|
|
||||||
|
/** 预览批次内的动态状态,用于阻止同一文件中产生重复的在住/换宿记录。 */
|
||||||
|
export interface ImportBatchState {
|
||||||
|
checkinStudentIds: Set<number>;
|
||||||
|
transferStudentIds: Set<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidatedRow {
|
||||||
|
errors: string[];
|
||||||
|
action: ImportRowAction | null;
|
||||||
|
matchKey: string | null;
|
||||||
|
targetId: number | null;
|
||||||
|
normalized: Record<string, CellValue>;
|
||||||
|
resolvedIds: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRow(
|
||||||
|
stepKey: ImportStepKey,
|
||||||
|
fields: Record<string, CellValue>,
|
||||||
|
lookups: ImportLookups,
|
||||||
|
batchState?: ImportBatchState,
|
||||||
|
): ValidatedRow {
|
||||||
|
const errors: string[] = [];
|
||||||
|
let action: ImportRowAction | null = null;
|
||||||
|
let matchKey: string | null = null;
|
||||||
|
let targetId: number | null = null;
|
||||||
|
const normalized: Record<string, CellValue> = { ...fields };
|
||||||
|
const resolvedIds: Record<string, number> = {};
|
||||||
|
|
||||||
|
if (stepKey === 'students') {
|
||||||
|
const name = textValue(fields.name);
|
||||||
|
if (!name) errors.push('姓名不能为空');
|
||||||
|
normalized.name = name;
|
||||||
|
const phone = textValue(fields.phone);
|
||||||
|
if (phone && !PHONE_RE.test(phone)) errors.push('手机号格式不正确');
|
||||||
|
normalized.phone = phone;
|
||||||
|
const genderRaw = textValue(fields.gender);
|
||||||
|
let gender = genderRaw;
|
||||||
|
if (genderRaw === '男' || genderRaw === '男性') gender = 'male';
|
||||||
|
if (genderRaw === '女' || genderRaw === '女性') gender = 'female';
|
||||||
|
if (genderRaw && !['male', 'female', '男', '女'].includes(genderRaw)) {
|
||||||
|
errors.push('性别只能是男/女');
|
||||||
|
}
|
||||||
|
normalized.gender = gender;
|
||||||
|
const statusRaw = textValue(fields.status);
|
||||||
|
if (statusRaw && !['active', 'inactive', 'archived'].includes(statusRaw)) {
|
||||||
|
errors.push('状态只能是 active/inactive/archived');
|
||||||
|
}
|
||||||
|
normalized.status = statusRaw || 'active';
|
||||||
|
normalized.studentNo = textValue(fields.studentNo);
|
||||||
|
normalized.idNumber = textValue(fields.idNumber);
|
||||||
|
normalized.ethnicity = textValue(fields.ethnicity);
|
||||||
|
normalized.emergencyContact = textValue(fields.emergencyContact);
|
||||||
|
normalized.emergencyPhone = textValue(fields.emergencyPhone);
|
||||||
|
const orgName = textValue(fields.organization);
|
||||||
|
normalized.organization = orgName;
|
||||||
|
if (orgName) {
|
||||||
|
const org = lookups.organizations.get(orgName);
|
||||||
|
if (!org) errors.push(`未找到校区:${orgName}`);
|
||||||
|
else resolvedIds._organizationId = org.id;
|
||||||
|
}
|
||||||
|
const studentNo = textValue(fields.studentNo);
|
||||||
|
let matched = studentNo ? lookups.studentsByNo.get(studentNo) : undefined;
|
||||||
|
if (!matched && phone) matched = lookups.studentsByPhone.get(phone);
|
||||||
|
if (matched) {
|
||||||
|
action = 'update';
|
||||||
|
targetId = matched.id;
|
||||||
|
matchKey =
|
||||||
|
studentNo && lookups.studentsByNo.get(studentNo) === matched
|
||||||
|
? studentNo
|
||||||
|
: phone || studentNo || null;
|
||||||
|
} else {
|
||||||
|
action = 'create';
|
||||||
|
matchKey = studentNo || phone || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepKey === 'rooms') {
|
||||||
|
const roomNumber = textValue(fields.roomNumber);
|
||||||
|
if (!roomNumber) errors.push('宿舍号不能为空');
|
||||||
|
normalized.roomNumber = roomNumber;
|
||||||
|
normalized.building = textValue(fields.building);
|
||||||
|
normalized.roomType = textValue(fields.roomType);
|
||||||
|
const capacity = Number(fields.capacity);
|
||||||
|
if (!Number.isInteger(capacity) || capacity <= 0 || capacity > 999) {
|
||||||
|
errors.push('容量必须是 1-999 的整数');
|
||||||
|
}
|
||||||
|
normalized.capacity = capacity;
|
||||||
|
if (fields.floor !== null && fields.floor !== undefined && fields.floor !== '') {
|
||||||
|
const floor = Number(fields.floor);
|
||||||
|
if (!Number.isInteger(floor) || floor < 0) errors.push('楼层必须是大于等于 0 的整数');
|
||||||
|
normalized.floor = floor;
|
||||||
|
}
|
||||||
|
const rentalCategory = textValue(fields.rentalCategory);
|
||||||
|
if (rentalCategory && !['short', 'long'].includes(rentalCategory)) {
|
||||||
|
errors.push('租期类型只能是 short/long');
|
||||||
|
}
|
||||||
|
normalized.rentalCategory = rentalCategory || 'short';
|
||||||
|
const monthlyRate = Number(fields.monthlyRate ?? 0);
|
||||||
|
if (!Number.isFinite(monthlyRate) || monthlyRate < 0)
|
||||||
|
errors.push('月租必须是大于等于 0 的数字');
|
||||||
|
normalized.monthlyRate = monthlyRate;
|
||||||
|
const matched = roomNumber ? lookups.roomsByNumber.get(roomNumber) : undefined;
|
||||||
|
if (matched) {
|
||||||
|
action = 'update';
|
||||||
|
targetId = matched.id;
|
||||||
|
matchKey = roomNumber;
|
||||||
|
} else {
|
||||||
|
action = 'create';
|
||||||
|
matchKey = roomNumber || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepKey === 'checkins' || stepKey === 'transfers') {
|
||||||
|
const studentNo = textValue(fields.studentNo);
|
||||||
|
const phone = textValue(fields.phone);
|
||||||
|
const name = textValue(fields.name);
|
||||||
|
let student: Student | undefined;
|
||||||
|
if (studentNo) {
|
||||||
|
student = lookups.studentsByNo.get(studentNo);
|
||||||
|
if (!student && phone) student = lookups.studentsByPhone.get(phone);
|
||||||
|
if (!student) errors.push('未找到匹配学生:请先完成“学生档案”阶段,或核对学号/手机号');
|
||||||
|
} else if (phone) {
|
||||||
|
student = lookups.studentsByPhone.get(phone);
|
||||||
|
if (!student) errors.push('未找到匹配学生:请先完成“学生档案”阶段,或核对手机号');
|
||||||
|
} else {
|
||||||
|
errors.push('缺少学生标识:请映射“学号”或“手机号”');
|
||||||
|
}
|
||||||
|
if (student && name && student.name !== name) {
|
||||||
|
errors.push(`姓名与手机号不匹配(档案姓名:${student.name})`);
|
||||||
|
}
|
||||||
|
if (student) resolvedIds._studentId = student.id;
|
||||||
|
const roomNumber = textValue(fields.roomNumber);
|
||||||
|
const room = roomNumber ? lookups.roomsByNumber.get(roomNumber) : undefined;
|
||||||
|
if (stepKey === 'checkins') {
|
||||||
|
if (!roomNumber) errors.push('宿舍号不能为空');
|
||||||
|
if (roomNumber && !room) {
|
||||||
|
errors.push('未找到宿舍:请先完成“宿舍档案”阶段,或核对宿舍号');
|
||||||
|
}
|
||||||
|
const checkInDate = parseDateValue(fields.checkInDate);
|
||||||
|
if (!checkInDate) errors.push('入住日期格式不正确(应为 YYYY-MM-DD)');
|
||||||
|
normalized.checkInDate = checkInDate;
|
||||||
|
normalized.stayType = textValue(fields.stayType) || 'short';
|
||||||
|
if (student) {
|
||||||
|
const active = lookups.activeOccupancies.get(student.id) ?? [];
|
||||||
|
const batchCheckedIn = batchState?.checkinStudentIds.has(student.id) ?? false;
|
||||||
|
const batchTransferred = batchState?.transferStudentIds.has(student.id) ?? false;
|
||||||
|
if (batchCheckedIn || batchTransferred) {
|
||||||
|
errors.push('该学生本次文件中已有在住记录,请勿重复导入');
|
||||||
|
} else if (active.some((o) => o.roomId === room?.id)) {
|
||||||
|
errors.push('该学生已有该宿舍的在住记录');
|
||||||
|
} else if (active.length > 0) {
|
||||||
|
errors.push('该学生已有在住记录:如需换宿请使用“换宿记录”阶段');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.length === 0 && student && room) {
|
||||||
|
action = 'create';
|
||||||
|
matchKey = `${student.id}|${room.id}`;
|
||||||
|
resolvedIds._roomId = room.id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const oldRoomNumber = textValue(fields.oldRoom);
|
||||||
|
const newRoomNumber = textValue(fields.newRoom);
|
||||||
|
const oldRoom = oldRoomNumber ? lookups.roomsByNumber.get(oldRoomNumber) : undefined;
|
||||||
|
const newRoom = newRoomNumber ? lookups.roomsByNumber.get(newRoomNumber) : undefined;
|
||||||
|
if (!oldRoomNumber) errors.push('原宿舍不能为空');
|
||||||
|
if (oldRoomNumber && !oldRoom) {
|
||||||
|
errors.push('未找到原宿舍:请先完成“宿舍档案”阶段,或核对宿舍号');
|
||||||
|
}
|
||||||
|
if (!newRoomNumber) errors.push('新宿舍不能为空');
|
||||||
|
if (newRoomNumber && !newRoom) {
|
||||||
|
errors.push('未找到新宿舍:请先完成“宿舍档案”阶段,或核对宿舍号');
|
||||||
|
}
|
||||||
|
if (oldRoom && newRoom && oldRoom.id === newRoom.id) errors.push('原宿舍和新宿舍不能相同');
|
||||||
|
const transferDate = parseDateValue(fields.transferDate);
|
||||||
|
if (!transferDate) errors.push('换宿日期格式不正确(应为 YYYY-MM-DD)');
|
||||||
|
normalized.transferDate = transferDate;
|
||||||
|
normalized.reason = textValue(fields.reason);
|
||||||
|
if (student) {
|
||||||
|
const active = lookups.activeOccupancies.get(student.id) ?? [];
|
||||||
|
const batchCheckedIn = batchState?.checkinStudentIds.has(student.id) ?? false;
|
||||||
|
const batchTransferred = batchState?.transferStudentIds.has(student.id) ?? false;
|
||||||
|
const oldOccupancy = active.find((o) => o.roomId === oldRoom?.id);
|
||||||
|
if (batchTransferred) {
|
||||||
|
errors.push('该学生本次文件中已有换宿记录,请勿重复换宿');
|
||||||
|
} else if (batchCheckedIn) {
|
||||||
|
errors.push('该学生的入住记录来自本次文件,请先提交入住阶段后再换宿');
|
||||||
|
} else if (!oldOccupancy) {
|
||||||
|
errors.push('未找到该学生在原宿舍的在住记录:请先完成“入住记录”阶段');
|
||||||
|
} else {
|
||||||
|
targetId = oldOccupancy.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.length === 0 && student && oldRoom && newRoom) {
|
||||||
|
action = 'create';
|
||||||
|
matchKey = `${student.id}|${oldRoom.id}->${newRoom.id}`;
|
||||||
|
resolvedIds._newRoomId = newRoom.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { errors, action, matchKey, targetId, normalized, resolvedIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeRow(
|
||||||
|
manager: EntityManager,
|
||||||
|
stepKey: ImportStepKey,
|
||||||
|
action: ImportRowAction,
|
||||||
|
fields: Record<string, CellValue>,
|
||||||
|
targetId: number | null,
|
||||||
|
): Promise<number | null> {
|
||||||
|
if (stepKey === 'students') {
|
||||||
|
const studentRepo = manager.getRepository(Student);
|
||||||
|
if (action === 'create') {
|
||||||
|
const student = studentRepo.create({
|
||||||
|
name: textValue(fields.name),
|
||||||
|
studentNo: textValue(fields.studentNo) || undefined,
|
||||||
|
phone: textValue(fields.phone) || undefined,
|
||||||
|
idNumber: textValue(fields.idNumber) || undefined,
|
||||||
|
gender: textValue(fields.gender) || undefined,
|
||||||
|
ethnicity: textValue(fields.ethnicity) || undefined,
|
||||||
|
emergencyContact: textValue(fields.emergencyContact) || undefined,
|
||||||
|
emergencyPhone: textValue(fields.emergencyPhone) || undefined,
|
||||||
|
status: textValue(fields.status) || 'active',
|
||||||
|
organizationId: optionalNumber(fields._organizationId) ?? undefined,
|
||||||
|
});
|
||||||
|
await studentRepo.save(student);
|
||||||
|
return student.id;
|
||||||
|
}
|
||||||
|
if (!targetId) throw new Error('缺少待更新学生记录');
|
||||||
|
const student = await studentRepo.findOneBy({ id: targetId });
|
||||||
|
if (!student) throw new Error('待更新的学生记录不存在');
|
||||||
|
applyString(student, 'name', fields.name);
|
||||||
|
applyString(student, 'studentNo', fields.studentNo);
|
||||||
|
applyString(student, 'phone', fields.phone);
|
||||||
|
applyString(student, 'idNumber', fields.idNumber);
|
||||||
|
applyString(student, 'gender', fields.gender);
|
||||||
|
applyString(student, 'ethnicity', fields.ethnicity);
|
||||||
|
applyString(student, 'emergencyContact', fields.emergencyContact);
|
||||||
|
applyString(student, 'emergencyPhone', fields.emergencyPhone);
|
||||||
|
applyString(student, 'status', fields.status);
|
||||||
|
const orgId = optionalNumber(fields._organizationId);
|
||||||
|
if (orgId !== null) {
|
||||||
|
student.organizationId = orgId;
|
||||||
|
}
|
||||||
|
await studentRepo.save(student);
|
||||||
|
return student.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepKey === 'rooms') {
|
||||||
|
const roomRepo = manager.getRepository(Room);
|
||||||
|
if (action === 'create') {
|
||||||
|
const room = roomRepo.create({
|
||||||
|
roomNumber: textValue(fields.roomNumber),
|
||||||
|
building: textValue(fields.building) || undefined,
|
||||||
|
floor: optionalNumber(fields.floor) ?? undefined,
|
||||||
|
capacity: Number(fields.capacity),
|
||||||
|
status: 'available',
|
||||||
|
roomType: textValue(fields.roomType) || undefined,
|
||||||
|
rentalCategory: textValue(fields.rentalCategory) || 'short',
|
||||||
|
monthlyRate: Number(fields.monthlyRate ?? 0),
|
||||||
|
});
|
||||||
|
await roomRepo.save(room);
|
||||||
|
return room.id;
|
||||||
|
}
|
||||||
|
if (!targetId) throw new Error('缺少待更新宿舍记录');
|
||||||
|
const room = await roomRepo.findOneBy({ id: targetId });
|
||||||
|
if (!room) throw new Error('待更新的宿舍记录不存在');
|
||||||
|
applyString(room, 'roomNumber', fields.roomNumber);
|
||||||
|
applyString(room, 'building', fields.building);
|
||||||
|
applyString(room, 'roomType', fields.roomType);
|
||||||
|
applyString(room, 'rentalCategory', fields.rentalCategory);
|
||||||
|
if (fields.floor !== null && fields.floor !== undefined && fields.floor !== '') {
|
||||||
|
room.floor = Number(fields.floor);
|
||||||
|
}
|
||||||
|
if (fields.capacity !== null && fields.capacity !== undefined && fields.capacity !== '') {
|
||||||
|
room.capacity = Number(fields.capacity);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
fields.monthlyRate !== null &&
|
||||||
|
fields.monthlyRate !== undefined &&
|
||||||
|
fields.monthlyRate !== ''
|
||||||
|
) {
|
||||||
|
room.monthlyRate = Number(fields.monthlyRate);
|
||||||
|
}
|
||||||
|
await roomRepo.save(room);
|
||||||
|
return room.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepKey === 'checkins') {
|
||||||
|
const studentId = optionalNumber(fields._studentId);
|
||||||
|
const roomId = optionalNumber(fields._roomId);
|
||||||
|
if (!studentId || !roomId) throw new Error('缺少学生或宿舍 ID');
|
||||||
|
const occupancy = manager.getRepository(Occupancy).create({
|
||||||
|
studentId,
|
||||||
|
roomId,
|
||||||
|
checkInDate: String(fields.checkInDate),
|
||||||
|
billingStartDate: String(fields.checkInDate),
|
||||||
|
status: 'active',
|
||||||
|
stayType: textValue(fields.stayType) || 'short',
|
||||||
|
});
|
||||||
|
await manager.save(Occupancy, occupancy);
|
||||||
|
return occupancy.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepKey === 'transfers') {
|
||||||
|
if (!targetId) throw new Error('缺少原入住记录');
|
||||||
|
const studentId = optionalNumber(fields._studentId);
|
||||||
|
const newRoomId = optionalNumber(fields._newRoomId);
|
||||||
|
if (!studentId || !newRoomId) throw new Error('缺少学生或新宿舍 ID');
|
||||||
|
const oldOccupancy = await manager.getRepository(Occupancy).findOneBy({ id: targetId });
|
||||||
|
if (!oldOccupancy) throw new Error('原入住记录不存在');
|
||||||
|
oldOccupancy.checkOutDate = String(fields.transferDate);
|
||||||
|
oldOccupancy.status = 'archived';
|
||||||
|
await manager.save(Occupancy, oldOccupancy);
|
||||||
|
const newOccupancy = manager.getRepository(Occupancy).create({
|
||||||
|
studentId,
|
||||||
|
roomId: newRoomId,
|
||||||
|
checkInDate: String(fields.transferDate),
|
||||||
|
billingStartDate: String(fields.transferDate),
|
||||||
|
status: 'active',
|
||||||
|
stayType: oldOccupancy.stayType,
|
||||||
|
});
|
||||||
|
await manager.save(Occupancy, newOccupancy);
|
||||||
|
return newOccupancy.id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
170
apps/server/src/imports/imports.run.service.ts
Normal file
170
apps/server/src/imports/imports.run.service.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
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 type {
|
||||||
|
CellValue,
|
||||||
|
ColumnMapping,
|
||||||
|
ImportRunSource,
|
||||||
|
ImportStageRequest,
|
||||||
|
ImportStepKey,
|
||||||
|
ParsedImportFile,
|
||||||
|
StepPreviewSummary,
|
||||||
|
} from './imports.types';
|
||||||
|
import { parseJson } from './imports.helpers';
|
||||||
|
import { extractSheets } from './imports.workbook';
|
||||||
|
import type { ImportSheetData } from './imports.workbook';
|
||||||
|
import { autoAssignedSheets, resolveAssignedSheets, suggestMapping, suggestStep } from './imports.mapping';
|
||||||
|
import { findOwnedRun } from './imports.access';
|
||||||
|
import type { ImportPrincipal } from './imports.access';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImportRunService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ImportRun)
|
||||||
|
private readonly runs: Repository<ImportRun>,
|
||||||
|
@InjectRepository(ImportStep)
|
||||||
|
private readonly steps: Repository<ImportStep>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createRun(
|
||||||
|
principal: ImportPrincipal,
|
||||||
|
source: ImportRunSource,
|
||||||
|
file: ParsedImportFile,
|
||||||
|
conversationId?: number | null,
|
||||||
|
stages?: ImportStageRequest[],
|
||||||
|
mappingByStep?: Partial<Record<ImportStepKey, ColumnMapping>>,
|
||||||
|
) {
|
||||||
|
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 runId = randomUUID();
|
||||||
|
const run = this.runs.create({
|
||||||
|
id: runId,
|
||||||
|
userId: principal.id,
|
||||||
|
conversationId: conversationId ?? null,
|
||||||
|
source,
|
||||||
|
fileName: file.originalName.slice(0, 255),
|
||||||
|
sheetsJson: JSON.stringify(sheets),
|
||||||
|
status: 'ready',
|
||||||
|
currentStepKey: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepRecords: ImportStep[] = [];
|
||||||
|
for (const stepKey of IMPORT_STEP_ORDER) {
|
||||||
|
const assigned =
|
||||||
|
stages && stages.length > 0
|
||||||
|
? resolveAssignedSheets(
|
||||||
|
stages,
|
||||||
|
stepKey,
|
||||||
|
sheets.map((s) => s.name),
|
||||||
|
)
|
||||||
|
: autoAssignedSheets(sheets, stepKey);
|
||||||
|
if (assigned.length === 0) {
|
||||||
|
stepRecords.push(
|
||||||
|
this.steps.create({
|
||||||
|
runId,
|
||||||
|
stepKey,
|
||||||
|
sheetsJson: '[]',
|
||||||
|
mappingJson: null,
|
||||||
|
status: 'skipped',
|
||||||
|
summaryJson: null,
|
||||||
|
committedAt: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const firstSheet = sheets.find((s) => s.name === assigned[0]);
|
||||||
|
const mapping =
|
||||||
|
mappingByStep?.[stepKey] ?? suggestMapping(firstSheet?.headers ?? [], stepKey);
|
||||||
|
stepRecords.push(
|
||||||
|
this.steps.create({
|
||||||
|
runId,
|
||||||
|
stepKey,
|
||||||
|
sheetsJson: JSON.stringify(assigned),
|
||||||
|
mappingJson: mapping ? JSON.stringify(mapping) : null,
|
||||||
|
status: 'pending',
|
||||||
|
summaryJson: null,
|
||||||
|
committedAt: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const firstActive = stepRecords.find((s) => s.status !== 'skipped');
|
||||||
|
run.currentStepKey = firstActive?.stepKey ?? null;
|
||||||
|
await this.runs.save(run);
|
||||||
|
await this.steps.save(stepRecords);
|
||||||
|
return this.getRun(principal.id, runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRun(userId: number, runId: string) {
|
||||||
|
const run = await findOwnedRun(this.runs, userId, runId);
|
||||||
|
const stepRecords = await this.steps.find({ where: { runId }, order: { id: 'ASC' } });
|
||||||
|
const sheets =
|
||||||
|
parseJson<Array<{ name: string; headers: string[]; rows: CellValue[][] }>>(run.sheetsJson) ??
|
||||||
|
[];
|
||||||
|
return {
|
||||||
|
id: run.id,
|
||||||
|
fileName: run.fileName,
|
||||||
|
source: run.source,
|
||||||
|
status: run.status,
|
||||||
|
currentStepKey: run.currentStepKey,
|
||||||
|
createdAt: run.createdAt.toISOString(),
|
||||||
|
sheets: sheets.map((sheet) => ({
|
||||||
|
name: sheet.name,
|
||||||
|
headers: sheet.headers,
|
||||||
|
rowCount: sheet.rows.length,
|
||||||
|
suggestedStepKey: suggestStep(sheet.headers)?.stepKey ?? null,
|
||||||
|
})),
|
||||||
|
steps: stepRecords.map((step) => ({
|
||||||
|
id: step.id,
|
||||||
|
stepKey: step.stepKey,
|
||||||
|
label: IMPORT_STEP_LABELS[step.stepKey],
|
||||||
|
sheets: parseJson<string[]>(step.sheetsJson) ?? [],
|
||||||
|
status: step.status,
|
||||||
|
mapping: parseJson<ColumnMapping>(step.mappingJson) ?? {},
|
||||||
|
summary: parseJson<StepPreviewSummary>(step.summaryJson),
|
||||||
|
committedAt: step.committedAt?.toISOString() ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
606
apps/server/src/imports/imports.service.spec.ts
Normal file
606
apps/server/src/imports/imports.service.spec.ts
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||||
|
import * as ExcelJS from 'exceljs';
|
||||||
|
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 { ImportRun } from './entities/import-run.entity';
|
||||||
|
import { ImportStep } from './entities/import-step.entity';
|
||||||
|
import { ImportRow } from './entities/import-row.entity';
|
||||||
|
import { ImportsService } from './imports.service';
|
||||||
|
import type { ParsedImportFile } from './imports.types';
|
||||||
|
|
||||||
|
function makeRowsRepo() {
|
||||||
|
let nextId = 1;
|
||||||
|
return {
|
||||||
|
create: jest.fn((value: unknown) => value),
|
||||||
|
save: jest.fn(async (rows: unknown[]) => {
|
||||||
|
const list = Array.isArray(rows) ? rows : [rows];
|
||||||
|
for (const row of list) {
|
||||||
|
const record = row as { id?: number };
|
||||||
|
if (record.id === undefined) record.id = nextId++;
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}),
|
||||||
|
delete: jest.fn().mockResolvedValue({ affected: 0 }),
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStepsRepo(step: ImportStep) {
|
||||||
|
return {
|
||||||
|
create: jest.fn((value: unknown) => value),
|
||||||
|
save: jest.fn(async (value: unknown) => value),
|
||||||
|
findOne: jest.fn().mockResolvedValue(step),
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRunsRepo(run: ImportRun) {
|
||||||
|
return {
|
||||||
|
create: jest.fn((value: unknown) => value),
|
||||||
|
save: jest.fn(async (value: unknown) => value),
|
||||||
|
findOne: jest.fn().mockResolvedValue(run),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function studentSheet() {
|
||||||
|
return {
|
||||||
|
name: '学生',
|
||||||
|
headers: ['姓名', '学号', '手机号'],
|
||||||
|
rows: [['张三', '2024001', '13800138000']],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function xlsxBuffer(sheet: {
|
||||||
|
name: string;
|
||||||
|
headers: string[];
|
||||||
|
rows: unknown[][];
|
||||||
|
}): Promise<Buffer> {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const ws = workbook.addWorksheet(sheet.name);
|
||||||
|
ws.addRow(sheet.headers);
|
||||||
|
for (const row of sheet.rows) ws.addRow(row);
|
||||||
|
return (await workbook.xlsx.writeBuffer()) as Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileOf(name: string, buffer: Buffer): ParsedImportFile {
|
||||||
|
return {
|
||||||
|
originalName: name,
|
||||||
|
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
size: buffer.length,
|
||||||
|
buffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const principal = {
|
||||||
|
id: 7,
|
||||||
|
permissions: ['student:import', 'room:create', 'occupancy:checkin'],
|
||||||
|
isSuperAdmin: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ImportsService', () => {
|
||||||
|
it('拒绝 .xls 文件', async () => {
|
||||||
|
const service = new ImportsService(
|
||||||
|
makeRunsRepo({} as ImportRun) as never,
|
||||||
|
makeStepsRepo({} as ImportStep) as never,
|
||||||
|
makeRowsRepo() as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.createRun(principal, 'manual', fileOf('a.xls', Buffer.from('not excel'))),
|
||||||
|
).rejects.toThrow('暂不支持 .xls');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('上传学生表时自动分配 students 阶段并返回运行详情', async () => {
|
||||||
|
const buffer = await xlsxBuffer(studentSheet());
|
||||||
|
const run = {
|
||||||
|
id: 'run-1',
|
||||||
|
userId: 7,
|
||||||
|
conversationId: null,
|
||||||
|
source: 'manual',
|
||||||
|
fileName: 'students.xlsx',
|
||||||
|
sheetsJson: JSON.stringify([studentSheet()]),
|
||||||
|
status: 'ready',
|
||||||
|
currentStepKey: 'students',
|
||||||
|
error: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as ImportRun;
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
runId: 'run-1',
|
||||||
|
stepKey: 'students',
|
||||||
|
sheetsJson: '["学生"]',
|
||||||
|
mappingJson: '{"name":"姓名","studentNo":"学号","phone":"手机号"}',
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
runId: 'run-1',
|
||||||
|
stepKey: 'rooms',
|
||||||
|
sheetsJson: '[]',
|
||||||
|
mappingJson: null,
|
||||||
|
status: 'skipped',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
runId: 'run-1',
|
||||||
|
stepKey: 'checkins',
|
||||||
|
sheetsJson: '[]',
|
||||||
|
mappingJson: null,
|
||||||
|
status: 'skipped',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
runId: 'run-1',
|
||||||
|
stepKey: 'transfers',
|
||||||
|
sheetsJson: '[]',
|
||||||
|
mappingJson: null,
|
||||||
|
status: 'skipped',
|
||||||
|
},
|
||||||
|
] as unknown as ImportStep[];
|
||||||
|
const runsRepo = makeRunsRepo(run);
|
||||||
|
const stepsRepo = {
|
||||||
|
create: jest.fn((value: unknown) => value),
|
||||||
|
save: jest.fn(async (value: unknown) => value),
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
find: jest.fn().mockResolvedValue(steps),
|
||||||
|
};
|
||||||
|
const service = new ImportsService(
|
||||||
|
runsRepo as never,
|
||||||
|
stepsRepo as never,
|
||||||
|
makeRowsRepo() as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const detail = await service.createRun(principal, 'manual', fileOf('students.xlsx', buffer));
|
||||||
|
expect(detail.currentStepKey).toBe('students');
|
||||||
|
expect(detail.steps.find((step) => step.stepKey === 'students')?.sheets).toEqual(['学生']);
|
||||||
|
expect(detail.steps.find((step) => step.stepKey === 'students')?.mapping).toEqual({
|
||||||
|
name: '姓名',
|
||||||
|
studentNo: '学号',
|
||||||
|
phone: '手机号',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('预览学生阶段:已有学号判为更新,并保留目标记录 ID', async () => {
|
||||||
|
const existing = {
|
||||||
|
id: 88,
|
||||||
|
name: '张三',
|
||||||
|
studentNo: '2024001',
|
||||||
|
phone: '13800138000',
|
||||||
|
} as Student;
|
||||||
|
const run = {
|
||||||
|
id: 'run-1',
|
||||||
|
userId: 7,
|
||||||
|
source: 'manual',
|
||||||
|
fileName: 'students.xlsx',
|
||||||
|
sheetsJson: JSON.stringify([studentSheet()]),
|
||||||
|
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: 1 });
|
||||||
|
expect(result.rows[0].action).toBe('update');
|
||||||
|
expect(result.rows[0].status).toBe('valid');
|
||||||
|
expect(result.rows[0].id).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('预览入住阶段:宿舍不存在时按依赖错误提示', async () => {
|
||||||
|
const existing = {
|
||||||
|
id: 88,
|
||||||
|
name: '张三',
|
||||||
|
studentNo: '2024001',
|
||||||
|
phone: '13800138000',
|
||||||
|
} as Student;
|
||||||
|
const run = {
|
||||||
|
id: 'run-2',
|
||||||
|
userId: 7,
|
||||||
|
source: 'manual',
|
||||||
|
fileName: 'checkins.xlsx',
|
||||||
|
sheetsJson: JSON.stringify([
|
||||||
|
{
|
||||||
|
name: '入住',
|
||||||
|
headers: ['姓名', '手机号', '宿舍号', '入住日期'],
|
||||||
|
rows: [['张三', '13800138000', 'A101', '2026-09-01']],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
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: 0, error: 1 });
|
||||||
|
expect(result.rows[0].status).toBe('error');
|
||||||
|
expect(result.rows[0].errors.join(';')).toContain('未找到宿舍');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('提交阶段需要对应权限;已完成的任务幂等返回回执', async () => {
|
||||||
|
const run = {
|
||||||
|
id: 'run-1',
|
||||||
|
userId: 7,
|
||||||
|
source: 'manual',
|
||||||
|
fileName: 'students.xlsx',
|
||||||
|
sheetsJson: '[]',
|
||||||
|
status: 'ready',
|
||||||
|
currentStepKey: 'transfers',
|
||||||
|
error: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as ImportRun;
|
||||||
|
const step = {
|
||||||
|
id: 4,
|
||||||
|
runId: 'run-1',
|
||||||
|
stepKey: 'transfers',
|
||||||
|
sheetsJson: '["换宿"]',
|
||||||
|
mappingJson: '{}',
|
||||||
|
status: 'ready',
|
||||||
|
} as ImportStep;
|
||||||
|
const rowsRepo = makeRowsRepo();
|
||||||
|
rowsRepo.find.mockResolvedValue([]);
|
||||||
|
const service = new ImportsService(
|
||||||
|
makeRunsRepo(run) as never,
|
||||||
|
makeStepsRepo(step) as never,
|
||||||
|
rowsRepo as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.commitStep(
|
||||||
|
{ id: 7, permissions: ['student:import'], isSuperAdmin: false },
|
||||||
|
'run-1',
|
||||||
|
'transfers',
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
|
||||||
|
const committedRun = { ...run, status: 'committed', currentStepKey: null } as ImportRun;
|
||||||
|
const committedStep = {
|
||||||
|
id: 1,
|
||||||
|
runId: 'run-1',
|
||||||
|
stepKey: 'students',
|
||||||
|
sheetsJson: '["学生"]',
|
||||||
|
mappingJson: '{}',
|
||||||
|
status: 'committed',
|
||||||
|
summaryJson: JSON.stringify({ total: 1, valid: 1, error: 0, create: 1, update: 0, skip: 0 }),
|
||||||
|
} as ImportStep;
|
||||||
|
const committedService = new ImportsService(
|
||||||
|
makeRunsRepo(committedRun) as never,
|
||||||
|
makeStepsRepo(committedStep) as never,
|
||||||
|
makeRowsRepo() as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
const receipt = await committedService.commitStep(principal, 'run-1', 'students', []);
|
||||||
|
expect(receipt.status).toBe('already_committed');
|
||||||
|
expect(receipt.created).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('提交阶段拒绝与预览分类矛盾的决策', async () => {
|
||||||
|
const run = {
|
||||||
|
id: 'run-1',
|
||||||
|
userId: 7,
|
||||||
|
source: 'manual',
|
||||||
|
fileName: 'students.xlsx',
|
||||||
|
sheetsJson: '[]',
|
||||||
|
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: '{}',
|
||||||
|
status: 'ready',
|
||||||
|
} as ImportStep;
|
||||||
|
const row = {
|
||||||
|
id: 11,
|
||||||
|
runId: 'run-1',
|
||||||
|
stepId: 1,
|
||||||
|
sheetName: '学生',
|
||||||
|
rowNumber: 3,
|
||||||
|
rawJson: '{}',
|
||||||
|
normalizedJson: JSON.stringify({ name: '张三', studentNo: '2024001', phone: '13800138000' }),
|
||||||
|
matchKey: '2024001',
|
||||||
|
action: 'update',
|
||||||
|
status: 'valid',
|
||||||
|
errorsJson: null,
|
||||||
|
targetId: 88,
|
||||||
|
} as ImportRow;
|
||||||
|
const rowsRepo = makeRowsRepo();
|
||||||
|
rowsRepo.find.mockResolvedValue([row]);
|
||||||
|
const dataSource = {
|
||||||
|
transaction: jest.fn(),
|
||||||
|
};
|
||||||
|
const service = new ImportsService(
|
||||||
|
makeRunsRepo(run) as never,
|
||||||
|
makeStepsRepo(step) as never,
|
||||||
|
rowsRepo as never,
|
||||||
|
dataSource as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.commitStep(principal, 'run-1', 'students', [{ rowId: 11, action: 'create' }]),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
await expect(
|
||||||
|
service.commitStep(principal, 'run-1', 'students', [{ rowId: 11, action: 'create' }]),
|
||||||
|
).rejects.toThrow('预览判定为「更新」');
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('预览学生阶段:学号未命中时回退到手机号匹配', async () => {
|
||||||
|
const existing = {
|
||||||
|
id: 88,
|
||||||
|
name: '张三',
|
||||||
|
studentNo: '2024001',
|
||||||
|
phone: '13800138000',
|
||||||
|
} as Student;
|
||||||
|
const run = {
|
||||||
|
id: 'run-1',
|
||||||
|
userId: 7,
|
||||||
|
source: 'manual',
|
||||||
|
fileName: 'students.xlsx',
|
||||||
|
sheetsJson: JSON.stringify([
|
||||||
|
{
|
||||||
|
name: '学生',
|
||||||
|
headers: ['姓名', '学号', '手机号'],
|
||||||
|
rows: [['张三', '2024999', '13800138000']],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
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: 1 });
|
||||||
|
expect(result.rows[0].action).toBe('update');
|
||||||
|
expect(result.rows[0].status).toBe('valid');
|
||||||
|
expect(result.rows[0].id).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('预览入住阶段:同一文件内重复入住标记为错误', 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: 'manual',
|
||||||
|
fileName: 'checkins.xlsx',
|
||||||
|
sheetsJson: JSON.stringify([
|
||||||
|
{
|
||||||
|
name: '入住',
|
||||||
|
headers: ['姓名', '手机号', '宿舍号', '入住日期'],
|
||||||
|
rows: [
|
||||||
|
['张三', '13800138000', 'A101', '2026-09-01'],
|
||||||
|
['张三', '13800138000', 'A101', '2026-09-02'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
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: 1, error: 1 });
|
||||||
|
expect(result.rows[0].status).toBe('valid');
|
||||||
|
expect(result.rows[1].status).toBe('error');
|
||||||
|
expect(result.rows[1].errors.join(';')).toContain('请勿重复导入');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('预览换宿阶段:同一文件内重复换宿标记为错误', async () => {
|
||||||
|
const existing = {
|
||||||
|
id: 88,
|
||||||
|
name: '张三',
|
||||||
|
studentNo: '2024001',
|
||||||
|
phone: '13800138000',
|
||||||
|
} as Student;
|
||||||
|
const oldRoom = { id: 5, roomNumber: 'A101' } as Room;
|
||||||
|
const newRoom = { id: 6, roomNumber: 'B202' } as Room;
|
||||||
|
const run = {
|
||||||
|
id: 'run-3',
|
||||||
|
userId: 7,
|
||||||
|
source: 'manual',
|
||||||
|
fileName: 'transfers.xlsx',
|
||||||
|
sheetsJson: JSON.stringify([
|
||||||
|
{
|
||||||
|
name: '换宿',
|
||||||
|
headers: ['姓名', '手机号', '原宿舍', '新宿舍', '换宿日期'],
|
||||||
|
rows: [
|
||||||
|
['张三', '13800138000', 'A101', 'B202', '2026-09-10'],
|
||||||
|
['张三', '13800138000', 'A101', 'B202', '2026-09-11'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
status: 'ready',
|
||||||
|
currentStepKey: 'transfers',
|
||||||
|
error: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as ImportRun;
|
||||||
|
const step = {
|
||||||
|
id: 4,
|
||||||
|
runId: 'run-3',
|
||||||
|
stepKey: 'transfers',
|
||||||
|
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([oldRoom, newRoom]) };
|
||||||
|
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
if (entity === Occupancy) {
|
||||||
|
return {
|
||||||
|
find: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ id: 77, studentId: 88, roomId: 5, status: 'active' }]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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-3', 'transfers', {
|
||||||
|
sheets: ['换宿'],
|
||||||
|
mapping: {
|
||||||
|
name: '姓名',
|
||||||
|
phone: '手机号',
|
||||||
|
oldRoom: '原宿舍',
|
||||||
|
newRoom: '新宿舍',
|
||||||
|
transferDate: '换宿日期',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.summary).toMatchObject({ total: 2, valid: 1, error: 1 });
|
||||||
|
expect(result.rows[0].status).toBe('valid');
|
||||||
|
expect(result.rows[1].status).toBe('error');
|
||||||
|
expect(result.rows[1].errors.join(';')).toContain('请勿重复换宿');
|
||||||
|
});
|
||||||
|
});
|
||||||
84
apps/server/src/imports/imports.service.ts
Normal file
84
apps/server/src/imports/imports.service.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
import { ImportRun } from './entities/import-run.entity';
|
||||||
|
import { ImportStep } from './entities/import-step.entity';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ImportSheetMeta,
|
||||||
|
ImportStepDetail,
|
||||||
|
ImportRunDetail,
|
||||||
|
StepPreviewResult,
|
||||||
|
} from './imports.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImportsService {
|
||||||
|
private runService?: ImportRunService;
|
||||||
|
private previewService?: ImportPreviewService;
|
||||||
|
private commitService?: ImportCommitService;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ImportRun)
|
||||||
|
private readonly runs: Repository<ImportRun>,
|
||||||
|
@InjectRepository(ImportStep)
|
||||||
|
private readonly steps: Repository<ImportStep>,
|
||||||
|
@InjectRepository(ImportRow)
|
||||||
|
private readonly rows: Repository<ImportRow>,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get runsSvc(): ImportRunService {
|
||||||
|
if (!this.runService) {
|
||||||
|
this.runService = new ImportRunService(this.runs, this.steps);
|
||||||
|
}
|
||||||
|
return this.runService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get previews(): ImportPreviewService {
|
||||||
|
if (!this.previewService) {
|
||||||
|
this.previewService = new ImportPreviewService(
|
||||||
|
this.runs,
|
||||||
|
this.steps,
|
||||||
|
this.rows,
|
||||||
|
this.dataSource,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.previewService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get commits(): ImportCommitService {
|
||||||
|
if (!this.commitService) {
|
||||||
|
this.commitService = new ImportCommitService(
|
||||||
|
this.runs,
|
||||||
|
this.steps,
|
||||||
|
this.rows,
|
||||||
|
this.dataSource,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.commitService;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRun(...args: Parameters<ImportRunService['createRun']>) {
|
||||||
|
return this.runsSvc.createRun(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRun(...args: Parameters<ImportRunService['getRun']>) {
|
||||||
|
return this.runsSvc.getRun(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
async previewStep(...args: Parameters<ImportPreviewService['previewStep']>) {
|
||||||
|
return this.previews.previewStep(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
async commitStep(...args: Parameters<ImportCommitService['commitStep']>) {
|
||||||
|
return this.commits.commitStep(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
async errorReport(...args: Parameters<ImportCommitService['errorReport']>) {
|
||||||
|
return this.commits.errorReport(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
210
apps/server/src/imports/imports.types.ts
Normal file
210
apps/server/src/imports/imports.types.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* Unified Excel batch-import workflow (v1).
|
||||||
|
*
|
||||||
|
* Staged by business dependency:
|
||||||
|
* students / rooms (基础档案) → checkins / transfers (关系)
|
||||||
|
* Each stage is previewed, confirmed and committed separately.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const IMPORT_STEP_KEYS = ['students', 'rooms', 'checkins', 'transfers'] as const;
|
||||||
|
export type ImportStepKey = (typeof IMPORT_STEP_KEYS)[number];
|
||||||
|
|
||||||
|
export const IMPORT_STEP_ORDER: readonly ImportStepKey[] = [
|
||||||
|
'students',
|
||||||
|
'rooms',
|
||||||
|
'checkins',
|
||||||
|
'transfers',
|
||||||
|
];
|
||||||
|
|
||||||
|
export type CellValue = string | number | boolean | Date | null;
|
||||||
|
|
||||||
|
export type ImportRunSource = 'ai' | 'manual';
|
||||||
|
|
||||||
|
export type ImportRunStatus =
|
||||||
|
| 'preparing'
|
||||||
|
| 'ready'
|
||||||
|
| 'committing'
|
||||||
|
| 'committed'
|
||||||
|
| 'failed'
|
||||||
|
| 'expired';
|
||||||
|
|
||||||
|
export type ImportStepStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'ready'
|
||||||
|
| 'committing'
|
||||||
|
| 'committed'
|
||||||
|
| 'failed'
|
||||||
|
| 'skipped';
|
||||||
|
|
||||||
|
export type ImportRowStatus = 'pending' | 'valid' | 'error' | 'committed' | 'skipped';
|
||||||
|
|
||||||
|
export type ImportRowAction = 'create' | 'update' | 'skip';
|
||||||
|
|
||||||
|
/** field -> sheet header name */
|
||||||
|
export type ColumnMapping = Record<string, string>;
|
||||||
|
|
||||||
|
// aislop-ignore-next-line: duplicate-type-declaration -- 与前端 ImportWizard 的 API 契约保持一致
|
||||||
|
export interface ImportStageRequest {
|
||||||
|
stepKey: ImportStepKey;
|
||||||
|
sheet?: string;
|
||||||
|
headerRow?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportStageSuggestion {
|
||||||
|
stepKey: ImportStepKey;
|
||||||
|
mapping: ColumnMapping;
|
||||||
|
matchedFields: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedImportFile {
|
||||||
|
originalName: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
buffer: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportRowDecision {
|
||||||
|
rowId: number;
|
||||||
|
action: ImportRowAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepPreviewRow {
|
||||||
|
id: number;
|
||||||
|
rowNumber: number;
|
||||||
|
sheetName: string;
|
||||||
|
raw: Record<string, CellValue>;
|
||||||
|
fields: Record<string, CellValue>;
|
||||||
|
action: ImportRowAction | null;
|
||||||
|
status: ImportRowStatus;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepPreviewSummary {
|
||||||
|
total: number;
|
||||||
|
valid: number;
|
||||||
|
error: number;
|
||||||
|
create: number;
|
||||||
|
update: number;
|
||||||
|
skip: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepCommitReceipt {
|
||||||
|
runId: string;
|
||||||
|
stepKey: ImportStepKey;
|
||||||
|
status: 'committed' | 'already_committed' | 'conflict';
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
total: number;
|
||||||
|
nextStepKey: ImportStepKey | null;
|
||||||
|
runStatus: ImportRunStatus;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// aislop-ignore-next-line: duplicate-type-declaration -- 与前端 ImportWizard 的 API 契约保持一致
|
||||||
|
export interface ImportSheetMeta {
|
||||||
|
name: string;
|
||||||
|
headers: string[];
|
||||||
|
rowCount: number;
|
||||||
|
suggestedStepKey: ImportStepKey | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportStepDetail {
|
||||||
|
id: number;
|
||||||
|
stepKey: ImportStepKey;
|
||||||
|
label: string;
|
||||||
|
sheets: string[];
|
||||||
|
status: import('./entities/import-step.entity').ImportStep['status'];
|
||||||
|
mapping: ColumnMapping;
|
||||||
|
summary: StepPreviewSummary | null;
|
||||||
|
committedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportRunDetail {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
source: ImportRunSource;
|
||||||
|
status: import('./entities/import-run.entity').ImportRun['status'];
|
||||||
|
currentStepKey: ImportStepKey | null;
|
||||||
|
createdAt: string;
|
||||||
|
sheets: ImportSheetMeta[];
|
||||||
|
steps: ImportStepDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepPreviewResult {
|
||||||
|
stepKey: ImportStepKey;
|
||||||
|
sheetNames: string[];
|
||||||
|
headers: string[];
|
||||||
|
mapping: ColumnMapping;
|
||||||
|
rows: StepPreviewRow[];
|
||||||
|
summary: StepPreviewSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IMPORT_ACTION_LABELS: Record<ImportRowAction, string> = {
|
||||||
|
create: '新建',
|
||||||
|
update: '更新',
|
||||||
|
skip: '跳过',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Field alias tables used to auto-suggest column mappings. */
|
||||||
|
export const IMPORT_FIELD_ALIASES: Record<ImportStepKey, Record<string, string[]>> = {
|
||||||
|
students: {
|
||||||
|
name: ['姓名', '名字', '学生姓名', '学生名字'],
|
||||||
|
studentNo: ['学号', '学生学号', '编号'],
|
||||||
|
phone: ['手机号', '手机号码', '联系电话', '电话'],
|
||||||
|
gender: ['性别'],
|
||||||
|
idNumber: ['身份证', '身份证号', '身份证号码'],
|
||||||
|
ethnicity: ['民族'],
|
||||||
|
emergencyContact: ['紧急联系人'],
|
||||||
|
emergencyPhone: ['紧急联系电话', '紧急电话'],
|
||||||
|
organization: ['校区', '机构', '组织', '校区名称'],
|
||||||
|
status: ['状态'],
|
||||||
|
},
|
||||||
|
rooms: {
|
||||||
|
roomNumber: ['宿舍号', '房间号', '房号', '宿舍编号'],
|
||||||
|
building: ['楼栋', '楼', '栋'],
|
||||||
|
floor: ['楼层'],
|
||||||
|
capacity: ['容量', '床位数', '人数'],
|
||||||
|
roomType: ['房型', '房间类型', '宿舍类型'],
|
||||||
|
rentalCategory: ['租期', '租期类型'],
|
||||||
|
monthlyRate: ['月租', '月租金', '租金'],
|
||||||
|
},
|
||||||
|
checkins: {
|
||||||
|
name: ['姓名', '学生姓名', '名字'],
|
||||||
|
studentNo: ['学号', '学生学号'],
|
||||||
|
phone: ['手机号', '手机号码', '电话'],
|
||||||
|
roomNumber: ['宿舍号', '房间号', '房号'],
|
||||||
|
checkInDate: ['入住日期', '入住时间', '日期'],
|
||||||
|
stayType: ['住宿类型', '类型'],
|
||||||
|
},
|
||||||
|
transfers: {
|
||||||
|
studentNo: ['学号', '学生学号'],
|
||||||
|
phone: ['手机号', '手机号码'],
|
||||||
|
oldRoom: ['原宿舍', '原房间', '原宿舍号', '旧宿舍', '旧房间'],
|
||||||
|
newRoom: ['新宿舍', '新房间', '新宿舍号'],
|
||||||
|
transferDate: ['换宿日期', '变更日期', '日期'],
|
||||||
|
reason: ['原因', '备注', '换宿原因'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IMPORT_STEP_LABELS: Record<ImportStepKey, string> = {
|
||||||
|
students: '学生档案',
|
||||||
|
rooms: '宿舍档案',
|
||||||
|
checkins: '入住记录',
|
||||||
|
transfers: '换宿记录',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IMPORT_STEP_REQUIRED_FIELDS: Record<ImportStepKey, string[]> = {
|
||||||
|
students: ['name'],
|
||||||
|
rooms: ['roomNumber', 'capacity'],
|
||||||
|
checkins: ['roomNumber', 'checkInDate'],
|
||||||
|
transfers: ['oldRoom', 'newRoom', 'transferDate'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IMPORT_STEP_IDENTITY_FIELDS: Record<ImportStepKey, string[]> = {
|
||||||
|
students: ['studentNo', 'phone'],
|
||||||
|
rooms: ['roomNumber'],
|
||||||
|
checkins: ['studentNo', 'phone'],
|
||||||
|
transfers: ['studentNo', 'phone'],
|
||||||
|
};
|
||||||
39
apps/server/src/imports/imports.workbook.ts
Normal file
39
apps/server/src/imports/imports.workbook.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import * as ExcelJS from 'exceljs';
|
||||||
|
import { cellValue, textValue } from './imports.helpers';
|
||||||
|
import type { CellValue } from './imports.types';
|
||||||
|
|
||||||
|
const MAX_SHEETS = 30;
|
||||||
|
const MAX_ROWS_PER_SHEET = 3000;
|
||||||
|
const MAX_COLS_PER_SHEET = 60;
|
||||||
|
|
||||||
|
export interface ImportSheetData {
|
||||||
|
name: string;
|
||||||
|
headers: string[];
|
||||||
|
rows: CellValue[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractSheets(workbook: ExcelJS.Workbook): ImportSheetData[] {
|
||||||
|
const sheets: ImportSheetData[] = [];
|
||||||
|
for (const worksheet of workbook.worksheets) {
|
||||||
|
if (sheets.length >= MAX_SHEETS) break;
|
||||||
|
const headers: string[] = [];
|
||||||
|
const rows: CellValue[][] = [];
|
||||||
|
const firstRow = worksheet.getRow(1);
|
||||||
|
for (let col = 1; col <= Math.min(firstRow.cellCount, MAX_COLS_PER_SHEET); col += 1) {
|
||||||
|
const header = textValue(cellValue(firstRow.getCell(col)));
|
||||||
|
headers.push(header);
|
||||||
|
}
|
||||||
|
if (!headers.some(Boolean)) continue;
|
||||||
|
worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
||||||
|
if (rowNumber === 1 || rows.length >= MAX_ROWS_PER_SHEET) return;
|
||||||
|
const values: CellValue[] = [];
|
||||||
|
for (let col = 1; col <= headers.length; col += 1) {
|
||||||
|
values.push(cellValue(row.getCell(col)));
|
||||||
|
}
|
||||||
|
if (values.every((v) => v === null || textValue(v) === '')) return;
|
||||||
|
rows.push(values);
|
||||||
|
});
|
||||||
|
if (rows.length > 0) sheets.push({ name: worksheet.name, headers, rows });
|
||||||
|
}
|
||||||
|
return sheets;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user