import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { createInterface } from "node:readline"; import pg from "pg"; const { Client } = pg; const EXPECTED_DATABASE = "booking_test"; const REQUIRED_MIGRATION = "002_legacy_import_and_bookings"; const DEFAULT_BATCH = path.resolve( process.cwd(), "../.planning/data_import_audit/import_batch_2026_07_31" ); async function readStandardInput() { const lines = createInterface({ input: process.stdin, terminal: false }); for await (const line of lines) { lines.close(); return line.trim(); } throw Object.assign(new Error("Missing connection input"), { code: "MISSING_CONNECTION_INPUT" }); } function safeError(error) { return { ok: false, errorCode: typeof error?.code === "string" ? error.code : "IMPORT_FAILED", errorMessage: typeof error?.message === "string" ? error.message.replace(/\s+/g, " ").slice(0, 300) : "Import failed" }; } async function loadBatch(batchDirectory) { const read = async name => JSON.parse( await readFile(path.join(batchDirectory, name), "utf8") ); const [manifest, owners, usage, rejected] = await Promise.all([ read("manifest.json"), read("owners.json"), read("usage_legacy.json"), read("rejected_rows.json") ]); return { manifest, owners, usage, rejected }; } function assertBatch(batch) { const { manifest, owners, usage, rejected } = batch; if (manifest.mode !== "local-import-preflight") { throw Object.assign(new Error("Unexpected import batch mode"), { code: "INVALID_IMPORT_BATCH" }); } if (manifest.rules.latest_workbook_only !== true || manifest.rules.missing_confirmation !== "clear_or_exclude" || manifest.rules.legacy_applied_multiplier !== null || manifest.rules.legacy_rule_version !== "legacy-source") { throw Object.assign(new Error("Import batch rules do not match approved policy"), { code: "INVALID_IMPORT_BATCH_RULES" }); } if (owners.length !== manifest.counts.owner_count || usage.length !== manifest.counts.accepted_usage_count || rejected.length !== manifest.counts.rejected_row_count) { throw Object.assign(new Error("Import batch counts do not match manifest"), { code: "INVALID_IMPORT_BATCH_COUNTS" }); } if (manifest.checks.owner_issues.length > 0 || manifest.checks.required_usage_issues.length > 0 || manifest.checks.date_night_issues.length > 0 || manifest.checks.arithmetic_issues.length > 0 || manifest.checks.balance_chain_issues.length > 0 || manifest.checks.room_gt_one_count !== 0 || manifest.checks.missing_owner_rooms.length > 0) { throw Object.assign(new Error("Import batch contains failed preflight checks"), { code: "IMPORT_PREFLIGHT_FAILED" }); } } function periodKey(roomNo, year) { return `${roomNo}|${year}`; } async function bulkInsert(client, table, columns, rows, chunkSize = 100) { if (rows.length === 0) return; for (let start = 0; start < rows.length; start += chunkSize) { const chunk = rows.slice(start, start + chunkSize); const values = []; const placeholders = chunk.map((row, rowIndex) => { const rowPlaceholders = row.map((value, columnIndex) => { values.push(value); return `$${rowIndex * columns.length + columnIndex + 1}`; }); return `(${rowPlaceholders.join(", ")})`; }); await client.query( `INSERT INTO ${table} (${columns.join(", ")}) VALUES ${placeholders.join(", ")}`, values ); } } function createImportPlan(batch) { const { manifest, owners, usage } = batch; const importBatch = `legacy-${manifest.source_sha256.slice(0, 16)}`; const ownerIds = new Map(owners.map(owner => [owner.room_no, randomUUID()])); const usageByPeriod = new Map(); for (const record of usage) { if (!ownerIds.has(record.owner_room_no)) { throw Object.assign(new Error(`Usage room is not in owner batch: ${record.owner_room_no}`), { code: "IMPORT_OWNER_NOT_FOUND" }); } if (!Number.isInteger(record.period_year)) { throw Object.assign(new Error(`Usage period is missing: ${record.source_sheet}!${record.source_row}`), { code: "IMPORT_PERIOD_MISSING" }); } const key = periodKey(record.owner_room_no, record.period_year); const rows = usageByPeriod.get(key) ?? []; rows.push(record); usageByPeriod.set(key, rows); } const carryByRoom = new Map(); for (const owner of owners) { const rows = usageByPeriod.get(periodKey(owner.room_no, 2025)) ?? []; if (rows.length > 0) { const finalBalance = rows.at(-1).balance; carryByRoom.set(owner.room_no, finalBalance); } } const periodRows = []; const periodIds = new Map(); const ledgerGrantRows = []; const ledgerCarryRows = []; for (const owner of owners) { const years = new Set([2026]); for (const record of usageByPeriod.get(periodKey(owner.room_no, 2025)) ?? []) { years.add(record.period_year); } for (const year of [...years].sort((left, right) => left - right)) { const rows = usageByPeriod.get(periodKey(owner.room_no, year)) ?? []; const carryForward = year === 2026 ? carryByRoom.get(owner.room_no) ?? 0 : 0; if (year === 2025 && rows.length > 0 && rows[0].total !== 15) { throw Object.assign(new Error(`2025 source Total is not 15 for ${owner.room_no}`), { code: "IMPORT_PERIOD_OPENING_MISMATCH" }); } if (year === 2026 && rows.length > 0 && carryForward === 0 && rows[0].total !== 15) { throw Object.assign(new Error(`2026 source Total is not 15 for ${owner.room_no}`), { code: "IMPORT_PERIOD_OPENING_MISMATCH" }); } const useSum = rows.reduce((sum, row) => sum + row.use, 0); const currentBalance = 15 + carryForward - useSum; const id = randomUUID(); periodIds.set(periodKey(owner.room_no, year), id); periodRows.push([ id, ownerIds.get(owner.room_no), year, `${year}-01-01`, `${year}-12-31`, 15, carryForward, currentBalance, 1 ]); const grantLedgerId = randomUUID(); ledgerGrantRows.push([ grantLedgerId, id, null, "annual_grant", 15, 0, 15 ]); if (carryForward > 0) { ledgerCarryRows.push([ randomUUID(), id, null, "carry_forward", carryForward, 15, 15 + carryForward ]); } } } const confirmationSet = new Set(usage.map(record => record.confirmation_no)); const bookingRows = [...confirmationSet].sort().map(confirmation => [confirmation]); const usageRows = []; const usageIds = new Map(); for (const record of usage) { const usageId = randomUUID(); const usageKey = `${record.source_sheet}!${record.source_row}`; usageIds.set(usageKey, usageId); usageRows.push([ usageId, ownerIds.get(record.owner_room_no), periodIds.get(periodKey(record.owner_room_no, record.period_year)), record.confirmation_no, record.check_in, record.check_out, record.night, record.canonical_used_room_type_code, record.raw_used_room_type, null, "legacy-source", record.use, record.total, record.balance, record.room, record.remark, randomUUID(), record.source_sheet, record.source_row, record.source_sequence, importBatch ]); } const usageLedgerRows = usage.map(record => [ randomUUID(), periodIds.get(periodKey(record.owner_room_no, record.period_year)), usageIds.get(`${record.source_sheet}!${record.source_row}`), "usage", -record.use, record.total, record.balance ]); return { importBatch, ownerIds, periodIds, periodRows, bookingRows, ledgerGrantRows, ledgerCarryRows, usageRows, usageLedgerRows, counts: { ownerCount: owners.length, periodCount: periodRows.length, bookingCount: bookingRows.length, usageCount: usageRows.length, ledgerCount: ledgerGrantRows.length + ledgerCarryRows.length + usageLedgerRows.length } }; } async function verifyEmptyTarget(client) { const result = await client.query(` SELECT (SELECT count(*)::integer FROM condon.owner_accounts) AS owners, (SELECT count(*)::integer FROM condon.entitlement_periods) AS periods, (SELECT count(*)::integer FROM condon.usage_records) AS usage, (SELECT count(*)::integer FROM condon.entitlement_ledger) AS ledger, (SELECT count(*)::integer FROM condon.bookings) AS bookings `); const counts = result.rows[0]; if (Object.values(counts).some(value => Number(value) !== 0)) { throw Object.assign(new Error("CONDO business tables are not empty; import aborted"), { code: "IMPORT_REQUIRES_EMPTY_CONDON" }); } return counts; } async function verifyMigration(client) { const result = await client.query(` SELECT version FROM condon.schema_migrations WHERE version = $1 `, [REQUIRED_MIGRATION]); if (result.rowCount !== 1) { throw Object.assign(new Error(`Required migration is missing: ${REQUIRED_MIGRATION}`), { code: "IMPORT_SCHEMA_NOT_READY" }); } } const batchDirectory = path.resolve(process.argv[2] ?? DEFAULT_BATCH); const apply = process.argv.includes("--apply"); let client; let transactionStarted = false; try { const batch = await loadBatch(batchDirectory); assertBatch(batch); const plan = createImportPlan(batch); if (!apply) { process.stdout.write(`${JSON.stringify({ ok: true, mode: "dry-run", batchDirectory, importBatch: plan.importBatch, counts: plan.counts }, null, 2)}\n`); process.exit(0); } const input = JSON.parse(await readStandardInput()); client = new Client({ host: input.host, port: input.port, user: input.user, password: input.password, database: input.database, ssl: input.ssl ? { rejectUnauthorized: false } : undefined, application_name: "condon_legacy_import", connectionTimeoutMillis: 10_000, query_timeout: 60_000, options: "-c statement_timeout=60000 -c lock_timeout=5000" }); await client.connect(); await client.query("BEGIN"); transactionStarted = true; const identity = (await client.query(` SELECT current_database() AS database_name, current_user AS role_name `)).rows[0]; if (identity.database_name !== EXPECTED_DATABASE || identity.role_name !== input.user) { throw Object.assign(new Error("Unexpected database or role"), { code: "UNEXPECTED_DATABASE" }); } await verifyMigration(client); const before = await verifyEmptyTarget(client); const ownerRows = batch.owners.map(owner => [ plan.ownerIds.get(owner.room_no), owner.account_no, owner.transfer_date, owner.name, owner.room_no, owner.purchased_room_type_code, owner.unit_no, owner.member_no ]); await bulkInsert( client, "condon.owner_accounts", ["id", "account_no", "transfer_date", "owner_name", "room_no", "purchased_room_type_code", "unit_no", "member_no"], ownerRows ); await bulkInsert( client, "condon.entitlement_periods", ["id", "owner_account_id", "period_year", "period_start", "period_end", "annual_grant", "carry_forward", "current_balance", "row_version"], plan.periodRows ); await bulkInsert(client, "condon.bookings", ["confirmation_no"], plan.bookingRows); await bulkInsert( client, "condon.entitlement_ledger", ["id", "entitlement_period_id", "usage_record_id", "entry_type", "delta_nights", "balance_before", "balance_after"], [...plan.ledgerGrantRows, ...plan.ledgerCarryRows] ); await bulkInsert( client, "condon.usage_records", [ "id", "owner_account_id", "entitlement_period_id", "confirmation_no", "check_in", "check_out", "night_count", "used_room_type_code", "raw_used_room_type", "applied_multiplier", "rule_version", "use_nights", "balance_before", "balance_after", "room_count", "remark", "idempotency_key", "source_sheet", "source_row", "source_sequence", "import_batch" ], plan.usageRows ); await bulkInsert( client, "condon.entitlement_ledger", ["id", "entitlement_period_id", "usage_record_id", "entry_type", "delta_nights", "balance_before", "balance_after"], plan.usageLedgerRows ); const afterResult = await client.query(` SELECT (SELECT count(*)::integer FROM condon.owner_accounts) AS owners, (SELECT count(*)::integer FROM condon.entitlement_periods) AS periods, (SELECT count(*)::integer FROM condon.bookings) AS bookings, (SELECT count(*)::integer FROM condon.usage_records) AS usage, (SELECT count(*)::integer FROM condon.entitlement_ledger) AS ledger, (SELECT coalesce(sum(use_nights), 0)::integer FROM condon.usage_records) AS use_sum, (SELECT coalesce(sum(current_balance), 0)::integer FROM condon.entitlement_periods WHERE period_year = 2026) AS current_balance_2026 `); const after = afterResult.rows[0]; if (Number(after.owners) !== plan.counts.ownerCount || Number(after.periods) !== plan.counts.periodCount || Number(after.bookings) !== plan.counts.bookingCount || Number(after.usage) !== plan.counts.usageCount || Number(after.ledger) !== plan.counts.ledgerCount || Number(after.use_sum) !== batch.manifest.counts.usage_use_sum) { throw Object.assign(new Error("Import post-check counts failed"), { code: "IMPORT_POSTCHECK_FAILED" }); } await client.query("COMMIT"); transactionStarted = false; process.stdout.write(`${JSON.stringify({ ok: true, mode: "applied", batchDirectory, importBatch: plan.importBatch, before, planned: plan.counts, after }, null, 2)}\n`); } catch (error) { if (client && transactionStarted) { await client.query("ROLLBACK").catch(() => undefined); transactionStarted = false; } process.stdout.write(`${JSON.stringify(safeError(error), null, 2)}\n`); process.exitCode = 1; } finally { if (client) await client.end().catch(() => { process.exitCode = 1; }); }