Files
pn-new-crm/scripts/migration/connect-berth-links.ts
Matt a7c11f2c51 feat(migration): exhaustive reconciliation + multi-berth link fix
reconcile-migration.ts: read-only cross-check of EVERY migrated record vs its
legacy source (via the ledger) — coverage (nothing dropped), field fidelity
(independently re-derives stage/eoiStatus/documensoId/berth/email), and
relationship integrity (orphans, dangling FKs).

connect-berth-links.ts: the dedup pipeline migrated only the single per-interest
Berth Number text field and missed the legacy _nc_m2m_Berths_Interests junction
(multi-berth deals) — 57 deals were missing links. Reads the junction from the
nocodb_legacy snapshot, resolves interest + berth via the ledger, inserts the
missing interest_berths rows (idempotent; respects the one-primary partial
unique index). Inserted 74 links, 51 new primaries.

After the fix: reconciliation = 0 discrepancies across all 255 deals, 165
expenses, 45 residential.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:16:41 +02:00

144 lines
5.2 KiB
TypeScript

/**
* Fix-up: connect the multi-berth links the main pipeline missed.
*
* The dedup pipeline migrated only each interest's single `Berth Number` text
* field; the legacy `_nc_m2m_Berths_Interests` junction (multi-berth deals) was
* not carried over. This reads that junction from the `nocodb_legacy` snapshot,
* resolves each legacy interest → its migrated interest (via the ledger) and
* each mooring → the migrated berth, and inserts the missing `interest_berths`
* rows.
*
* Idempotent: `ON CONFLICT (interest_id, berth_id) DO NOTHING`. Primary safety:
* only makes a berth primary when the interest has no primary yet (≤1 primary
* per interest is a partial unique index).
*
* pnpm tsx scripts/migration/connect-berth-links.ts [--port-slug port-nimara]
*/
import 'dotenv/config';
import { randomUUID } from 'node:crypto';
import postgres from 'postgres';
const slugArg = (() => {
const i = process.argv.indexOf('--port-slug');
return i >= 0 ? (process.argv[i + 1] ?? 'port-nimara') : 'port-nimara';
})();
const DRY = process.argv.includes('--dry-run');
const CRM_URL = process.env.DATABASE_URL!;
const LEGACY_URL = process.env.LEGACY_DB_URL ?? CRM_URL.replace(/\/[^/]+$/, '/nocodb_legacy');
const crm = postgres(CRM_URL, { max: 4 });
const legacy = postgres(LEGACY_URL, { max: 4 });
const canonMoo = (raw: string): string => {
const m = /^([A-Za-z]+)-?0*(\d+)$/.exec((raw ?? '').trim());
return m ? `${m[1]!.toUpperCase()}${parseInt(m[2]!, 10)}` : (raw ?? '').trim();
};
async function main() {
const [port] = await crm`select id from ports where slug=${slugArg} limit 1`;
if (!port) throw new Error(`no port ${slugArg}`);
const portId = port.id as string;
// legacy junction: interestId → set(moorings) (+ the Berth_Number text field)
const mooById = new Map<number, string>();
for (const b of await legacy`select id, "Mooring_Number" m from plplouets5zw1um."Berths"`)
mooById.set(b.id as number, canonMoo(b.m as string));
const legacyMoo = new Map<number, Set<string>>();
for (const j of await legacy`select "Interests_id" i, "Berths_id" b from plplouets5zw1um."_nc_m2m_Berths_Interests"`) {
const set = legacyMoo.get(j.i as number) ?? new Set<string>();
const m = mooById.get(j.b as number);
if (m) set.add(m);
legacyMoo.set(j.i as number, set);
}
// EOI-signed flag per legacy interest (for is_in_eoi_bundle)
const signed = new Set<number>();
for (const r of await legacy`select id, "EOI_Status" e, "LOI_NDA_Document" l from plplouets5zw1um."Interests"`) {
const e = ((r.e as string) ?? '').trim();
const l = ((r.l as string) ?? '').trim();
if (
e === 'Signed' ||
['Signing Complete', 'Signed by Client', 'Signed by Developer'].includes(l)
)
signed.add(r.id as number);
}
// ledger: legacy interest id → new interest id
const links =
await crm`select source_id, target_entity_id from migration_source_links where source_system='nocodb_interests' and target_entity_type='interest'`;
const newInterestBySrc = new Map(
links.map((l) => [Number(l.source_id), l.target_entity_id as string]),
);
// CRM berth id by mooring (this port)
const berthByMoo = new Map(
(await crm`select id, mooring_number m from berths where port_id=${portId}`).map((b) => [
b.m as string,
b.id as string,
]),
);
let inserted = 0;
let madePrimary = 0;
let skipped = 0;
const unresolved: string[] = [];
for (const [legacyId, moorings] of legacyMoo) {
const interestId = newInterestBySrc.get(legacyId);
if (!interestId) continue; // not a migrated interest (backup/copy tables)
// does this interest already have a primary?
const primaryCheck =
await crm`select exists(select 1 from interest_berths where interest_id=${interestId} and is_primary) as has`;
let hasPrimary = (primaryCheck[0]?.has as boolean | undefined) ?? false;
for (const moo of moorings) {
const berthId = berthByMoo.get(moo);
if (!berthId) {
unresolved.push(`${legacyId}:${moo}`);
continue;
}
const makePrimary = !hasPrimary;
if (DRY) {
inserted++;
if (makePrimary) {
madePrimary++;
hasPrimary = true;
}
continue;
}
const res = await crm`
insert into interest_berths (id, interest_id, berth_id, is_primary, is_specific_interest, is_in_eoi_bundle)
values (${randomUUID()}, ${interestId}, ${berthId}, ${makePrimary}, true, ${signed.has(legacyId)})
on conflict (interest_id, berth_id) do nothing
returning id`;
if (res.length > 0) {
inserted++;
if (makePrimary) {
madePrimary++;
hasPrimary = true;
}
} else {
skipped++;
}
}
}
console.log(
`connect-berth-links ${DRY ? '(DRY)' : ''}: inserted ${inserted} links (${madePrimary} new primary), ${skipped} already linked`,
);
if (unresolved.length)
console.log(
`${unresolved.length} moorings with no CRM berth: ${unresolved.slice(0, 20).join(', ')}`,
);
await crm.end();
await legacy.end();
process.exit(0);
}
main().catch(async (e) => {
console.error('connect-berth-links failed:', e);
await crm.end().catch(() => {});
await legacy.end().catch(() => {});
process.exit(1);
});