mirror of
https://github.com/tiennm99/bsk.git
synced 2026-08-24 10:26:21 +00:00
feat: add data migration from the original Java BSK SQLite database
npm run db:migrate-upstream imports a BSK.db file into the bsk schema via node:sqlite: re-keys all ids, maps shift 0/1 to morning/afternoon, converts RTF template content to field labels, keeps pre-1970 birth dates, folds suggestion/service-notes/ultrasound-doctor into visit notes, and warns on every dropped or unmappable value. Pure transforms live in scripts/upstream-transforms.mjs with a unit suite. Also grants service_role the table privileges PostgREST needs for this and the existing seed scripts.
This commit is contained in:
@@ -32,6 +32,20 @@ After provisioning Supabase/Upstash/Vercel and `npm run db:push`:
|
||||
|
||||
See [docs/supabase-shared-config.md](./docs/supabase-shared-config.md) for the shared-project rules and [docs/design-guidelines.md](./docs/design-guidelines.md) for UI conventions.
|
||||
|
||||
### Migrating data from the original BSK app
|
||||
|
||||
An existing install of the upstream Java app can be imported from its SQLite file (`database/BSK.db`):
|
||||
|
||||
```bash
|
||||
npm run db:migrate-upstream -- /path/to/BSK.db --dry-run # preview counts + warnings, no writes
|
||||
NEXT_PUBLIC_SUPABASE_URL=... SUPABASE_SECRET_KEY=... \
|
||||
npm run db:migrate-upstream -- /path/to/BSK.db
|
||||
```
|
||||
|
||||
**Copying the file:** the upstream server runs SQLite in WAL mode — stop the Java server first and copy `BSK.db` together with its `BSK.db-wal`/`BSK.db-shm` siblings (or checkpoint first), otherwise the newest visits are silently missing.
|
||||
|
||||
Run it once, after `npm run db:push`, against an otherwise empty `bsk` schema (it re-keys all ids and refuses a non-empty target). Migrated: clinic settings, doctors, medicines, services, checkup templates (upstream RTF content converted to plain-text field labels — review them in the admin UI afterwards), patients (including pre-1970 birth dates), visits (shift 0/1 → morning/afternoon; suggestion, per-service notes, and the ultrasound doctor are folded into the visit notes), prescriptions, service lines, payment state, queue counters. **Not** migrated: staff accounts (re-invite via staff management — upstream passwords are never reused), patient images (Google Drive stays where it is), medicine descriptions/preferred notes (warned when present), and patient province/ward codes (the names remain readable at the end of each address string; backfill codes in the UI after `npm run db:seed-geo`). The script header and the reports `researcher-260818-1712-…-migration-mapping` and `code-reviewer-260818-1749-…-upstream-compat-audit` under `plans/reports/` document the full field mapping and its source-level verification.
|
||||
|
||||
## Stack
|
||||
|
||||
- **npm** + **Next.js 16** (App Router) + **JavaScript with JSDoc types** (checked by tsc)
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"db:push": "npm run db:preflight && supabase db push",
|
||||
"db:gen-types": "supabase gen types typescript --schema bsk > types/supabase-bsk.d.ts",
|
||||
"db:seed-geo": "node scripts/seed-geo.mjs",
|
||||
"db:migrate-upstream": "node scripts/migrate-from-upstream.mjs",
|
||||
"check:no-secret-leak": "node scripts/check-no-secret-leak.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,847 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Migrate data from an original BSK desktop install (Java/Swing + SQLite,
|
||||
* https://github.com/lds217/BSK-All-in-One-Clinic-Management-System) into this
|
||||
* rewrite's Supabase `bsk` schema.
|
||||
*
|
||||
* Usage:
|
||||
* NEXT_PUBLIC_SUPABASE_URL=... SUPABASE_SECRET_KEY=... \
|
||||
* npm run db:migrate-upstream -- /path/to/BSK.db [--dry-run] [--allow-nonempty]
|
||||
*
|
||||
* --dry-run Read + transform only; prints what would be migrated and
|
||||
* every warning. Needs no Supabase env vars.
|
||||
* --allow-nonempty Skip the "target must be empty" preflight (the script
|
||||
* re-keys all ids, so re-running WILL duplicate rows —
|
||||
* only use this when you know what you are doing).
|
||||
*
|
||||
* IMPORTANT — the upstream server runs SQLite in WAL mode with periodic
|
||||
* checkpoints. Stop the Java server before copying, and copy BSK.db TOGETHER
|
||||
* with its BSK.db-wal / BSK.db-shm siblings (or run `PRAGMA wal_checkpoint;`
|
||||
* first) — otherwise the most recent visits are silently missing.
|
||||
*
|
||||
* Field-level mapping decisions are documented in
|
||||
* plans/reports/researcher-260818-1712-upstream-sqlite-to-supabase-migration-
|
||||
* mapping-report.md, and every assumption below was verified against the real
|
||||
* Java source in plans/reports/code-reviewer-260818-1749-migration-script-
|
||||
* upstream-compat-audit-report.md. Highlights:
|
||||
* - All primary keys are re-keyed (target uses GENERATED ALWAYS AS IDENTITY);
|
||||
* foreign keys are remapped via in-memory old→new id maps.
|
||||
* - Money: upstream DOUBLE VND → integer VND (rounded, clamped to >= 0).
|
||||
* - Dates: upstream stores epoch millis (negative for pre-1970 birth dates)
|
||||
* → clinic-local (UTC+7) DATE.
|
||||
* - Shifts: upstream 0=morning / 1=afternoon → target shift_id 1 / 2.
|
||||
* - Enum-ish text (status, gender, payment) is translated from the exact
|
||||
* labels the Java app writes; unknown values fall back with a warning.
|
||||
* - Template `content` is RTF (Swing RTFEditorKit); it is converted to plain
|
||||
* text and each line becomes one field label — review templates after.
|
||||
* - Folded into the visit's notes (no dedicated target column): the
|
||||
* "suggestion" text, per-service-line notes, and the ultrasound doctor.
|
||||
* remind_date is used as recheck_date when reCheckupDate is empty.
|
||||
* - Dropped (no target column; warned when populated): medicine
|
||||
* preferred_note / description / supplement flag, order processed_by /
|
||||
* total_amount (total is recomputed from line items by the app).
|
||||
* - Staff accounts are NOT migrated (auth lives in Supabase now, upstream
|
||||
* passwords must not be reused). The upstream roster is printed so the
|
||||
* admin can re-invite staff via the app's staff-management UI.
|
||||
* - Patient media (Google Drive ids) stays behind. province/ward codes are
|
||||
* left NULL — upstream appends ", ward, province" NAMES to the address
|
||||
* string, so they remain readable there; backfill codes in the UI. Geo
|
||||
* reference data always comes from `npm run db:seed-geo`, never upstream.
|
||||
*
|
||||
* Requires Node >= 24 (uses the built-in node:sqlite driver, read-only).
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import {
|
||||
GENDER_MAP,
|
||||
PAID_LABELS,
|
||||
STATUS_MAP,
|
||||
bool,
|
||||
int,
|
||||
mapShift,
|
||||
money,
|
||||
norm,
|
||||
rtfToText,
|
||||
setOnWarn,
|
||||
str,
|
||||
vnDate,
|
||||
} from "./upstream-transforms.mjs";
|
||||
|
||||
/** @typedef {string | number | bigint | Uint8Array | null} SqlValue */
|
||||
/** @typedef {Record<string, SqlValue>} Row */
|
||||
|
||||
/**
|
||||
* @param {string} msg
|
||||
* @returns {never}
|
||||
*/
|
||||
function die(msg) {
|
||||
process.stderr.write(`\n[migrate-upstream] ERROR: ${msg}\n\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** @type {string[]} */
|
||||
const warnings = [];
|
||||
/** @param {string} msg */
|
||||
function warn(msg) {
|
||||
warnings.push(msg);
|
||||
}
|
||||
setOnWarn(warn); // transforms report dropped/odd values through the same sink
|
||||
|
||||
/** @param {string} msg */
|
||||
function log(msg) {
|
||||
process.stdout.write(`[migrate-upstream] ${msg}\n`);
|
||||
}
|
||||
|
||||
// ─── CLI / env ────────────────────────────────────────────────────────────────
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const allowNonempty = args.includes("--allow-nonempty");
|
||||
const dbArg = args.find((a) => !a.startsWith("--"));
|
||||
if (!dbArg) {
|
||||
die("Usage: npm run db:migrate-upstream -- /path/to/BSK.db [--dry-run] [--allow-nonempty]");
|
||||
}
|
||||
const dbPath = resolve(process.cwd(), dbArg);
|
||||
if (!existsSync(dbPath)) die(`SQLite file not found: ${dbPath}`);
|
||||
|
||||
/** @typedef {import("@supabase/supabase-js").SupabaseClient<any, any, "bsk">} BskClient */
|
||||
|
||||
/** @type {BskClient | null} */
|
||||
let supabase = null;
|
||||
if (!dryRun) {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const key = process.env.SUPABASE_SECRET_KEY;
|
||||
if (!url || !key) {
|
||||
die(
|
||||
"Missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SECRET_KEY in the environment " +
|
||||
"(or pass --dry-run to preview without writing).",
|
||||
);
|
||||
}
|
||||
supabase = createClient(url, key, {
|
||||
db: { schema: "bsk" },
|
||||
auth: { persistSession: false },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── SQLite helpers (schema-tolerant: the upstream app evolved, so we probe
|
||||
// tables/columns instead of assuming the exact shape) ─────────────────────
|
||||
|
||||
const sqlite = new DatabaseSync(dbPath, { readOnly: true });
|
||||
|
||||
/**
|
||||
* Actual table name in the file for a case-insensitive lookup, or null.
|
||||
* @param {string} name
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function findTable(name) {
|
||||
const row = /** @type {Row | undefined} */ (
|
||||
sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND lower(name) = lower(?)")
|
||||
.get(name)
|
||||
);
|
||||
return row ? String(row.name) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* All rows of a table with keys lowercased, or [] when the table is absent.
|
||||
* @param {string} name
|
||||
* @returns {Row[]}
|
||||
*/
|
||||
function readTable(name) {
|
||||
const actual = findTable(name);
|
||||
if (!actual) return [];
|
||||
const rows = /** @type {Row[]} */ (sqlite.prepare(`SELECT * FROM "${actual}"`).all());
|
||||
return rows.map((r) => {
|
||||
/** @type {Row} */
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(r)) out[k.toLowerCase()] = v;
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Supabase write helpers ───────────────────────────────────────────────────
|
||||
|
||||
let dryRunIdCounter = -1;
|
||||
const CHUNK = 400;
|
||||
|
||||
/**
|
||||
* Requires the live client (never called in --dry-run).
|
||||
* @returns {BskClient}
|
||||
*/
|
||||
function sb() {
|
||||
if (!supabase) die("internal: supabase client used in dry-run");
|
||||
return supabase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts rows in chunks and returns the generated ids in input order
|
||||
* (PostgREST returns representations in insertion order). Dry-run hands back
|
||||
* placeholder negative ids so dependent transforms still run.
|
||||
* @param {string} table
|
||||
* @param {Record<string, unknown>[]} rows
|
||||
* @returns {Promise<number[]>}
|
||||
*/
|
||||
async function insertReturningIds(table, rows) {
|
||||
if (dryRun) return rows.map(() => dryRunIdCounter--);
|
||||
/** @type {number[]} */
|
||||
const ids = [];
|
||||
for (let i = 0; i < rows.length; i += CHUNK) {
|
||||
const batch = rows.slice(i, i + CHUNK);
|
||||
const { data, error } = await sb()
|
||||
.from(table)
|
||||
.insert(/** @type {never[]} */ (batch))
|
||||
.select("id");
|
||||
if (error) die(`Insert into ${table} failed at row ${i}: ${error.message}`);
|
||||
for (const r of /** @type {{ id: number }[]} */ (data ?? [])) ids.push(r.id);
|
||||
}
|
||||
if (ids.length !== rows.length) {
|
||||
die(`Insert into ${table}: expected ${rows.length} returned ids, got ${ids.length}`);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunked insert without id mapping (line items, counters).
|
||||
* @param {string} table
|
||||
* @param {Record<string, unknown>[]} rows
|
||||
* @param {{ upsert?: boolean }} [opts]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function insertRows(table, rows, opts = {}) {
|
||||
if (dryRun) return;
|
||||
for (let i = 0; i < rows.length; i += CHUNK) {
|
||||
const batch = /** @type {never[]} */ (rows.slice(i, i + CHUNK));
|
||||
const { error } = opts.upsert
|
||||
? await sb().from(table).upsert(batch)
|
||||
: await sb().from(table).insert(batch);
|
||||
if (error) die(`Write into ${table} failed at row ${i}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Row count of a target table (0 in dry-run: no remote access).
|
||||
* @param {string} table
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async function targetCount(table) {
|
||||
if (dryRun) return 0;
|
||||
const { count, error } = await sb().from(table).select("*", { count: "exact", head: true });
|
||||
if (error) die(`Counting bsk.${table} failed: ${error.message}`);
|
||||
return count ?? 0;
|
||||
}
|
||||
|
||||
// ─── Migration steps (FK dependency order) ────────────────────────────────────
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async function migrateClinicSettings() {
|
||||
const rows = readTable("Clinic");
|
||||
const first = rows[0];
|
||||
if (!first) {
|
||||
log("clinic settings: none found upstream — skipped");
|
||||
return;
|
||||
}
|
||||
if (rows.length > 1) warn(`Clinic has ${rows.length} rows; only the first was migrated.`);
|
||||
await insertRows(
|
||||
"clinic_settings",
|
||||
[
|
||||
{
|
||||
id: true,
|
||||
name: str(first.name),
|
||||
address: str(first.address),
|
||||
phone: str(first.phone),
|
||||
prefix: str(first.prefix),
|
||||
},
|
||||
],
|
||||
{ upsert: true },
|
||||
);
|
||||
log("clinic settings: 1 migrated");
|
||||
}
|
||||
|
||||
// NOTE: upstream provinces/wards tables are deliberately NOT migrated. No
|
||||
// upstream customer row references them (the address is one flat string), and
|
||||
// their vintage/code space would collide with the operator-chosen dataset —
|
||||
// geo reference data always comes from `npm run db:seed-geo`.
|
||||
|
||||
/**
|
||||
* @returns {Promise<{ map: Map<number, number>, nameOf: Map<number, string> }>}
|
||||
*/
|
||||
async function migrateDoctors() {
|
||||
const rows = readTable("Doctor");
|
||||
const usable = rows.filter((r) => int(r.doctor_id) != null);
|
||||
const ids = await insertReturningIds(
|
||||
"doctors",
|
||||
usable.map((r) => ({
|
||||
first_name: str(r.doctor_first_name) ?? "",
|
||||
last_name: str(r.doctor_last_name) ?? "",
|
||||
deleted: bool(r.deleted),
|
||||
})),
|
||||
);
|
||||
const map = new Map(usable.map((r, i) => [Number(r.doctor_id), /** @type {number} */ (ids[i])]));
|
||||
// Display names ("last first", Vietnamese order) for notes annotations.
|
||||
const nameOf = new Map(
|
||||
usable.map((r) => [
|
||||
Number(r.doctor_id),
|
||||
[str(r.doctor_last_name), str(r.doctor_first_name)].filter(Boolean).join(" "),
|
||||
]),
|
||||
);
|
||||
log(`doctors: ${usable.length} migrated`);
|
||||
return { map, nameOf };
|
||||
}
|
||||
|
||||
/** @returns {Promise<Map<number, number>>} */
|
||||
async function migrateMedicines() {
|
||||
const rows = readTable("Medicine");
|
||||
const usable = rows.filter((r) => {
|
||||
if (int(r.med_id) == null || !str(r.med_name)) {
|
||||
warn(`Medicine row skipped (missing med_id or med_name): ${JSON.stringify(r).slice(0, 120)}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const ids = await insertReturningIds(
|
||||
"medicines",
|
||||
usable.map((r) => ({
|
||||
name: str(r.med_name),
|
||||
unit: str(r.med_unit),
|
||||
sale_price: money(r.med_selling_price),
|
||||
company: str(r.med_company),
|
||||
route: str(r.route),
|
||||
deleted: bool(r.deleted),
|
||||
})),
|
||||
);
|
||||
const map = new Map(usable.map((r, i) => [Number(r.med_id), /** @type {number} */ (ids[i])]));
|
||||
// The target catalog deliberately has no description/preferred-note columns;
|
||||
// surface how much of that metadata exists so the operator can copy what
|
||||
// matters (e.g. preferred dosage notes) into the new catalog by hand.
|
||||
const withMeta = usable.filter(
|
||||
(r) => str(r.preferred_note) ?? str(r.med_description) ?? (bool(r.supplement) || null),
|
||||
).length;
|
||||
if (withMeta > 0) {
|
||||
warn(
|
||||
`${withMeta} medicine(s) carry preferred_note/description/supplement metadata ` +
|
||||
"that has no target column — review the upstream catalog before retiring it",
|
||||
);
|
||||
}
|
||||
log(`medicines: ${usable.length} migrated (${rows.length - usable.length} skipped)`);
|
||||
return map;
|
||||
}
|
||||
|
||||
/** @returns {Promise<Map<number, number>>} */
|
||||
async function migrateServices() {
|
||||
const rows = readTable("Service");
|
||||
const usable = rows.filter((r) => {
|
||||
if (int(r.service_id) == null || !str(r.service_name)) {
|
||||
warn(
|
||||
`Service row skipped (missing service_id or service_name): ${JSON.stringify(r).slice(0, 120)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const ids = await insertReturningIds(
|
||||
"services",
|
||||
usable.map((r) => ({
|
||||
name: str(r.service_name),
|
||||
price: money(r.service_cost),
|
||||
deleted: bool(r.deleted),
|
||||
})),
|
||||
);
|
||||
const map = new Map(usable.map((r, i) => [Number(r.service_id), /** @type {number} */ (ids[i])]));
|
||||
log(`services: ${usable.length} migrated (${rows.length - usable.length} skipped)`);
|
||||
return map;
|
||||
}
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async function migrateTemplates() {
|
||||
const rows = readTable("CheckupTemplate");
|
||||
if (rows.length === 0) {
|
||||
log("checkup templates: none found upstream — skipped");
|
||||
return;
|
||||
}
|
||||
rows.sort((a, b) => (int(a.stt) ?? 0) - (int(b.stt) ?? 0));
|
||||
/** @type {Record<string, "any" | "male" | "female" | "other">} */
|
||||
const tplGender = { ...GENDER_MAP, ANY: "any", ALL: "any", "CẢ HAI": "any", CHUNG: "any" };
|
||||
await insertRows(
|
||||
"checkup_templates",
|
||||
rows.map((r, i) => ({
|
||||
name: str(r.template_name) ?? str(r.template_title) ?? `Template ${i + 1}`,
|
||||
title: str(r.template_title),
|
||||
gender: tplGender[norm(r.template_gender)] ?? "any",
|
||||
photo_num: Math.max(0, int(r.photo_num) ?? 0),
|
||||
// Target stores fields as an ordered [{ label }] jsonb array
|
||||
// (lib/templates/template-schema.js). Upstream `content` is RTF from the
|
||||
// Swing editor (legacy rows may be plain text) that pre-filled the notes
|
||||
// body; after RTF→text each non-empty line becomes one field label.
|
||||
// print_type/conclusion/suggestion/diagnosis defaults have no target.
|
||||
fields: rtfToText(str(r.content) ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((label) => ({ label })),
|
||||
deleted: bool(r.deleted) || (r.visible != null && !bool(r.visible)),
|
||||
})),
|
||||
);
|
||||
warn(
|
||||
"Checkup templates were converted from the upstream RTF editor — review their " +
|
||||
"field lists in the admin UI before first use.",
|
||||
);
|
||||
// The default-text columns (print_type, conclusion, suggestion, diagnosis)
|
||||
// have no target — surface how many templates carry them so the operator
|
||||
// can copy anything worth keeping into the new template fields by hand.
|
||||
const withDefaults = rows.filter(
|
||||
(r) => str(r.print_type) ?? str(r.conclusion) ?? str(r.suggestion) ?? str(r.diagnosis),
|
||||
).length;
|
||||
if (withDefaults > 0) {
|
||||
warn(
|
||||
`${withDefaults} template(s) carry print_type/conclusion/suggestion/diagnosis ` +
|
||||
"default text that has no target column — copy what matters from the upstream app",
|
||||
);
|
||||
}
|
||||
log(`checkup templates: ${rows.length} migrated`);
|
||||
}
|
||||
|
||||
/** @returns {Promise<Map<number, number>>} */
|
||||
async function migrateCustomers() {
|
||||
const rows = readTable("Customer");
|
||||
const usable = rows.filter((r) => int(r.customer_id) != null);
|
||||
const ids = await insertReturningIds(
|
||||
"customers",
|
||||
usable.map((r) => {
|
||||
const gender = GENDER_MAP[norm(r.customer_gender)] ?? null;
|
||||
if (gender === null && str(r.customer_gender)) {
|
||||
warn(`Customer ${r.customer_id}: unknown gender "${r.customer_gender}" → NULL`);
|
||||
}
|
||||
return {
|
||||
first_name: str(r.customer_first_name) ?? "",
|
||||
last_name: str(r.customer_last_name) ?? "",
|
||||
dob: vnDate(r.customer_dob),
|
||||
gender,
|
||||
cccd: str(r.cccd_ddcn),
|
||||
phone: str(r.customer_number),
|
||||
// Upstream stores one flat address string; province/ward codes have no
|
||||
// source and stay NULL for the admin to backfill in the UI.
|
||||
address_detail: str(r.customer_address),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const map = new Map(
|
||||
usable.map((r, i) => [Number(r.customer_id), /** @type {number} */ (ids[i])]),
|
||||
);
|
||||
log(`customers: ${usable.length} migrated`);
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-service-line notes from CheckupService (the target table has no notes
|
||||
* column, so they are folded into the visit's notes instead of being lost).
|
||||
* @returns {Map<number, string[]>} upstream checkup_id → note lines
|
||||
*/
|
||||
function collectServiceNoteLines() {
|
||||
/** @type {Map<number, string>} */
|
||||
const serviceNameOf = new Map(
|
||||
readTable("Service").map((s) => [
|
||||
Number(s.service_id),
|
||||
str(s.service_name) ?? `#${s.service_id}`,
|
||||
]),
|
||||
);
|
||||
/** @type {Map<number, string[]>} */
|
||||
const byCheckup = new Map();
|
||||
for (const r of readTable("CheckupService")) {
|
||||
const checkupId = int(r.checkup_id);
|
||||
const note = str(r.notes);
|
||||
if (checkupId == null || !note) continue;
|
||||
const name = serviceNameOf.get(Number(r.service_id)) ?? `#${r.service_id}`;
|
||||
const lines = byCheckup.get(checkupId) ?? [];
|
||||
lines.push(`Dịch vụ ${name}: ${note}`);
|
||||
byCheckup.set(checkupId, lines);
|
||||
}
|
||||
return byCheckup;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Map<number, number>} customerMap
|
||||
* @param {{ map: Map<number, number>, nameOf: Map<number, string> }} doctors
|
||||
* @param {Map<number, string[]>} serviceNoteLines upstream checkup_id → lines
|
||||
* @returns {Promise<{ map: Map<number, number>, dateOf: Map<number, string> }>}
|
||||
*/
|
||||
async function migrateCheckups(customerMap, doctors, serviceNoteLines) {
|
||||
const doctorMap = doctors.map;
|
||||
const rows = readTable("Checkup");
|
||||
/** @type {Row[]} */
|
||||
const usable = [];
|
||||
/** @type {Set<string>} */
|
||||
const unknownStatuses = new Set();
|
||||
/** @type {Set<number>} */
|
||||
const unknownShifts = new Set();
|
||||
for (const r of rows) {
|
||||
if (int(r.checkup_id) == null) continue;
|
||||
if (!customerMap.has(Number(r.customer_id))) {
|
||||
warn(`Checkup ${r.checkup_id}: customer ${r.customer_id} not migrated — row skipped`);
|
||||
continue;
|
||||
}
|
||||
usable.push(r);
|
||||
}
|
||||
/** @type {Map<number, string>} */
|
||||
const dateOf = new Map();
|
||||
const payload = usable.map((r) => {
|
||||
const oldId = Number(r.checkup_id);
|
||||
const date = vnDate(r.checkup_date);
|
||||
if (date) dateOf.set(oldId, date);
|
||||
else warn(`Checkup ${oldId}: unparseable checkup_date "${r.checkup_date}" → defaults to today`);
|
||||
|
||||
let status = STATUS_MAP[norm(r.status)];
|
||||
if (!status) {
|
||||
if (str(r.status)) unknownStatuses.add(String(r.status));
|
||||
status = "done"; // migrated history is overwhelmingly completed visits
|
||||
}
|
||||
|
||||
const doctorId = int(r.doctor_id);
|
||||
if (doctorId != null && !doctorMap.has(doctorId)) {
|
||||
warn(`Checkup ${oldId}: doctor ${doctorId} not migrated → doctor left empty`);
|
||||
}
|
||||
|
||||
/** @param {SqlValue | undefined} v */
|
||||
const vital = (v) => {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
// numeric(5,2) tops out at 999.99; values from 999.995 round past it.
|
||||
if (n >= 999.995) {
|
||||
warn(`Checkup ${oldId}: vital value ${n} out of range → NULL`);
|
||||
return null;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
|
||||
// Data with no dedicated target column is folded into the visit notes so
|
||||
// nothing clinically visible in the old app is lost.
|
||||
const ultrasoundDoctorId = int(r.doctor_ultrasound_id);
|
||||
const ultrasoundName =
|
||||
ultrasoundDoctorId != null ? doctors.nameOf.get(ultrasoundDoctorId) : null;
|
||||
const suggestion = str(r.suggestion);
|
||||
const noteLines = [
|
||||
str(r.notes),
|
||||
suggestion ? `Đề nghị: ${suggestion}` : null,
|
||||
ultrasoundName ? `BS siêu âm: ${ultrasoundName}` : null,
|
||||
...(serviceNoteLines.get(oldId) ?? []),
|
||||
].filter(Boolean);
|
||||
|
||||
// Unfilled vitals are stored as 0 / "0/0" upstream — treat them as unset.
|
||||
const heartBeat = str(r.heart_beat);
|
||||
const bloodPressure = str(r.blood_pressure);
|
||||
return {
|
||||
customer_id: customerMap.get(Number(r.customer_id)),
|
||||
doctor_id: doctorId != null ? (doctorMap.get(doctorId) ?? null) : null,
|
||||
shift_id: mapShift(r.shift, unknownShifts),
|
||||
queue_number: int(r.queue_number),
|
||||
...(date
|
||||
? {
|
||||
checkup_date: date,
|
||||
// Anchor audit timestamps to the visit day (noon VN) so history
|
||||
// and exports sort sensibly instead of piling up on migration day.
|
||||
created_at: `${date}T12:00:00+07:00`,
|
||||
updated_at: `${date}T12:00:00+07:00`,
|
||||
}
|
||||
: {}),
|
||||
status,
|
||||
checkup_type: str(r.checkup_type),
|
||||
diagnosis: str(r.diagnosis),
|
||||
conclusion: str(r.conclusion),
|
||||
notes: noteLines.length > 0 ? noteLines.join("\n") : null,
|
||||
heart_beat: heartBeat === "0" ? null : heartBeat,
|
||||
blood_pressure: bloodPressure === "0/0" ? null : bloodPressure,
|
||||
weight: vital(r.customer_weight),
|
||||
height: vital(r.customer_height),
|
||||
// remind_date is the upstream recall reminder; it fills recheck_date
|
||||
// when no explicit re-checkup date was set (the rewrite's reminders
|
||||
// feature reads recheck_date).
|
||||
recheck_date: vnDate(r.recheckupdate) ?? vnDate(r.remind_date),
|
||||
deleted: bool(r.deleted),
|
||||
};
|
||||
});
|
||||
const ids = await insertReturningIds("checkups", payload);
|
||||
const map = new Map(usable.map((r, i) => [Number(r.checkup_id), /** @type {number} */ (ids[i])]));
|
||||
if (unknownStatuses.size > 0) {
|
||||
warn(
|
||||
`Checkup.status values with no mapping (defaulted to 'done'): ` +
|
||||
[...unknownStatuses].join(", "),
|
||||
);
|
||||
}
|
||||
log(`checkups: ${usable.length} migrated (${rows.length - usable.length} skipped)`);
|
||||
return { map, dateOf };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Map<number, number>} checkupMap
|
||||
* @param {Map<number, number>} medicineMap
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function migrateOrderItems(checkupMap, medicineMap) {
|
||||
const rows = readTable("OrderItem");
|
||||
// Legacy OrderItem rows predate the checkup_id column; resolve them through
|
||||
// MedicineOrder's prescription_id → checkup_id, mirroring the upstream
|
||||
// DeleteCheckup join.
|
||||
/** @type {Map<number, number>} */
|
||||
const prescToCheckup = new Map();
|
||||
for (const o of readTable("MedicineOrder")) {
|
||||
const presc = int(o.prescription_id);
|
||||
const checkup = int(o.checkup_id);
|
||||
if (presc != null && checkup != null) prescToCheckup.set(presc, checkup);
|
||||
}
|
||||
/** @type {Record<string, unknown>[]} */
|
||||
const payload = [];
|
||||
for (const r of rows) {
|
||||
const presc = int(r.prescription_id);
|
||||
const oldCheckupId =
|
||||
int(r.checkup_id) ?? (presc != null ? (prescToCheckup.get(presc) ?? null) : null);
|
||||
const checkupId = oldCheckupId != null ? checkupMap.get(oldCheckupId) : undefined;
|
||||
const medicineId = medicineMap.get(Number(r.med_id));
|
||||
const quantity = int(r.quantity_ordered) ?? 0;
|
||||
if (!checkupId || !medicineId || quantity <= 0) {
|
||||
warn(
|
||||
`OrderItem skipped (checkup ${r.checkup_id}, med ${r.med_id}, qty ${r.quantity_ordered}): ` +
|
||||
"missing reference or non-positive quantity",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const unitPrice = money(r.price_per_unit);
|
||||
// Trust the stored total only when it actually parses as a number —
|
||||
// legacy TEXT values like "" would otherwise become 0 and silently
|
||||
// falsify billing history. Anything unparseable is recomputed.
|
||||
const storedTotal = str(r.total_price) != null ? Number(r.total_price) : NaN;
|
||||
payload.push({
|
||||
checkup_id: checkupId,
|
||||
medicine_id: medicineId,
|
||||
quantity,
|
||||
dosage: str(r.dosage),
|
||||
unit_price: unitPrice,
|
||||
line_total: Number.isFinite(storedTotal) ? money(storedTotal) : quantity * unitPrice,
|
||||
notes: str(r.notes),
|
||||
});
|
||||
}
|
||||
await insertRows("order_items", payload);
|
||||
log(`order items: ${payload.length} migrated (${rows.length - payload.length} skipped)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Map<number, number>} checkupMap
|
||||
* @param {Map<number, number>} serviceMap
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function migrateCheckupServices(checkupMap, serviceMap) {
|
||||
const rows = readTable("CheckupService");
|
||||
/** @type {Record<string, unknown>[]} */
|
||||
const payload = [];
|
||||
for (const r of rows) {
|
||||
const checkupId = checkupMap.get(Number(r.checkup_id));
|
||||
const serviceId = serviceMap.get(Number(r.service_id));
|
||||
const quantity = int(r.quantity) ?? 0;
|
||||
if (!checkupId || !serviceId || quantity <= 0) {
|
||||
warn(
|
||||
`CheckupService skipped (checkup ${r.checkup_id}, service ${r.service_id}, ` +
|
||||
`qty ${r.quantity}): missing reference or non-positive quantity`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Upstream stores only the line total; the unit price is derived so the
|
||||
// stored total is preserved exactly (rounding differences land on the
|
||||
// unit price, which is display-only for migrated history).
|
||||
const lineTotal = money(r.total_cost);
|
||||
payload.push({
|
||||
checkup_id: checkupId,
|
||||
service_id: serviceId,
|
||||
quantity,
|
||||
unit_price: Math.round(lineTotal / quantity),
|
||||
line_total: lineTotal,
|
||||
});
|
||||
}
|
||||
await insertRows("checkup_services", payload);
|
||||
log(`checkup services: ${payload.length} migrated (${rows.length - payload.length} skipped)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Map<number, number>} checkupMap
|
||||
* @param {Map<number, string>} checkupDateOf
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function migrateMedicineOrders(checkupMap, checkupDateOf) {
|
||||
const rows = readTable("MedicineOrder");
|
||||
/** @type {Map<number, Record<string, unknown>>} */
|
||||
const byCheckup = new Map();
|
||||
let skipped = 0;
|
||||
for (const r of rows) {
|
||||
const oldCheckupId = Number(r.checkup_id);
|
||||
const checkupId = checkupMap.get(oldCheckupId);
|
||||
if (!checkupId) {
|
||||
warn(`MedicineOrder skipped: checkup ${r.checkup_id} not migrated`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const paid = PAID_LABELS.has(norm(r.payment_status));
|
||||
const visitDate = checkupDateOf.get(oldCheckupId);
|
||||
// Later rows win on duplicates (the upstream app had two INSERT paths).
|
||||
byCheckup.set(checkupId, {
|
||||
checkup_id: checkupId,
|
||||
payment_status: paid ? "paid" : "unpaid",
|
||||
// Upstream never recorded when an order was paid; the visit day (noon
|
||||
// VN) keeps history plausible without inventing precision.
|
||||
paid_at: paid && visitDate ? `${visitDate}T12:00:00+07:00` : null,
|
||||
});
|
||||
}
|
||||
await insertRows("medicine_orders", [...byCheckup.values()], { upsert: true });
|
||||
log(`medicine orders: ${byCheckup.size} migrated (${skipped} skipped)`);
|
||||
}
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async function migrateQueueCounters() {
|
||||
const rows = readTable("DailyQueueCounter");
|
||||
/** @type {Set<number>} */
|
||||
const unknownShifts = new Set();
|
||||
// Deduplicate on the target PK (day, shift_id) keeping the highest counter —
|
||||
// duplicate source rows would make the upsert fail with "ON CONFLICT ...
|
||||
// cannot affect row a second time".
|
||||
/** @type {Map<string, { day: string, shift_id: number, last_number: number }>} */
|
||||
const byKey = new Map();
|
||||
for (const r of rows) {
|
||||
const day = vnDate(r.date);
|
||||
const shiftId = mapShift(r.shift, unknownShifts);
|
||||
if (!day || shiftId == null) {
|
||||
warn(`DailyQueueCounter skipped (date ${r.date}, shift ${r.shift})`);
|
||||
continue;
|
||||
}
|
||||
const key = `${day}|${shiftId}`;
|
||||
const lastNumber = Math.max(0, int(r.current_count) ?? 0);
|
||||
const existing = byKey.get(key);
|
||||
if (existing) existing.last_number = Math.max(existing.last_number, lastNumber);
|
||||
else byKey.set(key, { day, shift_id: shiftId, last_number: lastNumber });
|
||||
}
|
||||
const payload = [...byKey.values()];
|
||||
await insertRows("daily_queue_counters", payload, { upsert: true });
|
||||
log(`queue counters: ${payload.length} migrated (${rows.length - payload.length} skipped)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff accounts are intentionally not migrated: auth is delegated to Supabase
|
||||
* and upstream passwords must not be reused. Print the roster so the admin can
|
||||
* re-invite everyone through the staff-management UI.
|
||||
* @returns {void}
|
||||
*/
|
||||
function reportUpstreamUsers() {
|
||||
const rows = readTable("User");
|
||||
if (rows.length === 0) return;
|
||||
log("");
|
||||
log(`staff accounts are NOT migrated — re-invite these ${rows.length} upstream users`);
|
||||
log("via the app's staff management (Supabase auth replaces upstream passwords):");
|
||||
for (const r of rows) {
|
||||
const name = [str(r.last_name), str(r.first_name)].filter(Boolean).join(" ") || "(no name)";
|
||||
const flags = bool(r.deleted) ? " [deleted upstream — probably skip]" : "";
|
||||
log(` - ${str(r.user_name) ?? "?"} — ${name}, role: ${str(r.role_name) ?? "?"}${flags}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints what the source actually contains before anything is written — the
|
||||
* value spaces this script's mappings depend on (older app versions may have
|
||||
* written variants the current Java source no longer shows).
|
||||
* @returns {void}
|
||||
*/
|
||||
function sourceOverview() {
|
||||
if (existsSync(`${dbPath}-wal`)) {
|
||||
log("note: a BSK.db-wal sibling exists and will be read through — good");
|
||||
} else {
|
||||
warn(
|
||||
"No BSK.db-wal sibling found. The upstream server runs WAL mode: if this file was " +
|
||||
"copied while the server was running, the newest visits may be missing — stop the " +
|
||||
"server and re-copy BSK.db together with its -wal/-shm files if unsure.",
|
||||
);
|
||||
}
|
||||
/**
|
||||
* @param {string} table
|
||||
* @param {string} column
|
||||
* @returns {string[]}
|
||||
*/
|
||||
const distinct = (table, column) => {
|
||||
const actual = findTable(table);
|
||||
if (!actual) return [];
|
||||
return /** @type {Row[]} */ (
|
||||
sqlite.prepare(`SELECT DISTINCT "${column}" AS v FROM "${actual}"`).all()
|
||||
).map((r) => String(r.v));
|
||||
};
|
||||
const statuses = distinct("Checkup", "status");
|
||||
const shifts = distinct("Checkup", "shift");
|
||||
if (statuses.length > 0) log(`source Checkup.status values: ${statuses.join(", ")}`);
|
||||
if (shifts.length > 0) log(`source Checkup.shift values: ${shifts.join(", ")}`);
|
||||
const orderItem = findTable("OrderItem");
|
||||
if (orderItem) {
|
||||
const row = /** @type {Row | undefined} */ (
|
||||
sqlite.prepare(`SELECT COUNT(*) AS n FROM "${orderItem}" WHERE checkup_id IS NULL`).get()
|
||||
);
|
||||
const n = Number(row?.n ?? 0);
|
||||
if (n > 0) log(`source OrderItem rows without checkup_id (legacy): ${n}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
log(`source: ${dbPath}${dryRun ? " (dry run — nothing will be written)" : ""}`);
|
||||
sourceOverview();
|
||||
|
||||
if (!dryRun && !allowNonempty) {
|
||||
// Ids are re-keyed on insert, so running twice would duplicate every row.
|
||||
// Every insert-target table is checked (upsert-keyed tables are exempt).
|
||||
for (const table of [
|
||||
"customers",
|
||||
"checkups",
|
||||
"medicines",
|
||||
"services",
|
||||
"doctors",
|
||||
"checkup_templates",
|
||||
"order_items",
|
||||
"checkup_services",
|
||||
]) {
|
||||
const count = await targetCount(table);
|
||||
if (count > 0) {
|
||||
die(
|
||||
`Target table bsk.${table} already has ${count} rows. This script expects an ` +
|
||||
"empty target (re-running duplicates data). Pass --allow-nonempty to override.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await migrateClinicSettings();
|
||||
const doctors = await migrateDoctors();
|
||||
const medicineMap = await migrateMedicines();
|
||||
const serviceMap = await migrateServices();
|
||||
await migrateTemplates();
|
||||
const customerMap = await migrateCustomers();
|
||||
const serviceNoteLines = collectServiceNoteLines();
|
||||
const { map: checkupMap, dateOf } = await migrateCheckups(customerMap, doctors, serviceNoteLines);
|
||||
await migrateOrderItems(checkupMap, medicineMap);
|
||||
await migrateCheckupServices(checkupMap, serviceMap);
|
||||
await migrateMedicineOrders(checkupMap, dateOf);
|
||||
await migrateQueueCounters();
|
||||
reportUpstreamUsers();
|
||||
|
||||
if (warnings.length > 0) {
|
||||
log("");
|
||||
log(`${warnings.length} warning(s):`);
|
||||
const MAX = 50;
|
||||
for (const w of warnings.slice(0, MAX)) log(` ! ${w}`);
|
||||
if (warnings.length > MAX) log(` … and ${warnings.length - MAX} more`);
|
||||
}
|
||||
log("");
|
||||
log(
|
||||
dryRun
|
||||
? "dry run complete — re-run without --dry-run to write."
|
||||
: "done. Post-migration: run `npm run db:seed-geo`, re-invite staff, review the " +
|
||||
"converted checkup templates, and backfill patient province/ward in the UI.",
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => die(e instanceof Error ? (e.stack ?? e.message) : String(e)))
|
||||
.finally(() => sqlite.close());
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Pure value transforms for migrating an original BSK desktop install's
|
||||
* SQLite data (see scripts/migrate-from-upstream.mjs, which orchestrates the
|
||||
* actual migration). Kept dependency-free so tests/unit can exercise the
|
||||
* trickiest conversions (RTF templates, epoch-millis dates, enum labels)
|
||||
* without touching a database.
|
||||
*
|
||||
* Every mapping here was verified against the upstream Java source —
|
||||
* plans/reports/code-reviewer-260818-1749-migration-script-upstream-compat-
|
||||
* audit-report.md has the file:line evidence.
|
||||
*/
|
||||
|
||||
/** @typedef {string | number | bigint | Uint8Array | null} SqlValue */
|
||||
|
||||
/** @type {(msg: string) => void} */
|
||||
let onWarn = () => {};
|
||||
|
||||
/**
|
||||
* Registers the warning sink used by transforms that can drop data.
|
||||
* @param {(msg: string) => void} fn
|
||||
*/
|
||||
export function setOnWarn(fn) {
|
||||
onWarn = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trimmed string or null.
|
||||
* @param {SqlValue | undefined} v
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function str(v) {
|
||||
if (v == null) return null;
|
||||
const s = String(v).trim();
|
||||
return s === "" ? null : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Integer VND from an upstream DOUBLE (rounded, clamped to >= 0 to satisfy the
|
||||
* target CHECK constraints).
|
||||
* @param {SqlValue | undefined} v
|
||||
* @returns {number}
|
||||
*/
|
||||
export function money(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.round(n));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SqlValue | undefined} v
|
||||
* @returns {number | null}
|
||||
*/
|
||||
export function int(v) {
|
||||
if (v == null) return null; // Number(null) is 0, which must stay "unset"
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? Math.trunc(n) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SqlValue | undefined} v
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function bool(v) {
|
||||
return v === 1 || v === 1n || v === "1" || v === "true";
|
||||
}
|
||||
|
||||
const vnDayFmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Asia/Ho_Chi_Minh",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
|
||||
// Sanity window for epoch-millis dates: 1900-01-01 … 2100-01-01. Upstream
|
||||
// writes Date.getTime() millis, which are NEGATIVE for pre-1970 birth dates
|
||||
// (verified: upstream AddDialog parses dd/MM/yyyy and expects "-?\d+").
|
||||
const MS_MIN = Date.UTC(1900, 0, 1);
|
||||
const MS_MAX = Date.UTC(2100, 0, 1);
|
||||
|
||||
/**
|
||||
* Clinic-local (UTC+7) YYYY-MM-DD from an upstream date value: Java epoch
|
||||
* millis (possibly negative), an ISO string, or Vietnamese dd/MM/yyyy.
|
||||
* Exactly 0 is treated as "unset" (a real 1970-01-01 VN date is -25200000).
|
||||
* @param {SqlValue | undefined} v
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function vnDate(v) {
|
||||
if (v == null) return null;
|
||||
if (typeof v === "number" || typeof v === "bigint") {
|
||||
const ms = Number(v);
|
||||
if (!Number.isFinite(ms) || ms === 0) return null;
|
||||
if (ms < MS_MIN || ms > MS_MAX) {
|
||||
onWarn(`Date value ${ms} outside 1900–2100 → NULL`);
|
||||
return null;
|
||||
}
|
||||
return vnDayFmt.format(new Date(ms));
|
||||
}
|
||||
const s = String(v).trim();
|
||||
if (!s) return null;
|
||||
const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`;
|
||||
const dmy = s.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{4})/);
|
||||
if (dmy) {
|
||||
// Validate the calendar date — Postgres rejects e.g. 1985-02-31, which
|
||||
// would abort a live migration mid-run.
|
||||
const day = Number(dmy[1]);
|
||||
const mon = Number(dmy[2]);
|
||||
const year = Number(dmy[3]);
|
||||
const probe = new Date(Date.UTC(year, mon - 1, day));
|
||||
if (
|
||||
probe.getUTCFullYear() !== year ||
|
||||
probe.getUTCMonth() !== mon - 1 ||
|
||||
probe.getUTCDate() !== day
|
||||
) {
|
||||
onWarn(`Invalid calendar date "${s}" → NULL`);
|
||||
return null;
|
||||
}
|
||||
return `${year}-${String(mon).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
if (/^-?\d{1,13}$/.test(s)) return vnDate(Number(s));
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an enum-ish label for dictionary lookup.
|
||||
* @param {SqlValue | undefined} v
|
||||
* @returns {string}
|
||||
*/
|
||||
export function norm(v) {
|
||||
return String(v ?? "")
|
||||
.normalize("NFC")
|
||||
.toUpperCase()
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** @type {Record<string, "waiting" | "in_progress" | "done">} */
|
||||
export const STATUS_MAP = {
|
||||
WAITING: "waiting",
|
||||
PENDING: "waiting",
|
||||
"CHỜ KHÁM": "waiting",
|
||||
"ĐANG CHỜ": "waiting",
|
||||
"CHƯA KHÁM": "waiting",
|
||||
IN_PROGRESS: "in_progress",
|
||||
"IN PROGRESS": "in_progress",
|
||||
PROCESSING: "in_progress",
|
||||
"ĐANG KHÁM": "in_progress",
|
||||
DONE: "done",
|
||||
COMPLETED: "done",
|
||||
FINISHED: "done",
|
||||
"ĐÃ KHÁM": "done",
|
||||
"HOÀN THÀNH": "done",
|
||||
};
|
||||
|
||||
/** @type {Record<string, "male" | "female" | "other">} */
|
||||
export const GENDER_MAP = {
|
||||
M: "male",
|
||||
MALE: "male",
|
||||
NAM: "male",
|
||||
F: "female",
|
||||
FEMALE: "female",
|
||||
NỮ: "female",
|
||||
NU: "female",
|
||||
OTHER: "other",
|
||||
KHÁC: "other",
|
||||
};
|
||||
|
||||
// The upstream Java app only ever writes "Unpaid" (no paid path exists in its
|
||||
// code) — this set is belt-and-braces for hand-edited databases.
|
||||
export const PAID_LABELS = new Set(["PAID", "ĐÃ THANH TOÁN", "ĐÃ THU", "ĐÃ TRẢ", "ĐÃ TRẢ TIỀN"]);
|
||||
|
||||
// Upstream shifts: 0 = morning, 1 = afternoon (LocalStorage.currentShift).
|
||||
// Target bsk.shifts: 1 = morning, 2 = afternoon, 3 = evening.
|
||||
/** @type {Record<number, number>} */
|
||||
export const SHIFT_MAP = { 0: 1, 1: 2 };
|
||||
|
||||
/**
|
||||
* Target shift_id for an upstream shift value, or null (with one warning per
|
||||
* distinct unknown value).
|
||||
* @param {SqlValue | undefined} v
|
||||
* @param {Set<number>} unknownSeen
|
||||
* @returns {number | null}
|
||||
*/
|
||||
export function mapShift(v, unknownSeen) {
|
||||
const s = int(v);
|
||||
if (s == null) return null;
|
||||
const mapped = SHIFT_MAP[s];
|
||||
if (mapped == null && !unknownSeen.has(s)) {
|
||||
unknownSeen.add(s);
|
||||
onWarn(`Unknown upstream shift value ${s} → NULL (expected 0=morning, 1=afternoon)`);
|
||||
}
|
||||
return mapped ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort plain text from Swing RTFEditorKit output. Upstream stores
|
||||
* checkup-template `content` as RTF ({\rtf1...}); this decodes \uN?/\'hh
|
||||
* escapes, turns \par|\line into newlines, skips header destination groups
|
||||
* (fonttbl, colortbl, …), and drops all other control words. Legacy templates
|
||||
* saved before the RTF editor are plain text and pass through untouched.
|
||||
* @param {string} content
|
||||
* @returns {string}
|
||||
*/
|
||||
export function rtfToText(content) {
|
||||
if (!content.startsWith("{\\rtf")) return content;
|
||||
let out = "";
|
||||
let skipDepth = 0; // inside a destination group whose text is not content
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const ch = content[i];
|
||||
if (ch === "\\") {
|
||||
// Escaped literals bind tighter than group tracking: consume \\ \{ \}
|
||||
// even inside skipped groups so they can never corrupt depth counting.
|
||||
const next = content[i + 1];
|
||||
if (next === "\\" || next === "{" || next === "}") {
|
||||
if (skipDepth === 0) out += next;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (skipDepth > 0) continue;
|
||||
const hex = content.slice(i, i + 4).match(/^\\'([0-9a-f]{2})/i);
|
||||
if (hex) {
|
||||
out += String.fromCharCode(parseInt(/** @type {string} */ (hex[1]), 16));
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
const word = content.slice(i).match(/^\\([a-z]+)(-?\d+)? ?/i);
|
||||
if (word) {
|
||||
if (word[1] === "par" || word[1] === "line") out += "\n";
|
||||
else if (word[1] === "u" && word[2]) {
|
||||
out += String.fromCharCode(((Number(word[2]) % 65536) + 65536) % 65536);
|
||||
// \uN is followed by a one-character ANSI fallback to skip.
|
||||
i += word[0].length;
|
||||
continue;
|
||||
}
|
||||
i += word[0].length - 1;
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "{") {
|
||||
if (skipDepth > 0) skipDepth++;
|
||||
else if (/^\\(?:\*|fonttbl|colortbl|stylesheet|info|pict)/.test(content.slice(i + 1, i + 12)))
|
||||
skipDepth = 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "}") {
|
||||
if (skipDepth > 0) skipDepth--;
|
||||
continue;
|
||||
}
|
||||
if (skipDepth > 0) continue;
|
||||
if (ch !== "\r" && ch !== "\n") out += ch; // raw newlines are not RTF content
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
-- BSK — table-level grants for service_role.
|
||||
--
|
||||
-- The bsk schema only ever granted USAGE to service_role (20260525163300).
|
||||
-- service_role bypasses RLS but is NOT a superuser: without table grants every
|
||||
-- PostgREST request made with the secret key fails with "permission denied".
|
||||
-- That breaks the server-side admin paths and the operator scripts
|
||||
-- (scripts/seed-geo.mjs, scripts/migrate-from-upstream.mjs, the cron sweep).
|
||||
--
|
||||
-- RLS policies are unaffected: service_role skips RLS by design, and the
|
||||
-- authenticated role's grants/policies stay exactly as they were.
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA bsk TO service_role;
|
||||
|
||||
-- Tables added by future migrations get the same grants automatically.
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA bsk
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO service_role;
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
GENDER_MAP,
|
||||
STATUS_MAP,
|
||||
bool,
|
||||
int,
|
||||
mapShift,
|
||||
money,
|
||||
norm,
|
||||
rtfToText,
|
||||
setOnWarn,
|
||||
str,
|
||||
vnDate,
|
||||
} from "@/scripts/upstream-transforms.mjs";
|
||||
|
||||
describe("vnDate", () => {
|
||||
it("converts positive epoch millis to the VN-local date", () => {
|
||||
expect(vnDate(631152000000)).toBe("1990-01-01");
|
||||
});
|
||||
|
||||
it("keeps pre-1970 birth dates (negative epoch millis)", () => {
|
||||
expect(vnDate(-631152000000)).toBe("1950-01-01");
|
||||
// Midnight 1970-01-01 in VN is -7h UTC — must not be treated as unset.
|
||||
expect(vnDate(-25200000)).toBe("1970-01-01");
|
||||
});
|
||||
|
||||
it("does not misread 1970–1973 millis as epoch seconds", () => {
|
||||
// Saigon was UTC+8 until 1975 (IANA tzdata), hence the 3rd, not the 2nd.
|
||||
expect(vnDate(50000000000)).toBe("1971-08-03");
|
||||
});
|
||||
|
||||
it("treats exactly 0 as unset", () => {
|
||||
expect(vnDate(0)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects out-of-window values with a warning", () => {
|
||||
/** @type {string[]} */
|
||||
const warned = [];
|
||||
setOnWarn((m) => warned.push(m));
|
||||
expect(vnDate(9e15)).toBeNull();
|
||||
expect(warned).toHaveLength(1);
|
||||
setOnWarn(() => {});
|
||||
});
|
||||
|
||||
it("parses ISO and Vietnamese dd/MM/yyyy strings, and numeric strings", () => {
|
||||
expect(vnDate("2026-08-18T10:00:00")).toBe("2026-08-18");
|
||||
expect(vnDate("18/08/2026")).toBe("2026-08-18");
|
||||
expect(vnDate("-631152000000")).toBe("1950-01-01");
|
||||
expect(vnDate("garbage")).toBeNull();
|
||||
expect(vnDate(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects impossible dd/MM/yyyy calendar dates instead of emitting them", () => {
|
||||
/** @type {string[]} */
|
||||
const warned = [];
|
||||
setOnWarn((m) => warned.push(m));
|
||||
// Postgres would reject 1985-02-31 and abort a live run mid-migration.
|
||||
expect(vnDate("31/02/1985")).toBeNull();
|
||||
expect(vnDate("00/01/1985")).toBeNull();
|
||||
expect(warned).toHaveLength(2);
|
||||
setOnWarn(() => {});
|
||||
expect(vnDate("29/02/2024")).toBe("2024-02-29"); // real leap day survives
|
||||
});
|
||||
});
|
||||
|
||||
describe("rtfToText", () => {
|
||||
it("extracts plain text from Swing RTFEditorKit output", () => {
|
||||
const rtf =
|
||||
"{\\rtf1\\ansi\\ansicpg1252\\deff0{\\fonttbl{\\f0\\fnil Dialog;}}" +
|
||||
"\\f0\\fs24 Tim thai\\par Nhau \\u7889?i\\par\\par C\\u226?n n\\u7863?ng thai\\par}";
|
||||
expect(rtfToText(rtf)).toBe("Tim thai\nNhau ối\n\nCân nặng thai\n");
|
||||
});
|
||||
|
||||
it("passes legacy plain-text content through untouched", () => {
|
||||
expect(rtfToText("Mạch\nHuyết áp")).toBe("Mạch\nHuyết áp");
|
||||
});
|
||||
|
||||
it("decodes \\'hh hex escapes and escaped braces", () => {
|
||||
expect(rtfToText("{\\rtf1 a\\'41b \\{x\\}}")).toBe("aAb {x}");
|
||||
});
|
||||
|
||||
it("keeps depth tracking intact when a skipped group contains escaped braces", () => {
|
||||
// The \{ inside the fonttbl group must not be counted as a group opener,
|
||||
// or everything after the header would be silently swallowed.
|
||||
expect(rtfToText("{\\rtf1{\\fonttbl{\\f0 F\\{oo;}}Hello}")).toBe("Hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapShift", () => {
|
||||
it("maps upstream 0=morning/1=afternoon to target shift ids 1/2", () => {
|
||||
const seen = new Set();
|
||||
expect(mapShift(0, seen)).toBe(1);
|
||||
expect(mapShift(1, seen)).toBe(2);
|
||||
expect(mapShift(null, seen)).toBeNull();
|
||||
});
|
||||
|
||||
it("nulls unknown shifts and warns once per distinct value", () => {
|
||||
/** @type {string[]} */
|
||||
const warned = [];
|
||||
setOnWarn((m) => warned.push(m));
|
||||
const seen = new Set();
|
||||
expect(mapShift(7, seen)).toBeNull();
|
||||
expect(mapShift(7, seen)).toBeNull();
|
||||
expect(warned).toHaveLength(1);
|
||||
setOnWarn(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("scalar transforms", () => {
|
||||
it("rounds and clamps money to non-negative integer VND", () => {
|
||||
expect(money(1523.7)).toBe(1524);
|
||||
expect(money(-5)).toBe(0);
|
||||
expect(money("garbage")).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps SQL NULL distinct from 0 in int()", () => {
|
||||
expect(int(null)).toBeNull();
|
||||
expect(int(0)).toBe(0);
|
||||
expect(int("1.5")).toBe(1);
|
||||
});
|
||||
|
||||
it("reads SQLite boolean-ish values", () => {
|
||||
expect(bool(1)).toBe(true);
|
||||
expect(bool("1")).toBe(true);
|
||||
expect(bool(0)).toBe(false);
|
||||
expect(bool(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("trims strings to null", () => {
|
||||
expect(str(" x ")).toBe("x");
|
||||
expect(str(" ")).toBeNull();
|
||||
expect(str(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("enum label maps", () => {
|
||||
it("maps the exact Vietnamese labels the Java app writes", () => {
|
||||
expect(STATUS_MAP[norm("đã khám")]).toBe("done");
|
||||
expect(STATUS_MAP[norm("ĐANG KHÁM")]).toBe("in_progress");
|
||||
expect(STATUS_MAP[norm("Chờ khám")]).toBe("waiting");
|
||||
expect(GENDER_MAP[norm("Nữ")]).toBe("female");
|
||||
expect(GENDER_MAP[norm("Nam")]).toBe("male");
|
||||
expect(GENDER_MAP[norm("Không rõ")]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user