task-d.7.5

This commit is contained in:
phaichayon
2026-07-03 09:30:57 +07:00
parent 0b7aeea0a7
commit b7589017ab
7 changed files with 2171 additions and 0 deletions

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

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

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

File diff suppressed because it is too large Load Diff