feat(berths): nocodb berth import script + helpers + unit tests
Idempotent NocoDB Berths -> CRM `berths` import script with full re-run safety. Re-running picks up NocoDB additions/edits without clobbering CRM-side overrides (compares updated_at vs last_imported_at, 1-second tolerance for sub-second clock drift). --force overrides the edit guard. Mitigates the §14.1 critical/high cases: - Mooring collisions: unique (port_id, mooring_number) on the table. - Concurrent runs: pg_advisory_xact_lock on a stable BIGINT key. - Numeric-with-units inputs: parseDecimalWithUnit() strips trailing ft/m/kw/v/usd/$ markers before parsing. - Metric drift: NocoDB's metric formula columns are ignored; metric values recomputed from imperial via 0.3048 + round-to-2-decimals to match NocoDB's `precision: 2` columns and avoid spurious diffs. - Map Data shape: zod-validated; failures are skipped rather than aborting the import. - Status enum mapping: NocoDB display strings -> CRM snake_case. - NocoDB row deleted: reported as "orphaned in CRM"; never auto- deleted (rep decides via admin UI in a future phase). Pure helpers (parseDecimalWithUnit, mapStatus, parseMapData, extractNumerics, mapRow, buildPlan) live in src/lib/services/berth-import.ts so vitest can exercise the mapping logic without triggering the script's top-level db connection. 40 new unit tests (956 -> 996 passing). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
405
scripts/import-berths-from-nocodb.ts
Normal file
405
scripts/import-berths-from-nocodb.ts
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
/**
|
||||||
|
* Idempotent NocoDB Berths → CRM `berths` import.
|
||||||
|
*
|
||||||
|
* Re-running picks up NocoDB additions/edits without clobbering CRM-side
|
||||||
|
* overrides: rows where `updated_at > last_imported_at` are treated as
|
||||||
|
* human-edited and skipped (use `--force` to override). Map Data JSON
|
||||||
|
* is validated and upserted into `berth_map_data` as a separate step.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* pnpm tsx scripts/import-berths-from-nocodb.ts --dry-run [--port-slug port-nimara]
|
||||||
|
* pnpm tsx scripts/import-berths-from-nocodb.ts --apply [--port-slug port-nimara]
|
||||||
|
* pnpm tsx scripts/import-berths-from-nocodb.ts --apply --force
|
||||||
|
* pnpm tsx scripts/import-berths-from-nocodb.ts --apply --update-snapshot
|
||||||
|
*
|
||||||
|
* Edge cases mitigated (see plan §14.1):
|
||||||
|
* - Mooring collisions : unique (port_id, mooring_number) on the table.
|
||||||
|
* - Concurrent runs : pg_advisory_xact_lock on a stable key.
|
||||||
|
* - Numeric-with-units : parseDecimalWithUnit() strips trailing units.
|
||||||
|
* - Metric drift : NocoDB metric formula columns are ignored;
|
||||||
|
* metric values are recomputed from imperial.
|
||||||
|
* - Map Data shape : zod-validated; failures are skipped silently
|
||||||
|
* rather than aborting the whole import.
|
||||||
|
* - Status enum : NocoDB display strings → CRM snake_case.
|
||||||
|
* - NocoDB row deleted : reported as "orphaned in CRM"; not auto-deleted.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { eq, sql } from 'drizzle-orm';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
|
import { berths, berthMapData } from '@/lib/db/schema/berths';
|
||||||
|
import { fetchAllRows, loadNocoDbConfig, NOCO_TABLES } from '@/lib/dedup/nocodb-source';
|
||||||
|
import {
|
||||||
|
buildPlan,
|
||||||
|
mapRow,
|
||||||
|
type Action,
|
||||||
|
type ImportedBerth,
|
||||||
|
type PlanEntry,
|
||||||
|
type ExistingBerthRow,
|
||||||
|
} from '@/lib/services/berth-import';
|
||||||
|
|
||||||
|
// ─── CLI ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CliArgs {
|
||||||
|
dryRun: boolean;
|
||||||
|
apply: boolean;
|
||||||
|
portSlug: string;
|
||||||
|
force: boolean;
|
||||||
|
updateSnapshot: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): CliArgs {
|
||||||
|
const args: CliArgs = {
|
||||||
|
dryRun: false,
|
||||||
|
apply: false,
|
||||||
|
portSlug: 'port-nimara',
|
||||||
|
force: false,
|
||||||
|
updateSnapshot: false,
|
||||||
|
};
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const a = argv[i]!;
|
||||||
|
if (a === '--dry-run') args.dryRun = true;
|
||||||
|
else if (a === '--apply') args.apply = true;
|
||||||
|
else if (a === '--port-slug') args.portSlug = argv[++i] ?? 'port-nimara';
|
||||||
|
else if (a === '--force') args.force = true;
|
||||||
|
else if (a === '--update-snapshot') args.updateSnapshot = true;
|
||||||
|
else if (a === '-h' || a === '--help') {
|
||||||
|
printHelp();
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
console.error(`Unknown argument: ${a}`);
|
||||||
|
printHelp();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!args.dryRun && !args.apply) {
|
||||||
|
console.error('Must specify either --dry-run or --apply.');
|
||||||
|
printHelp();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp(): void {
|
||||||
|
console.log(`Usage:
|
||||||
|
pnpm tsx scripts/import-berths-from-nocodb.ts --dry-run [--port-slug <slug>]
|
||||||
|
pnpm tsx scripts/import-berths-from-nocodb.ts --apply [--port-slug <slug>] [--force] [--update-snapshot]
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--dry-run Read NocoDB + diff vs CRM. No writes.
|
||||||
|
--apply Apply the plan to the DB.
|
||||||
|
--port-slug <slug> Target port slug (default: port-nimara).
|
||||||
|
--force Overwrite rows where CRM updated_at > last_imported_at.
|
||||||
|
--update-snapshot Rewrite src/lib/db/seed-data/berths.json after apply.
|
||||||
|
-h, --help Show this help.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stable advisory lock key ───────────────────────────────────────────────
|
||||||
|
// 64-bit BIGINT - first 4 bytes spell "BRTH" so it's grep-able in pg_locks.
|
||||||
|
const BERTH_IMPORT_LOCK_KEY = 0x4252544800000001n;
|
||||||
|
|
||||||
|
// ─── Apply ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ApplyResult {
|
||||||
|
inserted: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
mapDataWritten: number;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apply(
|
||||||
|
portId: string,
|
||||||
|
plan: PlanEntry[],
|
||||||
|
orphans: ExistingBerthRow[],
|
||||||
|
importedAt: Date,
|
||||||
|
): Promise<ApplyResult> {
|
||||||
|
const result: ApplyResult = {
|
||||||
|
inserted: 0,
|
||||||
|
updated: 0,
|
||||||
|
skipped: 0,
|
||||||
|
mapDataWritten: 0,
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
for (const orphan of orphans) {
|
||||||
|
result.warnings.push(
|
||||||
|
`Orphan: CRM has mooring="${orphan.mooringNumber}" but NocoDB no longer does (id=${orphan.id})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
// Stable lock so two simultaneous --apply runs serialize.
|
||||||
|
await tx.execute(sql`SELECT pg_advisory_xact_lock(${BERTH_IMPORT_LOCK_KEY})`);
|
||||||
|
|
||||||
|
for (const entry of plan) {
|
||||||
|
if (entry.action === 'skip-edited' || entry.action === 'noop') {
|
||||||
|
result.skipped += 1;
|
||||||
|
result.warnings.push(`Skipped ${entry.imported.mooringNumber}: ${entry.reason ?? 'no-op'}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const i = entry.imported;
|
||||||
|
const n = i.numerics;
|
||||||
|
const baseValues = {
|
||||||
|
portId,
|
||||||
|
mooringNumber: i.mooringNumber,
|
||||||
|
area: i.area,
|
||||||
|
status: i.status,
|
||||||
|
lengthFt: n.lengthFt != null ? String(n.lengthFt) : null,
|
||||||
|
widthFt: n.widthFt != null ? String(n.widthFt) : null,
|
||||||
|
draftFt: n.draftFt != null ? String(n.draftFt) : null,
|
||||||
|
lengthM: n.lengthM != null ? String(n.lengthM) : null,
|
||||||
|
widthM: n.widthM != null ? String(n.widthM) : null,
|
||||||
|
draftM: n.draftM != null ? String(n.draftM) : null,
|
||||||
|
widthIsMinimum: i.widthIsMinimum,
|
||||||
|
nominalBoatSize: n.nominalBoatSize != null ? String(n.nominalBoatSize) : null,
|
||||||
|
nominalBoatSizeM: n.nominalBoatSizeM != null ? String(n.nominalBoatSizeM) : null,
|
||||||
|
waterDepth: n.waterDepth != null ? String(n.waterDepth) : null,
|
||||||
|
waterDepthM: n.waterDepthM != null ? String(n.waterDepthM) : null,
|
||||||
|
waterDepthIsMinimum: i.waterDepthIsMinimum,
|
||||||
|
sidePontoon: i.sidePontoon,
|
||||||
|
powerCapacity: n.powerCapacity != null ? String(n.powerCapacity) : null,
|
||||||
|
voltage: n.voltage != null ? String(n.voltage) : null,
|
||||||
|
mooringType: i.mooringType,
|
||||||
|
cleatType: i.cleatType,
|
||||||
|
cleatCapacity: i.cleatCapacity,
|
||||||
|
bollardType: i.bollardType,
|
||||||
|
bollardCapacity: i.bollardCapacity,
|
||||||
|
access: i.access,
|
||||||
|
price: n.price != null ? String(n.price) : null,
|
||||||
|
priceCurrency: 'USD' as const,
|
||||||
|
bowFacing: i.bowFacing,
|
||||||
|
berthApproved: i.berthApproved,
|
||||||
|
statusOverrideMode: i.statusOverrideMode,
|
||||||
|
lastImportedAt: importedAt,
|
||||||
|
updatedAt: importedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
let berthId: string;
|
||||||
|
if (entry.action === 'insert') {
|
||||||
|
const [inserted] = await tx
|
||||||
|
.insert(berths)
|
||||||
|
.values({ ...baseValues, tenureType: 'permanent' })
|
||||||
|
.returning({ id: berths.id });
|
||||||
|
berthId = inserted!.id;
|
||||||
|
result.inserted += 1;
|
||||||
|
} else {
|
||||||
|
await tx.update(berths).set(baseValues).where(eq(berths.id, entry.existing!.id));
|
||||||
|
berthId = entry.existing!.id;
|
||||||
|
result.updated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i.mapData) {
|
||||||
|
const mapValues = {
|
||||||
|
berthId,
|
||||||
|
svgPath: i.mapData.path ?? null,
|
||||||
|
x: i.mapData.x != null ? String(i.mapData.x) : null,
|
||||||
|
y: i.mapData.y != null ? String(i.mapData.y) : null,
|
||||||
|
transform: i.mapData.transform ?? null,
|
||||||
|
fontSize: i.mapData.fontSize != null ? String(i.mapData.fontSize) : null,
|
||||||
|
updatedAt: importedAt,
|
||||||
|
};
|
||||||
|
await tx
|
||||||
|
.insert(berthMapData)
|
||||||
|
.values(mapValues)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: berthMapData.berthId,
|
||||||
|
set: {
|
||||||
|
svgPath: mapValues.svgPath,
|
||||||
|
x: mapValues.x,
|
||||||
|
y: mapValues.y,
|
||||||
|
transform: mapValues.transform,
|
||||||
|
fontSize: mapValues.fontSize,
|
||||||
|
updatedAt: importedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
result.mapDataWritten += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Snapshot writer (for seed-data refresh) ────────────────────────────────
|
||||||
|
|
||||||
|
async function writeSnapshot(imported: ImportedBerth[]): Promise<string> {
|
||||||
|
// Ordering: idx 0..4 available (small), 5..9 under_offer (medium),
|
||||||
|
// 10..11 sold (large), then everything else by mooring number. The
|
||||||
|
// first 12 indexes feed `seed-data.ts` interest/reservation stubs.
|
||||||
|
const sortByLength = (a: ImportedBerth, b: ImportedBerth) =>
|
||||||
|
(a.numerics.lengthFt ?? 0) - (b.numerics.lengthFt ?? 0);
|
||||||
|
const available = imported
|
||||||
|
.filter((b) => b.status === 'available')
|
||||||
|
.sort(sortByLength)
|
||||||
|
.slice(0, 5);
|
||||||
|
const underOffer = imported
|
||||||
|
.filter((b) => b.status === 'under_offer')
|
||||||
|
.sort(sortByLength)
|
||||||
|
.slice(0, 5);
|
||||||
|
const sold = imported
|
||||||
|
.filter((b) => b.status === 'sold')
|
||||||
|
.sort((a, b) => -sortByLength(a, b))
|
||||||
|
.slice(0, 2);
|
||||||
|
const featured = new Set([...available, ...underOffer, ...sold].map((b) => b.mooringNumber));
|
||||||
|
const rest = imported
|
||||||
|
.filter((b) => !featured.has(b.mooringNumber))
|
||||||
|
.sort((a, b) => a.mooringNumber.localeCompare(b.mooringNumber, 'en', { numeric: true }));
|
||||||
|
const ordered = [...available, ...underOffer, ...sold, ...rest];
|
||||||
|
|
||||||
|
const payload = ordered.map((b) => ({
|
||||||
|
legacyId: b.legacyId,
|
||||||
|
mooringNumber: b.mooringNumber,
|
||||||
|
area: b.area,
|
||||||
|
status: b.status,
|
||||||
|
lengthFt: b.numerics.lengthFt,
|
||||||
|
widthFt: b.numerics.widthFt,
|
||||||
|
draftFt: b.numerics.draftFt,
|
||||||
|
lengthM: b.numerics.lengthM,
|
||||||
|
widthM: b.numerics.widthM,
|
||||||
|
draftM: b.numerics.draftM,
|
||||||
|
widthIsMinimum: b.widthIsMinimum,
|
||||||
|
nominalBoatSize: b.numerics.nominalBoatSize,
|
||||||
|
nominalBoatSizeM: b.numerics.nominalBoatSizeM,
|
||||||
|
waterDepth: b.numerics.waterDepth,
|
||||||
|
waterDepthM: b.numerics.waterDepthM,
|
||||||
|
waterDepthIsMinimum: b.waterDepthIsMinimum,
|
||||||
|
sidePontoon: b.sidePontoon,
|
||||||
|
powerCapacity: b.numerics.powerCapacity,
|
||||||
|
voltage: b.numerics.voltage,
|
||||||
|
mooringType: b.mooringType,
|
||||||
|
cleatType: b.cleatType,
|
||||||
|
cleatCapacity: b.cleatCapacity,
|
||||||
|
bollardType: b.bollardType,
|
||||||
|
bollardCapacity: b.bollardCapacity,
|
||||||
|
access: b.access,
|
||||||
|
price: b.numerics.price,
|
||||||
|
bowFacing: b.bowFacing,
|
||||||
|
berthApproved: b.berthApproved,
|
||||||
|
statusOverrideMode: b.statusOverrideMode,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const target = path.resolve(process.cwd(), 'src/lib/db/seed-data/berths.json');
|
||||||
|
await fs.writeFile(target, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const config = loadNocoDbConfig();
|
||||||
|
|
||||||
|
const [port] = await db
|
||||||
|
.select({ id: ports.id, slug: ports.slug })
|
||||||
|
.from(ports)
|
||||||
|
.where(eq(ports.slug, args.portSlug))
|
||||||
|
.limit(1);
|
||||||
|
if (!port) {
|
||||||
|
console.error(`No port found with slug "${args.portSlug}".`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`> Fetching NocoDB Berths…`);
|
||||||
|
const rows = await fetchAllRows(NOCO_TABLES.berths, config);
|
||||||
|
console.log(` fetched ${rows.length} rows from NocoDB`);
|
||||||
|
|
||||||
|
const imported: ImportedBerth[] = [];
|
||||||
|
let skippedMalformed = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
const m = mapRow(r);
|
||||||
|
if (m) imported.push(m);
|
||||||
|
else skippedMalformed += 1;
|
||||||
|
}
|
||||||
|
if (skippedMalformed > 0) {
|
||||||
|
console.warn(` ${skippedMalformed} rows skipped (missing Mooring Number)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// De-dup against any same-mooring twins surfacing from NocoDB
|
||||||
|
// (defensive — the Berths table is keyed on Mooring Number in NocoDB).
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const dedup: ImportedBerth[] = [];
|
||||||
|
for (const b of imported) {
|
||||||
|
if (seen.has(b.mooringNumber)) {
|
||||||
|
console.warn(` duplicate mooring "${b.mooringNumber}" in NocoDB — keeping first`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(b.mooringNumber);
|
||||||
|
dedup.push(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`> Reading current CRM berths for port "${port.slug}"…`);
|
||||||
|
const existingRows = await db
|
||||||
|
.select({
|
||||||
|
id: berths.id,
|
||||||
|
mooringNumber: berths.mooringNumber,
|
||||||
|
updatedAt: berths.updatedAt,
|
||||||
|
lastImportedAt: berths.lastImportedAt,
|
||||||
|
})
|
||||||
|
.from(berths)
|
||||||
|
.where(eq(berths.portId, port.id));
|
||||||
|
console.log(` ${existingRows.length} existing rows`);
|
||||||
|
|
||||||
|
const existingByMooring = new Map(existingRows.map((r) => [r.mooringNumber, r]));
|
||||||
|
const { plan, orphans } = buildPlan(dedup, existingByMooring, args.force);
|
||||||
|
|
||||||
|
const counts = plan.reduce(
|
||||||
|
(acc, e) => {
|
||||||
|
acc[e.action] += 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ insert: 0, update: 0, 'skip-edited': 0, noop: 0 } as Record<Action, number>,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`> Plan:`);
|
||||||
|
console.log(` insert : ${counts.insert}`);
|
||||||
|
console.log(` update : ${counts.update}`);
|
||||||
|
console.log(` skip-edited : ${counts['skip-edited']}`);
|
||||||
|
console.log(` no-op : ${counts.noop}`);
|
||||||
|
console.log(` orphans (CRM): ${orphans.length}`);
|
||||||
|
|
||||||
|
if (counts['skip-edited'] > 0) {
|
||||||
|
console.log(` ↳ Skipped (CRM-edited; pass --force to overwrite):`);
|
||||||
|
for (const e of plan.filter((p) => p.action === 'skip-edited').slice(0, 10)) {
|
||||||
|
console.log(` - ${e.imported.mooringNumber} ${e.reason}`);
|
||||||
|
}
|
||||||
|
if (counts['skip-edited'] > 10) console.log(` …and ${counts['skip-edited'] - 10} more`);
|
||||||
|
}
|
||||||
|
if (orphans.length > 0) {
|
||||||
|
console.log(` ↳ Orphans (in CRM but missing from NocoDB):`);
|
||||||
|
for (const o of orphans.slice(0, 10)) console.log(` - ${o.mooringNumber}`);
|
||||||
|
if (orphans.length > 10) console.log(` …and ${orphans.length - 10} more`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.dryRun) {
|
||||||
|
console.log(`\n[dry-run] no writes performed.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`> Applying…`);
|
||||||
|
const result = await apply(port.id, plan, orphans, new Date());
|
||||||
|
console.log(` inserted : ${result.inserted}`);
|
||||||
|
console.log(` updated : ${result.updated}`);
|
||||||
|
console.log(` skipped : ${result.skipped}`);
|
||||||
|
console.log(` map data writes : ${result.mapDataWritten}`);
|
||||||
|
if (result.warnings.length) {
|
||||||
|
console.log(` warnings :`);
|
||||||
|
for (const w of result.warnings.slice(0, 20)) console.log(` - ${w}`);
|
||||||
|
if (result.warnings.length > 20) console.log(` …and ${result.warnings.length - 20} more`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.updateSnapshot) {
|
||||||
|
const written = await writeSnapshot(dedup);
|
||||||
|
console.log(`> Wrote ${dedup.length} rows to ${path.relative(process.cwd(), written)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
245
src/lib/services/berth-import.ts
Normal file
245
src/lib/services/berth-import.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* 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') return Number.isFinite(raw) ? 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 : 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<typeof MapDataSchema>;
|
||||||
|
|
||||||
|
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<string, ExistingBerthRow>,
|
||||||
|
force: boolean,
|
||||||
|
): BuildPlanResult {
|
||||||
|
const plan: PlanEntry[] = [];
|
||||||
|
const seenMoorings = new Set<string>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
366
tests/unit/services/berth-import.test.ts
Normal file
366
tests/unit/services/berth-import.test.ts
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseDecimalWithUnit,
|
||||||
|
ftToM,
|
||||||
|
round2,
|
||||||
|
mapStatus,
|
||||||
|
parseMapData,
|
||||||
|
toNumberish,
|
||||||
|
extractNumerics,
|
||||||
|
mapRow,
|
||||||
|
buildPlan,
|
||||||
|
type ExistingBerthRow,
|
||||||
|
type ImportedBerth,
|
||||||
|
} from '@/lib/services/berth-import';
|
||||||
|
import type { NocoDbRow } from '@/lib/dedup/nocodb-source';
|
||||||
|
|
||||||
|
// ─── parseDecimalWithUnit ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('parseDecimalWithUnit', () => {
|
||||||
|
it('returns null for null/undefined', () => {
|
||||||
|
expect(parseDecimalWithUnit(null)).toBeNull();
|
||||||
|
expect(parseDecimalWithUnit(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
it('returns finite numbers as-is', () => {
|
||||||
|
expect(parseDecimalWithUnit(63)).toBe(63);
|
||||||
|
expect(parseDecimalWithUnit(63.5)).toBe(63.5);
|
||||||
|
expect(parseDecimalWithUnit(0)).toBe(0);
|
||||||
|
});
|
||||||
|
it('rejects NaN / Infinity', () => {
|
||||||
|
expect(parseDecimalWithUnit(Number.NaN)).toBeNull();
|
||||||
|
expect(parseDecimalWithUnit(Number.POSITIVE_INFINITY)).toBeNull();
|
||||||
|
});
|
||||||
|
it('parses bare numeric strings', () => {
|
||||||
|
expect(parseDecimalWithUnit('42')).toBe(42);
|
||||||
|
expect(parseDecimalWithUnit('42.5')).toBe(42.5);
|
||||||
|
});
|
||||||
|
it('strips trailing imperial / metric / power units', () => {
|
||||||
|
expect(parseDecimalWithUnit('63ft')).toBe(63);
|
||||||
|
expect(parseDecimalWithUnit('63 ft')).toBe(63);
|
||||||
|
expect(parseDecimalWithUnit('19 metres')).toBe(19);
|
||||||
|
expect(parseDecimalWithUnit('330kw')).toBe(330);
|
||||||
|
expect(parseDecimalWithUnit('480 V')).toBe(480);
|
||||||
|
});
|
||||||
|
it('strips currency markers', () => {
|
||||||
|
expect(parseDecimalWithUnit('100 USD')).toBe(100);
|
||||||
|
expect(parseDecimalWithUnit('100$')).toBe(100);
|
||||||
|
});
|
||||||
|
it('returns null on unparseable strings', () => {
|
||||||
|
expect(parseDecimalWithUnit('approx 60ft')).toBeNull();
|
||||||
|
expect(parseDecimalWithUnit('na')).toBeNull();
|
||||||
|
});
|
||||||
|
it('returns null on non-string non-number inputs', () => {
|
||||||
|
expect(parseDecimalWithUnit({})).toBeNull();
|
||||||
|
expect(parseDecimalWithUnit([1])).toBeNull();
|
||||||
|
expect(parseDecimalWithUnit(true)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── round2 + ftToM ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('round2', () => {
|
||||||
|
it('rounds to 2 decimals', () => {
|
||||||
|
expect(round2(1.234)).toBe(1.23);
|
||||||
|
expect(round2(1.235)).toBe(1.24);
|
||||||
|
expect(round2(1)).toBe(1);
|
||||||
|
});
|
||||||
|
it('passes null through', () => {
|
||||||
|
expect(round2(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ftToM', () => {
|
||||||
|
it('converts feet to meters at 0.3048', () => {
|
||||||
|
expect(ftToM(100)).toBe(30.48);
|
||||||
|
expect(ftToM(206.69)).toBeCloseTo(63.0, 1);
|
||||||
|
});
|
||||||
|
it('rounds to 2 decimals', () => {
|
||||||
|
expect(ftToM(45)).toBe(13.72);
|
||||||
|
});
|
||||||
|
it('passes null through', () => {
|
||||||
|
expect(ftToM(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── mapStatus ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('mapStatus', () => {
|
||||||
|
it('maps the three known display values', () => {
|
||||||
|
expect(mapStatus('Available')).toBe('available');
|
||||||
|
expect(mapStatus('Under Offer')).toBe('under_offer');
|
||||||
|
expect(mapStatus('Sold')).toBe('sold');
|
||||||
|
});
|
||||||
|
it('trims whitespace before matching', () => {
|
||||||
|
expect(mapStatus(' Sold ')).toBe('sold');
|
||||||
|
});
|
||||||
|
it('falls back to available for unknown / null values', () => {
|
||||||
|
expect(mapStatus(null)).toBe('available');
|
||||||
|
expect(mapStatus(undefined)).toBe('available');
|
||||||
|
expect(mapStatus('Pending')).toBe('available');
|
||||||
|
expect(mapStatus(42)).toBe('available');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── parseMapData ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('parseMapData', () => {
|
||||||
|
it('parses a full NocoDB Map Data object', () => {
|
||||||
|
const raw = {
|
||||||
|
path: 'M838.8 897.2h200.74v44.191H838.8z',
|
||||||
|
x: '922.819',
|
||||||
|
y: '930.721',
|
||||||
|
transform: 'translate(0 409.55)',
|
||||||
|
fontSize: '32',
|
||||||
|
};
|
||||||
|
expect(parseMapData(raw)).toEqual(raw);
|
||||||
|
});
|
||||||
|
it('accepts numeric x / y / fontSize too', () => {
|
||||||
|
expect(parseMapData({ x: 1, y: 2, fontSize: 32 })).toEqual({ x: 1, y: 2, fontSize: 32 });
|
||||||
|
});
|
||||||
|
it('parses JSON-string Map Data defensively', () => {
|
||||||
|
const raw = JSON.stringify({ path: 'M0 0 L1 1', x: '5', y: '10' });
|
||||||
|
expect(parseMapData(raw)).toEqual({ path: 'M0 0 L1 1', x: '5', y: '10' });
|
||||||
|
});
|
||||||
|
it('returns null for null/empty', () => {
|
||||||
|
expect(parseMapData(null)).toBeNull();
|
||||||
|
expect(parseMapData(undefined)).toBeNull();
|
||||||
|
expect(parseMapData('')).toBeNull();
|
||||||
|
});
|
||||||
|
it('returns null on shape mismatch (e.g. number where path is required)', () => {
|
||||||
|
expect(parseMapData({ path: 42 })).toBeNull();
|
||||||
|
});
|
||||||
|
it('returns null on malformed JSON string', () => {
|
||||||
|
expect(parseMapData('{not valid json')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── toNumberish ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('toNumberish', () => {
|
||||||
|
it('returns numbers as-is, rejects NaN/Infinity', () => {
|
||||||
|
expect(toNumberish(42)).toBe(42);
|
||||||
|
expect(toNumberish(Number.NaN)).toBeNull();
|
||||||
|
expect(toNumberish(Number.POSITIVE_INFINITY)).toBeNull();
|
||||||
|
});
|
||||||
|
it('parses numeric strings', () => {
|
||||||
|
expect(toNumberish('42')).toBe(42);
|
||||||
|
expect(toNumberish('42.5')).toBe(42.5);
|
||||||
|
});
|
||||||
|
it('returns null on non-numeric / blank', () => {
|
||||||
|
expect(toNumberish('')).toBeNull();
|
||||||
|
expect(toNumberish(null)).toBeNull();
|
||||||
|
expect(toNumberish('apple')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── extractNumerics ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('extractNumerics', () => {
|
||||||
|
it('extracts the full set + computes metric values from imperial', () => {
|
||||||
|
const row: NocoDbRow = {
|
||||||
|
Id: 1,
|
||||||
|
Length: 206.69,
|
||||||
|
Width: 46.56,
|
||||||
|
Draft: 14.5,
|
||||||
|
'Water Depth': 16.08,
|
||||||
|
'Nominal Boat Size': 200,
|
||||||
|
'Power Capacity': 330,
|
||||||
|
Voltage: 480,
|
||||||
|
Price: 3528000,
|
||||||
|
};
|
||||||
|
const n = extractNumerics(row);
|
||||||
|
expect(n.lengthFt).toBe(206.69);
|
||||||
|
expect(n.widthFt).toBe(46.56);
|
||||||
|
expect(n.draftFt).toBe(14.5);
|
||||||
|
expect(n.lengthM).toBe(63);
|
||||||
|
expect(n.widthM).toBe(14.19);
|
||||||
|
expect(n.draftM).toBe(4.42);
|
||||||
|
expect(n.waterDepth).toBe(16.08);
|
||||||
|
expect(n.waterDepthM).toBe(4.9);
|
||||||
|
expect(n.nominalBoatSize).toBe(200);
|
||||||
|
expect(n.nominalBoatSizeM).toBe(60.96);
|
||||||
|
expect(n.powerCapacity).toBe(330);
|
||||||
|
expect(n.voltage).toBe(480);
|
||||||
|
expect(n.price).toBe(3528000);
|
||||||
|
});
|
||||||
|
it('handles missing fields', () => {
|
||||||
|
const n = extractNumerics({ Id: 2 });
|
||||||
|
expect(n.lengthFt).toBeNull();
|
||||||
|
expect(n.lengthM).toBeNull();
|
||||||
|
});
|
||||||
|
it('rounds CRM values to 2 decimals to neutralize NocoDB precision drift', () => {
|
||||||
|
const n = extractNumerics({ Id: 3, Length: 12.3456789 });
|
||||||
|
expect(n.lengthFt).toBe(12.35);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── mapRow ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('mapRow', () => {
|
||||||
|
const sampleRow: NocoDbRow = {
|
||||||
|
Id: 1,
|
||||||
|
'Mooring Number': 'A1',
|
||||||
|
Area: 'A',
|
||||||
|
Status: 'Under Offer',
|
||||||
|
Length: 206.69,
|
||||||
|
Width: 46.56,
|
||||||
|
Draft: 14.5,
|
||||||
|
'Side Pontoon': 'Quay PT',
|
||||||
|
'Power Capacity': 330,
|
||||||
|
Voltage: 480,
|
||||||
|
'Mooring Type': 'Side Pier / Med Mooring',
|
||||||
|
'Cleat Type': 'A5',
|
||||||
|
'Cleat Capacity': '20-24 ton break load',
|
||||||
|
'Bollard Type': 'Bull bollard type B',
|
||||||
|
'Bollard Capacity': '40 ton break load',
|
||||||
|
Access: 'Car (3t) to Vessel',
|
||||||
|
'Bow Facing': 'East',
|
||||||
|
'Berth Approved': false,
|
||||||
|
'Width Is Minimum': false,
|
||||||
|
'Water Depth': 16.08,
|
||||||
|
'Water Depth Is Minimum': false,
|
||||||
|
'Nominal Boat Size': 200,
|
||||||
|
Price: 3528000,
|
||||||
|
status_override_mode: 'auto',
|
||||||
|
'Map Data': { path: 'M0 0', x: '1', y: '2', transform: '', fontSize: '32' },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('maps a representative NocoDB Berth row end-to-end', () => {
|
||||||
|
const out = mapRow(sampleRow);
|
||||||
|
expect(out).not.toBeNull();
|
||||||
|
expect(out!.legacyId).toBe(1);
|
||||||
|
expect(out!.mooringNumber).toBe('A1');
|
||||||
|
expect(out!.status).toBe('under_offer');
|
||||||
|
expect(out!.area).toBe('A');
|
||||||
|
expect(out!.numerics.lengthFt).toBe(206.69);
|
||||||
|
expect(out!.numerics.lengthM).toBe(63);
|
||||||
|
expect(out!.statusOverrideMode).toBe('auto');
|
||||||
|
expect(out!.mapData?.path).toBe('M0 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when Mooring Number is missing', () => {
|
||||||
|
const rest = { ...sampleRow };
|
||||||
|
delete (rest as Record<string, unknown>)['Mooring Number'];
|
||||||
|
const out = mapRow({ ...rest, Id: 99 } as NocoDbRow);
|
||||||
|
expect(out).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims whitespace from Mooring Number', () => {
|
||||||
|
const out = mapRow({ ...sampleRow, 'Mooring Number': ' A1 ' });
|
||||||
|
expect(out?.mooringNumber).toBe('A1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces Berth Approved to a strict boolean', () => {
|
||||||
|
const a = mapRow({ ...sampleRow, 'Berth Approved': true });
|
||||||
|
const b = mapRow({ ...sampleRow, 'Berth Approved': null });
|
||||||
|
const c = mapRow({ ...sampleRow, 'Berth Approved': 'yes' });
|
||||||
|
expect(a?.berthApproved).toBe(true);
|
||||||
|
expect(b?.berthApproved).toBe(false);
|
||||||
|
expect(c?.berthApproved).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops malformed Map Data gracefully', () => {
|
||||||
|
const out = mapRow({ ...sampleRow, 'Map Data': { path: 42 } as unknown });
|
||||||
|
expect(out?.mapData).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── buildPlan ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('buildPlan', () => {
|
||||||
|
function importedBerth(mooring: string, overrides: Partial<ImportedBerth> = {}): ImportedBerth {
|
||||||
|
return {
|
||||||
|
legacyId: 0,
|
||||||
|
mooringNumber: mooring,
|
||||||
|
area: null,
|
||||||
|
status: 'available',
|
||||||
|
numerics: extractNumerics({ Id: 0 }),
|
||||||
|
widthIsMinimum: false,
|
||||||
|
waterDepthIsMinimum: false,
|
||||||
|
sidePontoon: null,
|
||||||
|
mooringType: null,
|
||||||
|
cleatType: null,
|
||||||
|
cleatCapacity: null,
|
||||||
|
bollardType: null,
|
||||||
|
bollardCapacity: null,
|
||||||
|
access: null,
|
||||||
|
bowFacing: null,
|
||||||
|
berthApproved: false,
|
||||||
|
statusOverrideMode: null,
|
||||||
|
mapData: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function existing(
|
||||||
|
mooring: string,
|
||||||
|
updatedAt: Date,
|
||||||
|
lastImportedAt: Date | null,
|
||||||
|
): ExistingBerthRow {
|
||||||
|
return { id: `id-${mooring}`, mooringNumber: mooring, updatedAt, lastImportedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('plans inserts for moorings missing from CRM', () => {
|
||||||
|
const { plan, orphans } = buildPlan([importedBerth('A1')], new Map(), false);
|
||||||
|
expect(plan).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
action: 'insert',
|
||||||
|
imported: expect.objectContaining({ mooringNumber: 'A1' }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(orphans).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plans updates for unedited rows', () => {
|
||||||
|
const map = new Map<string, ExistingBerthRow>([
|
||||||
|
['A1', existing('A1', new Date('2026-04-01'), new Date('2026-04-02'))],
|
||||||
|
]);
|
||||||
|
const { plan } = buildPlan([importedBerth('A1')], map, false);
|
||||||
|
expect(plan[0]?.action).toBe('update');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips human-edited rows by default', () => {
|
||||||
|
const map = new Map<string, ExistingBerthRow>([
|
||||||
|
['A1', existing('A1', new Date('2026-04-10'), new Date('2026-04-01'))],
|
||||||
|
]);
|
||||||
|
const { plan } = buildPlan([importedBerth('A1')], map, false);
|
||||||
|
expect(plan[0]?.action).toBe('skip-edited');
|
||||||
|
expect(plan[0]?.reason).toMatch(/updated_at=.*last_imported_at=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours --force by updating even human-edited rows', () => {
|
||||||
|
const map = new Map<string, ExistingBerthRow>([
|
||||||
|
['A1', existing('A1', new Date('2026-04-10'), new Date('2026-04-01'))],
|
||||||
|
]);
|
||||||
|
const { plan } = buildPlan([importedBerth('A1')], map, true);
|
||||||
|
expect(plan[0]?.action).toBe('update');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats lastImportedAt=null as never-imported, not human-edited (so we update)', () => {
|
||||||
|
const map = new Map<string, ExistingBerthRow>([
|
||||||
|
['A1', existing('A1', new Date('2026-04-10'), null)],
|
||||||
|
]);
|
||||||
|
const { plan } = buildPlan([importedBerth('A1')], map, false);
|
||||||
|
expect(plan[0]?.action).toBe('update');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports CRM-side moorings missing from the import as orphans', () => {
|
||||||
|
const map = new Map<string, ExistingBerthRow>([
|
||||||
|
['A1', existing('A1', new Date(), null)],
|
||||||
|
['Z99', existing('Z99', new Date(), null)],
|
||||||
|
]);
|
||||||
|
const { plan, orphans } = buildPlan([importedBerth('A1')], map, false);
|
||||||
|
expect(plan).toHaveLength(1);
|
||||||
|
expect(orphans.map((o) => o.mooringNumber)).toEqual(['Z99']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('1-second tolerance: tiny updated_at lead is treated as in-sync, not edited', () => {
|
||||||
|
const lastImported = new Date('2026-04-10T12:00:00.000Z');
|
||||||
|
const updatedAt = new Date('2026-04-10T12:00:00.500Z'); // 500ms later
|
||||||
|
const map = new Map<string, ExistingBerthRow>([
|
||||||
|
['A1', existing('A1', updatedAt, lastImported)],
|
||||||
|
]);
|
||||||
|
const { plan } = buildPlan([importedBerth('A1')], map, false);
|
||||||
|
expect(plan[0]?.action).toBe('update');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user