feat: 新增通用导入中心
This commit is contained in:
46
apps/server/src/imports/entities/import-row.entity.ts
Normal file
46
apps/server/src/imports/entities/import-row.entity.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import type { ImportRowAction, ImportRowStatus } from '../imports.types';
|
||||
|
||||
@Entity('import_rows')
|
||||
@Index('idx_import_rows_step', ['stepId'])
|
||||
@Index('idx_import_rows_run_status', ['runId', 'status'])
|
||||
export class ImportRow {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'run_id', type: 'varchar', length: 36 })
|
||||
runId: string;
|
||||
|
||||
@Column({ name: 'step_id', type: 'integer' })
|
||||
stepId: number;
|
||||
|
||||
@Column({ name: 'sheet_name', type: 'varchar', length: 200 })
|
||||
sheetName: string;
|
||||
|
||||
@Column({ name: 'row_number', type: 'integer' })
|
||||
rowNumber: number;
|
||||
|
||||
@Column({ name: 'raw_json', type: 'text' })
|
||||
rawJson: string;
|
||||
|
||||
@Column({ name: 'normalized_json', type: 'text', nullable: true })
|
||||
normalizedJson: string | null;
|
||||
|
||||
@Column({ name: 'match_key', type: 'varchar', length: 200, nullable: true })
|
||||
matchKey: string | null;
|
||||
|
||||
@Column({ name: 'action', type: 'varchar', length: 10, nullable: true })
|
||||
action: ImportRowAction | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'pending' })
|
||||
status: ImportRowStatus;
|
||||
|
||||
@Column({ name: 'errors_json', type: 'text', nullable: true })
|
||||
errorsJson: string | null;
|
||||
|
||||
@Column({ name: 'target_id', type: 'integer', nullable: true })
|
||||
targetId: number | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
}
|
||||
40
apps/server/src/imports/entities/import-run.entity.ts
Normal file
40
apps/server/src/imports/entities/import-run.entity.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn, UpdateDateColumn } from 'typeorm';
|
||||
import type { ImportRunSource, ImportRunStatus, ImportStepKey } from '../imports.types';
|
||||
|
||||
@Entity('import_runs')
|
||||
@Index('idx_import_runs_user_created', ['userId', 'createdAt'])
|
||||
export class ImportRun {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'user_id', type: 'integer' })
|
||||
userId: number;
|
||||
|
||||
@Column({ name: 'conversation_id', type: 'integer', nullable: true })
|
||||
conversationId: number | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 10, default: 'manual' })
|
||||
source: ImportRunSource;
|
||||
|
||||
@Column({ name: 'file_name', type: 'varchar', length: 255 })
|
||||
fileName: string;
|
||||
|
||||
/** Serialized sheet data — parsed rows are kept here for v1. */
|
||||
@Column({ name: 'sheets_json', type: 'text' })
|
||||
sheetsJson: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'preparing' })
|
||||
status: ImportRunStatus;
|
||||
|
||||
@Column({ name: 'current_step_key', type: 'varchar', length: 20, nullable: true })
|
||||
currentStepKey: ImportStepKey | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
error: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
41
apps/server/src/imports/entities/import-step.entity.ts
Normal file
41
apps/server/src/imports/entities/import-step.entity.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import type { ImportStepKey, ImportStepStatus } from '../imports.types';
|
||||
|
||||
@Entity('import_steps')
|
||||
@Index('idx_import_steps_run_key', ['runId', 'stepKey'])
|
||||
export class ImportStep {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'run_id', type: 'varchar', length: 36 })
|
||||
runId: string;
|
||||
|
||||
@Column({ name: 'step_key', type: 'varchar', length: 20 })
|
||||
stepKey: ImportStepKey;
|
||||
|
||||
/** Serialized string[] of sheet names assigned to this stage. */
|
||||
@Column({ name: 'sheets_json', type: 'text' })
|
||||
sheetsJson: string;
|
||||
|
||||
@Column({ name: 'mapping_json', type: 'text', nullable: true })
|
||||
mappingJson: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'pending' })
|
||||
status: ImportStepStatus;
|
||||
|
||||
/** Serialized StepPreviewSummary of the last commit. */
|
||||
@Column({ name: 'summary_json', type: 'text', nullable: true })
|
||||
summaryJson: string | null;
|
||||
|
||||
@Column({ name: 'committed_at', type: 'datetime', nullable: true })
|
||||
committedAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
}
|
||||
47
apps/server/src/imports/imports.access.ts
Normal file
47
apps/server/src/imports/imports.access.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ImportRun } from './entities/import-run.entity';
|
||||
import { ImportStep } from './entities/import-step.entity';
|
||||
import { IMPORT_STEP_LABELS } from './imports.types';
|
||||
import type { ImportStepKey } from './imports.types';
|
||||
|
||||
export interface ImportPrincipal {
|
||||
id: number;
|
||||
permissions: string[];
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
const STEP_PERMISSIONS: Record<ImportStepKey, string[]> = {
|
||||
students: ['student:import'],
|
||||
rooms: ['room:create', 'room:edit'],
|
||||
checkins: ['occupancy:checkin'],
|
||||
transfers: ['occupancy:transfer'],
|
||||
};
|
||||
|
||||
export async function findOwnedRun(
|
||||
runs: Repository<ImportRun>,
|
||||
userId: number,
|
||||
runId: string,
|
||||
): Promise<ImportRun> {
|
||||
const run = await runs.findOne({ where: { id: runId, userId } });
|
||||
if (!run) throw new NotFoundException('导入任务不存在');
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function findStep(
|
||||
steps: Repository<ImportStep>,
|
||||
runId: string,
|
||||
stepKey: ImportStepKey,
|
||||
): Promise<ImportStep | null> {
|
||||
return steps.findOne({ where: { runId, stepKey } });
|
||||
}
|
||||
|
||||
export function assertStepPermission(principal: ImportPrincipal, stepKey: ImportStepKey): void {
|
||||
if (principal.isSuperAdmin) return;
|
||||
const required = STEP_PERMISSIONS[stepKey];
|
||||
if (!required.some((code) => principal.permissions.includes(code))) {
|
||||
throw new ForbiddenException(
|
||||
`权限不足:提交「${IMPORT_STEP_LABELS[stepKey]}」需要 ${required.join(' 或 ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
247
apps/server/src/imports/imports.commit.service.ts
Normal file
247
apps/server/src/imports/imports.commit.service.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, In, Repository } from 'typeorm';
|
||||
import { ImportRun } from './entities/import-run.entity';
|
||||
import { ImportStep } from './entities/import-step.entity';
|
||||
import { ImportRow } from './entities/import-row.entity';
|
||||
import { IMPORT_ACTION_LABELS, IMPORT_STEP_LABELS, IMPORT_STEP_ORDER } from './imports.types';
|
||||
import type {
|
||||
CellValue,
|
||||
ImportRowAction,
|
||||
ImportRowDecision,
|
||||
ImportStepKey,
|
||||
StepCommitReceipt,
|
||||
StepPreviewSummary,
|
||||
} from './imports.types';
|
||||
import { csvCell, parseJson, safeError } from './imports.helpers';
|
||||
import { writeRow } from './imports.rows';
|
||||
import { assertStepPermission, findOwnedRun, findStep } from './imports.access';
|
||||
import type { ImportPrincipal } from './imports.access';
|
||||
|
||||
@Injectable()
|
||||
export class ImportCommitService {
|
||||
constructor(
|
||||
@InjectRepository(ImportRun)
|
||||
private readonly runs: Repository<ImportRun>,
|
||||
@InjectRepository(ImportStep)
|
||||
private readonly steps: Repository<ImportStep>,
|
||||
@InjectRepository(ImportRow)
|
||||
private readonly rows: Repository<ImportRow>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async commitStep(
|
||||
principal: ImportPrincipal,
|
||||
runId: string,
|
||||
stepKey: ImportStepKey,
|
||||
decisions: ImportRowDecision[],
|
||||
): Promise<StepCommitReceipt> {
|
||||
const run = await findOwnedRun(this.runs, principal.id, runId);
|
||||
if (run.status === 'committed') {
|
||||
const receipt = await this.existingReceipt(run, stepKey);
|
||||
return { ...receipt, status: 'already_committed' };
|
||||
}
|
||||
if (run.currentStepKey !== stepKey) {
|
||||
return {
|
||||
runId,
|
||||
stepKey,
|
||||
status: 'conflict',
|
||||
created: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
total: 0,
|
||||
nextStepKey: run.currentStepKey,
|
||||
runStatus: run.status,
|
||||
message: `请先完成「${run.currentStepKey ? IMPORT_STEP_LABELS[run.currentStepKey] : ''}」阶段`,
|
||||
};
|
||||
}
|
||||
const step = await findStep(this.steps, runId, stepKey);
|
||||
if (!step || step.status === 'skipped') {
|
||||
throw new BadRequestException(`阶段「${IMPORT_STEP_LABELS[stepKey]}」没有分配工作表`);
|
||||
}
|
||||
if (step.status === 'committed') {
|
||||
const receipt = await this.existingReceipt(run, stepKey);
|
||||
return { ...receipt, status: 'already_committed' };
|
||||
}
|
||||
if (step.status !== 'ready') {
|
||||
throw new BadRequestException(`阶段「${IMPORT_STEP_LABELS[stepKey]}」尚未预览,请先预览确认`);
|
||||
}
|
||||
assertStepPermission(principal, stepKey);
|
||||
|
||||
const pendingRows = await this.rows.find({ where: { stepId: step.id, status: 'valid' } });
|
||||
const decisionMap = new Map<number, ImportRowAction>();
|
||||
for (const decision of decisions ?? []) {
|
||||
if (
|
||||
Number.isInteger(decision.rowId) &&
|
||||
(decision.action === 'create' || decision.action === 'update' || decision.action === 'skip')
|
||||
) {
|
||||
decisionMap.set(decision.rowId, decision.action);
|
||||
}
|
||||
}
|
||||
if (pendingRows.length === 0) {
|
||||
throw new BadRequestException('没有可提交的有效行,请检查预览结果');
|
||||
}
|
||||
const rowById = new Map(pendingRows.map((row) => [row.id, row]));
|
||||
for (const [rowId, action] of decisionMap) {
|
||||
const row = rowById.get(rowId);
|
||||
if (!row) continue;
|
||||
if (action === 'skip') continue;
|
||||
if (!row.action) {
|
||||
throw new BadRequestException(
|
||||
`第 ${row.rowNumber} 行(${row.sheetName})没有预览判定,只能选择「跳过」`,
|
||||
);
|
||||
}
|
||||
if (action !== row.action) {
|
||||
throw new BadRequestException(
|
||||
`第 ${row.rowNumber} 行(${row.sheetName})预览判定为「${IMPORT_ACTION_LABELS[row.action]}」,不能改为「${IMPORT_ACTION_LABELS[action]}」`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
run.status = 'committing';
|
||||
step.status = 'committing';
|
||||
await this.runs.save(run);
|
||||
await this.steps.save(step);
|
||||
|
||||
const counts = { created: 0, updated: 0, skipped: 0, failed: 0 };
|
||||
try {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
for (const row of pendingRows) {
|
||||
const action = decisionMap.get(row.id) ?? row.action ?? 'create';
|
||||
if (action === 'skip') {
|
||||
row.status = 'skipped';
|
||||
row.action = 'skip';
|
||||
counts.skipped += 1;
|
||||
await manager.save(ImportRow, row);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const fields = parseJson<Record<string, CellValue>>(row.normalizedJson) ?? {};
|
||||
const targetId = await writeRow(manager, stepKey, action, fields, row.targetId);
|
||||
row.status = 'committed';
|
||||
row.action = action;
|
||||
row.targetId = targetId ?? row.targetId;
|
||||
if (action === 'create') counts.created += 1;
|
||||
else counts.updated += 1;
|
||||
} catch (error) {
|
||||
row.status = 'error';
|
||||
row.errorsJson = JSON.stringify([`写入失败:${safeError(error)}`]);
|
||||
counts.failed += 1;
|
||||
}
|
||||
await manager.save(ImportRow, row);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
run.status = 'failed';
|
||||
run.error = safeError(error).slice(0, 500);
|
||||
await this.runs.save(run);
|
||||
throw new ConflictException(`提交失败:${safeError(error)}`);
|
||||
}
|
||||
|
||||
const summary: StepPreviewSummary = {
|
||||
total: pendingRows.length,
|
||||
valid: counts.created + counts.updated + counts.skipped,
|
||||
error: counts.failed,
|
||||
create: counts.created,
|
||||
update: counts.updated,
|
||||
skip: counts.skipped,
|
||||
};
|
||||
step.status = 'committed';
|
||||
step.committedAt = new Date();
|
||||
step.summaryJson = JSON.stringify(summary);
|
||||
await this.steps.save(step);
|
||||
|
||||
const nextStepKey = await this.nextStepKey(runId, stepKey);
|
||||
run.currentStepKey = nextStepKey;
|
||||
run.status = nextStepKey ? 'ready' : 'committed';
|
||||
await this.runs.save(run);
|
||||
|
||||
const message =
|
||||
`阶段「${IMPORT_STEP_LABELS[stepKey]}」提交完成:新建 ${counts.created}、更新 ${counts.updated}、跳过 ${counts.skipped}、失败 ${counts.failed};` +
|
||||
(nextStepKey ? `下一步:${IMPORT_STEP_LABELS[nextStepKey]}` : '全部阶段已完成');
|
||||
return {
|
||||
runId,
|
||||
stepKey,
|
||||
status: 'committed',
|
||||
created: counts.created,
|
||||
updated: counts.updated,
|
||||
skipped: counts.skipped,
|
||||
failed: counts.failed,
|
||||
total: pendingRows.length,
|
||||
nextStepKey,
|
||||
runStatus: run.status,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
async errorReport(
|
||||
userId: number,
|
||||
runId: string,
|
||||
stepKey?: ImportStepKey,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const run = await findOwnedRun(this.runs, userId, runId);
|
||||
const stepRecords = await this.steps.find({ where: { runId }, order: { id: 'ASC' } });
|
||||
const stepIds = stepKey
|
||||
? stepRecords.filter((s) => s.stepKey === stepKey).map((s) => s.id)
|
||||
: stepRecords.map((s) => s.id);
|
||||
if (stepIds.length === 0) return { filename: '', buffer: Buffer.from('') };
|
||||
const rows = await this.rows.find({
|
||||
where: { stepId: In(stepIds), status: 'error' },
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
const lines: string[] = ['工作表,行号,原始数据,错误信息'];
|
||||
for (const row of rows) {
|
||||
const raw = parseJson<Record<string, CellValue>>(row.rawJson) ?? {};
|
||||
const errors = parseJson<string[]>(row.errorsJson) ?? [];
|
||||
lines.push(
|
||||
[
|
||||
csvCell(row.sheetName),
|
||||
String(row.rowNumber),
|
||||
csvCell(JSON.stringify(raw)),
|
||||
csvCell(errors.join(';')),
|
||||
].join(','),
|
||||
);
|
||||
}
|
||||
return {
|
||||
filename: `导入错误报告-${run.fileName.replace(/\.(xlsx|csv)$/i, '')}.csv`,
|
||||
buffer: Buffer.from(`\uFEFF${lines.join('\n')}`, 'utf8'),
|
||||
};
|
||||
}
|
||||
|
||||
private async nextStepKey(
|
||||
runId: string,
|
||||
currentKey: ImportStepKey,
|
||||
): Promise<ImportStepKey | null> {
|
||||
const stepRecords = await this.steps.find({ where: { runId } });
|
||||
const currentIndex = IMPORT_STEP_ORDER.indexOf(currentKey);
|
||||
for (let i = currentIndex + 1; i < IMPORT_STEP_ORDER.length; i += 1) {
|
||||
const candidate = IMPORT_STEP_ORDER[i];
|
||||
const step = stepRecords.find((s) => s.stepKey === candidate);
|
||||
if (step && step.status !== 'skipped' && step.status !== 'committed') {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async existingReceipt(
|
||||
run: ImportRun,
|
||||
stepKey: ImportStepKey,
|
||||
): Promise<Omit<StepCommitReceipt, 'status'>> {
|
||||
const step = await findStep(this.steps, run.id, stepKey);
|
||||
const summary = parseJson<StepPreviewSummary>(step?.summaryJson);
|
||||
return {
|
||||
runId: run.id,
|
||||
stepKey,
|
||||
created: summary?.create ?? 0,
|
||||
updated: summary?.update ?? 0,
|
||||
skipped: summary?.skip ?? 0,
|
||||
failed: summary?.error ?? 0,
|
||||
total: summary?.total ?? 0,
|
||||
nextStepKey: run.currentStepKey,
|
||||
runStatus: run.status,
|
||||
message: `阶段「${IMPORT_STEP_LABELS[stepKey]}」此前已提交`,
|
||||
};
|
||||
}
|
||||
}
|
||||
167
apps/server/src/imports/imports.controller.ts
Normal file
167
apps/server/src/imports/imports.controller.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Request, Response } from 'express';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import type { AuthenticatedUser } from '../authorization';
|
||||
import {
|
||||
IMPORT_STEP_KEYS,
|
||||
type ImportRowDecision,
|
||||
type ImportStageRequest,
|
||||
type ImportStepKey,
|
||||
} from './imports.types';
|
||||
import { ImportsService } from './imports.service';
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user: AuthenticatedUser;
|
||||
}
|
||||
|
||||
const IMPORT_GATE_PERMISSIONS = [
|
||||
'student:import',
|
||||
'room:create',
|
||||
'room:edit',
|
||||
'occupancy:checkin',
|
||||
'occupancy:transfer',
|
||||
] as const;
|
||||
|
||||
@Controller('imports')
|
||||
@RequirePermission(...IMPORT_GATE_PERMISSIONS)
|
||||
export class ImportsController {
|
||||
constructor(private readonly importsService: ImportsService) {}
|
||||
|
||||
@Post('runs')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))
|
||||
async create(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body() body: Record<string, unknown>,
|
||||
) {
|
||||
if (!file) throw new BadRequestException('缺少上传文件');
|
||||
let stages: ImportStageRequest[] | undefined;
|
||||
if (typeof body.stages === 'string' && body.stages.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(body.stages) as unknown;
|
||||
if (!Array.isArray(parsed)) throw new Error('not array');
|
||||
stages = parsed as ImportStageRequest[];
|
||||
} catch {
|
||||
throw new BadRequestException('stages 参数格式错误');
|
||||
}
|
||||
}
|
||||
let mapping: Partial<Record<ImportStepKey, Record<string, string>>> | undefined;
|
||||
if (typeof body.mapping === 'string' && body.mapping.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(body.mapping) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
mapping = parsed;
|
||||
}
|
||||
} catch {
|
||||
throw new BadRequestException('mapping 参数格式错误');
|
||||
}
|
||||
}
|
||||
const conversationId =
|
||||
body.conversationId !== undefined ? Number(body.conversationId) : undefined;
|
||||
const source = body.source === 'ai' ? 'ai' : 'manual';
|
||||
const data = await this.importsService.createRun(
|
||||
this.principal(req.user),
|
||||
source,
|
||||
{
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
buffer: file.buffer,
|
||||
},
|
||||
Number.isFinite(conversationId) ? conversationId : undefined,
|
||||
stages,
|
||||
mapping,
|
||||
);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Get('runs/:id')
|
||||
async get(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return { success: true, data: await this.importsService.getRun(req.user.id, id) };
|
||||
}
|
||||
|
||||
@Post('runs/:id/steps/:stepKey/preview')
|
||||
async preview(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Param('stepKey') stepKey: string,
|
||||
@Body() body: { sheets?: string[]; mapping?: Record<string, string> },
|
||||
) {
|
||||
const data = await this.importsService.previewStep(
|
||||
this.principal(req.user),
|
||||
id,
|
||||
this.parseStepKey(stepKey),
|
||||
body ?? {},
|
||||
);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Post('runs/:id/steps/:stepKey/commit')
|
||||
async commit(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Param('stepKey') stepKey: string,
|
||||
@Body() body: { decisions?: ImportRowDecision[] },
|
||||
) {
|
||||
const data = await this.importsService.commitStep(
|
||||
this.principal(req.user),
|
||||
id,
|
||||
this.parseStepKey(stepKey),
|
||||
Array.isArray(body?.decisions) ? body.decisions : [],
|
||||
);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Get('runs/:id/report')
|
||||
async report(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res() res: Response,
|
||||
@Param('id') id: string,
|
||||
@Query('stepKey') stepKey?: string,
|
||||
) {
|
||||
const { filename, buffer } = await this.importsService.errorReport(
|
||||
req.user.id,
|
||||
id,
|
||||
stepKey ? this.parseStepKey(stepKey) : undefined,
|
||||
);
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename*=UTF-8''${encodeURIComponent(filename || 'import-errors.csv')}`,
|
||||
);
|
||||
res.setHeader('Content-Length', String(buffer.length));
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
private parseStepKey(value: string): ImportStepKey {
|
||||
if ((IMPORT_STEP_KEYS as readonly string[]).includes(value)) {
|
||||
return value as ImportStepKey;
|
||||
}
|
||||
throw new BadRequestException(`未知导入阶段:${value}`);
|
||||
}
|
||||
|
||||
private principal(user: AuthenticatedUser): {
|
||||
id: number;
|
||||
permissions: string[];
|
||||
isSuperAdmin: boolean;
|
||||
} {
|
||||
return {
|
||||
id: user.id,
|
||||
permissions: user.permissions,
|
||||
isSuperAdmin: user.isSuperAdmin,
|
||||
};
|
||||
}
|
||||
}
|
||||
92
apps/server/src/imports/imports.helpers.ts
Normal file
92
apps/server/src/imports/imports.helpers.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import type { CellValue } from './imports.types';
|
||||
|
||||
export function parseJson<T>(raw: string | null | undefined): T | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function textValue(value: CellValue): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
export function normalizeHeader(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s()()]/g, '');
|
||||
}
|
||||
|
||||
export function headerMatches(header: string, alias: string): boolean {
|
||||
const h = normalizeHeader(header);
|
||||
const a = normalizeHeader(alias);
|
||||
if (!h || !a) return false;
|
||||
return h === a || h.includes(a) || a.includes(h);
|
||||
}
|
||||
|
||||
export function cellValue(cell: ExcelJS.Cell | undefined): CellValue {
|
||||
if (!cell) return null;
|
||||
const value = cell.value;
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (value instanceof Date) return value;
|
||||
if (typeof value === 'object') {
|
||||
const candidate = value as { text?: unknown; result?: unknown };
|
||||
if (typeof candidate.text === 'string') return candidate.text;
|
||||
if (typeof candidate.result === 'string' || typeof candidate.result === 'number') {
|
||||
return candidate.result;
|
||||
}
|
||||
if (candidate.result instanceof Date) return candidate.result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseDateValue(value: CellValue): string | null {
|
||||
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
const raw = textValue(value);
|
||||
if (!raw) return null;
|
||||
const match = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/.exec(raw);
|
||||
if (!match) return null;
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
if (
|
||||
date.getUTCFullYear() !== year ||
|
||||
date.getUTCMonth() !== month - 1 ||
|
||||
date.getUTCDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function safeError(error: unknown): string {
|
||||
if (error instanceof Error) return error.message.slice(0, 120);
|
||||
return '未知错误';
|
||||
}
|
||||
|
||||
export function applyString(target: object, key: string, value: CellValue): void {
|
||||
const text = textValue(value);
|
||||
if (text) (target as Record<string, unknown>)[key] = text;
|
||||
}
|
||||
|
||||
export function optionalNumber(value: CellValue): number | null {
|
||||
const text = textValue(value);
|
||||
if (!text) return null;
|
||||
const parsed = Number(text);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function csvCell(value: string): string {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
118
apps/server/src/imports/imports.lookups.ts
Normal file
118
apps/server/src/imports/imports.lookups.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { DataSource, In } from 'typeorm';
|
||||
import { Organization } from '../entities/organization.entity';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { textValue } from './imports.helpers';
|
||||
import type { CellValue, ColumnMapping, ImportStepKey } from './imports.types';
|
||||
|
||||
export interface ImportLookups {
|
||||
studentsByNo: Map<string, Student>;
|
||||
studentsByPhone: Map<string, Student>;
|
||||
roomsByNumber: Map<string, Room>;
|
||||
activeOccupancies: Map<number, Occupancy[]>;
|
||||
organizations: Map<string, Organization>;
|
||||
}
|
||||
|
||||
export async function buildLookups(
|
||||
dataSource: DataSource,
|
||||
stepKey: ImportStepKey,
|
||||
headers: string[],
|
||||
rows: CellValue[][],
|
||||
mapping: ColumnMapping,
|
||||
): Promise<ImportLookups> {
|
||||
const studentNos = new Set<string>();
|
||||
const phones = new Set<string>();
|
||||
const roomNumbers = new Set<string>();
|
||||
const organizationNames = new Set<string>();
|
||||
const fieldIndex: Record<string, number> = {};
|
||||
for (const [field, header] of Object.entries(mapping)) {
|
||||
const index = headers.indexOf(header);
|
||||
if (index >= 0) fieldIndex[field] = index;
|
||||
}
|
||||
const valueOf = (row: CellValue[], field: string): CellValue => {
|
||||
const index = fieldIndex[field];
|
||||
return index === undefined ? null : (row[index] ?? null);
|
||||
};
|
||||
for (const row of rows) {
|
||||
if (stepKey === 'students' || stepKey === 'checkins' || stepKey === 'transfers') {
|
||||
const no = textValue(valueOf(row, 'studentNo'));
|
||||
if (no) studentNos.add(no);
|
||||
const phone = textValue(valueOf(row, 'phone'));
|
||||
if (phone) phones.add(phone);
|
||||
}
|
||||
if (stepKey === 'rooms' || stepKey === 'checkins' || stepKey === 'transfers') {
|
||||
const roomField = stepKey === 'transfers' ? 'oldRoom' : 'roomNumber';
|
||||
const newRoomField = stepKey === 'transfers' ? 'newRoom' : undefined;
|
||||
const roomNo = textValue(valueOf(row, roomField));
|
||||
if (roomNo) roomNumbers.add(roomNo);
|
||||
if (newRoomField) {
|
||||
const newRoomNo = textValue(valueOf(row, newRoomField));
|
||||
if (newRoomNo) roomNumbers.add(newRoomNo);
|
||||
}
|
||||
}
|
||||
if (stepKey === 'students') {
|
||||
const org = textValue(valueOf(row, 'organization'));
|
||||
if (org) organizationNames.add(org);
|
||||
}
|
||||
}
|
||||
|
||||
const students: Student[] = [];
|
||||
if (studentNos.size > 0) {
|
||||
students.push(
|
||||
...(await dataSource.getRepository(Student).find({
|
||||
where: { studentNo: In([...studentNos]) },
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (phones.size > 0) {
|
||||
students.push(
|
||||
...(await dataSource.getRepository(Student).find({
|
||||
where: { phone: In([...phones]) },
|
||||
})),
|
||||
);
|
||||
}
|
||||
const studentsByNo = new Map<string, Student>();
|
||||
const studentsByPhone = new Map<string, Student>();
|
||||
for (const student of students) {
|
||||
if (student.studentNo) studentsByNo.set(student.studentNo, student);
|
||||
if (student.phone) studentsByPhone.set(student.phone, student);
|
||||
}
|
||||
|
||||
const rooms =
|
||||
roomNumbers.size > 0
|
||||
? await dataSource.getRepository(Room).find({
|
||||
where: { roomNumber: In([...roomNumbers]) },
|
||||
})
|
||||
: [];
|
||||
const roomsByNumber = new Map<string, Room>();
|
||||
for (const room of rooms) roomsByNumber.set(room.roomNumber, room);
|
||||
|
||||
const organizations =
|
||||
organizationNames.size > 0 ? await dataSource.getRepository(Organization).find() : [];
|
||||
const organizationsByName = new Map<string, Organization>();
|
||||
for (const org of organizations) organizationsByName.set(org.name, org);
|
||||
|
||||
const activeOccupancies = new Map<number, Occupancy[]>();
|
||||
if (stepKey === 'checkins' || stepKey === 'transfers') {
|
||||
const studentIds = [...new Set(students.map((s) => s.id))];
|
||||
if (studentIds.length > 0) {
|
||||
const occupancies = await dataSource.getRepository(Occupancy).find({
|
||||
where: { studentId: In(studentIds), status: 'active' },
|
||||
});
|
||||
for (const occupancy of occupancies) {
|
||||
const list = activeOccupancies.get(occupancy.studentId) ?? [];
|
||||
list.push(occupancy);
|
||||
activeOccupancies.set(occupancy.studentId, list);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
studentsByNo,
|
||||
studentsByPhone,
|
||||
roomsByNumber,
|
||||
activeOccupancies,
|
||||
organizations: organizationsByName,
|
||||
};
|
||||
}
|
||||
107
apps/server/src/imports/imports.mapping.ts
Normal file
107
apps/server/src/imports/imports.mapping.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
IMPORT_FIELD_ALIASES,
|
||||
IMPORT_STEP_IDENTITY_FIELDS,
|
||||
IMPORT_STEP_LABELS,
|
||||
IMPORT_STEP_ORDER,
|
||||
IMPORT_STEP_REQUIRED_FIELDS,
|
||||
} from './imports.types';
|
||||
import type {
|
||||
ColumnMapping,
|
||||
ImportStageRequest,
|
||||
ImportStageSuggestion,
|
||||
ImportStepKey,
|
||||
} from './imports.types';
|
||||
import { headerMatches } from './imports.helpers';
|
||||
import type { ImportSheetData } from './imports.workbook';
|
||||
|
||||
export function suggestMapping(headers: string[], stepKey: ImportStepKey): ColumnMapping {
|
||||
const mapping: ColumnMapping = {};
|
||||
for (const [field, aliases] of Object.entries(IMPORT_FIELD_ALIASES[stepKey])) {
|
||||
const found = headers.find((header) => aliases.some((alias) => headerMatches(header, alias)));
|
||||
if (found) mapping[field] = found;
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
export function suggestStep(headers: string[]): ImportStageSuggestion | null {
|
||||
let best: ImportStageSuggestion | null = null;
|
||||
for (const stepKey of IMPORT_STEP_ORDER) {
|
||||
const mapping = suggestMapping(headers, stepKey);
|
||||
if (Object.keys(mapping).length === 0) continue;
|
||||
const identity = IMPORT_STEP_IDENTITY_FIELDS[stepKey].filter(
|
||||
(field) => mapping[field],
|
||||
).length;
|
||||
const required = IMPORT_STEP_REQUIRED_FIELDS[stepKey].filter(
|
||||
(field) => mapping[field],
|
||||
).length;
|
||||
const score = Object.keys(mapping).length + identity * 3 + required * 2;
|
||||
if (!best || score > best.matchedFields) {
|
||||
best = { stepKey, mapping, matchedFields: score };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function autoAssignedSheets(
|
||||
sheets: ImportSheetData[],
|
||||
stepKey: ImportStepKey,
|
||||
): string[] {
|
||||
return sheets
|
||||
.filter((sheet) => suggestStep(sheet.headers)?.stepKey === stepKey)
|
||||
.map((sheet) => sheet.name);
|
||||
}
|
||||
|
||||
export function resolveAssignedSheets(
|
||||
stages: ImportStageRequest[],
|
||||
stepKey: ImportStepKey,
|
||||
available: string[],
|
||||
): string[] {
|
||||
const names = stages
|
||||
.filter((stage) => stage.stepKey === stepKey && stage.sheet)
|
||||
.map((stage) => stage.sheet as string);
|
||||
const missing = names.filter((name) => !available.includes(name));
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(`工作表不存在:${missing.join('、')}`);
|
||||
}
|
||||
return [...new Set(names)];
|
||||
}
|
||||
|
||||
export function assertMapping(
|
||||
stepKey: ImportStepKey,
|
||||
mapping: ColumnMapping,
|
||||
sheetsData: ImportSheetData[],
|
||||
usedSheets: string[],
|
||||
): void {
|
||||
const required = IMPORT_STEP_REQUIRED_FIELDS[stepKey];
|
||||
const missingRequired = required.filter((field) => !mapping[field]);
|
||||
if (missingRequired.length > 0) {
|
||||
const labels: Record<string, string> = {
|
||||
name: '姓名',
|
||||
roomNumber: '宿舍号',
|
||||
capacity: '容量',
|
||||
checkInDate: '入住日期',
|
||||
oldRoom: '原宿舍',
|
||||
newRoom: '新宿舍',
|
||||
transferDate: '换宿日期',
|
||||
};
|
||||
throw new BadRequestException(
|
||||
`阶段「${IMPORT_STEP_LABELS[stepKey]}」缺少必需列映射:${missingRequired
|
||||
.map((field) => labels[field] ?? field)
|
||||
.join('、')}`,
|
||||
);
|
||||
}
|
||||
if (stepKey === 'students' || stepKey === 'checkins' || stepKey === 'transfers') {
|
||||
const hasIdentity = IMPORT_STEP_IDENTITY_FIELDS[stepKey].some((field) => mapping[field]);
|
||||
if (!hasIdentity) {
|
||||
throw new BadRequestException('请至少映射“学号”或“手机号”列用于匹配学生');
|
||||
}
|
||||
}
|
||||
const usedHeaders = new Set(
|
||||
usedSheets.flatMap((name) => sheetsData.find((s) => s.name === name)?.headers ?? []),
|
||||
);
|
||||
const missingHeaders = Object.values(mapping).filter((header) => !usedHeaders.has(header));
|
||||
if (missingHeaders.length > 0) {
|
||||
throw new BadRequestException(`映射的列不存在于所选工作表:${missingHeaders.join('、')}`);
|
||||
}
|
||||
}
|
||||
20
apps/server/src/imports/imports.module.ts
Normal file
20
apps/server/src/imports/imports.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { ImportRun } from './entities/import-run.entity';
|
||||
import { ImportStep } from './entities/import-step.entity';
|
||||
import { ImportRow } from './entities/import-row.entity';
|
||||
import { ImportsController } from './imports.controller';
|
||||
import { ImportsService } from './imports.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ImportRun, ImportStep, ImportRow, Student, Room, Occupancy]),
|
||||
],
|
||||
controllers: [ImportsController],
|
||||
providers: [ImportsService],
|
||||
exports: [ImportsService],
|
||||
})
|
||||
export class ImportsModule {}
|
||||
163
apps/server/src/imports/imports.preview.service.ts
Normal file
163
apps/server/src/imports/imports.preview.service.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { ImportRun } from './entities/import-run.entity';
|
||||
import { ImportStep } from './entities/import-step.entity';
|
||||
import { ImportRow } from './entities/import-row.entity';
|
||||
import { IMPORT_STEP_LABELS } from './imports.types';
|
||||
import type {
|
||||
CellValue,
|
||||
ColumnMapping,
|
||||
ImportStepKey,
|
||||
StepPreviewSummary,
|
||||
} from './imports.types';
|
||||
import { parseJson } from './imports.helpers';
|
||||
import { assertMapping, suggestMapping } from './imports.mapping';
|
||||
import { buildLookups } from './imports.lookups';
|
||||
import { validateRow } from './imports.rows';
|
||||
import type { ImportBatchState } from './imports.rows';
|
||||
import { findOwnedRun, findStep } from './imports.access';
|
||||
import type { ImportPrincipal } from './imports.access';
|
||||
|
||||
@Injectable()
|
||||
export class ImportPreviewService {
|
||||
constructor(
|
||||
@InjectRepository(ImportRun)
|
||||
private readonly runs: Repository<ImportRun>,
|
||||
@InjectRepository(ImportStep)
|
||||
private readonly steps: Repository<ImportStep>,
|
||||
@InjectRepository(ImportRow)
|
||||
private readonly rows: Repository<ImportRow>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async previewStep(
|
||||
principal: ImportPrincipal,
|
||||
runId: string,
|
||||
stepKey: ImportStepKey,
|
||||
body: { sheets?: string[]; mapping?: ColumnMapping },
|
||||
) {
|
||||
const run = await findOwnedRun(this.runs, principal.id, runId);
|
||||
if (run.status === 'committed') {
|
||||
throw new BadRequestException('该导入任务已完成,无需再次预览');
|
||||
}
|
||||
if (run.status === 'committing') {
|
||||
throw new ConflictException('导入正在提交中,请稍候');
|
||||
}
|
||||
const step = await findStep(this.steps, runId, stepKey);
|
||||
if (!step || step.status === 'skipped') {
|
||||
throw new BadRequestException(`阶段「${IMPORT_STEP_LABELS[stepKey]}」没有分配工作表`);
|
||||
}
|
||||
if (step.status === 'committed') {
|
||||
throw new BadRequestException(`阶段「${IMPORT_STEP_LABELS[stepKey]}」已提交,不能重复预览`);
|
||||
}
|
||||
|
||||
const sheetsData =
|
||||
parseJson<Array<{ name: string; headers: string[]; rows: CellValue[][] }>>(run.sheetsJson) ??
|
||||
[];
|
||||
const sheetNames = body.sheets?.length
|
||||
? body.sheets
|
||||
: (parseJson<string[]>(step.sheetsJson) ?? []);
|
||||
const usedSheets = sheetNames.filter((name) => sheetsData.some((s) => s.name === name));
|
||||
if (usedSheets.length === 0) {
|
||||
throw new BadRequestException('指定的工作表不存在');
|
||||
}
|
||||
|
||||
const mapping =
|
||||
body.mapping && Object.keys(body.mapping).length > 0
|
||||
? body.mapping
|
||||
: (parseJson<ColumnMapping>(step.mappingJson) ??
|
||||
suggestMapping(sheetsData[0]?.headers ?? [], stepKey));
|
||||
assertMapping(stepKey, mapping, sheetsData, usedSheets);
|
||||
|
||||
await this.rows.delete({ stepId: step.id });
|
||||
const rowEntities: ImportRow[] = [];
|
||||
const summary: StepPreviewSummary = {
|
||||
total: 0,
|
||||
valid: 0,
|
||||
error: 0,
|
||||
create: 0,
|
||||
update: 0,
|
||||
skip: 0,
|
||||
};
|
||||
const batchState: ImportBatchState = {
|
||||
checkinStudentIds: new Set<number>(),
|
||||
transferStudentIds: new Set<number>(),
|
||||
};
|
||||
|
||||
for (const sheetName of usedSheets) {
|
||||
const sheet = sheetsData.find((s) => s.name === sheetName);
|
||||
if (!sheet) continue;
|
||||
const lookups = await buildLookups(
|
||||
this.dataSource,
|
||||
stepKey,
|
||||
sheet.headers,
|
||||
sheet.rows,
|
||||
mapping,
|
||||
);
|
||||
for (let i = 0; i < sheet.rows.length; i += 1) {
|
||||
const rawValues = sheet.rows[i];
|
||||
const raw: Record<string, CellValue> = {};
|
||||
sheet.headers.forEach((header, index) => {
|
||||
raw[header] = rawValues[index] ?? null;
|
||||
});
|
||||
const fields: Record<string, CellValue> = {};
|
||||
for (const [field, header] of Object.entries(mapping)) {
|
||||
fields[field] = rawValues[sheet.headers.indexOf(header)] ?? null;
|
||||
}
|
||||
const result = validateRow(stepKey, fields, lookups, batchState);
|
||||
const normalized = { ...result.normalized, ...result.resolvedIds };
|
||||
summary.total += 1;
|
||||
if (result.errors.length > 0) {
|
||||
summary.error += 1;
|
||||
} else {
|
||||
summary.valid += 1;
|
||||
if (result.action === 'create') summary.create += 1;
|
||||
if (result.action === 'update') summary.update += 1;
|
||||
if (result.action === 'create') {
|
||||
const studentId = result.resolvedIds._studentId;
|
||||
if (studentId !== undefined) {
|
||||
if (stepKey === 'checkins') batchState.checkinStudentIds.add(studentId);
|
||||
if (stepKey === 'transfers') batchState.transferStudentIds.add(studentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
rowEntities.push(
|
||||
this.rows.create({
|
||||
runId,
|
||||
stepId: step.id,
|
||||
sheetName,
|
||||
rowNumber: i + 2,
|
||||
rawJson: JSON.stringify(raw),
|
||||
normalizedJson: JSON.stringify(normalized),
|
||||
matchKey: result.matchKey,
|
||||
action: result.action,
|
||||
status: result.errors.length > 0 ? 'error' : 'valid',
|
||||
errorsJson: result.errors.length > 0 ? JSON.stringify(result.errors) : null,
|
||||
targetId: result.targetId ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.rows.save(rowEntities);
|
||||
step.sheetsJson = JSON.stringify(usedSheets);
|
||||
step.mappingJson = JSON.stringify(mapping);
|
||||
step.status = 'ready';
|
||||
step.summaryJson = JSON.stringify(summary);
|
||||
await this.steps.save(step);
|
||||
|
||||
const headers = sheetsData.find((s) => s.name === usedSheets[0])?.headers ?? [];
|
||||
const rows = rowEntities.map((entity) => ({
|
||||
id: entity.id,
|
||||
rowNumber: entity.rowNumber,
|
||||
sheetName: entity.sheetName,
|
||||
raw: parseJson<Record<string, CellValue>>(entity.rawJson) ?? {},
|
||||
fields: parseJson<Record<string, CellValue>>(entity.normalizedJson) ?? {},
|
||||
action: entity.action,
|
||||
status: entity.status,
|
||||
errors: parseJson<string[]>(entity.errorsJson) ?? [],
|
||||
}));
|
||||
return { stepKey, sheetNames: usedSheets, headers, mapping, rows, summary };
|
||||
}
|
||||
}
|
||||
341
apps/server/src/imports/imports.rows.ts
Normal file
341
apps/server/src/imports/imports.rows.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { EntityManager } from 'typeorm';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { applyString, optionalNumber, parseDateValue, textValue } from './imports.helpers';
|
||||
import type { ImportLookups } from './imports.lookups';
|
||||
import type {
|
||||
CellValue,
|
||||
ImportRowAction,
|
||||
ImportStepKey,
|
||||
} from './imports.types';
|
||||
|
||||
const PHONE_RE = /^1[3-9]\d{9}$/;
|
||||
|
||||
/** 预览批次内的动态状态,用于阻止同一文件中产生重复的在住/换宿记录。 */
|
||||
export interface ImportBatchState {
|
||||
checkinStudentIds: Set<number>;
|
||||
transferStudentIds: Set<number>;
|
||||
}
|
||||
|
||||
export interface ValidatedRow {
|
||||
errors: string[];
|
||||
action: ImportRowAction | null;
|
||||
matchKey: string | null;
|
||||
targetId: number | null;
|
||||
normalized: Record<string, CellValue>;
|
||||
resolvedIds: Record<string, number>;
|
||||
}
|
||||
|
||||
export function validateRow(
|
||||
stepKey: ImportStepKey,
|
||||
fields: Record<string, CellValue>,
|
||||
lookups: ImportLookups,
|
||||
batchState?: ImportBatchState,
|
||||
): ValidatedRow {
|
||||
const errors: string[] = [];
|
||||
let action: ImportRowAction | null = null;
|
||||
let matchKey: string | null = null;
|
||||
let targetId: number | null = null;
|
||||
const normalized: Record<string, CellValue> = { ...fields };
|
||||
const resolvedIds: Record<string, number> = {};
|
||||
|
||||
if (stepKey === 'students') {
|
||||
const name = textValue(fields.name);
|
||||
if (!name) errors.push('姓名不能为空');
|
||||
normalized.name = name;
|
||||
const phone = textValue(fields.phone);
|
||||
if (phone && !PHONE_RE.test(phone)) errors.push('手机号格式不正确');
|
||||
normalized.phone = phone;
|
||||
const genderRaw = textValue(fields.gender);
|
||||
let gender = genderRaw;
|
||||
if (genderRaw === '男' || genderRaw === '男性') gender = 'male';
|
||||
if (genderRaw === '女' || genderRaw === '女性') gender = 'female';
|
||||
if (genderRaw && !['male', 'female', '男', '女'].includes(genderRaw)) {
|
||||
errors.push('性别只能是男/女');
|
||||
}
|
||||
normalized.gender = gender;
|
||||
const statusRaw = textValue(fields.status);
|
||||
if (statusRaw && !['active', 'inactive', 'archived'].includes(statusRaw)) {
|
||||
errors.push('状态只能是 active/inactive/archived');
|
||||
}
|
||||
normalized.status = statusRaw || 'active';
|
||||
normalized.studentNo = textValue(fields.studentNo);
|
||||
normalized.idNumber = textValue(fields.idNumber);
|
||||
normalized.ethnicity = textValue(fields.ethnicity);
|
||||
normalized.emergencyContact = textValue(fields.emergencyContact);
|
||||
normalized.emergencyPhone = textValue(fields.emergencyPhone);
|
||||
const orgName = textValue(fields.organization);
|
||||
normalized.organization = orgName;
|
||||
if (orgName) {
|
||||
const org = lookups.organizations.get(orgName);
|
||||
if (!org) errors.push(`未找到校区:${orgName}`);
|
||||
else resolvedIds._organizationId = org.id;
|
||||
}
|
||||
const studentNo = textValue(fields.studentNo);
|
||||
let matched = studentNo ? lookups.studentsByNo.get(studentNo) : undefined;
|
||||
if (!matched && phone) matched = lookups.studentsByPhone.get(phone);
|
||||
if (matched) {
|
||||
action = 'update';
|
||||
targetId = matched.id;
|
||||
matchKey =
|
||||
studentNo && lookups.studentsByNo.get(studentNo) === matched
|
||||
? studentNo
|
||||
: phone || studentNo || null;
|
||||
} else {
|
||||
action = 'create';
|
||||
matchKey = studentNo || phone || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (stepKey === 'rooms') {
|
||||
const roomNumber = textValue(fields.roomNumber);
|
||||
if (!roomNumber) errors.push('宿舍号不能为空');
|
||||
normalized.roomNumber = roomNumber;
|
||||
normalized.building = textValue(fields.building);
|
||||
normalized.roomType = textValue(fields.roomType);
|
||||
const capacity = Number(fields.capacity);
|
||||
if (!Number.isInteger(capacity) || capacity <= 0 || capacity > 999) {
|
||||
errors.push('容量必须是 1-999 的整数');
|
||||
}
|
||||
normalized.capacity = capacity;
|
||||
if (fields.floor !== null && fields.floor !== undefined && fields.floor !== '') {
|
||||
const floor = Number(fields.floor);
|
||||
if (!Number.isInteger(floor) || floor < 0) errors.push('楼层必须是大于等于 0 的整数');
|
||||
normalized.floor = floor;
|
||||
}
|
||||
const rentalCategory = textValue(fields.rentalCategory);
|
||||
if (rentalCategory && !['short', 'long'].includes(rentalCategory)) {
|
||||
errors.push('租期类型只能是 short/long');
|
||||
}
|
||||
normalized.rentalCategory = rentalCategory || 'short';
|
||||
const monthlyRate = Number(fields.monthlyRate ?? 0);
|
||||
if (!Number.isFinite(monthlyRate) || monthlyRate < 0)
|
||||
errors.push('月租必须是大于等于 0 的数字');
|
||||
normalized.monthlyRate = monthlyRate;
|
||||
const matched = roomNumber ? lookups.roomsByNumber.get(roomNumber) : undefined;
|
||||
if (matched) {
|
||||
action = 'update';
|
||||
targetId = matched.id;
|
||||
matchKey = roomNumber;
|
||||
} else {
|
||||
action = 'create';
|
||||
matchKey = roomNumber || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (stepKey === 'checkins' || stepKey === 'transfers') {
|
||||
const studentNo = textValue(fields.studentNo);
|
||||
const phone = textValue(fields.phone);
|
||||
const name = textValue(fields.name);
|
||||
let student: Student | undefined;
|
||||
if (studentNo) {
|
||||
student = lookups.studentsByNo.get(studentNo);
|
||||
if (!student && phone) student = lookups.studentsByPhone.get(phone);
|
||||
if (!student) errors.push('未找到匹配学生:请先完成“学生档案”阶段,或核对学号/手机号');
|
||||
} else if (phone) {
|
||||
student = lookups.studentsByPhone.get(phone);
|
||||
if (!student) errors.push('未找到匹配学生:请先完成“学生档案”阶段,或核对手机号');
|
||||
} else {
|
||||
errors.push('缺少学生标识:请映射“学号”或“手机号”');
|
||||
}
|
||||
if (student && name && student.name !== name) {
|
||||
errors.push(`姓名与手机号不匹配(档案姓名:${student.name})`);
|
||||
}
|
||||
if (student) resolvedIds._studentId = student.id;
|
||||
const roomNumber = textValue(fields.roomNumber);
|
||||
const room = roomNumber ? lookups.roomsByNumber.get(roomNumber) : undefined;
|
||||
if (stepKey === 'checkins') {
|
||||
if (!roomNumber) errors.push('宿舍号不能为空');
|
||||
if (roomNumber && !room) {
|
||||
errors.push('未找到宿舍:请先完成“宿舍档案”阶段,或核对宿舍号');
|
||||
}
|
||||
const checkInDate = parseDateValue(fields.checkInDate);
|
||||
if (!checkInDate) errors.push('入住日期格式不正确(应为 YYYY-MM-DD)');
|
||||
normalized.checkInDate = checkInDate;
|
||||
normalized.stayType = textValue(fields.stayType) || 'short';
|
||||
if (student) {
|
||||
const active = lookups.activeOccupancies.get(student.id) ?? [];
|
||||
const batchCheckedIn = batchState?.checkinStudentIds.has(student.id) ?? false;
|
||||
const batchTransferred = batchState?.transferStudentIds.has(student.id) ?? false;
|
||||
if (batchCheckedIn || batchTransferred) {
|
||||
errors.push('该学生本次文件中已有在住记录,请勿重复导入');
|
||||
} else if (active.some((o) => o.roomId === room?.id)) {
|
||||
errors.push('该学生已有该宿舍的在住记录');
|
||||
} else if (active.length > 0) {
|
||||
errors.push('该学生已有在住记录:如需换宿请使用“换宿记录”阶段');
|
||||
}
|
||||
}
|
||||
if (errors.length === 0 && student && room) {
|
||||
action = 'create';
|
||||
matchKey = `${student.id}|${room.id}`;
|
||||
resolvedIds._roomId = room.id;
|
||||
}
|
||||
} else {
|
||||
const oldRoomNumber = textValue(fields.oldRoom);
|
||||
const newRoomNumber = textValue(fields.newRoom);
|
||||
const oldRoom = oldRoomNumber ? lookups.roomsByNumber.get(oldRoomNumber) : undefined;
|
||||
const newRoom = newRoomNumber ? lookups.roomsByNumber.get(newRoomNumber) : undefined;
|
||||
if (!oldRoomNumber) errors.push('原宿舍不能为空');
|
||||
if (oldRoomNumber && !oldRoom) {
|
||||
errors.push('未找到原宿舍:请先完成“宿舍档案”阶段,或核对宿舍号');
|
||||
}
|
||||
if (!newRoomNumber) errors.push('新宿舍不能为空');
|
||||
if (newRoomNumber && !newRoom) {
|
||||
errors.push('未找到新宿舍:请先完成“宿舍档案”阶段,或核对宿舍号');
|
||||
}
|
||||
if (oldRoom && newRoom && oldRoom.id === newRoom.id) errors.push('原宿舍和新宿舍不能相同');
|
||||
const transferDate = parseDateValue(fields.transferDate);
|
||||
if (!transferDate) errors.push('换宿日期格式不正确(应为 YYYY-MM-DD)');
|
||||
normalized.transferDate = transferDate;
|
||||
normalized.reason = textValue(fields.reason);
|
||||
if (student) {
|
||||
const active = lookups.activeOccupancies.get(student.id) ?? [];
|
||||
const batchCheckedIn = batchState?.checkinStudentIds.has(student.id) ?? false;
|
||||
const batchTransferred = batchState?.transferStudentIds.has(student.id) ?? false;
|
||||
const oldOccupancy = active.find((o) => o.roomId === oldRoom?.id);
|
||||
if (batchTransferred) {
|
||||
errors.push('该学生本次文件中已有换宿记录,请勿重复换宿');
|
||||
} else if (batchCheckedIn) {
|
||||
errors.push('该学生的入住记录来自本次文件,请先提交入住阶段后再换宿');
|
||||
} else if (!oldOccupancy) {
|
||||
errors.push('未找到该学生在原宿舍的在住记录:请先完成“入住记录”阶段');
|
||||
} else {
|
||||
targetId = oldOccupancy.id;
|
||||
}
|
||||
}
|
||||
if (errors.length === 0 && student && oldRoom && newRoom) {
|
||||
action = 'create';
|
||||
matchKey = `${student.id}|${oldRoom.id}->${newRoom.id}`;
|
||||
resolvedIds._newRoomId = newRoom.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { errors, action, matchKey, targetId, normalized, resolvedIds };
|
||||
}
|
||||
|
||||
export async function writeRow(
|
||||
manager: EntityManager,
|
||||
stepKey: ImportStepKey,
|
||||
action: ImportRowAction,
|
||||
fields: Record<string, CellValue>,
|
||||
targetId: number | null,
|
||||
): Promise<number | null> {
|
||||
if (stepKey === 'students') {
|
||||
const studentRepo = manager.getRepository(Student);
|
||||
if (action === 'create') {
|
||||
const student = studentRepo.create({
|
||||
name: textValue(fields.name),
|
||||
studentNo: textValue(fields.studentNo) || undefined,
|
||||
phone: textValue(fields.phone) || undefined,
|
||||
idNumber: textValue(fields.idNumber) || undefined,
|
||||
gender: textValue(fields.gender) || undefined,
|
||||
ethnicity: textValue(fields.ethnicity) || undefined,
|
||||
emergencyContact: textValue(fields.emergencyContact) || undefined,
|
||||
emergencyPhone: textValue(fields.emergencyPhone) || undefined,
|
||||
status: textValue(fields.status) || 'active',
|
||||
organizationId: optionalNumber(fields._organizationId) ?? undefined,
|
||||
});
|
||||
await studentRepo.save(student);
|
||||
return student.id;
|
||||
}
|
||||
if (!targetId) throw new Error('缺少待更新学生记录');
|
||||
const student = await studentRepo.findOneBy({ id: targetId });
|
||||
if (!student) throw new Error('待更新的学生记录不存在');
|
||||
applyString(student, 'name', fields.name);
|
||||
applyString(student, 'studentNo', fields.studentNo);
|
||||
applyString(student, 'phone', fields.phone);
|
||||
applyString(student, 'idNumber', fields.idNumber);
|
||||
applyString(student, 'gender', fields.gender);
|
||||
applyString(student, 'ethnicity', fields.ethnicity);
|
||||
applyString(student, 'emergencyContact', fields.emergencyContact);
|
||||
applyString(student, 'emergencyPhone', fields.emergencyPhone);
|
||||
applyString(student, 'status', fields.status);
|
||||
const orgId = optionalNumber(fields._organizationId);
|
||||
if (orgId !== null) {
|
||||
student.organizationId = orgId;
|
||||
}
|
||||
await studentRepo.save(student);
|
||||
return student.id;
|
||||
}
|
||||
|
||||
if (stepKey === 'rooms') {
|
||||
const roomRepo = manager.getRepository(Room);
|
||||
if (action === 'create') {
|
||||
const room = roomRepo.create({
|
||||
roomNumber: textValue(fields.roomNumber),
|
||||
building: textValue(fields.building) || undefined,
|
||||
floor: optionalNumber(fields.floor) ?? undefined,
|
||||
capacity: Number(fields.capacity),
|
||||
status: 'available',
|
||||
roomType: textValue(fields.roomType) || undefined,
|
||||
rentalCategory: textValue(fields.rentalCategory) || 'short',
|
||||
monthlyRate: Number(fields.monthlyRate ?? 0),
|
||||
});
|
||||
await roomRepo.save(room);
|
||||
return room.id;
|
||||
}
|
||||
if (!targetId) throw new Error('缺少待更新宿舍记录');
|
||||
const room = await roomRepo.findOneBy({ id: targetId });
|
||||
if (!room) throw new Error('待更新的宿舍记录不存在');
|
||||
applyString(room, 'roomNumber', fields.roomNumber);
|
||||
applyString(room, 'building', fields.building);
|
||||
applyString(room, 'roomType', fields.roomType);
|
||||
applyString(room, 'rentalCategory', fields.rentalCategory);
|
||||
if (fields.floor !== null && fields.floor !== undefined && fields.floor !== '') {
|
||||
room.floor = Number(fields.floor);
|
||||
}
|
||||
if (fields.capacity !== null && fields.capacity !== undefined && fields.capacity !== '') {
|
||||
room.capacity = Number(fields.capacity);
|
||||
}
|
||||
if (
|
||||
fields.monthlyRate !== null &&
|
||||
fields.monthlyRate !== undefined &&
|
||||
fields.monthlyRate !== ''
|
||||
) {
|
||||
room.monthlyRate = Number(fields.monthlyRate);
|
||||
}
|
||||
await roomRepo.save(room);
|
||||
return room.id;
|
||||
}
|
||||
|
||||
if (stepKey === 'checkins') {
|
||||
const studentId = optionalNumber(fields._studentId);
|
||||
const roomId = optionalNumber(fields._roomId);
|
||||
if (!studentId || !roomId) throw new Error('缺少学生或宿舍 ID');
|
||||
const occupancy = manager.getRepository(Occupancy).create({
|
||||
studentId,
|
||||
roomId,
|
||||
checkInDate: String(fields.checkInDate),
|
||||
billingStartDate: String(fields.checkInDate),
|
||||
status: 'active',
|
||||
stayType: textValue(fields.stayType) || 'short',
|
||||
});
|
||||
await manager.save(Occupancy, occupancy);
|
||||
return occupancy.id;
|
||||
}
|
||||
|
||||
if (stepKey === 'transfers') {
|
||||
if (!targetId) throw new Error('缺少原入住记录');
|
||||
const studentId = optionalNumber(fields._studentId);
|
||||
const newRoomId = optionalNumber(fields._newRoomId);
|
||||
if (!studentId || !newRoomId) throw new Error('缺少学生或新宿舍 ID');
|
||||
const oldOccupancy = await manager.getRepository(Occupancy).findOneBy({ id: targetId });
|
||||
if (!oldOccupancy) throw new Error('原入住记录不存在');
|
||||
oldOccupancy.checkOutDate = String(fields.transferDate);
|
||||
oldOccupancy.status = 'archived';
|
||||
await manager.save(Occupancy, oldOccupancy);
|
||||
const newOccupancy = manager.getRepository(Occupancy).create({
|
||||
studentId,
|
||||
roomId: newRoomId,
|
||||
checkInDate: String(fields.transferDate),
|
||||
billingStartDate: String(fields.transferDate),
|
||||
status: 'active',
|
||||
stayType: oldOccupancy.stayType,
|
||||
});
|
||||
await manager.save(Occupancy, newOccupancy);
|
||||
return newOccupancy.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
170
apps/server/src/imports/imports.run.service.ts
Normal file
170
apps/server/src/imports/imports.run.service.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import { ImportRun } from './entities/import-run.entity';
|
||||
import { ImportStep } from './entities/import-step.entity';
|
||||
import {
|
||||
IMPORT_STEP_LABELS,
|
||||
IMPORT_STEP_ORDER,
|
||||
} from './imports.types';
|
||||
import type {
|
||||
CellValue,
|
||||
ColumnMapping,
|
||||
ImportRunSource,
|
||||
ImportStageRequest,
|
||||
ImportStepKey,
|
||||
ParsedImportFile,
|
||||
StepPreviewSummary,
|
||||
} from './imports.types';
|
||||
import { parseJson } from './imports.helpers';
|
||||
import { extractSheets } from './imports.workbook';
|
||||
import type { ImportSheetData } from './imports.workbook';
|
||||
import { autoAssignedSheets, resolveAssignedSheets, suggestMapping, suggestStep } from './imports.mapping';
|
||||
import { findOwnedRun } from './imports.access';
|
||||
import type { ImportPrincipal } from './imports.access';
|
||||
|
||||
@Injectable()
|
||||
export class ImportRunService {
|
||||
constructor(
|
||||
@InjectRepository(ImportRun)
|
||||
private readonly runs: Repository<ImportRun>,
|
||||
@InjectRepository(ImportStep)
|
||||
private readonly steps: Repository<ImportStep>,
|
||||
) {}
|
||||
|
||||
async createRun(
|
||||
principal: ImportPrincipal,
|
||||
source: ImportRunSource,
|
||||
file: ParsedImportFile,
|
||||
conversationId?: number | null,
|
||||
stages?: ImportStageRequest[],
|
||||
mappingByStep?: Partial<Record<ImportStepKey, ColumnMapping>>,
|
||||
) {
|
||||
if (!file.buffer || file.buffer.length === 0) {
|
||||
throw new BadRequestException('上传文件为空');
|
||||
}
|
||||
const isCsv =
|
||||
/\.csv$/i.test(file.originalName) ||
|
||||
/csv/i.test(file.mimeType) ||
|
||||
/text\/(csv|plain)/i.test(file.mimeType);
|
||||
const isXlsx =
|
||||
/\.xlsx$/i.test(file.originalName) ||
|
||||
/spreadsheetml/i.test(file.mimeType) ||
|
||||
/excel/i.test(file.mimeType);
|
||||
if (!isCsv && !isXlsx) {
|
||||
throw new BadRequestException('仅支持 .xlsx / .csv 文件');
|
||||
}
|
||||
if (/\.xls$/i.test(file.originalName) && !/\.xlsx$/i.test(file.originalName)) {
|
||||
throw new BadRequestException('暂不支持 .xls,请另存为 .xlsx 或 .csv 后重试');
|
||||
}
|
||||
|
||||
let sheets: ImportSheetData[];
|
||||
try {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
if (isCsv) {
|
||||
await workbook.csv.read(Readable.from(Buffer.from(file.buffer)));
|
||||
} else {
|
||||
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
|
||||
}
|
||||
sheets = extractSheets(workbook);
|
||||
} catch {
|
||||
throw new BadRequestException('Excel 文件解析失败,请检查文件格式');
|
||||
}
|
||||
if (!sheets.length) {
|
||||
throw new BadRequestException('文件中没有可用的工作表数据');
|
||||
}
|
||||
|
||||
const runId = randomUUID();
|
||||
const run = this.runs.create({
|
||||
id: runId,
|
||||
userId: principal.id,
|
||||
conversationId: conversationId ?? null,
|
||||
source,
|
||||
fileName: file.originalName.slice(0, 255),
|
||||
sheetsJson: JSON.stringify(sheets),
|
||||
status: 'ready',
|
||||
currentStepKey: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const stepRecords: ImportStep[] = [];
|
||||
for (const stepKey of IMPORT_STEP_ORDER) {
|
||||
const assigned =
|
||||
stages && stages.length > 0
|
||||
? resolveAssignedSheets(
|
||||
stages,
|
||||
stepKey,
|
||||
sheets.map((s) => s.name),
|
||||
)
|
||||
: autoAssignedSheets(sheets, stepKey);
|
||||
if (assigned.length === 0) {
|
||||
stepRecords.push(
|
||||
this.steps.create({
|
||||
runId,
|
||||
stepKey,
|
||||
sheetsJson: '[]',
|
||||
mappingJson: null,
|
||||
status: 'skipped',
|
||||
summaryJson: null,
|
||||
committedAt: null,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const firstSheet = sheets.find((s) => s.name === assigned[0]);
|
||||
const mapping =
|
||||
mappingByStep?.[stepKey] ?? suggestMapping(firstSheet?.headers ?? [], stepKey);
|
||||
stepRecords.push(
|
||||
this.steps.create({
|
||||
runId,
|
||||
stepKey,
|
||||
sheetsJson: JSON.stringify(assigned),
|
||||
mappingJson: mapping ? JSON.stringify(mapping) : null,
|
||||
status: 'pending',
|
||||
summaryJson: null,
|
||||
committedAt: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const firstActive = stepRecords.find((s) => s.status !== 'skipped');
|
||||
run.currentStepKey = firstActive?.stepKey ?? null;
|
||||
await this.runs.save(run);
|
||||
await this.steps.save(stepRecords);
|
||||
return this.getRun(principal.id, runId);
|
||||
}
|
||||
|
||||
async getRun(userId: number, runId: string) {
|
||||
const run = await findOwnedRun(this.runs, userId, runId);
|
||||
const stepRecords = await this.steps.find({ where: { runId }, order: { id: 'ASC' } });
|
||||
const sheets =
|
||||
parseJson<Array<{ name: string; headers: string[]; rows: CellValue[][] }>>(run.sheetsJson) ??
|
||||
[];
|
||||
return {
|
||||
id: run.id,
|
||||
fileName: run.fileName,
|
||||
source: run.source,
|
||||
status: run.status,
|
||||
currentStepKey: run.currentStepKey,
|
||||
createdAt: run.createdAt.toISOString(),
|
||||
sheets: sheets.map((sheet) => ({
|
||||
name: sheet.name,
|
||||
headers: sheet.headers,
|
||||
rowCount: sheet.rows.length,
|
||||
suggestedStepKey: suggestStep(sheet.headers)?.stepKey ?? null,
|
||||
})),
|
||||
steps: stepRecords.map((step) => ({
|
||||
id: step.id,
|
||||
stepKey: step.stepKey,
|
||||
label: IMPORT_STEP_LABELS[step.stepKey],
|
||||
sheets: parseJson<string[]>(step.sheetsJson) ?? [],
|
||||
status: step.status,
|
||||
mapping: parseJson<ColumnMapping>(step.mappingJson) ?? {},
|
||||
summary: parseJson<StepPreviewSummary>(step.summaryJson),
|
||||
committedAt: step.committedAt?.toISOString() ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
606
apps/server/src/imports/imports.service.spec.ts
Normal file
606
apps/server/src/imports/imports.service.spec.ts
Normal file
@@ -0,0 +1,606 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import { Organization } from '../entities/organization.entity';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { ImportRun } from './entities/import-run.entity';
|
||||
import { ImportStep } from './entities/import-step.entity';
|
||||
import { ImportRow } from './entities/import-row.entity';
|
||||
import { ImportsService } from './imports.service';
|
||||
import type { ParsedImportFile } from './imports.types';
|
||||
|
||||
function makeRowsRepo() {
|
||||
let nextId = 1;
|
||||
return {
|
||||
create: jest.fn((value: unknown) => value),
|
||||
save: jest.fn(async (rows: unknown[]) => {
|
||||
const list = Array.isArray(rows) ? rows : [rows];
|
||||
for (const row of list) {
|
||||
const record = row as { id?: number };
|
||||
if (record.id === undefined) record.id = nextId++;
|
||||
}
|
||||
return list;
|
||||
}),
|
||||
delete: jest.fn().mockResolvedValue({ affected: 0 }),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
}
|
||||
|
||||
function makeStepsRepo(step: ImportStep) {
|
||||
return {
|
||||
create: jest.fn((value: unknown) => value),
|
||||
save: jest.fn(async (value: unknown) => value),
|
||||
findOne: jest.fn().mockResolvedValue(step),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
}
|
||||
|
||||
function makeRunsRepo(run: ImportRun) {
|
||||
return {
|
||||
create: jest.fn((value: unknown) => value),
|
||||
save: jest.fn(async (value: unknown) => value),
|
||||
findOne: jest.fn().mockResolvedValue(run),
|
||||
};
|
||||
}
|
||||
|
||||
function studentSheet() {
|
||||
return {
|
||||
name: '学生',
|
||||
headers: ['姓名', '学号', '手机号'],
|
||||
rows: [['张三', '2024001', '13800138000']],
|
||||
};
|
||||
}
|
||||
|
||||
async function xlsxBuffer(sheet: {
|
||||
name: string;
|
||||
headers: string[];
|
||||
rows: unknown[][];
|
||||
}): Promise<Buffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet(sheet.name);
|
||||
ws.addRow(sheet.headers);
|
||||
for (const row of sheet.rows) ws.addRow(row);
|
||||
return (await workbook.xlsx.writeBuffer()) as Buffer;
|
||||
}
|
||||
|
||||
function fileOf(name: string, buffer: Buffer): ParsedImportFile {
|
||||
return {
|
||||
originalName: name,
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
size: buffer.length,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
const principal = {
|
||||
id: 7,
|
||||
permissions: ['student:import', 'room:create', 'occupancy:checkin'],
|
||||
isSuperAdmin: false,
|
||||
};
|
||||
|
||||
describe('ImportsService', () => {
|
||||
it('拒绝 .xls 文件', async () => {
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo({} as ImportRun) as never,
|
||||
makeStepsRepo({} as ImportStep) as never,
|
||||
makeRowsRepo() as never,
|
||||
{} as never,
|
||||
);
|
||||
await expect(
|
||||
service.createRun(principal, 'manual', fileOf('a.xls', Buffer.from('not excel'))),
|
||||
).rejects.toThrow('暂不支持 .xls');
|
||||
});
|
||||
|
||||
it('上传学生表时自动分配 students 阶段并返回运行详情', async () => {
|
||||
const buffer = await xlsxBuffer(studentSheet());
|
||||
const run = {
|
||||
id: 'run-1',
|
||||
userId: 7,
|
||||
conversationId: null,
|
||||
source: 'manual',
|
||||
fileName: 'students.xlsx',
|
||||
sheetsJson: JSON.stringify([studentSheet()]),
|
||||
status: 'ready',
|
||||
currentStepKey: 'students',
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as ImportRun;
|
||||
const steps = [
|
||||
{
|
||||
id: 1,
|
||||
runId: 'run-1',
|
||||
stepKey: 'students',
|
||||
sheetsJson: '["学生"]',
|
||||
mappingJson: '{"name":"姓名","studentNo":"学号","phone":"手机号"}',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
runId: 'run-1',
|
||||
stepKey: 'rooms',
|
||||
sheetsJson: '[]',
|
||||
mappingJson: null,
|
||||
status: 'skipped',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
runId: 'run-1',
|
||||
stepKey: 'checkins',
|
||||
sheetsJson: '[]',
|
||||
mappingJson: null,
|
||||
status: 'skipped',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
runId: 'run-1',
|
||||
stepKey: 'transfers',
|
||||
sheetsJson: '[]',
|
||||
mappingJson: null,
|
||||
status: 'skipped',
|
||||
},
|
||||
] as unknown as ImportStep[];
|
||||
const runsRepo = makeRunsRepo(run);
|
||||
const stepsRepo = {
|
||||
create: jest.fn((value: unknown) => value),
|
||||
save: jest.fn(async (value: unknown) => value),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
find: jest.fn().mockResolvedValue(steps),
|
||||
};
|
||||
const service = new ImportsService(
|
||||
runsRepo as never,
|
||||
stepsRepo as never,
|
||||
makeRowsRepo() as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const detail = await service.createRun(principal, 'manual', fileOf('students.xlsx', buffer));
|
||||
expect(detail.currentStepKey).toBe('students');
|
||||
expect(detail.steps.find((step) => step.stepKey === 'students')?.sheets).toEqual(['学生']);
|
||||
expect(detail.steps.find((step) => step.stepKey === 'students')?.mapping).toEqual({
|
||||
name: '姓名',
|
||||
studentNo: '学号',
|
||||
phone: '手机号',
|
||||
});
|
||||
});
|
||||
|
||||
it('预览学生阶段:已有学号判为更新,并保留目标记录 ID', async () => {
|
||||
const existing = {
|
||||
id: 88,
|
||||
name: '张三',
|
||||
studentNo: '2024001',
|
||||
phone: '13800138000',
|
||||
} as Student;
|
||||
const run = {
|
||||
id: 'run-1',
|
||||
userId: 7,
|
||||
source: 'manual',
|
||||
fileName: 'students.xlsx',
|
||||
sheetsJson: JSON.stringify([studentSheet()]),
|
||||
status: 'ready',
|
||||
currentStepKey: 'students',
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as ImportRun;
|
||||
const step = {
|
||||
id: 1,
|
||||
runId: 'run-1',
|
||||
stepKey: 'students',
|
||||
sheetsJson: '["学生"]',
|
||||
mappingJson: null,
|
||||
status: 'pending',
|
||||
} as ImportStep;
|
||||
const rowsRepo = makeRowsRepo();
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
|
||||
if (entity === Room) return { find: jest.fn().mockResolvedValue([]) };
|
||||
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
|
||||
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
};
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo(run) as never,
|
||||
makeStepsRepo(step) as never,
|
||||
rowsRepo as never,
|
||||
dataSource as never,
|
||||
);
|
||||
|
||||
const result = await service.previewStep(principal, 'run-1', 'students', {
|
||||
sheets: ['学生'],
|
||||
mapping: { name: '姓名', studentNo: '学号', phone: '手机号' },
|
||||
});
|
||||
expect(result.summary).toMatchObject({ total: 1, valid: 1, create: 0, update: 1 });
|
||||
expect(result.rows[0].action).toBe('update');
|
||||
expect(result.rows[0].status).toBe('valid');
|
||||
expect(result.rows[0].id).toBeDefined();
|
||||
});
|
||||
|
||||
it('预览入住阶段:宿舍不存在时按依赖错误提示', async () => {
|
||||
const existing = {
|
||||
id: 88,
|
||||
name: '张三',
|
||||
studentNo: '2024001',
|
||||
phone: '13800138000',
|
||||
} as Student;
|
||||
const run = {
|
||||
id: 'run-2',
|
||||
userId: 7,
|
||||
source: 'manual',
|
||||
fileName: 'checkins.xlsx',
|
||||
sheetsJson: JSON.stringify([
|
||||
{
|
||||
name: '入住',
|
||||
headers: ['姓名', '手机号', '宿舍号', '入住日期'],
|
||||
rows: [['张三', '13800138000', 'A101', '2026-09-01']],
|
||||
},
|
||||
]),
|
||||
status: 'ready',
|
||||
currentStepKey: 'checkins',
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as ImportRun;
|
||||
const step = {
|
||||
id: 3,
|
||||
runId: 'run-2',
|
||||
stepKey: 'checkins',
|
||||
sheetsJson: '["入住"]',
|
||||
mappingJson: null,
|
||||
status: 'pending',
|
||||
} as ImportStep;
|
||||
const rowsRepo = makeRowsRepo();
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
|
||||
if (entity === Room) return { find: jest.fn().mockResolvedValue([]) };
|
||||
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
|
||||
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
};
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo(run) as never,
|
||||
makeStepsRepo(step) as never,
|
||||
rowsRepo as never,
|
||||
dataSource as never,
|
||||
);
|
||||
|
||||
const result = await service.previewStep(principal, 'run-2', 'checkins', {
|
||||
sheets: ['入住'],
|
||||
mapping: {
|
||||
name: '姓名',
|
||||
phone: '手机号',
|
||||
roomNumber: '宿舍号',
|
||||
checkInDate: '入住日期',
|
||||
},
|
||||
});
|
||||
expect(result.summary).toMatchObject({ total: 1, valid: 0, error: 1 });
|
||||
expect(result.rows[0].status).toBe('error');
|
||||
expect(result.rows[0].errors.join(';')).toContain('未找到宿舍');
|
||||
});
|
||||
|
||||
it('提交阶段需要对应权限;已完成的任务幂等返回回执', async () => {
|
||||
const run = {
|
||||
id: 'run-1',
|
||||
userId: 7,
|
||||
source: 'manual',
|
||||
fileName: 'students.xlsx',
|
||||
sheetsJson: '[]',
|
||||
status: 'ready',
|
||||
currentStepKey: 'transfers',
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as ImportRun;
|
||||
const step = {
|
||||
id: 4,
|
||||
runId: 'run-1',
|
||||
stepKey: 'transfers',
|
||||
sheetsJson: '["换宿"]',
|
||||
mappingJson: '{}',
|
||||
status: 'ready',
|
||||
} as ImportStep;
|
||||
const rowsRepo = makeRowsRepo();
|
||||
rowsRepo.find.mockResolvedValue([]);
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo(run) as never,
|
||||
makeStepsRepo(step) as never,
|
||||
rowsRepo as never,
|
||||
{} as never,
|
||||
);
|
||||
await expect(
|
||||
service.commitStep(
|
||||
{ id: 7, permissions: ['student:import'], isSuperAdmin: false },
|
||||
'run-1',
|
||||
'transfers',
|
||||
[],
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
const committedRun = { ...run, status: 'committed', currentStepKey: null } as ImportRun;
|
||||
const committedStep = {
|
||||
id: 1,
|
||||
runId: 'run-1',
|
||||
stepKey: 'students',
|
||||
sheetsJson: '["学生"]',
|
||||
mappingJson: '{}',
|
||||
status: 'committed',
|
||||
summaryJson: JSON.stringify({ total: 1, valid: 1, error: 0, create: 1, update: 0, skip: 0 }),
|
||||
} as ImportStep;
|
||||
const committedService = new ImportsService(
|
||||
makeRunsRepo(committedRun) as never,
|
||||
makeStepsRepo(committedStep) as never,
|
||||
makeRowsRepo() as never,
|
||||
{} as never,
|
||||
);
|
||||
const receipt = await committedService.commitStep(principal, 'run-1', 'students', []);
|
||||
expect(receipt.status).toBe('already_committed');
|
||||
expect(receipt.created).toBe(1);
|
||||
});
|
||||
|
||||
it('提交阶段拒绝与预览分类矛盾的决策', async () => {
|
||||
const run = {
|
||||
id: 'run-1',
|
||||
userId: 7,
|
||||
source: 'manual',
|
||||
fileName: 'students.xlsx',
|
||||
sheetsJson: '[]',
|
||||
status: 'ready',
|
||||
currentStepKey: 'students',
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as ImportRun;
|
||||
const step = {
|
||||
id: 1,
|
||||
runId: 'run-1',
|
||||
stepKey: 'students',
|
||||
sheetsJson: '["学生"]',
|
||||
mappingJson: '{}',
|
||||
status: 'ready',
|
||||
} as ImportStep;
|
||||
const row = {
|
||||
id: 11,
|
||||
runId: 'run-1',
|
||||
stepId: 1,
|
||||
sheetName: '学生',
|
||||
rowNumber: 3,
|
||||
rawJson: '{}',
|
||||
normalizedJson: JSON.stringify({ name: '张三', studentNo: '2024001', phone: '13800138000' }),
|
||||
matchKey: '2024001',
|
||||
action: 'update',
|
||||
status: 'valid',
|
||||
errorsJson: null,
|
||||
targetId: 88,
|
||||
} as ImportRow;
|
||||
const rowsRepo = makeRowsRepo();
|
||||
rowsRepo.find.mockResolvedValue([row]);
|
||||
const dataSource = {
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo(run) as never,
|
||||
makeStepsRepo(step) as never,
|
||||
rowsRepo as never,
|
||||
dataSource as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.commitStep(principal, 'run-1', 'students', [{ rowId: 11, action: 'create' }]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.commitStep(principal, 'run-1', 'students', [{ rowId: 11, action: 'create' }]),
|
||||
).rejects.toThrow('预览判定为「更新」');
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('预览学生阶段:学号未命中时回退到手机号匹配', async () => {
|
||||
const existing = {
|
||||
id: 88,
|
||||
name: '张三',
|
||||
studentNo: '2024001',
|
||||
phone: '13800138000',
|
||||
} as Student;
|
||||
const run = {
|
||||
id: 'run-1',
|
||||
userId: 7,
|
||||
source: 'manual',
|
||||
fileName: 'students.xlsx',
|
||||
sheetsJson: JSON.stringify([
|
||||
{
|
||||
name: '学生',
|
||||
headers: ['姓名', '学号', '手机号'],
|
||||
rows: [['张三', '2024999', '13800138000']],
|
||||
},
|
||||
]),
|
||||
status: 'ready',
|
||||
currentStepKey: 'students',
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as ImportRun;
|
||||
const step = {
|
||||
id: 1,
|
||||
runId: 'run-1',
|
||||
stepKey: 'students',
|
||||
sheetsJson: '["学生"]',
|
||||
mappingJson: null,
|
||||
status: 'pending',
|
||||
} as ImportStep;
|
||||
const rowsRepo = makeRowsRepo();
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
|
||||
if (entity === Room) return { find: jest.fn().mockResolvedValue([]) };
|
||||
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
|
||||
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
};
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo(run) as never,
|
||||
makeStepsRepo(step) as never,
|
||||
rowsRepo as never,
|
||||
dataSource as never,
|
||||
);
|
||||
|
||||
const result = await service.previewStep(principal, 'run-1', 'students', {
|
||||
sheets: ['学生'],
|
||||
mapping: { name: '姓名', studentNo: '学号', phone: '手机号' },
|
||||
});
|
||||
expect(result.summary).toMatchObject({ total: 1, valid: 1, create: 0, update: 1 });
|
||||
expect(result.rows[0].action).toBe('update');
|
||||
expect(result.rows[0].status).toBe('valid');
|
||||
expect(result.rows[0].id).toBeDefined();
|
||||
});
|
||||
|
||||
it('预览入住阶段:同一文件内重复入住标记为错误', async () => {
|
||||
const existing = {
|
||||
id: 88,
|
||||
name: '张三',
|
||||
studentNo: '2024001',
|
||||
phone: '13800138000',
|
||||
} as Student;
|
||||
const room = { id: 5, roomNumber: 'A101' } as Room;
|
||||
const run = {
|
||||
id: 'run-2',
|
||||
userId: 7,
|
||||
source: 'manual',
|
||||
fileName: 'checkins.xlsx',
|
||||
sheetsJson: JSON.stringify([
|
||||
{
|
||||
name: '入住',
|
||||
headers: ['姓名', '手机号', '宿舍号', '入住日期'],
|
||||
rows: [
|
||||
['张三', '13800138000', 'A101', '2026-09-01'],
|
||||
['张三', '13800138000', 'A101', '2026-09-02'],
|
||||
],
|
||||
},
|
||||
]),
|
||||
status: 'ready',
|
||||
currentStepKey: 'checkins',
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as ImportRun;
|
||||
const step = {
|
||||
id: 3,
|
||||
runId: 'run-2',
|
||||
stepKey: 'checkins',
|
||||
sheetsJson: '["入住"]',
|
||||
mappingJson: null,
|
||||
status: 'pending',
|
||||
} as ImportStep;
|
||||
const rowsRepo = makeRowsRepo();
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
|
||||
if (entity === Room) return { find: jest.fn().mockResolvedValue([room]) };
|
||||
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
|
||||
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
};
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo(run) as never,
|
||||
makeStepsRepo(step) as never,
|
||||
rowsRepo as never,
|
||||
dataSource as never,
|
||||
);
|
||||
|
||||
const result = await service.previewStep(principal, 'run-2', 'checkins', {
|
||||
sheets: ['入住'],
|
||||
mapping: {
|
||||
name: '姓名',
|
||||
phone: '手机号',
|
||||
roomNumber: '宿舍号',
|
||||
checkInDate: '入住日期',
|
||||
},
|
||||
});
|
||||
expect(result.summary).toMatchObject({ total: 2, valid: 1, error: 1 });
|
||||
expect(result.rows[0].status).toBe('valid');
|
||||
expect(result.rows[1].status).toBe('error');
|
||||
expect(result.rows[1].errors.join(';')).toContain('请勿重复导入');
|
||||
});
|
||||
|
||||
it('预览换宿阶段:同一文件内重复换宿标记为错误', async () => {
|
||||
const existing = {
|
||||
id: 88,
|
||||
name: '张三',
|
||||
studentNo: '2024001',
|
||||
phone: '13800138000',
|
||||
} as Student;
|
||||
const oldRoom = { id: 5, roomNumber: 'A101' } as Room;
|
||||
const newRoom = { id: 6, roomNumber: 'B202' } as Room;
|
||||
const run = {
|
||||
id: 'run-3',
|
||||
userId: 7,
|
||||
source: 'manual',
|
||||
fileName: 'transfers.xlsx',
|
||||
sheetsJson: JSON.stringify([
|
||||
{
|
||||
name: '换宿',
|
||||
headers: ['姓名', '手机号', '原宿舍', '新宿舍', '换宿日期'],
|
||||
rows: [
|
||||
['张三', '13800138000', 'A101', 'B202', '2026-09-10'],
|
||||
['张三', '13800138000', 'A101', 'B202', '2026-09-11'],
|
||||
],
|
||||
},
|
||||
]),
|
||||
status: 'ready',
|
||||
currentStepKey: 'transfers',
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as ImportRun;
|
||||
const step = {
|
||||
id: 4,
|
||||
runId: 'run-3',
|
||||
stepKey: 'transfers',
|
||||
sheetsJson: '["换宿"]',
|
||||
mappingJson: null,
|
||||
status: 'pending',
|
||||
} as ImportStep;
|
||||
const rowsRepo = makeRowsRepo();
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
|
||||
if (entity === Room) return { find: jest.fn().mockResolvedValue([oldRoom, newRoom]) };
|
||||
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
|
||||
if (entity === Occupancy) {
|
||||
return {
|
||||
find: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 77, studentId: 88, roomId: 5, status: 'active' }]),
|
||||
};
|
||||
}
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
};
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo(run) as never,
|
||||
makeStepsRepo(step) as never,
|
||||
rowsRepo as never,
|
||||
dataSource as never,
|
||||
);
|
||||
|
||||
const result = await service.previewStep(principal, 'run-3', 'transfers', {
|
||||
sheets: ['换宿'],
|
||||
mapping: {
|
||||
name: '姓名',
|
||||
phone: '手机号',
|
||||
oldRoom: '原宿舍',
|
||||
newRoom: '新宿舍',
|
||||
transferDate: '换宿日期',
|
||||
},
|
||||
});
|
||||
expect(result.summary).toMatchObject({ total: 2, valid: 1, error: 1 });
|
||||
expect(result.rows[0].status).toBe('valid');
|
||||
expect(result.rows[1].status).toBe('error');
|
||||
expect(result.rows[1].errors.join(';')).toContain('请勿重复换宿');
|
||||
});
|
||||
});
|
||||
84
apps/server/src/imports/imports.service.ts
Normal file
84
apps/server/src/imports/imports.service.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { ImportRun } from './entities/import-run.entity';
|
||||
import { ImportStep } from './entities/import-step.entity';
|
||||
import { ImportRow } from './entities/import-row.entity';
|
||||
import { ImportRunService } from './imports.run.service';
|
||||
import { ImportPreviewService } from './imports.preview.service';
|
||||
import { ImportCommitService } from './imports.commit.service';
|
||||
|
||||
export type {
|
||||
ImportSheetMeta,
|
||||
ImportStepDetail,
|
||||
ImportRunDetail,
|
||||
StepPreviewResult,
|
||||
} from './imports.types';
|
||||
|
||||
@Injectable()
|
||||
export class ImportsService {
|
||||
private runService?: ImportRunService;
|
||||
private previewService?: ImportPreviewService;
|
||||
private commitService?: ImportCommitService;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ImportRun)
|
||||
private readonly runs: Repository<ImportRun>,
|
||||
@InjectRepository(ImportStep)
|
||||
private readonly steps: Repository<ImportStep>,
|
||||
@InjectRepository(ImportRow)
|
||||
private readonly rows: Repository<ImportRow>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
private get runsSvc(): ImportRunService {
|
||||
if (!this.runService) {
|
||||
this.runService = new ImportRunService(this.runs, this.steps);
|
||||
}
|
||||
return this.runService;
|
||||
}
|
||||
|
||||
private get previews(): ImportPreviewService {
|
||||
if (!this.previewService) {
|
||||
this.previewService = new ImportPreviewService(
|
||||
this.runs,
|
||||
this.steps,
|
||||
this.rows,
|
||||
this.dataSource,
|
||||
);
|
||||
}
|
||||
return this.previewService;
|
||||
}
|
||||
|
||||
private get commits(): ImportCommitService {
|
||||
if (!this.commitService) {
|
||||
this.commitService = new ImportCommitService(
|
||||
this.runs,
|
||||
this.steps,
|
||||
this.rows,
|
||||
this.dataSource,
|
||||
);
|
||||
}
|
||||
return this.commitService;
|
||||
}
|
||||
|
||||
async createRun(...args: Parameters<ImportRunService['createRun']>) {
|
||||
return this.runsSvc.createRun(...args);
|
||||
}
|
||||
|
||||
async getRun(...args: Parameters<ImportRunService['getRun']>) {
|
||||
return this.runsSvc.getRun(...args);
|
||||
}
|
||||
|
||||
async previewStep(...args: Parameters<ImportPreviewService['previewStep']>) {
|
||||
return this.previews.previewStep(...args);
|
||||
}
|
||||
|
||||
async commitStep(...args: Parameters<ImportCommitService['commitStep']>) {
|
||||
return this.commits.commitStep(...args);
|
||||
}
|
||||
|
||||
async errorReport(...args: Parameters<ImportCommitService['errorReport']>) {
|
||||
return this.commits.errorReport(...args);
|
||||
}
|
||||
}
|
||||
210
apps/server/src/imports/imports.types.ts
Normal file
210
apps/server/src/imports/imports.types.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Unified Excel batch-import workflow (v1).
|
||||
*
|
||||
* Staged by business dependency:
|
||||
* students / rooms (基础档案) → checkins / transfers (关系)
|
||||
* Each stage is previewed, confirmed and committed separately.
|
||||
*/
|
||||
|
||||
export const IMPORT_STEP_KEYS = ['students', 'rooms', 'checkins', 'transfers'] as const;
|
||||
export type ImportStepKey = (typeof IMPORT_STEP_KEYS)[number];
|
||||
|
||||
export const IMPORT_STEP_ORDER: readonly ImportStepKey[] = [
|
||||
'students',
|
||||
'rooms',
|
||||
'checkins',
|
||||
'transfers',
|
||||
];
|
||||
|
||||
export type CellValue = string | number | boolean | Date | null;
|
||||
|
||||
export type ImportRunSource = 'ai' | 'manual';
|
||||
|
||||
export type ImportRunStatus =
|
||||
| 'preparing'
|
||||
| 'ready'
|
||||
| 'committing'
|
||||
| 'committed'
|
||||
| 'failed'
|
||||
| 'expired';
|
||||
|
||||
export type ImportStepStatus =
|
||||
| 'pending'
|
||||
| 'ready'
|
||||
| 'committing'
|
||||
| 'committed'
|
||||
| 'failed'
|
||||
| 'skipped';
|
||||
|
||||
export type ImportRowStatus = 'pending' | 'valid' | 'error' | 'committed' | 'skipped';
|
||||
|
||||
export type ImportRowAction = 'create' | 'update' | 'skip';
|
||||
|
||||
/** field -> sheet header name */
|
||||
export type ColumnMapping = Record<string, string>;
|
||||
|
||||
// aislop-ignore-next-line: duplicate-type-declaration -- 与前端 ImportWizard 的 API 契约保持一致
|
||||
export interface ImportStageRequest {
|
||||
stepKey: ImportStepKey;
|
||||
sheet?: string;
|
||||
headerRow?: number;
|
||||
}
|
||||
|
||||
export interface ImportStageSuggestion {
|
||||
stepKey: ImportStepKey;
|
||||
mapping: ColumnMapping;
|
||||
matchedFields: number;
|
||||
}
|
||||
|
||||
export interface ParsedImportFile {
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface ImportRowDecision {
|
||||
rowId: number;
|
||||
action: ImportRowAction;
|
||||
}
|
||||
|
||||
export interface StepPreviewRow {
|
||||
id: number;
|
||||
rowNumber: number;
|
||||
sheetName: string;
|
||||
raw: Record<string, CellValue>;
|
||||
fields: Record<string, CellValue>;
|
||||
action: ImportRowAction | null;
|
||||
status: ImportRowStatus;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface StepPreviewSummary {
|
||||
total: number;
|
||||
valid: number;
|
||||
error: number;
|
||||
create: number;
|
||||
update: number;
|
||||
skip: number;
|
||||
}
|
||||
|
||||
export interface StepCommitReceipt {
|
||||
runId: string;
|
||||
stepKey: ImportStepKey;
|
||||
status: 'committed' | 'already_committed' | 'conflict';
|
||||
created: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
nextStepKey: ImportStepKey | null;
|
||||
runStatus: ImportRunStatus;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// aislop-ignore-next-line: duplicate-type-declaration -- 与前端 ImportWizard 的 API 契约保持一致
|
||||
export interface ImportSheetMeta {
|
||||
name: string;
|
||||
headers: string[];
|
||||
rowCount: number;
|
||||
suggestedStepKey: ImportStepKey | null;
|
||||
}
|
||||
|
||||
export interface ImportStepDetail {
|
||||
id: number;
|
||||
stepKey: ImportStepKey;
|
||||
label: string;
|
||||
sheets: string[];
|
||||
status: import('./entities/import-step.entity').ImportStep['status'];
|
||||
mapping: ColumnMapping;
|
||||
summary: StepPreviewSummary | null;
|
||||
committedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ImportRunDetail {
|
||||
id: string;
|
||||
fileName: string;
|
||||
source: ImportRunSource;
|
||||
status: import('./entities/import-run.entity').ImportRun['status'];
|
||||
currentStepKey: ImportStepKey | null;
|
||||
createdAt: string;
|
||||
sheets: ImportSheetMeta[];
|
||||
steps: ImportStepDetail[];
|
||||
}
|
||||
|
||||
export interface StepPreviewResult {
|
||||
stepKey: ImportStepKey;
|
||||
sheetNames: string[];
|
||||
headers: string[];
|
||||
mapping: ColumnMapping;
|
||||
rows: StepPreviewRow[];
|
||||
summary: StepPreviewSummary;
|
||||
}
|
||||
|
||||
export const IMPORT_ACTION_LABELS: Record<ImportRowAction, string> = {
|
||||
create: '新建',
|
||||
update: '更新',
|
||||
skip: '跳过',
|
||||
};
|
||||
|
||||
/** Field alias tables used to auto-suggest column mappings. */
|
||||
export const IMPORT_FIELD_ALIASES: Record<ImportStepKey, Record<string, string[]>> = {
|
||||
students: {
|
||||
name: ['姓名', '名字', '学生姓名', '学生名字'],
|
||||
studentNo: ['学号', '学生学号', '编号'],
|
||||
phone: ['手机号', '手机号码', '联系电话', '电话'],
|
||||
gender: ['性别'],
|
||||
idNumber: ['身份证', '身份证号', '身份证号码'],
|
||||
ethnicity: ['民族'],
|
||||
emergencyContact: ['紧急联系人'],
|
||||
emergencyPhone: ['紧急联系电话', '紧急电话'],
|
||||
organization: ['校区', '机构', '组织', '校区名称'],
|
||||
status: ['状态'],
|
||||
},
|
||||
rooms: {
|
||||
roomNumber: ['宿舍号', '房间号', '房号', '宿舍编号'],
|
||||
building: ['楼栋', '楼', '栋'],
|
||||
floor: ['楼层'],
|
||||
capacity: ['容量', '床位数', '人数'],
|
||||
roomType: ['房型', '房间类型', '宿舍类型'],
|
||||
rentalCategory: ['租期', '租期类型'],
|
||||
monthlyRate: ['月租', '月租金', '租金'],
|
||||
},
|
||||
checkins: {
|
||||
name: ['姓名', '学生姓名', '名字'],
|
||||
studentNo: ['学号', '学生学号'],
|
||||
phone: ['手机号', '手机号码', '电话'],
|
||||
roomNumber: ['宿舍号', '房间号', '房号'],
|
||||
checkInDate: ['入住日期', '入住时间', '日期'],
|
||||
stayType: ['住宿类型', '类型'],
|
||||
},
|
||||
transfers: {
|
||||
studentNo: ['学号', '学生学号'],
|
||||
phone: ['手机号', '手机号码'],
|
||||
oldRoom: ['原宿舍', '原房间', '原宿舍号', '旧宿舍', '旧房间'],
|
||||
newRoom: ['新宿舍', '新房间', '新宿舍号'],
|
||||
transferDate: ['换宿日期', '变更日期', '日期'],
|
||||
reason: ['原因', '备注', '换宿原因'],
|
||||
},
|
||||
};
|
||||
|
||||
export const IMPORT_STEP_LABELS: Record<ImportStepKey, string> = {
|
||||
students: '学生档案',
|
||||
rooms: '宿舍档案',
|
||||
checkins: '入住记录',
|
||||
transfers: '换宿记录',
|
||||
};
|
||||
|
||||
export const IMPORT_STEP_REQUIRED_FIELDS: Record<ImportStepKey, string[]> = {
|
||||
students: ['name'],
|
||||
rooms: ['roomNumber', 'capacity'],
|
||||
checkins: ['roomNumber', 'checkInDate'],
|
||||
transfers: ['oldRoom', 'newRoom', 'transferDate'],
|
||||
};
|
||||
|
||||
export const IMPORT_STEP_IDENTITY_FIELDS: Record<ImportStepKey, string[]> = {
|
||||
students: ['studentNo', 'phone'],
|
||||
rooms: ['roomNumber'],
|
||||
checkins: ['studentNo', 'phone'],
|
||||
transfers: ['studentNo', 'phone'],
|
||||
};
|
||||
39
apps/server/src/imports/imports.workbook.ts
Normal file
39
apps/server/src/imports/imports.workbook.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import { cellValue, textValue } from './imports.helpers';
|
||||
import type { CellValue } from './imports.types';
|
||||
|
||||
const MAX_SHEETS = 30;
|
||||
const MAX_ROWS_PER_SHEET = 3000;
|
||||
const MAX_COLS_PER_SHEET = 60;
|
||||
|
||||
export interface ImportSheetData {
|
||||
name: string;
|
||||
headers: string[];
|
||||
rows: CellValue[][];
|
||||
}
|
||||
|
||||
export function extractSheets(workbook: ExcelJS.Workbook): ImportSheetData[] {
|
||||
const sheets: ImportSheetData[] = [];
|
||||
for (const worksheet of workbook.worksheets) {
|
||||
if (sheets.length >= MAX_SHEETS) break;
|
||||
const headers: string[] = [];
|
||||
const rows: CellValue[][] = [];
|
||||
const firstRow = worksheet.getRow(1);
|
||||
for (let col = 1; col <= Math.min(firstRow.cellCount, MAX_COLS_PER_SHEET); col += 1) {
|
||||
const header = textValue(cellValue(firstRow.getCell(col)));
|
||||
headers.push(header);
|
||||
}
|
||||
if (!headers.some(Boolean)) continue;
|
||||
worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
||||
if (rowNumber === 1 || rows.length >= MAX_ROWS_PER_SHEET) return;
|
||||
const values: CellValue[] = [];
|
||||
for (let col = 1; col <= headers.length; col += 1) {
|
||||
values.push(cellValue(row.getCell(col)));
|
||||
}
|
||||
if (values.every((v) => v === null || textValue(v) === '')) return;
|
||||
rows.push(values);
|
||||
});
|
||||
if (rows.length > 0) sheets.push({ name: worksheet.name, headers, rows });
|
||||
}
|
||||
return sheets;
|
||||
}
|
||||
Reference in New Issue
Block a user