Files
gongxue-base/apps/server/src/imports/imports.preview.service.ts
wangziqi ae88372ef8 fix(imports): 修复 AI 导入向导多工作表与表头误判
- AI resolve 生成向导时按阶段携带全部 sheetNames,不再只取第一张表
- ImportStageRequest 支持 sheets 数组并兼容旧 sheet;手动重传同步修复
- headerMatches 收窄为单向包含,避免宿舍号被原/新宿舍号反向匹配
- suggestStep 增加入住/换宿显式表头信号,修复入住表误判为换宿
- 预检与预览按工作表逐表解析列映射,兼容异构表头
- 修复预检卡生成向导成功后按钮未复位 loading 的问题
- 补充 mapping/预检/run/ai-chat 多工作表测试
2026-08-06 14:46:33 +08:00

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 };
}
}