- AI resolve 生成向导时按阶段携带全部 sheetNames,不再只取第一张表 - ImportStageRequest 支持 sheets 数组并兼容旧 sheet;手动重传同步修复 - headerMatches 收窄为单向包含,避免宿舍号被原/新宿舍号反向匹配 - suggestStep 增加入住/换宿显式表头信号,修复入住表误判为换宿 - 预检与预览按工作表逐表解析列映射,兼容异构表头 - 修复预检卡生成向导成功后按钮未复位 loading 的问题 - 补充 mapping/预检/run/ai-chat 多工作表测试
177 lines
6.6 KiB
TypeScript
177 lines
6.6 KiB
TypeScript
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<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[][];
|
|
headerRow?: number;
|
|
rowNumbers?: number[];
|
|
}>
|
|
>(run.sheetsJson) ?? [];
|
|
const settings = parseJson<ImportRunSettings>(run.settingsJson) ?? {};
|
|
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 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<string, CellValue> = {};
|
|
sheet.headers.forEach((header, index) => {
|
|
raw[header] = rawValues[index] ?? null;
|
|
});
|
|
const fields: Record<string, CellValue> = {};
|
|
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<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 };
|
|
}
|
|
}
|