feat: 新增通用导入中心

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

View File

@@ -0,0 +1,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]}」此前已提交`,
};
}
}