feat: add Condo owner desk frontend and backend
This commit is contained in:
219
backend/scripts/verify-import-batch.mjs
Normal file
219
backend/scripts/verify-import-batch.mjs
Normal file
@@ -0,0 +1,219 @@
|
||||
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 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 new Error("Missing connection input");
|
||||
}
|
||||
|
||||
async function readBatch(batchDirectory) {
|
||||
const read = async name => JSON.parse(
|
||||
await readFile(path.join(batchDirectory, name), "utf8")
|
||||
);
|
||||
return {
|
||||
manifest: await read("manifest.json"),
|
||||
owners: await read("owners.json"),
|
||||
usage: await read("usage_legacy.json")
|
||||
};
|
||||
}
|
||||
|
||||
function same(left, right) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
const batchDirectory = path.resolve(process.argv[2] ?? DEFAULT_BATCH);
|
||||
let client;
|
||||
let transactionStarted = false;
|
||||
|
||||
try {
|
||||
const batch = await readBatch(batchDirectory);
|
||||
const input = JSON.parse(await readStandardInput());
|
||||
const importBatch = `legacy-${batch.manifest.source_sha256.slice(0, 16)}`;
|
||||
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_import_readonly_verification",
|
||||
connectionTimeoutMillis: 10_000,
|
||||
query_timeout: 30_000,
|
||||
options: "-c default_transaction_read_only=on -c statement_timeout=30000 -c lock_timeout=2000"
|
||||
});
|
||||
await client.connect();
|
||||
await client.query("BEGIN TRANSACTION READ ONLY");
|
||||
transactionStarted = true;
|
||||
|
||||
const identity = (await client.query(`
|
||||
SELECT current_database() AS database_name, current_user AS role_name
|
||||
`)).rows[0];
|
||||
const ownerRows = (await client.query(`
|
||||
SELECT account_no, transfer_date::text, owner_name, room_no,
|
||||
purchased_room_type_code, unit_no, member_no
|
||||
FROM condon.owner_accounts
|
||||
ORDER BY room_no
|
||||
`)).rows;
|
||||
const usageRows = (await client.query(`
|
||||
SELECT
|
||||
ur.source_sheet,
|
||||
ur.source_row,
|
||||
oa.room_no AS owner_room_no,
|
||||
ur.confirmation_no,
|
||||
ur.check_in::text,
|
||||
ur.check_out::text,
|
||||
ur.night_count,
|
||||
ur.room_count,
|
||||
ur.balance_before,
|
||||
ur.use_nights,
|
||||
ur.balance_after,
|
||||
ur.raw_used_room_type,
|
||||
ur.used_room_type_code,
|
||||
ur.remark,
|
||||
ur.applied_multiplier,
|
||||
ur.rule_version,
|
||||
ur.import_batch
|
||||
FROM condon.usage_records ur
|
||||
JOIN condon.owner_accounts oa ON oa.id = ur.owner_account_id
|
||||
WHERE ur.import_batch = $1
|
||||
ORDER BY ur.source_sheet, ur.source_row
|
||||
`, [importBatch])).rows;
|
||||
const counts = (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 count(*)::integer FROM condon.usage_records WHERE rule_version='legacy-source' AND applied_multiplier IS NULL) AS legacy_count
|
||||
`)).rows[0];
|
||||
const carry = (await client.query(`
|
||||
SELECT ep.period_year, ep.annual_grant, ep.carry_forward, ep.current_balance
|
||||
FROM condon.entitlement_periods ep
|
||||
JOIN condon.owner_accounts oa ON oa.id=ep.owner_account_id
|
||||
WHERE oa.room_no='4311'
|
||||
ORDER BY ep.period_year
|
||||
`)).rows;
|
||||
|
||||
const ownerMap = new Map(ownerRows.map(row => [row.room_no, row]));
|
||||
const expectedOwnerMap = new Map(batch.owners.map(owner => [owner.room_no, owner]));
|
||||
const ownerMismatches = [];
|
||||
for (const owner of batch.owners) {
|
||||
const actual = ownerMap.get(owner.room_no);
|
||||
const expected = {
|
||||
account_no: owner.account_no,
|
||||
transfer_date: owner.transfer_date,
|
||||
owner_name: owner.name,
|
||||
room_no: owner.room_no,
|
||||
purchased_room_type_code: owner.purchased_room_type_code,
|
||||
unit_no: owner.unit_no,
|
||||
member_no: owner.member_no
|
||||
};
|
||||
if (!actual || !same(actual, expected)) {
|
||||
ownerMismatches.push({ room_no: owner.room_no, expected, actual: actual ?? null });
|
||||
}
|
||||
}
|
||||
|
||||
const expectedUsageMap = new Map(
|
||||
batch.usage.map(row => [`${row.source_sheet}!${row.source_row}`, row])
|
||||
);
|
||||
const actualUsageMap = new Map(
|
||||
usageRows.map(row => [`${row.source_sheet}!${row.source_row}`, row])
|
||||
);
|
||||
const usageMismatches = [];
|
||||
for (const expected of batch.usage) {
|
||||
const key = `${expected.source_sheet}!${expected.source_row}`;
|
||||
const actual = actualUsageMap.get(key);
|
||||
const normalizedExpected = {
|
||||
source_sheet: expected.source_sheet,
|
||||
source_row: expected.source_row,
|
||||
owner_room_no: expected.owner_room_no,
|
||||
confirmation_no: expected.confirmation_no,
|
||||
check_in: expected.check_in,
|
||||
check_out: expected.check_out,
|
||||
night_count: expected.night,
|
||||
room_count: expected.room,
|
||||
balance_before: expected.total,
|
||||
use_nights: expected.use,
|
||||
balance_after: expected.balance,
|
||||
raw_used_room_type: expected.raw_used_room_type,
|
||||
used_room_type_code: expected.canonical_used_room_type_code,
|
||||
remark: expected.remark,
|
||||
applied_multiplier: null,
|
||||
rule_version: "legacy-source",
|
||||
import_batch: importBatch
|
||||
};
|
||||
if (!actual || !same(actual, normalizedExpected)) {
|
||||
usageMismatches.push({ key, expected: normalizedExpected, actual: actual ?? null });
|
||||
}
|
||||
}
|
||||
|
||||
const checks = {
|
||||
databaseVerified: identity.database_name === EXPECTED_DATABASE
|
||||
&& identity.role_name === input.user,
|
||||
counts: Number(counts.owners) === batch.manifest.counts.owner_count
|
||||
&& Number(counts.periods) === 389
|
||||
&& Number(counts.bookings) === 155
|
||||
&& Number(counts.usage) === batch.manifest.counts.accepted_usage_count
|
||||
&& Number(counts.ledger) === 547
|
||||
&& Number(counts.use_sum) === batch.manifest.counts.usage_use_sum,
|
||||
ownersMatchBatch: ownerRows.length === expectedOwnerMap.size && ownerMismatches.length === 0,
|
||||
usagesMatchBatch: usageRows.length === batch.usage.length && usageMismatches.length === 0,
|
||||
legacyValuesPreserved: Number(counts.legacy_count) === batch.usage.length,
|
||||
carryForward4311: same(carry, [
|
||||
{
|
||||
period_year: 2025,
|
||||
annual_grant: 15,
|
||||
carry_forward: 0,
|
||||
current_balance: 12
|
||||
},
|
||||
{
|
||||
period_year: 2026,
|
||||
annual_grant: 15,
|
||||
carry_forward: 12,
|
||||
current_balance: 27
|
||||
}
|
||||
])
|
||||
};
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok: Object.values(checks).every(Boolean),
|
||||
batchDirectory,
|
||||
importBatch,
|
||||
checks,
|
||||
counts,
|
||||
ownerMismatchCount: ownerMismatches.length,
|
||||
usageMismatchCount: usageMismatches.length,
|
||||
ownerMismatches: ownerMismatches.slice(0, 5),
|
||||
usageMismatches: usageMismatches.slice(0, 5),
|
||||
carry4311: carry
|
||||
}, null, 2)}\n`);
|
||||
if (Object.values(checks).some(value => value === false)) process.exitCode = 1;
|
||||
} catch (error) {
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok: false,
|
||||
errorCode: typeof error?.code === "string" ? error.code : "VERIFY_IMPORT_FAILED",
|
||||
errorMessage: typeof error?.message === "string" ? error.message : "Import verification failed"
|
||||
}, null, 2)}\n`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (client) {
|
||||
if (transactionStarted) await client.query("ROLLBACK").catch(() => undefined);
|
||||
await client.end().catch(() => { process.exitCode = 1; });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user