449 lines
14 KiB
JavaScript
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; });
|
|
}
|