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, @InjectRepository(ImportStep) private readonly steps: Repository, @InjectRepository(ImportRow) private readonly rows: Repository, private readonly dataSource: DataSource, ) {} async commitStep( principal: ImportPrincipal, runId: string, stepKey: ImportStepKey, decisions: ImportRowDecision[], ): Promise { 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(); 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>(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>(row.rawJson) ?? {}; const errors = parseJson(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 { 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> { const step = await findStep(this.steps, run.id, stepKey); const summary = parseJson(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]}」此前已提交`, }; } }