feat(import): engine core + companies/clients/berths adapters
Second importer increment: the generic engine + the three no-FK adapters,
fully unit + integration tested.
- types: ImportAdapter contract (targetFields, matchKey, findExisting,
resolveForeignKeys, insert/update) + engine types.
- mapping: fuzzy header → target-field auto-suggest (exact / alias / edit
distance, one header per field) + applyMapping (drops empty cells).
- classify: per-field zod + cross-field extraValidate, FK resolution hook,
natural-key dedup, and the conflict-policy matrix
(skip-matches / update-matches / error-on-match) → row outcomes + summary.
- engine: CSV (papaparse) + XLSX (ExcelJS) parse into a uniform
{headers, rows} of trimmed strings.
- adapters (delegating to existing create/update services for audit +
validation): companies (name dedup, update), clients (flat email/phone →
contacts[], email-or-phone dedup, insert-only), berths (canonical mooring
dedup, numeric coercion, update).
- registry: implemented adapters in dependency order.
Tests: 11 unit (mapping/validation/matchKey/parse) + 3 integration
(dedup + all three conflict policies on a seeded DB). 14 passing.
Next increments: FK adapters (yachts/interests/tenancies/expenses),
commit runner + worker, API routes + permission, wizard UI + undo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
108
src/lib/import/classify.ts
Normal file
108
src/lib/import/classify.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Row classification — the heart of both the dry-run preview and the commit.
|
||||
*
|
||||
* For one mapped row: validate (required + per-field zod) → resolve FKs →
|
||||
* dedup by natural key → resolve to an outcome under the chosen conflict
|
||||
* policy. Pure with respect to writes; the commit step acts on the outcome.
|
||||
*/
|
||||
import { applyMapping } from './mapping';
|
||||
import type {
|
||||
ClassifiedRow,
|
||||
ConflictPolicy,
|
||||
ImportAdapter,
|
||||
ImportCtx,
|
||||
MappedRow,
|
||||
RawRow,
|
||||
RowError,
|
||||
} from './types';
|
||||
|
||||
/** Required-field + per-field zod validation. Never throws; returns all errors. */
|
||||
export function validateRow(adapter: ImportAdapter, mapped: MappedRow): RowError[] {
|
||||
const errors: RowError[] = [];
|
||||
for (const field of adapter.targetFields) {
|
||||
const value = mapped[field.key];
|
||||
if (value === undefined) {
|
||||
if (field.required) errors.push({ field: field.key, message: `${field.label} is required` });
|
||||
continue;
|
||||
}
|
||||
const res = field.zod.safeParse(value);
|
||||
if (!res.success) {
|
||||
const first = res.error.issues[0];
|
||||
errors.push({ field: field.key, message: first?.message ?? 'Invalid value' });
|
||||
}
|
||||
}
|
||||
if (errors.length === 0 && adapter.extraValidate) errors.push(...adapter.extraValidate(mapped));
|
||||
return errors;
|
||||
}
|
||||
|
||||
export async function classifyRow(
|
||||
adapter: ImportAdapter,
|
||||
raw: RawRow,
|
||||
mapping: Record<string, string>,
|
||||
rowNumber: number,
|
||||
policy: ConflictPolicy,
|
||||
ctx: ImportCtx,
|
||||
): Promise<ClassifiedRow> {
|
||||
const mapped = applyMapping(raw, mapping);
|
||||
|
||||
const errors = validateRow(adapter, mapped);
|
||||
if (errors.length > 0) return { rowNumber, outcome: 'error', errors };
|
||||
|
||||
// Foreign keys (by natural key) — resolution failures are row errors.
|
||||
let resolved: Record<string, string> = {};
|
||||
if (adapter.resolveForeignKeys) {
|
||||
const fk = await adapter.resolveForeignKeys(mapped, ctx);
|
||||
if (!fk.ok) return { rowNumber, outcome: 'error', errors: fk.errors };
|
||||
resolved = fk.resolved;
|
||||
}
|
||||
|
||||
// Dedup by natural key.
|
||||
const key = adapter.matchKey(mapped);
|
||||
const existing = key ? await adapter.findExisting(ctx.portId, key) : null;
|
||||
|
||||
if (existing) {
|
||||
if (policy === 'error-on-match') {
|
||||
return {
|
||||
rowNumber,
|
||||
outcome: 'error',
|
||||
existingId: existing.id,
|
||||
errors: [{ field: '*', message: 'Matches an existing record' }],
|
||||
};
|
||||
}
|
||||
if (policy === 'update-matches' && adapter.update) {
|
||||
return { rowNumber, outcome: 'update', existingId: existing.id, mapped, resolved };
|
||||
}
|
||||
// skip-matches, or update requested on an insert-only adapter.
|
||||
return { rowNumber, outcome: 'skip', existingId: existing.id, mapped, resolved };
|
||||
}
|
||||
|
||||
return { rowNumber, outcome: 'insert', mapped, resolved };
|
||||
}
|
||||
|
||||
export interface DryRunSummary {
|
||||
total: number;
|
||||
insert: number;
|
||||
update: number;
|
||||
skip: number;
|
||||
error: number;
|
||||
rows: ClassifiedRow[];
|
||||
}
|
||||
|
||||
/** Classify a whole file. Sequential to keep FK lookups + dedup ordering
|
||||
* deterministic (migration files are small). */
|
||||
export async function classifyRows(
|
||||
adapter: ImportAdapter,
|
||||
rawRows: RawRow[],
|
||||
mapping: Record<string, string>,
|
||||
policy: ConflictPolicy,
|
||||
ctx: ImportCtx,
|
||||
): Promise<DryRunSummary> {
|
||||
const rows: ClassifiedRow[] = [];
|
||||
const summary = { total: rawRows.length, insert: 0, update: 0, skip: 0, error: 0 };
|
||||
for (let i = 0; i < rawRows.length; i++) {
|
||||
const c = await classifyRow(adapter, rawRows[i]!, mapping, i + 1, policy, ctx);
|
||||
rows.push(c);
|
||||
summary[c.outcome] += 1;
|
||||
}
|
||||
return { ...summary, rows };
|
||||
}
|
||||
Reference in New Issue
Block a user