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