/** * Pure helpers + plan-builder for the NocoDB → CRM berth import. * * Lives outside the CLI script (`scripts/import-berths-from-nocodb.ts`) * so vitest can exercise the mapping/normalization/plan logic without * triggering the script's top-level db connection. */ import { z } from 'zod'; import type { NocoDbRow } from '@/lib/dedup/nocodb-source'; // ─── Helpers ──────────────────────────────────────────────────────────────── /** * Strip trailing units ("63ft", "12 m") and return a JS number, or null * if the input doesn't parse cleanly. NocoDB stores plain numerics for * the Berth fields we care about, but defensive against future drift or * legacy import data. */ export function parseDecimalWithUnit(raw: unknown): number | null { if (raw == null) return null; if (typeof raw === 'number') { // Berth dimensions / capacities / prices are all non-negative; treat // a negative number as malformed input rather than silently importing // it (a "-50ft" length would otherwise nuke the recommender's // feasibility filter). return Number.isFinite(raw) && raw >= 0 ? raw : null; } if (typeof raw !== 'string') return null; const m = /^\s*(\d+(?:\.\d+)?)\s*(?:ft|feet|m|metres|meters|kw|v|usd|\$)?\s*$/i.exec(raw); if (!m) return null; const n = parseFloat(m[1]!); return Number.isFinite(n) && n >= 0 ? n : null; } /** Round to 2 decimals to match NocoDB's `precision: 2` decimal columns. */ export function round2(n: number | null): number | null { if (n == null) return null; return Math.round(n * 100) / 100; } const FT_TO_M = 0.3048; /** Imperial → metric. Returns null when the input is null. */ export function ftToM(ft: number | null): number | null { if (ft == null) return null; return round2(ft * FT_TO_M); } /** NocoDB display Status → CRM internal status. Defaults to 'available'. */ export function mapStatus(raw: unknown): 'available' | 'under_offer' | 'sold' { switch (typeof raw === 'string' ? raw.trim() : raw) { case 'Available': return 'available'; case 'Under Offer': return 'under_offer'; case 'Sold': return 'sold'; default: return 'available'; } } const MapDataSchema = z.object({ path: z.string().optional(), x: z.union([z.string(), z.number()]).optional(), y: z.union([z.string(), z.number()]).optional(), transform: z.string().optional(), fontSize: z.union([z.string(), z.number()]).optional(), }); export type MapData = z.infer; export function parseMapData(raw: unknown): MapData | null { if (raw == null) return null; const candidate = typeof raw === 'string' ? safeJsonParse(raw) : raw; if (candidate == null) return null; const parsed = MapDataSchema.safeParse(candidate); return parsed.success ? parsed.data : null; } function safeJsonParse(s: string): unknown { try { return JSON.parse(s); } catch { return null; } } export function toNumberish(raw: unknown): number | null { if (raw == null || raw === '') return null; if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null; if (typeof raw === 'string') { const n = parseFloat(raw); return Number.isFinite(n) ? n : null; } return null; } // ─── Numerics extractor ───────────────────────────────────────────────────── export interface NumericFields { lengthFt: number | null; widthFt: number | null; draftFt: number | null; lengthM: number | null; widthM: number | null; draftM: number | null; nominalBoatSize: number | null; nominalBoatSizeM: number | null; waterDepth: number | null; waterDepthM: number | null; powerCapacity: number | null; voltage: number | null; price: number | null; } export function extractNumerics(row: NocoDbRow): NumericFields { const lengthFt = round2(parseDecimalWithUnit(row['Length'])); const widthFt = round2(parseDecimalWithUnit(row['Width'])); const draftFt = round2(parseDecimalWithUnit(row['Draft'])); const waterDepth = round2(parseDecimalWithUnit(row['Water Depth'])); const nominalBoatSize = toNumberish(row['Nominal Boat Size']); return { lengthFt, widthFt, draftFt, lengthM: ftToM(lengthFt), widthM: ftToM(widthFt), draftM: ftToM(draftFt), nominalBoatSize, nominalBoatSizeM: ftToM(nominalBoatSize), waterDepth, waterDepthM: ftToM(waterDepth), powerCapacity: toNumberish(row['Power Capacity']), voltage: toNumberish(row['Voltage']), price: toNumberish(row['Price']), }; } // ─── Row mapper ───────────────────────────────────────────────────────────── export interface ImportedBerth { legacyId: number; mooringNumber: string; area: string | null; status: 'available' | 'under_offer' | 'sold'; numerics: NumericFields; widthIsMinimum: boolean; waterDepthIsMinimum: boolean; sidePontoon: string | null; mooringType: string | null; cleatType: string | null; cleatCapacity: string | null; bollardType: string | null; bollardCapacity: string | null; access: string | null; bowFacing: string | null; berthApproved: boolean; statusOverrideMode: string | null; mapData: MapData | null; } export function mapRow(row: NocoDbRow): ImportedBerth | null { const mooringNumber = typeof row['Mooring Number'] === 'string' ? row['Mooring Number'].trim() : ''; if (!mooringNumber) return null; return { legacyId: row.Id, mooringNumber, area: typeof row['Area'] === 'string' ? row['Area'] : null, status: mapStatus(row['Status']), numerics: extractNumerics(row), widthIsMinimum: row['Width Is Minimum'] === true, waterDepthIsMinimum: row['Water Depth Is Minimum'] === true, sidePontoon: typeof row['Side Pontoon'] === 'string' ? row['Side Pontoon'] : null, mooringType: typeof row['Mooring Type'] === 'string' ? row['Mooring Type'] : null, cleatType: typeof row['Cleat Type'] === 'string' ? row['Cleat Type'] : null, cleatCapacity: typeof row['Cleat Capacity'] === 'string' ? row['Cleat Capacity'] : null, bollardType: typeof row['Bollard Type'] === 'string' ? row['Bollard Type'] : null, bollardCapacity: typeof row['Bollard Capacity'] === 'string' ? row['Bollard Capacity'] : null, access: typeof row['Access'] === 'string' ? row['Access'] : null, bowFacing: typeof row['Bow Facing'] === 'string' ? row['Bow Facing'] : null, berthApproved: row['Berth Approved'] === true, statusOverrideMode: typeof row['status_override_mode'] === 'string' ? row['status_override_mode'] : null, mapData: parseMapData(row['Map Data']), }; } // ─── Plan builder ─────────────────────────────────────────────────────────── export interface ExistingBerthRow { id: string; mooringNumber: string; updatedAt: Date; lastImportedAt: Date | null; } export type Action = 'insert' | 'update' | 'skip-edited' | 'noop'; export interface PlanEntry { action: Action; imported: ImportedBerth; existing: ExistingBerthRow | null; reason?: string; } export interface BuildPlanResult { plan: PlanEntry[]; orphans: ExistingBerthRow[]; } export function buildPlan( imported: ImportedBerth[], existingByMooring: Map, force: boolean, ): BuildPlanResult { const plan: PlanEntry[] = []; const seenMoorings = new Set(); for (const row of imported) { seenMoorings.add(row.mooringNumber); const existing = existingByMooring.get(row.mooringNumber) ?? null; if (!existing) { plan.push({ action: 'insert', imported: row, existing: null }); continue; } const lastImported = existing.lastImportedAt; // 1-second tolerance: an UPDATE inside the apply transaction sets // updated_at and last_imported_at to the same `importedAt` timestamp, // but Postgres can race them by sub-second on busy boxes. const isHumanEdited = lastImported == null ? false : existing.updatedAt.getTime() > lastImported.getTime() + 1000; if (isHumanEdited && !force) { plan.push({ action: 'skip-edited', imported: row, existing, reason: `CRM updated_at=${existing.updatedAt.toISOString()} > last_imported_at=${ lastImported?.toISOString() ?? 'null' }`, }); continue; } plan.push({ action: 'update', imported: row, existing }); } const orphans = Array.from(existingByMooring.values()).filter( (e) => !seenMoorings.has(e.mooringNumber), ); return { plan, orphans }; }