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:
2026-06-01 22:32:19 +02:00
parent 372b585bf9
commit 3cf12b3015
10 changed files with 869 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
/**
* Integration test: dry-run classifier dedup + conflict-policy matrix.
*
* Seeds an existing company, then classifies a 3-row "file" (one matching the
* existing record, one new, one invalid) under each conflict policy and asserts
* the per-row outcomes + summary counts. Runs against the real test DB.
*/
import { beforeAll, describe, expect, it } from 'vitest';
import { classifyRows } from '@/lib/import/classify';
import { companiesAdapter } from '@/lib/import/adapters/companies';
import type { RawRow } from '@/lib/import/types';
let makePort: typeof import('../helpers/factories').makePort;
let makeCompany: typeof import('../helpers/factories').makeCompany;
let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta;
beforeAll(async () => {
const f = await import('../helpers/factories');
makePort = f.makePort;
makeCompany = f.makeCompany;
makeAuditMeta = f.makeAuditMeta;
});
const MAPPING = { name: 'Name' };
describe('classifyRows — dedup + conflict policy', () => {
async function setup() {
const port = await makePort();
await makeCompany({ portId: port.id, overrides: { name: 'Acme Marine' } });
const ctx = { portId: port.id, meta: makeAuditMeta({ portId: port.id }) };
const rows: RawRow[] = [
{ Name: 'Acme Marine' }, // matches existing (case-insensitive)
{ Name: 'Brand New Co' }, // insert
{ Name: '' }, // error: required
];
return { ctx, rows };
}
it('skip-matches: existing→skip, new→insert, blank→error', async () => {
const { ctx, rows } = await setup();
const r = await classifyRows(companiesAdapter, rows, MAPPING, 'skip-matches', ctx);
expect({ insert: r.insert, update: r.update, skip: r.skip, error: r.error }).toEqual({
insert: 1,
update: 0,
skip: 1,
error: 1,
});
expect(r.rows.map((x) => x.outcome)).toEqual(['skip', 'insert', 'error']);
expect(r.rows[0]!.existingId).toBeTruthy();
});
it('update-matches: existing→update (adapter supports update)', async () => {
const { ctx, rows } = await setup();
const r = await classifyRows(companiesAdapter, rows, MAPPING, 'update-matches', ctx);
expect(r.rows[0]!.outcome).toBe('update');
expect(r.update).toBe(1);
});
it('error-on-match: existing→error', async () => {
const { ctx, rows } = await setup();
const r = await classifyRows(companiesAdapter, rows, MAPPING, 'error-on-match', ctx);
expect(r.rows[0]!.outcome).toBe('error');
// The blank row is still an error too → 2 errors total.
expect(r.error).toBe(2);
});
});