feat: add Condo owner desk frontend and backend
This commit is contained in:
448
backend/scripts/import-confirmation-batch.mjs
Normal file
448
backend/scripts/import-confirmation-batch.mjs
Normal file
@@ -0,0 +1,448 @@
|
||||
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; });
|
||||
}
|
||||
Reference in New Issue
Block a user