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, ImportRunSettings, ImportStepKey, StepPreviewSummary, } from './imports.types'; import { parseJson } from './imports.helpers'; import { assertMapping, resolveSheetMapping, suggestMapping } from './imports.mapping'; import { buildLookups } from './imports.lookups'; import { validateRow } from './imports.rows'; import type { ImportBatchState } from './imports.rows'; import { applyPreviewPolicies } from './imports.policies'; import { findOwnedRun, findStep } from './imports.access'; import type { ImportPrincipal } from './imports.access'; @Injectable() export class ImportPreviewService { constructor( @InjectRepository(ImportRun) private readonly runs: Repository, @InjectRepository(ImportStep) private readonly steps: Repository, @InjectRepository(ImportRow) private readonly rows: Repository, 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[][]; headerRow?: number; rowNumbers?: number[]; }> >(run.sheetsJson) ?? []; const settings = parseJson(run.settingsJson) ?? {}; const sheetNames = body.sheets?.length ? body.sheets : (parseJson(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(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(), transferStudentIds: new Set(), }; for (const sheetName of usedSheets) { const sheet = sheetsData.find((s) => s.name === sheetName); if (!sheet) continue; const sheetMapping = resolveSheetMapping(mapping, sheet.headers, stepKey); const lookups = await buildLookups( this.dataSource, stepKey, sheet.headers, sheet.rows, sheetMapping, ); for (let i = 0; i < sheet.rows.length; i += 1) { const rawValues = sheet.rows[i]; const raw: Record = {}; sheet.headers.forEach((header, index) => { raw[header] = rawValues[index] ?? null; }); const fields: Record = {}; for (const [field, header] of Object.entries(sheetMapping)) { fields[field] = rawValues[sheet.headers.indexOf(header)] ?? null; } const result = validateRow(stepKey, fields, lookups, batchState); const policy = applyPreviewPolicies(result, settings); const normalized = { ...result.normalized, ...result.resolvedIds }; summary.total += 1; if (policy.status === 'error') { summary.error += 1; } else { summary.valid += 1; if (policy.action === 'create') summary.create += 1; if (policy.action === 'update') summary.update += 1; if (policy.action === 'skip') summary.skip += 1; } if (policy.status === 'valid' && policy.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: sheet.rowNumbers?.[i] ?? (sheet.headerRow ?? 1) + i + 1, rawJson: JSON.stringify(raw), normalizedJson: JSON.stringify(normalized), matchKey: result.matchKey, action: policy.action, status: policy.status, errorsJson: policy.errors.length > 0 ? JSON.stringify(policy.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>(entity.rawJson) ?? {}, fields: parseJson>(entity.normalizedJson) ?? {}, action: entity.action, status: entity.status, errors: parseJson(entity.errorsJson) ?? [], })); return { stepKey, sheetNames: usedSheets, headers, mapping, rows, summary }; } }