task-d.7.5
This commit is contained in:
131
src/features/setup/server/csv-commit.service.ts
Normal file
131
src/features/setup/server/csv-commit.service.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'server-only';
|
||||
|
||||
import { buildCsvCommitPlan } from './csv/commit-builder';
|
||||
import { buildImportReport, createImportError, type ImportReport } from './csv/import-report';
|
||||
import { CsvCommitExecutionError, executeCsvCommitPlan } from './csv/upsert-executor';
|
||||
import { previewSetupCsvImport } from './csv-preview.service';
|
||||
import type { CsvPreviewFileInput } from './csv/types';
|
||||
|
||||
export interface CommitSetupCsvImportInput {
|
||||
files: CsvPreviewFileInput[];
|
||||
previewHash: string;
|
||||
dryRun?: boolean;
|
||||
actorId: string;
|
||||
}
|
||||
|
||||
export async function commitSetupCsvImport(input: CommitSetupCsvImportInput): Promise<ImportReport> {
|
||||
const preview = await previewSetupCsvImport(input.files);
|
||||
const plan = buildCsvCommitPlan(preview);
|
||||
|
||||
if (!input.previewHash) {
|
||||
return buildImportReport({
|
||||
previewHash: preview.previewHash,
|
||||
committedHash: plan.committedHash,
|
||||
dryRun: Boolean(input.dryRun),
|
||||
files: [
|
||||
{
|
||||
fileName: '__request__',
|
||||
template: 'setup-csv-commit',
|
||||
status: 'FAIL',
|
||||
imported: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
errors: [
|
||||
createImportError({
|
||||
file: '__request__',
|
||||
row: 1,
|
||||
column: 'previewHash',
|
||||
code: 'PREVIEW_HASH_REQUIRED',
|
||||
message: 'previewHash is required before committing CSV import.',
|
||||
suggestedFix: 'Call /api/setup/import/preview and submit the returned previewHash.'
|
||||
})
|
||||
],
|
||||
warnings: [],
|
||||
rows: []
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if (preview.previewHash !== input.previewHash) {
|
||||
return buildImportReport({
|
||||
previewHash: preview.previewHash,
|
||||
committedHash: plan.committedHash,
|
||||
dryRun: Boolean(input.dryRun),
|
||||
files: [
|
||||
{
|
||||
fileName: '__request__',
|
||||
template: 'setup-csv-commit',
|
||||
status: 'FAIL',
|
||||
imported: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
errors: [
|
||||
createImportError({
|
||||
file: '__request__',
|
||||
row: 1,
|
||||
column: 'previewHash',
|
||||
code: 'PREVIEW_HASH_MISMATCH',
|
||||
message: 'Submitted previewHash does not match the uploaded CSV files.',
|
||||
suggestedFix: 'Re-run preview for the exact files being committed and retry with the new previewHash.'
|
||||
})
|
||||
],
|
||||
warnings: [],
|
||||
rows: []
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if (preview.summary.error > 0) {
|
||||
return buildImportReport({
|
||||
previewHash: preview.previewHash,
|
||||
committedHash: plan.committedHash,
|
||||
dryRun: Boolean(input.dryRun),
|
||||
files: preview.files.map((file) => ({
|
||||
fileName: file.fileName,
|
||||
template: file.template,
|
||||
status: 'FAIL',
|
||||
imported: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
errors: file.errors,
|
||||
warnings: file.warnings,
|
||||
rows: file.rows.map((row) => ({
|
||||
row: row.row,
|
||||
action: row.action === 'error' ? 'error' : 'skip',
|
||||
businessKey: row.key,
|
||||
message: row.action === 'error' ? 'Preview row has blocking errors.' : 'Commit blocked because preview contains errors.',
|
||||
warnings: row.warnings,
|
||||
errors: row.errors
|
||||
}))
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
let files;
|
||||
try {
|
||||
files = await executeCsvCommitPlan(plan, {
|
||||
actorId: input.actorId,
|
||||
dryRun: Boolean(input.dryRun)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof CsvCommitExecutionError) {
|
||||
return buildImportReport({
|
||||
previewHash: preview.previewHash,
|
||||
committedHash: plan.committedHash,
|
||||
dryRun: Boolean(input.dryRun),
|
||||
files: error.files
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return buildImportReport({
|
||||
previewHash: preview.previewHash,
|
||||
committedHash: plan.committedHash,
|
||||
dryRun: Boolean(input.dryRun),
|
||||
files
|
||||
});
|
||||
}
|
||||
65
src/features/setup/server/csv/commit-builder.ts
Normal file
65
src/features/setup/server/csv/commit-builder.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { CsvPreviewFileResult, CsvPreviewResult, CsvPreviewRow } from './types';
|
||||
|
||||
export interface CsvCommitPlanRow {
|
||||
fileName: string;
|
||||
template: string;
|
||||
importOrder: number;
|
||||
row: CsvPreviewRow;
|
||||
}
|
||||
|
||||
export interface CsvCommitPlanFile {
|
||||
fileName: string;
|
||||
template: string;
|
||||
importOrder: number;
|
||||
rows: CsvCommitPlanRow[];
|
||||
}
|
||||
|
||||
export interface CsvCommitPlan {
|
||||
preview: CsvPreviewResult;
|
||||
files: CsvCommitPlanFile[];
|
||||
rows: CsvCommitPlanRow[];
|
||||
committedHash: string;
|
||||
}
|
||||
|
||||
function stableRowPayload(file: CsvPreviewFileResult, row: CsvPreviewRow) {
|
||||
return {
|
||||
fileName: file.fileName,
|
||||
importOrder: file.importOrder,
|
||||
row: row.row,
|
||||
key: row.key,
|
||||
action: row.action,
|
||||
values: row.values
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCsvCommitPlan(preview: CsvPreviewResult): CsvCommitPlan {
|
||||
const files = preview.files
|
||||
.toSorted((left, right) => left.importOrder - right.importOrder || left.fileName.localeCompare(right.fileName))
|
||||
.map((file) => ({
|
||||
fileName: file.fileName,
|
||||
template: file.template,
|
||||
importOrder: file.importOrder,
|
||||
rows: file.rows
|
||||
.filter((row) => row.action !== 'error')
|
||||
.toSorted((left, right) => left.row - right.row)
|
||||
.map((row) => ({
|
||||
fileName: file.fileName,
|
||||
template: file.template,
|
||||
importOrder: file.importOrder,
|
||||
row
|
||||
}))
|
||||
}));
|
||||
|
||||
const rows = files.flatMap((file) => file.rows);
|
||||
const committedHash = createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
previewHash: preview.previewHash,
|
||||
rows: preview.files.flatMap((file) => file.rows.map((row) => stableRowPayload(file, row)))
|
||||
})
|
||||
)
|
||||
.digest('hex');
|
||||
|
||||
return { preview, files, rows, committedHash };
|
||||
}
|
||||
121
src/features/setup/server/csv/import-report.ts
Normal file
121
src/features/setup/server/csv/import-report.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { CsvPreviewError, CsvPreviewWarning } from './types';
|
||||
|
||||
export type ImportStatus = 'PASS' | 'WARNING' | 'FAIL';
|
||||
export type ImportAction = 'create' | 'update' | 'skip' | 'error';
|
||||
|
||||
export interface ImportRowReport {
|
||||
row: number;
|
||||
action: ImportAction;
|
||||
businessKey: string | null;
|
||||
entityId?: string;
|
||||
message: string;
|
||||
warnings: CsvPreviewWarning[];
|
||||
errors: CsvPreviewError[];
|
||||
}
|
||||
|
||||
export interface ImportFileReport {
|
||||
fileName: string;
|
||||
template: string;
|
||||
status: ImportStatus;
|
||||
imported: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
errors: CsvPreviewError[];
|
||||
warnings: CsvPreviewWarning[];
|
||||
rows: ImportRowReport[];
|
||||
}
|
||||
|
||||
export interface ImportReport {
|
||||
status: ImportStatus;
|
||||
generatedAt: string;
|
||||
previewHash: string;
|
||||
committedHash: string;
|
||||
dryRun: boolean;
|
||||
summary: {
|
||||
imported: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
errors: number;
|
||||
warnings: number;
|
||||
};
|
||||
files: ImportFileReport[];
|
||||
errors: CsvPreviewError[];
|
||||
warnings: CsvPreviewWarning[];
|
||||
}
|
||||
|
||||
export function createImportError(input: {
|
||||
file: string;
|
||||
row: number;
|
||||
column: string;
|
||||
code: string;
|
||||
message: string;
|
||||
suggestedFix: string;
|
||||
}): CsvPreviewError {
|
||||
return input;
|
||||
}
|
||||
|
||||
export function createImportWarning(input: {
|
||||
file: string;
|
||||
row: number;
|
||||
column: string;
|
||||
code: string;
|
||||
message: string;
|
||||
suggestedFix: string;
|
||||
}): CsvPreviewWarning {
|
||||
return input;
|
||||
}
|
||||
|
||||
export function buildImportReport(input: {
|
||||
previewHash: string;
|
||||
committedHash: string;
|
||||
dryRun: boolean;
|
||||
files: ImportFileReport[];
|
||||
}): ImportReport {
|
||||
const errors = input.files.flatMap((file) => file.errors);
|
||||
const warnings = input.files.flatMap((file) => file.warnings);
|
||||
const summary = input.files.reduce(
|
||||
(accumulator, file) => ({
|
||||
imported: accumulator.imported + file.imported,
|
||||
updated: accumulator.updated + file.updated,
|
||||
skipped: accumulator.skipped + file.skipped,
|
||||
errors: accumulator.errors + file.errors.length,
|
||||
warnings: accumulator.warnings + file.warnings.length
|
||||
}),
|
||||
{ imported: 0, updated: 0, skipped: 0, errors: 0, warnings: 0 }
|
||||
);
|
||||
|
||||
return {
|
||||
status: errors.length > 0 ? 'FAIL' : warnings.length > 0 ? 'WARNING' : 'PASS',
|
||||
generatedAt: new Date().toISOString(),
|
||||
previewHash: input.previewHash,
|
||||
committedHash: input.committedHash,
|
||||
dryRun: input.dryRun,
|
||||
summary,
|
||||
files: input.files,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeFileRows(input: {
|
||||
fileName: string;
|
||||
template: string;
|
||||
rows: ImportRowReport[];
|
||||
warnings?: CsvPreviewWarning[];
|
||||
}): ImportFileReport {
|
||||
const errors = input.rows.flatMap((row) => row.errors);
|
||||
const rowWarnings = input.rows.flatMap((row) => row.warnings);
|
||||
const warnings = [...rowWarnings, ...(input.warnings ?? [])];
|
||||
|
||||
return {
|
||||
fileName: input.fileName,
|
||||
template: input.template,
|
||||
status: errors.length > 0 ? 'FAIL' : warnings.length > 0 ? 'WARNING' : 'PASS',
|
||||
imported: input.rows.filter((row) => row.action === 'create').length,
|
||||
updated: input.rows.filter((row) => row.action === 'update').length,
|
||||
skipped: input.rows.filter((row) => row.action === 'skip').length,
|
||||
errors,
|
||||
warnings,
|
||||
rows: input.rows
|
||||
};
|
||||
}
|
||||
1244
src/features/setup/server/csv/upsert-executor.ts
Normal file
1244
src/features/setup/server/csv/upsert-executor.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user