Files
pn-new-crm/src/lib/import/classify.ts
Matt 3cf12b3015 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>
2026-06-01 22:32:19 +02:00

109 lines
3.4 KiB
TypeScript

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