feat: 新增通用导入中心

This commit is contained in:
2026-08-05 17:11:39 +08:00
parent e9c8a1085d
commit 0c94ca54df
20 changed files with 3355 additions and 0 deletions

View 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}` : ''}`;
}

View 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>
);
};

View 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: '原因/备注' },
],
};

View 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;
}

View 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;
}

View 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;
}

View 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(' 或 ')}`,
);
}
}

View 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]}」此前已提交`,
};
}
}

View 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,
};
}
}

View 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, '""')}"`;
}

View 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,
};
}

View 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('、')}`);
}
}

View 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 {}

View 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 };
}
}

View 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;
}

View 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,
})),
};
}
}

View 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('请勿重复换宿');
});
});

View 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);
}
}

View 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'],
};

View 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;
}