Files
wyndham-Condon/backend/scripts/import-confirmation-batch.mjs
2026-08-02 13:33:13 +08:00

449 lines
14 KiB
JavaScript

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; });
}