commit ed6229c203facf3e4a8d9d0ca75a91cdc8ec5b61 Author: Wyndham ARR Date: Sun Aug 2 13:33:13 2026 +0800 feat: add Condo owner desk frontend and backend diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0823f85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +.DS_Store +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +build/ +dist/ +*.egg-info/ + +.venv/ +.venv-mcp/ +.venv*/ + +.env +.env.* +*.env +!*.env.example +!**/*.env.example +!/deploy/.env.production.example + +*.log +*.sqlite3 +*.sqlite3-* + +/outputs/ +/runtime/ +/private-fixtures/ +/.planning/ +/.work/ +/.tmp/ +/.validation/ +/.codex-tmp/ +/task_plan.md +/findings.md +/progress.md + +node_modules/ +**/node_modules/ + +/*.xml +/*.XML +/*.xlsx diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3caf40 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Condo Owner Desk Prototype + +Static local frontend for annual owner stay privilege management. The primary navigation contains Dashboard and Owner Accounts; account data and linked Usage history are managed together inside Owner Accounts. + +Use the `EN / 中文 / ไทย` control at the bottom-left of the sidebar (or the matching control on the login page) to switch the complete interface in place. English is restored when the page is refreshed. Thai dates use Thai month/day names with the Gregorian year and Latin operational digits. + +## Sign in + +The workspace now requires the backend-authenticated operator login: + +- Username: `wyndhamcondon` +- Password: `wyndhamcondon` + +The browser never stores the password. A successful login creates a 12-hour, HttpOnly session cookie; refresh keeps the session and Sign out invalidates it immediately. The local defaults can be overridden with backend authentication environment variables. + +## Run locally + +For a login-protected preview with the complete verified historical import, start the Fastify snapshot backend first: + +```bash +cd backend +npm run build +npm run preview:history +``` + +This validates and loads the retained `legacy-016dc36d15cc40a5` import snapshot in memory: 388 owner accounts, 157 usage records, 438 privilege nights used in 2026 and 5,394 remaining. It uses the same backend login/session and API contract as PostgreSQL, but does not require or modify the remote database. Preview writes are memory-only and reset when this backend process stops. + +In a second terminal, serve the frontend: + +```bash +python3 -m http.server 4173 --bind 127.0.0.1 +``` + +Then open . + +Authentication always requires a backend. Use the verified snapshot command above for the complete historical preview, or start the PostgreSQL backend with its runtime database secrets for persistent live data. + +The default URL uses the backend API and loads the imported owner accounts and usage history after sign-in. To use the local prototype dataset after authentication, open `http://127.0.0.1:4173/?mode=demo`. Imported usage history keeps its original source-row order. Records added in the interface appear above imported history in newest-entry-first order, independent of Check-in or Check-out dates. + +Demo-mode records and snapshot-preview writes are stored in memory. A page refresh keeps them while their serving process remains active, but restarting the relevant process restores the original dataset. PostgreSQL-backed changes remain persistent. Annual entitlement information remains on the Dashboard; there is no separate Annual Report page. + +## API integration mode + +Start the backend on `127.0.0.1:3000`, start the frontend on `127.0.0.1:4173`, then open the default URL: + + + +This mode: + +- verifies the operator session before loading or rendering the workspace; +- includes the HttpOnly session cookie on every API request and returns to login when the session expires; +- loads health, room types, all paginated owner accounts, all paginated usage records and Dashboard aggregates from the API; +- keeps UUIDs as strings and safely renders nullable account numbers, transfer dates and annual balances; +- shows explicit loading, connected, empty and error states with a Retry action; +- never falls back to the 190 demo accounts when the API fails; +- sends only owner, confirmation, dates, used room type, optional AC2 multiplier, remark and an idempotency key when creating usage; Night, Use and Balance remain server-derived. + +The API base URL and default mode are centralized in `runtime-config.js`. The API hostname follows the frontend hostname so local cookies remain same-site on both `127.0.0.1` and `localhost`. It contains no database or login credentials. Browser code never connects directly to PostgreSQL. + +For tiny-fixture frontend debugging without database writes, start the two-account in-memory Mock API: + +```bash +node tests/mock-api-server.mjs +``` + +Use `node tests/mock-api-server.mjs --empty` to verify the empty-database interface. The Mock is intentionally not the historical preview; it runs only in process memory and is discarded when stopped. + +Run the frontend API-client and localization tests with: + +```bash +node --test tests/*.test.mjs +``` + +## Backend foundation + +The isolated PostgreSQL database layer and TypeScript/Fastify API are implemented under [backend](./backend/README.md). + +- The existing database remains `booking_test`. +- New database objects live only in the `condon` schema. +- The current latest workbook import has been applied and verified in the isolated `condon` schema. +- The frontend uses API integration by default; `?mode=demo` is an explicit static-prototype override. +- Login, session validation and logout are owned by the backend; all health, documentation and business endpoints require an authenticated session. +- The latest verified import contains 388 owner accounts and 157 usage records. diff --git a/api-client.js b/api-client.js new file mode 100644 index 0000000..cd812b2 --- /dev/null +++ b/api-client.js @@ -0,0 +1,142 @@ +(function initializeCondoApi(windowObject) { + "use strict"; + + const config = windowObject.CONDO_RUNTIME_CONFIG || {}; + const requestedMode = new URLSearchParams(windowObject.location.search).get("mode"); + const mode = requestedMode === "api" + ? "api" + : requestedMode === "demo" + ? "demo" + : config.defaultMode === "api" + ? "api" + : "demo"; + const periodYear = Number.isInteger(config.periodYear) ? config.periodYear : new Date().getUTCFullYear(); + const baseUrl = new URL(config.apiBaseUrl || "http://127.0.0.1:3000", windowObject.location.origin); + + if (!["http:", "https:"].includes(baseUrl.protocol)) { + throw new Error("CONDO API base URL must use HTTP or HTTPS."); + } + + class CondoApiError extends Error { + constructor(message, { status = 0, code = "NETWORK_ERROR" } = {}) { + super(message); + this.name = "CondoApiError"; + this.status = status; + this.code = code; + } + } + + function apiUrl(path, query = {}) { + const url = new URL(path, `${baseUrl.href.replace(/\/$/, "")}/`); + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + }); + return url; + } + + async function request(path, { method = "GET", query, body, signal, timeoutMs = 12_000 } = {}) { + const controller = new AbortController(); + const abortFromCaller = () => controller.abort(signal.reason); + if (signal?.aborted) abortFromCaller(); + else signal?.addEventListener("abort", abortFromCaller, { once: true }); + const timeout = windowObject.setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await windowObject.fetch(apiUrl(path, query), { + method, + credentials: "include", + headers: body === undefined ? { Accept: "application/json" } : { + Accept: "application/json", + "Content-Type": "application/json" + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal + }); + const payload = await response.json().catch(() => null); + if (!response.ok) { + throw new CondoApiError( + payload?.error?.message || `API request failed with status ${response.status}`, + { status: response.status, code: payload?.error?.code || "API_ERROR" } + ); + } + return payload; + } catch (error) { + if (error instanceof CondoApiError) throw error; + const aborted = controller.signal.aborted; + throw new CondoApiError( + aborted ? "The API request timed out or was cancelled." : "The API is unavailable.", + { code: aborted ? "REQUEST_ABORTED" : "NETWORK_ERROR" } + ); + } finally { + windowObject.clearTimeout(timeout); + signal?.removeEventListener("abort", abortFromCaller); + } + } + + async function collectPages(loader, options = {}) { + const first = await loader({ ...options, page: 1, pageSize: 100 }); + const items = [...first.items]; + const pageCount = Math.ceil(first.total / first.pageSize); + for (let page = 2; page <= pageCount; page += 1) { + const next = await loader({ ...options, page, pageSize: 100 }); + items.push(...next.items); + } + return { items, total: first.total }; + } + + const client = Object.freeze({ + getSession: options => request("auth/session", options), + login: (credentials, options = {}) => request("auth/login", { + ...options, + method: "POST", + body: credentials + }), + logout: options => request("auth/logout", { + ...options, + method: "POST" + }), + health: options => request("health", options), + listRoomTypes: options => request("room-types", options), + listOwnerAccounts: ({ q, roomType, year = periodYear, page = 1, pageSize = 100, ...options } = {}) => request("owner-accounts", { + ...options, + query: { q, roomType, year, page, pageSize } + }), + listAllOwnerAccounts: ({ q, roomType, year = periodYear, ...options } = {}) => collectPages( + pageOptions => client.listOwnerAccounts(pageOptions), + { q, roomType, year, ...options } + ), + getOwnerAccount: (id, { year = periodYear, ...options } = {}) => request(`owner-accounts/${encodeURIComponent(id)}`, { + ...options, + query: { year } + }), + listUsageRecords: ({ ownerAccountId, confirmationNo, usedRoomType, page = 1, pageSize = 100, ...options } = {}) => request("usage-records", { + ...options, + query: { ownerAccountId, confirmationNo, usedRoomType, page, pageSize } + }), + listAllUsageRecords: ({ ownerAccountId, confirmationNo, usedRoomType, ...options } = {}) => collectPages( + pageOptions => client.listUsageRecords(pageOptions), + { ownerAccountId, confirmationNo, usedRoomType, ...options } + ), + getDashboard: ({ year = periodYear, ...options } = {}) => request("dashboard", { + ...options, + query: { year } + }), + createUsageRecord: (input, options = {}) => request("usage-records", { + ...options, + method: "POST", + body: input + }) + }); + + windowObject.CONDO_API = Object.freeze({ + runtime: Object.freeze({ + mode, + apiBaseUrl: baseUrl.href.replace(/\/$/, ""), + periodYear + }), + client, + CondoApiError + }); +})(window); diff --git a/app.js b/app.js new file mode 100644 index 0000000..cd5182a --- /dev/null +++ b/app.js @@ -0,0 +1,1806 @@ +let ROOM_TYPES = ["RM1", "RM2", "RM3", "RM4", "SU1", "SU2", "SU3", "SU6", "UG1", "UG2", "AC2"]; +const ROOM_TYPE_DEFINITIONS = Object.freeze({ + RM1: { en: "No balcony TWN (4+4 F)", zh: "无阳台双床(4+4 F)" }, + RM2: { en: "Superior King (6F)", zh: "高级大床(6F)" }, + RM3: { en: "Superior TWN (4+4 F)", zh: "高级双床(4+4 F)" }, + RM4: { en: "Superior TWN (6+4 F)", zh: "高级双床(6+4 F)" }, + UG1: { en: "Deluxe King (6F)", zh: "豪华大床(6F)" }, + UG2: { en: "Deluxe TWN (6+4 F)", zh: "豪华双床(6+4 F)" }, + SU1: { en: "Junior Suite King (6F)", zh: "初级套房大床(6F)" }, + SU2: { en: "Junior Suite King (6F) · Pool view", zh: "初级套房大床(6F)· 泳池景" }, + SU6: { en: "Junior Suite TWN (4+4 F)", zh: "初级套房双床(4+4 F)" }, + SU3: { en: "Two bedroom / Family room (King + TWN)", zh: "两卧室/家庭房(大床 + 双床)" }, + AC1: { en: "Handicap King", zh: "无障碍大床" }, + AC2: { en: "Handicap TWN", zh: "无障碍双床" } +}); +const API_RUNTIME = window.CONDO_API?.runtime || { mode: "demo", periodYear: new Date().getUTCFullYear() }; +const API_CLIENT = window.CONDO_API?.client || null; +const IS_API_MODE = API_RUNTIME.mode === "api"; +const PERIOD_YEAR = API_RUNTIME.periodYear; + +if (!IS_API_MODE && (!Array.isArray(window.CONDO_OWNER_DATA) || window.CONDO_OWNER_DATA.length !== 190)) { + throw new Error("The complete 190-record owner dataset could not be loaded."); +} + +let owners = IS_API_MODE ? [] : window.CONDO_OWNER_DATA.map(record => ({ + ...record, + used: Math.max(0, 15 - record.remaining) +})); + +let stayRecords = IS_API_MODE ? [] : [ + { confirmation: "26081472", ownerId: 3, checkin: "2026-08-04", checkout: "2026-08-06", nights: 2, usedType: "RM3", multiplier: 1, deducted: 2, remark: "" }, + { confirmation: "26082318", ownerId: 4, checkin: "2026-08-09", checkout: "2026-08-12", nights: 3, usedType: "SU1", multiplier: 2, deducted: 6, remark: "" }, + { confirmation: "26082405", ownerId: 5, checkin: "2026-08-14", checkout: "2026-08-16", nights: 2, usedType: "RM2", multiplier: 1, deducted: 2, remark: "" }, + { confirmation: "26071190", ownerId: 1, checkin: "2026-07-11", checkout: "2026-07-13", nights: 2, usedType: "RM3", multiplier: 1, deducted: 2, remark: "" }, + { confirmation: "26062844", ownerId: 2, checkin: "2026-06-28", checkout: "2026-06-30", nights: 2, usedType: "RM3", multiplier: 1, deducted: 2, remark: "" }, + { confirmation: "26052136", ownerId: 6, checkin: "2026-05-21", checkout: "2026-05-23", nights: 2, usedType: "RM2", multiplier: 1, deducted: 2, remark: "" } +].map((record, importOrder) => ({ + ...record, + recordSource: "imported", + importOrder, + enteredAt: null, + entrySequence: null, + balance: owners.find(owner => owner.id === record.ownerId).remaining +})); + +let nextManualEntrySequence = 1; + +let aggregateRemaining = owners.reduce((total, owner) => total + owner.remaining, 0); +let aggregateUsed = owners.reduce((total, owner) => total + owner.used, 0); +let dashboardData = null; +let connectionState = IS_API_MODE ? "loading" : "demo"; +let connectionErrorCode = null; +let activeLoadController = null; +let isSubmittingStay = false; +let stayIdempotencyKey = null; +let currentLanguage = "en"; +const LANGUAGE_CONFIG = Object.freeze({ + en: Object.freeze({ html: "en", intl: "en-US", name: "English" }), + zh: Object.freeze({ html: "zh-CN", intl: "zh-CN", name: "中文" }), + th: Object.freeze({ html: "th", intl: "th-TH-u-ca-gregory-nu-latn", name: "ไทย" }) +}); +const SUPPORTED_LANGUAGES = Object.freeze(Object.keys(LANGUAGE_CONFIG)); +let detailOwnerId = null; +let activeOwnerView = "accounts"; +let stayReturnOwnerId = null; +let stayOwnerLocked = false; +let toastTimer; +let lastToast = null; +let workspaceInitialized = false; +let currentUser = null; +let isAuthenticating = false; +let isLoggingOut = false; +let sessionErrorTimer = null; +let loginErrorKey = null; +let sessionErrorKey = null; + +function usageRecordsInDisplayOrder(records) { + return [...records].sort((a, b) => { + if (a.recordSource === "api" || b.recordSource === "api") { + const createdDifference = String(b.enteredAt || "").localeCompare(String(a.enteredAt || "")); + return createdDifference || String(b.id || "").localeCompare(String(a.id || "")); + } + const aIsManual = a.recordSource === "manual"; + const bIsManual = b.recordSource === "manual"; + + if (aIsManual !== bIsManual) return aIsManual ? -1 : 1; + if (aIsManual) { + const sequenceDifference = (b.entrySequence || 0) - (a.entrySequence || 0); + return sequenceDifference || String(b.enteredAt || "").localeCompare(String(a.enteredAt || "")); + } + + return (a.importOrder ?? Number.MAX_SAFE_INTEGER) - (b.importOrder ?? Number.MAX_SAFE_INTEGER); + }); +} + +const I18N = { + en: { + appTitle: "Condo", + metaDescription: "Condo owner annual stay privilege management prototype", + ownerOperations: "Owner operations", + privateWorkspace: "PRIVATE WORKSPACE", + loginIntroTitle: "Owner stay privileges, securely managed.", + loginIntroDescription: "A focused workspace for owner accounts, room usage and annual privilege balances.", + authorizedAccessOnly: "Authorized operator access only", + secureAccess: "SECURE ACCESS", + welcomeBack: "Welcome back", + signInDescription: "Sign in to continue to the Condo owner workspace.", + checkingSession: "Checking your secure session…", + username: "Username", + usernamePlaceholder: "Enter your username", + password: "Password", + passwordPlaceholder: "Enter your password", + showPassword: "Show password", + hidePassword: "Hide password", + signIn: "Sign in", + signingIn: "Signing in…", + secureSessionNote: "Your session is protected and expires automatically.", + internalSystem: "Internal operations system", + credentialsRequired: "Enter both your username and password.", + invalidCredentials: "The username or password is incorrect.", + authServiceUnavailable: "The secure login service is unavailable. Start the API and try again.", + sessionExpired: "Your session has expired. Sign in again to continue.", + signedInAs: "Signed in as", + signOut: "Sign out", + signingOut: "Signing out…", + signOutFailed: "Could not sign out. Check the API connection and try again.", + primaryNavigation: "Primary navigation", + dashboard: "Dashboard", + ownerAccounts: "Owner Accounts", + ownerAccountsHeading: "Owner accounts", + language: "Language", + runtimeDemoTitle: "Demo data", + runtimeDemoMessage: "Showing the local prototype dataset. Use ?mode=demo to enter prototype mode explicitly.", + runtimeApiLoadingTitle: "Connecting to local API", + runtimeApiLoadingMessage: "Loading database-backed accounts, usage and dashboard data…", + runtimeApiReadyTitle: "Local API connected", + runtimeApiReadyMessage: ({ count }) => `${formatNumber(count)} database account${count === 1 ? "" : "s"} loaded.`, + runtimeApiErrorTitle: "Local API unavailable", + runtimeApiErrorMessage: "No demo data was substituted. Start the API and retry.", + retry: "Retry", + openNavigation: "Open navigation", + closeNavigation: "Close navigation", + currentPeriod: "Current period", + currentPeriodAria: ({ year }) => `Current period ${year}`, + dashboardDate: "WEDNESDAY, JULY 29", + addUsageRecord: "Add usage record", + ownerRooms: "Owner rooms", + activeAccounts: "Active accounts", + remainingPrivileges: "Remaining privileges", + nightsAvailable: "Nights available", + usedThisYear: "Used this year", + privilegeNightsDeducted: "Privilege nights deducted", + privilegeUsage: "Privilege usage", + deductedByMonth: "Deducted nights by month", + nightsUsed2026: ({ year }) => `nights used in ${year}`, + monthlyChartLabel: "Monthly privilege use chart", + roomTypeMix: "Room type mix", + roomTypeMixDescription: "Owner room mix compared with consumed room nights", + roomTypeMixLegendAria: "Room type chart legend", + sourceRoomType: "Room Type", + roomTypeMixNote: "Bars show each room type as a share of its own series total.", + ownerRoomSeriesTotal: ({ count }) => `${count} owner rooms`, + usedRoomNightSeriesTotal: ({ count }) => `${count} room nights`, + roomTypeMixChartAria: ({ ownerTotal, usedTotal }) => `Room type distribution comparing ${ownerTotal} owner rooms with ${usedTotal} consumed room nights.`, + roomTypeSeriesAria: ({ type, count, percent }) => `Room Type ${type}: ${count} owner rooms, ${percent}.`, + usedRoomTypeSeriesAria: ({ type, count, percent }) => `Used Room Type ${type}: ${count} room nights, ${percent}.`, + monthJan: "Jan", + monthFeb: "Feb", + monthMar: "Mar", + monthApr: "Apr", + monthMay: "May", + monthJun: "Jun", + monthJul: "Jul", + monthAug: "Aug", + monthSep: "Sep", + monthOct: "Oct", + monthNov: "Nov", + monthDec: "Dec", + confirmation: "Confirmation", + owner: "Owner", + stayDates: "Stay dates", + usedRoom: "Used room", + deduction: "Deduction", + ownerWorkspaceAria: "Owner account workspace", + accounts: "Accounts", + usageHistory: "Usage history", + ownerSearchPlaceholder: "Search No., owner, room, unit or member…", + ownerSearchAria: "Search owner accounts", + purchasedTypeFilterAria: "Filter by purchased room type", + allRoomTypes: "All room types", + no: "No", + transferDate: "Transfer Date", + name: "Name", + roomNo: "Room No.", + roomType: "Room Type", + unitNo: "Unit No.", + memberNo: "Member No.", + remainingStayPrivileges: "Remaining stay privileges", + usageSearchPlaceholder: "Search confirmation, owner, room or member…", + usageSearchAria: "Search usage history", + usedTypeFilterAria: "Filter by used room type", + allUsedRoomTypes: "All used room types", + ownerRoom: "Owner / room", + checkIn: "Check-in", + checkOut: "Check-out", + night: "Night", + nights: "Nights", + use: "Use", + drawerUsingNights: "Use", + balance: "Balance (nights)", + usedType: "Used type", + multiplier: "Multiplier", + deducted: "Deducted", + remark: "Remark", + newUsage: "NEW USAGE", + reviewDeduction: "Review the deduction before saving.", + closeForm: "Close form", + ownerAccount: "Owner account", + ownerLockNote: "This account was selected from the owner workspace.", + confirmationNo: "Confirmation No.", + digitsOnlyPlaceholder: "Enter digits only", + usedRoomType: "Used Room Type", + deductionMultiplier: "Deduction multiplier", + selectMultiplier: "Select a multiplier", + ac2RequiredNote: "Required because AC2 is involved in this stay.", + remarkPlaceholder: "Add an optional note", + deductionPreview: "Use preview", + automaticRule: "Automatic rule", + ac2ManualRule: "AC2 manual rule", + currentBalance: "Current balance", + afterSaving: "After saving", + cancel: "Cancel", + saveRecord: "Save record", + ownerAccountUpper: "OWNER ACCOUNT", + accountDetails: "Account details", + closeAccountDetails: "Close account details", + usageSaved: "Usage record saved", + balanceUpdated: "The privilege balance has been updated.", + accountsShown: ({ count }) => `${formatNumber(count)} account${count === 1 ? "" : "s"} shown`, + recordsShown: ({ count }) => `${formatNumber(count)} record${count === 1 ? "" : "s"} shown`, + viewAccount: ({ name }) => `View ${name} account`, + openAccount: ({ name }) => `Open ${name} account`, + noOwnerAccountsFound: "No owner accounts found", + tryOwnerFilter: "Try a different search or room-type filter.", + noDatabaseOwners: "No database accounts yet", + noDatabaseOwnersDescription: "Import or create owner accounts before recording usage.", + room: "Room", + member: "Member", + roomLine: ({ room }) => `Room ${room}`, + roomMemberLine: ({ room, member }) => `Room ${room} · Member ${member}`, + confirmationResult: ({ confirmation }) => `Confirmation ${confirmation}`, + noUsageRecordsFound: "No usage records found", + tryUsageFilter: "Try a different confirmation, owner or room-type filter.", + noDatabaseUsage: "No database usage records yet", + noDatabaseUsageDescription: "Usage records will appear here after accounts and usage data are added.", + noRemark: "No remark", + to: "to", + purchasedType: "Purchased type", + remaining: "Remaining", + notCalculated: "Not calculated", + pending: "Pending", + checkoutLater: "Check-out must be later than check-in.", + selectAc2Multiplier: "Select a deduction multiplier because AC2 is involved.", + insufficientPrivileges: ({ required, available }) => `Insufficient remaining stay privileges. Required: ${required}; available: ${available}.`, + duplicateConfirmation: "This confirmation number already exists.", + recordingRoom: ({ room }) => `Recording usage for Room ${room}.`, + chooseOwnerReview: "Choose an owner account and review the deduction before saving.", + noAccountsForUsage: "No owner account is available for a usage record.", + entitlementUnavailable: "This account has no entitlement balance for the selected year.", + savingRecord: "Saving…", + usageSaveFailed: "The usage record could not be saved. Review the data and try again.", + usageSaveInsufficient: "The account no longer has enough stay privileges. Reload and review the balance.", + usageSaveConflict: "This request conflicts with current data. Check the confirmation number and try again.", + usageSaveNotFound: "The account, entitlement period or room type is no longer available.", + usageSaveRuleViolation: "The record does not meet the current usage rules.", + usageSaveNetwork: "The local API could not be reached. The record was not saved.", + transferDateSubtitle: ({ date }) => `Transfer Date: ${date}`, + remaining2026: ({ year }) => `${year} remaining stay privileges`, + annualGrant: "Annual grant", + usedIn2026: ({ year }) => `Used in ${year}`, + usageRecords: "Usage records", + accountInformation: "Account information", + workbookSource: "Workbook source", + databaseSource: "Database source", + purchasedRoomType: "Purchased room type", + roomUsed: ({ type }) => `${type} room used`, + amountDeducted: ({ amount }) => `${amount} deducted`, + stay: "Stay", + noUsage2026: ({ year }) => `No usage records in ${year}`, + addFirstUsage: "Add the first usage record for this account.", + usageSavedMessage: ({ deduction, room }) => `${deduction} were deducted from Room ${room}.`, + noGlobalMatches: "No matching owners or usage records." + }, + zh: { + appTitle: "Condo 业主管理", + metaDescription: "Condo 业主年度入住权益管理原型", + ownerOperations: "业主运营", + privateWorkspace: "内部工作区", + loginIntroTitle: "安全管理业主入住权益。", + loginIntroDescription: "集中管理业主账户、房间使用记录与年度权益余额。", + authorizedAccessOnly: "仅限授权操作人员访问", + secureAccess: "安全登录", + welcomeBack: "欢迎回来", + signInDescription: "登录后继续使用 Condo 业主工作区。", + checkingSession: "正在检查安全会话…", + username: "用户名", + usernamePlaceholder: "请输入用户名", + password: "密码", + passwordPlaceholder: "请输入密码", + showPassword: "显示密码", + hidePassword: "隐藏密码", + signIn: "登录", + signingIn: "正在登录…", + secureSessionNote: "会话受安全保护,并会自动过期。", + internalSystem: "内部运营系统", + credentialsRequired: "请输入用户名和密码。", + invalidCredentials: "用户名或密码不正确。", + authServiceUnavailable: "安全登录服务暂时不可用,请启动 API 后重试。", + sessionExpired: "登录会话已过期,请重新登录后继续。", + signedInAs: "当前账号", + signOut: "退出登录", + signingOut: "正在退出…", + signOutFailed: "退出失败,请检查 API 连接后重试。", + primaryNavigation: "主导航", + dashboard: "概览", + ownerAccounts: "业主账户", + ownerAccountsHeading: "业主账户", + language: "语言", + runtimeDemoTitle: "演示数据", + runtimeDemoMessage: "当前显示本地原型数据;仅在地址增加 ?mode=demo 时使用原型数据。", + runtimeApiLoadingTitle: "正在连接本地 API", + runtimeApiLoadingMessage: "正在加载数据库账户、使用记录和概览数据…", + runtimeApiReadyTitle: "本地 API 已连接", + runtimeApiReadyMessage: ({ count }) => `已加载 ${formatNumber(count)} 个数据库账户。`, + runtimeApiErrorTitle: "本地 API 不可用", + runtimeApiErrorMessage: "未使用演示数据替代。请启动 API 后重试。", + retry: "重试", + openNavigation: "打开导航", + closeNavigation: "关闭导航", + currentPeriod: "当前周期", + currentPeriodAria: ({ year }) => `当前周期 ${year}年`, + dashboardDate: "7月29日 星期三", + addUsageRecord: "新增使用记录", + ownerRooms: "业主房间", + activeAccounts: "有效账户", + remainingPrivileges: "剩余权益", + nightsAvailable: "可用晚数", + usedThisYear: "本年已使用", + privilegeNightsDeducted: "已扣减权益晚数", + privilegeUsage: "权益使用", + deductedByMonth: "每月扣减晚数", + nightsUsed2026: ({ year }) => `${year}年已使用权益晚数`, + monthlyChartLabel: "每月权益使用图", + roomTypeMix: "房型结构", + roomTypeMixDescription: "业主原始房型与实际使用房晚结构对比", + roomTypeMixLegendAria: "房型结构图例", + sourceRoomType: "原始房型", + roomTypeMixNote: "条形长度表示各房型在各自总量中的占比。", + ownerRoomSeriesTotal: ({ count }) => `${count} 间业主房`, + usedRoomNightSeriesTotal: ({ count }) => `${count} 个使用房晚`, + roomTypeMixChartAria: ({ ownerTotal, usedTotal }) => `房型分布:对比 ${ownerTotal} 间业主房与 ${usedTotal} 个实际使用房晚。`, + roomTypeSeriesAria: ({ type, count, percent }) => `原始房型 ${type}:${count} 间业主房,占 ${percent}。`, + usedRoomTypeSeriesAria: ({ type, count, percent }) => `使用房型 ${type}:${count} 个房晚,占 ${percent}。`, + monthJan: "1月", + monthFeb: "2月", + monthMar: "3月", + monthApr: "4月", + monthMay: "5月", + monthJun: "6月", + monthJul: "7月", + monthAug: "8月", + monthSep: "9月", + monthOct: "10月", + monthNov: "11月", + monthDec: "12月", + confirmation: "确认号", + owner: "业主", + stayDates: "入住日期", + usedRoom: "使用房型", + deduction: "扣减", + ownerWorkspaceAria: "业主账户工作区", + accounts: "账户", + usageHistory: "使用记录", + ownerSearchPlaceholder: "搜索编号、业主、房间、单元或会员号…", + ownerSearchAria: "搜索业主账户", + purchasedTypeFilterAria: "按购买房型筛选", + allRoomTypes: "所有房型", + no: "编号", + transferDate: "转让日期", + name: "姓名", + roomNo: "房间号", + roomType: "房型", + unitNo: "单元号", + memberNo: "会员号", + remainingStayPrivileges: "剩余入住权益", + usageSearchPlaceholder: "搜索确认号、业主、房间或会员号…", + usageSearchAria: "搜索使用记录", + usedTypeFilterAria: "按使用房型筛选", + allUsedRoomTypes: "所有使用房型", + ownerRoom: "业主 / 房间", + checkIn: "入住日期", + checkOut: "退房日期", + night: "晚数", + nights: "晚数", + use: "使用", + drawerUsingNights: "使用", + balance: "余额(晚数)", + usedType: "使用房型", + multiplier: "扣减倍数", + deducted: "已扣减", + remark: "备注", + newUsage: "新增使用", + reviewDeduction: "保存前请核对扣减结果。", + closeForm: "关闭表单", + ownerAccount: "业主账户", + ownerLockNote: "此账户来自业主账户工作区,已锁定。", + confirmationNo: "确认号", + digitsOnlyPlaceholder: "仅输入数字", + usedRoomType: "使用房型", + deductionMultiplier: "扣减倍数", + selectMultiplier: "请选择倍数", + ac2RequiredNote: "本次使用涉及 AC2,必须选择扣减倍数。", + remarkPlaceholder: "添加可选备注", + deductionPreview: "使用预览", + automaticRule: "自动规则", + ac2ManualRule: "AC2 手动规则", + currentBalance: "当前余额", + afterSaving: "保存后", + cancel: "取消", + saveRecord: "保存记录", + ownerAccountUpper: "业主账户", + accountDetails: "账户详情", + closeAccountDetails: "关闭账户详情", + usageSaved: "使用记录已保存", + balanceUpdated: "入住权益余额已更新。", + accountsShown: ({ count }) => `显示 ${formatNumber(count)} 个账户`, + recordsShown: ({ count }) => `显示 ${formatNumber(count)} 条记录`, + viewAccount: ({ name }) => `查看 ${name} 的账户`, + openAccount: ({ name }) => `打开 ${name} 的账户`, + noOwnerAccountsFound: "未找到业主账户", + tryOwnerFilter: "请尝试其他搜索内容或房型筛选条件。", + noDatabaseOwners: "数据库中暂无业主账户", + noDatabaseOwnersDescription: "导入或创建业主账户后,才能登记使用记录。", + room: "房间", + member: "会员号", + roomLine: ({ room }) => `房间 ${room}`, + roomMemberLine: ({ room, member }) => `房间 ${room} · 会员号 ${member}`, + confirmationResult: ({ confirmation }) => `确认号 ${confirmation}`, + noUsageRecordsFound: "未找到使用记录", + tryUsageFilter: "请尝试其他确认号、业主或房型筛选条件。", + noDatabaseUsage: "数据库中暂无使用记录", + noDatabaseUsageDescription: "账户和使用数据建立后,记录会显示在这里。", + noRemark: "无备注", + to: "至", + purchasedType: "购买房型", + remaining: "剩余权益", + notCalculated: "尚未计算", + pending: "待计算", + checkoutLater: "退房日期必须晚于入住日期。", + selectAc2Multiplier: "本次使用涉及 AC2,请选择扣减倍数。", + insufficientPrivileges: ({ required, available }) => `剩余入住权益不足。需要:${required};可用:${available}。`, + duplicateConfirmation: "该确认号已存在。", + recordingRoom: ({ room }) => `正在记录房间 ${room} 的使用情况。`, + chooseOwnerReview: "请选择业主账户,并在保存前核对扣减结果。", + noAccountsForUsage: "当前没有可用于新增使用记录的业主账户。", + entitlementUnavailable: "该账户在所选年度没有可用权益余额。", + savingRecord: "正在保存…", + usageSaveFailed: "使用记录保存失败,请检查数据后重试。", + usageSaveInsufficient: "该账户当前权益不足,请刷新后重新核对余额。", + usageSaveConflict: "请求与当前数据冲突,请检查确认号后重试。", + usageSaveNotFound: "账户、权益期间或房型已不存在。", + usageSaveRuleViolation: "该记录不符合当前使用规则。", + usageSaveNetwork: "无法连接本地 API,记录尚未保存。", + transferDateSubtitle: ({ date }) => `转让日期:${date}`, + remaining2026: ({ year }) => `${year}年剩余入住权益`, + annualGrant: "年度新增", + usedIn2026: ({ year }) => `${year}年已使用`, + usageRecords: "使用记录", + accountInformation: "账户信息", + workbookSource: "工作簿来源", + databaseSource: "数据库来源", + purchasedRoomType: "购买房型", + roomUsed: ({ type }) => `使用房型 ${type}`, + amountDeducted: ({ amount }) => `已扣减 ${amount}`, + stay: "入住", + noUsage2026: ({ year }) => `${year}年暂无使用记录`, + addFirstUsage: "为此账户新增第一条使用记录。", + usageSavedMessage: ({ deduction, room }) => `已从房间 ${room} 扣减 ${deduction}。`, + noGlobalMatches: "未找到匹配的业主或使用记录。" + }, + th: { + appTitle: "Condo การจัดการสิทธิ์เจ้าของห้อง", + metaDescription: "ต้นแบบระบบจัดการสิทธิ์การเข้าพักประจำปีของเจ้าของห้อง Condo", + ownerOperations: "การดำเนินงานเจ้าของห้อง", + privateWorkspace: "พื้นที่ทำงานภายใน", + loginIntroTitle: "จัดการสิทธิ์การเข้าพักของเจ้าของห้องอย่างปลอดภัย", + loginIntroDescription: "พื้นที่ทำงานสำหรับบัญชีเจ้าของห้อง การใช้ห้อง และยอดสิทธิ์ประจำปี", + authorizedAccessOnly: "สำหรับผู้ปฏิบัติงานที่ได้รับอนุญาตเท่านั้น", + secureAccess: "เข้าสู่ระบบอย่างปลอดภัย", + welcomeBack: "ยินดีต้อนรับกลับ", + signInDescription: "เข้าสู่ระบบเพื่อใช้งานพื้นที่ทำงานเจ้าของห้อง Condo ต่อ", + checkingSession: "กำลังตรวจสอบเซสชันที่ปลอดภัย…", + username: "ชื่อผู้ใช้", + usernamePlaceholder: "ป้อนชื่อผู้ใช้", + password: "รหัสผ่าน", + passwordPlaceholder: "ป้อนรหัสผ่าน", + showPassword: "แสดงรหัสผ่าน", + hidePassword: "ซ่อนรหัสผ่าน", + signIn: "เข้าสู่ระบบ", + signingIn: "กำลังเข้าสู่ระบบ…", + secureSessionNote: "เซสชันของคุณได้รับการปกป้องและจะหมดอายุโดยอัตโนมัติ", + internalSystem: "ระบบปฏิบัติการภายใน", + credentialsRequired: "กรุณาป้อนชื่อผู้ใช้และรหัสผ่าน", + invalidCredentials: "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง", + authServiceUnavailable: "บริการเข้าสู่ระบบอย่างปลอดภัยไม่พร้อมใช้งาน กรุณาเริ่ม API แล้วลองอีกครั้ง", + sessionExpired: "เซสชันหมดอายุแล้ว กรุณาเข้าสู่ระบบอีกครั้งเพื่อดำเนินการต่อ", + signedInAs: "เข้าสู่ระบบในชื่อ", + signOut: "ออกจากระบบ", + signingOut: "กำลังออกจากระบบ…", + signOutFailed: "ออกจากระบบไม่สำเร็จ กรุณาตรวจสอบการเชื่อมต่อ API แล้วลองอีกครั้ง", + primaryNavigation: "การนำทางหลัก", + dashboard: "แดชบอร์ด", + ownerAccounts: "บัญชีเจ้าของห้อง", + ownerAccountsHeading: "บัญชีเจ้าของห้อง", + language: "ภาษา", + runtimeDemoTitle: "ข้อมูลสาธิต", + runtimeDemoMessage: "กำลังแสดงชุดข้อมูลต้นแบบภายในเครื่อง ใช้ ?mode=demo เพื่อเข้าสู่โหมดต้นแบบโดยเฉพาะ", + runtimeApiLoadingTitle: "กำลังเชื่อมต่อ API ภายในเครื่อง", + runtimeApiLoadingMessage: "กำลังโหลดบัญชี รายการใช้สิทธิ์ และข้อมูลแดชบอร์ดจากฐานข้อมูล…", + runtimeApiReadyTitle: "เชื่อมต่อ API ภายในเครื่องแล้ว", + runtimeApiReadyMessage: ({ count }) => `โหลดบัญชีจากฐานข้อมูลแล้ว ${formatNumber(count)} บัญชี`, + runtimeApiErrorTitle: "API ภายในเครื่องไม่พร้อมใช้งาน", + runtimeApiErrorMessage: "ไม่มีการใช้ข้อมูลสาธิตแทน กรุณาเริ่ม API แล้วลองอีกครั้ง", + retry: "ลองอีกครั้ง", + openNavigation: "เปิดการนำทาง", + closeNavigation: "ปิดการนำทาง", + currentPeriod: "รอบปัจจุบัน", + currentPeriodAria: ({ year }) => `รอบปัจจุบัน ${year}`, + dashboardDate: "วันพุธที่ 29 กรกฎาคม", + addUsageRecord: "เพิ่มรายการใช้สิทธิ์", + ownerRooms: "ห้องของเจ้าของ", + activeAccounts: "บัญชีที่ใช้งานอยู่", + remainingPrivileges: "สิทธิ์คงเหลือ", + nightsAvailable: "คืนที่ใช้ได้", + usedThisYear: "ใช้ไปในปีนี้", + privilegeNightsDeducted: "คืนสิทธิ์ที่หักแล้ว", + privilegeUsage: "การใช้สิทธิ์", + deductedByMonth: "คืนที่หักแยกตามเดือน", + nightsUsed2026: ({ year }) => `คืนที่ใช้ในปี ${year}`, + monthlyChartLabel: "กราฟการใช้สิทธิ์รายเดือน", + roomTypeMix: "สัดส่วนประเภทห้อง", + roomTypeMixDescription: "เปรียบเทียบสัดส่วนห้องของเจ้าของกับจำนวนคืนห้องที่ใช้", + roomTypeMixLegendAria: "คำอธิบายกราฟประเภทห้อง", + sourceRoomType: "ประเภทห้องที่ซื้อ", + roomTypeMixNote: "แถบแสดงสัดส่วนของแต่ละประเภทห้องเมื่อเทียบกับยอดรวมของชุดข้อมูลนั้น", + ownerRoomSeriesTotal: ({ count }) => `${count} ห้องของเจ้าของ`, + usedRoomNightSeriesTotal: ({ count }) => `${count} คืนห้อง`, + roomTypeMixChartAria: ({ ownerTotal, usedTotal }) => `การกระจายประเภทห้อง เปรียบเทียบ ${ownerTotal} ห้องของเจ้าของกับ ${usedTotal} คืนห้องที่ใช้`, + roomTypeSeriesAria: ({ type, count, percent }) => `ประเภทห้อง ${type}: ${count} ห้องของเจ้าของ คิดเป็น ${percent}`, + usedRoomTypeSeriesAria: ({ type, count, percent }) => `ประเภทห้องที่ใช้ ${type}: ${count} คืนห้อง คิดเป็น ${percent}`, + monthJan: "ม.ค.", + monthFeb: "ก.พ.", + monthMar: "มี.ค.", + monthApr: "เม.ย.", + monthMay: "พ.ค.", + monthJun: "มิ.ย.", + monthJul: "ก.ค.", + monthAug: "ส.ค.", + monthSep: "ก.ย.", + monthOct: "ต.ค.", + monthNov: "พ.ย.", + monthDec: "ธ.ค.", + confirmation: "หมายเลขยืนยัน", + owner: "เจ้าของห้อง", + stayDates: "วันที่เข้าพัก", + usedRoom: "ห้องที่ใช้", + deduction: "การหักสิทธิ์", + ownerWorkspaceAria: "พื้นที่ทำงานบัญชีเจ้าของห้อง", + accounts: "บัญชี", + usageHistory: "ประวัติการใช้สิทธิ์", + ownerSearchPlaceholder: "ค้นหาเลขที่ เจ้าของ ห้อง ยูนิต หรือสมาชิก…", + ownerSearchAria: "ค้นหาบัญชีเจ้าของห้อง", + purchasedTypeFilterAria: "กรองตามประเภทห้องที่ซื้อ", + allRoomTypes: "ทุกประเภทห้อง", + no: "ลำดับ", + transferDate: "วันที่โอน", + name: "ชื่อ", + roomNo: "หมายเลขห้อง", + roomType: "ประเภทห้อง", + unitNo: "หมายเลขยูนิต", + memberNo: "หมายเลขสมาชิก", + remainingStayPrivileges: "สิทธิ์เข้าพักคงเหลือ", + usageSearchPlaceholder: "ค้นหาหมายเลขยืนยัน เจ้าของ ห้อง หรือสมาชิก…", + usageSearchAria: "ค้นหาประวัติการใช้สิทธิ์", + usedTypeFilterAria: "กรองตามประเภทห้องที่ใช้", + allUsedRoomTypes: "ทุกประเภทห้องที่ใช้", + ownerRoom: "เจ้าของ / ห้อง", + checkIn: "เช็กอิน", + checkOut: "เช็กเอาต์", + night: "คืน", + nights: "คืน", + use: "ใช้สิทธิ์", + drawerUsingNights: "ใช้สิทธิ์", + balance: "สิทธิ์คงเหลือ (คืน)", + usedType: "ประเภทที่ใช้", + multiplier: "ตัวคูณ", + deducted: "หักแล้ว", + remark: "หมายเหตุ", + newUsage: "การใช้สิทธิ์ใหม่", + reviewDeduction: "ตรวจสอบยอดหักก่อนบันทึก", + closeForm: "ปิดแบบฟอร์ม", + ownerAccount: "บัญชีเจ้าของห้อง", + ownerLockNote: "เลือกบัญชีนี้จากพื้นที่ทำงานเจ้าของห้องแล้ว", + confirmationNo: "หมายเลขยืนยัน", + digitsOnlyPlaceholder: "ป้อนเฉพาะตัวเลข", + usedRoomType: "ประเภทห้องที่ใช้", + deductionMultiplier: "ตัวคูณการหักสิทธิ์", + selectMultiplier: "เลือกตัวคูณ", + ac2RequiredNote: "จำเป็นต้องเลือกตัวคูณเนื่องจากการเข้าพักนี้เกี่ยวข้องกับ AC2", + remarkPlaceholder: "เพิ่มหมายเหตุ (ไม่บังคับ)", + deductionPreview: "ตัวอย่างการใช้สิทธิ์", + automaticRule: "กฎอัตโนมัติ", + ac2ManualRule: "กฎ AC2 แบบกำหนดเอง", + currentBalance: "สิทธิ์คงเหลือปัจจุบัน", + afterSaving: "หลังบันทึก", + cancel: "ยกเลิก", + saveRecord: "บันทึกรายการ", + ownerAccountUpper: "บัญชีเจ้าของห้อง", + accountDetails: "รายละเอียดบัญชี", + closeAccountDetails: "ปิดรายละเอียดบัญชี", + usageSaved: "บันทึกรายการใช้สิทธิ์แล้ว", + balanceUpdated: "อัปเดตสิทธิ์คงเหลือแล้ว", + accountsShown: ({ count }) => `แสดง ${formatNumber(count)} บัญชี`, + recordsShown: ({ count }) => `แสดง ${formatNumber(count)} รายการ`, + viewAccount: ({ name }) => `ดูบัญชีของ ${name}`, + openAccount: ({ name }) => `เปิดบัญชีของ ${name}`, + noOwnerAccountsFound: "ไม่พบบัญชีเจ้าของห้อง", + tryOwnerFilter: "ลองค้นหาอื่นหรือเปลี่ยนตัวกรองประเภทห้อง", + noDatabaseOwners: "ยังไม่มีบัญชีในฐานข้อมูล", + noDatabaseOwnersDescription: "นำเข้าหรือสร้างบัญชีเจ้าของห้องก่อนบันทึกการใช้สิทธิ์", + room: "ห้อง", + member: "สมาชิก", + roomLine: ({ room }) => `ห้อง ${room}`, + roomMemberLine: ({ room, member }) => `ห้อง ${room} · สมาชิก ${member}`, + confirmationResult: ({ confirmation }) => `หมายเลขยืนยัน ${confirmation}`, + noUsageRecordsFound: "ไม่พบรายการใช้สิทธิ์", + tryUsageFilter: "ลองค้นหาหมายเลขยืนยัน เจ้าของ หรือประเภทห้องอื่น", + noDatabaseUsage: "ยังไม่มีรายการใช้สิทธิ์ในฐานข้อมูล", + noDatabaseUsageDescription: "รายการใช้สิทธิ์จะแสดงที่นี่หลังจากเพิ่มบัญชีและข้อมูลการใช้แล้ว", + noRemark: "ไม่มีหมายเหตุ", + to: "ถึง", + purchasedType: "ประเภทห้องที่ซื้อ", + remaining: "คงเหลือ", + notCalculated: "ยังไม่คำนวณ", + pending: "รอดำเนินการ", + checkoutLater: "วันที่เช็กเอาต์ต้องหลังวันที่เช็กอิน", + selectAc2Multiplier: "การใช้สิทธิ์นี้เกี่ยวข้องกับ AC2 กรุณาเลือกตัวคูณ", + insufficientPrivileges: ({ required, available }) => `สิทธิ์เข้าพักคงเหลือไม่เพียงพอ ต้องใช้: ${required}; ใช้ได้: ${available}`, + duplicateConfirmation: "หมายเลขยืนยันนี้มีอยู่แล้ว", + recordingRoom: ({ room }) => `กำลังบันทึกการใช้สิทธิ์สำหรับห้อง ${room}`, + chooseOwnerReview: "เลือกบัญชีเจ้าของห้องและตรวจสอบยอดหักก่อนบันทึก", + noAccountsForUsage: "ไม่มีบัญชีเจ้าของห้องสำหรับสร้างรายการใช้สิทธิ์", + entitlementUnavailable: "บัญชีนี้ไม่มีสิทธิ์คงเหลือสำหรับปีที่เลือก", + savingRecord: "กำลังบันทึก…", + usageSaveFailed: "ไม่สามารถบันทึกรายการใช้สิทธิ์ได้ กรุณาตรวจสอบข้อมูลแล้วลองอีกครั้ง", + usageSaveInsufficient: "บัญชีนี้มีสิทธิ์เข้าพักไม่เพียงพอแล้ว กรุณาโหลดใหม่และตรวจสอบยอดคงเหลือ", + usageSaveConflict: "คำขอนี้ขัดแย้งกับข้อมูลปัจจุบัน ตรวจสอบหมายเลขยืนยันแล้วลองอีกครั้ง", + usageSaveNotFound: "ไม่พบบัญชี รอบสิทธิ์ หรือประเภทห้องนี้แล้ว", + usageSaveRuleViolation: "รายการนี้ไม่เป็นไปตามกฎการใช้สิทธิ์ปัจจุบัน", + usageSaveNetwork: "ไม่สามารถเชื่อมต่อ API ภายในเครื่องได้ รายการยังไม่ได้บันทึก", + transferDateSubtitle: ({ date }) => `วันที่โอน: ${date}`, + remaining2026: ({ year }) => `สิทธิ์เข้าพักคงเหลือในปี ${year}`, + annualGrant: "สิทธิ์ที่ได้รับประจำปี", + usedIn2026: ({ year }) => `ใช้ไปในปี ${year}`, + usageRecords: "รายการใช้สิทธิ์", + accountInformation: "ข้อมูลบัญชี", + workbookSource: "แหล่งข้อมูลจากเวิร์กบุ๊ก", + databaseSource: "แหล่งข้อมูลจากฐานข้อมูล", + purchasedRoomType: "ประเภทห้องที่ซื้อ", + roomUsed: ({ type }) => `ใช้ห้องประเภท ${type}`, + amountDeducted: ({ amount }) => `หัก ${amount}`, + stay: "การเข้าพัก", + noUsage2026: ({ year }) => `ยังไม่มีรายการใช้สิทธิ์ในปี ${year}`, + addFirstUsage: "เพิ่มรายการใช้สิทธิ์แรกสำหรับบัญชีนี้", + usageSavedMessage: ({ deduction, room }) => `หัก ${deduction} จากห้อง ${room} แล้ว`, + noGlobalMatches: "ไม่พบเจ้าของห้องหรือรายการใช้สิทธิ์ที่ตรงกัน" + } +}; + +const $ = (selector, root = document) => root.querySelector(selector); +const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; + +function mapApiOwner(record) { + return { + id: record.id, + sourceNo: record.accountNo, + transferDate: record.transferDate, + name: record.name, + room: record.roomNo, + purchasedType: record.purchasedRoomType, + unit: record.unitNo, + member: record.memberNo, + remaining: record.remainingStayPrivileges, + used: 0 + }; +} + +function mapApiUsage(record) { + return { + id: record.id, + confirmation: record.confirmationNo, + ownerId: record.ownerAccountId, + checkin: record.checkIn, + checkout: record.checkOut, + nights: record.night, + usedType: record.usedRoomType, + multiplier: record.appliedMultiplier, + deducted: record.use, + balance: record.balance, + remark: record.remark, + recordSource: "api", + importOrder: null, + enteredAt: record.createdAt, + entrySequence: null + }; +} + +function updateOwnerUsageTotals() { + const useByOwner = stayRecords.reduce((totals, record) => { + totals.set(record.ownerId, (totals.get(record.ownerId) || 0) + record.deducted); + return totals; + }, new Map()); + owners.forEach(owner => { + owner.used = useByOwner.get(owner.id) || 0; + }); +} + +function setConnectionState(state, errorCode = null) { + connectionState = state; + connectionErrorCode = errorCode; + renderConnectionState(); + updateUsageEntryAvailability(); +} + +function renderConnectionState() { + const banner = $("#connectionBanner"); + if (!banner) return; + banner.hidden = connectionState === "demo" || connectionState === "ready"; + banner.dataset.state = connectionState; + document.documentElement.dataset.dataMode = IS_API_MODE ? "api" : "demo"; + const copy = { + demo: ["runtimeDemoTitle", "runtimeDemoMessage"], + loading: ["runtimeApiLoadingTitle", "runtimeApiLoadingMessage"], + ready: ["runtimeApiReadyTitle", "runtimeApiReadyMessage"], + error: ["runtimeApiErrorTitle", "runtimeApiErrorMessage"] + }[connectionState] || ["runtimeApiErrorTitle", "runtimeApiErrorMessage"]; + $("#connectionTitle").textContent = t(copy[0]); + $("#connectionMessage").textContent = t(copy[1], { count: owners.length, code: connectionErrorCode || "" }); + $("#retryApiButton").textContent = t("retry"); + $("#retryApiButton").hidden = !IS_API_MODE || connectionState !== "error"; +} + +function updateUsageEntryAvailability() { + const unavailable = owners.length === 0 || connectionState === "loading" || connectionState === "error" || isSubmittingStay; + $$(".open-stay-drawer").forEach(button => { button.disabled = unavailable; }); + if ($("#detailAddStay")) $("#detailAddStay").disabled = unavailable || !detailOwnerId; +} + +function refreshDataViews() { + populateFormOptions(); + renderOwners(); + renderStays(); + renderRoomTypeMix(); + renderMonthlyUse(); + updateAggregateUI(); + renderAccountContext(); + calculateForm(); + updateUsageEntryAvailability(); + if ($("#ownerDrawer").classList.contains("open") && detailOwnerId && ownerFor(detailOwnerId)) { + renderOwnerDrawer(detailOwnerId); + } +} + +async function loadApiData() { + if (!IS_API_MODE) return; + if (!API_CLIENT) { + setConnectionState("error", "API_CLIENT_MISSING"); + return; + } + + activeLoadController?.abort(); + const controller = new AbortController(); + activeLoadController = controller; + setConnectionState("loading"); + + try { + const [health, roomTypes, ownerResult, usageResult, dashboard] = await Promise.all([ + API_CLIENT.health({ signal: controller.signal }), + API_CLIENT.listRoomTypes({ signal: controller.signal }), + API_CLIENT.listAllOwnerAccounts({ year: PERIOD_YEAR, signal: controller.signal }), + API_CLIENT.listAllUsageRecords({ signal: controller.signal }), + API_CLIENT.getDashboard({ year: PERIOD_YEAR, signal: controller.signal }) + ]); + if (controller !== activeLoadController) return; + if (health.status !== "ok" || health.database !== "booking_test" || health.schema !== "condon") { + throw new Error("API health contract mismatch"); + } + + ROOM_TYPES = roomTypes.map(roomType => roomType.code); + owners = ownerResult.items.map(mapApiOwner); + stayRecords = usageResult.items.map(mapApiUsage); + dashboardData = dashboard; + aggregateRemaining = dashboard.remainingPrivileges; + aggregateUsed = dashboard.used; + updateOwnerUsageTotals(); + setConnectionState("ready"); + refreshDataViews(); + } catch (error) { + if (controller !== activeLoadController || controller.signal.aborted) return; + if (isUnauthorized(error)) { + showLoginScreen("sessionExpired"); + return; + } + owners = []; + stayRecords = []; + dashboardData = null; + aggregateRemaining = 0; + aggregateUsed = 0; + setConnectionState("error", error?.code || "LOAD_FAILED"); + refreshDataViews(); + } finally { + if (controller === activeLoadController) activeLoadController = null; + } +} + +function t(key, params = {}) { + const messages = I18N[currentLanguage] || I18N.en; + const value = messages[key] ?? I18N.en[key] ?? key; + const resolvedParams = { year: PERIOD_YEAR, ...params }; + if (typeof value === "function") return value(resolvedParams); + return value.replace(/\{(\w+)\}/g, (_, name) => resolvedParams[name] ?? ""); +} + +function isUnauthorized(error) { + return error?.status === 401 || error?.code === "UNAUTHORIZED"; +} + +function updatePasswordToggle() { + const input = $("#loginPassword"); + const button = $("#passwordToggle"); + if (!input || !button) return; + const isVisible = input.type === "text"; + button.setAttribute("aria-label", t(isVisible ? "hidePassword" : "showPassword")); + button.setAttribute("aria-pressed", String(isVisible)); + $(".password-eye-open", button).hidden = isVisible; + $(".password-eye-closed", button).hidden = !isVisible; +} + +function setLoginError(key = null) { + loginErrorKey = key; + const error = $("#loginError"); + if (!error) return; + error.textContent = key ? t(key) : ""; + error.hidden = !key; +} + +function showLoginChecking() { + $("#loginScreen").hidden = false; + $("#loginScreen").dataset.authState = "checking"; + $("#appShell").hidden = true; + $("#appShell").setAttribute("aria-hidden", "true"); + $("#loginChecking").hidden = false; + $("#loginForm").hidden = true; + setLoginError(null); +} + +function showLoginScreen(errorKey = null, focusPassword = false) { + activeLoadController?.abort(); + activeLoadController = null; + currentUser = null; + document.body.style.overflow = ""; + $("#loginScreen").hidden = false; + $("#loginScreen").dataset.authState = errorKey ? "error" : "ready"; + $("#appShell").hidden = true; + $("#appShell").setAttribute("aria-hidden", "true"); + $("#loginChecking").hidden = true; + $("#loginForm").hidden = false; + $("#loginPassword").value = ""; + $("#loginPassword").type = "password"; + updatePasswordToggle(); + setLoginError(errorKey); + + if (workspaceInitialized) { + $("#stayDrawer").classList.remove("open"); + $("#stayDrawer").setAttribute("aria-hidden", "true"); + $("#ownerDrawer").classList.remove("open"); + $("#ownerDrawer").setAttribute("aria-hidden", "true"); + $("#drawerScrim").hidden = true; + $("#mobileScrim").hidden = true; + $("#sidebar").classList.remove("open"); + $("#menuButton").setAttribute("aria-expanded", "false"); + $("#toast").hidden = true; + } + + window.requestAnimationFrame(() => { + $(focusPassword ? "#loginPassword" : "#loginUsername").focus(); + }); +} + +function updateAuthenticationControls() { + const loginButton = $("#loginSubmit"); + const loginButtonLabel = $("#loginSubmit span"); + if (loginButton && loginButtonLabel) { + loginButton.disabled = isAuthenticating; + loginButtonLabel.textContent = t(isAuthenticating ? "signingIn" : "signIn"); + } + const logoutButton = $("#logoutButton"); + const logoutButtonLabel = $("#logoutButton span"); + if (logoutButton && logoutButtonLabel) { + logoutButton.disabled = isLoggingOut; + logoutButtonLabel.textContent = t(isLoggingOut ? "signingOut" : "signOut"); + } +} + +function showSessionError(key = null) { + window.clearTimeout(sessionErrorTimer); + sessionErrorKey = key; + const error = $("#sessionError"); + if (!error) return; + error.textContent = key ? t(key) : ""; + error.hidden = !key; + if (key) { + sessionErrorTimer = window.setTimeout(() => showSessionError(null), 5000); + } +} + +async function enterWorkspace(session) { + currentUser = session?.user?.username || ""; + $("#sessionUsername").textContent = currentUser; + showSessionError(null); + $("#loginScreen").hidden = true; + $("#appShell").hidden = false; + $("#appShell").setAttribute("aria-hidden", "false"); + + if (!workspaceInitialized) { + workspaceInitialized = true; + bindEvents(); + resetStayForm(owners[0]?.id || ""); + setOwnerWorkspaceView(activeOwnerView); + } + applyLanguage(currentLanguage); + + if (IS_API_MODE) await loadApiData(); + else setConnectionState("demo"); +} + +async function submitLogin(event) { + event.preventDefault(); + if (isAuthenticating) return; + const username = $("#loginUsername").value.trim(); + const password = $("#loginPassword").value; + if (!username || !password) { + setLoginError("credentialsRequired"); + $(username ? "#loginPassword" : "#loginUsername").focus(); + return; + } + if (!API_CLIENT) { + setLoginError("authServiceUnavailable"); + return; + } + + isAuthenticating = true; + setLoginError(null); + updateAuthenticationControls(); + try { + const session = await API_CLIENT.login({ username, password }); + if (!session?.authenticated) throw new Error("Authentication contract mismatch"); + $("#loginForm").reset(); + updatePasswordToggle(); + await enterWorkspace(session); + } catch (error) { + const invalidCredentials = error?.status === 401 || error?.code === "INVALID_CREDENTIALS"; + showLoginScreen(invalidCredentials ? "invalidCredentials" : "authServiceUnavailable", invalidCredentials); + } finally { + isAuthenticating = false; + updateAuthenticationControls(); + } +} + +async function signOut() { + if (isLoggingOut) return; + isLoggingOut = true; + showSessionError(null); + updateAuthenticationControls(); + try { + if (!API_CLIENT) throw new Error("API client unavailable"); + await API_CLIENT.logout(); + $("#loginForm").reset(); + showLoginScreen(); + } catch { + showSessionError("signOutFailed"); + } finally { + isLoggingOut = false; + updateAuthenticationControls(); + } +} + +function bindLanguageGroup(selector) { + const buttons = $$(selector); + buttons.forEach((button, index) => { + button.addEventListener("click", () => applyLanguage(button.dataset.language || button.dataset.loginLanguage)); + button.addEventListener("keydown", event => { + if (["Enter", " "].includes(event.key)) { + event.preventDefault(); + applyLanguage(button.dataset.language || button.dataset.loginLanguage); + return; + } + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + let nextIndex = index; + if (event.key === "ArrowLeft") nextIndex = (index - 1 + buttons.length) % buttons.length; + if (event.key === "ArrowRight") nextIndex = (index + 1) % buttons.length; + if (event.key === "Home") nextIndex = 0; + if (event.key === "End") nextIndex = buttons.length - 1; + const nextButton = buttons[nextIndex]; + applyLanguage(nextButton.dataset.language || nextButton.dataset.loginLanguage); + nextButton.focus(); + }); + }); +} + +function bindAuthenticationEvents() { + bindLanguageGroup("[data-login-language]"); + $("#loginForm").addEventListener("submit", submitLogin); + $("#passwordToggle").addEventListener("click", () => { + const input = $("#loginPassword"); + input.type = input.type === "password" ? "text" : "password"; + updatePasswordToggle(); + input.focus(); + }); + ["#loginUsername", "#loginPassword"].forEach(selector => { + $(selector).addEventListener("input", () => { + if (loginErrorKey) setLoginError(null); + }); + }); + $("#logoutButton").addEventListener("click", () => { void signOut(); }); +} + +function locale() { + return LANGUAGE_CONFIG[currentLanguage]?.intl || LANGUAGE_CONFIG.en.intl; +} + +function formatNumber(value) { + if (!Number.isFinite(value)) return "—"; + return new Intl.NumberFormat(locale()).format(value); +} + +function formatPercent(value) { + return new Intl.NumberFormat(locale(), { style: "percent", maximumFractionDigits: 1 }).format(Number.isFinite(value) ? value : 0); +} + +function iconChevron() { + return ''; +} + +function escapeHtml(value) { + return String(value).replace(/[&<>'"]/g, character => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[character]); +} + +function roomTypeLabel(type) { + const definition = ROOM_TYPE_DEFINITIONS[type]; + if (!definition) return type; + return `${type} — ${definition[currentLanguage] || definition.en}`; +} + +function roomTypeOption(type) { + return ``; +} + +function displayValue(value) { + return value === null || value === undefined || value === "" ? "—" : String(value); +} + +function ownerFor(id) { + return owners.find(owner => String(owner.id) === String(id)); +} + +function shortDate(isoDate) { + if (!isoDate) return "—"; + return new Intl.DateTimeFormat(locale(), { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }).format(new Date(`${isoDate}T00:00:00Z`)); +} + +function shortDateNoYear(isoDate) { + if (!isoDate) return "—"; + return new Intl.DateTimeFormat(locale(), { month: "short", day: "numeric", timeZone: "UTC" }).format(new Date(`${isoDate}T00:00:00Z`)); +} + +function plural(value, word) { + if (!Number.isFinite(value)) return "—"; + const units = { + en: { night: "night", room: "room", record: "record", account: "account" }, + zh: { night: "晚", room: "间房", record: "条记录", account: "个账户" }, + th: { night: "คืน", room: "ห้อง", record: "รายการ", account: "บัญชี" } + }[currentLanguage] || {}; + const unit = units[word] || word; + return currentLanguage === "en" + ? `${formatNumber(value)} ${unit}${value === 1 ? "" : "s"}` + : `${formatNumber(value)} ${unit}`; +} + +function getTier(type) { + if (["RM1", "RM2", "RM3", "RM4", "UG1", "UG2"].includes(type)) return 1; + if (["SU1", "SU2", "SU6"].includes(type)) return 2; + if (type === "SU3") return 3; + return null; +} + +function getAutomaticMultiplier(purchasedType, usedType) { + const purchasedTier = getTier(purchasedType); + const usedTier = getTier(usedType); + if (purchasedTier === null || usedTier === null) return null; + return Math.max(1, usedTier - purchasedTier + 1); +} + +function calculateNights(checkin, checkout) { + if (!checkin || !checkout) return 0; + const start = Date.parse(`${checkin}T00:00:00Z`); + const end = Date.parse(`${checkout}T00:00:00Z`); + return Math.round((end - start) / 86400000); +} + +function switchPage(pageName) { + const safePage = pageName === "owners" ? "owners" : "dashboard"; + $$(".page").forEach(page => page.classList.toggle("active", page.dataset.pageContent === safePage)); + $$(".nav-item").forEach(item => item.classList.toggle("active", item.dataset.page === safePage)); + closeMobileMenu(); + window.scrollTo({ top: 0, behavior: "smooth" }); +} + +function setOwnerWorkspaceView(viewName, { focus = false } = {}) { + const nextView = viewName === "usage" ? "usage" : "accounts"; + activeOwnerView = nextView; + $$("[data-owner-view]").forEach(tab => { + const isActive = tab.dataset.ownerView === nextView; + tab.classList.toggle("active", isActive); + tab.setAttribute("aria-selected", String(isActive)); + tab.tabIndex = isActive ? 0 : -1; + if (isActive && focus) tab.focus(); + }); + $$("[data-owner-view-panel]").forEach(panel => { + const isActive = panel.dataset.ownerViewPanel === nextView; + panel.classList.toggle("active", isActive); + panel.hidden = !isActive; + }); + if (nextView === "accounts") renderOwners(); + else renderStays(); +} + +function bindTableRowActivation(row, activate) { + let pointerStart = null; + + row.addEventListener("pointerdown", event => { + if (event.button !== 0) return; + pointerStart = { x: event.clientX, y: event.clientY }; + }); + + row.addEventListener("pointercancel", () => { + pointerStart = null; + }); + + row.addEventListener("click", event => { + const pointerMoved = pointerStart + && Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) > 4; + const selection = window.getSelection(); + const hasTextSelection = selection + && !selection.isCollapsed + && selection.toString().trim().length > 0; + const explicitAction = event.target.closest(".row-action"); + pointerStart = null; + + if (!explicitAction && (pointerMoved || hasTextSelection)) return; + activate(); + }); + + row.addEventListener("keydown", event => { + if (event.target !== row || !["Enter", " "].includes(event.key)) return; + event.preventDefault(); + activate(); + }); +} + +function renderOwners() { + const query = $("#ownerSearch").value.trim().toLowerCase(); + const type = $("#roomTypeFilter").value; + const filtered = owners.filter(owner => { + const haystack = `${owner.sourceNo} ${owner.transferDate} ${owner.name} ${owner.room} ${owner.purchasedType} ${owner.unit} ${owner.member}`.toLowerCase(); + return haystack.includes(query) && (!type || owner.purchasedType === type); + }); + + $("#ownerCount").textContent = t("accountsShown", { count: filtered.length }); + $("#accountTabCount").textContent = formatNumber(owners.length); + const databaseEmpty = IS_API_MODE && connectionState === "ready" && owners.length === 0; + $("#ownerTableBody").innerHTML = filtered.length ? filtered.map(owner => ` + + ${escapeHtml(displayValue(owner.sourceNo))} + ${shortDate(owner.transferDate)} + ${escapeHtml(owner.name)} + ${escapeHtml(owner.room)} + ${escapeHtml(owner.purchasedType)} + ${escapeHtml(owner.unit)} + ${escapeHtml(owner.member)} + ${plural(owner.remaining, "night")} + + `).join("") : `
${t(databaseEmpty ? "noDatabaseOwners" : "noOwnerAccountsFound")}${t(databaseEmpty ? "noDatabaseOwnersDescription" : "tryOwnerFilter")}
`; + + $$("#ownerTableBody tr[data-owner-id]").forEach(row => { + bindTableRowActivation(row, () => openOwnerDrawer(row.dataset.ownerId)); + }); +} + +function renderStays() { + const query = $("#staySearch").value.trim().toLowerCase(); + const type = $("#stayTypeFilter").value; + const filtered = usageRecordsInDisplayOrder(stayRecords.filter(record => { + const owner = ownerFor(record.ownerId); + if (!owner) return false; + const haystack = `${record.confirmation} ${owner.name} ${owner.room} ${owner.member} ${owner.unit} ${record.usedType} ${record.remark || ""}`.toLowerCase(); + return haystack.includes(query) && (!type || record.usedType === type); + })); + + $("#stayCount").textContent = t("recordsShown", { count: filtered.length }); + $("#usageTabCount").textContent = formatNumber(stayRecords.length); + const databaseEmpty = IS_API_MODE && connectionState === "ready" && stayRecords.length === 0; + $("#stayTableBody").innerHTML = filtered.length ? filtered.map(record => { + const owner = ownerFor(record.ownerId); + return ` + ${record.confirmation} + ${escapeHtml(owner.name)}${escapeHtml(t("roomMemberLine", { room: owner.room, member: owner.member }))} + ${shortDate(record.checkin)} + ${shortDate(record.checkout)} + ${record.nights} + ${record.deducted} + ${plural(record.balance, "night")} + ${escapeHtml(record.usedType)} + ${escapeHtml(record.remark || t("noRemark"))} + + `; + }).join("") : `
${t(databaseEmpty ? "noDatabaseUsage" : "noUsageRecordsFound")}${t(databaseEmpty ? "noDatabaseUsageDescription" : "tryUsageFilter")}
`; + + $$("#stayTableBody tr[data-owner-id]").forEach(row => { + bindTableRowActivation(row, () => openOwnerDrawer(row.dataset.ownerId)); + }); +} + +function renderRoomTypeMix() { + const ownerCounts = IS_API_MODE && dashboardData + ? Object.fromEntries(dashboardData.purchasedRoomTypes.map(item => [item.roomType, item.count])) + : owners.reduce((counts, owner) => { + counts[owner.purchasedType] = (counts[owner.purchasedType] || 0) + 1; + return counts; + }, {}); + const usedRoomNights = IS_API_MODE && dashboardData + ? Object.fromEntries(dashboardData.usedRoomTypes.map(item => [item.roomType, item.count])) + : stayRecords.reduce((counts, record) => { + counts[record.usedType] = (counts[record.usedType] || 0) + record.nights; + return counts; + }, {}); + const ownerTotal = IS_API_MODE && dashboardData ? dashboardData.ownerRooms : owners.length; + const usedTotal = Object.values(usedRoomNights).reduce((total, value) => total + value, 0); + const activeTypes = ROOM_TYPES.filter(type => ownerCounts[type] || usedRoomNights[type]); + + $("#roomTypeMixOwnerTotal").textContent = t("ownerRoomSeriesTotal", { count: formatNumber(ownerTotal) }); + $("#roomTypeMixUsedTotal").textContent = t("usedRoomNightSeriesTotal", { count: formatNumber(usedTotal) }); + $("#roomTypeMixChart").setAttribute("aria-label", t("roomTypeMixChartAria", { + ownerTotal: formatNumber(ownerTotal), + usedTotal: formatNumber(usedTotal) + })); + $("#roomTypeMixChart").innerHTML = activeTypes.map(type => { + const ownerCount = ownerCounts[type] || 0; + const usedCount = usedRoomNights[type] || 0; + const ownerShare = ownerTotal ? ownerCount / ownerTotal : 0; + const usedShare = usedTotal ? usedCount / usedTotal : 0; + const ownerPercent = formatPercent(ownerShare); + const usedPercent = formatPercent(usedShare); + const ownerShareWidth = `${Math.min(100, Math.max(0, ownerShare * 100)).toFixed(2)}%`; + const usedShareWidth = `${Math.min(100, Math.max(0, usedShare * 100)).toFixed(2)}%`; + + return `
+ ${escapeHtml(type)} +
+
+ + ${formatNumber(ownerCount)}${ownerPercent} +
+
+ + ${formatNumber(usedCount)}${usedPercent} +
+
+
`; + }).join(""); +} + +function renderMonthlyUse() { + if (!IS_API_MODE || !dashboardData) return; + const byMonth = new Map(dashboardData.monthlyUse.map(item => [item.month, item.use])); + const maximum = Math.max(1, ...byMonth.values()); + $$(".bar-item[data-month]").forEach(item => { + const month = Number(item.dataset.month); + const use = byMonth.get(month) || 0; + const height = `${Math.round((use / maximum) * 100)}%`; + const bar = item.querySelector("span"); + bar.style.setProperty("--height", height); + item.title = `${item.querySelector("small").textContent}: ${use}`; + }); +} + +function updateAggregateUI() { + $("#ownerRoomCount").textContent = formatNumber(IS_API_MODE && dashboardData ? dashboardData.ownerRooms : owners.length); + $("#accountTabCount").textContent = formatNumber(owners.length); + $("#usageTabCount").textContent = formatNumber(stayRecords.length); + $("#totalRemaining").textContent = formatNumber(aggregateRemaining); + $("#totalUsed").textContent = formatNumber(aggregateUsed); + $("#chartUsed").textContent = formatNumber(aggregateUsed); + $(".period-display strong").textContent = String(PERIOD_YEAR); +} + +function populateFormOptions() { + const ownerSelect = $("#ownerSelect"); + const usedTypeSelect = $("#usedTypeSelect"); + [ + [$("#roomTypeFilter"), "allRoomTypes"], + [$("#stayTypeFilter"), "allUsedRoomTypes"] + ].forEach(([select, allKey]) => { + const selected = select.value; + select.innerHTML = `${ROOM_TYPES.map(roomTypeOption).join("")}`; + select.value = ROOM_TYPES.includes(selected) ? selected : ""; + }); + const selectedOwner = ownerSelect.value || String(owners[0]?.id || ""); + const selectedType = usedTypeSelect.value || owners[0]?.purchasedType || ROOM_TYPES[0] || ""; + ownerSelect.innerHTML = owners.length + ? owners.map(owner => ``).join("") + : ``; + usedTypeSelect.innerHTML = ROOM_TYPES.map(roomTypeOption).join(""); + ownerSelect.value = owners.some(owner => String(owner.id) === selectedOwner) ? selectedOwner : String(owners[0]?.id || ""); + ownerSelect.disabled = owners.length === 0 || stayOwnerLocked; + usedTypeSelect.value = ROOM_TYPES.includes(selectedType) ? selectedType : owners[0]?.purchasedType || ROOM_TYPES[0] || ""; +} + +function renderAccountContext() { + const owner = ownerFor($("#ownerSelect").value); + if (!owner) { + $("#accountContext").innerHTML = `
${escapeHtml(t("noAccountsForUsage"))}
`; + return; + } + $("#accountContext").innerHTML = ` +
${t("room")}${owner.room}
+
${t("purchasedType")}${owner.purchasedType}
+
${t("memberNo")}${owner.member}
+
${t("remaining")}${plural(owner.remaining, "night")}
`; +} + +function calculateForm() { + const owner = ownerFor($("#ownerSelect").value) || owners[0] || null; + const checkin = $("#checkinInput").value; + const checkout = $("#checkoutInput").value; + const nights = calculateNights(checkin, checkout); + const usedType = $("#usedTypeSelect").value; + const confirmation = $("#confirmationInput").value.trim(); + if (!owner) { + $("#manualMultiplierField").hidden = true; + $("#manualMultiplier").required = false; + $("#ruleSource").textContent = t("automaticRule"); + $("#nightsInput").value = nights > 0 ? plural(nights, "night") : t("notCalculated"); + $("#calculationFormula").textContent = t("notCalculated"); + $("#deductionOutput").textContent = t("pending"); + $("#currentBalance").textContent = "—"; + $("#afterBalance").textContent = "—"; + $("#formError").textContent = t("noAccountsForUsage"); + $("#formError").hidden = false; + $("#saveStayButton").disabled = true; + return { owner: null, checkin, checkout, nights, usedType, multiplier: 0, manualMultiplier: null, deduction: 0, confirmation, valid: false }; + } + const autoMultiplier = getAutomaticMultiplier(owner.purchasedType, usedType); + const needsManual = autoMultiplier === null; + const multiplier = needsManual ? Number.parseInt($("#manualMultiplier").value, 10) || 0 : autoMultiplier; + const deduction = nights > 0 && multiplier > 0 ? nights * multiplier : 0; + const hasBalance = Number.isFinite(owner.remaining); + const after = hasBalance ? owner.remaining - deduction : null; + + $("#manualMultiplierField").hidden = !needsManual; + $("#manualMultiplier").required = needsManual; + $("#ruleSource").textContent = needsManual ? t("ac2ManualRule") : t("automaticRule"); + $("#nightsInput").value = nights > 0 ? plural(nights, "night") : t("notCalculated"); + $("#calculationFormula").textContent = `${plural(Math.max(nights, 0), "night")} × ${multiplier || t("pending")}`; + $("#deductionOutput").textContent = deduction ? plural(deduction, "night") : t("pending"); + $("#currentBalance").textContent = plural(owner.remaining, "night"); + $("#afterBalance").textContent = deduction ? plural(after, "night") : t("pending"); + + let error = ""; + if (!hasBalance) error = t("entitlementUnavailable"); + else if (checkin && checkout && nights <= 0) error = t("checkoutLater"); + else if (needsManual && !multiplier) error = t("selectAc2Multiplier"); + else if (deduction > owner.remaining) error = t("insufficientPrivileges", { required: plural(deduction, "night"), available: plural(owner.remaining, "night") }); + else if (confirmation && stayRecords.some(record => record.confirmation === confirmation)) error = t("duplicateConfirmation"); + + $("#formError").textContent = error; + $("#formError").hidden = !error; + + const valid = /^\d+$/.test(confirmation) && nights > 0 && multiplier > 0 && hasBalance && deduction <= owner.remaining && !error && !isSubmittingStay; + $("#saveStayButton").disabled = !valid; + return { + owner, + checkin, + checkout, + nights, + usedType, + multiplier, + manualMultiplier: needsManual ? multiplier : null, + deduction, + confirmation, + valid + }; +} + +function resetStayForm(ownerId = owners[0]?.id || "") { + $("#stayForm").reset(); + stayIdempotencyKey = IS_API_MODE ? window.crypto.randomUUID() : null; + $("#ownerSelect").value = String(ownerId); + $("#confirmationInput").value = ""; + $("#checkinInput").value = "2026-08-01"; + $("#checkoutInput").value = "2026-08-04"; + $("#usedTypeSelect").value = ownerFor(ownerId)?.purchasedType || ROOM_TYPES[0] || ""; + $("#manualMultiplier").value = ""; + renderAccountContext(); + calculateForm(); +} + +function updateStayDrawerCopy() { + const owner = ownerFor($("#ownerSelect").value) || owners[0] || null; + $("#drawerTitle").textContent = t("addUsageRecord"); + $("#drawerSubtitle").textContent = !owner + ? t("noAccountsForUsage") + : stayOwnerLocked + ? t("recordingRoom", { room: owner.room }) + : t("chooseOwnerReview"); +} + +function openStayDrawer(ownerId = owners[0]?.id || "", lockOwner = false) { + if (!owners.length || !ownerFor(ownerId)) return; + stayReturnOwnerId = lockOwner ? ownerId : null; + stayOwnerLocked = lockOwner; + closeOwnerDrawer(false); + resetStayForm(ownerId); + $("#ownerSelect").disabled = lockOwner; + $("#ownerLockNote").hidden = !lockOwner; + if (lockOwner) $("#ownerSelect").setAttribute("aria-describedby", "ownerLockNote"); + else $("#ownerSelect").removeAttribute("aria-describedby"); + updateStayDrawerCopy(); + $("#drawerScrim").hidden = false; + $("#stayDrawer").classList.add("open"); + $("#stayDrawer").setAttribute("aria-hidden", "false"); + document.body.style.overflow = "hidden"; + window.setTimeout(() => $("#confirmationInput").focus(), 220); +} + +function closeStayDrawer(restoreOwner = false) { + const ownerToRestore = restoreOwner ? stayReturnOwnerId : null; + $("#stayDrawer").classList.remove("open"); + $("#stayDrawer").setAttribute("aria-hidden", "true"); + $("#ownerSelect").disabled = owners.length === 0; + $("#ownerSelect").removeAttribute("aria-describedby"); + $("#ownerLockNote").hidden = true; + stayReturnOwnerId = null; + stayOwnerLocked = false; + if (!$("#ownerDrawer").classList.contains("open")) { + $("#drawerScrim").hidden = true; + document.body.style.overflow = ""; + } + if (ownerToRestore) window.setTimeout(() => openOwnerDrawer(ownerToRestore), 0); +} + +function renderOwnerDrawer(ownerId) { + const owner = ownerFor(ownerId); + if (!owner) return; + const records = usageRecordsInDisplayOrder(stayRecords.filter(record => String(record.ownerId) === String(ownerId))); + $("#ownerDrawerTitle").textContent = owner.name; + $("#ownerDrawerSubtitle").textContent = t("transferDateSubtitle", { date: shortDate(owner.transferDate) }); + $("#ownerDrawerBody").innerHTML = ` +
+
${t("balance")}${formatNumber(owner.remaining)}${t("nightsAvailable")}
+
+
${t("annualGrant")}15
+
${t("usedIn2026")}${owner.used}
+
+
+

${t("accountInformation")}

${t(IS_API_MODE ? "databaseSource" : "workbookSource")}
+
+
${t("roomNo")}${escapeHtml(owner.room)}
+
${t("purchasedRoomType")}${escapeHtml(owner.purchasedType)}
+
${t("unitNo")}${escapeHtml(owner.unit)}
+
${t("memberNo")}${escapeHtml(owner.member)}
+
+
+

${t("usageHistory")}

${plural(records.length, "record")}
+ ${records.length ? `
+ + ${records.map(record => ` + + + + + + + + + `).join("")} +
${t("confirmationNo")}${t("checkIn")}${t("checkOut")}${t("night")}${t("drawerUsingNights")}${t("usedRoomType")}${t("balance")}${t("remark")}
${record.confirmation}${shortDate(record.checkin)}${shortDate(record.checkout)}${record.nights}${record.deducted}${escapeHtml(record.usedType)}${plural(record.balance, "night")}${escapeHtml(record.remark || t("noRemark"))}
` : `
${t("noUsage2026")}${t("addFirstUsage")}
`} +
`; +} + +function openOwnerDrawer(ownerId) { + if (!ownerFor(ownerId)) return; + detailOwnerId = ownerId; + renderOwnerDrawer(ownerId); + $("#drawerScrim").hidden = false; + $("#ownerDrawer").classList.add("open"); + $("#ownerDrawer").setAttribute("aria-hidden", "false"); + document.body.style.overflow = "hidden"; + updateUsageEntryAvailability(); +} + +function closeOwnerDrawer(hideScrim = true) { + $("#ownerDrawer").classList.remove("open"); + $("#ownerDrawer").setAttribute("aria-hidden", "true"); + if (hideScrim && !$("#stayDrawer").classList.contains("open")) { + $("#drawerScrim").hidden = true; + document.body.style.overflow = ""; + } + updateUsageEntryAvailability(); +} + +function renderToast() { + if (!lastToast) return; + $("#toast strong").textContent = t(lastToast.titleKey, lastToast.params); + $("#toast span").textContent = t(lastToast.messageKey, lastToast.params); +} + +function showToast(titleKey, messageKey, params = {}) { + window.clearTimeout(toastTimer); + lastToast = { titleKey, messageKey, params }; + renderToast(); + $("#toast").hidden = false; + toastTimer = window.setTimeout(() => { $("#toast").hidden = true; }, 3600); +} + +function updateDashboardAfterCreatedUsage(record) { + if (!dashboardData) return; + dashboardData.remainingPrivileges = Math.max(0, dashboardData.remainingPrivileges - record.deducted); + dashboardData.used += record.deducted; + const usedType = dashboardData.usedRoomTypes.find(item => item.roomType === record.usedType); + if (usedType) usedType.count += record.nights; + else dashboardData.usedRoomTypes.push({ roomType: record.usedType, count: record.nights }); + const month = Number(record.checkin.slice(5, 7)); + const monthly = dashboardData.monthlyUse.find(item => item.month === month); + if (monthly) monthly.use += record.deducted; + else dashboardData.monthlyUse.push({ month, use: record.deducted }); +} + +function usageSaveErrorKey(error) { + return { + INSUFFICIENT_BALANCE: "usageSaveInsufficient", + CONFLICT: "usageSaveConflict", + NOT_FOUND: "usageSaveNotFound", + BUSINESS_RULE_VIOLATION: "usageSaveRuleViolation", + VALIDATION_ERROR: "usageSaveRuleViolation", + NETWORK_ERROR: "usageSaveNetwork", + REQUEST_ABORTED: "usageSaveNetwork" + }[error?.code] || "usageSaveFailed"; +} + +async function submitStay(event) { + event.preventDefault(); + const calculation = calculateForm(); + if (!calculation.valid) return; + + let createdRecord; + let errorKey = null; + let authenticationRequired = false; + isSubmittingStay = true; + $("#saveStayButton").textContent = t("savingRecord"); + $("#saveStayButton").disabled = true; + updateUsageEntryAvailability(); + + try { + if (IS_API_MODE) { + const response = await API_CLIENT.createUsageRecord({ + ownerAccountId: calculation.owner.id, + confirmationNo: calculation.confirmation, + checkIn: calculation.checkin, + checkOut: calculation.checkout, + usedRoomType: calculation.usedType, + manualMultiplier: calculation.manualMultiplier, + remark: $("#remarkInput").value.trim(), + idempotencyKey: stayIdempotencyKey || (stayIdempotencyKey = window.crypto.randomUUID()) + }); + createdRecord = mapApiUsage(response); + calculation.owner.remaining = createdRecord.balance; + stayRecords = [createdRecord, ...stayRecords.filter(record => record.id !== createdRecord.id)]; + updateOwnerUsageTotals(); + aggregateRemaining = Math.max(0, aggregateRemaining - createdRecord.deducted); + aggregateUsed += createdRecord.deducted; + updateDashboardAfterCreatedUsage(createdRecord); + } else { + calculation.owner.remaining -= calculation.deduction; + calculation.owner.used += calculation.deduction; + aggregateRemaining -= calculation.deduction; + aggregateUsed += calculation.deduction; + createdRecord = { + confirmation: calculation.confirmation, + ownerId: calculation.owner.id, + checkin: calculation.checkin, + checkout: calculation.checkout, + nights: calculation.nights, + usedType: calculation.usedType, + multiplier: calculation.multiplier, + deducted: calculation.deduction, + balance: calculation.owner.remaining, + remark: $("#remarkInput").value.trim(), + recordSource: "manual", + importOrder: null, + enteredAt: new Date().toISOString(), + entrySequence: nextManualEntrySequence++ + }; + stayRecords.push(createdRecord); + } + } catch (error) { + if (isUnauthorized(error)) authenticationRequired = true; + else errorKey = usageSaveErrorKey(error); + } finally { + isSubmittingStay = false; + $("#saveStayButton").textContent = t("saveRecord"); + updateUsageEntryAvailability(); + } + + if (authenticationRequired) { + showLoginScreen("sessionExpired"); + return; + } + + if (errorKey) { + calculateForm(); + $("#formError").textContent = t(errorKey); + $("#formError").hidden = false; + return; + } + + refreshDataViews(); + closeStayDrawer(false); + switchPage("owners"); + setOwnerWorkspaceView("accounts"); + window.setTimeout(() => openOwnerDrawer(calculation.owner.id), 0); + showToast("usageSaved", "usageSavedMessage", { deduction: plural(createdRecord.deducted, "night"), room: calculation.owner.room }); +} + +function updateMenuAria() { + const isOpen = $("#sidebar").classList.contains("open"); + $("#menuButton").setAttribute("aria-label", t(isOpen ? "closeNavigation" : "openNavigation")); +} + +function openMobileMenu() { + $("#sidebar").classList.add("open"); + $("#mobileScrim").hidden = false; + $("#menuButton").setAttribute("aria-expanded", "true"); + updateMenuAria(); +} + +function closeMobileMenu() { + $("#sidebar").classList.remove("open"); + $("#mobileScrim").hidden = true; + $("#menuButton").setAttribute("aria-expanded", "false"); + updateMenuAria(); +} + +function applyStaticTranslations() { + $$("[data-i18n]").forEach(element => { + element.textContent = t(element.dataset.i18n); + }); + $$("[data-i18n-placeholder]").forEach(element => { + element.setAttribute("placeholder", t(element.dataset.i18nPlaceholder)); + }); + $$("[data-i18n-aria-label]").forEach(element => { + element.setAttribute("aria-label", t(element.dataset.i18nAriaLabel)); + }); +} + +function applyLanguage(language) { + currentLanguage = SUPPORTED_LANGUAGES.includes(language) ? language : "en"; + document.documentElement.lang = LANGUAGE_CONFIG[currentLanguage].html; + document.documentElement.dataset.locale = currentLanguage; + document.title = t("appTitle"); + const metaDescription = $('meta[name="description"]'); + if (metaDescription) metaDescription.setAttribute("content", t("metaDescription")); + + applyStaticTranslations(); + $$("[data-language]").forEach(button => { + const isActive = button.dataset.language === currentLanguage; + button.classList.toggle("active", isActive); + button.setAttribute("aria-pressed", String(isActive)); + }); + $$("[data-login-language]").forEach(button => { + const isActive = button.dataset.loginLanguage === currentLanguage; + button.classList.toggle("active", isActive); + button.setAttribute("aria-pressed", String(isActive)); + }); + updatePasswordToggle(); + updateAuthenticationControls(); + if (loginErrorKey) setLoginError(loginErrorKey); + if (sessionErrorKey && $("#sessionError")) $("#sessionError").textContent = t(sessionErrorKey); + + if (!workspaceInitialized) return; + renderConnectionState(); + refreshDataViews(); + updateStayDrawerCopy(); + updateMenuAria(); + + if ($("#ownerDrawer").classList.contains("open") && detailOwnerId) renderOwnerDrawer(detailOwnerId); + if (!$("#toast").hidden) renderToast(); +} + +function bindEvents() { + $$(".nav-item").forEach(button => button.addEventListener("click", () => { + switchPage(button.dataset.page); + if (button.dataset.page === "owners") setOwnerWorkspaceView("accounts"); + })); + $$("[data-owner-view]").forEach((tab, index, tabs) => { + tab.addEventListener("click", () => setOwnerWorkspaceView(tab.dataset.ownerView)); + tab.addEventListener("keydown", event => { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + let nextIndex = index; + if (event.key === "ArrowLeft") nextIndex = (index - 1 + tabs.length) % tabs.length; + if (event.key === "ArrowRight") nextIndex = (index + 1) % tabs.length; + if (event.key === "Home") nextIndex = 0; + if (event.key === "End") nextIndex = tabs.length - 1; + setOwnerWorkspaceView(tabs[nextIndex].dataset.ownerView, { focus: true }); + }); + }); + bindLanguageGroup("[data-language]"); + $$(".open-stay-drawer").forEach(button => button.addEventListener("click", () => { + if (owners[0]) openStayDrawer(owners[0].id, false); + })); + $("#closeDrawer").addEventListener("click", () => closeStayDrawer(true)); + $("#cancelDrawer").addEventListener("click", () => closeStayDrawer(true)); + $("#drawerScrim").addEventListener("click", () => { + closeStayDrawer(false); + closeOwnerDrawer(); + }); + $("#closeOwnerDrawer").addEventListener("click", () => closeOwnerDrawer()); + $("#detailAddStay").addEventListener("click", () => { + const ownerId = detailOwnerId || owners[0]?.id; + if (ownerId) openStayDrawer(ownerId, true); + }); + $("#stayForm").addEventListener("submit", submitStay); + $("#retryApiButton").addEventListener("click", () => { void loadApiData(); }); + $("#ownerSelect").addEventListener("change", () => { + renderAccountContext(); + calculateForm(); + }); + ["#confirmationInput", "#checkinInput", "#checkoutInput", "#usedTypeSelect", "#manualMultiplier"].forEach(selector => $(selector).addEventListener("input", calculateForm)); + $("#confirmationInput").addEventListener("input", event => { + event.target.value = event.target.value.replace(/\D/g, ""); + }); + $("#ownerSearch").addEventListener("input", renderOwners); + $("#roomTypeFilter").addEventListener("change", renderOwners); + $("#staySearch").addEventListener("input", renderStays); + $("#stayTypeFilter").addEventListener("change", renderStays); + document.addEventListener("keydown", event => { + if (event.key === "Escape") { + if ($("#stayDrawer").classList.contains("open")) closeStayDrawer(true); + else if ($("#ownerDrawer").classList.contains("open")) closeOwnerDrawer(); + closeMobileMenu(); + } + }); + $("#menuButton").addEventListener("click", () => $("#sidebar").classList.contains("open") ? closeMobileMenu() : openMobileMenu()); + $("#mobileScrim").addEventListener("click", closeMobileMenu); +} + +async function initialize() { + bindAuthenticationEvents(); + applyLanguage("en"); + showLoginChecking(); + if (!API_CLIENT) { + showLoginScreen("authServiceUnavailable"); + return; + } + try { + const session = await API_CLIENT.getSession(); + if (session?.authenticated) await enterWorkspace(session); + else showLoginScreen(); + } catch { + showLoginScreen("authServiceUnavailable"); + } +} + +void initialize(); diff --git a/backend-data-model.md b/backend-data-model.md new file mode 100644 index 0000000..34bd500 --- /dev/null +++ b/backend-data-model.md @@ -0,0 +1,390 @@ +# CONDO 后端数据库模型 + +更新日期:2026-07-31 +业务依据:当前前端页面、字段和运行规则 + +## 1. 设计原则 + +- 当前前端是生产数据库和 API 的业务规格。 +- 生产表与本地导入预演分开;历史批次通过受控 importer 写入生产表,不把原始 Excel 作为数据库运行时依赖。 +- 所有新对象仅位于现有 `booking_test` 数据库的 `condon` schema;不改动其他 schema 的对象、数据、角色或权限。 +- 一个 Usage Record 代表一间房的一次使用。 +- 每个业主房号独立管理权益余额。 +- 所有 Use 和 Balance 都由后端计算,不能信任前端传来的结果。 +- V1 只实现实时新增使用记录;取消、冲正、调整、账户状态和用户角色不预建。 + +## 2. 当前前端字段 + +### 2.1 Owner Account + +- No. +- Transfer Date +- Name +- Room No. +- Room Type +- Unit No. +- Member No. +- Remaining stay privileges + +### 2.2 Usage History + +1. Confirmation No. +2. Check-in +3. Check-out +4. Night +5. Use +6. Balance +7. Used Room Type +8. Remark + +关系为: + +- `Night = Check-out - Check-in` +- `Use = Night × Multiplier` +- `Balance After = Balance Before - Use` + +历史导入另外保留 `Room`、`Total` 和来源定位字段;前端新建记录仍默认 Room=1,余额由后端维护。 + +## 3. 房型使用规则 + +### 3.0 酒店房型代码定义 + +| 代码 | 酒店定义 | +| --- | --- | +| RM1 | No balcony TWN(4+4 F) | +| RM2 | Superior King(6F) | +| RM3 | Superior TWN(4+4 F) | +| RM4 | Superior TWN(6+4 F) | +| UG1 | Deluxe King(6F) | +| UG2 | Deluxe TWN(6+4 F) | +| SU1 | Junior Suite King(6F) | +| SU2 | Junior Suite King(6F),Pool view | +| SU6 | Junior Suite TWN(4+4 F) | +| SU3 | Two bedroom / Family room(1 room King + 1 room TWN) | +| AC1 | Handicap King | +| AC2 | Handicap TWN | + +历史源文件中的泛称(如 `Superior Room`、`Deluxe Room`、`Junior Suite (One Bedroom)`)可能缺少 King/TWN/Pool view 信息,不能仅凭文字强制映射;历史原文应保留。当前已部署模型只初始化 AC2,AC1 是否加入生产代码及其扣减规则需单独确认。 + +### 3.1 房型分级 + +| Tier | 房型 | +| --- | --- | +| 1 | RM1、RM2、RM3、RM4、UG1、UG2 | +| 2 | SU1、SU2、SU6 | +| 3 | SU3 | + +AC2 不属于自动分级,继续采用当前前端的人工倍数规则。 + +### 3.2 自动倍数矩阵 + +| 购买房型 | 使用 RM/UG | 使用 SU1/SU2/SU6 | 使用 SU3 | +| --- | ---: | ---: | ---: | +| RM/UG | 1 | 2 | 3 | +| SU1/SU2/SU6 | 1 | 1 | 2 | +| SU3 | 1 | 1 | 1 | + +等价计算公式: + +```text +Multiplier = max(1, Used Tier - Purchased Tier + 1) +``` + +后端必须根据 Owner Account 的 Purchased Room Type 和 Usage Record 的 Used Room Type 重新计算倍数。普通请求不能直接指定 multiplier。 + +购买或使用 AC2 时请求可以携带人工 multiplier,后端校验范围为当前前端支持的 1–3。 + +新业务 Usage Record 保存实际使用的 `applied_multiplier` 和 `rule_version`;legacy 导入记录使用 `applied_multiplier=NULL`、`rule_version=legacy-source`,以源 Use/Balance 为事实。 + +## 4. 推荐数据表 + +```mermaid +erDiagram + ROOM_TYPES ||--o{ OWNER_ACCOUNTS : purchased_as + OWNER_ACCOUNTS ||--o{ ENTITLEMENT_PERIODS : owns + BOOKINGS ||--o{ USAGE_RECORDS : contains + OWNER_ACCOUNTS ||--o{ USAGE_RECORDS : creates + ROOM_TYPES ||--o{ USAGE_RECORDS : used_as + ENTITLEMENT_PERIODS ||--o{ USAGE_RECORDS : applies_to + ENTITLEMENT_PERIODS ||--o{ ENTITLEMENT_LEDGER : records + USAGE_RECORDS ||--o| ENTITLEMENT_LEDGER : deducts +``` + +### 4.0 `condon.bookings` + +一条 Confirmation 对应一条 booking;同一 booking 可以关联多条 `usage_records`。因此 `usage_records.confirmation_no` 不设唯一约束,而是引用本表主键。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `confirmation_no` | `varchar(64)` PK | 预订 Confirmation,当前新业务要求纯数字 | +| `created_at` | `timestamptz` | 首次出现时间 | + +### 4.1 `condon.room_types` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `code` | `varchar(16)` PK | RM3、SU1、AC2 等 | +| `entitlement_tier` | `smallint` nullable | 自动规则为 1–3;AC2 为空 | +| `requires_manual_multiplier` | `boolean` | AC2 为 true,其他房型为 false | +| `created_at` | `timestamptz` | 创建时间 | +| `updated_at` | `timestamptz` | 更新时间 | + +约束: + +- 自动房型必须 `entitlement_tier IN (1, 2, 3)` 且 `requires_manual_multiplier = false`。 +- 人工房型必须 `entitlement_tier IS NULL` 且 `requires_manual_multiplier = true`。 +- 初始化代码仅包含 RM1、RM2、RM3、RM4、UG1、UG2、SU1、SU2、SU6、SU3、AC2。 + +### 4.2 `condon.owner_accounts` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | `uuid` PK | 内部不可变主键 | +| `account_no` | `integer` nullable | 前端 No. | +| `transfer_date` | `date` nullable | Transfer Date | +| `owner_name` | `text` | 前端显示名称 | +| `room_no` | `varchar(32)` | 业主房号 | +| `purchased_room_type_code` | FK | 购买房型 | +| `unit_no` | `varchar(32)` | Unit No. | +| `member_no` | `varchar(32)` | Member No.,按文本保存 | +| `created_at` | `timestamptz` | 创建时间 | +| `updated_at` | `timestamptz` | 更新时间 | + +约束: + +- `account_no` 在非空时唯一。 +- `room_no` 唯一。 +- `purchased_room_type_code` 引用 `condon.room_types(code)`。 +- 姓名、Unit No.、Member No. 不作为内部主键。 +- 本表不保存余额;余额的单一权威来源是 `condon.entitlement_periods.current_balance`。 + +### 4.3 `condon.entitlement_periods` + +每个 Owner Account 每个权益年度一条记录。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | `uuid` PK | 权益期间 ID | +| `owner_account_id` | FK | 所属业主账户 | +| `period_year` | `smallint` | 权益年度 | +| `period_start` | `date` | 当前规则为 1 月 1 日 | +| `period_end` | `date` | 当前规则为 12 月 31 日 | +| `annual_grant` | `integer` | 当前规则为 15 晚 | +| `carry_forward` | `integer` | 上期结转 | +| `current_balance` | `integer` | 唯一权威余额缓存 | +| `row_version` | `integer` | 防止并发重复扣减 | +| `created_at` | `timestamptz` | 创建时间 | +| `updated_at` | `timestamptz` | 更新时间 | + +约束: + +- `(owner_account_id, period_year)` 唯一。 +- `period_start` 和 `period_end` 必须分别为该年的 1 月 1 日和 12 月 31 日。 +- `annual_grant >= 0`。 +- `carry_forward >= 0`。 +- `current_balance >= 0`。 +- `row_version >= 1`。 + +`current_balance` 用于快速显示;所有余额变化必须与 ledger 在同一事务内完成。 + +### 4.4 `condon.usage_records` + +一行对应前端 Usage History 的一行。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | `uuid` PK | 内部记录 ID | +| `owner_account_id` | FK | 所属 Owner Account | +| `entitlement_period_id` | FK | 扣减的权益年度 | +| `confirmation_no` | `varchar(64)` | Confirmation No. | +| `check_in` | `date` | Check-in | +| `check_out` | `date` | Check-out | +| `night_count` | `integer` | Night | +| `used_room_type_code` | FK nullable | 可映射到标准代码的 Used Room Type;历史泛称可为空 | +| `raw_used_room_type` | `text` | 源文件原始房型文本 | +| `applied_multiplier` | `smallint nullable` | 新记录为 1–3;legacy 历史统一为空 | +| `rule_version` | `varchar(32)` | 规则版本 | +| `use_nights` | `integer` | Use | +| `balance_before` | `integer` | 扣减前余额 | +| `balance_after` | `integer` | Balance | +| `room_count` | `integer` | 源历史 Room;当前最新批次均为 1 | +| `remark` | `text` | Remark | +| `idempotency_key` | `uuid` | 防止重复请求 | +| `source_sheet` | `varchar(128) nullable` | Excel 子表名 | +| `source_row` | `integer nullable` | Excel 行号 | +| `source_sequence` | `integer nullable` | 子表 No.,保留源顺序 | +| `import_batch` | `varchar(128) nullable` | 导入批次标识 | +| `created_at` | `timestamptz` | 录入时间 | + +约束: + +- `confirmation_no` 引用 `condon.bookings`,满足当前前端的纯数字规则;同一号允许多条 usage。 +- `check_out > check_in`。 +- `night_count = check_out - check_in`。 +- 新记录 `applied_multiplier BETWEEN 1 AND 3`,且 `use_nights = night_count × applied_multiplier`。 +- legacy 记录 `applied_multiplier IS NULL`,源 `Use`、`Total`、`Balance` 原样保存。 +- `balance_before >= use_nights`。 +- `balance_after = balance_before - use_nights`。 +- `balance_after >= 0`。 +- `idempotency_key` 唯一。 +- Check-in 和 Check-out 必须在同一个权益年度内;跨年度请求在 V1 明确拒绝。 + +### 4.5 `condon.entitlement_ledger` + +保存每一次年度新增、结转和使用扣减。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | `uuid` PK | 流水 ID | +| `entitlement_period_id` | FK | 所属权益期间 | +| `usage_record_id` | FK nullable | 使用扣减对应的记录 | +| `entry_type` | `varchar(20)` | annual_grant、carry_forward、usage | +| `delta_nights` | `integer` | 增加为正,扣减为负 | +| `balance_before` | `integer` | 变动前余额 | +| `balance_after` | `integer` | 变动后余额 | +| `occurred_at` | `timestamptz` | 发生时间 | + +约束: + +- `balance_after = balance_before + delta_nights`。 +- `balance_after >= 0`。 +- 一条 Usage Record 只能产生一笔 usage debit。 +- annual_grant 和非零 carry_forward 为正数,usage 为负数。 +- usage 必须引用 Usage Record;annual_grant 和 carry_forward 不引用 Usage Record。 + +### 4.6 `condon.schema_migrations` + +仅记录 `condon` 自身的迁移版本,不借用 `public`。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `version` | `varchar(64)` PK | 迁移版本 | +| `checksum` | `varchar(64)` | SQL 内容校验值 | +| `applied_at` | `timestamptz` | 应用时间 | + +导入批次和拒绝行保存在仓库内的本地审计产物中;数据库只保存已接受记录的 `import_batch` 与来源定位字段。 + +## 5. 后端保存事务 + +前端提交: + +```json +{ + "ownerAccountId": "uuid", + "confirmationNo": "26090001", + "checkIn": "2026-09-01", + "checkOut": "2026-09-04", + "usedRoomType": "SU1", + "manualMultiplier": null, + "remark": "", + "idempotencyKey": "uuid" +} +``` + +后端处理: + +1. 校验 idempotency key 是否已处理。 +2. 校验 Confirmation No. 为纯数字且未使用。 +3. 读取 Owner Account、Purchased Room Type 和当前权益期间。 +4. 计算 Night。 +5. 根据购买/使用房型 tier 计算 multiplier;AC2 校验人工 multiplier。 +6. 计算 `Use = Night × Multiplier`。 +7. 开启事务并锁定对应 entitlement period,同时检查 row version。 +8. 校验余额足够。 +9. 写入 Usage Record 和 ledger debit;Confirmation 通过 booking 1:N 关系复用。 +10. 更新 current balance 和 row version。 +11. 提交事务并返回完整 Usage History 数据。 + +任一步失败都整体回滚,避免出现“余额已扣但记录未保存”或相反的情况。 + +前端传来的 Night、Use、Balance 和自动 multiplier 只用于即时预览,不能作为后端权威值。 + +数据库函数: + +- `condon.calculate_multiplier(...)`:返回自动倍率或校验 AC2 手动倍率。 +- `condon.open_entitlement_period(...)`:按 15 晚年度新增和上一年度余额结转建立新期间,写入年度新增 ledger,并在结转大于 0 时写入结转 ledger。 +- `condon.create_usage_record(...)`:完成计算、行锁、余额验证、usage/ledger 写入和余额更新。 + +三个函数均使用显式 schema 名、固定安全 `search_path` 和调用者权限,不修改角色或全局权限。 + +## 6. API 读取模型 + +### Owner Accounts + +返回: + +- `id` +- `accountNo` +- `transferDate` +- `name` +- `roomNo` +- `purchasedRoomType` +- `unitNo` +- `memberNo` +- `remainingStayPrivileges` + +### Usage History + +返回: + +- `id` +- `confirmationNo` +- `ownerAccountId` +- `ownerName` +- `ownerRoomNo` +- `checkIn` +- `checkOut` +- `night` +- `use` +- `balance` +- `usedRoomType` +- `remark` +- `createdAt` + +这组返回值可以直接支持全局 Usage History、账户详情、Confirmation 搜索和当前排序规则。 + +## 7. Dashboard 计算 + +- Owner rooms:Owner Account 数量。 +- Remaining privileges:当前 entitlement period 的 `current_balance` 总和。 +- Used this year:当前期间 Usage Record 的 `use_nights` 总和。 +- Room Type:按 Owner Account 的 purchased room type 计数。 +- Used Room Type:按 Usage Record 的 `night_count` 汇总实际房晚;一条记录代表一间房,所以不乘 Room 数量。 +- 月度 Use:按 Check-in 月份汇总 `use_nights`。 + +所有 Dashboard 指标由同一权益期间和已保存的 Usage Record 生成,不能继续使用静态图表值。 + +## 8. 推荐索引 + +- `owner_accounts(account_no)` 唯一(允许多个 NULL)。 +- `owner_accounts(room_no)` 唯一。 +- `owner_accounts(member_no)`。 +- `owner_accounts(purchased_room_type_code)`。 +- `entitlement_periods(owner_account_id, period_year)` 唯一。 +- `bookings(confirmation_no)` 主键;usage 通过 Confirmation 外键关联。 +- `usage_records(import_batch, source_sheet, source_row)` 部分唯一索引。 +- `usage_records(owner_account_id, created_at, id)`。 +- `usage_records(entitlement_period_id)`。 +- `usage_records(used_room_type_code, check_in)`。 +- `entitlement_ledger(entitlement_period_id, occurred_at, id)`。 +- `entitlement_ledger(usage_record_id)` 唯一(允许 annual_grant/carry_forward 为 NULL)。 + +## 9. V1 明确不预建的规则 + +1. Usage Record 取消、冲正和人工余额调整。 +2. 房屋转让时的余额归属与历史账户处理。 +3. 真实用户登录、角色和操作人追踪。 +4. 跨权益年度的一次使用;V1 返回明确错误。 +5. 新历史批次的业务纠正与房型映射;已确认的 `(2)` 批次已导入,后续批次继续走预演流程。 + +这些能力以后通过独立迁移扩展,不在基础表中预先加入未经确认的字段。 + +## 10. 推荐实施顺序 + +1. 只读盘点 `booking_test`,确认目标 schema 和业务表状态。 +2. 本地编写版本化迁移、精确回滚和 SQL allowlist 安全检查。 +3. 应用 `001_create_condon_schema` 与 `002_legacy_import_and_bookings`。 +4. 生成最新工作簿本地导入预演批次,排除无 Confirmation 行并逐条校验。 +5. 在空业务表中单事务导入 owner、权益期间、booking、legacy usage 和 ledger。 +6. 通过只读逐条比对及 API smoke 后再开放前端读取。 +7. 新增 usage 继续只调用数据库计算倍率的 v2 写入路径。 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..9bf0bb1 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,20 @@ +NODE_ENV=development +API_HOST=127.0.0.1 +API_PORT=3000 +CORS_ORIGINS=http://127.0.0.1:4173,http://localhost:4173 +LOG_LEVEL=info + +# Local operator login. Override both values through deployment secrets in production. +AUTH_USERNAME=wyndhamcondon +AUTH_PASSWORD=wyndhamcondon +AUTH_SESSION_TTL_HOURS=12 +AUTH_COOKIE_SECURE=false + +DB_HOST=replace-with-database-host +DB_PORT=5432 +DB_USER=replace-with-database-user +DB_PASSWORD=replace-with-database-password +DB_NAME=booking_test +DB_SSL=false +DB_POOL_MAX=10 +DB_STATEMENT_TIMEOUT_MS=15000 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..72caf49 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.env +.env.* +!.env.example +coverage/ +dist/ diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..f28b825 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,143 @@ +# CONDO Backend + +TypeScript/Fastify API and PostgreSQL database layer for the current CONDO owner-account frontend. + +## Current state + +- Database: existing `booking_test`. +- Isolated schema: `condon`. +- Migrations: `001_create_condon_schema`, `002_legacy_import_and_bookings`. +- Reference data: 11 room types. +- Imported latest `(2)` batch: 388 owners, 389 periods, 155 bookings, 157 usage records and 547 ledger rows. +- Historical data import: `legacy-016dc36d15cc40a5` completed after local preflight and read-only verification. +- Existing `booking`, `finance`, `ingestion` and `public` objects were not changed. +- Operator access uses an expiring opaque server session and an HttpOnly cookie; credentials are verified only in the backend. + +## Database objects + +| Object | Purpose | +| --- | --- | +| `condon.room_types` | Room codes, automatic tiers and AC2 manual-rule marker | +| `condon.owner_accounts` | One independent account per owner room | +| `condon.entitlement_periods` | Annual grant, carry-forward and the single authoritative balance | +| `condon.bookings` | Unique Confirmation booking header; usage is 1:N | +| `condon.usage_records` | Confirmation, stay dates, Night, multiplier, Use and post-use Balance | +| `condon.entitlement_ledger` | Annual grant, carry-forward and usage balance movements | +| `condon.schema_migrations` | Migrations belonging only to `condon` | + +The database functions are: + +- `condon.calculate_multiplier` +- `condon.open_entitlement_period` +- `condon.create_usage_record` +- `condon.create_usage_record_v2` + +`create_usage_record_v2` is the authoritative new-record write path. It calculates Night, Multiplier, Use and Balance, locks the entitlement period and writes the usage row, ledger row and new balance atomically. Historical rows use `legacy-source`, preserve source Use/Balance/Room/raw room type, and may have a null multiplier. + +## API + +| Method | Path | Purpose | +| --- | --- | --- | +| POST | `/auth/login` | Verify operator credentials and create a 12-hour session | +| GET | `/auth/session` | Check the current browser session without exposing the cookie | +| POST | `/auth/logout` | Revoke the current session and expire its cookie | +| GET | `/health` | Database/schema/migration health | +| GET | `/room-types` | Room-type rule metadata | +| GET | `/owner-accounts` | Searchable, paginated account list | +| GET | `/owner-accounts/:id` | Account detail and period balance | +| GET | `/usage-records` | Searchable, paginated usage history | +| POST | `/usage-records` | Transactional usage deduction | +| GET | `/dashboard` | Annual totals and room-type/month aggregates | + +Runtime OpenAPI documentation is available at `/docs`. + +Only the three `/auth/*` routes and CORS preflight are public. Health, OpenAPI documentation and every business route require the `condon_session` cookie. Session tokens are cryptographically random, stored only as SHA-256 keys in backend memory, and expire after the configured TTL or logout. + +Example POST body: + +```json +{ + "ownerAccountId": "00000000-0000-4000-8000-000000000001", + "confirmationNo": "26090001", + "checkIn": "2026-09-01", + "checkOut": "2026-09-04", + "usedRoomType": "SU1", + "manualMultiplier": null, + "remark": "", + "idempotencyKey": "00000000-0000-4000-8000-000000000002" +} +``` + +The request does not accept Night, Use, Balance or an automatic multiplier. PostgreSQL derives them from the account, dates and room rules. + +## Configuration + +Use runtime environment variables or a deployment secret manager. Do not commit live credentials. + +Required variables: + +- `DB_HOST` +- `DB_PORT` +- `DB_USER` +- `DB_PASSWORD` +- `DB_NAME=booking_test` + +Optional variables and safe placeholders are documented in [.env.example](./.env.example). The application refuses to start if `DB_NAME` is anything other than `booking_test`. + +Authentication defaults for this deployment are `wyndhamcondon / wyndhamcondon`. Override `AUTH_USERNAME`, `AUTH_PASSWORD`, `AUTH_SESSION_TTL_HOURS` and `AUTH_COOKIE_SECURE` through runtime secrets for another environment. Secure cookies default on automatically when `NODE_ENV=production`. + +## Local commands + +```bash +npm install +npm run typecheck +npm test +npm run build +npm run preview:history:check +npm run preview:history +npm run dev +``` + +The API listens on `127.0.0.1:3000` by default. The default CORS allowlist accepts the existing frontend at `127.0.0.1:4173` and `localhost:4173`. + +`preview:history:check` validates the retained import source hash and the fixed 388 owner / 157 usage / 438 used / 5,394 remaining totals without opening a port. `preview:history` serves that exact snapshot through the normal Fastify authentication and API routes without connecting to PostgreSQL. It is intended for safe local preview when remote database secrets are not present; any new preview records and balance changes are memory-only and reset when the process stops. + +Database operator commands: + +```bash +npm run db:check-migrations +npm run db:inventory +npm run db:verify +npm run db:test-integration +npm run db:verify-import +``` + +`db:test-integration` is an empty-database migration test; it must not be run against the imported production data. + +The database commands read connection material from one non-echoed standard-input line. They do not read `.env` files or print credentials. + +## Verification + +- Migration safety checks: 16/16. +- API/auth/config/error tests: 12/12. +- Live imported-database API smoke checks: 6/6. +- Live transactional API write checks: 8/8. +- Imported-batch read-only verification: 6/6. +- Post-test deployment checks: 11/11. +- npm vulnerability audit: 0. + +The database integration test covers all 100 automatic room-type combinations, both AC2 manual paths, annual grant/carry-forward, derived Night/Use/Balance, idempotency, duplicate confirmation, cross-year rejection, insufficient balance and concurrent overspend protection. Synthetic records are removed and the final four business tables must be empty. + +## Rollback + +The latest migration rollback is [002_legacy_import_and_bookings.down.sql](./migrations/002_legacy_import_and_bookings.down.sql). Do not run it after business data has been imported. The original empty-schema rollback remains [001_create_condon_schema.down.sql](./migrations/001_create_condon_schema.down.sql). + +Do not run rollback after business data has been imported. Rollback is an explicit operator action and is not exposed as a normal npm command. + +## Deliberately deferred + +- Importing a newer workbook without a new preflight batch. +- Switching the static frontend to the API. +- Multi-user identities, roles and backend-restart-persistent sessions. +- Usage cancellation, reversal and manual balance adjustment. +- Cross-entitlement-year stays. diff --git a/backend/migrations/001_create_condon_schema.down.sql b/backend/migrations/001_create_condon_schema.down.sql new file mode 100644 index 0000000..b81a109 --- /dev/null +++ b/backend/migrations/001_create_condon_schema.down.sql @@ -0,0 +1,65 @@ +REVOKE ALL ON FUNCTION condon.create_usage_record( + uuid, + uuid, + uuid, + varchar, + date, + date, + varchar, + smallint, + text, + uuid +) FROM PUBLIC; +REVOKE ALL ON FUNCTION condon.open_entitlement_period( + uuid, + uuid, + smallint, + uuid, + uuid +) FROM PUBLIC; +REVOKE ALL ON FUNCTION condon.calculate_multiplier( + varchar, + varchar, + smallint +) FROM PUBLIC; + +DROP FUNCTION condon.create_usage_record( + uuid, + uuid, + uuid, + varchar, + date, + date, + varchar, + smallint, + text, + uuid +); +DROP FUNCTION condon.open_entitlement_period( + uuid, + uuid, + smallint, + uuid, + uuid +); +DROP FUNCTION condon.calculate_multiplier( + varchar, + varchar, + smallint +); + +DROP INDEX condon.entitlement_ledger_period_occurred_idx; +DROP INDEX condon.usage_records_used_type_check_in_idx; +DROP INDEX condon.usage_records_period_idx; +DROP INDEX condon.usage_records_owner_created_idx; +DROP INDEX condon.owner_accounts_purchased_room_type_idx; +DROP INDEX condon.owner_accounts_member_no_idx; + +DROP TABLE condon.entitlement_ledger; +DROP TABLE condon.usage_records; +DROP TABLE condon.entitlement_periods; +DROP TABLE condon.owner_accounts; +DROP TABLE condon.room_types; +DROP TABLE condon.schema_migrations; + +DROP SCHEMA condon; diff --git a/backend/migrations/001_create_condon_schema.up.sql b/backend/migrations/001_create_condon_schema.up.sql new file mode 100644 index 0000000..404f2ba --- /dev/null +++ b/backend/migrations/001_create_condon_schema.up.sql @@ -0,0 +1,713 @@ +CREATE SCHEMA condon; +REVOKE ALL ON SCHEMA condon FROM PUBLIC; +COMMENT ON SCHEMA condon IS 'CONDO owner entitlement backend'; + +CREATE TABLE condon.schema_migrations ( + version varchar(64) PRIMARY KEY, + checksum varchar(64) NOT NULL, + applied_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), + CONSTRAINT schema_migrations_checksum_check + CHECK (checksum ~ '^[0-9a-f]{64}$') +); + +CREATE TABLE condon.room_types ( + code varchar(16) PRIMARY KEY, + entitlement_tier smallint, + requires_manual_multiplier boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), + CONSTRAINT room_types_code_check + CHECK (code ~ '^[A-Z0-9]+$'), + CONSTRAINT room_types_rule_shape_check + CHECK ( + (requires_manual_multiplier AND entitlement_tier IS NULL) + OR + ( + NOT requires_manual_multiplier + AND entitlement_tier BETWEEN 1 AND 3 + ) + ) +); + +CREATE TABLE condon.owner_accounts ( + id uuid PRIMARY KEY, + account_no integer, + transfer_date date, + owner_name text NOT NULL, + room_no varchar(32) NOT NULL, + purchased_room_type_code varchar(16) NOT NULL, + unit_no varchar(32) NOT NULL, + member_no varchar(32) NOT NULL, + created_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), + CONSTRAINT owner_accounts_account_no_key UNIQUE (account_no), + CONSTRAINT owner_accounts_room_no_key UNIQUE (room_no), + CONSTRAINT owner_accounts_account_no_check + CHECK (account_no IS NULL OR account_no > 0), + CONSTRAINT owner_accounts_owner_name_check + CHECK (pg_catalog.btrim(owner_name) <> ''), + CONSTRAINT owner_accounts_room_no_check + CHECK (pg_catalog.btrim(room_no) <> ''), + CONSTRAINT owner_accounts_unit_no_check + CHECK (pg_catalog.btrim(unit_no) <> ''), + CONSTRAINT owner_accounts_member_no_check + CHECK (pg_catalog.btrim(member_no) <> ''), + CONSTRAINT owner_accounts_purchased_room_type_fk + FOREIGN KEY (purchased_room_type_code) + REFERENCES condon.room_types(code) + ON UPDATE RESTRICT + ON DELETE RESTRICT +); + +CREATE TABLE condon.entitlement_periods ( + id uuid PRIMARY KEY, + owner_account_id uuid NOT NULL, + period_year smallint NOT NULL, + period_start date NOT NULL, + period_end date NOT NULL, + annual_grant integer NOT NULL DEFAULT 15, + carry_forward integer NOT NULL DEFAULT 0, + current_balance integer NOT NULL, + row_version integer NOT NULL DEFAULT 1, + created_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), + CONSTRAINT entitlement_periods_owner_year_key + UNIQUE (owner_account_id, period_year), + CONSTRAINT entitlement_periods_id_owner_key + UNIQUE (id, owner_account_id), + CONSTRAINT entitlement_periods_owner_fk + FOREIGN KEY (owner_account_id) + REFERENCES condon.owner_accounts(id) + ON UPDATE RESTRICT + ON DELETE RESTRICT, + CONSTRAINT entitlement_periods_year_check + CHECK (period_year BETWEEN 2000 AND 9999), + CONSTRAINT entitlement_periods_dates_check + CHECK ( + period_start = pg_catalog.make_date(period_year::integer, 1, 1) + AND period_end = pg_catalog.make_date(period_year::integer, 12, 31) + ), + CONSTRAINT entitlement_periods_annual_grant_check + CHECK (annual_grant >= 0), + CONSTRAINT entitlement_periods_carry_forward_check + CHECK (carry_forward >= 0), + CONSTRAINT entitlement_periods_current_balance_check + CHECK (current_balance >= 0), + CONSTRAINT entitlement_periods_row_version_check + CHECK (row_version >= 1) +); + +CREATE TABLE condon.usage_records ( + id uuid PRIMARY KEY, + owner_account_id uuid NOT NULL, + entitlement_period_id uuid NOT NULL, + confirmation_no varchar(64) NOT NULL, + check_in date NOT NULL, + check_out date NOT NULL, + night_count integer NOT NULL, + used_room_type_code varchar(16) NOT NULL, + applied_multiplier smallint NOT NULL, + rule_version varchar(32) NOT NULL DEFAULT 'v1', + use_nights integer NOT NULL, + balance_before integer NOT NULL, + balance_after integer NOT NULL, + remark text NOT NULL DEFAULT '', + idempotency_key uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), + CONSTRAINT usage_records_confirmation_no_key UNIQUE (confirmation_no), + CONSTRAINT usage_records_idempotency_key_key UNIQUE (idempotency_key), + CONSTRAINT usage_records_id_period_key UNIQUE (id, entitlement_period_id), + CONSTRAINT usage_records_account_fk + FOREIGN KEY (owner_account_id) + REFERENCES condon.owner_accounts(id) + ON UPDATE RESTRICT + ON DELETE RESTRICT, + CONSTRAINT usage_records_period_account_fk + FOREIGN KEY (entitlement_period_id, owner_account_id) + REFERENCES condon.entitlement_periods(id, owner_account_id) + ON UPDATE RESTRICT + ON DELETE RESTRICT, + CONSTRAINT usage_records_used_room_type_fk + FOREIGN KEY (used_room_type_code) + REFERENCES condon.room_types(code) + ON UPDATE RESTRICT + ON DELETE RESTRICT, + CONSTRAINT usage_records_confirmation_no_check + CHECK (confirmation_no ~ '^[0-9]+$'), + CONSTRAINT usage_records_dates_check + CHECK (check_out > check_in), + CONSTRAINT usage_records_same_year_check + CHECK ( + EXTRACT(year FROM check_in) + = EXTRACT(year FROM check_out) + ), + CONSTRAINT usage_records_night_count_check + CHECK (night_count = check_out - check_in AND night_count > 0), + CONSTRAINT usage_records_multiplier_check + CHECK (applied_multiplier BETWEEN 1 AND 3), + CONSTRAINT usage_records_use_nights_check + CHECK ( + use_nights = night_count * applied_multiplier + AND use_nights > 0 + ), + CONSTRAINT usage_records_balance_check + CHECK ( + balance_before >= use_nights + AND balance_after = balance_before - use_nights + AND balance_after >= 0 + ) +); + +CREATE TABLE condon.entitlement_ledger ( + id uuid PRIMARY KEY, + entitlement_period_id uuid NOT NULL, + usage_record_id uuid, + entry_type varchar(32) NOT NULL, + delta_nights integer NOT NULL, + balance_before integer NOT NULL, + balance_after integer NOT NULL, + occurred_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), + CONSTRAINT entitlement_ledger_usage_record_key + UNIQUE (usage_record_id), + CONSTRAINT entitlement_ledger_period_fk + FOREIGN KEY (entitlement_period_id) + REFERENCES condon.entitlement_periods(id) + ON UPDATE RESTRICT + ON DELETE RESTRICT, + CONSTRAINT entitlement_ledger_usage_period_fk + FOREIGN KEY (usage_record_id, entitlement_period_id) + REFERENCES condon.usage_records(id, entitlement_period_id) + ON UPDATE RESTRICT + ON DELETE RESTRICT, + CONSTRAINT entitlement_ledger_entry_type_check + CHECK (entry_type IN ('annual_grant', 'carry_forward', 'usage')), + CONSTRAINT entitlement_ledger_balance_check + CHECK ( + balance_after = balance_before + delta_nights + AND balance_after >= 0 + ), + CONSTRAINT entitlement_ledger_entry_shape_check + CHECK ( + ( + entry_type = 'usage' + AND usage_record_id IS NOT NULL + AND delta_nights < 0 + ) + OR + ( + entry_type IN ('annual_grant', 'carry_forward') + AND usage_record_id IS NULL + AND delta_nights > 0 + ) + ) +); + +CREATE INDEX owner_accounts_member_no_idx + ON condon.owner_accounts (member_no); +CREATE INDEX owner_accounts_purchased_room_type_idx + ON condon.owner_accounts (purchased_room_type_code); +CREATE INDEX usage_records_owner_created_idx + ON condon.usage_records (owner_account_id, created_at DESC, id DESC); +CREATE INDEX usage_records_period_idx + ON condon.usage_records (entitlement_period_id); +CREATE INDEX usage_records_used_type_check_in_idx + ON condon.usage_records (used_room_type_code, check_in); +CREATE INDEX entitlement_ledger_period_occurred_idx + ON condon.entitlement_ledger (entitlement_period_id, occurred_at, id); + +INSERT INTO condon.room_types ( + code, + entitlement_tier, + requires_manual_multiplier +) +VALUES + ('RM1', 1, false), + ('RM2', 1, false), + ('RM3', 1, false), + ('RM4', 1, false), + ('UG1', 1, false), + ('UG2', 1, false), + ('SU1', 2, false), + ('SU2', 2, false), + ('SU6', 2, false), + ('SU3', 3, false), + ('AC2', NULL, true); + +CREATE FUNCTION condon.calculate_multiplier( + p_purchased_room_type_code varchar, + p_used_room_type_code varchar, + p_manual_multiplier smallint +) +RETURNS smallint +LANGUAGE plpgsql +STABLE +SECURITY INVOKER +SET search_path = pg_catalog +AS $function$ +DECLARE + v_purchased_tier smallint; + v_used_tier smallint; + v_purchased_manual boolean; + v_used_manual boolean; +BEGIN + SELECT + entitlement_tier, + requires_manual_multiplier + INTO + v_purchased_tier, + v_purchased_manual + FROM condon.room_types + WHERE code = p_purchased_room_type_code; + + IF NOT FOUND THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_PURCHASED_ROOM_TYPE_NOT_FOUND'; + END IF; + + SELECT + entitlement_tier, + requires_manual_multiplier + INTO + v_used_tier, + v_used_manual + FROM condon.room_types + WHERE code = p_used_room_type_code; + + IF NOT FOUND THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_USED_ROOM_TYPE_NOT_FOUND'; + END IF; + + IF v_purchased_manual OR v_used_manual THEN + IF p_manual_multiplier IS NULL OR p_manual_multiplier NOT BETWEEN 1 AND 3 THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_MANUAL_MULTIPLIER_REQUIRED'; + END IF; + RETURN p_manual_multiplier; + END IF; + + IF p_manual_multiplier IS NOT NULL THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_MANUAL_MULTIPLIER_NOT_ALLOWED'; + END IF; + + RETURN GREATEST( + 1, + v_used_tier - v_purchased_tier + 1 + )::smallint; +END; +$function$; + +CREATE FUNCTION condon.open_entitlement_period( + p_period_id uuid, + p_owner_account_id uuid, + p_period_year smallint, + p_annual_grant_ledger_id uuid, + p_carry_forward_ledger_id uuid +) +RETURNS condon.entitlement_periods +LANGUAGE plpgsql +VOLATILE +SECURITY INVOKER +SET search_path = pg_catalog +AS $function$ +DECLARE + v_carry_forward integer := 0; + v_period condon.entitlement_periods%ROWTYPE; + v_now timestamptz := pg_catalog.clock_timestamp(); +BEGIN + IF p_period_id IS NULL + OR p_owner_account_id IS NULL + OR p_annual_grant_ledger_id IS NULL + THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_REQUIRED_ID_MISSING'; + END IF; + + IF p_period_year NOT BETWEEN 2000 AND 9999 THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_INVALID_PERIOD_YEAR'; + END IF; + + PERFORM 1 + FROM condon.owner_accounts + WHERE id = p_owner_account_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION USING + ERRCODE = 'P0002', + MESSAGE = 'CONDON_OWNER_ACCOUNT_NOT_FOUND'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM condon.entitlement_periods + WHERE owner_account_id = p_owner_account_id + AND period_year = p_period_year + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23505', + MESSAGE = 'CONDON_ENTITLEMENT_PERIOD_EXISTS'; + END IF; + + SELECT current_balance + INTO v_carry_forward + FROM condon.entitlement_periods + WHERE owner_account_id = p_owner_account_id + AND period_year = p_period_year - 1 + FOR UPDATE; + + v_carry_forward := COALESCE(v_carry_forward, 0); + + IF v_carry_forward > 0 AND p_carry_forward_ledger_id IS NULL THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_CARRY_FORWARD_LEDGER_ID_REQUIRED'; + END IF; + + IF v_carry_forward = 0 AND p_carry_forward_ledger_id IS NOT NULL THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_CARRY_FORWARD_LEDGER_ID_NOT_ALLOWED'; + END IF; + + INSERT INTO condon.entitlement_periods ( + id, + owner_account_id, + period_year, + period_start, + period_end, + annual_grant, + carry_forward, + current_balance, + row_version, + created_at, + updated_at + ) + VALUES ( + p_period_id, + p_owner_account_id, + p_period_year, + pg_catalog.make_date(p_period_year::integer, 1, 1), + pg_catalog.make_date(p_period_year::integer, 12, 31), + 15, + v_carry_forward, + 15 + v_carry_forward, + 1, + v_now, + v_now + ) + RETURNING * INTO v_period; + + INSERT INTO condon.entitlement_ledger ( + id, + entitlement_period_id, + usage_record_id, + entry_type, + delta_nights, + balance_before, + balance_after, + occurred_at + ) + VALUES ( + p_annual_grant_ledger_id, + p_period_id, + NULL, + 'annual_grant', + 15, + 0, + 15, + v_now + ); + + IF v_carry_forward > 0 THEN + INSERT INTO condon.entitlement_ledger ( + id, + entitlement_period_id, + usage_record_id, + entry_type, + delta_nights, + balance_before, + balance_after, + occurred_at + ) + VALUES ( + p_carry_forward_ledger_id, + p_period_id, + NULL, + 'carry_forward', + v_carry_forward, + 15, + 15 + v_carry_forward, + v_now + ); + END IF; + + RETURN v_period; +END; +$function$; + +CREATE FUNCTION condon.create_usage_record( + p_usage_record_id uuid, + p_ledger_id uuid, + p_owner_account_id uuid, + p_confirmation_no varchar, + p_check_in date, + p_check_out date, + p_used_room_type_code varchar, + p_manual_multiplier smallint, + p_remark text, + p_idempotency_key uuid +) +RETURNS condon.usage_records +LANGUAGE plpgsql +VOLATILE +SECURITY INVOKER +SET search_path = pg_catalog +AS $function$ +DECLARE + v_purchased_room_type_code varchar(16); + v_multiplier smallint; + v_night_count integer; + v_use_nights integer; + v_balance_before integer; + v_balance_after integer; + v_period condon.entitlement_periods%ROWTYPE; + v_existing condon.usage_records%ROWTYPE; + v_usage condon.usage_records%ROWTYPE; + v_now timestamptz := pg_catalog.clock_timestamp(); + v_updated_count integer; +BEGIN + IF p_usage_record_id IS NULL + OR p_ledger_id IS NULL + OR p_owner_account_id IS NULL + OR p_idempotency_key IS NULL + THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_REQUIRED_ID_MISSING'; + END IF; + + IF p_confirmation_no IS NULL + OR p_confirmation_no !~ '^[0-9]+$' + THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_INVALID_CONFIRMATION_NO'; + END IF; + + IF p_check_in IS NULL + OR p_check_out IS NULL + OR p_check_out <= p_check_in + THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_INVALID_STAY_DATES'; + END IF; + + IF EXTRACT(year FROM p_check_in) + <> EXTRACT(year FROM p_check_out) + THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_CROSS_YEAR_STAY_NOT_SUPPORTED'; + END IF; + + SELECT purchased_room_type_code + INTO v_purchased_room_type_code + FROM condon.owner_accounts + WHERE id = p_owner_account_id + FOR SHARE; + + IF NOT FOUND THEN + RAISE EXCEPTION USING + ERRCODE = 'P0002', + MESSAGE = 'CONDON_OWNER_ACCOUNT_NOT_FOUND'; + END IF; + + v_multiplier := condon.calculate_multiplier( + v_purchased_room_type_code, + p_used_room_type_code, + p_manual_multiplier + ); + v_night_count := p_check_out - p_check_in; + v_use_nights := v_night_count * v_multiplier; + + PERFORM pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended(p_idempotency_key::text, 0) + ); + + SELECT * + INTO v_existing + FROM condon.usage_records + WHERE idempotency_key = p_idempotency_key; + + IF FOUND THEN + IF v_existing.owner_account_id IS DISTINCT FROM p_owner_account_id + OR v_existing.confirmation_no IS DISTINCT FROM p_confirmation_no + OR v_existing.check_in IS DISTINCT FROM p_check_in + OR v_existing.check_out IS DISTINCT FROM p_check_out + OR v_existing.used_room_type_code IS DISTINCT FROM p_used_room_type_code + OR v_existing.applied_multiplier IS DISTINCT FROM v_multiplier + OR v_existing.remark IS DISTINCT FROM COALESCE(p_remark, '') + THEN + RAISE EXCEPTION USING + ERRCODE = '23505', + MESSAGE = 'CONDON_IDEMPOTENCY_KEY_REUSED'; + END IF; + RETURN v_existing; + END IF; + + PERFORM pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended('confirmation:' || p_confirmation_no, 0) + ); + + IF EXISTS ( + SELECT 1 + FROM condon.usage_records + WHERE confirmation_no = p_confirmation_no + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23505', + MESSAGE = 'CONDON_CONFIRMATION_NO_EXISTS'; + END IF; + + SELECT * + INTO v_period + FROM condon.entitlement_periods + WHERE owner_account_id = p_owner_account_id + AND period_year = EXTRACT(year FROM p_check_in)::smallint + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION USING + ERRCODE = 'P0002', + MESSAGE = 'CONDON_ENTITLEMENT_PERIOD_NOT_FOUND'; + END IF; + + IF p_check_in < v_period.period_start + OR p_check_out > v_period.period_end + THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_STAY_OUTSIDE_ENTITLEMENT_PERIOD'; + END IF; + + v_balance_before := v_period.current_balance; + v_balance_after := v_balance_before - v_use_nights; + + IF v_balance_after < 0 THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'CONDON_INSUFFICIENT_BALANCE'; + END IF; + + INSERT INTO condon.usage_records ( + id, + owner_account_id, + entitlement_period_id, + confirmation_no, + check_in, + check_out, + night_count, + used_room_type_code, + applied_multiplier, + rule_version, + use_nights, + balance_before, + balance_after, + remark, + idempotency_key, + created_at + ) + VALUES ( + p_usage_record_id, + p_owner_account_id, + v_period.id, + p_confirmation_no, + p_check_in, + p_check_out, + v_night_count, + p_used_room_type_code, + v_multiplier, + 'v1', + v_use_nights, + v_balance_before, + v_balance_after, + COALESCE(p_remark, ''), + p_idempotency_key, + v_now + ) + RETURNING * INTO v_usage; + + INSERT INTO condon.entitlement_ledger ( + id, + entitlement_period_id, + usage_record_id, + entry_type, + delta_nights, + balance_before, + balance_after, + occurred_at + ) + VALUES ( + p_ledger_id, + v_period.id, + p_usage_record_id, + 'usage', + -v_use_nights, + v_balance_before, + v_balance_after, + v_now + ); + + UPDATE condon.entitlement_periods + SET + current_balance = v_balance_after, + row_version = row_version + 1, + updated_at = v_now + WHERE id = v_period.id + AND row_version = v_period.row_version; + + GET DIAGNOSTICS v_updated_count = ROW_COUNT; + IF v_updated_count <> 1 THEN + RAISE EXCEPTION USING + ERRCODE = '40001', + MESSAGE = 'CONDON_BALANCE_CONCURRENTLY_CHANGED'; + END IF; + + RETURN v_usage; +END; +$function$; + +REVOKE ALL ON FUNCTION condon.calculate_multiplier( + varchar, + varchar, + smallint +) FROM PUBLIC; +REVOKE ALL ON FUNCTION condon.open_entitlement_period( + uuid, + uuid, + smallint, + uuid, + uuid +) FROM PUBLIC; +REVOKE ALL ON FUNCTION condon.create_usage_record( + uuid, + uuid, + uuid, + varchar, + date, + date, + varchar, + smallint, + text, + uuid +) FROM PUBLIC; diff --git a/backend/migrations/002_legacy_import_and_bookings.down.sql b/backend/migrations/002_legacy_import_and_bookings.down.sql new file mode 100644 index 0000000..df56c33 --- /dev/null +++ b/backend/migrations/002_legacy_import_and_bookings.down.sql @@ -0,0 +1,69 @@ +REVOKE ALL ON FUNCTION condon.create_usage_record_v2( + uuid, + uuid, + uuid, + varchar, + date, + date, + varchar, + smallint, + text, + uuid +) FROM PUBLIC; +REVOKE ALL ON FUNCTION condon.ensure_booking_for_usage() FROM PUBLIC; + +DROP FUNCTION condon.create_usage_record_v2( + uuid, + uuid, + uuid, + varchar, + date, + date, + varchar, + smallint, + text, + uuid +); +DROP TRIGGER usage_records_booking_trg ON condon.usage_records; +DROP FUNCTION condon.ensure_booking_for_usage(); + +DROP INDEX condon.usage_records_source_location_key; +DROP INDEX condon.bookings_created_idx; + +ALTER TABLE condon.usage_records + DROP CONSTRAINT usage_records_confirmation_booking_fk, + DROP CONSTRAINT usage_records_room_count_check, + DROP CONSTRAINT usage_records_source_location_check, + DROP CONSTRAINT usage_records_multiplier_check, + DROP CONSTRAINT usage_records_use_nights_check, + DROP CONSTRAINT usage_records_balance_check; + +ALTER TABLE condon.usage_records + DROP COLUMN import_batch, + DROP COLUMN source_sequence, + DROP COLUMN source_row, + DROP COLUMN source_sheet, + DROP COLUMN room_count, + DROP COLUMN raw_used_room_type; + +ALTER TABLE condon.usage_records + ALTER COLUMN used_room_type_code SET NOT NULL, + ALTER COLUMN applied_multiplier SET NOT NULL; + +ALTER TABLE condon.usage_records + ADD CONSTRAINT usage_records_confirmation_no_key UNIQUE (confirmation_no), + ADD CONSTRAINT usage_records_multiplier_check + CHECK (applied_multiplier BETWEEN 1 AND 3), + ADD CONSTRAINT usage_records_use_nights_check + CHECK ( + use_nights = night_count * applied_multiplier + AND use_nights > 0 + ), + ADD CONSTRAINT usage_records_balance_check + CHECK ( + balance_before >= use_nights + AND balance_after = balance_before - use_nights + AND balance_after >= 0 + ); + +DROP TABLE condon.bookings; diff --git a/backend/migrations/002_legacy_import_and_bookings.up.sql b/backend/migrations/002_legacy_import_and_bookings.up.sql new file mode 100644 index 0000000..c831f22 --- /dev/null +++ b/backend/migrations/002_legacy_import_and_bookings.up.sql @@ -0,0 +1,348 @@ +-- Extend the empty CONDO schema for the confirmed booking 1:N semantics and +-- lossless legacy history import. This migration does not touch other schemas. + +CREATE TABLE condon.bookings ( + confirmation_no varchar(64) PRIMARY KEY, + created_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), + CONSTRAINT bookings_confirmation_no_check + CHECK (confirmation_no ~ '^[0-9]+$') +); + +COMMENT ON TABLE condon.bookings IS + 'One row per booking/Confirmation; one booking may have many usage records'; + +ALTER TABLE condon.usage_records + DROP CONSTRAINT usage_records_confirmation_no_key, + DROP CONSTRAINT usage_records_multiplier_check, + DROP CONSTRAINT usage_records_use_nights_check, + DROP CONSTRAINT usage_records_balance_check; + +ALTER TABLE condon.usage_records + ALTER COLUMN used_room_type_code DROP NOT NULL, + ALTER COLUMN applied_multiplier DROP NOT NULL; + +ALTER TABLE condon.usage_records + ADD COLUMN raw_used_room_type text NOT NULL DEFAULT '', + ADD COLUMN room_count integer NOT NULL DEFAULT 1, + ADD COLUMN source_sheet varchar(128), + ADD COLUMN source_row integer, + ADD COLUMN source_sequence integer, + ADD COLUMN import_batch varchar(128); + +ALTER TABLE condon.usage_records + ADD CONSTRAINT usage_records_confirmation_booking_fk + FOREIGN KEY (confirmation_no) + REFERENCES condon.bookings(confirmation_no) + ON UPDATE RESTRICT + ON DELETE RESTRICT, + ADD CONSTRAINT usage_records_room_count_check + CHECK (room_count >= 1), + ADD CONSTRAINT usage_records_source_location_check + CHECK ( + (source_sheet IS NULL AND source_row IS NULL AND source_sequence IS NULL) + OR + (source_sheet IS NOT NULL AND source_row > 0 AND source_sequence IS NOT NULL) + ), + ADD CONSTRAINT usage_records_multiplier_check + CHECK ( + (rule_version = 'legacy-source' AND applied_multiplier IS NULL) + OR + (rule_version <> 'legacy-source' AND applied_multiplier BETWEEN 1 AND 3) + ), + ADD CONSTRAINT usage_records_use_nights_check + CHECK ( + ( + rule_version = 'legacy-source' + AND use_nights > 0 + ) + OR + ( + rule_version <> 'legacy-source' + AND used_room_type_code IS NOT NULL + AND applied_multiplier BETWEEN 1 AND 3 + AND use_nights = night_count * applied_multiplier + AND use_nights > 0 + ) + ), + ADD CONSTRAINT usage_records_balance_check + CHECK ( + balance_before >= use_nights + AND balance_after = balance_before - use_nights + AND balance_after >= 0 + ); + +CREATE INDEX bookings_created_idx + ON condon.bookings (created_at DESC, confirmation_no); + +CREATE UNIQUE INDEX usage_records_source_location_key + ON condon.usage_records (import_batch, source_sheet, source_row) + WHERE import_batch IS NOT NULL + AND source_sheet IS NOT NULL + AND source_row IS NOT NULL; + +CREATE FUNCTION condon.ensure_booking_for_usage() +RETURNS trigger +LANGUAGE plpgsql +VOLATILE +SECURITY INVOKER +SET search_path = pg_catalog +AS $function$ +BEGIN + INSERT INTO condon.bookings (confirmation_no) + VALUES (NEW.confirmation_no) + ON CONFLICT (confirmation_no) DO NOTHING; + RETURN NEW; +END; +$function$; + +CREATE TRIGGER usage_records_booking_trg +BEFORE INSERT ON condon.usage_records +FOR EACH ROW +EXECUTE FUNCTION condon.ensure_booking_for_usage(); + +-- New-record path: same business contract as v1, but Confirmation is a +-- booking key and may legitimately appear on multiple usage rows. +CREATE FUNCTION condon.create_usage_record_v2( + p_usage_record_id uuid, + p_ledger_id uuid, + p_owner_account_id uuid, + p_confirmation_no varchar, + p_check_in date, + p_check_out date, + p_used_room_type_code varchar, + p_manual_multiplier smallint, + p_remark text, + p_idempotency_key uuid +) +RETURNS condon.usage_records +LANGUAGE plpgsql +VOLATILE +SECURITY INVOKER +SET search_path = pg_catalog +AS $function$ +DECLARE + v_purchased_room_type_code varchar(16); + v_multiplier smallint; + v_night_count integer; + v_use_nights integer; + v_balance_before integer; + v_balance_after integer; + v_period condon.entitlement_periods%ROWTYPE; + v_existing condon.usage_records%ROWTYPE; + v_usage condon.usage_records%ROWTYPE; + v_now timestamptz := pg_catalog.clock_timestamp(); + v_updated_count integer; +BEGIN + IF p_usage_record_id IS NULL + OR p_ledger_id IS NULL + OR p_owner_account_id IS NULL + OR p_idempotency_key IS NULL + THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_REQUIRED_ID_MISSING'; + END IF; + + IF p_confirmation_no IS NULL + OR p_confirmation_no !~ '^[0-9]+$' + THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_INVALID_CONFIRMATION_NO'; + END IF; + + IF p_check_in IS NULL + OR p_check_out IS NULL + OR p_check_out <= p_check_in + THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_INVALID_STAY_DATES'; + END IF; + + IF EXTRACT(year FROM p_check_in) + <> EXTRACT(year FROM p_check_out) + THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_CROSS_YEAR_STAY_NOT_SUPPORTED'; + END IF; + + SELECT purchased_room_type_code + INTO v_purchased_room_type_code + FROM condon.owner_accounts + WHERE id = p_owner_account_id + FOR SHARE; + + IF NOT FOUND THEN + RAISE EXCEPTION USING + ERRCODE = 'P0002', + MESSAGE = 'CONDON_OWNER_ACCOUNT_NOT_FOUND'; + END IF; + + v_multiplier := condon.calculate_multiplier( + v_purchased_room_type_code, + p_used_room_type_code, + p_manual_multiplier + ); + v_night_count := p_check_out - p_check_in; + v_use_nights := v_night_count * v_multiplier; + + PERFORM pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended(p_idempotency_key::text, 0) + ); + + SELECT * + INTO v_existing + FROM condon.usage_records + WHERE idempotency_key = p_idempotency_key; + + IF FOUND THEN + IF v_existing.owner_account_id IS DISTINCT FROM p_owner_account_id + OR v_existing.confirmation_no IS DISTINCT FROM p_confirmation_no + OR v_existing.check_in IS DISTINCT FROM p_check_in + OR v_existing.check_out IS DISTINCT FROM p_check_out + OR v_existing.used_room_type_code IS DISTINCT FROM p_used_room_type_code + OR v_existing.applied_multiplier IS DISTINCT FROM v_multiplier + OR v_existing.remark IS DISTINCT FROM COALESCE(p_remark, '') + THEN + RAISE EXCEPTION USING + ERRCODE = '23505', + MESSAGE = 'CONDON_IDEMPOTENCY_KEY_REUSED'; + END IF; + RETURN v_existing; + END IF; + + PERFORM pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended('confirmation:' || p_confirmation_no, 0) + ); + + INSERT INTO condon.bookings (confirmation_no) + VALUES (p_confirmation_no) + ON CONFLICT (confirmation_no) DO NOTHING; + + SELECT * + INTO v_period + FROM condon.entitlement_periods + WHERE owner_account_id = p_owner_account_id + AND period_year = EXTRACT(year FROM p_check_in)::smallint + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION USING + ERRCODE = 'P0002', + MESSAGE = 'CONDON_ENTITLEMENT_PERIOD_NOT_FOUND'; + END IF; + + IF p_check_in < v_period.period_start + OR p_check_out > v_period.period_end + THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_STAY_OUTSIDE_ENTITLEMENT_PERIOD'; + END IF; + + v_balance_before := v_period.current_balance; + v_balance_after := v_balance_before - v_use_nights; + + IF v_balance_after < 0 THEN + RAISE EXCEPTION USING + ERRCODE = 'P0001', + MESSAGE = 'CONDON_INSUFFICIENT_BALANCE'; + END IF; + + INSERT INTO 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, + created_at + ) + VALUES ( + p_usage_record_id, + p_owner_account_id, + v_period.id, + p_confirmation_no, + p_check_in, + p_check_out, + v_night_count, + p_used_room_type_code, + p_used_room_type_code, + v_multiplier, + 'v1', + v_use_nights, + v_balance_before, + v_balance_after, + 1, + COALESCE(p_remark, ''), + p_idempotency_key, + v_now + ) + RETURNING * INTO v_usage; + + INSERT INTO condon.entitlement_ledger ( + id, + entitlement_period_id, + usage_record_id, + entry_type, + delta_nights, + balance_before, + balance_after, + occurred_at + ) + VALUES ( + p_ledger_id, + v_period.id, + p_usage_record_id, + 'usage', + -v_use_nights, + v_balance_before, + v_balance_after, + v_now + ); + + UPDATE condon.entitlement_periods + SET + current_balance = v_balance_after, + row_version = row_version + 1, + updated_at = v_now + WHERE id = v_period.id + AND row_version = v_period.row_version; + + GET DIAGNOSTICS v_updated_count = ROW_COUNT; + IF v_updated_count <> 1 THEN + RAISE EXCEPTION USING + ERRCODE = '40001', + MESSAGE = 'CONDON_BALANCE_CONCURRENTLY_CHANGED'; + END IF; + + RETURN v_usage; +END; +$function$; + +REVOKE ALL ON FUNCTION condon.ensure_booking_for_usage() FROM PUBLIC; +REVOKE ALL ON FUNCTION condon.create_usage_record_v2( + uuid, + uuid, + uuid, + varchar, + date, + date, + varchar, + smallint, + text, + uuid +) FROM PUBLIC; diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..a8b8deb --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,2122 @@ +{ + "name": "condon-backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "condon-backend", + "version": "0.1.0", + "dependencies": { + "@fastify/cors": "11.3.0", + "@fastify/swagger": "9.8.1", + "@fastify/swagger-ui": "6.1.1", + "fastify": "5.10.0", + "pg": "8.22.0", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/node": "26.1.2", + "@types/pg": "8.20.0", + "tsx": "4.23.1", + "typescript": "7.0.2" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz", + "integrity": "sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^6.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.2.tgz", + "integrity": "sha512-G/g18cG9tLutT/OVyN1AIsHIl9L1UwmJ+S3dkyhVpplIx0nEMicd7RGQ+uJLyhKKF4a3tTcQydccn3Mop1fX+Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/error": "^4.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^2.0.1", + "fastify-plugin": "^6.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, + "node_modules/@fastify/swagger": { + "version": "9.8.1", + "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.8.1.tgz", + "integrity": "sha512-VpHMnqZTY8iBZYJE8WWkbKPrXIYWy2rDfIf5qLr6DzZSpQYZ+KxQVcJFiq/AMlvNwI4gCBd66++iUlxXXGT0IQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^6.0.0", + "json-schema-resolver": "^3.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.2" + } + }, + "node_modules/@fastify/swagger-ui": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-6.1.1.tgz", + "integrity": "sha512-RKCLSHASlzS2JZvHWn14NmEpHyl0yNosGvqzhUumm/LGPG6RWQBf4oscTFt83QDvc5O5Tol3Beup8inAl/k4EA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/static": "^10.1.0", + "fastify-plugin": "^6.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.1" + } + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.1.tgz", + "integrity": "sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", + "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz", + "integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fast-uri": "^3.0.5", + "rfdc": "^1.1.4" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/Eomm/json-schema-resolver?sponsor=1" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..5f820dc --- /dev/null +++ b/backend/package.json @@ -0,0 +1,40 @@ +{ + "name": "condon-backend", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=24" + }, + "scripts": { + "api:smoke": "node scripts/api-smoke.mjs", + "api:test-write": "node scripts/api-write-smoke.mjs", + "build": "tsc -p tsconfig.json", + "dev": "tsx watch src/server.ts", + "preview:history": "node scripts/import-preview-server.mjs", + "preview:history:check": "node scripts/import-preview-server.mjs --check", + "start": "node dist/src/server.js", + "test": "tsx --test tests/**/*.test.ts", + "typecheck": "tsc --noEmit -p tsconfig.json", + "db:inventory": "node scripts/db-inventory.mjs", + "db:check-migrations": "node scripts/check-migrations.mjs", + "db:migrate:up": "node scripts/migrate-up.mjs", + "db:test-integration": "node scripts/test-database.mjs", + "db:verify-import": "node scripts/verify-import-batch.mjs", + "db:verify": "node scripts/verify-deployment.mjs" + }, + "dependencies": { + "@fastify/cors": "11.3.0", + "@fastify/swagger": "9.8.1", + "@fastify/swagger-ui": "6.1.1", + "fastify": "5.10.0", + "pg": "8.22.0", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/node": "26.1.2", + "@types/pg": "8.20.0", + "tsx": "4.23.1", + "typescript": "7.0.2" + } +} diff --git a/backend/scripts/api-smoke.mjs b/backend/scripts/api-smoke.mjs new file mode 100644 index 0000000..89ac08f --- /dev/null +++ b/backend/scripts/api-smoke.mjs @@ -0,0 +1,96 @@ +import process from "node:process"; +import { createInterface } from "node:readline"; +import pg from "pg"; +import { buildApp } from "../dist/src/app.js"; +import { PostgresCondoRepository } from "../dist/src/postgres-repository.js"; + +const { Pool } = pg; + +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 : "API_SMOKE_FAILED" + }; +} + +let app; + +try { + const input = JSON.parse(await readStandardInput()); + const pool = new Pool({ + host: input.host, + port: input.port, + user: input.user, + password: input.password, + database: input.database, + ssl: input.ssl ? { rejectUnauthorized: false } : undefined, + max: 2, + application_name: "condon_api_readonly_smoke", + connectionTimeoutMillis: 10_000, + options: "-c default_transaction_read_only=on -c statement_timeout=15000 -c lock_timeout=2000" + }); + const repository = new PostgresCondoRepository(pool); + app = await buildApp({ repository, logger: false }); + + const requests = await Promise.all([ + app.inject({ method: "GET", url: "/health" }), + app.inject({ method: "GET", url: "/room-types" }), + app.inject({ method: "GET", url: "/owner-accounts?year=2026" }), + app.inject({ method: "GET", url: "/usage-records" }), + app.inject({ method: "GET", url: "/dashboard?year=2026" }) + ]); + const [health, roomTypes, owners, usage, dashboard] = requests; + + const checks = { + health: + health.statusCode === 200 + && health.json().database === "booking_test" + && health.json().schema === "condon", + roomTypes: + roomTypes.statusCode === 200 + && roomTypes.json().length === 11, + ownersImported: + owners.statusCode === 200 + && owners.json().total === 388 + && owners.json().items.length > 0, + usageImported: + usage.statusCode === 200 + && usage.json().total === 157 + && usage.json().items.length > 0, + dashboardImported: + dashboard.statusCode === 200 + && dashboard.json().ownerRooms === 388 + && dashboard.json().remainingPrivileges === 5394 + && dashboard.json().used === 438, + openApiPaths: Object.keys(app.swagger().paths ?? {}).length === 6 + }; + + process.stdout.write(`${JSON.stringify({ + ok: Object.values(checks).every(Boolean), + checks + }, null, 2)}\n`); + if (Object.values(checks).some(result => !result)) process.exitCode = 1; +} catch (error) { + process.stdout.write(`${JSON.stringify(safeError(error), null, 2)}\n`); + process.exitCode = 1; +} finally { + if (app) { + await app.close().catch(() => { + process.exitCode = 1; + }); + } +} diff --git a/backend/scripts/api-write-smoke.mjs b/backend/scripts/api-write-smoke.mjs new file mode 100644 index 0000000..968b891 --- /dev/null +++ b/backend/scripts/api-write-smoke.mjs @@ -0,0 +1,256 @@ +import { randomUUID } from "node:crypto"; +import process from "node:process"; +import { createInterface } from "node:readline"; +import pg from "pg"; +import { buildApp } from "../dist/src/app.js"; +import { PostgresCondoRepository } from "../dist/src/postgres-repository.js"; + +const { Pool } = pg; + +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 + : "API_WRITE_SMOKE_FAILED" + }; +} + +const checks = {}; +let pool; +let app; +let ownerId; +let periodId; +let testRoomNo; +let confirmationNo = "997000001"; +let baselineCounts; + +try { + const input = JSON.parse(await readStandardInput()); + pool = new Pool({ + host: input.host, + port: input.port, + user: input.user, + password: input.password, + database: input.database, + ssl: input.ssl ? { rejectUnauthorized: false } : undefined, + max: 3, + application_name: "condon_api_write_smoke", + connectionTimeoutMillis: 10_000, + options: "-c statement_timeout=30000 -c lock_timeout=5000" + }); + + const initialResult = await pool.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 + `); + baselineCounts = initialResult.rows[0]; + checks.initiallyAvailable = true; + + ownerId = randomUUID(); + periodId = randomUUID(); + testRoomNo = `T${ownerId.replaceAll("-", "").slice(0, 12)}`; + const setupClient = await pool.connect(); + try { + await setupClient.query("BEGIN"); + await setupClient.query( + ` + INSERT INTO condon.owner_accounts ( + id, + account_no, + transfer_date, + owner_name, + room_no, + purchased_room_type_code, + unit_no, + member_no + ) + VALUES ($1, NULL, NULL, 'API Write Test Owner', $2, 'RM1', 'TEST/3', 'TEST-3') + `, + [ownerId, testRoomNo] + ); + await setupClient.query( + ` + SELECT condon.open_entitlement_period( + $1::uuid, + $2::uuid, + 2097::smallint, + $3::uuid, + NULL::uuid + ) + `, + [periodId, ownerId, randomUUID()] + ); + await setupClient.query("COMMIT"); + } catch (error) { + await setupClient.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + setupClient.release(); + } + + const repository = new PostgresCondoRepository(pool); + app = await buildApp({ repository, logger: false }); + const idempotencyKey = randomUUID(); + const payload = { + ownerAccountId: ownerId, + confirmationNo, + checkIn: "2097-03-01", + checkOut: "2097-03-03", + usedRoomType: "RM1", + remark: "API write smoke", + idempotencyKey + }; + + const created = await app.inject({ + method: "POST", + url: "/usage-records", + payload + }); + const createdBody = created.json(); + checks.apiCreate = + created.statusCode === 201 + && createdBody.ownerAccountId === ownerId + && createdBody.night === 2 + && createdBody.use === 2 + && createdBody.balance === 13; + + const retried = await app.inject({ + method: "POST", + url: "/usage-records", + payload + }); + const retriedBody = retried.json(); + checks.apiIdempotency = + retried.statusCode === 201 + && retriedBody.id === createdBody.id + && retriedBody.balance === 13; + + const dashboard = await app.inject({ + method: "GET", + url: "/dashboard?year=2097" + }); + checks.apiDashboardReflectsWrite = + dashboard.statusCode === 200 + && dashboard.json().ownerRooms === Number(baselineCounts.owners) + 1 + && dashboard.json().remainingPrivileges === 13 + && dashboard.json().used === 2; + + const usage = await app.inject({ + method: "GET", + url: `/usage-records?ownerAccountId=${ownerId}` + }); + checks.apiHistoryReflectsWrite = + usage.statusCode === 200 + && usage.json().total === 1 + && usage.json().items[0].confirmationNo === confirmationNo; + + const ownerBeyondPage = await app.inject({ + method: "GET", + url: `/owner-accounts?q=${encodeURIComponent(testRoomNo)}&year=2097&page=2&pageSize=1` + }); + const usageBeyondPage = await app.inject({ + method: "GET", + url: `/usage-records?ownerAccountId=${ownerId}&page=2&pageSize=1` + }); + checks.paginationTotalsSurviveEmptyPage = + ownerBeyondPage.statusCode === 200 + && ownerBeyondPage.json().total === 1 + && ownerBeyondPage.json().items.length === 0 + && usageBeyondPage.statusCode === 200 + && usageBeyondPage.json().total === 1 + && usageBeyondPage.json().items.length === 0; +} catch (error) { + process.stdout.write(`${JSON.stringify(safeError(error), null, 2)}\n`); + process.exitCode = 1; +} finally { + if (pool && ownerId && periodId) { + const cleanup = await pool.connect().catch(() => undefined); + if (cleanup) { + try { + await cleanup.query("BEGIN"); + await cleanup.query( + "DELETE FROM condon.entitlement_ledger WHERE entitlement_period_id = $1", + [periodId] + ); + await cleanup.query( + "DELETE FROM condon.usage_records WHERE owner_account_id = $1", + [ownerId] + ); + await cleanup.query( + "DELETE FROM condon.bookings WHERE confirmation_no = $1", + [confirmationNo] + ); + await cleanup.query( + "DELETE FROM condon.entitlement_periods WHERE id = $1", + [periodId] + ); + await cleanup.query( + "DELETE FROM condon.owner_accounts WHERE id = $1", + [ownerId] + ); + await cleanup.query("COMMIT"); + checks.cleanup = true; + } catch { + await cleanup.query("ROLLBACK").catch(() => undefined); + checks.cleanup = false; + process.exitCode = 1; + } finally { + cleanup.release(); + } + } + } + + if (pool) { + try { + const finalResult = await pool.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 + `); + checks.finalBusinessTablesPreserved = Object.entries(baselineCounts).every( + ([key, value]) => Number(finalResult.rows[0][key]) === Number(value) + ); + } catch { + checks.finalBusinessTablesEmpty = false; + process.exitCode = 1; + } + } + + if (app) { + await app.close().catch(() => { + process.exitCode = 1; + }); + } else if (pool) { + await pool.end().catch(() => { + process.exitCode = 1; + }); + } + + if (Object.keys(checks).length > 0) { + const ok = Object.values(checks).every(Boolean); + process.stdout.write(`${JSON.stringify({ ok, checks }, null, 2)}\n`); + if (!ok) process.exitCode = 1; + } +} diff --git a/backend/scripts/audit-confirmation-workbook.py b/backend/scripts/audit-confirmation-workbook.py new file mode 100644 index 0000000..6bffa1f --- /dev/null +++ b/backend/scripts/audit-confirmation-workbook.py @@ -0,0 +1,837 @@ +#!/usr/bin/env python3 +"""Read-only structural audit for Confirmation Report 2026.xlsx.""" + +from __future__ import annotations + +import json +import re +import sys +from collections import Counter, defaultdict +from datetime import date, datetime +from pathlib import Path +from typing import Any + +from openpyxl import load_workbook + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_WORKBOOK = Path( + "/Users/chillishark/Desktop/Condo公寓/Confirmation Report 2026.xlsx" +) +OWNER_MD = PROJECT_ROOT / "业主账户.md" +USAGE_MD = PROJECT_ROOT / "使用记录.md" +VALID_PURCHASED_ROOM_TYPES = { + "RM1", + "RM2", + "RM3", + "RM4", + "UG1", + "UG2", + "SU1", + "SU2", + "SU6", + "SU3", + "AC2", +} + + +def scalar(value: Any) -> Any: + if isinstance(value, (datetime, date)): + return value.date().isoformat() if isinstance(value, datetime) else value.isoformat() + if isinstance(value, float) and value.is_integer(): + return int(value) + return value + + +def text(value: Any) -> str: + value = scalar(value) + return "" if value is None else str(value).strip() + + +def number(value: Any) -> int | None: + value = scalar(value) + if isinstance(value, int): + return value + if isinstance(value, str) and re.fullmatch(r"-?\d+", value.strip()): + return int(value.strip()) + return None + + +def date_value(value: Any) -> date | None: + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + if isinstance(value, str): + candidate = value.strip() + for parser in ( + lambda item: date.fromisoformat(item), + lambda item: datetime.strptime(item, "%d-%b-%y").date(), + lambda item: datetime.strptime(item, "%d-%b-%Y").date(), + ): + try: + return parser(candidate) + except ValueError: + continue + return None + + +def room_numbers(value: Any) -> list[str]: + return list(dict.fromkeys(re.findall(r"\b\d{4}\b", text(value)))) + + +def room_key(value: Any) -> tuple[str, ...]: + return tuple(sorted(room_numbers(value))) + + +def normalized_name(value: Any) -> str: + return re.sub(r"[^A-Z0-9]+", "", text(value).upper()) + + +def markdown_cells(line: str) -> list[str] | None: + value = line.strip() + if not value.startswith("|") or not value.endswith("|"): + return None + return [cell.strip() for cell in value[1:-1].split("|")] + + +def parse_owner_markdown() -> dict[str, dict[str, str]]: + owners: dict[str, dict[str, str]] = {} + for line_no, line in enumerate(OWNER_MD.read_text(encoding="utf-8").splitlines(), 1): + cells = markdown_cells(line) + if not cells or len(cells) != 5 or not re.fullmatch(r"\d{4}", cells[1]): + continue + owners[cells[1]] = { + "line": str(line_no), + "name": cells[0], + "room_type": cells[2], + "unit_no": cells[3], + "member_no": cells[4], + } + return owners + + +def parse_usage_markdown() -> dict[tuple[str, ...], list[dict[str, str]]]: + sections: dict[tuple[str, ...], list[dict[str, str]]] = {} + current_key: tuple[str, ...] | None = None + for line_no, line in enumerate(USAGE_MD.read_text(encoding="utf-8").splitlines(), 1): + heading = re.match(r"^## Room No\. (.+)$", line) + if heading: + current_key = room_key(heading.group(1)) + sections[current_key] = [] + continue + cells = markdown_cells(line) + if current_key is None or not cells or len(cells) != 9: + continue + if not re.match(r"^\d", cells[0]): + continue + sections[current_key].append( + { + "line": str(line_no), + "confirmation": cells[0], + "check_in": cells[1], + "check_out": cells[2], + "night": cells[3], + "room": cells[4], + "use": cells[5], + "balance": cells[6], + "room_type": cells[7], + "remark": cells[8], + } + ) + return sections + + +def normalize_room_type(value: Any) -> str: + return re.sub(r"\r?\n", "
", text(value)) + + +def normalize_header(value: Any) -> str: + """Normalize a spreadsheet header for resilient column lookup.""" + return re.sub(r"[^a-z0-9]+", "", text(value).lower()) + + +def audit(workbook_path: Path) -> dict[str, Any]: + formulas = load_workbook(workbook_path, data_only=False, read_only=False) + values = load_workbook(workbook_path, data_only=True, read_only=False) + master_formula = formulas["Total2024-2026"] + master_value = values["Total2024-2026"] + + # The newest workbook has an additional descriptive room-type column and + # places Unit/Member/Remaining at G/H/I. Resolve the six authoritative + # fields by header rather than assuming the old positional layout. + master_header_map: dict[str, int] = {} + for column in range(1, master_value.max_column + 1): + header = normalize_header(master_value.cell(2, column).value) + if header: + master_header_map.setdefault(header, column) + + def master_column(header: str, fallback: int) -> int: + return master_header_map.get(normalize_header(header), fallback) + + master_columns = { + "no": master_column("No", 1), + "transfer_date": master_column("Transfer Date", 2), + "name": master_column("Name", 3), + "room_no": master_column("Room No.", 4), + "room_type": master_column("Room Type", 5), + "unit_no": master_column("Unit No.", 6), + "member_no": master_column("Member No.", 7), + "remaining": master_column("Remaining stay privileges", 8), + } + + owners: list[dict[str, Any]] = [] + for row in range(4, master_value.max_row + 1): + room_no = text(master_value.cell(row, master_columns["room_no"]).value) + if not re.fullmatch(r"\d{4}", room_no): + continue + owners.append( + { + "row": row, + "no": number(master_value.cell(row, master_columns["no"]).value), + "transfer_date": scalar( + master_value.cell(row, master_columns["transfer_date"]).value + ), + "name": text(master_value.cell(row, master_columns["name"]).value), + "room_no": room_no, + "room_type": text( + master_value.cell(row, master_columns["room_type"]).value + ), + "unit_no": text( + master_value.cell(row, master_columns["unit_no"]).value + ), + "member_no": text( + master_value.cell(row, master_columns["member_no"]).value + ), + "remaining": number( + master_value.cell(row, master_columns["remaining"]).value + ), + } + ) + owners_by_room = {owner["room_no"]: owner for owner in owners} + owner_rooms_by_name: dict[str, list[str]] = defaultdict(list) + for owner in owners: + owner_rooms_by_name[normalized_name(owner["name"])].append(owner["room_no"]) + + owner_required_field_issues = [ + { + "row": owner["row"], + "room_no": owner["room_no"], + "missing": [ + field + for field in ( + "no", + "transfer_date", + "name", + "room_no", + "room_type", + "unit_no", + "member_no", + "remaining", + ) + if owner[field] in (None, "") + ], + } + for owner in owners + if any( + owner[field] in (None, "") + for field in ( + "no", + "transfer_date", + "name", + "room_no", + "room_type", + "unit_no", + "member_no", + "remaining", + ) + ) + ] + owner_no_counts = Counter(owner["no"] for owner in owners if owner["no"] is not None) + owner_duplicate_nos = { + value: count for value, count in owner_no_counts.items() if count > 1 + } + owner_invalid_transfer_dates = [ + { + "row": owner["row"], + "room_no": owner["room_no"], + "transfer_date": owner["transfer_date"], + } + for owner in owners + if date_value(owner["transfer_date"]) is None + ] + owner_invalid_room_types = [ + { + "row": owner["row"], + "room_no": owner["room_no"], + "room_type": owner["room_type"], + } + for owner in owners + if owner["room_type"] not in VALID_PURCHASED_ROOM_TYPES + ] + owner_remaining_out_of_range = [ + { + "row": owner["row"], + "room_no": owner["room_no"], + "remaining": owner["remaining"], + } + for owner in owners + if owner["remaining"] is not None + and not 0 <= owner["remaining"] <= 15 + ] + + expected_headers = [ + "No.", + "Confirmation No.", + "Check -IN", + "Check-OUT", + "Night", + "Room", + "Total", + "Use", + "Balance", + "Room Type", + "Remark", + ] + sheets: list[dict[str, Any]] = [] + all_usage: list[dict[str, Any]] = [] + header_issues: list[dict[str, Any]] = [] + arithmetic_issues: list[dict[str, Any]] = [] + chain_issues: list[dict[str, Any]] = [] + initial_total_issues: list[dict[str, Any]] = [] + room_identity_mismatches: list[dict[str, Any]] = [] + usage_required_field_issues: list[dict[str, Any]] = [] + date_night_issues: list[dict[str, Any]] = [] + cross_year_issues: list[dict[str, Any]] = [] + numeric_domain_issues: list[dict[str, Any]] = [] + orphan_data_rows: list[dict[str, Any]] = [] + chronological_order_issues: list[dict[str, Any]] = [] + sheet_rooms_missing_from_master: list[dict[str, Any]] = [] + template_sheets: list[str] = [] + + for title in values.sheetnames[1:]: + value_sheet = values[title] + formula_sheet = formulas[title] + # A workbook may contain a blank non-room template (currently named + # ``FROM``). It is not an owner's usage tab and must not contribute + # to usage counts, orphan rows, or balance checks. + if not room_numbers(title): + template_sheets.append(title) + continue + headers = [text(value_sheet.cell(3, column).value).rstrip(" ") for column in range(1, 12)] + normalized_headers = [header.replace("Check-OUT ", "Check-OUT").strip() for header in headers] + if normalized_headers != expected_headers: + header_issues.append({"sheet": title, "headers": headers}) + + title_rooms = room_numbers(title) + header_rooms = room_numbers(value_sheet.cell(2, 4).value) + rooms = title_rooms or header_rooms + room_resolution = "sheet title and Room No. header agree" + if set(title_rooms) != set(header_rooms): + name_matches = owner_rooms_by_name.get( + normalized_name(value_sheet.cell(2, 2).value), [] + ) + rooms = title_rooms or header_rooms + room_resolution = "sheet title used; account metadata comes from master" + room_identity_mismatches.append( + { + "sheet": title, + "owner_name": text(value_sheet.cell(2, 2).value), + "title_rooms": title_rooms, + "header_rooms": header_rooms, + "master_name_matches": name_matches, + "resolved_rooms": rooms, + "resolution": room_resolution, + } + ) + missing_master_rooms = [room for room in rooms if room not in owners_by_room] + if missing_master_rooms: + sheet_rooms_missing_from_master.append( + {"sheet": title, "missing_rooms": missing_master_rooms} + ) + usages: list[dict[str, Any]] = [] + previous_balance: int | None = None + previous_check_in: date | None = None + for row in range(4, value_sheet.max_row + 1): + confirmation = text(value_sheet.cell(row, 2).value) + if not confirmation: + # E/G/H/I contain prefilled formulas on template rows. Only + # inspect user-entered columns when looking for an orphan row. + populated_columns = [ + column + for column in (3, 4, 6, 10, 11) + if text(value_sheet.cell(row, column).value) + ] + if populated_columns: + orphan_data_rows.append( + { + "sheet": title, + "row": row, + "populated_columns": populated_columns, + "no": number(value_sheet.cell(row, 1).value), + "check_in": scalar(value_sheet.cell(row, 3).value), + "check_out": scalar(value_sheet.cell(row, 4).value), + "night": number(value_sheet.cell(row, 5).value), + "room": number(value_sheet.cell(row, 6).value), + "total": number(value_sheet.cell(row, 7).value), + "use": number(value_sheet.cell(row, 8).value), + "balance": number(value_sheet.cell(row, 9).value), + "room_type": normalize_room_type( + value_sheet.cell(row, 10).value + ), + "remark": text(value_sheet.cell(row, 11).value), + } + ) + continue + item = { + "sheet": title, + "row": row, + "section_rooms": rooms, + "no": number(value_sheet.cell(row, 1).value), + "confirmation": confirmation, + "check_in": scalar(value_sheet.cell(row, 3).value), + "check_out": scalar(value_sheet.cell(row, 4).value), + "night": number(value_sheet.cell(row, 5).value), + "room": number(value_sheet.cell(row, 6).value), + "total": number(value_sheet.cell(row, 7).value), + "use": number(value_sheet.cell(row, 8).value), + "balance": number(value_sheet.cell(row, 9).value), + "room_type": normalize_room_type(value_sheet.cell(row, 10).value), + "remark": text(value_sheet.cell(row, 11).value), + "night_formula": text(formula_sheet.cell(row, 5).value), + "total_formula": text(formula_sheet.cell(row, 7).value), + "use_formula": text(formula_sheet.cell(row, 8).value), + "balance_formula": text(formula_sheet.cell(row, 9).value), + } + missing_fields = [ + field + for field in ( + "confirmation", + "check_in", + "check_out", + "night", + "room", + "total", + "use", + "balance", + "room_type", + "remark", + ) + if item[field] in (None, "") + ] + if missing_fields: + usage_required_field_issues.append( + {"sheet": title, "row": row, "missing": missing_fields} + ) + check_in_date = date_value(value_sheet.cell(row, 3).value) + check_out_date = date_value(value_sheet.cell(row, 4).value) + if ( + check_in_date is None + or check_out_date is None + or check_out_date <= check_in_date + or item["night"] != (check_out_date - check_in_date).days + ): + date_night_issues.append( + { + "sheet": title, + "row": row, + "check_in": item["check_in"], + "check_out": item["check_out"], + "night": item["night"], + } + ) + if ( + check_in_date is not None + and check_out_date is not None + and check_in_date.year != check_out_date.year + ): + cross_year_issues.append( + { + "sheet": title, + "row": row, + "check_in": item["check_in"], + "check_out": item["check_out"], + } + ) + if any( + item[field] is None or item[field] < minimum + for field, minimum in ( + ("night", 1), + ("room", 1), + ("total", 0), + ("use", 0), + ("balance", 0), + ) + ): + numeric_domain_issues.append( + { + "sheet": title, + "row": row, + "night": item["night"], + "room": item["room"], + "total": item["total"], + "use": item["use"], + "balance": item["balance"], + } + ) + if ( + previous_check_in is not None + and check_in_date is not None + and check_in_date < previous_check_in + ): + chronological_order_issues.append( + { + "sheet": title, + "row": row, + "previous_check_in": previous_check_in.isoformat(), + "actual_check_in": check_in_date.isoformat(), + } + ) + if check_in_date is not None: + previous_check_in = check_in_date + if ( + item["total"] is None + or item["use"] is None + or item["balance"] is None + or item["total"] - item["use"] != item["balance"] + ): + arithmetic_issues.append(item) + if previous_balance is not None and item["total"] != previous_balance: + chain_issues.append( + { + "sheet": title, + "row": row, + "expected_total": previous_balance, + "actual_total": item["total"], + } + ) + previous_balance = item["balance"] + usages.append(item) + all_usage.append(item) + + expected_initial = len(rooms) * 15 + if usages and usages[0]["total"] != expected_initial: + initial_total_issues.append( + { + "sheet": title, + "rooms": rooms, + "expected": expected_initial, + "actual": usages[0]["total"], + } + ) + final_balance = usages[-1]["balance"] if usages else None + master_remaining_sum = sum( + owners_by_room[room]["remaining"] or 0 + for room in rooms + if room in owners_by_room + ) + sheets.append( + { + "title": title, + "owner_name": text(value_sheet.cell(2, 2).value), + "room_no_raw": text(value_sheet.cell(2, 4).value), + "title_rooms": title_rooms, + "header_rooms": header_rooms, + "rooms": rooms, + "room_resolution": room_resolution, + "purchased_room_type_raw": text(value_sheet.cell(2, 6).value), + "transfer_on_raw": scalar(value_sheet.cell(2, 10).value), + "usage_count": len(usages), + "initial_total": usages[0]["total"] if usages else None, + "final_balance": final_balance, + "master_remaining_sum": master_remaining_sum, + "master_remaining_matches_final": final_balance == master_remaining_sum, + } + ) + + confirmations: dict[str, list[dict[str, Any]]] = defaultdict(list) + for item in all_usage: + confirmations[item["confirmation"]].append( + {"sheet": item["sheet"], "row": item["row"]} + ) + duplicate_confirmations = { + key: locations for key, locations in confirmations.items() if len(locations) > 1 + } + duplicate_confirmation_details = { + confirmation: [ + { + "sheet": item["sheet"], + "row": item["row"], + "section_rooms": item["section_rooms"], + "check_in": item["check_in"], + "check_out": item["check_out"], + "night": item["night"], + "room": item["room"], + "total": item["total"], + "use": item["use"], + "balance": item["balance"], + "room_type": item["room_type"], + "remark": item["remark"], + } + for item in items + ] + for confirmation, items in ( + (confirmation, [ + item for item in all_usage if item["confirmation"] == confirmation + ]) + for confirmation in duplicate_confirmations + ) + } + duplicate_confirmation_date_mismatches = { + confirmation: details + for confirmation, details in duplicate_confirmation_details.items() + if len({(item["check_in"], item["check_out"]) for item in details}) > 1 + } + usage_check_in_dates = [ + parsed + for item in all_usage + if (parsed := date_value(item["check_in"])) is not None + ] + + owner_md = parse_owner_markdown() + owner_md_differences: list[dict[str, Any]] = [] + for owner in owners: + markdown = owner_md.get(owner["room_no"]) + if markdown is None: + owner_md_differences.append( + {"room_no": owner["room_no"], "reason": "missing in markdown"} + ) + continue + differences = {} + for excel_key, md_key in [ + ("name", "name"), + ("room_type", "room_type"), + ("unit_no", "unit_no"), + ("member_no", "member_no"), + ]: + if text(owner[excel_key]) != text(markdown[md_key]): + differences[excel_key] = { + "excel": owner[excel_key], + "markdown": markdown[md_key], + } + if differences: + owner_md_differences.append( + {"room_no": owner["room_no"], "differences": differences} + ) + + usage_md = parse_usage_markdown() + workbook_by_key: dict[tuple[str, ...], list[dict[str, Any]]] = { + tuple(sorted(sheet["rooms"])): [ + item for item in all_usage if item["sheet"] == sheet["title"] + ] + for sheet in sheets + } + usage_md_count_differences: list[dict[str, Any]] = [] + usage_md_core_differences: list[dict[str, Any]] = [] + for key, workbook_rows in workbook_by_key.items(): + markdown_rows = usage_md.get(key, []) + if len(workbook_rows) != len(markdown_rows): + usage_md_count_differences.append( + { + "rooms": list(key), + "excel": len(workbook_rows), + "markdown": len(markdown_rows), + } + ) + continue + for index, (excel, markdown) in enumerate(zip(workbook_rows, markdown_rows), 1): + excel_core = [ + excel["confirmation"], + text(excel["check_in"]), + text(excel["check_out"]), + text(excel["night"]), + text(excel["room"]), + text(excel["use"]), + text(excel["balance"]), + ] + markdown_core = [ + markdown["confirmation"], + markdown["check_in"], + markdown["check_out"], + markdown["night"], + markdown["room"], + markdown["use"], + markdown["balance"], + ] + if excel_core != markdown_core: + usage_md_core_differences.append( + { + "rooms": list(key), + "index": index, + "excel": excel_core, + "markdown": markdown_core, + } + ) + + workbook_keys = set(workbook_by_key) + markdown_keys = set(usage_md) + for key in sorted(markdown_keys - workbook_keys): + usage_md_count_differences.append( + { + "rooms": list(key), + "excel": 0, + "markdown": len(usage_md[key]), + } + ) + + transfer_dates = [owner["transfer_date"] for owner in owners] + remaining_values = [owner["remaining"] for owner in owners] + single_sheets = [sheet for sheet in sheets if len(sheet["rooms"]) == 1] + grouped_sheets = [sheet for sheet in sheets if len(sheet["rooms"]) > 1] + nonempty_sheets = [sheet for sheet in sheets if sheet["usage_count"] > 0] + empty_sheets = [sheet for sheet in sheets if sheet["usage_count"] == 0] + + return { + "mode": "read-only", + "workbook": str(workbook_path), + "sheet_count": len(values.sheetnames), + "master_columns": master_columns, + "master": { + "record_count": len(owners), + "unique_room_count": len(owners_by_room), + "missing_transfer_dates": sum(value in (None, "") for value in transfer_dates), + "missing_remaining": sum(value is None for value in remaining_values), + "remaining_sum": sum(value or 0 for value in remaining_values), + "implied_used_from_388_x_15": len(owners) * 15 - sum(value or 0 for value in remaining_values), + "remaining_distribution": dict(sorted(Counter(remaining_values).items(), key=lambda item: (item[0] is None, item[0]))), + "room_type_distribution": dict(sorted(Counter(owner["room_type"] for owner in owners).items())), + "duplicate_member_numbers": { + member: [owner["room_no"] for owner in owners if owner["member_no"] == member] + for member, count in Counter(owner["member_no"] for owner in owners).items() + if count > 1 + }, + "duplicate_unit_numbers": { + unit: [owner["room_no"] for owner in owners if owner["unit_no"] == unit] + for unit, count in Counter(owner["unit_no"] for owner in owners).items() + if count > 1 + }, + "required_field_issues": owner_required_field_issues, + "duplicate_no_values": owner_duplicate_nos, + "invalid_transfer_dates": owner_invalid_transfer_dates, + "invalid_room_types": owner_invalid_room_types, + "remaining_out_of_range": owner_remaining_out_of_range, + "markdown_record_count": len(owner_md), + "markdown_differences": owner_md_differences, + }, + "usage": { + "sub_sheet_count": len(sheets), + "template_sheets": template_sheets, + "nonempty_sub_sheet_count": len(nonempty_sheets), + "empty_sub_sheet_count": len(empty_sheets), + "empty_sub_sheets": [sheet["title"] for sheet in empty_sheets], + "record_count": len(all_usage), + "single_room_sheet_count": len(single_sheets), + "grouped_room_sheet_count": len(grouped_sheets), + "room_count_distribution": dict(sorted(Counter(item["room"] for item in all_usage).items())), + "confirmation_length_distribution": dict( + sorted(Counter(len(item["confirmation"]) for item in all_usage).items()) + ), + "check_in_year_distribution": dict( + sorted(Counter(value.year for value in usage_check_in_dates).items()) + ), + "check_in_date_range": { + "minimum": min(usage_check_in_dates).isoformat() + if usage_check_in_dates + else None, + "maximum": max(usage_check_in_dates).isoformat() + if usage_check_in_dates + else None, + }, + "used_room_type_distribution": dict( + sorted(Counter(item["room_type"] for item in all_usage).items()) + ), + "canonical_used_room_type_count": sum( + item["room_type"] in VALID_PURCHASED_ROOM_TYPES for item in all_usage + ), + "noncanonical_used_room_type_rows": [ + { + "sheet": item["sheet"], + "row": item["row"], + "confirmation": item["confirmation"], + "room_type": item["room_type"], + } + for item in all_usage + if item["room_type"] not in VALID_PURCHASED_ROOM_TYPES + ], + "duplicate_confirmations": duplicate_confirmations, + "duplicate_confirmation_details": duplicate_confirmation_details, + "duplicate_confirmation_date_mismatches": duplicate_confirmation_date_mismatches, + "non_digit_confirmations": [ + { + "sheet": item["sheet"], + "row": item["row"], + "confirmation": item["confirmation"], + } + for item in all_usage + if not item["confirmation"].isdigit() + ], + "header_issues": header_issues, + "arithmetic_issues": arithmetic_issues, + "chain_issues": chain_issues, + "initial_total_issues": initial_total_issues, + "room_identity_mismatches": room_identity_mismatches, + "sheet_rooms_missing_from_master": sheet_rooms_missing_from_master, + "required_field_issues": usage_required_field_issues, + "date_night_issues": date_night_issues, + "cross_year_issues": cross_year_issues, + "numeric_domain_issues": numeric_domain_issues, + "orphan_data_rows": orphan_data_rows, + "chronological_order_issues": chronological_order_issues, + "use_sum": sum(item["use"] or 0 for item in all_usage), + "single_sheet_master_balance_mismatches": [ + sheet + for sheet in single_sheets + if sheet["usage_count"] > 0 + and not sheet["master_remaining_matches_final"] + ], + "empty_sheet_master_balance_results": empty_sheets, + "grouped_sheet_master_balance_results": grouped_sheets, + "room_gt_one_rows": [ + { + "sheet": item["sheet"], + "row": item["row"], + "confirmation": item["confirmation"], + "check_in": item["check_in"], + "check_out": item["check_out"], + "night": item["night"], + "room": item["room"], + "total": item["total"], + "use": item["use"], + "balance": item["balance"], + "room_type": item["room_type"], + "remark": item["remark"], + } + for item in all_usage + if (item["room"] or 0) > 1 + ], + "manual_use_rows": [ + { + "sheet": item["sheet"], + "row": item["row"], + "confirmation": item["confirmation"], + "night": item["night"], + "room": item["room"], + "use": item["use"], + "use_formula": item["use_formula"], + } + for item in all_usage + if not item["use_formula"].startswith("=") + ], + "markdown_section_count": len(usage_md), + "markdown_record_count": sum(len(rows) for rows in usage_md.values()), + "markdown_count_differences": usage_md_count_differences, + "markdown_core_differences": usage_md_core_differences, + }, + "sheets": sheets, + } + + +def main() -> None: + path = Path(sys.argv[1]).expanduser() if len(sys.argv) > 1 else DEFAULT_WORKBOOK + if not path.is_file(): + raise SystemExit(f"Workbook not found: {path}") + print(json.dumps(audit(path), ensure_ascii=False, indent=2, default=str)) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/audit-import-data.mjs b/backend/scripts/audit-import-data.mjs new file mode 100644 index 0000000..a5f637c --- /dev/null +++ b/backend/scripts/audit-import-data.mjs @@ -0,0 +1,885 @@ +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const projectRoot = resolve(scriptDirectory, "../.."); +const ownerFile = resolve(projectRoot, "业主账户.md"); +const usageFile = resolve(projectRoot, "使用记录.md"); + +const ROOM_TYPES = [ + "RM1", + "RM2", + "RM3", + "RM4", + "UG1", + "UG2", + "SU1", + "SU2", + "SU6", + "SU3", + "AC2" +]; +const ROOM_TYPE_SET = new Set(ROOM_TYPES); +const TIER = new Map([ + ["RM1", 1], + ["RM2", 1], + ["RM3", 1], + ["RM4", 1], + ["UG1", 1], + ["UG2", 1], + ["SU1", 2], + ["SU2", 2], + ["SU6", 2], + ["SU3", 3] +]); + +function markdownCells(line) { + const value = line.trim(); + if (!value.startsWith("|") || !value.endsWith("|")) return null; + return value + .slice(1, -1) + .split("|") + .map(cell => cell.trim()); +} + +function isSeparatorRow(cells) { + return cells?.length > 0 && cells.every(cell => /^:?-{3,}:?$/.test(cell)); +} + +function tableRows(lines, headerIndex, endIndex) { + const rows = []; + for (let index = headerIndex + 1; index < endIndex; index += 1) { + if (!lines[index].trim()) { + if (rows.length) break; + continue; + } + const cells = markdownCells(lines[index]); + if (!cells) { + if (rows.length) break; + continue; + } + if (isSeparatorRow(cells)) continue; + rows.push({ cells, line: index + 1 }); + } + return rows; +} + +function integerValue(value) { + return /^-?\d+$/.test(value) ? Number(value) : null; +} + +function roomNumbers(value) { + return [...new Set(value.match(/\b\d{4}\b/g) ?? [])]; +} + +function normalizedName(value) { + return value + .normalize("NFKC") + .toUpperCase() + .replace(/[^A-Z0-9\u0E00-\u0E7F]+/g, ""); +} + +function sameValues(left, right) { + return [...left].sort().join("|") === [...right].sort().join("|"); +} + +function validIsoDate(value) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const timestamp = Date.parse(`${value}T00:00:00Z`); + return Number.isFinite(timestamp) + && new Date(timestamp).toISOString().slice(0, 10) === value; +} + +function dateDifference(checkIn, checkOut) { + if (!validIsoDate(checkIn) || !validIsoDate(checkOut)) return null; + return Math.round( + (Date.parse(`${checkOut}T00:00:00Z`) - Date.parse(`${checkIn}T00:00:00Z`)) + / 86_400_000 + ); +} + +function grouped(items, keyOf) { + const groups = new Map(); + for (const item of items) { + const key = keyOf(item); + const values = groups.get(key) ?? []; + values.push(item); + groups.set(key, values); + } + return groups; +} + +function duplicateSummary(items, keyOf, project) { + return [...grouped(items, keyOf)] + .filter(([, values]) => values.length > 1) + .map(([key, values]) => ({ + key, + count: values.length, + records: values.map(project) + })); +} + +function explicitRoomTypeCodes(value) { + const upper = value.toUpperCase(); + return ROOM_TYPES.filter(code => { + const expression = new RegExp(`(^|[^A-Z0-9])${code}([^A-Z0-9]|$)`); + return expression.test(upper); + }); +} + +function classifyUsedRoomType(value) { + const normalized = value + .replace(//gi, " ") + .replace(/\s+/g, " ") + .trim() + .toUpperCase(); + const explicitCodes = explicitRoomTypeCodes(normalized); + if (explicitCodes.length === 1) { + const code = explicitCodes[0]; + return { + kind: "canonical", + code, + tier: TIER.get(code) ?? null, + candidates: [code], + normalized + }; + } + if (/\bFAM(?:ILY)?\b/.test(normalized)) { + return { + kind: "alias", + code: null, + tier: 3, + candidates: ["SU3"], + normalized + }; + } + if (normalized.includes("JUNIOR SUITE")) { + return { + kind: "alias", + code: null, + tier: 2, + candidates: ["SU1", "SU2", "SU6"], + normalized + }; + } + if (normalized.includes("SUPERIOR ROOM")) { + return { + kind: "alias", + code: null, + tier: 1, + candidates: ["RM1", "RM2", "RM3", "RM4"], + normalized + }; + } + if (normalized.includes("DELUXE ROOM")) { + return { + kind: "alias", + code: null, + tier: 1, + candidates: ["UG1", "UG2"], + normalized + }; + } + return { + kind: "unknown", + code: null, + tier: null, + candidates: [], + normalized + }; +} + +function automaticMultiplier(purchasedCodes, usedClassification, impliedEffectiveRatio) { + if ( + purchasedCodes.includes("AC2") + || usedClassification.code === "AC2" + ) { + return Number.isInteger(impliedEffectiveRatio) + && impliedEffectiveRatio >= 1 + && impliedEffectiveRatio <= 3 + ? impliedEffectiveRatio + : null; + } + const purchasedTiers = purchasedCodes.map(code => TIER.get(code)); + if ( + !purchasedTiers.length + || purchasedTiers.some(tier => !tier) + || new Set(purchasedTiers).size !== 1 + || !usedClassification.tier + ) return null; + return Math.max(1, usedClassification.tier - purchasedTiers[0] + 1); +} + +const [ownerText, usageText] = await Promise.all([ + readFile(ownerFile, "utf8"), + readFile(usageFile, "utf8") +]); +const ownerLines = ownerText.split(/\r?\n/); +const usageLines = usageText.split(/\r?\n/); + +const ownerHeaderIndex = ownerLines.findIndex(line => + line.startsWith("| Name | Room No. | Room Type | Unit No. | Member No. |") +); +if (ownerHeaderIndex < 0) throw new Error("OWNER_HEADER_NOT_FOUND"); + +const ownerRows = tableRows(ownerLines, ownerHeaderIndex, ownerLines.length) + .map(({ cells, line }) => { + if (cells.length !== 5) { + return { line, malformed: true, cells }; + } + const [name, roomNo, roomType, unitNo, memberNo] = cells; + return { + line, + malformed: false, + name, + roomNo, + roomType, + unitNo, + memberNo + }; + }); +const validOwnerRows = ownerRows.filter(row => !row.malformed); +const ownersByRoom = new Map(validOwnerRows.map(owner => [owner.roomNo, owner])); + +const sectionStarts = usageLines + .map((line, index) => { + const match = line.match(/^## Room No\. (.+)$/); + return match ? { index, heading: match[1].trim() } : null; + }) + .filter(Boolean); + +const sections = sectionStarts.map((start, sectionIndex) => { + const end = sectionStarts[sectionIndex + 1]?.index ?? usageLines.length; + const metadataHeaderIndex = usageLines.findIndex( + (line, index) => + index > start.index + && index < end + && line.startsWith("| Owenr Name | Room No. | PURCHASED ROOM TYPE |"), + ); + const usageHeaderIndex = usageLines.findIndex( + (line, index) => + index > start.index + && index < end + && line.startsWith("| Confirmation No. | Check -IN | Check-OUT |"), + ); + const metadataRows = metadataHeaderIndex < 0 + ? [] + : tableRows(usageLines, metadataHeaderIndex, usageHeaderIndex < 0 ? end : usageHeaderIndex); + const usageRows = usageHeaderIndex < 0 + ? [] + : tableRows(usageLines, usageHeaderIndex, end); + const metadata = metadataRows[0]?.cells.length === 4 + ? { + ownerName: metadataRows[0].cells[0], + roomNoRaw: metadataRows[0].cells[1], + purchasedTypeRaw: metadataRows[0].cells[2], + transferOnRaw: metadataRows[0].cells[3], + line: metadataRows[0].line + } + : null; + + return { + heading: start.heading, + line: start.index + 1, + headingRooms: roomNumbers(start.heading), + metadata, + metadataRooms: metadata ? roomNumbers(metadata.roomNoRaw) : [], + usageRows: usageRows.map(({ cells, line }) => { + if (cells.length !== 9) return { line, malformed: true, cells }; + const [ + confirmationNo, + checkIn, + checkOut, + nightRaw, + roomRaw, + useRaw, + balanceRaw, + usedRoomTypeRaw, + remark + ] = cells; + const night = integerValue(nightRaw); + const room = integerValue(roomRaw); + const use = integerValue(useRaw); + const balance = integerValue(balanceRaw); + const denominator = night && room ? night * room : null; + const impliedEffectiveRatio = denominator && use !== null && use % denominator === 0 + ? use / denominator + : null; + const usedClassification = classifyUsedRoomType(usedRoomTypeRaw); + const remarkRoomNumbers = roomNumbers(remark); + const remarkOwners = remarkRoomNumbers + .map(roomNo => ownersByRoom.get(roomNo)) + .filter(Boolean); + const remarkRoomTypes = [...new Set(remarkOwners.map(owner => owner.roomType))]; + const remarkResolutionComplete = remarkRoomNumbers.length > 0 + && remarkOwners.length === remarkRoomNumbers.length + && remarkRoomTypes.length === 1; + const resolvedUsedClassification = remarkResolutionComplete + ? { + kind: "remark", + code: remarkRoomTypes[0], + tier: TIER.get(remarkRoomTypes[0]) ?? null, + candidates: [remarkRoomTypes[0]], + normalized: usedClassification.normalized + } + : usedClassification; + return { + line, + malformed: false, + confirmationNo, + checkIn, + checkOut, + nightRaw, + roomRaw, + useRaw, + balanceRaw, + night, + room, + use, + balance, + usedRoomTypeRaw, + usedClassification, + resolvedUsedClassification, + usedTypeConflict: Boolean( + usedClassification.code + && resolvedUsedClassification.code + && usedClassification.code !== resolvedUsedClassification.code + ), + remarkRoomNumbers, + remarkRoomsFound: remarkOwners.length, + remarkRoomTypes, + remark, + impliedEffectiveRatio + }; + }) + }; +}); + +const issues = []; +function issue(code, severity, location, detail) { + issues.push({ code, severity, ...location, detail }); +} + +for (const row of ownerRows) { + if (row.malformed) { + issue("OWNER_ROW_MALFORMED", "blocker", { + file: "业主账户.md", + line: row.line + }, `Expected 5 columns, found ${row.cells.length}`); + continue; + } + for (const field of ["name", "roomNo", "roomType", "unitNo", "memberNo"]) { + if (!row[field]) { + issue("OWNER_REQUIRED_VALUE_MISSING", "blocker", { + file: "业主账户.md", + line: row.line, + roomNo: row.roomNo + }, `Missing ${field}`); + } + } + if (!/^\d{4}$/.test(row.roomNo)) { + issue("OWNER_ROOM_FORMAT_INVALID", "blocker", { + file: "业主账户.md", + line: row.line, + roomNo: row.roomNo + }, "Room No. is not exactly four digits"); + } + if (!ROOM_TYPE_SET.has(row.roomType)) { + issue("OWNER_ROOM_TYPE_UNKNOWN", "blocker", { + file: "业主账户.md", + line: row.line, + roomNo: row.roomNo + }, `Unknown purchased room type ${row.roomType}`); + } +} + +const ownerRoomDuplicates = duplicateSummary( + validOwnerRows, + owner => owner.roomNo, + owner => ({ line: owner.line, name: owner.name }) +); +for (const duplicate of ownerRoomDuplicates) { + issue("OWNER_ROOM_DUPLICATE", "blocker", { + file: "业主账户.md", + roomNo: duplicate.key + }, `${duplicate.count} owner rows use this room number`); +} + +const allUsageRows = []; +for (const section of sections) { + if (!section.metadata) { + issue("SECTION_METADATA_MISSING", "blocker", { + file: "使用记录.md", + line: section.line, + section: section.heading + }, "Owner metadata row is missing or malformed"); + } + if (!section.usageRows.length) { + issue("SECTION_USAGE_MISSING", "blocker", { + file: "使用记录.md", + line: section.line, + section: section.heading + }, "No usage rows were parsed"); + } + if (section.metadata && !sameValues(section.headingRooms, section.metadataRooms)) { + issue("SECTION_ROOM_HEADING_MISMATCH", "blocker", { + file: "使用记录.md", + line: section.line, + section: section.heading + }, `Heading rooms ${section.headingRooms.join(",")} differ from metadata rooms ${section.metadataRooms.join(",")}`); + } + for (const roomNo of section.metadataRooms) { + if (!ownersByRoom.has(roomNo)) { + issue("SECTION_OWNER_ROOM_NOT_FOUND", "blocker", { + file: "使用记录.md", + line: section.metadata?.line ?? section.line, + section: section.heading, + roomNo + }, "Room number is absent from 业主账户.md"); + } + } + if (section.metadataRooms.length > 1) { + issue("SECTION_MULTIPLE_ENTITLEMENT_ROOMS", "review", { + file: "使用记录.md", + line: section.metadata?.line ?? section.line, + section: section.heading + }, `${section.metadataRooms.length} independent owner rooms are combined`); + } + if (section.metadata) { + const explicitPurchased = explicitRoomTypeCodes(section.metadata.purchasedTypeRaw); + if (!explicitPurchased.length) { + issue("SECTION_PURCHASED_TYPE_NONCANONICAL", "review", { + file: "使用记录.md", + line: section.metadata.line, + section: section.heading + }, `Noncanonical purchased type: ${section.metadata.purchasedTypeRaw}`); + } + if (section.metadataRooms.length === 1) { + const master = ownersByRoom.get(section.metadataRooms[0]); + if ( + master + && explicitPurchased.length === 1 + && explicitPurchased[0] !== master.roomType + ) { + issue("SECTION_PURCHASED_TYPE_MISMATCH", "blocker", { + file: "使用记录.md", + line: section.metadata.line, + section: section.heading, + roomNo: master.roomNo + }, `Metadata says ${explicitPurchased[0]}, owner master says ${master.roomType}`); + } + if ( + master + && normalizedName(section.metadata.ownerName) !== normalizedName(master.name) + ) { + issue("SECTION_OWNER_NAME_MISMATCH", "review", { + file: "使用记录.md", + line: section.metadata.line, + section: section.heading, + roomNo: master.roomNo + }, `Metadata owner "${section.metadata.ownerName}" differs from master "${master.name}"`); + } + } + } + + const linkedOwners = section.metadataRooms + .map(roomNo => ownersByRoom.get(roomNo)) + .filter(Boolean); + const balancesByYear = new Map(); + const currentRuleBalancesByYear = new Map(); + const initialBalance = section.metadataRooms.length * 15; + + for (const row of section.usageRows) { + row.section = section.heading; + row.sectionLine = section.line; + row.metadataRooms = section.metadataRooms; + row.headingRooms = section.headingRooms; + row.linkedOwners = linkedOwners; + row.balanceChainOk = false; + allUsageRows.push(row); + + const location = { + file: "使用记录.md", + line: row.line, + section: section.heading, + confirmationNo: row.confirmationNo + }; + if (row.malformed) { + issue("USAGE_ROW_MALFORMED", "blocker", location, `Expected 9 columns, found ${row.cells.length}`); + continue; + } + if (!/^\d+$/.test(row.confirmationNo)) { + issue("CONFIRMATION_NOT_DIGITS", "blocker", location, `Value is ${row.confirmationNo}`); + } + for (const field of ["night", "room", "use", "balance"]) { + if (!Number.isInteger(row[field])) { + issue("USAGE_INTEGER_INVALID", "blocker", location, `${field} is not an integer`); + } + } + if (!validIsoDate(row.checkIn) || !validIsoDate(row.checkOut)) { + issue("USAGE_DATE_INVALID", "blocker", location, `${row.checkIn} → ${row.checkOut}`); + } else { + const difference = dateDifference(row.checkIn, row.checkOut); + if (difference !== row.night || difference <= 0) { + issue("NIGHT_DATE_MISMATCH", "blocker", location, `Source Night ${row.night}; date difference ${difference}`); + } + if (row.checkIn.slice(0, 4) !== row.checkOut.slice(0, 4)) { + issue("CROSS_YEAR_STAY", "blocker", location, `${row.checkIn} → ${row.checkOut}`); + } + } + if (!Number.isInteger(row.room) || row.room <= 0) { + issue("ROOM_COUNT_INVALID", "blocker", location, `Room is ${row.roomRaw}`); + } else if (row.room > 1) { + issue("ROOM_COUNT_GT_ONE", "transform", location, `Room=${row.room}; current usage model represents one room`); + } + if (!Number.isInteger(row.impliedEffectiveRatio)) { + issue("IMPLIED_EFFECTIVE_RATIO_NOT_INTEGER", "blocker", location, `Use ${row.use} is not divisible by Night×Room`); + } else if (row.impliedEffectiveRatio > 3) { + issue("IMPLIED_EFFECTIVE_RATIO_GT_THREE", "blocker", location, + `Use÷(Night×Room) implies ${row.impliedEffectiveRatio}; the source has no multiplier field`); + } + if (row.remarkRoomNumbers.length !== row.remarkRoomsFound) { + issue("REMARK_ROOM_NOT_FOUND", "review", location, + `Remark rooms ${row.remarkRoomNumbers.join(",")}; ${row.remarkRoomsFound} found in owner master`); + } + if ( + Number.isInteger(row.room) + && row.remarkRoomNumbers.length > 0 + && row.remarkRoomNumbers.length !== row.room + ) { + issue("REMARK_ROOM_COUNT_MISMATCH", "review", location, + `Room=${row.room}; Remark contains ${row.remarkRoomNumbers.length} room numbers`); + } + if (row.remarkRoomTypes.length > 1) { + issue("REMARK_MULTIPLE_USED_TYPES", "transform", location, + `Remark rooms resolve to ${row.remarkRoomTypes.join(",")}`); + } + if (row.usedTypeConflict) { + issue("USED_TYPE_REMARK_CONFLICT", "blocker", location, + `Raw code ${row.usedClassification.code}; Remark rooms resolve to ${row.resolvedUsedClassification.code}`); + } + if (!row.resolvedUsedClassification.code) { + issue("USED_ROOM_TYPE_MAPPING_REQUIRED", "review", location, + `${row.usedRoomTypeRaw} → candidates ${row.resolvedUsedClassification.candidates.join(",") || "unknown"}`); + } + + if ( + validIsoDate(row.checkIn) + && Number.isInteger(row.use) + && Number.isInteger(row.balance) + && initialBalance > 0 + ) { + const year = row.checkIn.slice(0, 4); + const balanceBefore = balancesByYear.get(year) ?? initialBalance; + const expectedBalance = balanceBefore - row.use; + row.balanceBeforeSource = balanceBefore; + row.expectedSourceBalance = expectedBalance; + row.balanceChainOk = row.balance === expectedBalance; + if (!row.balanceChainOk) { + issue("BALANCE_CHAIN_MISMATCH", "blocker", location, + `Expected ${expectedBalance} after ${balanceBefore}-${row.use}; source has ${row.balance}`); + } + balancesByYear.set(year, row.balance); + } + + const purchasedTypes = linkedOwners.map(owner => owner.roomType); + const expectedMultiplier = automaticMultiplier( + purchasedTypes, + row.resolvedUsedClassification, + row.impliedEffectiveRatio + ); + row.purchasedTypesForRule = purchasedTypes; + row.expectedMultiplier = expectedMultiplier; + row.expectedUseCurrentRule = expectedMultiplier !== null + && Number.isInteger(row.night) + && Number.isInteger(row.room) + ? row.night * row.room * expectedMultiplier + : null; + row.currentRuleCompatible = expectedMultiplier !== null + && row.use === row.expectedUseCurrentRule; + if ( + expectedMultiplier !== null + && Number.isInteger(row.use) + && Number.isInteger(row.night) + && Number.isInteger(row.room) + && !row.currentRuleCompatible + ) { + issue("CURRENT_RULE_USE_MISMATCH", "blocker", location, + `Source Use ${row.use}; current room lookup/rules imply ${row.expectedUseCurrentRule}`); + } + + if (validIsoDate(row.checkIn)) { + const year = row.checkIn.slice(0, 4); + const balanceBefore = currentRuleBalancesByYear.has(year) + ? currentRuleBalancesByYear.get(year) + : initialBalance; + if ( + balanceBefore !== null + && Number.isInteger(row.expectedUseCurrentRule) + ) { + row.balanceBeforeCurrentRule = balanceBefore; + row.balanceAfterCurrentRule = balanceBefore - row.expectedUseCurrentRule; + row.currentRuleBalanceMatchesSource = row.balanceAfterCurrentRule === row.balance; + if (!row.currentRuleBalanceMatchesSource) { + issue("CURRENT_RULE_BALANCE_MISMATCH", "blocker", location, + `Current-rule balance would be ${row.balanceAfterCurrentRule}; source has ${row.balance}`); + } + currentRuleBalancesByYear.set(year, row.balanceAfterCurrentRule); + } else { + row.currentRuleBalanceMatchesSource = null; + currentRuleBalancesByYear.set(year, null); + } + } + } +} + +const usableRows = allUsageRows.filter(row => !row.malformed); +const confirmationDuplicates = duplicateSummary( + usableRows, + row => row.confirmationNo, + row => ({ line: row.line, section: row.section, balance: row.balance, usedRoomType: row.usedRoomTypeRaw }) +); +for (const duplicate of confirmationDuplicates) { + issue("CONFIRMATION_DUPLICATE", "blocker", { + file: "使用记录.md", + confirmationNo: duplicate.key + }, `${duplicate.count} usage lines share this confirmation`); +} + +const duplicateConfirmations = new Set(confirmationDuplicates.map(item => item.key)); +for (const row of usableRows) { + const singleLinkedOwner = row.metadataRooms.length === 1 + && row.linkedOwners.length === 1; + const headingMatches = sameValues(row.headingRooms, row.metadataRooms); + const validDates = validIsoDate(row.checkIn) + && validIsoDate(row.checkOut) + && dateDifference(row.checkIn, row.checkOut) === row.night + && row.checkIn.slice(0, 4) === row.checkOut.slice(0, 4); + row.structurallyDirect = singleLinkedOwner + && headingMatches + && row.room === 1 + && /^\d+$/.test(row.confirmationNo) + && !duplicateConfirmations.has(row.confirmationNo) + && validDates + && row.balanceChainOk; + row.fullyDirect = row.structurallyDirect + && Boolean(row.resolvedUsedClassification.code) + && !row.usedTypeConflict + && row.currentRuleCompatible + && row.currentRuleBalanceMatchesSource !== false; +} + +const declaredOwnerCount = Number(ownerText.match(/共\s*(\d+)\s*条记录/)?.[1]); +const declaredSectionCount = Number(usageText.match(/共\s*(\d+)\s*个房号子表/)?.[1]); +const declaredUsageCount = Number(usageText.match(/、(\d+)\s*条实际使用记录/)?.[1]); +const declaredCoveredRoomCount = Number(usageText.match(/覆盖\s*(\d+)\s*个房号/)?.[1]); +if (declaredOwnerCount !== validOwnerRows.length) { + issue("DECLARED_OWNER_COUNT_MISMATCH", "blocker", { + file: "业主账户.md", + line: 3 + }, `Declared ${declaredOwnerCount}; parsed ${validOwnerRows.length}`); +} +if (declaredSectionCount !== sections.length) { + issue("DECLARED_SECTION_COUNT_MISMATCH", "blocker", { + file: "使用记录.md", + line: 3 + }, `Declared ${declaredSectionCount}; parsed ${sections.length}`); +} +if (declaredUsageCount !== usableRows.length) { + issue("DECLARED_USAGE_COUNT_MISMATCH", "blocker", { + file: "使用记录.md", + line: 3 + }, `Declared ${declaredUsageCount}; parsed ${usableRows.length}`); +} + +const uniqueHeadingRooms = new Set(sections.flatMap(section => section.headingRooms)); +if (declaredCoveredRoomCount !== uniqueHeadingRooms.size) { + issue("DECLARED_COVERED_ROOM_COUNT_MISMATCH", "blocker", { + file: "使用记录.md", + line: 5 + }, `Declared ${declaredCoveredRoomCount}; heading tokens contain ${uniqueHeadingRooms.size}`); +} + +const issueCounts = Object.fromEntries( + [...grouped(issues, item => item.code)] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([code, values]) => [code, values.length]) +); +const issueSamples = Object.fromEntries( + [...grouped(issues, item => item.code)] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([code, values]) => [code, values.slice(0, 8)]) +); +const blockerIssues = issues.filter(item => item.severity === "blocker"); +const transformIssues = issues.filter(item => item.severity === "transform"); + +const roomTypeDistribution = Object.fromEntries( + [...grouped(validOwnerRows, owner => owner.roomType)] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([code, values]) => [code, values.length]) +); +const usedRoomTypeDistribution = Object.fromEntries( + [...grouped(usableRows, row => row.usedRoomTypeRaw)] + .sort((left, right) => right[1].length - left[1].length || left[0].localeCompare(right[0])) + .map(([label, values]) => [label, values.length]) +); +const impliedEffectiveRatioDistribution = Object.fromEntries( + [...grouped(usableRows, row => String(row.impliedEffectiveRatio))] + .sort(([left], [right]) => Number(left) - Number(right)) + .map(([value, rows]) => [value, rows.length]) +); +const yearDistribution = Object.fromEntries( + [...grouped(usableRows, row => row.checkIn.slice(0, 4))] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([year, rows]) => [year, rows.length]) +); +const metadataValues = sections + .filter(section => section.metadata) + .map(section => ({ + line: section.metadata.line, + section: section.heading, + transferOnRaw: section.metadata.transferOnRaw, + purchasedTypeRaw: section.metadata.purchasedTypeRaw + })); + +const report = { + mode: "read-only", + sourceFiles: { + owners: "业主账户.md", + usage: "使用记录.md" + }, + owners: { + declaredRecords: declaredOwnerCount, + parsedRecords: validOwnerRows.length, + malformedRows: ownerRows.length - validOwnerRows.length, + uniqueRoomNumbers: new Set(validOwnerRows.map(owner => owner.roomNo)).size, + roomTypeDistribution, + importDefaultsRequired: { + accountNo: "not supplied; database column is nullable", + transferDate: "not supplied in 业主账户.md; database column is nullable" + }, + maxFieldLengths: { + name: Math.max(...validOwnerRows.map(owner => owner.name.length)), + roomNo: Math.max(...validOwnerRows.map(owner => owner.roomNo.length)), + roomType: Math.max(...validOwnerRows.map(owner => owner.roomType.length)), + unitNo: Math.max(...validOwnerRows.map(owner => owner.unitNo.length)), + memberNo: Math.max(...validOwnerRows.map(owner => owner.memberNo.length)) + }, + duplicateRoomNumbers: ownerRoomDuplicates, + duplicateMemberNumbers: duplicateSummary( + validOwnerRows, + owner => owner.memberNo, + owner => ({ line: owner.line, roomNo: owner.roomNo, name: owner.name }) + ), + duplicateUnitNumbers: duplicateSummary( + validOwnerRows, + owner => owner.unitNo, + owner => ({ line: owner.line, roomNo: owner.roomNo, name: owner.name }) + ) + }, + usage: { + declaredSections: declaredSectionCount, + parsedSections: sections.length, + declaredRecords: declaredUsageCount, + parsedRecords: usableRows.length, + declaredCoveredRooms: declaredCoveredRoomCount, + uniqueHeadingRooms: uniqueHeadingRooms.size, + uniqueMetadataRooms: new Set(sections.flatMap(section => section.metadataRooms)).size, + uniqueConfirmations: new Set(usableRows.map(row => row.confirmationNo)).size, + confirmationDuplicates, + nonDigitConfirmations: usableRows + .filter(row => !/^\d+$/.test(row.confirmationNo)) + .map(row => ({ line: row.line, section: row.section, confirmationNo: row.confirmationNo })), + yearDistribution, + impliedEffectiveRatioDistribution, + roomCountDistribution: Object.fromEntries( + [...grouped(usableRows, row => String(row.room))] + .sort(([left], [right]) => Number(left) - Number(right)) + .map(([value, rows]) => [value, rows.length]) + ), + usedRoomTypeDistribution, + rawCanonicalUsedTypeRows: usableRows.filter(row => row.usedClassification.kind === "canonical").length, + rawAliasUsedTypeRows: usableRows.filter(row => row.usedClassification.kind === "alias").length, + rawUnknownUsedTypeRows: usableRows.filter(row => row.usedClassification.kind === "unknown").length, + remarkResolvedUsedTypeRows: usableRows.filter(row => row.resolvedUsedClassification.kind === "remark").length, + resolvedCanonicalUsedTypeRows: usableRows.filter(row => Boolean(row.resolvedUsedClassification.code)).length, + unresolvedUsedTypeRows: usableRows.filter(row => !row.resolvedUsedClassification.code).length, + usedTypeConflictRows: usableRows.filter(row => row.usedTypeConflict).length, + structurallyDirectRows: usableRows.filter(row => row.structurallyDirect).length, + fullyDirectRows: usableRows.filter(row => row.fullyDirect).length, + currentRuleCompatibleRows: usableRows.filter(row => row.currentRuleCompatible).length, + currentRuleEvaluatedRows: usableRows.filter(row => row.expectedMultiplier !== null).length, + balanceChainValidRows: usableRows.filter(row => row.balanceChainOk).length, + currentRuleBalanceEvaluatedRows: usableRows.filter(row => typeof row.currentRuleBalanceMatchesSource === "boolean").length, + currentRuleBalanceMatchingRows: usableRows.filter(row => row.currentRuleBalanceMatchesSource === true).length, + currentRuleBalanceMismatchingRows: usableRows.filter(row => row.currentRuleBalanceMatchesSource === false).length, + maxRoomCount: Math.max(...usableRows.map(row => row.room ?? 0)), + maxBalance: Math.max(...usableRows.map(row => row.balance ?? 0)), + maxFieldLengths: { + confirmationNo: Math.max(...usableRows.map(row => row.confirmationNo.length)), + usedRoomTypeRaw: Math.max(...usableRows.map(row => row.usedRoomTypeRaw.length)), + remark: Math.max(...usableRows.map(row => row.remark.length)) + } + }, + associations: { + multiRoomSections: sections + .filter(section => section.metadataRooms.length > 1) + .map(section => ({ + line: section.line, + heading: section.heading, + metadataRooms: section.metadataRooms, + usageRows: section.usageRows.length + })), + headingMetadataMismatches: sections + .filter(section => !sameValues(section.headingRooms, section.metadataRooms)) + .map(section => ({ + line: section.line, + heading: section.heading, + headingRooms: section.headingRooms, + metadataRooms: section.metadataRooms + })), + metadataRoomsAbsentFromOwners: [...new Set( + sections + .flatMap(section => section.metadataRooms) + .filter(roomNo => !ownersByRoom.has(roomNo)) + )], + rowsWithRemarkRoomNumbers: usableRows.filter(row => row.remarkRoomNumbers.length > 0).length, + rowsWithAllRemarkRoomsFound: usableRows.filter(row => + row.remarkRoomNumbers.length > 0 + && row.remarkRoomNumbers.length === row.remarkRoomsFound + ).length, + rowsWithMultipleRemarkRoomTypes: usableRows.filter(row => row.remarkRoomTypes.length > 1).length + }, + metadata: { + isoTransferDateRows: metadataValues.filter(item => validIsoDate(item.transferOnRaw)).length, + nonIsoOrCompoundTransferDateRows: metadataValues + .filter(item => !validIsoDate(item.transferOnRaw)) + .map(item => ({ + line: item.line, + section: item.section, + transferOnRaw: item.transferOnRaw + })), + canonicalPurchasedTypeRows: metadataValues.filter(item => + explicitRoomTypeCodes(item.purchasedTypeRaw).length > 0 + ).length, + noncanonicalPurchasedTypeRows: metadataValues + .filter(item => explicitRoomTypeCodes(item.purchasedTypeRaw).length === 0) + .map(item => ({ + line: item.line, + section: item.section, + purchasedTypeRaw: item.purchasedTypeRaw + })) + }, + issueCounts, + issueSamples, + blockerIssues, + transformIssues, + severityCounts: Object.fromEntries( + [...grouped(issues, item => item.severity)] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([severity, values]) => [severity, values.length]) + ) +}; + +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); diff --git a/backend/scripts/check-migrations.mjs b/backend/scripts/check-migrations.mjs new file mode 100644 index 0000000..6225219 --- /dev/null +++ b/backend/scripts/check-migrations.mjs @@ -0,0 +1,179 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const backendDirectory = path.resolve(scriptDirectory, ".."); +const upPath = path.join( + backendDirectory, + "migrations", + "001_create_condon_schema.up.sql" +); +const downPath = path.join( + backendDirectory, + "migrations", + "001_create_condon_schema.down.sql" +); +const legacyUpPath = path.join( + backendDirectory, + "migrations", + "002_legacy_import_and_bookings.up.sql" +); +const legacyDownPath = path.join( + backendDirectory, + "migrations", + "002_legacy_import_and_bookings.down.sql" +); + +const [upSql, downSql, legacyUpSql, legacyDownSql] = await Promise.all([ + readFile(upPath, "utf8"), + readFile(downPath, "utf8"), + readFile(legacyUpPath, "utf8"), + readFile(legacyDownPath, "utf8") +]); + +const checks = []; +const record = (name, ok, detail = "") => { + checks.push({ name, ok, detail }); +}; +const count = (source, pattern) => [...source.matchAll(pattern)].length; + +record( + "up creates exact schema without reuse", + count(upSql, /^CREATE SCHEMA condon;$/gm) === 1 + && !/\bIF\s+NOT\s+EXISTS\b/i.test(upSql) +); +record( + "up has no cross-schema references", + !/\b(?:public|booking|finance|ingestion)\s*\./i.test(upSql) +); +record( + "down has no cross-schema references", + !/\b(?:public|booking|finance|ingestion)\s*\./i.test(downSql) +); +record( + "no shared-database mutations", + !/\b(?:CREATE\s+EXTENSION|ALTER\s+(?:ROLE|DATABASE|SYSTEM)|SET\s+ROLE|COPY|GRANT)\b/i.test( + `${upSql}\n${downSql}` + ) +); +record( + "no cascade or broad conditional drop", + !/\bCASCADE\b/i.test(`${upSql}\n${downSql}`) + && !/\bDROP\b[\s\S]{0,80}\bIF\s+EXISTS\b/i.test(downSql) +); +record( + "up does not delete or drop", + !/^\s*(?:DELETE|TRUNCATE|DROP)\b/im.test(upSql) +); + +const upMutationTargets = [ + ...upSql.matchAll( + /^\s*(?:CREATE\s+(?:TABLE|FUNCTION)|INSERT\s+INTO|UPDATE)\s+([a-z_][a-z0-9_.]*)/gim + ) +].map(match => match[1].toLowerCase()); +record( + "all up mutation targets are condon-qualified", + upMutationTargets.length > 0 + && upMutationTargets.every(target => target.startsWith("condon.")), + upMutationTargets.join(", ") +); + +const downDropTargets = [ + ...downSql.matchAll( + /^\s*DROP\s+(?:FUNCTION|INDEX|TABLE|SCHEMA)\s+([a-z_][a-z0-9_.]*)/gim + ) +].map(match => match[1].toLowerCase()); +record( + "all down drop targets are condon-qualified", + downDropTargets.length === 16 + && downDropTargets.every(target => target === "condon" || target.startsWith("condon.")), + downDropTargets.join(", ") +); + +record( + "expected table count", + count(upSql, /^CREATE TABLE condon\./gm) === 6 +); +record( + "expected function count and security mode", + count(upSql, /^CREATE FUNCTION condon\./gm) === 3 + && count(upSql, /^SECURITY INVOKER$/gm) === 3 + && count(upSql, /^SET search_path = pg_catalog$/gm) === 3 +); +record( + "public execute revoked on new functions", + count(upSql, /^REVOKE ALL ON FUNCTION condon\./gm) === 3 +); +record( + "indexes are created only on condon tables", + count(upSql, /^CREATE INDEX /gm) === 6 + && count(upSql, /^\s+ON condon\./gm) === 6 +); +record( + "foreign keys stay inside condon", + count(upSql, /^\s+REFERENCES condon\./gm) >= 6 + && !/^\s+REFERENCES (?!condon\.)/gim.test(upSql) +); + +const seededRoomTypes = [ + ...upSql.matchAll(/^\s*\('([A-Z0-9]+)',\s*(?:[123]|NULL),\s*(?:true|false)\)[,;]$/gm) +].map(match => match[1]); +record( + "exact room-type seed set", + JSON.stringify(seededRoomTypes.sort()) === JSON.stringify( + ["AC2", "RM1", "RM2", "RM3", "RM4", "SU1", "SU2", "SU3", "SU6", "UG1", "UG2"].sort() + ) +); +record( + "derived values are function-owned", + upSql.includes("v_night_count := p_check_out - p_check_in;") + && upSql.includes("v_use_nights := v_night_count * v_multiplier;") + && upSql.includes("v_balance_after := v_balance_before - v_use_nights;") +); +record( + "down removes only exact new object set", + count(downSql, /^DROP FUNCTION condon\./gm) === 3 + && count(downSql, /^DROP INDEX condon\./gm) === 6 + && count(downSql, /^DROP TABLE condon\./gm) === 6 + && count(downSql, /^DROP SCHEMA condon;$/gm) === 1 +); +record( + "legacy migration stays inside condon", + !/\b(?:public|booking|finance|ingestion)\s*\./i.test(`${legacyUpSql}\n${legacyDownSql}`) + && !/\bCASCADE\b/i.test(`${legacyUpSql}\n${legacyDownSql}`) +); +record( + "legacy migration has booking and source fields", + /^CREATE TABLE condon\.bookings/gm.test(legacyUpSql) + && legacyUpSql.includes("raw_used_room_type") + && legacyUpSql.includes("source_sheet") + && legacyUpSql.includes("source_sequence") + && legacyUpSql.includes("usage_records_confirmation_booking_fk") +); +record( + "legacy migration has 1:N API function", + /^CREATE FUNCTION condon\.create_usage_record_v2\(/m.test(legacyUpSql) + && legacyUpSql.includes("ON CONFLICT (confirmation_no) DO NOTHING") + && legacyUpSql.includes("rule_version = 'legacy-source'") + && /^REVOKE ALL ON FUNCTION condon\.create_usage_record_v2/gm.test(legacyUpSql) +); +record( + "legacy migration down reverses its objects", + /^DROP TABLE condon\.bookings;$/gm.test(legacyDownSql) + && /^DROP FUNCTION condon\.create_usage_record_v2/gm.test(legacyDownSql) + && /^DROP INDEX condon\.usage_records_source_location_key;$/gm.test(legacyDownSql) +); + +for (const check of checks) { + process.stdout.write( + `${check.ok ? "PASS" : "FAIL"} ${check.name}${check.ok || !check.detail ? "" : `: ${check.detail}`}\n` + ); +} + +const checksum = createHash("sha256").update(upSql).digest("hex"); +process.stdout.write(`migration checksum ${checksum}\n`); + +if (checks.some(check => !check.ok)) process.exitCode = 1; diff --git a/backend/scripts/db-inventory.mjs b/backend/scripts/db-inventory.mjs new file mode 100644 index 0000000..4167b75 --- /dev/null +++ b/backend/scripts/db-inventory.mjs @@ -0,0 +1,170 @@ +import { createHash } from "node:crypto"; +import process from "node:process"; +import { createInterface } from "node:readline"; +import pg from "pg"; + +const { Client } = pg; +const EXPECTED_DATABASE = "booking_test"; +const TARGET_SCHEMA = "condon"; + +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 sanitizeError(error) { + return { + ok: false, + errorCode: typeof error?.code === "string" ? error.code : "INVENTORY_FAILED" + }; +} + +function fingerprint(rows) { + return createHash("sha256").update(JSON.stringify(rows)).digest("hex"); +} + +let client; +let transactionStarted = false; + +try { + const rawInput = await readStandardInput(); + const input = JSON.parse(rawInput); + + 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_readonly_inventory", + connectionTimeoutMillis: 10_000, + query_timeout: 15_000, + options: "-c default_transaction_read_only=on -c statement_timeout=15000 -c lock_timeout=2000" + }); + + await client.connect(); + await client.query("BEGIN TRANSACTION READ ONLY"); + transactionStarted = true; + + const identityResult = await client.query(` + SELECT + current_database() AS database_name, + current_user AS role_name, + current_setting('server_version') AS server_version, + current_setting('server_version_num') AS server_version_num, + current_setting('transaction_read_only') AS transaction_read_only + `); + const identity = identityResult.rows[0]; + + if (identity.database_name !== EXPECTED_DATABASE) { + throw Object.assign(new Error("Unexpected database"), { code: "UNEXPECTED_DATABASE" }); + } + + const schemaResult = await client.query(` + SELECT nspname AS schema_name + FROM pg_catalog.pg_namespace + WHERE nspname !~ '^pg_temp_' + AND nspname !~ '^pg_toast_temp_' + ORDER BY nspname + `); + const schemaNames = schemaResult.rows.map(row => row.schema_name); + const condonExists = schemaNames.includes(TARGET_SCHEMA); + + const privilegeResult = await client.query(` + SELECT + has_database_privilege(current_user, current_database(), 'CONNECT') AS can_connect, + has_database_privilege(current_user, current_database(), 'CREATE') AS can_create_schema + `); + + const extensionResult = await client.query(` + SELECT extname AS extension_name, extversion AS extension_version + FROM pg_catalog.pg_extension + ORDER BY extname + `); + + const objectResult = await client.query(` + SELECT + n.nspname AS schema_name, + c.relname AS object_name, + c.relkind AS object_kind, + pg_catalog.pg_get_userbyid(c.relowner) AS owner_name + FROM pg_catalog.pg_class AS c + JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace + WHERE n.nspname NOT LIKE 'pg_%' + AND n.nspname <> 'information_schema' + ORDER BY n.nspname, c.relkind, c.relname + `); + + const routineResult = await client.query(` + SELECT + n.nspname AS schema_name, + p.proname AS routine_name, + pg_catalog.pg_get_userbyid(p.proowner) AS owner_name, + p.prokind AS routine_kind, + pg_catalog.pg_get_function_identity_arguments(p.oid) AS identity_arguments + FROM pg_catalog.pg_proc AS p + JOIN pg_catalog.pg_namespace AS n ON n.oid = p.pronamespace + WHERE n.nspname NOT LIKE 'pg_%' + AND n.nspname <> 'information_schema' + ORDER BY n.nspname, p.proname, identity_arguments + `); + + const relationCounts = Object.entries( + objectResult.rows.reduce((counts, row) => { + const key = `${row.schema_name}:${row.object_kind}`; + counts[key] = (counts[key] ?? 0) + 1; + return counts; + }, {}) + ).map(([key, count]) => { + const [schemaName, objectKind] = key.split(":"); + return { schemaName, objectKind, count }; + }); + + const report = { + ok: true, + databaseVerified: identity.database_name === EXPECTED_DATABASE, + roleVerified: identity.role_name === input.user, + serverVersion: identity.server_version, + serverVersionNum: Number(identity.server_version_num), + transactionReadOnly: identity.transaction_read_only === "on", + targetSchema: TARGET_SCHEMA, + targetSchemaExists: condonExists, + databasePrivileges: privilegeResult.rows[0], + schemaNames, + extensions: extensionResult.rows, + relationCounts, + existingObjectCount: objectResult.rowCount, + existingRoutineCount: routineResult.rowCount, + existingCatalogFingerprint: fingerprint({ + objects: objectResult.rows, + routines: routineResult.rows + }) + }; + + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + process.exitCode = condonExists ? 2 : 0; +} catch (error) { + process.stdout.write(`${JSON.stringify(sanitizeError(error))}\n`); + process.exitCode = 1; +} finally { + if (client) { + if (transactionStarted) { + try { + await client.query("ROLLBACK"); + } catch { + process.exitCode = 1; + } + } + await client.end().catch(() => { + process.exitCode = 1; + }); + } +} diff --git a/backend/scripts/import-confirmation-batch.mjs b/backend/scripts/import-confirmation-batch.mjs new file mode 100644 index 0000000..d988f7b --- /dev/null +++ b/backend/scripts/import-confirmation-batch.mjs @@ -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; }); +} diff --git a/backend/scripts/import-preview-server.mjs b/backend/scripts/import-preview-server.mjs new file mode 100644 index 0000000..e4616f1 --- /dev/null +++ b/backend/scripts/import-preview-server.mjs @@ -0,0 +1,504 @@ +import { createHash, randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { buildApp } from "../dist/src/app.js"; +import { ApiError } from "../dist/src/errors.js"; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(scriptDirectory, "../.."); +const defaultBatchDirectory = path.join( + projectRoot, + ".planning/data_import_audit/import_batch_2026_07_31" +); +const expectedSourceSha256 = "016dc36d15cc40a5004c49403a52ab0df24d0a38d27a867d414884e2a9592c3a"; +const expectedImport = Object.freeze({ + owners: 388, + usage: 157, + used2026: 438, + remaining2026: 5394 +}); +const importTimestamp = Date.UTC(2026, 6, 31, 12, 0, 0); + +const roomTypes = Object.freeze([ + ["RM1", 1], ["RM2", 1], ["RM3", 1], ["RM4", 1], ["UG1", 1], ["UG2", 1], + ["SU1", 2], ["SU2", 2], ["SU6", 2], ["SU3", 3], ["AC2", null] +].map(([code, entitlementTier]) => Object.freeze({ + code, + entitlementTier, + requiresManualMultiplier: entitlementTier === null +}))); + +function stableUuid(namespace, value) { + const bytes = createHash("sha256").update(`${namespace}:${value}`).digest().subarray(0, 16); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = bytes.toString("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function sourceOrder(left, right) { + const sheetDifference = String(left.source_sheet).localeCompare(String(right.source_sheet)); + if (sheetDifference !== 0) return sheetDifference; + const sequenceDifference = Number(left.source_sequence) - Number(right.source_sequence); + if (sequenceDifference !== 0) return sequenceDifference; + return Number(left.source_row) - Number(right.source_row); +} + +function paginate(items, query) { + const start = (query.page - 1) * query.pageSize; + return { + items: items.slice(start, start + query.pageSize), + total: items.length, + page: query.page, + pageSize: query.pageSize + }; +} + +function periodKey(ownerId, year) { + return `${ownerId}|${year}`; +} + +function lower(value) { + return String(value ?? "").toLocaleLowerCase("en"); +} + +function inputsMatch(left, right) { + return left.ownerAccountId === right.ownerAccountId + && left.confirmationNo === right.confirmationNo + && left.checkIn === right.checkIn + && left.checkOut === right.checkOut + && left.usedRoomType === right.usedRoomType + && left.manualMultiplier === right.manualMultiplier + && left.remark === right.remark; +} + +function safeIntegerEnvironment(name, fallback, minimum, maximum) { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value < minimum || value > maximum) { + throw Object.assign(new Error(`Invalid ${name}`), { code: "INVALID_PREVIEW_CONFIGURATION" }); + } + return value; +} + +async function readBatch(batchDirectory) { + const read = async fileName => JSON.parse( + await readFile(path.join(batchDirectory, fileName), "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 assertVerifiedBatch(batch) { + const { manifest, owners, usage, rejected } = batch; + const issueKeys = [ + "arithmetic_issues", + "balance_chain_issues", + "date_night_issues", + "missing_owner_rooms", + "owner_issues", + "required_usage_issues" + ]; + const hasIssues = issueKeys.some(key => !Array.isArray(manifest.checks?.[key]) || manifest.checks[key].length > 0); + const useSum = usage.reduce((sum, record) => sum + Number(record.use), 0); + + if (manifest.mode !== "local-import-preflight" + || manifest.source_sha256 !== expectedSourceSha256 + || owners.length !== expectedImport.owners + || usage.length !== expectedImport.usage + || owners.length !== manifest.counts?.owner_count + || usage.length !== manifest.counts?.accepted_usage_count + || rejected.length !== manifest.counts?.rejected_row_count + || useSum !== manifest.counts?.usage_use_sum + || manifest.checks?.room_gt_one_count !== 0 + || hasIssues) { + throw Object.assign(new Error("The retained import snapshot failed verification"), { + code: "INVALID_IMPORT_SNAPSHOT" + }); + } +} + +export class ImportSnapshotRepository { + static async load(batchDirectory = defaultBatchDirectory) { + const batch = await readBatch(batchDirectory); + assertVerifiedBatch(batch); + const repository = new ImportSnapshotRepository(batch); + const diagnostics = await repository.diagnostics(); + if (diagnostics.owners !== expectedImport.owners + || diagnostics.usage !== expectedImport.usage + || diagnostics.used2026 !== expectedImport.used2026 + || diagnostics.remaining2026 !== expectedImport.remaining2026) { + throw Object.assign(new Error("The import snapshot does not match the verified database totals"), { + code: "IMPORT_SNAPSHOT_TOTAL_MISMATCH" + }); + } + return repository; + } + + constructor(batch) { + this.manifest = batch.manifest; + this.ownerIdsByRoom = new Map(); + this.owners = batch.owners.map(record => { + const id = stableUuid("owner", record.room_no); + this.ownerIdsByRoom.set(record.room_no, id); + return { + id, + accountNo: record.account_no ?? null, + transferDate: record.transfer_date || null, + name: String(record.name), + roomNo: String(record.room_no), + purchasedRoomType: String(record.purchased_room_type_code), + unitNo: String(record.unit_no), + memberNo: String(record.member_no) + }; + }).sort((left, right) => ( + (left.accountNo ?? Number.MAX_SAFE_INTEGER) - (right.accountNo ?? Number.MAX_SAFE_INTEGER) + || left.roomNo.localeCompare(right.roomNo) + || left.id.localeCompare(right.id) + )); + this.ownerById = new Map(this.owners.map(owner => [owner.id, owner])); + this.periodBalances = new Map(); + this.periodYears = new Set(); + this.idempotentResponses = new Map(); + + const orderedUsage = [...batch.usage].sort(sourceOrder); + const usageByRoomAndYear = new Map(); + for (const record of orderedUsage) { + const key = `${record.owner_room_no}|${record.period_year}`; + const records = usageByRoomAndYear.get(key) ?? []; + records.push(record); + usageByRoomAndYear.set(key, records); + } + + for (const owner of this.owners) { + const room = owner.roomNo; + const rows2025 = usageByRoomAndYear.get(`${room}|2025`) ?? []; + const carryForward = rows2025.length > 0 ? Number(rows2025.at(-1).balance) : 0; + this.periodYears.add(periodKey(owner.id, 2026)); + const used2026 = (usageByRoomAndYear.get(`${room}|2026`) ?? []) + .reduce((sum, record) => sum + Number(record.use), 0); + this.periodBalances.set(periodKey(owner.id, 2026), 15 + carryForward - used2026); + + const otherYears = new Set( + orderedUsage + .filter(record => record.owner_room_no === room && Number(record.period_year) !== 2026) + .map(record => Number(record.period_year)) + ); + for (const year of otherYears) { + const rows = usageByRoomAndYear.get(`${room}|${year}`) ?? []; + this.periodYears.add(periodKey(owner.id, year)); + this.periodBalances.set( + periodKey(owner.id, year), + 15 - rows.reduce((sum, record) => sum + Number(record.use), 0) + ); + } + } + + this.usageEntries = orderedUsage.map((record, index) => { + const ownerAccountId = this.ownerIdsByRoom.get(record.owner_room_no); + const owner = this.ownerById.get(ownerAccountId); + if (!owner) { + throw Object.assign(new Error("Usage owner is missing from the snapshot"), { + code: "IMPORT_SNAPSHOT_OWNER_MISSING" + }); + } + return { + periodYear: Number(record.period_year), + sourceSheet: String(record.source_sheet), + sourceSequence: Number(record.source_sequence), + sourceRow: Number(record.source_row), + canonicalUsedRoomType: record.canonical_used_room_type_code || null, + publicRecord: { + id: stableUuid("usage", `${record.source_sheet}:${record.source_row}:${record.source_sequence}`), + confirmationNo: String(record.confirmation_no), + ownerAccountId, + ownerName: owner.name, + ownerRoomNo: owner.roomNo, + checkIn: String(record.check_in), + checkOut: String(record.check_out), + night: Number(record.night), + use: Number(record.use), + balance: Number(record.balance), + usedRoomType: String(record.canonical_used_room_type_code || record.raw_used_room_type || ""), + remark: String(record.remark ?? ""), + appliedMultiplier: null, + createdAt: new Date(importTimestamp + (orderedUsage.length - index) * 1_000).toISOString() + } + }; + }); + } + + async diagnostics() { + const dashboard = await this.getDashboard(2026); + return { + sourceSha256: this.manifest.source_sha256, + owners: this.owners.length, + usage: this.usageEntries.length, + used2026: dashboard.used, + remaining2026: dashboard.remainingPrivileges + }; + } + + async health() { + return { + status: "ok", + database: "booking_test", + schema: "condon", + migrationVersion: "002_legacy_import_and_bookings:snapshot" + }; + } + + async listRoomTypes() { + return roomTypes.map(roomType => ({ ...roomType })); + } + + ownerForYear(owner, periodYear) { + const key = periodKey(owner.id, periodYear); + return { + ...owner, + remainingStayPrivileges: this.periodYears.has(key) + ? this.periodBalances.get(key) + : null + }; + } + + async listOwnerAccounts(query) { + const needle = lower(query.query); + const filtered = this.owners.filter(owner => { + const searchable = lower([ + owner.accountNo ?? "", + owner.name, + owner.roomNo, + owner.unitNo, + owner.memberNo + ].join(" ")); + return (!needle || searchable.includes(needle)) + && (!query.roomType || owner.purchasedRoomType === query.roomType); + }); + return { + ...paginate(filtered.map(owner => this.ownerForYear(owner, query.periodYear)), query), + periodYear: query.periodYear + }; + } + + async getOwnerAccount(id, periodYear) { + const owner = this.ownerById.get(id); + return owner ? this.ownerForYear(owner, periodYear) : null; + } + + async listUsageRecords(query) { + const filtered = this.usageEntries.filter(entry => ( + (!query.ownerAccountId || entry.publicRecord.ownerAccountId === query.ownerAccountId) + && (!query.confirmationNo || entry.publicRecord.confirmationNo.includes(query.confirmationNo)) + && (!query.usedRoomType || entry.canonicalUsedRoomType === query.usedRoomType) + )); + return paginate(filtered.map(entry => ({ ...entry.publicRecord })), query); + } + + async createUsageRecord(input) { + const previous = this.idempotentResponses.get(input.idempotencyKey); + if (previous) { + if (!inputsMatch(previous.input, input)) { + throw new ApiError(409, "CONFLICT", "The request conflicts with current data"); + } + return { ...previous.response }; + } + + const owner = this.ownerById.get(input.ownerAccountId); + if (!owner) { + throw new ApiError(404, "NOT_FOUND", "The requested business record was not found"); + } + const purchased = roomTypes.find(roomType => roomType.code === owner.purchasedRoomType); + const used = roomTypes.find(roomType => roomType.code === input.usedRoomType); + if (!purchased || !used) { + throw new ApiError(404, "NOT_FOUND", "The requested business record was not found"); + } + + const checkInMs = Date.parse(`${input.checkIn}T00:00:00Z`); + const checkOutMs = Date.parse(`${input.checkOut}T00:00:00Z`); + const night = Math.round((checkOutMs - checkInMs) / 86_400_000); + if (!Number.isInteger(night) || night <= 0) { + throw new ApiError(400, "BUSINESS_RULE_VIOLATION", "The request violates a business rule"); + } + + const manualRequired = purchased.entitlementTier === null || used.entitlementTier === null; + if (manualRequired !== (input.manualMultiplier !== null)) { + throw new ApiError(400, "BUSINESS_RULE_VIOLATION", "The request violates a business rule"); + } + const appliedMultiplier = manualRequired + ? input.manualMultiplier + : Math.max(1, used.entitlementTier - purchased.entitlementTier + 1); + const periodYear = Number(input.checkIn.slice(0, 4)); + const key = periodKey(owner.id, periodYear); + if (!this.periodYears.has(key)) { + throw new ApiError(404, "NOT_FOUND", "The requested business record was not found"); + } + if (!input.checkOut.startsWith(`${periodYear}-`) || input.checkOut > `${periodYear}-12-31`) { + throw new ApiError(400, "BUSINESS_RULE_VIOLATION", "The request violates a business rule"); + } + + const use = night * appliedMultiplier; + const balanceBefore = this.periodBalances.get(key); + if (use > balanceBefore) { + throw new ApiError( + 422, + "INSUFFICIENT_BALANCE", + "The owner account does not have enough stay privileges" + ); + } + const balance = balanceBefore - use; + this.periodBalances.set(key, balance); + const response = { + id: randomUUID(), + confirmationNo: input.confirmationNo, + ownerAccountId: owner.id, + ownerName: owner.name, + ownerRoomNo: owner.roomNo, + checkIn: input.checkIn, + checkOut: input.checkOut, + night, + use, + balance, + usedRoomType: input.usedRoomType, + remark: input.remark, + appliedMultiplier, + createdAt: new Date().toISOString() + }; + this.usageEntries.push({ + periodYear, + sourceSheet: null, + sourceSequence: null, + sourceRow: null, + canonicalUsedRoomType: input.usedRoomType, + publicRecord: response + }); + this.idempotentResponses.set(input.idempotencyKey, { + input: { ...input }, + response: { ...response } + }); + return { ...response }; + } + + async getDashboard(periodYear) { + const purchasedCounts = new Map(); + for (const owner of this.owners) { + purchasedCounts.set( + owner.purchasedRoomType, + (purchasedCounts.get(owner.purchasedRoomType) ?? 0) + 1 + ); + } + + const yearEntries = this.usageEntries.filter(entry => entry.periodYear === periodYear); + const usedCounts = new Map(); + const monthlyCounts = new Map(); + for (const entry of yearEntries) { + const roomType = entry.publicRecord.usedRoomType; + usedCounts.set(roomType, (usedCounts.get(roomType) ?? 0) + entry.publicRecord.night); + const month = Number(entry.publicRecord.checkIn.slice(5, 7)); + monthlyCounts.set(month, (monthlyCounts.get(month) ?? 0) + entry.publicRecord.use); + } + + let remainingPrivileges = 0; + for (const owner of this.owners) { + const key = periodKey(owner.id, periodYear); + if (this.periodYears.has(key)) remainingPrivileges += this.periodBalances.get(key); + } + return { + periodYear, + ownerRooms: this.owners.length, + remainingPrivileges, + used: yearEntries.reduce((sum, entry) => sum + entry.publicRecord.use, 0), + purchasedRoomTypes: [...purchasedCounts] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([roomType, count]) => ({ roomType, count })), + usedRoomTypes: [...usedCounts] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([roomType, count]) => ({ roomType, count })), + monthlyUse: [...monthlyCounts] + .sort(([left], [right]) => left - right) + .map(([month, use]) => ({ month, use })) + }; + } + + async close() {} +} + +export async function startImportPreview({ + batchDirectory = process.env.CONDO_IMPORT_SNAPSHOT_DIR || defaultBatchDirectory, + host = process.env.API_HOST || "127.0.0.1", + port = safeIntegerEnvironment("API_PORT", 3000, 1, 65535) +} = {}) { + const repository = await ImportSnapshotRepository.load(path.resolve(batchDirectory)); + const corsOrigins = (process.env.CORS_ORIGINS || "http://127.0.0.1:4173,http://localhost:4173") + .split(",") + .map(origin => origin.trim()) + .filter(Boolean); + const app = await buildApp({ + repository, + corsOrigins, + authentication: { + username: process.env.AUTH_USERNAME || "wyndhamcondon", + password: process.env.AUTH_PASSWORD || "wyndhamcondon", + sessionTtlMs: safeIntegerEnvironment("AUTH_SESSION_TTL_HOURS", 12, 1, 168) * 60 * 60 * 1_000, + cookieSecure: process.env.AUTH_COOKIE_SECURE === "true" + }, + logger: false + }); + await app.listen({ host, port }); + return { app, repository, host, port }; +} + +async function main() { + const batchDirectory = path.resolve( + process.env.CONDO_IMPORT_SNAPSHOT_DIR || defaultBatchDirectory + ); + const repository = await ImportSnapshotRepository.load(batchDirectory); + const diagnostics = await repository.diagnostics(); + if (process.argv.includes("--check")) { + process.stdout.write(`${JSON.stringify({ + ok: true, + mode: "verified-import-snapshot", + ...diagnostics + }, null, 2)}\n`); + return; + } + + const preview = await startImportPreview({ batchDirectory }); + process.stdout.write(`${JSON.stringify({ + event: "IMPORT_PREVIEW_READY", + url: `http://${preview.host}:${preview.port}`, + owners: diagnostics.owners, + usage: diagnostics.usage, + used2026: diagnostics.used2026, + remaining2026: diagnostics.remaining2026 + })}\n`); + + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + await preview.app.close(); + }; + process.once("SIGINT", () => { void shutdown(); }); + process.once("SIGTERM", () => { void shutdown(); }); +} + +const isMain = process.argv[1] + && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; +if (isMain) { + main().catch(error => { + process.stderr.write(`${JSON.stringify({ + event: "IMPORT_PREVIEW_FAILED", + errorCode: typeof error?.code === "string" ? error.code : "IMPORT_PREVIEW_FAILED" + })}\n`); + process.exitCode = 1; + }); +} diff --git a/backend/scripts/live-ui-fixture.mjs b/backend/scripts/live-ui-fixture.mjs new file mode 100644 index 0000000..254df24 --- /dev/null +++ b/backend/scripts/live-ui-fixture.mjs @@ -0,0 +1,445 @@ +import { randomInt, randomUUID } from "node:crypto"; +import process from "node:process"; +import { createInterface } from "node:readline"; +import pg from "pg"; + +const { Pool } = pg; + +const TEST_YEAR = 2026; +const OWNER_NAME = "Codex Live UI Test"; +const PURCHASED_ROOM_TYPE = "RM1"; +const USED_ROOM_TYPE = "SU3"; +const CHECK_IN = "2026-08-10"; +const CHECK_OUT = "2026-08-12"; +const EXPECTED_NIGHT = 2; +const EXPECTED_MULTIPLIER = 3; +const EXPECTED_USE = 6; +const EXPECTED_BALANCE = 9; + +function safeError(error, fallback = "LIVE_UI_FIXTURE_FAILED") { + return typeof error?.code === "string" ? error.code : fallback; +} + +function writeEvent(event, details = {}) { + process.stdout.write(`${JSON.stringify({ event, ...details })}\n`); +} + +function businessCounts(row) { + return { + owners: Number(row.owners), + periods: Number(row.periods), + usage: Number(row.usage), + ledger: Number(row.ledger) + }; +} + +function allZero(counts) { + return Object.values(counts).every(value => value === 0); +} + +async function getBusinessCounts(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 + `); + return businessCounts(result.rows[0]); +} + +async function setupFixture(pool, fixture) { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const initialCounts = await getBusinessCounts(client); + if (!allZero(initialCounts)) { + throw Object.assign(new Error("CONDO business tables must be empty"), { + code: "TEST_REQUIRES_EMPTY_CONDON" + }); + } + + const multiplier = await client.query( + ` + SELECT condon.calculate_multiplier( + $1::varchar, + $2::varchar, + NULL::smallint + )::integer AS value + `, + [PURCHASED_ROOM_TYPE, USED_ROOM_TYPE] + ); + if (multiplier.rows[0].value !== EXPECTED_MULTIPLIER) { + throw Object.assign(new Error("Unexpected multiplier"), { + code: "UNEXPECTED_MULTIPLIER" + }); + } + + await client.query( + ` + INSERT INTO condon.owner_accounts ( + id, + account_no, + transfer_date, + owner_name, + room_no, + purchased_room_type_code, + unit_no, + member_no + ) + VALUES ($1, NULL, NULL, $2, $3, $4, $5, $6) + `, + [ + fixture.ownerId, + OWNER_NAME, + fixture.roomNo, + PURCHASED_ROOM_TYPE, + fixture.unitNo, + fixture.memberNo + ] + ); + + const periodResult = await client.query( + ` + SELECT period.* + FROM condon.open_entitlement_period( + $1::uuid, + $2::uuid, + $3::smallint, + $4::uuid, + NULL::uuid + ) AS period + `, + [ + fixture.periodId, + fixture.ownerId, + TEST_YEAR, + fixture.annualGrantLedgerId + ] + ); + const period = periodResult.rows[0]; + if ( + Number(period.annual_grant) !== 15 + || Number(period.carry_forward) !== 0 + || Number(period.current_balance) !== 15 + ) { + throw Object.assign(new Error("Unexpected entitlement period"), { + code: "UNEXPECTED_ENTITLEMENT_PERIOD" + }); + } + + const setupCounts = await getBusinessCounts(client); + if ( + setupCounts.owners !== 1 + || setupCounts.periods !== 1 + || setupCounts.usage !== 0 + || setupCounts.ledger !== 1 + ) { + throw Object.assign(new Error("Unexpected setup counts"), { + code: "UNEXPECTED_SETUP_COUNTS" + }); + } + + await client.query("COMMIT"); + return setupCounts; + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } +} + +async function observeUsage(pool, fixture) { + const result = await pool.query( + ` + SELECT + ur.id, + ur.confirmation_no, + ur.night_count, + ur.applied_multiplier, + ur.use_nights, + ur.balance_before, + ur.balance_after, + ur.used_room_type_code, + ur.remark, + ep.current_balance, + ep.row_version, + el.id AS usage_ledger_id, + el.delta_nights, + el.balance_before AS ledger_balance_before, + el.balance_after AS ledger_balance_after + FROM condon.usage_records AS ur + JOIN condon.entitlement_periods AS ep + ON ep.id = ur.entitlement_period_id + JOIN condon.entitlement_ledger AS el + ON el.usage_record_id = ur.id + WHERE ur.owner_account_id = $1 + AND ur.entitlement_period_id = $2 + AND ur.confirmation_no = $3 + `, + [fixture.ownerId, fixture.periodId, fixture.confirmationNo] + ); + if (result.rowCount !== 1) return null; + const row = result.rows[0]; + return { + usageRecordId: row.id, + usageLedgerId: row.usage_ledger_id, + confirmationNo: row.confirmation_no, + night: Number(row.night_count), + multiplier: Number(row.applied_multiplier), + use: Number(row.use_nights), + balanceBefore: Number(row.balance_before), + balance: Number(row.balance_after), + usedRoomType: row.used_room_type_code, + remark: row.remark, + periodBalance: Number(row.current_balance), + periodRowVersion: Number(row.row_version), + ledgerDelta: Number(row.delta_nights), + ledgerBalanceBefore: Number(row.ledger_balance_before), + ledgerBalance: Number(row.ledger_balance_after) + }; +} + +function usageIsExpected(usage, fixture) { + return usage !== null + && usage.confirmationNo === fixture.confirmationNo + && usage.night === EXPECTED_NIGHT + && usage.multiplier === EXPECTED_MULTIPLIER + && usage.use === EXPECTED_USE + && usage.balanceBefore === 15 + && usage.balance === EXPECTED_BALANCE + && usage.usedRoomType === USED_ROOM_TYPE + && usage.remark === fixture.remark + && usage.periodBalance === EXPECTED_BALANCE + && usage.periodRowVersion === 2 + && usage.ledgerDelta === -EXPECTED_USE + && usage.ledgerBalanceBefore === 15 + && usage.ledgerBalance === EXPECTED_BALANCE; +} + +async function cleanupFixture(pool, fixture) { + const client = await pool.connect(); + let deleted; + try { + await client.query("BEGIN"); + + const ownerGuard = await client.query( + ` + SELECT id + FROM condon.owner_accounts + WHERE id = $1 + AND owner_name = $2 + AND room_no = $3 + AND member_no = $4 + FOR UPDATE + `, + [fixture.ownerId, OWNER_NAME, fixture.roomNo, fixture.memberNo] + ); + if (ownerGuard.rowCount !== 1) { + throw Object.assign(new Error("Fixture owner guard failed"), { + code: "FIXTURE_OWNER_GUARD_FAILED" + }); + } + + const ledgerResult = await client.query( + ` + DELETE FROM condon.entitlement_ledger + WHERE entitlement_period_id = $1 + RETURNING id, entry_type, usage_record_id + `, + [fixture.periodId] + ); + const usageResult = await client.query( + ` + DELETE FROM condon.usage_records + WHERE owner_account_id = $1 + AND entitlement_period_id = $2 + RETURNING id, confirmation_no + `, + [fixture.ownerId, fixture.periodId] + ); + const periodResult = await client.query( + ` + DELETE FROM condon.entitlement_periods + WHERE id = $1 + AND owner_account_id = $2 + RETURNING id + `, + [fixture.periodId, fixture.ownerId] + ); + const ownerResult = await client.query( + ` + DELETE FROM condon.owner_accounts + WHERE id = $1 + AND owner_name = $2 + AND room_no = $3 + AND member_no = $4 + RETURNING id + `, + [fixture.ownerId, OWNER_NAME, fixture.roomNo, fixture.memberNo] + ); + + if (periodResult.rowCount !== 1 || ownerResult.rowCount !== 1) { + throw Object.assign(new Error("Fixture cleanup cardinality failed"), { + code: "FIXTURE_CLEANUP_CARDINALITY_FAILED" + }); + } + + deleted = { + ledger: ledgerResult.rowCount, + usage: usageResult.rowCount, + periods: periodResult.rowCount, + owners: ownerResult.rowCount + }; + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } + + const finalCounts = await getBusinessCounts(pool); + if (!allZero(finalCounts)) { + throw Object.assign(new Error("CONDO business tables are not empty"), { + code: "FINAL_CONDON_NOT_EMPTY" + }); + } + return { deleted, finalCounts }; +} + +const lines = createInterface({ input: process.stdin, terminal: false }); +const iterator = lines[Symbol.asyncIterator](); +let pool; +let fixture; +let setupComplete = false; +let cleanupComplete = false; +let usageObservation = null; +let expectUsage = true; +let failureCode = null; +let signalResolve; +const signalPromise = new Promise(resolve => { + signalResolve = resolve; +}); + +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.once(signal, () => signalResolve({ signal })); +} + +try { + const first = await iterator.next(); + if (first.done || !first.value.trim()) { + throw Object.assign(new Error("Missing connection input"), { + code: "MISSING_CONNECTION_INPUT" + }); + } + const input = JSON.parse(first.value); + if (input.database !== "booking_test") { + throw Object.assign(new Error("Unexpected database"), { + code: "UNEXPECTED_DATABASE" + }); + } + + pool = new Pool({ + host: input.host, + port: input.port, + user: input.user, + password: input.password, + database: input.database, + ssl: input.ssl ? { rejectUnauthorized: false } : undefined, + max: 2, + application_name: "condon_live_ui_fixture", + connectionTimeoutMillis: 10_000, + options: "-c statement_timeout=30000 -c lock_timeout=5000" + }); + + const token = randomUUID().replaceAll("-", "").slice(0, 12).toUpperCase(); + fixture = { + ownerId: randomUUID(), + periodId: randomUUID(), + annualGrantLedgerId: randomUUID(), + roomNo: `E2E-${token}`, + unitNo: `TEST-${token.slice(0, 6)}`, + memberNo: `E2E-${token}`, + confirmationNo: `98${Date.now()}${randomInt(100, 1000)}`, + remark: `Live UI E2E ${token}` + }; + + const setupCounts = await setupFixture(pool, fixture); + setupComplete = true; + writeEvent("fixtureReady", { + fixture: { + ...fixture, + ownerName: OWNER_NAME, + purchasedRoomType: PURCHASED_ROOM_TYPE, + usedRoomType: USED_ROOM_TYPE, + year: TEST_YEAR, + checkIn: CHECK_IN, + checkOut: CHECK_OUT, + expectedNight: EXPECTED_NIGHT, + expectedMultiplier: EXPECTED_MULTIPLIER, + expectedUse: EXPECTED_USE, + expectedBalance: EXPECTED_BALANCE + }, + setupCounts + }); + + const next = await Promise.race([ + iterator.next(), + signalPromise + ]); + if (next?.signal) { + expectUsage = false; + writeEvent("cleanupRequested", { reason: next.signal }); + } else if (next.done) { + expectUsage = false; + writeEvent("cleanupRequested", { reason: "stdin_closed" }); + } else { + const command = JSON.parse(next.value); + if (command.action !== "cleanup") { + throw Object.assign(new Error("Unexpected command"), { + code: "UNEXPECTED_FIXTURE_COMMAND" + }); + } + expectUsage = command.expectUsage !== false; + writeEvent("cleanupRequested", { reason: "command" }); + } + + usageObservation = await observeUsage(pool, fixture); + if (expectUsage && !usageIsExpected(usageObservation, fixture)) { + throw Object.assign(new Error("Unexpected usage result"), { + code: "UNEXPECTED_USAGE_RESULT" + }); + } +} catch (error) { + failureCode = safeError(error); + process.exitCode = 1; + writeEvent("fixtureError", { errorCode: failureCode }); +} finally { + if (pool && setupComplete && fixture) { + try { + const cleanup = await cleanupFixture(pool, fixture); + cleanupComplete = true; + writeEvent("fixtureCleaned", { + usageExpected: expectUsage, + usageVerified: expectUsage ? usageIsExpected(usageObservation, fixture) : null, + usage: usageObservation, + ...cleanup + }); + } catch (error) { + failureCode ??= safeError(error, "LIVE_UI_CLEANUP_FAILED"); + process.exitCode = 1; + writeEvent("cleanupError", { errorCode: safeError(error, "LIVE_UI_CLEANUP_FAILED") }); + } + } + + lines.close(); + if (pool) await pool.end().catch(() => { + process.exitCode = 1; + }); + writeEvent("fixtureClosed", { + ok: !failureCode && cleanupComplete, + errorCode: failureCode + }); +} diff --git a/backend/scripts/migrate-up.mjs b/backend/scripts/migrate-up.mjs new file mode 100644 index 0000000..a9ad608 --- /dev/null +++ b/backend/scripts/migrate-up.mjs @@ -0,0 +1,204 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; +import pg from "pg"; + +const { Client } = pg; +const EXPECTED_DATABASE = "booking_test"; +const TARGET_SCHEMA = "condon"; +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const migrationsDirectory = path.resolve(scriptDirectory, "..", "migrations"); + +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 sanitizeError(error) { + const errorCode = typeof error?.code === "string" ? error.code : "MIGRATION_FAILED"; + const connectionErrorCodes = new Set([ + "ECONNREFUSED", + "ECONNRESET", + "ENOTFOUND", + "ETIMEDOUT" + ]); + return { + ok: false, + errorCode, + errorMessage: connectionErrorCodes.has(errorCode) + ? "Database connection failed" + : typeof error?.message === "string" + ? error.message.replace(/\s+/g, " ").slice(0, 240) + : "Migration failed" + }; +} + +async function loadMigrations() { + const names = (await readdir(migrationsDirectory)) + .filter(name => name.endsWith(".up.sql")) + .sort(); + if (names.length === 0) { + throw Object.assign(new Error("No up migrations found"), { + code: "NO_MIGRATIONS_FOUND" + }); + } + return Promise.all(names.map(async fileName => { + const sql = await readFile(path.join(migrationsDirectory, fileName), "utf8"); + return { + fileName, + version: fileName.slice(0, -".up.sql".length), + sql, + checksum: createHash("sha256").update(sql).digest("hex") + }; + })); +} + +let client; +let transactionStarted = false; + +try { + const [rawInput, migrations] = await Promise.all([ + readStandardInput(), + loadMigrations() + ]); + const input = JSON.parse(rawInput); + + 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_guarded_migration", + connectionTimeoutMillis: 10_000, + query_timeout: 60_000, + options: "-c statement_timeout=60000 -c lock_timeout=2000" + }); + + await client.connect(); + await client.query("BEGIN"); + transactionStarted = true; + + const identityResult = await client.query(` + SELECT + current_database() AS database_name, + current_user AS role_name, + current_setting('transaction_read_only') AS transaction_read_only + `); + const identity = identityResult.rows[0]; + if (identity.database_name !== EXPECTED_DATABASE) { + throw Object.assign(new Error("Unexpected database"), { + code: "UNEXPECTED_DATABASE" + }); + } + if (identity.role_name !== input.user) { + throw Object.assign(new Error("Unexpected database role"), { + code: "UNEXPECTED_DATABASE_ROLE" + }); + } + if (identity.transaction_read_only !== "off") { + throw Object.assign(new Error("Migration transaction is read-only"), { + code: "MIGRATION_TRANSACTION_READ_ONLY" + }); + } + + const schemaResult = await client.query(` + SELECT EXISTS ( + SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1 + ) AS exists + `, [TARGET_SCHEMA]); + const schemaExists = schemaResult.rows[0].exists === true; + if (schemaExists) { + const migrationTableResult = await client.query(` + SELECT to_regclass('condon.schema_migrations') IS NOT NULL AS exists + `); + if (!migrationTableResult.rows[0].exists) { + throw Object.assign(new Error("Migration table is missing"), { + code: "MIGRATION_TABLE_MISSING" + }); + } + } + + const applied = []; + const skipped = []; + for (const migration of migrations) { + let existing = null; + if (schemaExists || migration !== migrations[0]) { + const result = await client.query( + "SELECT checksum FROM condon.schema_migrations WHERE version = $1", + [migration.version] + ); + existing = result.rows[0] ?? null; + } + if (existing) { + if (existing.checksum !== migration.checksum) { + throw Object.assign(new Error(`Checksum mismatch for ${migration.version}`), { + code: "MIGRATION_CHECKSUM_MISMATCH" + }); + } + skipped.push(migration.version); + continue; + } + + await client.query(migration.sql); + await client.query( + `INSERT INTO condon.schema_migrations (version, checksum) VALUES ($1, $2)`, + [migration.version, migration.checksum] + ); + applied.push(migration.version); + } + + const verificationResult = await client.query(` + SELECT + (SELECT count(*)::integer FROM condon.room_types) AS room_type_count, + (SELECT count(*)::integer FROM condon.owner_accounts) AS owner_account_count, + (SELECT count(*)::integer FROM condon.entitlement_periods) AS entitlement_period_count, + (SELECT count(*)::integer FROM condon.usage_records) AS usage_record_count, + (SELECT count(*)::integer FROM condon.entitlement_ledger) AS ledger_count, + (SELECT count(*)::integer FROM condon.schema_migrations) AS migration_count, + (SELECT count(*)::integer FROM condon.bookings) AS booking_count + `); + const counts = verificationResult.rows[0]; + await client.query("COMMIT"); + transactionStarted = false; + + process.stdout.write(`${JSON.stringify({ + ok: true, + databaseVerified: true, + targetSchema: TARGET_SCHEMA, + applied, + skipped, + migrations: migrations.map(migration => ({ + version: migration.version, + checksum: migration.checksum + })), + counts + }, null, 2)}\n`); +} catch (error) { + if (client && transactionStarted) { + try { + await client.query("ROLLBACK"); + transactionStarted = false; + } catch { + // Keep the sanitized migration failure authoritative. + } + } + process.stdout.write(`${JSON.stringify(sanitizeError(error), null, 2)}\n`); + process.exitCode = 1; +} finally { + if (client) { + await client.end().catch(() => { + process.exitCode = 1; + }); + } +} diff --git a/backend/scripts/prepare-confirmation-import.py b/backend/scripts/prepare-confirmation-import.py new file mode 100644 index 0000000..911b94d --- /dev/null +++ b/backend/scripts/prepare-confirmation-import.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python3 +"""Prepare a deterministic, read-only legacy import batch from the latest workbook. + +This script never connects to PostgreSQL. It keeps source Use/Balance and raw +room-type text, filters rows without Confirmation, and writes auditable JSON +artifacts for the later database importer. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import sys +from collections import Counter, defaultdict +from datetime import date, datetime +from pathlib import Path +from typing import Any + +from openpyxl import load_workbook + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_WORKBOOK = Path( + "/Users/chillishark/Desktop/Condo公寓/Confirmation Report 2026 สิทธิ์ 15 วันคอนโดเท่านั้น(2).xlsx" +) +DEFAULT_OUTPUT = PROJECT_ROOT / ".planning" / "data_import_audit" / "import_batch_2026_07_31" +MASTER_SHEET = "Total2024-2026" +ROOM_TYPES = { + "RM1", + "RM2", + "RM3", + "RM4", + "UG1", + "UG2", + "SU1", + "SU2", + "SU6", + "SU3", + "AC2", +} + + +def scalar(value: Any) -> Any: + if isinstance(value, datetime): + return value.date().isoformat() + if isinstance(value, date): + return value.isoformat() + if isinstance(value, float) and value.is_integer(): + return int(value) + return value + + +def text(value: Any) -> str: + value = scalar(value) + return "" if value is None else str(value).strip() + + +def number(value: Any) -> int | None: + value = scalar(value) + if isinstance(value, int): + return value + if isinstance(value, str) and re.fullmatch(r"-?\d+", value.strip()): + return int(value.strip()) + return None + + +def parsed_date(value: Any) -> date | None: + value = scalar(value) + if isinstance(value, date): + return value + if isinstance(value, str): + for fmt in ("%Y-%m-%d", "%d-%b-%y", "%d-%b-%Y"): + try: + return datetime.strptime(value.strip(), fmt).date() + except ValueError: + pass + return None + + +def normalize_header(value: Any) -> str: + return re.sub(r"[^a-z0-9]+", "", text(value).lower()) + + +def normalize_room_type(value: Any) -> str: + return re.sub(r"\r?\n", "
", text(value)) + + +def room_numbers(value: Any) -> list[str]: + return list(dict.fromkeys(re.findall(r"\b\d{4}\b", text(value)))) + + +def canonical_room_type(raw: str) -> str | None: + """Only exact standard codes are canonicalized; legacy text is not guessed.""" + return raw if raw in ROOM_TYPES else None + + +def write_json(path: Path, value: Any) -> None: + path.write_text( + json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def prepare(workbook_path: Path, output_dir: Path) -> dict[str, Any]: + workbook_bytes = workbook_path.read_bytes() + source_sha256 = hashlib.sha256(workbook_bytes).hexdigest() + workbook = load_workbook(workbook_path, data_only=True, read_only=False) + if MASTER_SHEET not in workbook.sheetnames: + raise ValueError(f"Missing master sheet: {MASTER_SHEET}") + + master = workbook[MASTER_SHEET] + header_map: dict[str, int] = {} + for column in range(1, master.max_column + 1): + header = normalize_header(master.cell(2, column).value) + if header: + header_map.setdefault(header, column) + + def column(header: str, fallback: int) -> int: + return header_map.get(normalize_header(header), fallback) + + columns = { + "no": column("No", 1), + "transfer_date": column("Transfer Date", 2), + "name": column("Name", 3), + "room_no": column("Room No.", 4), + "room_type": column("Room Type", 5), + "unit_no": column("Unit No.", 6), + "member_no": column("Member No.", 7), + "remaining": column("Remaining stay privileges", 8), + } + + owners: list[dict[str, Any]] = [] + owner_by_room: dict[str, dict[str, Any]] = {} + owner_issues: list[dict[str, Any]] = [] + for row in range(4, master.max_row + 1): + room_no = text(master.cell(row, columns["room_no"]).value) + if not re.fullmatch(r"\d{4}", room_no): + continue + owner = { + "account_no": number(master.cell(row, columns["no"]).value), + "transfer_date": scalar(master.cell(row, columns["transfer_date"]).value), + "name": text(master.cell(row, columns["name"]).value), + "room_no": room_no, + "purchased_room_type_code": text(master.cell(row, columns["room_type"]).value), + "unit_no": text(master.cell(row, columns["unit_no"]).value), + "member_no": text(master.cell(row, columns["member_no"]).value), + "reported_remaining": number(master.cell(row, columns["remaining"]).value), + "source_sheet": MASTER_SHEET, + "source_row": row, + } + missing = [ + key + for key in ( + "account_no", + "transfer_date", + "name", + "room_no", + "purchased_room_type_code", + "unit_no", + "member_no", + "reported_remaining", + ) + if owner[key] in (None, "") + ] + if missing: + owner_issues.append({"source_row": row, "room_no": room_no, "missing": missing}) + if owner["purchased_room_type_code"] not in ROOM_TYPES: + owner_issues.append( + { + "source_row": row, + "room_no": room_no, + "invalid_purchased_room_type": owner["purchased_room_type_code"], + } + ) + if owner["reported_remaining"] is not None and not 0 <= owner["reported_remaining"] <= 15: + owner_issues.append( + { + "source_row": row, + "room_no": room_no, + "remaining_out_of_range": owner["reported_remaining"], + } + ) + owners.append(owner) + owner_by_room[room_no] = owner + + usage: list[dict[str, Any]] = [] + rejected: list[dict[str, Any]] = [] + sheet_diagnostics: list[dict[str, Any]] = [] + arithmetic_issues: list[dict[str, Any]] = [] + chain_issues: list[dict[str, Any]] = [] + required_issues: list[dict[str, Any]] = [] + date_issues: list[dict[str, Any]] = [] + identity_issues: list[dict[str, Any]] = [] + previous_by_sheet: dict[str, dict[str, Any]] = {} + duplicate_confirmation_locations: dict[str, list[dict[str, Any]]] = defaultdict(list) + + expected_headers = [ + "No.", + "Confirmation No.", + "Check -IN", + "Check-OUT", + "Night", + "Room", + "Total", + "Use", + "Balance", + "Room Type", + "Remark", + ] + + for title in workbook.sheetnames: + if title == MASTER_SHEET: + continue + sheet = workbook[title] + title_rooms = room_numbers(title) + if not title_rooms: + sheet_diagnostics.append({"sheet": title, "kind": "template", "usage_count": 0}) + continue + header_values = [text(sheet.cell(3, column).value).rstrip(" ") for column in range(1, 12)] + normalized_headers = [header.replace("Check-OUT ", "Check-OUT").strip() for header in header_values] + header_issue = normalized_headers != expected_headers + header_rooms = room_numbers(sheet.cell(2, 4).value) + if set(title_rooms) != set(header_rooms): + identity_issues.append( + { + "sheet": title, + "title_rooms": title_rooms, + "header_rooms": header_rooms, + "resolution": "sheet title is authoritative", + } + ) + accepted_count = 0 + sheet_rows: list[dict[str, Any]] = [] + for row in range(4, sheet.max_row + 1): + raw = { + "no": number(sheet.cell(row, 1).value), + "confirmation_no": text(sheet.cell(row, 2).value), + "check_in": scalar(sheet.cell(row, 3).value), + "check_out": scalar(sheet.cell(row, 4).value), + "night": number(sheet.cell(row, 5).value), + "room": number(sheet.cell(row, 6).value), + "total": number(sheet.cell(row, 7).value), + "use": number(sheet.cell(row, 8).value), + "balance": number(sheet.cell(row, 9).value), + "raw_used_room_type": normalize_room_type(sheet.cell(row, 10).value), + "remark": text(sheet.cell(row, 11).value), + } + if not raw["confirmation_no"]: + populated_columns = [ + column + for column in (3, 4, 6, 10, 11) + if text(sheet.cell(row, column).value) + ] + if populated_columns: + rejected.append( + { + "reason_code": "missing_confirmation", + "reason": "Confirmation No. is empty; clear/exclude this row", + "source_sheet": title, + "source_row": row, + "populated_columns": populated_columns, + **raw, + } + ) + continue + + missing = [ + key + for key in ( + "confirmation_no", + "check_in", + "check_out", + "night", + "room", + "total", + "use", + "balance", + "raw_used_room_type", + "remark", + ) + if raw[key] in (None, "") + ] + if missing: + required_issues.append({"source_sheet": title, "source_row": row, "missing": missing}) + + check_in = parsed_date(raw["check_in"]) + check_out = parsed_date(raw["check_out"]) + if ( + check_in is None + or check_out is None + or check_out <= check_in + or raw["night"] != (check_out - check_in).days + ): + date_issues.append({"source_sheet": title, "source_row": row, **raw}) + if raw["total"] is None or raw["use"] is None or raw["balance"] is None: + arithmetic_issues.append({"source_sheet": title, "source_row": row, **raw}) + elif raw["total"] - raw["use"] != raw["balance"]: + arithmetic_issues.append({"source_sheet": title, "source_row": row, **raw}) + previous = previous_by_sheet.get(title) + if previous is not None and raw["total"] != previous["balance"]: + chain_issues.append( + { + "source_sheet": title, + "source_row": row, + "expected_total": previous["balance"], + "actual_total": raw["total"], + } + ) + previous_by_sheet[title] = raw + period_year = check_in.year if check_in else None + record = { + "owner_room_no": title_rooms[0] if len(title_rooms) == 1 else None, + "confirmation_no": raw["confirmation_no"], + "check_in": raw["check_in"], + "check_out": raw["check_out"], + "night": raw["night"], + "room": raw["room"], + "total": raw["total"], + "use": raw["use"], + "balance": raw["balance"], + "raw_used_room_type": raw["raw_used_room_type"], + "canonical_used_room_type_code": canonical_room_type(raw["raw_used_room_type"]), + "remark": raw["remark"], + "source_sheet": title, + "source_row": row, + "source_sequence": raw["no"], + "period_year": period_year, + "applied_multiplier": None, + "rule_version": "legacy-source", + } + usage.append(record) + sheet_rows.append(record) + accepted_count += 1 + duplicate_confirmation_locations[raw["confirmation_no"]].append( + {"source_sheet": title, "source_row": row} + ) + sheet_diagnostics.append( + { + "sheet": title, + "kind": "room_usage", + "title_rooms": title_rooms, + "header_rooms": header_rooms, + "header_issue": header_issue, + "usage_count": accepted_count, + "rejected_count": sum( + 1 for row in rejected if row["source_sheet"] == title + ), + "final_balance": sheet_rows[-1]["balance"] if sheet_rows else None, + } + ) + + duplicate_confirmations = { + confirmation: locations + for confirmation, locations in duplicate_confirmation_locations.items() + if len(locations) > 1 + } + master_remaining_sum = sum(owner["reported_remaining"] or 0 for owner in owners) + usage_sum = sum(record["use"] or 0 for record in usage) + empty_usage_sheets = [ + item["sheet"] + for item in sheet_diagnostics + if item["kind"] == "room_usage" and item["usage_count"] == 0 + ] + noncanonical = [ + record + for record in usage + if record["canonical_used_room_type_code"] is None + ] + manifest = { + "mode": "local-import-preflight", + "source_workbook": str(workbook_path), + "source_sha256": source_sha256, + "master_sheet": MASTER_SHEET, + "rules": { + "latest_workbook_only": True, + "missing_confirmation": "clear_or_exclude", + "usage_owner": "sheet_title", + "owner_metadata": "master_sheet", + "history_values": "preserve_source_use_balance_total_room", + "legacy_applied_multiplier": None, + "legacy_rule_version": "legacy-source", + "new_usage_room_type": "standard_code_only", + "database_write_performed": False, + }, + "counts": { + "owner_count": len(owners), + "unique_owner_room_count": len(owner_by_room), + "accepted_usage_count": len(usage), + "rejected_row_count": len(rejected), + "rejected_missing_confirmation_count": sum( + item["reason_code"] == "missing_confirmation" for item in rejected + ), + "usage_use_sum": usage_sum, + "master_remaining_sum": master_remaining_sum, + "implied_used": len(owners) * 15 - master_remaining_sum, + "canonical_used_room_type_count": len(usage) - len(noncanonical), + "noncanonical_used_room_type_count": len(noncanonical), + "duplicate_confirmation_group_count": len(duplicate_confirmations), + }, + "checks": { + "owner_issues": owner_issues, + "required_usage_issues": required_issues, + "date_night_issues": date_issues, + "arithmetic_issues": arithmetic_issues, + "balance_chain_issues": chain_issues, + "room_gt_one_count": sum((record["room"] or 0) > 1 for record in usage), + "missing_owner_rooms": sorted( + { + record["owner_room_no"] + for record in usage + if record["owner_room_no"] not in owner_by_room + } + ), + "empty_usage_sheets": empty_usage_sheets, + }, + "duplicate_confirmations": duplicate_confirmations, + "year_counts": dict(sorted(Counter(record["period_year"] for record in usage).items())), + "raw_used_room_type_counts": dict( + sorted(Counter(record["raw_used_room_type"] for record in usage).items()) + ), + } + + output_dir.mkdir(parents=True, exist_ok=True) + write_json(output_dir / "owners.json", owners) + write_json(output_dir / "usage_legacy.json", usage) + write_json(output_dir / "rejected_rows.json", rejected) + write_json(output_dir / "sheet_diagnostics.json", sheet_diagnostics) + write_json(output_dir / "identity_issues.json", identity_issues) + write_json(output_dir / "manifest.json", manifest) + return manifest + + +def main() -> None: + workbook_path = Path(sys.argv[1]).expanduser() if len(sys.argv) > 1 else DEFAULT_WORKBOOK + output_dir = Path(sys.argv[2]).expanduser() if len(sys.argv) > 2 else DEFAULT_OUTPUT + if not workbook_path.is_file(): + raise SystemExit(f"Workbook not found: {workbook_path}") + manifest = prepare(workbook_path, output_dir) + print(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/test-database.mjs b/backend/scripts/test-database.mjs new file mode 100644 index 0000000..9a34df2 --- /dev/null +++ b/backend/scripts/test-database.mjs @@ -0,0 +1,519 @@ +import { randomUUID } from "node:crypto"; +import process from "node:process"; +import { createInterface } from "node:readline"; +import pg from "pg"; + +const { Pool } = pg; + +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 databaseMessage(error) { + return typeof error?.message === "string" ? error.message : ""; +} + +function safeError(error) { + return { + ok: false, + errorCode: typeof error?.code === "string" + ? error.code + : "DATABASE_TEST_FAILED", + errorMessage: databaseMessage(error).startsWith("CONDON_") + ? databaseMessage(error) + : "Database integration test failed" + }; +} + +async function expectDatabaseError( + client, + checks, + label, + text, + values, + expectedMessage +) { + await client.query("SAVEPOINT expected_error"); + let matched = false; + try { + await client.query(text, values); + } catch (error) { + matched = databaseMessage(error) === expectedMessage; + } finally { + await client.query("ROLLBACK TO SAVEPOINT expected_error"); + await client.query("RELEASE SAVEPOINT expected_error"); + } + checks[label] = matched; +} + +function usageFunctionSql() { + return ` + SELECT record.* + FROM condon.create_usage_record( + $1::uuid, + $2::uuid, + $3::uuid, + $4::varchar, + $5::date, + $6::date, + $7::varchar, + $8::smallint, + $9::text, + $10::uuid + ) AS record + `; +} + +const checks = {}; +let pool; +let rollbackClient; +let concurrencyOwnerId; +let concurrencyPeriodId; + +try { + const input = JSON.parse(await readStandardInput()); + pool = new Pool({ + host: input.host, + port: input.port, + user: input.user, + password: input.password, + database: input.database, + ssl: input.ssl ? { rejectUnauthorized: false } : undefined, + max: 4, + application_name: "condon_database_integration_test", + connectionTimeoutMillis: 10_000, + options: "-c statement_timeout=30000 -c lock_timeout=5000" + }); + + const identityResult = await pool.query(` + SELECT + current_database() AS database_name, + pg_catalog.to_regnamespace('condon') IS NOT NULL AS schema_exists + `); + checks.databaseTarget = + identityResult.rows[0].database_name === "booking_test" + && identityResult.rows[0].schema_exists === true; + + const initialCountResult = await pool.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 + `); + if (Object.values(initialCountResult.rows[0]).some(value => value !== 0)) { + throw Object.assign(new Error("CONDO business tables must be empty"), { + code: "TEST_REQUIRES_EMPTY_CONDON" + }); + } + checks.initiallyEmpty = true; + + rollbackClient = await pool.connect(); + await rollbackClient.query("BEGIN"); + + const matrixResult = await rollbackClient.query(` + SELECT + count(*)::integer AS case_count, + bool_and( + condon.calculate_multiplier( + purchased.code, + used.code, + NULL + ) = GREATEST( + 1, + used.entitlement_tier - purchased.entitlement_tier + 1 + ) + ) AS all_match + FROM condon.room_types AS purchased + CROSS JOIN condon.room_types AS used + WHERE NOT purchased.requires_manual_multiplier + AND NOT used.requires_manual_multiplier + `); + checks.fullAutomaticMatrix = + matrixResult.rows[0].case_count === 100 + && matrixResult.rows[0].all_match === true; + + const manualResult = await rollbackClient.query(` + SELECT + condon.calculate_multiplier('AC2', 'RM1', 3::smallint) AS purchased_ac2, + condon.calculate_multiplier('RM1', 'AC2', 2::smallint) AS used_ac2 + `); + checks.ac2ManualValues = + manualResult.rows[0].purchased_ac2 === 3 + && manualResult.rows[0].used_ac2 === 2; + + await expectDatabaseError( + rollbackClient, + checks, + "ac2RequiresManual", + "SELECT condon.calculate_multiplier('AC2', 'RM1', NULL::smallint)", + [], + "CONDON_MANUAL_MULTIPLIER_REQUIRED" + ); + await expectDatabaseError( + rollbackClient, + checks, + "automaticRejectsManual", + "SELECT condon.calculate_multiplier('RM1', 'SU1', 2::smallint)", + [], + "CONDON_MANUAL_MULTIPLIER_NOT_ALLOWED" + ); + + const ownerId = randomUUID(); + const periodId = randomUUID(); + const grantLedgerId = randomUUID(); + await rollbackClient.query( + ` + INSERT INTO condon.owner_accounts ( + id, + account_no, + transfer_date, + owner_name, + room_no, + purchased_room_type_code, + unit_no, + member_no + ) + VALUES ($1, NULL, NULL, 'Rollback Test Owner', $2, 'RM1', 'TEST/1', 'TEST-1') + `, + [ownerId, `R${ownerId.replaceAll("-", "").slice(0, 12)}`] + ); + const periodResult = await rollbackClient.query( + ` + SELECT period.* + FROM condon.open_entitlement_period( + $1::uuid, + $2::uuid, + 2026::smallint, + $3::uuid, + NULL::uuid + ) AS period + `, + [periodId, ownerId, grantLedgerId] + ); + checks.openingPeriod = + periodResult.rows[0].annual_grant === 15 + && periodResult.rows[0].carry_forward === 0 + && periodResult.rows[0].current_balance === 15; + + const usageId = randomUUID(); + const usageLedgerId = randomUUID(); + const idempotencyKey = randomUUID(); + const usageResult = await rollbackClient.query( + usageFunctionSql(), + [ + usageId, + usageLedgerId, + ownerId, + "990000001", + "2026-09-01", + "2026-09-04", + "SU1", + null, + "matrix test", + idempotencyKey + ] + ); + checks.derivedUsage = + usageResult.rows[0].night_count === 3 + && usageResult.rows[0].applied_multiplier === 2 + && usageResult.rows[0].use_nights === 6 + && usageResult.rows[0].balance_before === 15 + && usageResult.rows[0].balance_after === 9; + + const retryResult = await rollbackClient.query( + usageFunctionSql(), + [ + randomUUID(), + randomUUID(), + ownerId, + "990000001", + "2026-09-01", + "2026-09-04", + "SU1", + null, + "matrix test", + idempotencyKey + ] + ); + const idempotencyCountResult = await rollbackClient.query( + ` + SELECT + (SELECT count(*)::integer FROM condon.usage_records WHERE owner_account_id = $1) AS usage_count, + (SELECT current_balance FROM condon.entitlement_periods WHERE id = $2) AS balance + `, + [ownerId, periodId] + ); + checks.idempotency = + retryResult.rows[0].id === usageId + && idempotencyCountResult.rows[0].usage_count === 1 + && idempotencyCountResult.rows[0].balance === 9; + + await expectDatabaseError( + rollbackClient, + checks, + "duplicateConfirmation", + usageFunctionSql(), + [ + randomUUID(), + randomUUID(), + ownerId, + "990000001", + "2026-10-01", + "2026-10-02", + "RM1", + null, + "", + randomUUID() + ], + "CONDON_CONFIRMATION_NO_EXISTS" + ); + await expectDatabaseError( + rollbackClient, + checks, + "crossYearRejected", + usageFunctionSql(), + [ + randomUUID(), + randomUUID(), + ownerId, + "990000002", + "2026-12-30", + "2027-01-02", + "RM1", + null, + "", + randomUUID() + ], + "CONDON_CROSS_YEAR_STAY_NOT_SUPPORTED" + ); + await expectDatabaseError( + rollbackClient, + checks, + "insufficientBalance", + usageFunctionSql(), + [ + randomUUID(), + randomUUID(), + ownerId, + "990000003", + "2026-10-01", + "2026-10-05", + "SU3", + null, + "", + randomUUID() + ], + "CONDON_INSUFFICIENT_BALANCE" + ); + + const carryPeriodResult = await rollbackClient.query( + ` + SELECT period.* + FROM condon.open_entitlement_period( + $1::uuid, + $2::uuid, + 2027::smallint, + $3::uuid, + $4::uuid + ) AS period + `, + [randomUUID(), ownerId, randomUUID(), randomUUID()] + ); + checks.annualCarryForward = + carryPeriodResult.rows[0].annual_grant === 15 + && carryPeriodResult.rows[0].carry_forward === 9 + && carryPeriodResult.rows[0].current_balance === 24; + + await rollbackClient.query("ROLLBACK"); + rollbackClient.release(); + rollbackClient = undefined; + + const postRollbackCountResult = await pool.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 + `); + checks.rollbackLeavesNoData = + Object.values(postRollbackCountResult.rows[0]).every(value => value === 0); + + concurrencyOwnerId = randomUUID(); + concurrencyPeriodId = randomUUID(); + const concurrencyRoomNo = + `C${concurrencyOwnerId.replaceAll("-", "").slice(0, 12)}`; + const setupClient = await pool.connect(); + try { + await setupClient.query("BEGIN"); + await setupClient.query( + ` + INSERT INTO condon.owner_accounts ( + id, + account_no, + transfer_date, + owner_name, + room_no, + purchased_room_type_code, + unit_no, + member_no + ) + VALUES ($1, NULL, NULL, 'Concurrency Test Owner', $2, 'RM1', 'TEST/2', 'TEST-2') + `, + [concurrencyOwnerId, concurrencyRoomNo] + ); + await setupClient.query( + ` + SELECT condon.open_entitlement_period( + $1::uuid, + $2::uuid, + 2098::smallint, + $3::uuid, + NULL::uuid + ) + `, + [concurrencyPeriodId, concurrencyOwnerId, randomUUID()] + ); + await setupClient.query("COMMIT"); + } catch (error) { + await setupClient.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + setupClient.release(); + } + + const concurrentCalls = [ + [ + randomUUID(), + randomUUID(), + concurrencyOwnerId, + "998000001", + "2098-01-01", + "2098-01-11", + "RM1", + null, + "concurrency A", + randomUUID() + ], + [ + randomUUID(), + randomUUID(), + concurrencyOwnerId, + "998000002", + "2098-02-01", + "2098-02-11", + "RM1", + null, + "concurrency B", + randomUUID() + ] + ]; + const concurrentResults = await Promise.allSettled( + concurrentCalls.map(values => pool.query(usageFunctionSql(), values)) + ); + const fulfilled = concurrentResults.filter(result => result.status === "fulfilled"); + const rejected = concurrentResults.filter(result => result.status === "rejected"); + checks.concurrentOverspendBlocked = + fulfilled.length === 1 + && rejected.length === 1 + && databaseMessage(rejected[0]?.reason) === "CONDON_INSUFFICIENT_BALANCE"; + + const concurrencyStateResult = await pool.query( + ` + SELECT + ep.current_balance, + (SELECT count(*)::integer FROM condon.usage_records WHERE owner_account_id = $1) AS usage_count, + ( + SELECT count(*)::integer + FROM condon.entitlement_ledger + WHERE entitlement_period_id = $2 + AND entry_type = 'usage' + ) AS usage_ledger_count + FROM condon.entitlement_periods AS ep + WHERE ep.id = $2 + `, + [concurrencyOwnerId, concurrencyPeriodId] + ); + checks.concurrentStateConsistent = + concurrencyStateResult.rows[0].current_balance === 5 + && concurrencyStateResult.rows[0].usage_count === 1 + && concurrencyStateResult.rows[0].usage_ledger_count === 1; +} catch (error) { + process.stdout.write(`${JSON.stringify(safeError(error), null, 2)}\n`); + process.exitCode = 1; +} finally { + if (rollbackClient) { + await rollbackClient.query("ROLLBACK").catch(() => undefined); + rollbackClient.release(); + } + + if (pool && concurrencyOwnerId && concurrencyPeriodId) { + const cleanupClient = await pool.connect().catch(() => undefined); + if (cleanupClient) { + try { + await cleanupClient.query("BEGIN"); + await cleanupClient.query( + "DELETE FROM condon.entitlement_ledger WHERE entitlement_period_id = $1", + [concurrencyPeriodId] + ); + await cleanupClient.query( + "DELETE FROM condon.usage_records WHERE owner_account_id = $1", + [concurrencyOwnerId] + ); + await cleanupClient.query( + "DELETE FROM condon.entitlement_periods WHERE id = $1", + [concurrencyPeriodId] + ); + await cleanupClient.query( + "DELETE FROM condon.owner_accounts WHERE id = $1", + [concurrencyOwnerId] + ); + await cleanupClient.query("COMMIT"); + checks.concurrencyCleanup = true; + } catch { + await cleanupClient.query("ROLLBACK").catch(() => undefined); + checks.concurrencyCleanup = false; + process.exitCode = 1; + } finally { + cleanupClient.release(); + } + } + } + + if (pool) { + try { + const finalCountResult = await pool.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 + `); + checks.finalBusinessTablesEmpty = + Object.values(finalCountResult.rows[0]).every(value => value === 0); + if (!checks.finalBusinessTablesEmpty) process.exitCode = 1; + } catch { + checks.finalBusinessTablesEmpty = false; + process.exitCode = 1; + } + await pool.end().catch(() => { + process.exitCode = 1; + }); + } + + if (Object.keys(checks).length > 0) { + const ok = Object.values(checks).every(Boolean); + process.stdout.write(`${JSON.stringify({ ok, checks }, null, 2)}\n`); + if (!ok) process.exitCode = 1; + } +} diff --git a/backend/scripts/verify-deployment.mjs b/backend/scripts/verify-deployment.mjs new file mode 100644 index 0000000..9a0c717 --- /dev/null +++ b/backend/scripts/verify-deployment.mjs @@ -0,0 +1,415 @@ +import { createHash } from "node:crypto"; +import process from "node:process"; +import { createInterface } from "node:readline"; +import pg from "pg"; + +const { Client } = pg; +const EXPECTED_DATABASE = "booking_test"; +const TARGET_SCHEMA = "condon"; +const BASELINE_FINGERPRINT = + // Read-only baseline captured from the existing booking/finance/ingestion/ + // reporting schemas before the CONDO import. The CONDO migrations do not + // target these schemas. + "4b130b7f0f57eb80bb8b168fb1c11325db4c3b417e7c99cf423f9a48f0322af3"; +const MIGRATION_VERSION = "001_create_condon_schema"; +const MIGRATION_CHECKSUM = + "7ab84c0c22fca16f75263be7291fc5c1977b46bada0794cf5b069e770d7e07a9"; +const MIGRATION_V2 = "002_legacy_import_and_bookings"; +const MIGRATION_V2_CHECKSUM = + "cfc38024984c883490cb46ab8ab1fca2e72c67f10dc3a43c179b12a8e6f2405b"; + +const expectedColumns = { + bookings: [ + "confirmation_no", + "created_at" + ], + entitlement_ledger: [ + "id", + "entitlement_period_id", + "usage_record_id", + "entry_type", + "delta_nights", + "balance_before", + "balance_after", + "occurred_at" + ], + entitlement_periods: [ + "id", + "owner_account_id", + "period_year", + "period_start", + "period_end", + "annual_grant", + "carry_forward", + "current_balance", + "row_version", + "created_at", + "updated_at" + ], + owner_accounts: [ + "id", + "account_no", + "transfer_date", + "owner_name", + "room_no", + "purchased_room_type_code", + "unit_no", + "member_no", + "created_at", + "updated_at" + ], + room_types: [ + "code", + "entitlement_tier", + "requires_manual_multiplier", + "created_at", + "updated_at" + ], + schema_migrations: [ + "version", + "checksum", + "applied_at" + ], + usage_records: [ + "id", + "owner_account_id", + "entitlement_period_id", + "confirmation_no", + "check_in", + "check_out", + "night_count", + "used_room_type_code", + "applied_multiplier", + "rule_version", + "use_nights", + "balance_before", + "balance_after", + "remark", + "idempotency_key", + "created_at", + "raw_used_room_type", + "room_count", + "source_sheet", + "source_row", + "source_sequence", + "import_batch" + ] +}; + +const expectedIndexes = [ + "bookings_created_idx", + "bookings_pkey", + "entitlement_ledger_period_occurred_idx", + "entitlement_ledger_pkey", + "entitlement_ledger_usage_record_key", + "entitlement_periods_id_owner_key", + "entitlement_periods_owner_year_key", + "entitlement_periods_pkey", + "owner_accounts_account_no_key", + "owner_accounts_member_no_idx", + "owner_accounts_pkey", + "owner_accounts_purchased_room_type_idx", + "owner_accounts_room_no_key", + "room_types_pkey", + "schema_migrations_pkey", + "usage_records_id_period_key", + "usage_records_idempotency_key_key", + "usage_records_owner_created_idx", + "usage_records_period_idx", + "usage_records_pkey", + "usage_records_source_location_key", + "usage_records_used_type_check_in_idx" +].sort(); + +const expectedRoomTypes = [ + ["AC2", null, true], + ["RM1", 1, false], + ["RM2", 1, false], + ["RM3", 1, false], + ["RM4", 1, false], + ["SU1", 2, false], + ["SU2", 2, false], + ["SU3", 3, false], + ["SU6", 2, false], + ["UG1", 1, false], + ["UG2", 1, false] +]; + +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 fingerprint(rows) { + return createHash("sha256").update(JSON.stringify(rows)).digest("hex"); +} + +function sameJson(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} + +function sanitizeError(error) { + return { + ok: false, + errorCode: typeof error?.code === "string" ? error.code : "VERIFY_FAILED" + }; +} + +let client; +let transactionStarted = false; + +try { + const rawInput = await readStandardInput(); + const input = JSON.parse(rawInput); + + 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_readonly_verification", + connectionTimeoutMillis: 10_000, + query_timeout: 20_000, + options: "-c default_transaction_read_only=on -c statement_timeout=20000 -c lock_timeout=2000" + }); + + await client.connect(); + await client.query("BEGIN TRANSACTION READ ONLY"); + transactionStarted = true; + + const identityResult = await client.query(` + SELECT + current_database() AS database_name, + current_user AS role_name, + current_setting('transaction_read_only') AS transaction_read_only + `); + const identity = identityResult.rows[0]; + + const objectResult = await client.query(` + SELECT + n.nspname AS schema_name, + c.relname AS object_name, + c.relkind AS object_kind, + pg_catalog.pg_get_userbyid(c.relowner) AS owner_name + FROM pg_catalog.pg_class AS c + JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace + WHERE n.nspname NOT LIKE 'pg_%' + AND n.nspname <> 'information_schema' + AND n.nspname <> 'condon' + ORDER BY n.nspname, c.relkind, c.relname + `); + const routineResult = await client.query(` + SELECT + n.nspname AS schema_name, + p.proname AS routine_name, + pg_catalog.pg_get_userbyid(p.proowner) AS owner_name, + p.prokind AS routine_kind, + pg_catalog.pg_get_function_identity_arguments(p.oid) AS identity_arguments + FROM pg_catalog.pg_proc AS p + JOIN pg_catalog.pg_namespace AS n ON n.oid = p.pronamespace + WHERE n.nspname NOT LIKE 'pg_%' + AND n.nspname <> 'information_schema' + AND n.nspname <> 'condon' + ORDER BY n.nspname, p.proname, identity_arguments + `); + const existingFingerprint = fingerprint({ + objects: objectResult.rows, + routines: routineResult.rows + }); + + const columnResult = await client.query(` + SELECT table_name, column_name + FROM information_schema.columns + WHERE table_schema = 'condon' + ORDER BY table_name, ordinal_position + `); + const actualColumns = columnResult.rows.reduce((tables, row) => { + tables[row.table_name] ??= []; + tables[row.table_name].push(row.column_name); + return tables; + }, {}); + + const indexResult = await client.query(` + SELECT indexname AS index_name + FROM pg_catalog.pg_indexes + WHERE schemaname = 'condon' + ORDER BY indexname + `); + const actualIndexes = indexResult.rows.map(row => row.index_name); + + const functionResult = await client.query(` + SELECT + p.proname AS function_name, + p.prosecdef AS security_definer, + p.provolatile AS volatility, + p.proconfig @> ARRAY['search_path=pg_catalog']::text[] AS safe_search_path, + p.proacl IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.aclexplode(p.proacl) AS acl + WHERE acl.grantee = 0 + AND acl.privilege_type = 'EXECUTE' + ) AS public_execute_revoked + FROM pg_catalog.pg_proc AS p + JOIN pg_catalog.pg_namespace AS n ON n.oid = p.pronamespace + WHERE n.nspname = 'condon' + ORDER BY p.proname + `); + + const constraintResult = await client.query(` + SELECT + count(*)::integer AS constraint_count, + COALESCE(bool_and( + c.confrelid = 0 + OR referenced_namespace.nspname = 'condon' + ), true) AS all_foreign_keys_internal + FROM pg_catalog.pg_constraint AS c + JOIN pg_catalog.pg_namespace AS own_namespace + ON own_namespace.oid = c.connamespace + LEFT JOIN pg_catalog.pg_class AS referenced_class + ON referenced_class.oid = c.confrelid + LEFT JOIN pg_catalog.pg_namespace AS referenced_namespace + ON referenced_namespace.oid = referenced_class.relnamespace + WHERE own_namespace.nspname = 'condon' + `); + + const aclResult = await client.query(` + SELECT + n.nspacl IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.aclexplode(n.nspacl) AS acl + WHERE acl.grantee = 0 + ) AS schema_public_access_revoked, + NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class AS c + CROSS JOIN LATERAL pg_catalog.aclexplode( + COALESCE(c.relacl, pg_catalog.acldefault('r', c.relowner)) + ) AS acl + WHERE c.relnamespace = n.oid + AND acl.grantee = 0 + ) AS tables_have_no_public_access + FROM pg_catalog.pg_namespace AS n + WHERE n.nspname = 'condon' + `); + + const roomTypeResult = await client.query(` + SELECT code, entitlement_tier, requires_manual_multiplier + FROM condon.room_types + ORDER BY code + `); + const actualRoomTypes = roomTypeResult.rows.map(row => [ + row.code, + row.entitlement_tier, + row.requires_manual_multiplier + ]); + + const countResult = await client.query(` + SELECT + (SELECT count(*)::integer FROM condon.owner_accounts) AS owner_account_count, + (SELECT count(*)::integer FROM condon.entitlement_periods) AS entitlement_period_count, + (SELECT count(*)::integer FROM condon.usage_records) AS usage_record_count, + (SELECT count(*)::integer FROM condon.entitlement_ledger) AS ledger_count + `); + const businessCounts = countResult.rows[0]; + + const migrationResult = await client.query(` + SELECT version, checksum + FROM condon.schema_migrations + `); + + const expectedFunctions = { + calculate_multiplier: { volatility: "s" }, + create_usage_record: { volatility: "v" }, + create_usage_record_v2: { volatility: "v" }, + ensure_booking_for_usage: { volatility: "v" }, + open_entitlement_period: { volatility: "v" } + }; + const functionsValid = + functionResult.rowCount === 5 + && functionResult.rows.every(row => + expectedFunctions[row.function_name]?.volatility === row.volatility + && row.security_definer === false + && row.safe_search_path === true + && row.public_execute_revoked === true + ); + + const checks = { + databaseVerified: + identity.database_name === EXPECTED_DATABASE + && identity.role_name === input.user, + transactionReadOnly: identity.transaction_read_only === "on", + existingCatalogUnchanged: + objectResult.rowCount === 128 + && routineResult.rowCount === 5 + && existingFingerprint === BASELINE_FINGERPRINT, + exactTableColumns: sameJson(actualColumns, expectedColumns), + exactIndexes: sameJson(actualIndexes, expectedIndexes), + functionsValid, + constraintsValid: + constraintResult.rows[0].constraint_count === 49 + && constraintResult.rows[0].all_foreign_keys_internal === true, + permissionsRestricted: + aclResult.rows[0]?.schema_public_access_revoked === true + && aclResult.rows[0]?.tables_have_no_public_access === true, + exactRoomTypes: sameJson(actualRoomTypes, expectedRoomTypes), + businessDataImported: + businessCounts.owner_account_count === 388 + && businessCounts.entitlement_period_count === 389 + && businessCounts.usage_record_count === 157 + && businessCounts.ledger_count === 547, + migrationVerified: + migrationResult.rowCount === 2 + && migrationResult.rows.some(row => + row.version === MIGRATION_VERSION && row.checksum === MIGRATION_CHECKSUM + ) + && migrationResult.rows.some(row => + row.version === MIGRATION_V2 && row.checksum === MIGRATION_V2_CHECKSUM + ) + }; + + process.stdout.write(`${JSON.stringify({ + ok: Object.values(checks).every(Boolean), + targetSchema: TARGET_SCHEMA, + checks, + existingCatalogFingerprint: existingFingerprint, + targetSummary: { + tableCount: Object.keys(actualColumns).length, + indexCount: actualIndexes.length, + constraintCount: constraintResult.rows[0].constraint_count, + functionCount: functionResult.rowCount, + roomTypeCount: roomTypeResult.rowCount, + businessCounts + } + }, null, 2)}\n`); + + if (Object.values(checks).some(result => !result)) process.exitCode = 1; +} catch (error) { + process.stdout.write(`${JSON.stringify(sanitizeError(error), null, 2)}\n`); + process.exitCode = 1; +} finally { + if (client) { + if (transactionStarted) { + try { + await client.query("ROLLBACK"); + } catch { + process.exitCode = 1; + } + } + await client.end().catch(() => { + process.exitCode = 1; + }); + } +} diff --git a/backend/scripts/verify-import-batch.mjs b/backend/scripts/verify-import-batch.mjs new file mode 100644 index 0000000..dbaad76 --- /dev/null +++ b/backend/scripts/verify-import-batch.mjs @@ -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; }); + } +} diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..aed64ec --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,617 @@ +import cors from "@fastify/cors"; +import swagger from "@fastify/swagger"; +import swaggerUi from "@fastify/swagger-ui"; +import Fastify, { + type FastifyInstance, + type FastifyServerOptions +} from "fastify"; +import { z } from "zod"; +import { + SessionManager, + type AuthenticationConfig +} from "./auth.js"; +import { + ApiError, + ValidationApiError, + safeErrorCode +} from "./errors.js"; +import type { CondoRepository } from "./repository.js"; + +const currentYear = new Date().getUTCFullYear(); +const paginationSchema = { + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(25) +}; +const ownerQuerySchema = z.object({ + ...paginationSchema, + q: z.string().trim().min(1).max(100).optional(), + roomType: z.string().regex(/^[A-Z0-9]+$/).max(16).optional(), + year: z.coerce.number().int().min(2000).max(9999).default(currentYear) +}); +const ownerParamsSchema = z.object({ + id: z.string().uuid() +}); +const ownerDetailQuerySchema = z.object({ + year: z.coerce.number().int().min(2000).max(9999).default(currentYear) +}); +const usageQuerySchema = z.object({ + ...paginationSchema, + ownerAccountId: z.string().uuid().optional(), + confirmationNo: z.string().regex(/^[0-9]+$/).max(64).optional(), + usedRoomType: z.string().regex(/^[A-Z0-9]+$/).max(16).optional() +}); +const createUsageSchema = z.object({ + ownerAccountId: z.string().uuid(), + confirmationNo: z.string().regex(/^[0-9]+$/).max(64), + checkIn: z.iso.date(), + checkOut: z.iso.date(), + usedRoomType: z.string().regex(/^[A-Z0-9]+$/).max(16), + manualMultiplier: z.number().int().min(1).max(3).nullable().optional(), + remark: z.string().max(2_000).optional(), + idempotencyKey: z.string().uuid() +}); +const dashboardQuerySchema = z.object({ + year: z.coerce.number().int().min(2000).max(9999).default(currentYear) +}); +const loginSchema = z.object({ + username: z.string().min(1).max(128), + password: z.string().min(1).max(256) +}); + +function parse(schema: z.ZodType, value: unknown): T { + const result = schema.safeParse(value); + if (!result.success) throw new ValidationApiError(); + return result.data; +} + +function isFastifyValidationError(error: unknown): boolean { + return typeof error === "object" + && error !== null + && "validation" in error; +} + +const errorResponseSchema = { + type: "object", + required: ["error"], + properties: { + error: { + type: "object", + required: ["code", "message"], + properties: { + code: { type: "string" }, + message: { type: "string" } + } + } + } +} as const; + +const authSessionResponseSchema = { + type: "object", + required: ["authenticated"], + properties: { + authenticated: { type: "boolean" }, + user: { + type: "object", + required: ["username"], + properties: { + username: { type: "string" } + } + }, + expiresAt: { type: "string", format: "date-time" } + } +} as const; + +const ownerAccountResponseSchema = { + type: "object", + required: [ + "id", + "accountNo", + "transferDate", + "name", + "roomNo", + "purchasedRoomType", + "unitNo", + "memberNo", + "remainingStayPrivileges" + ], + properties: { + id: { type: "string", format: "uuid" }, + accountNo: { anyOf: [{ type: "integer" }, { type: "null" }] }, + transferDate: { + anyOf: [{ type: "string", format: "date" }, { type: "null" }] + }, + name: { type: "string" }, + roomNo: { type: "string" }, + purchasedRoomType: { type: "string" }, + unitNo: { type: "string" }, + memberNo: { type: "string" }, + remainingStayPrivileges: { + anyOf: [{ type: "integer" }, { type: "null" }] + } + } +} as const; + +const usageRecordResponseSchema = { + type: "object", + required: [ + "id", + "confirmationNo", + "ownerAccountId", + "ownerName", + "ownerRoomNo", + "checkIn", + "checkOut", + "night", + "use", + "balance", + "usedRoomType", + "remark", + "appliedMultiplier", + "createdAt" + ], + properties: { + id: { type: "string", format: "uuid" }, + confirmationNo: { type: "string" }, + ownerAccountId: { type: "string", format: "uuid" }, + ownerName: { type: "string" }, + ownerRoomNo: { type: "string" }, + checkIn: { type: "string", format: "date" }, + checkOut: { type: "string", format: "date" }, + night: { type: "integer", minimum: 1 }, + use: { type: "integer", minimum: 1 }, + balance: { type: "integer", minimum: 0 }, + usedRoomType: { type: "string" }, + remark: { type: "string" }, + appliedMultiplier: { + anyOf: [ + { type: "integer", minimum: 1, maximum: 3 }, + { type: "null" } + ] + }, + createdAt: { type: "string", format: "date-time" } + } +} as const; + +export interface BuildAppOptions { + repository: CondoRepository; + authentication: AuthenticationConfig; + corsOrigins?: string[]; + logger?: FastifyServerOptions["logger"]; +} + +export async function buildApp( + options: BuildAppOptions +): Promise { + const app = Fastify({ + logger: options.logger ?? false + }); + const sessions = new SessionManager(options.authentication); + + await app.register(cors, { + origin: options.corsOrigins ?? [ + "http://127.0.0.1:4173", + "http://localhost:4173" + ], + credentials: true, + methods: ["GET", "POST"] + }); + + const publicAuthPaths = new Set([ + "/auth/login", + "/auth/logout", + "/auth/session" + ]); + app.addHook("onRequest", async (request, reply) => { + const path = request.url.split("?", 1)[0] ?? request.url; + if (request.method === "OPTIONS" || publicAuthPaths.has(path)) return; + + if (!sessions.get(request.headers.cookie)) { + if (sessions.hasCookie(request.headers.cookie)) { + reply.header("Set-Cookie", sessions.clearedCookie()); + } + throw new ApiError(401, "UNAUTHORIZED", "Authentication required"); + } + reply.header("Cache-Control", "no-store"); + }); + + await app.register(swagger, { + openapi: { + info: { + title: "CONDO Backend API", + version: "0.1.0" + } + } + }); + await app.register(swaggerUi, { + routePrefix: "/docs" + }); + + app.post("/auth/login", { + schema: { + tags: ["authentication"], + body: { + type: "object", + additionalProperties: false, + required: ["username", "password"], + properties: { + username: { type: "string", minLength: 1, maxLength: 128 }, + password: { type: "string", minLength: 1, maxLength: 256 } + } + }, + response: { + 200: authSessionResponseSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 500: errorResponseSchema + } + } + }, async (request, reply) => { + const body = parse(loginSchema, request.body); + if (!sessions.credentialsMatch(body.username, body.password)) { + throw new ApiError(401, "INVALID_CREDENTIALS", "Invalid username or password"); + } + + sessions.revoke(request.headers.cookie); + const { token, session } = sessions.create(); + reply.header("Cache-Control", "no-store"); + reply.header("Set-Cookie", sessions.sessionCookie(token)); + return { + authenticated: true, + user: { username: session.username }, + expiresAt: session.expiresAt + }; + }); + + app.get("/auth/session", { + schema: { + tags: ["authentication"], + response: { + 200: authSessionResponseSchema, + 500: errorResponseSchema + } + } + }, async (request, reply) => { + reply.header("Cache-Control", "no-store"); + const session = sessions.get(request.headers.cookie); + if (!session) { + if (sessions.hasCookie(request.headers.cookie)) { + reply.header("Set-Cookie", sessions.clearedCookie()); + } + return { authenticated: false }; + } + return { + authenticated: true, + user: { username: session.username }, + expiresAt: session.expiresAt + }; + }); + + app.post("/auth/logout", { + schema: { + tags: ["authentication"], + response: { + 200: authSessionResponseSchema, + 500: errorResponseSchema + } + } + }, async (request, reply) => { + sessions.revoke(request.headers.cookie); + reply.header("Cache-Control", "no-store"); + reply.header("Set-Cookie", sessions.clearedCookie()); + return { authenticated: false }; + }); + + app.get("/health", { + schema: { + tags: ["system"], + response: { + 200: { + type: "object", + required: ["status", "database", "schema", "migrationVersion"], + properties: { + status: { type: "string", const: "ok" }, + database: { type: "string", const: "booking_test" }, + schema: { type: "string", const: "condon" }, + migrationVersion: { type: "string" } + } + }, + 500: errorResponseSchema + } + } + }, async () => options.repository.health()); + + app.get("/room-types", { + schema: { + tags: ["room-types"], + response: { + 200: { + type: "array", + items: { + type: "object", + required: [ + "code", + "entitlementTier", + "requiresManualMultiplier" + ], + properties: { + code: { type: "string" }, + entitlementTier: { + anyOf: [{ type: "integer" }, { type: "null" }] + }, + requiresManualMultiplier: { type: "boolean" } + } + } + } + } + } + }, async () => options.repository.listRoomTypes()); + + app.get("/owner-accounts", { + schema: { + tags: ["owner-accounts"], + querystring: { + type: "object", + properties: { + q: { type: "string" }, + roomType: { type: "string" }, + year: { type: "integer" }, + page: { type: "integer", minimum: 1 }, + pageSize: { type: "integer", minimum: 1, maximum: 100 } + } + }, + response: { + 200: { + type: "object", + required: ["items", "total", "page", "pageSize", "periodYear"], + properties: { + items: { + type: "array", + items: ownerAccountResponseSchema + }, + total: { type: "integer", minimum: 0 }, + page: { type: "integer", minimum: 1 }, + pageSize: { type: "integer", minimum: 1, maximum: 100 }, + periodYear: { type: "integer", minimum: 2000, maximum: 9999 } + } + }, + 400: errorResponseSchema, + 500: errorResponseSchema + } + } + }, async request => { + const query = parse(ownerQuerySchema, request.query); + return options.repository.listOwnerAccounts({ + query: query.q, + roomType: query.roomType, + periodYear: query.year, + page: query.page, + pageSize: query.pageSize + }); + }); + + app.get("/owner-accounts/:id", { + schema: { + tags: ["owner-accounts"], + params: { + type: "object", + required: ["id"], + properties: { + id: { type: "string", format: "uuid" } + } + }, + querystring: { + type: "object", + properties: { + year: { type: "integer" } + } + }, + response: { + 200: ownerAccountResponseSchema, + 400: errorResponseSchema, + 404: errorResponseSchema + } + } + }, async request => { + const params = parse(ownerParamsSchema, request.params); + const query = parse(ownerDetailQuerySchema, request.query); + const account = await options.repository.getOwnerAccount(params.id, query.year); + if (!account) { + throw new ApiError(404, "NOT_FOUND", "Owner account not found"); + } + return account; + }); + + app.get("/usage-records", { + schema: { + tags: ["usage-records"], + querystring: { + type: "object", + properties: { + ownerAccountId: { type: "string", format: "uuid" }, + confirmationNo: { type: "string" }, + usedRoomType: { type: "string" }, + page: { type: "integer", minimum: 1 }, + pageSize: { type: "integer", minimum: 1, maximum: 100 } + } + }, + response: { + 200: { + type: "object", + required: ["items", "total", "page", "pageSize"], + properties: { + items: { + type: "array", + items: usageRecordResponseSchema + }, + total: { type: "integer", minimum: 0 }, + page: { type: "integer", minimum: 1 }, + pageSize: { type: "integer", minimum: 1, maximum: 100 } + } + }, + 400: errorResponseSchema, + 500: errorResponseSchema + } + } + }, async request => { + const query = parse(usageQuerySchema, request.query); + return options.repository.listUsageRecords({ + ownerAccountId: query.ownerAccountId, + confirmationNo: query.confirmationNo, + usedRoomType: query.usedRoomType, + page: query.page, + pageSize: query.pageSize + }); + }); + + app.post("/usage-records", { + schema: { + tags: ["usage-records"], + body: { + type: "object", + additionalProperties: false, + required: [ + "ownerAccountId", + "confirmationNo", + "checkIn", + "checkOut", + "usedRoomType", + "idempotencyKey" + ], + properties: { + ownerAccountId: { type: "string", format: "uuid" }, + confirmationNo: { type: "string", pattern: "^[0-9]+$" }, + checkIn: { type: "string", format: "date" }, + checkOut: { type: "string", format: "date" }, + usedRoomType: { type: "string" }, + manualMultiplier: { + anyOf: [ + { type: "integer", minimum: 1, maximum: 3 }, + { type: "null" } + ] + }, + remark: { type: "string", maxLength: 2000 }, + idempotencyKey: { type: "string", format: "uuid" } + } + }, + response: { + 201: usageRecordResponseSchema, + 400: errorResponseSchema, + 404: errorResponseSchema, + 409: errorResponseSchema, + 422: errorResponseSchema, + 500: errorResponseSchema + } + } + }, async (request, reply) => { + const body = parse(createUsageSchema, request.body); + const result = await options.repository.createUsageRecord({ + ownerAccountId: body.ownerAccountId, + confirmationNo: body.confirmationNo, + checkIn: body.checkIn, + checkOut: body.checkOut, + usedRoomType: body.usedRoomType, + manualMultiplier: body.manualMultiplier ?? null, + remark: body.remark ?? "", + idempotencyKey: body.idempotencyKey + }); + return reply.code(201).send(result); + }); + + app.get("/dashboard", { + schema: { + tags: ["dashboard"], + querystring: { + type: "object", + properties: { + year: { type: "integer" } + } + }, + response: { + 200: { + type: "object", + required: [ + "periodYear", + "ownerRooms", + "remainingPrivileges", + "used", + "purchasedRoomTypes", + "usedRoomTypes", + "monthlyUse" + ], + properties: { + periodYear: { type: "integer", minimum: 2000, maximum: 9999 }, + ownerRooms: { type: "integer", minimum: 0 }, + remainingPrivileges: { type: "integer", minimum: 0 }, + used: { type: "integer", minimum: 0 }, + purchasedRoomTypes: { + type: "array", + items: { + type: "object", + required: ["roomType", "count"], + properties: { + roomType: { type: "string" }, + count: { type: "integer", minimum: 0 } + } + } + }, + usedRoomTypes: { + type: "array", + items: { + type: "object", + required: ["roomType", "count"], + properties: { + roomType: { type: "string" }, + count: { type: "integer", minimum: 0 } + } + } + }, + monthlyUse: { + type: "array", + items: { + type: "object", + required: ["month", "use"], + properties: { + month: { type: "integer", minimum: 1, maximum: 12 }, + use: { type: "integer", minimum: 0 } + } + } + } + } + }, + 400: errorResponseSchema, + 500: errorResponseSchema + } + } + }, async request => { + const query = parse(dashboardQuerySchema, request.query); + return options.repository.getDashboard(query.year); + }); + + app.setErrorHandler((error, request, reply) => { + const apiError = error instanceof ApiError + ? error + : isFastifyValidationError(error) + ? new ValidationApiError() + : new ApiError(500, "INTERNAL_ERROR", "An internal error occurred"); + + if (apiError.statusCode >= 500) { + request.log.error( + { errorCode: safeErrorCode(error), route: request.routeOptions.url }, + "request failed" + ); + } + + return reply.code(apiError.statusCode).send({ + error: { + code: apiError.code, + message: apiError.message + } + }); + }); + + app.addHook("onClose", async () => { + await options.repository.close(); + }); + + return app; +} diff --git a/backend/src/auth.ts b/backend/src/auth.ts new file mode 100644 index 0000000..970dd20 --- /dev/null +++ b/backend/src/auth.ts @@ -0,0 +1,132 @@ +import { + createHash, + randomBytes, + timingSafeEqual +} from "node:crypto"; + +export const SESSION_COOKIE_NAME = "condon_session"; + +export interface AuthenticationConfig { + username: string; + password: string; + sessionTtlMs: number; + cookieSecure: boolean; +} + +export interface AuthenticatedSession { + username: string; + expiresAt: string; +} + +interface StoredSession { + username: string; + expiresAtMs: number; +} + +function digest(value: string): Buffer { + return createHash("sha256").update(value, "utf8").digest(); +} + +function constantTimeEqual(left: string, right: string): boolean { + return timingSafeEqual(digest(left), digest(right)); +} + +function tokenKey(token: string): string { + return digest(token).toString("hex"); +} + +function cookieValue(cookieHeader: string | undefined, name: string): string | null { + if (!cookieHeader) return null; + for (const part of cookieHeader.split(";")) { + const separator = part.indexOf("="); + if (separator < 0) continue; + if (part.slice(0, separator).trim() !== name) continue; + const value = part.slice(separator + 1).trim(); + return value || null; + } + return null; +} + +export class SessionManager { + private readonly sessions = new Map(); + + constructor( + private readonly config: AuthenticationConfig, + private readonly now: () => number = Date.now + ) {} + + credentialsMatch(username: string, password: string): boolean { + const usernameMatches = constantTimeEqual(username, this.config.username); + const passwordMatches = constantTimeEqual(password, this.config.password); + return usernameMatches && passwordMatches; + } + + create(): { token: string; session: AuthenticatedSession } { + this.pruneExpired(); + const token = randomBytes(32).toString("base64url"); + const expiresAtMs = this.now() + this.config.sessionTtlMs; + this.sessions.set(tokenKey(token), { + username: this.config.username, + expiresAtMs + }); + return { + token, + session: { + username: this.config.username, + expiresAt: new Date(expiresAtMs).toISOString() + } + }; + } + + get(cookieHeader: string | undefined): AuthenticatedSession | null { + const token = cookieValue(cookieHeader, SESSION_COOKIE_NAME); + if (!token) return null; + const key = tokenKey(token); + const stored = this.sessions.get(key); + if (!stored) return null; + if (stored.expiresAtMs <= this.now()) { + this.sessions.delete(key); + return null; + } + return { + username: stored.username, + expiresAt: new Date(stored.expiresAtMs).toISOString() + }; + } + + revoke(cookieHeader: string | undefined): void { + const token = cookieValue(cookieHeader, SESSION_COOKIE_NAME); + if (token) this.sessions.delete(tokenKey(token)); + } + + hasCookie(cookieHeader: string | undefined): boolean { + return cookieValue(cookieHeader, SESSION_COOKIE_NAME) !== null; + } + + sessionCookie(token: string): string { + const maxAge = Math.floor(this.config.sessionTtlMs / 1_000); + return this.serializeCookie(token, `Max-Age=${maxAge}`); + } + + clearedCookie(): string { + return this.serializeCookie("", "Max-Age=0"); + } + + private serializeCookie(value: string, lifetime: string): string { + return [ + `${SESSION_COOKIE_NAME}=${value}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + lifetime, + this.config.cookieSecure ? "Secure" : "" + ].filter(Boolean).join("; "); + } + + private pruneExpired(): void { + const currentTime = this.now(); + for (const [key, session] of this.sessions) { + if (session.expiresAtMs <= currentTime) this.sessions.delete(key); + } + } +} diff --git a/backend/src/config.ts b/backend/src/config.ts new file mode 100644 index 0000000..53c4403 --- /dev/null +++ b/backend/src/config.ts @@ -0,0 +1,110 @@ +import { z } from "zod"; + +const booleanString = z + .enum(["true", "false"]) + .default("false") + .transform(value => value === "true"); + +const optionalBooleanString = z + .enum(["true", "false"]) + .optional() + .transform(value => value === undefined ? undefined : value === "true"); + +const configSchema = z.object({ + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + API_HOST: z.string().min(1).default("127.0.0.1"), + API_PORT: z.coerce.number().int().min(1).max(65535).default(3000), + CORS_ORIGINS: z + .string() + .default("http://127.0.0.1:4173,http://localhost:4173"), + LOG_LEVEL: z + .enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]) + .default("info"), + AUTH_USERNAME: z.string().min(1).max(128).default("wyndhamcondon"), + AUTH_PASSWORD: z.string().min(1).max(256).default("wyndhamcondon"), + AUTH_SESSION_TTL_HOURS: z.coerce.number().int().min(1).max(168).default(12), + AUTH_COOKIE_SECURE: optionalBooleanString, + DB_HOST: z.string().min(1), + DB_PORT: z.coerce.number().int().min(1).max(65535).default(5432), + DB_USER: z.string().min(1), + DB_PASSWORD: z.string().min(1), + DB_NAME: z.literal("booking_test"), + DB_SSL: booleanString, + DB_POOL_MAX: z.coerce.number().int().min(1).max(50).default(10), + DB_STATEMENT_TIMEOUT_MS: z.coerce + .number() + .int() + .min(1_000) + .max(120_000) + .default(15_000) +}); + +export interface AppConfig { + nodeEnv: "development" | "test" | "production"; + apiHost: string; + apiPort: number; + corsOrigins: string[]; + logLevel: "fatal" | "error" | "warn" | "info" | "debug" | "trace" | "silent"; + authentication: { + username: string; + password: string; + sessionTtlMs: number; + cookieSecure: boolean; + }; + database: { + host: string; + port: number; + user: string; + password: string; + name: "booking_test"; + ssl: boolean; + poolMax: number; + statementTimeoutMs: number; + }; +} + +export class ConfigurationError extends Error { + readonly code = "INVALID_CONFIGURATION"; +} + +export function loadConfig( + environment: NodeJS.ProcessEnv = process.env +): AppConfig { + const result = configSchema.safeParse(environment); + if (!result.success) { + const fields = [ + ...new Set(result.error.issues.map(issue => String(issue.path[0] ?? "unknown"))) + ].sort(); + throw new ConfigurationError( + `Invalid environment configuration: ${fields.join(", ")}` + ); + } + + const value = result.data; + return { + nodeEnv: value.NODE_ENV, + apiHost: value.API_HOST, + apiPort: value.API_PORT, + corsOrigins: value.CORS_ORIGINS + .split(",") + .map(origin => origin.trim()) + .filter(Boolean), + logLevel: value.LOG_LEVEL, + authentication: { + username: value.AUTH_USERNAME, + password: value.AUTH_PASSWORD, + sessionTtlMs: value.AUTH_SESSION_TTL_HOURS * 60 * 60 * 1_000, + cookieSecure: value.AUTH_COOKIE_SECURE ?? value.NODE_ENV === "production" + }, + database: { + host: value.DB_HOST, + port: value.DB_PORT, + user: value.DB_USER, + password: value.DB_PASSWORD, + name: value.DB_NAME, + ssl: value.DB_SSL, + poolMax: value.DB_POOL_MAX, + statementTimeoutMs: value.DB_STATEMENT_TIMEOUT_MS + } + }; +} diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..fac8aca --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,23 @@ +import pg from "pg"; +import type { AppConfig } from "./config.js"; + +const { Pool } = pg; + +export function createPool(config: AppConfig["database"]): pg.Pool { + return new Pool({ + host: config.host, + port: config.port, + user: config.user, + password: config.password, + database: config.name, + ssl: config.ssl ? { rejectUnauthorized: false } : undefined, + max: config.poolMax, + connectionTimeoutMillis: 10_000, + idleTimeoutMillis: 30_000, + application_name: "condon_api", + options: [ + `-c statement_timeout=${config.statementTimeoutMs}`, + "-c lock_timeout=2000" + ].join(" ") + }); +} diff --git a/backend/src/errors.ts b/backend/src/errors.ts new file mode 100644 index 0000000..af9cb0c --- /dev/null +++ b/backend/src/errors.ts @@ -0,0 +1,82 @@ +interface DatabaseErrorShape { + code?: string; + message?: string; +} + +export class ApiError extends Error { + constructor( + readonly statusCode: number, + readonly code: string, + message: string + ) { + super(message); + } +} + +export class ValidationApiError extends ApiError { + constructor(message = "Request validation failed") { + super(400, "VALIDATION_ERROR", message); + } +} + +function asDatabaseError(error: unknown): DatabaseErrorShape { + if (typeof error !== "object" || error === null) return {}; + const candidate = error as Record; + return { + code: typeof candidate.code === "string" ? candidate.code : undefined, + message: typeof candidate.message === "string" ? candidate.message : undefined + }; +} + +export function mapDatabaseError(error: unknown): ApiError { + const databaseError = asDatabaseError(error); + const message = databaseError.message ?? ""; + + if (message === "CONDON_INSUFFICIENT_BALANCE") { + return new ApiError( + 422, + "INSUFFICIENT_BALANCE", + "The owner account does not have enough stay privileges" + ); + } + + if ( + message === "CONDON_OWNER_ACCOUNT_NOT_FOUND" + || message === "CONDON_ENTITLEMENT_PERIOD_NOT_FOUND" + || message.endsWith("_ROOM_TYPE_NOT_FOUND") + ) { + return new ApiError(404, "NOT_FOUND", "The requested business record was not found"); + } + + if ( + message === "CONDON_CONFIRMATION_NO_EXISTS" + || message === "CONDON_IDEMPOTENCY_KEY_REUSED" + || message === "CONDON_ENTITLEMENT_PERIOD_EXISTS" + || message === "CONDON_BALANCE_CONCURRENTLY_CHANGED" + || databaseError.code === "23505" + || databaseError.code === "40001" + ) { + return new ApiError(409, "CONFLICT", "The request conflicts with current data"); + } + + if ( + message.startsWith("CONDON_") + && ( + databaseError.code === "22023" + || message.includes("INVALID") + || message.includes("NOT_ALLOWED") + || message.includes("REQUIRED") + || message.includes("NOT_SUPPORTED") + || message.includes("OUTSIDE") + ) + ) { + return new ApiError(400, "BUSINESS_RULE_VIOLATION", "The request violates a business rule"); + } + + return new ApiError(500, "INTERNAL_ERROR", "An internal error occurred"); +} + +export function safeErrorCode(error: unknown): string { + if (error instanceof ApiError) return error.code; + return asDatabaseError(error).code ?? "UNEXPECTED_ERROR"; +} diff --git a/backend/src/postgres-repository.ts b/backend/src/postgres-repository.ts new file mode 100644 index 0000000..5ede53e --- /dev/null +++ b/backend/src/postgres-repository.ts @@ -0,0 +1,445 @@ +import { randomUUID } from "node:crypto"; +import type pg from "pg"; +import { mapDatabaseError } from "./errors.js"; +import type { CondoRepository } from "./repository.js"; +import type { + CreateUsageRecordInput, + DashboardResult, + OwnerAccount, + OwnerAccountList, + OwnerAccountQuery, + RoomType, + UsageRecord, + UsageRecordList, + UsageRecordQuery +} from "./types.js"; + +type Row = Record; + +function numberValue(value: unknown): number { + return typeof value === "number" ? value : Number(value); +} + +function nullableNumber(value: unknown): number | null { + return value === null || value === undefined ? null : numberValue(value); +} + +export function dateValue(value: unknown): string { + if (value instanceof Date) { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } + return String(value); +} + +function nullableDate(value: unknown): string | null { + return value === null || value === undefined ? null : dateValue(value); +} + +function timestampValue(value: unknown): string { + if (value instanceof Date) return value.toISOString(); + return new Date(String(value)).toISOString(); +} + +function mapOwnerAccount(row: Row): OwnerAccount { + return { + id: String(row.id), + accountNo: nullableNumber(row.account_no), + transferDate: nullableDate(row.transfer_date), + name: String(row.owner_name), + roomNo: String(row.room_no), + purchasedRoomType: String(row.purchased_room_type_code), + unitNo: String(row.unit_no), + memberNo: String(row.member_no), + remainingStayPrivileges: nullableNumber(row.current_balance) + }; +} + +function mapUsageRecord(row: Row): UsageRecord { + return { + id: String(row.id), + confirmationNo: String(row.confirmation_no), + ownerAccountId: String(row.owner_account_id), + ownerName: String(row.owner_name), + ownerRoomNo: String(row.room_no), + checkIn: dateValue(row.check_in), + checkOut: dateValue(row.check_out), + night: numberValue(row.night_count), + use: numberValue(row.use_nights), + balance: numberValue(row.balance_after), + usedRoomType: String(row.used_room_type ?? row.raw_used_room_type ?? ""), + remark: String(row.remark), + appliedMultiplier: nullableNumber(row.applied_multiplier), + createdAt: timestampValue(row.created_at) + }; +} + +export class PostgresCondoRepository implements CondoRepository { + constructor(private readonly pool: pg.Pool) {} + + async health() { + const result = await this.pool.query(` + SELECT + current_database() AS database_name, + pg_catalog.to_regnamespace('condon') IS NOT NULL AS schema_exists, + ( + SELECT version + FROM condon.schema_migrations + ORDER BY applied_at DESC + LIMIT 1 + ) AS migration_version + `); + const row = result.rows[0] as Row; + if ( + row.database_name !== "booking_test" + || row.schema_exists !== true + || typeof row.migration_version !== "string" + ) { + throw new Error("Database health contract failed"); + } + return { + status: "ok" as const, + database: "booking_test" as const, + schema: "condon" as const, + migrationVersion: row.migration_version + }; + } + + async listRoomTypes(): Promise { + const result = await this.pool.query(` + SELECT code, entitlement_tier, requires_manual_multiplier + FROM condon.room_types + ORDER BY + requires_manual_multiplier, + entitlement_tier NULLS LAST, + code + `); + return result.rows.map((row: Row) => ({ + code: String(row.code), + entitlementTier: nullableNumber(row.entitlement_tier), + requiresManualMultiplier: row.requires_manual_multiplier === true + })); + } + + async listOwnerAccounts(query: OwnerAccountQuery): Promise { + const offset = (query.page - 1) * query.pageSize; + const result = await this.pool.query( + ` + WITH filtered AS ( + SELECT + oa.id, + oa.account_no, + oa.transfer_date, + oa.owner_name, + oa.room_no, + oa.purchased_room_type_code, + oa.unit_no, + oa.member_no, + ep.current_balance + FROM condon.owner_accounts AS oa + LEFT JOIN condon.entitlement_periods AS ep + ON ep.owner_account_id = oa.id + AND ep.period_year = $3 + WHERE ( + $1::text IS NULL + OR POSITION( + pg_catalog.lower($1) + IN pg_catalog.lower( + pg_catalog.concat_ws( + ' ', + oa.account_no::text, + oa.owner_name, + oa.room_no, + oa.unit_no, + oa.member_no + ) + ) + ) > 0 + ) + AND ( + $2::text IS NULL + OR oa.purchased_room_type_code = $2 + ) + ), + page AS ( + SELECT * + FROM filtered + ORDER BY account_no NULLS LAST, room_no, id + LIMIT $4 + OFFSET $5 + ) + SELECT + page.*, + (SELECT count(*)::integer FROM filtered) AS total_count + FROM (SELECT 1) AS sentinel + LEFT JOIN page ON true + ORDER BY page.account_no NULLS LAST, page.room_no, page.id + `, + [ + query.query ?? null, + query.roomType ?? null, + query.periodYear, + query.pageSize, + offset + ] + ); + const itemRows = result.rows.filter((row: Row) => row.id !== null); + return { + items: itemRows.map((row: Row) => mapOwnerAccount(row)), + total: numberValue((result.rows[0] as Row).total_count), + page: query.page, + pageSize: query.pageSize, + periodYear: query.periodYear + }; + } + + async getOwnerAccount( + id: string, + periodYear: number + ): Promise { + const result = await this.pool.query( + ` + SELECT + oa.id, + oa.account_no, + oa.transfer_date, + oa.owner_name, + oa.room_no, + oa.purchased_room_type_code, + oa.unit_no, + oa.member_no, + ep.current_balance + FROM condon.owner_accounts AS oa + LEFT JOIN condon.entitlement_periods AS ep + ON ep.owner_account_id = oa.id + AND ep.period_year = $2 + WHERE oa.id = $1 + `, + [id, periodYear] + ); + return result.rowCount === 0 ? null : mapOwnerAccount(result.rows[0] as Row); + } + + async listUsageRecords(query: UsageRecordQuery): Promise { + const offset = (query.page - 1) * query.pageSize; + const result = await this.pool.query( + ` + WITH filtered AS ( + SELECT + ur.id, + ur.confirmation_no, + ur.owner_account_id, + oa.owner_name, + oa.room_no, + ur.check_in, + ur.check_out, + ur.night_count, + ur.use_nights, + ur.balance_after, + COALESCE(ur.used_room_type_code, NULLIF(ur.raw_used_room_type, '')) AS used_room_type, + ur.raw_used_room_type, + ur.remark, + ur.applied_multiplier, + ur.source_sheet, + ur.source_row, + ur.source_sequence, + ur.created_at + FROM condon.usage_records AS ur + JOIN condon.owner_accounts AS oa + ON oa.id = ur.owner_account_id + WHERE ( + $1::uuid IS NULL + OR ur.owner_account_id = $1 + ) + AND ( + $2::text IS NULL + OR POSITION($2 IN ur.confirmation_no) > 0 + ) + AND ( + $3::text IS NULL + OR ur.used_room_type_code = $3 + ) + ), + page AS ( + SELECT * + FROM filtered + ORDER BY + CASE WHEN source_sheet IS NULL THEN 1 ELSE 0 END, + source_sheet NULLS LAST, + source_sequence NULLS LAST, + source_row NULLS LAST, + created_at DESC, + id DESC + LIMIT $4 + OFFSET $5 + ) + SELECT + page.*, + (SELECT count(*)::integer FROM filtered) AS total_count + FROM (SELECT 1) AS sentinel + LEFT JOIN page ON true + ORDER BY + CASE WHEN page.source_sheet IS NULL THEN 1 ELSE 0 END, + page.source_sheet NULLS LAST, + page.source_sequence NULLS LAST, + page.source_row NULLS LAST, + page.created_at DESC, + page.id DESC + `, + [ + query.ownerAccountId ?? null, + query.confirmationNo ?? null, + query.usedRoomType ?? null, + query.pageSize, + offset + ] + ); + const itemRows = result.rows.filter((row: Row) => row.id !== null); + return { + items: itemRows.map((row: Row) => mapUsageRecord(row)), + total: numberValue((result.rows[0] as Row).total_count), + page: query.page, + pageSize: query.pageSize + }; + } + + async createUsageRecord( + input: CreateUsageRecordInput + ): Promise { + try { + const result = await this.pool.query( + ` + WITH created AS MATERIALIZED ( + SELECT record.* + FROM condon.create_usage_record_v2( + $1::uuid, + $2::uuid, + $3::uuid, + $4::varchar, + $5::date, + $6::date, + $7::varchar, + $8::smallint, + $9::text, + $10::uuid + ) AS record + ) + SELECT + created.*, + oa.owner_name, + oa.room_no + FROM created + JOIN condon.owner_accounts AS oa + ON oa.id = created.owner_account_id + `, + [ + randomUUID(), + randomUUID(), + input.ownerAccountId, + input.confirmationNo, + input.checkIn, + input.checkOut, + input.usedRoomType, + input.manualMultiplier, + input.remark, + input.idempotencyKey + ] + ); + return mapUsageRecord(result.rows[0] as Row); + } catch (error) { + throw mapDatabaseError(error); + } + } + + async getDashboard(periodYear: number): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN TRANSACTION READ ONLY"); + const summary = await client.query( + ` + SELECT + (SELECT count(*)::integer FROM condon.owner_accounts) AS owner_rooms, + COALESCE(sum(ep.current_balance), 0)::integer AS remaining_privileges, + COALESCE(( + SELECT sum(ur.use_nights) + FROM condon.usage_records AS ur + JOIN condon.entitlement_periods AS usage_period + ON usage_period.id = ur.entitlement_period_id + WHERE usage_period.period_year = $1 + ), 0)::integer AS used + FROM condon.entitlement_periods AS ep + WHERE ep.period_year = $1 + `, + [periodYear] + ); + const purchasedTypes = await client.query(` + SELECT + purchased_room_type_code AS room_type, + count(*)::integer AS count + FROM condon.owner_accounts + GROUP BY purchased_room_type_code + ORDER BY purchased_room_type_code + `); + const usedTypes = await client.query( + ` + SELECT + COALESCE(ur.used_room_type_code, NULLIF(ur.raw_used_room_type, '')) AS room_type, + sum(ur.night_count)::integer AS count + FROM condon.usage_records AS ur + JOIN condon.entitlement_periods AS ep + ON ep.id = ur.entitlement_period_id + WHERE ep.period_year = $1 + GROUP BY COALESCE(ur.used_room_type_code, NULLIF(ur.raw_used_room_type, '')) + ORDER BY COALESCE(ur.used_room_type_code, NULLIF(ur.raw_used_room_type, '')) + `, + [periodYear] + ); + const monthlyUse = await client.query( + ` + SELECT + EXTRACT(month FROM ur.check_in)::integer AS month, + sum(ur.use_nights)::integer AS use + FROM condon.usage_records AS ur + JOIN condon.entitlement_periods AS ep + ON ep.id = ur.entitlement_period_id + WHERE ep.period_year = $1 + GROUP BY EXTRACT(month FROM ur.check_in) + ORDER BY month + `, + [periodYear] + ); + await client.query("COMMIT"); + + const row = summary.rows[0] as Row; + return { + periodYear, + ownerRooms: numberValue(row.owner_rooms), + remainingPrivileges: numberValue(row.remaining_privileges), + used: numberValue(row.used), + purchasedRoomTypes: purchasedTypes.rows.map((item: Row) => ({ + roomType: String(item.room_type), + count: numberValue(item.count) + })), + usedRoomTypes: usedTypes.rows.map((item: Row) => ({ + roomType: String(item.room_type), + count: numberValue(item.count) + })), + monthlyUse: monthlyUse.rows.map((item: Row) => ({ + month: numberValue(item.month), + use: numberValue(item.use) + })) + }; + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } + } + + async close(): Promise { + await this.pool.end(); + } +} diff --git a/backend/src/repository.ts b/backend/src/repository.ts new file mode 100644 index 0000000..c9ad806 --- /dev/null +++ b/backend/src/repository.ts @@ -0,0 +1,23 @@ +import type { + CreateUsageRecordInput, + DashboardResult, + HealthResult, + OwnerAccount, + OwnerAccountList, + OwnerAccountQuery, + RoomType, + UsageRecord, + UsageRecordList, + UsageRecordQuery +} from "./types.js"; + +export interface CondoRepository { + health(): Promise; + listRoomTypes(): Promise; + listOwnerAccounts(query: OwnerAccountQuery): Promise; + getOwnerAccount(id: string, periodYear: number): Promise; + listUsageRecords(query: UsageRecordQuery): Promise; + createUsageRecord(input: CreateUsageRecordInput): Promise; + getDashboard(periodYear: number): Promise; + close(): Promise; +} diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..b2ed1b9 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,54 @@ +import { buildApp } from "./app.js"; +import { loadConfig } from "./config.js"; +import { createPool } from "./db.js"; +import { PostgresCondoRepository } from "./postgres-repository.js"; + +const config = loadConfig(); +const pool = createPool(config.database); +const repository = new PostgresCondoRepository(pool); +const app = await buildApp({ + repository, + corsOrigins: config.corsOrigins, + authentication: config.authentication, + logger: { + level: config.logLevel, + redact: { + paths: [ + "req.headers.authorization", + "req.headers.cookie", + "password", + "*.password", + "authentication.password", + "database.password" + ], + censor: "[REDACTED]" + } + } +}); + +let shuttingDown = false; +async function shutdown(signal: string) { + if (shuttingDown) return; + shuttingDown = true; + app.log.info({ signal }, "shutting down"); + await app.close(); +} + +process.once("SIGINT", () => { + void shutdown("SIGINT"); +}); +process.once("SIGTERM", () => { + void shutdown("SIGTERM"); +}); + +try { + await app.listen({ + host: config.apiHost, + port: config.apiPort + }); +} catch (error) { + app.log.error({ errorCode: "STARTUP_FAILED" }, "server failed to start"); + await app.close().catch(() => undefined); + process.exitCode = 1; + if (config.nodeEnv !== "production") throw error; +} diff --git a/backend/src/types.ts b/backend/src/types.ts new file mode 100644 index 0000000..1aa7829 --- /dev/null +++ b/backend/src/types.ts @@ -0,0 +1,104 @@ +export interface HealthResult { + status: "ok"; + database: "booking_test"; + schema: "condon"; + migrationVersion: string; +} + +export interface RoomType { + code: string; + entitlementTier: number | null; + requiresManualMultiplier: boolean; +} + +export interface OwnerAccount { + id: string; + accountNo: number | null; + transferDate: string | null; + name: string; + roomNo: string; + purchasedRoomType: string; + unitNo: string; + memberNo: string; + remainingStayPrivileges: number | null; +} + +export interface OwnerAccountList { + items: OwnerAccount[]; + total: number; + page: number; + pageSize: number; + periodYear: number; +} + +export interface UsageRecord { + id: string; + confirmationNo: string; + ownerAccountId: string; + ownerName: string; + ownerRoomNo: string; + checkIn: string; + checkOut: string; + night: number; + use: number; + balance: number; + usedRoomType: string; + remark: string; + appliedMultiplier: number | null; + createdAt: string; +} + +export interface UsageRecordList { + items: UsageRecord[]; + total: number; + page: number; + pageSize: number; +} + +export interface DashboardRoomTypeCount { + roomType: string; + count: number; +} + +export interface DashboardMonthUse { + month: number; + use: number; +} + +export interface DashboardResult { + periodYear: number; + ownerRooms: number; + remainingPrivileges: number; + used: number; + purchasedRoomTypes: DashboardRoomTypeCount[]; + usedRoomTypes: DashboardRoomTypeCount[]; + monthlyUse: DashboardMonthUse[]; +} + +export interface PaginationInput { + page: number; + pageSize: number; +} + +export interface OwnerAccountQuery extends PaginationInput { + query?: string; + roomType?: string; + periodYear: number; +} + +export interface UsageRecordQuery extends PaginationInput { + ownerAccountId?: string; + confirmationNo?: string; + usedRoomType?: string; +} + +export interface CreateUsageRecordInput { + ownerAccountId: string; + confirmationNo: string; + checkIn: string; + checkOut: string; + usedRoomType: string; + manualMultiplier: number | null; + remark: string; + idempotencyKey: string; +} diff --git a/backend/tests/app.test.ts b/backend/tests/app.test.ts new file mode 100644 index 0000000..235726f --- /dev/null +++ b/backend/tests/app.test.ts @@ -0,0 +1,417 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { FastifyInstance } from "fastify"; +import { buildApp } from "../src/app.js"; +import { ApiError } from "../src/errors.js"; +import type { CondoRepository } from "../src/repository.js"; +import type { + CreateUsageRecordInput, + OwnerAccountQuery, + UsageRecordQuery +} from "../src/types.js"; + +const ownerId = "00000000-0000-4000-8000-000000000001"; +const usageId = "00000000-0000-4000-8000-000000000002"; +const idempotencyKey = "00000000-0000-4000-8000-000000000003"; +const authentication = { + username: "wyndhamcondon", + password: "wyndhamcondon", + sessionTtlMs: 12 * 60 * 60 * 1_000, + cookieSecure: false +}; + +function createRepository( + overrides: Partial = {} +): CondoRepository { + return { + async health() { + return { + status: "ok", + database: "booking_test", + schema: "condon", + migrationVersion: "001_create_condon_schema" + }; + }, + async listRoomTypes() { + return [ + { + code: "RM1", + entitlementTier: 1, + requiresManualMultiplier: false + }, + { + code: "AC2", + entitlementTier: null, + requiresManualMultiplier: true + } + ]; + }, + async listOwnerAccounts(query: OwnerAccountQuery) { + return { + items: [], + total: 0, + page: query.page, + pageSize: query.pageSize, + periodYear: query.periodYear + }; + }, + async getOwnerAccount() { + return null; + }, + async listUsageRecords(query: UsageRecordQuery) { + return { + items: [], + total: 0, + page: query.page, + pageSize: query.pageSize + }; + }, + async createUsageRecord(input: CreateUsageRecordInput) { + return { + id: usageId, + confirmationNo: input.confirmationNo, + ownerAccountId: input.ownerAccountId, + ownerName: "Test Owner", + ownerRoomNo: "3201", + checkIn: input.checkIn, + checkOut: input.checkOut, + night: 2, + use: 2, + balance: 13, + usedRoomType: input.usedRoomType, + remark: input.remark, + appliedMultiplier: 1, + createdAt: "2026-07-29T00:00:00.000Z" + }; + }, + async getDashboard(periodYear: number) { + return { + periodYear, + ownerRooms: 0, + remainingPrivileges: 0, + used: 0, + purchasedRoomTypes: [], + usedRoomTypes: [], + monthlyUse: [] + }; + }, + async close() {}, + ...overrides + }; +} + +async function buildAuthenticatedApp(repository: CondoRepository): Promise<{ + app: FastifyInstance; + cookie: string; +}> { + const app = await buildApp({ repository, authentication }); + const login = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { + username: authentication.username, + password: authentication.password + } + }); + assert.equal(login.statusCode, 200); + const setCookie = login.headers["set-cookie"]; + if (typeof setCookie !== "string") throw new Error("Missing session cookie"); + const cookie = setCookie.split(";", 1)[0]; + if (!cookie) throw new Error("Invalid session cookie"); + return { app, cookie }; +} + +test("authentication protects the API and logout invalidates the session", async t => { + const app = await buildApp({ + repository: createRepository(), + authentication + }); + t.after(() => app.close()); + + const anonymous = await app.inject({ method: "GET", url: "/health" }); + assert.equal(anonymous.statusCode, 401); + assert.equal(anonymous.json().error.code, "UNAUTHORIZED"); + + const initialSession = await app.inject({ method: "GET", url: "/auth/session" }); + assert.deepEqual(initialSession.json(), { authenticated: false }); + + const rejected = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { username: "wyndhamcondon", password: "incorrect" } + }); + assert.equal(rejected.statusCode, 401); + assert.equal(rejected.json().error.code, "INVALID_CREDENTIALS"); + assert.equal(rejected.headers["set-cookie"], undefined); + + const accepted = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { + username: authentication.username, + password: authentication.password + } + }); + assert.equal(accepted.statusCode, 200); + const setCookie = accepted.headers["set-cookie"]; + if (typeof setCookie !== "string") throw new Error("Missing session cookie"); + assert.match(setCookie, /^condon_session=[A-Za-z0-9_-]+;/); + assert.match(setCookie, /HttpOnly/); + assert.match(setCookie, /SameSite=Lax/); + assert.match(setCookie, /Max-Age=43200/); + assert.equal(setCookie.includes("Secure"), false); + const cookie = setCookie.split(";", 1)[0]; + + const activeSession = await app.inject({ + method: "GET", + url: "/auth/session", + headers: { cookie } + }); + assert.equal(activeSession.json().authenticated, true); + assert.equal(activeSession.json().user.username, authentication.username); + assert.match(activeSession.json().expiresAt, /^\d{4}-\d{2}-\d{2}T/); + + const protectedResponse = await app.inject({ + method: "GET", + url: "/health", + headers: { cookie } + }); + assert.equal(protectedResponse.statusCode, 200); + assert.equal(protectedResponse.headers["cache-control"], "no-store"); + + const logout = await app.inject({ + method: "POST", + url: "/auth/logout", + headers: { cookie } + }); + assert.equal(logout.statusCode, 200); + assert.equal(logout.json().authenticated, false); + assert.match(String(logout.headers["set-cookie"]), /Max-Age=0/); + + const afterLogout = await app.inject({ + method: "GET", + url: "/health", + headers: { cookie } + }); + assert.equal(afterLogout.statusCode, 401); + + const preflight = await app.inject({ + method: "OPTIONS", + url: "/health", + headers: { + origin: "http://127.0.0.1:4173", + "access-control-request-method": "GET" + } + }); + assert.equal(preflight.statusCode, 204); + assert.equal(preflight.headers["access-control-allow-credentials"], "true"); +}); + +test("health and room type contracts", async t => { + const { app, cookie } = await buildAuthenticatedApp(createRepository()); + t.after(() => app.close()); + + const health = await app.inject({ method: "GET", url: "/health", headers: { cookie } }); + assert.equal(health.statusCode, 200); + assert.deepEqual(health.json(), { + status: "ok", + database: "booking_test", + schema: "condon", + migrationVersion: "001_create_condon_schema" + }); + + const roomTypes = await app.inject({ method: "GET", url: "/room-types", headers: { cookie } }); + assert.equal(roomTypes.statusCode, 200); + assert.equal(roomTypes.json().length, 2); + assert.deepEqual(roomTypes.json()[1], { + code: "AC2", + entitlementTier: null, + requiresManualMultiplier: true + }); +}); + +test("owner account query applies pagination and period defaults", async t => { + let captured: OwnerAccountQuery | undefined; + const repository = createRepository({ + async listOwnerAccounts(query) { + captured = query; + return { + items: [], + total: 0, + page: query.page, + pageSize: query.pageSize, + periodYear: query.periodYear + }; + } + }); + const { app, cookie } = await buildAuthenticatedApp(repository); + t.after(() => app.close()); + + const response = await app.inject({ + method: "GET", + url: "/owner-accounts?q=3201&page=2&pageSize=10", + headers: { cookie } + }); + assert.equal(response.statusCode, 200); + assert.deepEqual(captured, { + query: "3201", + roomType: undefined, + periodYear: new Date().getUTCFullYear(), + page: 2, + pageSize: 10 + }); +}); + +test("missing owner and malformed owner id return safe client errors", async t => { + const { app, cookie } = await buildAuthenticatedApp(createRepository()); + t.after(() => app.close()); + + const missing = await app.inject({ + method: "GET", + url: `/owner-accounts/${ownerId}?year=2026`, + headers: { cookie } + }); + assert.equal(missing.statusCode, 404); + assert.deepEqual(missing.json(), { + error: { + code: "NOT_FOUND", + message: "Owner account not found" + } + }); + + const malformed = await app.inject({ + method: "GET", + url: "/owner-accounts/not-a-uuid", + headers: { cookie } + }); + assert.equal(malformed.statusCode, 400); + assert.equal(malformed.json().error.code, "VALIDATION_ERROR"); +}); + +test("usage creation normalizes optional inputs and returns 201", async t => { + let captured: CreateUsageRecordInput | undefined; + const base = createRepository(); + const repository = createRepository({ + async createUsageRecord(input) { + captured = input; + return base.createUsageRecord(input); + } + }); + const { app, cookie } = await buildAuthenticatedApp(repository); + t.after(() => app.close()); + + const response = await app.inject({ + method: "POST", + url: "/usage-records", + headers: { cookie }, + payload: { + ownerAccountId: ownerId, + confirmationNo: "26090001", + checkIn: "2026-09-01", + checkOut: "2026-09-03", + usedRoomType: "RM1", + idempotencyKey + } + }); + + assert.equal(response.statusCode, 201); + assert.deepEqual(captured, { + ownerAccountId: ownerId, + confirmationNo: "26090001", + checkIn: "2026-09-01", + checkOut: "2026-09-03", + usedRoomType: "RM1", + manualMultiplier: null, + remark: "", + idempotencyKey + }); + assert.equal(response.json().use, 2); + assert.equal(response.json().balance, 13); +}); + +test("invalid usage payload is rejected before repository access", async t => { + let called = false; + const repository = createRepository({ + async createUsageRecord(input) { + called = true; + return createRepository().createUsageRecord(input); + } + }); + const { app, cookie } = await buildAuthenticatedApp(repository); + t.after(() => app.close()); + + const response = await app.inject({ + method: "POST", + url: "/usage-records", + headers: { cookie }, + payload: { + ownerAccountId: ownerId, + confirmationNo: "ABC", + checkIn: "2026-09-01", + checkOut: "2026-09-03", + usedRoomType: "RM1", + idempotencyKey + } + }); + assert.equal(response.statusCode, 400); + assert.equal(response.json().error.code, "VALIDATION_ERROR"); + assert.equal(called, false); +}); + +test("business errors retain safe status and code", async t => { + const repository = createRepository({ + async createUsageRecord() { + throw new ApiError( + 422, + "INSUFFICIENT_BALANCE", + "The owner account does not have enough stay privileges" + ); + } + }); + const { app, cookie } = await buildAuthenticatedApp(repository); + t.after(() => app.close()); + + const response = await app.inject({ + method: "POST", + url: "/usage-records", + headers: { cookie }, + payload: { + ownerAccountId: ownerId, + confirmationNo: "26090002", + checkIn: "2026-09-01", + checkOut: "2026-09-03", + usedRoomType: "SU3", + idempotencyKey + } + }); + assert.equal(response.statusCode, 422); + assert.equal(response.json().error.code, "INSUFFICIENT_BALANCE"); + assert.equal(JSON.stringify(response.json()).includes("password"), false); +}); + +test("dashboard and OpenAPI expose the planned surface", async t => { + const { app, cookie } = await buildAuthenticatedApp(createRepository()); + t.after(() => app.close()); + + const dashboard = await app.inject({ + method: "GET", + url: "/dashboard?year=2026", + headers: { cookie } + }); + assert.equal(dashboard.statusCode, 200); + assert.equal(dashboard.json().periodYear, 2026); + + const openApi = app.swagger(); + const paths = Object.keys(openApi.paths ?? {}).sort(); + assert.deepEqual(paths, [ + "/auth/login", + "/auth/logout", + "/auth/session", + "/dashboard", + "/health", + "/owner-accounts", + "/owner-accounts/{id}", + "/room-types", + "/usage-records" + ]); +}); diff --git a/backend/tests/auth.test.ts b/backend/tests/auth.test.ts new file mode 100644 index 0000000..97ee4ab --- /dev/null +++ b/backend/tests/auth.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SessionManager } from "../src/auth.js"; + +test("session manager expires and revokes opaque sessions", () => { + let now = Date.parse("2026-08-02T00:00:00.000Z"); + const sessions = new SessionManager({ + username: "operator", + password: "secret", + sessionTtlMs: 60_000, + cookieSecure: true + }, () => now); + + assert.equal(sessions.credentialsMatch("operator", "secret"), true); + assert.equal(sessions.credentialsMatch("operator", "wrong"), false); + assert.equal(sessions.credentialsMatch("wrong", "secret"), false); + + const created = sessions.create(); + const cookie = sessions.sessionCookie(created.token); + assert.match(cookie, /^condon_session=[A-Za-z0-9_-]+;/); + assert.match(cookie, /HttpOnly/); + assert.match(cookie, /SameSite=Lax/); + assert.match(cookie, /Secure/); + assert.match(cookie, /Max-Age=60/); + assert.equal(cookie.includes("secret"), false); + + const requestCookie = cookie.split(";", 1)[0]; + assert.equal(sessions.get(requestCookie)?.username, "operator"); + now += 60_001; + assert.equal(sessions.get(requestCookie), null); + + const replacement = sessions.create(); + const replacementCookie = sessions.sessionCookie(replacement.token).split(";", 1)[0]; + sessions.revoke(replacementCookie); + assert.equal(sessions.get(replacementCookie), null); + assert.match(sessions.clearedCookie(), /Max-Age=0/); +}); diff --git a/backend/tests/config.test.ts b/backend/tests/config.test.ts new file mode 100644 index 0000000..b06fd45 --- /dev/null +++ b/backend/tests/config.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + ConfigurationError, + loadConfig +} from "../src/config.js"; + +test("configuration accepts only booking_test and never echoes secrets", () => { + const secret = "sensitive-test-value"; + const config = loadConfig({ + DB_HOST: "database.example", + DB_PORT: "5432", + DB_USER: "app_user", + DB_PASSWORD: secret, + DB_NAME: "booking_test", + DB_SSL: "true" + }); + + assert.equal(config.database.name, "booking_test"); + assert.equal(config.database.ssl, true); + assert.equal(config.apiHost, "127.0.0.1"); + assert.deepEqual(config.corsOrigins, [ + "http://127.0.0.1:4173", + "http://localhost:4173" + ]); + assert.deepEqual(config.authentication, { + username: "wyndhamcondon", + password: "wyndhamcondon", + sessionTtlMs: 12 * 60 * 60 * 1_000, + cookieSecure: false + }); + + const productionConfig = loadConfig({ + NODE_ENV: "production", + AUTH_USERNAME: "operator", + AUTH_PASSWORD: "production-secret", + AUTH_SESSION_TTL_HOURS: "24", + DB_HOST: "database.example", + DB_USER: "app_user", + DB_PASSWORD: secret, + DB_NAME: "booking_test" + }); + assert.equal(productionConfig.authentication.username, "operator"); + assert.equal(productionConfig.authentication.sessionTtlMs, 24 * 60 * 60 * 1_000); + assert.equal(productionConfig.authentication.cookieSecure, true); + + assert.throws( + () => loadConfig({ + DB_HOST: "database.example", + DB_USER: "app_user", + DB_PASSWORD: secret, + DB_NAME: "another_database" + }), + error => { + assert.ok(error instanceof ConfigurationError); + assert.equal(error.message.includes("DB_NAME"), true); + assert.equal(error.message.includes(secret), false); + return true; + } + ); +}); diff --git a/backend/tests/errors.test.ts b/backend/tests/errors.test.ts new file mode 100644 index 0000000..14ecac6 --- /dev/null +++ b/backend/tests/errors.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mapDatabaseError } from "../src/errors.js"; + +test("database business errors map to stable API responses", () => { + const insufficient = mapDatabaseError({ + code: "P0001", + message: "CONDON_INSUFFICIENT_BALANCE" + }); + assert.equal(insufficient.statusCode, 422); + assert.equal(insufficient.code, "INSUFFICIENT_BALANCE"); + + const duplicate = mapDatabaseError({ + code: "23505", + message: "CONDON_CONFIRMATION_NO_EXISTS" + }); + assert.equal(duplicate.statusCode, 409); + assert.equal(duplicate.code, "CONFLICT"); + + const invalid = mapDatabaseError({ + code: "22023", + message: "CONDON_CROSS_YEAR_STAY_NOT_SUPPORTED" + }); + assert.equal(invalid.statusCode, 400); + assert.equal(invalid.code, "BUSINESS_RULE_VIOLATION"); + + const unknown = mapDatabaseError({ + code: "XX000", + message: "internal database details" + }); + assert.equal(unknown.statusCode, 500); + assert.equal(unknown.message, "An internal error occurred"); + assert.equal(unknown.message.includes("database details"), false); +}); diff --git a/backend/tests/postgres-repository.test.ts b/backend/tests/postgres-repository.test.ts new file mode 100644 index 0000000..aa51541 --- /dev/null +++ b/backend/tests/postgres-repository.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { dateValue } from "../src/postgres-repository.js"; + +test("database date serialization preserves the local calendar date", () => { + const previousTimezone = process.env.TZ; + process.env.TZ = "Asia/Shanghai"; + + try { + const databaseDate = new Date(2026, 7, 10); + + assert.equal(databaseDate.toISOString().slice(0, 10), "2026-08-09"); + assert.equal(dateValue(databaseDate), "2026-08-10"); + assert.equal(dateValue("2026-08-10"), "2026-08-10"); + } finally { + if (previousTimezone === undefined) delete process.env.TZ; + else process.env.TZ = previousTimezone; + } +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..8412f1a --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": ".", + "outDir": "dist", + "strict": true, + "noUncheckedIndexedAccess": true, + "useUnknownInCatchVariables": true, + "verbatimModuleSyntax": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "sourceMap": true, + "noEmitOnError": true, + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts", + "tests/**/*.ts" + ] +} diff --git a/design-system/condo-owner-desk/MASTER.md b/design-system/condo-owner-desk/MASTER.md new file mode 100644 index 0000000..315c047 --- /dev/null +++ b/design-system/condo-owner-desk/MASTER.md @@ -0,0 +1,203 @@ +# Design System Master File + +> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`. +> If that file exists, its rules **override** this Master file. +> If not, strictly follow the rules below. + +--- + +**Project:** Condo Owner Desk +**Generated:** 2026-07-28 20:11:29 +**Category:** Analytics Dashboard + +--- + +## Global Rules + +### Color Palette + +| Role | Hex | CSS Variable | +|------|-----|--------------| +| Primary | `#1E40AF` | `--color-primary` | +| Secondary | `#3B82F6` | `--color-secondary` | +| CTA/Accent | `#F59E0B` | `--color-cta` | +| Background | `#F8FAFC` | `--color-background` | +| Text | `#1E3A8A` | `--color-text` | + +**Color Notes:** Blue data + amber highlights + +### Typography + +- **Heading Font:** Fira Code +- **Body Font:** Fira Sans +- **Mood:** dashboard, data, analytics, code, technical, precise +- **Google Fonts:** [Fira Code + Fira Sans](https://fonts.google.com/share?selection.family=Fira+Code:wght@400;500;600;700|Fira+Sans:wght@300;400;500;600;700) + +**CSS Import:** +```css +@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Fira+Sans:wght@300;400;500;600;700&display=swap'); +``` + +### Spacing Variables + +| Token | Value | Usage | +|-------|-------|-------| +| `--space-xs` | `4px` / `0.25rem` | Tight gaps | +| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing | +| `--space-md` | `16px` / `1rem` | Standard padding | +| `--space-lg` | `24px` / `1.5rem` | Section padding | +| `--space-xl` | `32px` / `2rem` | Large gaps | +| `--space-2xl` | `48px` / `3rem` | Section margins | +| `--space-3xl` | `64px` / `4rem` | Hero padding | + +### Shadow Depths + +| Level | Value | Usage | +|-------|-------|-------| +| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift | +| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons | +| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns | +| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards | + +--- + +## Component Specs + +### Buttons + +```css +/* Primary Button */ +.btn-primary { + background: #F59E0B; + color: white; + padding: 12px 24px; + border-radius: 8px; + font-weight: 600; + transition: all 200ms ease; + cursor: pointer; +} + +.btn-primary:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +/* Secondary Button */ +.btn-secondary { + background: transparent; + color: #1E40AF; + border: 2px solid #1E40AF; + padding: 12px 24px; + border-radius: 8px; + font-weight: 600; + transition: all 200ms ease; + cursor: pointer; +} +``` + +### Cards + +```css +.card { + background: #F8FAFC; + border-radius: 12px; + padding: 24px; + box-shadow: var(--shadow-md); + transition: all 200ms ease; + cursor: pointer; +} + +.card:hover { + box-shadow: var(--shadow-lg); + transform: translateY(-2px); +} +``` + +### Inputs + +```css +.input { + padding: 12px 16px; + border: 1px solid #E2E8F0; + border-radius: 8px; + font-size: 16px; + transition: border-color 200ms ease; +} + +.input:focus { + border-color: #1E40AF; + outline: none; + box-shadow: 0 0 0 3px #1E40AF20; +} +``` + +### Modals + +```css +.modal-overlay { + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); +} + +.modal { + background: white; + border-radius: 16px; + padding: 32px; + box-shadow: var(--shadow-xl); + max-width: 500px; + width: 90%; +} +``` + +--- + +## Style Guidelines + +**Style:** Data-Dense Dashboard + +**Keywords:** Multiple charts/widgets, data tables, KPI cards, minimal padding, grid layout, space-efficient, maximum data visibility + +**Best For:** Business intelligence dashboards, financial analytics, enterprise reporting, operational dashboards, data warehousing + +**Key Effects:** Hover tooltips, chart zoom on click, row highlighting on hover, smooth filter animations, data loading spinners + +### Page Pattern + +**Pattern Name:** Pricing-Focused Landing + +- **Conversion Strategy:** Annual discount 20-30%. Recommend mid-tier (most popular badge). Address objections in FAQ. +- **CTA Placement:** Each pricing card + Sticky CTA in nav + Bottom +- **Section Order:** 1. Hero (value proposition), 2. Pricing cards (3 tiers), 3. Feature comparison, 4. FAQ, 5. Final CTA + +--- + +## Anti-Patterns (Do NOT Use) + +- ❌ Ornate design +- ❌ No filtering + +### Additional Forbidden Patterns + +- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons) +- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer +- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout +- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio +- ❌ **Instant state changes** — Always use transitions (150-300ms) +- ❌ **Invisible focus states** — Focus states must be visible for a11y + +--- + +## Pre-Delivery Checklist + +Before delivering any UI code, verify: + +- [ ] No emojis used as icons (use SVG instead) +- [ ] All icons from consistent icon set (Heroicons/Lucide) +- [ ] `cursor-pointer` on all clickable elements +- [ ] Hover states with smooth transitions (150-300ms) +- [ ] Light mode: text contrast 4.5:1 minimum +- [ ] Focus states visible for keyboard navigation +- [ ] `prefers-reduced-motion` respected +- [ ] Responsive: 375px, 768px, 1024px, 1440px +- [ ] No content hidden behind fixed navbars +- [ ] No horizontal scroll on mobile diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..1927a7b --- /dev/null +++ b/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..c3d0dea --- /dev/null +++ b/index.html @@ -0,0 +1,331 @@ + + + + + + + Condo + + + + + + + + + + + + + + + + + + + + + + + diff --git a/owners-data.js b/owners-data.js new file mode 100644 index 0000000..8833b62 --- /dev/null +++ b/owners-data.js @@ -0,0 +1,2093 @@ +// Generated from 公寓基础信息xlsx.xlsx. Do not edit manually. +window.CONDO_OWNER_DATA = [ + { + "id": 1, + "sourceNo": 1, + "transferDate": "2024-01-08", + "name": "MS.Onuma Nakjakha", + "room": "3210", + "purchasedType": "RM3", + "unit": "555/335", + "member": "88001", + "remaining": 13 + }, + { + "id": 2, + "sourceNo": 5, + "transferDate": "2024-01-24", + "name": "Mr.SHENGYUAN ZHU", + "room": "3307", + "purchasedType": "RM3", + "unit": "555/358", + "member": "88005", + "remaining": 11 + }, + { + "id": 3, + "sourceNo": 6, + "transferDate": "2024-01-24", + "name": "MR.THANAPON KANOKKARNJANANON", + "room": "3308", + "purchasedType": "RM3", + "unit": "555/359", + "member": "88006", + "remaining": 15 + }, + { + "id": 4, + "sourceNo": 7, + "transferDate": "2024-01-24", + "name": "MRS.VIPAPORN KANOKKANJANANON", + "room": "3309", + "purchasedType": "RM3", + "unit": "555/360", + "member": "88007", + "remaining": 15 + }, + { + "id": 5, + "sourceNo": 13, + "transferDate": "2024-01-29", + "name": "MR.KANTHARAT SAKRUNGRUANG", + "room": "3511", + "purchasedType": "RM3", + "unit": "555/414", + "member": "88017", + "remaining": 15 + }, + { + "id": 6, + "sourceNo": 14, + "transferDate": "2024-01-29", + "name": "MS.DUANGJAI BUTTAYOTEE", + "room": "3616", + "purchasedType": "RM3", + "unit": "555/445", + "member": "88013", + "remaining": 15 + }, + { + "id": 7, + "sourceNo": 16, + "transferDate": "2024-01-29", + "name": "MR.Kitipong Tarathamathighon", + "room": "4305", + "purchasedType": "RM3", + "unit": "555/525", + "member": "88012", + "remaining": 15 + }, + { + "id": 8, + "sourceNo": 17, + "transferDate": "2024-01-29", + "name": "MS.PORNTHIP SALANOK", + "room": "4408", + "purchasedType": "RM3", + "unit": "555/554", + "member": "88015", + "remaining": 15 + }, + { + "id": 9, + "sourceNo": 19, + "transferDate": "2024-01-30", + "name": "Mr.NICHOLAS ALEXANDER FRANCIS FLEGEL", + "room": "3613", + "purchasedType": "RM3", + "unit": "555/442", + "member": "88019", + "remaining": 15 + }, + { + "id": 10, + "sourceNo": 20, + "transferDate": "2024-01-30", + "name": "Mr.XINZHI LI & Mrs.LING JIN", + "room": "4218", + "purchasedType": "RM3", + "unit": "555/512", + "member": "88020", + "remaining": 15 + }, + { + "id": 11, + "sourceNo": 23, + "transferDate": "2024-02-05", + "name": "Ms.WANG YING", + "room": "3311", + "purchasedType": "RM3", + "unit": "555/362", + "member": "88023", + "remaining": 15 + }, + { + "id": 12, + "sourceNo": 24, + "transferDate": "2024-02-05", + "name": "Ms.WANG YING", + "room": "3317", + "purchasedType": "RM3", + "unit": "555/368", + "member": "88024", + "remaining": 15 + }, + { + "id": 13, + "sourceNo": 25, + "transferDate": "2024-02-05", + "name": "Ms.WANG YING", + "room": "3318", + "purchasedType": "RM3", + "unit": "555/369", + "member": "88025", + "remaining": 15 + }, + { + "id": 14, + "sourceNo": 26, + "transferDate": "2024-02-05", + "name": "Ms.WANG YING", + "room": "3412", + "purchasedType": "RM3", + "unit": "555/389", + "member": "88026", + "remaining": 15 + }, + { + "id": 15, + "sourceNo": 27, + "transferDate": "2024-02-05", + "name": "Ms.WANG YING", + "room": "3413", + "purchasedType": "RM3", + "unit": "555/390", + "member": "88027", + "remaining": 15 + }, + { + "id": 16, + "sourceNo": 28, + "transferDate": "2024-02-05", + "name": "Ms.WANG YING", + "room": "3414", + "purchasedType": "RM3", + "unit": "555/391", + "member": "88028", + "remaining": 15 + }, + { + "id": 17, + "sourceNo": 29, + "transferDate": "2024-02-05", + "name": "Ms.WANG YING", + "room": "3415", + "purchasedType": "RM3", + "unit": "555/392", + "member": "88029", + "remaining": 15 + }, + { + "id": 18, + "sourceNo": 30, + "transferDate": "2024-02-06", + "name": "MR.ZHU XINGLIN", + "room": "3205", + "purchasedType": "RM3", + "unit": "555/330", + "member": "88030", + "remaining": 15 + }, + { + "id": 19, + "sourceNo": 31, + "transferDate": "2024-02-06", + "name": "MR.ZHU XINGLIN", + "room": "3206", + "purchasedType": "RM3", + "unit": "555/331", + "member": "88031", + "remaining": 15 + }, + { + "id": 20, + "sourceNo": 32, + "transferDate": "2024-02-06", + "name": "MR.ZHU XINGLIN", + "room": "3207", + "purchasedType": "RM3", + "unit": "555/332", + "member": "88032", + "remaining": 15 + }, + { + "id": 21, + "sourceNo": 33, + "transferDate": "2024-02-06", + "name": "MR.ZHU XINGLIN", + "room": "3208", + "purchasedType": "RM3", + "unit": "555/333", + "member": "88033", + "remaining": 15 + }, + { + "id": 22, + "sourceNo": 34, + "transferDate": "2024-02-06", + "name": "MR.ZHU XINGLIN", + "room": "3209", + "purchasedType": "RM3", + "unit": "555/334", + "member": "88034", + "remaining": 15 + }, + { + "id": 23, + "sourceNo": 35, + "transferDate": "2024-02-06", + "name": "MR.DING QI", + "room": "4205", + "purchasedType": "RM3", + "unit": "555/499", + "member": "88035", + "remaining": 15 + }, + { + "id": 24, + "sourceNo": 36, + "transferDate": "2024-02-06", + "name": "MR.DING QI", + "room": "4206", + "purchasedType": "RM3", + "unit": "555/500", + "member": "88036", + "remaining": 15 + }, + { + "id": 25, + "sourceNo": 37, + "transferDate": "2024-02-06", + "name": "MR.DING QI", + "room": "4207", + "purchasedType": "RM3", + "unit": "555/501", + "member": "88037", + "remaining": 15 + }, + { + "id": 26, + "sourceNo": 38, + "transferDate": "2024-02-06", + "name": "MR.DING QI", + "room": "4208", + "purchasedType": "RM3", + "unit": "555/502", + "member": "88038", + "remaining": 15 + }, + { + "id": 27, + "sourceNo": 39, + "transferDate": "2024-02-06", + "name": "MR.DING QI", + "room": "4209", + "purchasedType": "RM3", + "unit": "555/503", + "member": "88039", + "remaining": 15 + }, + { + "id": 28, + "sourceNo": 41, + "transferDate": "2024-02-07", + "name": "MR.JACKY SAECHEN", + "room": "4511", + "purchasedType": "RM3", + "unit": "555/583", + "member": "88041", + "remaining": 15 + }, + { + "id": 29, + "sourceNo": 43, + "transferDate": "2024-02-09", + "name": "MRS.HE YAFEI", + "room": "3216", + "purchasedType": "RM3", + "unit": "555/341", + "member": "88042", + "remaining": 15 + }, + { + "id": 30, + "sourceNo": 44, + "transferDate": "2024-02-09", + "name": "MRS.HE YAFEI", + "room": "3217", + "purchasedType": "RM3", + "unit": "555/342", + "member": "88043", + "remaining": 15 + }, + { + "id": 31, + "sourceNo": 45, + "transferDate": "2024-02-09", + "name": "MR.CAI YUMING", + "room": "4217", + "purchasedType": "RM3", + "unit": "555/511", + "member": "88044", + "remaining": 15 + }, + { + "id": 32, + "sourceNo": 46, + "transferDate": "2024-02-12", + "name": "MRS.YUE MA", + "room": "3512", + "purchasedType": "RM3", + "unit": "555/415", + "member": "88046", + "remaining": 15 + }, + { + "id": 33, + "sourceNo": 47, + "transferDate": "2024-02-12", + "name": "MRS.YUE MA", + "room": "3514", + "purchasedType": "RM3", + "unit": "555/417", + "member": "88047", + "remaining": 15 + }, + { + "id": 34, + "sourceNo": 48, + "transferDate": "2024-02-12", + "name": "MRS.YUE MA", + "room": "3515", + "purchasedType": "RM3", + "unit": "555/418", + "member": "88048", + "remaining": 15 + }, + { + "id": 35, + "sourceNo": 49, + "transferDate": "2024-02-12", + "name": "MRS.CHENGFANG GU", + "room": "3516", + "purchasedType": "RM3", + "unit": "555/419", + "member": "88053", + "remaining": 15 + }, + { + "id": 36, + "sourceNo": 54, + "transferDate": "2024-02-12", + "name": "MRS.YUE MA", + "room": "4611", + "purchasedType": "RM3", + "unit": "555/609", + "member": "88052", + "remaining": 15 + }, + { + "id": 37, + "sourceNo": 55, + "transferDate": "2024-02-14", + "name": "MRS.HOU HAIXIA", + "room": "4307", + "purchasedType": "RM3", + "unit": "555/527", + "member": "88055", + "remaining": 15 + }, + { + "id": 38, + "sourceNo": 56, + "transferDate": "2024-02-16", + "name": "MRS.LIU TING", + "room": "3116", + "purchasedType": "RM3", + "unit": "555/322", + "member": "88056", + "remaining": 15 + }, + { + "id": 39, + "sourceNo": 60, + "transferDate": "2024-02-27", + "name": "MS.ZHANG XIUYUN", + "room": "3108", + "purchasedType": "RM3", + "unit": "555/314", + "member": "88069", + "remaining": 15 + }, + { + "id": 40, + "sourceNo": 61, + "transferDate": "2024-02-27", + "name": "MR.SHILIANG LU", + "room": "3214", + "purchasedType": "RM3", + "unit": "555/339", + "member": "88060", + "remaining": 15 + }, + { + "id": 41, + "sourceNo": 62, + "transferDate": "2024-02-27", + "name": "MR.SHILIANG LU", + "room": "3215", + "purchasedType": "RM3", + "unit": "555/340", + "member": "88061", + "remaining": 15 + }, + { + "id": 42, + "sourceNo": 66, + "transferDate": "2024-02-27", + "name": "MS.JINGPING LIU", + "room": "4315", + "purchasedType": "RM3", + "unit": "555/535", + "member": "88065", + "remaining": 15 + }, + { + "id": 43, + "sourceNo": 67, + "transferDate": "2024-02-27", + "name": "Mr.Yang Fengmin (คนเก่าชื่อ MS.JINGPING LIU)", + "room": "4316", + "purchasedType": "RM3", + "unit": "555/536", + "member": "88066", + "remaining": 15 + }, + { + "id": 44, + "sourceNo": 68, + "transferDate": "2024-02-27", + "name": "MS.YANNI ZHENG", + "room": "4317", + "purchasedType": "RM3", + "unit": "555/537", + "member": "88067", + "remaining": 15 + }, + { + "id": 45, + "sourceNo": 69, + "transferDate": "2024-02-27", + "name": "MS.YANNI ZHENG", + "room": "4318", + "purchasedType": "RM3", + "unit": "555/538", + "member": "88068", + "remaining": 15 + }, + { + "id": 46, + "sourceNo": 71, + "transferDate": "2024-02-28", + "name": "MR.SIRICHAI PAIPAE", + "room": "3312", + "purchasedType": "RM3", + "unit": "555/363", + "member": "88071", + "remaining": 12 + }, + { + "id": 47, + "sourceNo": 72, + "transferDate": "2024-02-28", + "name": "MR.RUAN JIANCONG", + "room": "3314", + "purchasedType": "RM3", + "unit": "555/365", + "member": "88072", + "remaining": 15 + }, + { + "id": 48, + "sourceNo": 73, + "transferDate": "2024-02-28", + "name": "MR.RUAN JIAJU", + "room": "3315", + "purchasedType": "RM3", + "unit": "555/366", + "member": "88073", + "remaining": 15 + }, + { + "id": 49, + "sourceNo": 74, + "transferDate": "2024-02-29", + "name": "MR.YANG AILIANG", + "room": "3517", + "purchasedType": "RM3", + "unit": "555/420", + "member": "88075", + "remaining": 15 + }, + { + "id": 50, + "sourceNo": 83, + "transferDate": "2024-03-08", + "name": "MR.TOSHIHIKO WAKUI", + "room": "3306", + "purchasedType": "RM3", + "unit": "555/357", + "member": "88083", + "remaining": 4 + }, + { + "id": 51, + "sourceNo": 84, + "transferDate": "2024-03-11", + "name": "Mr.JINGHUA LIU", + "room": "4212", + "purchasedType": "RM3", + "unit": "555/506", + "member": "88084", + "remaining": 15 + }, + { + "id": 52, + "sourceNo": 85, + "transferDate": "2024-03-18", + "name": "MRS.LIU YI", + "room": "4213", + "purchasedType": "RM3", + "unit": "555/507", + "member": "88089", + "remaining": 15 + }, + { + "id": 53, + "sourceNo": 87, + "transferDate": "2024-03-18", + "name": "MR.HTAN WIN", + "room": "4711", + "purchasedType": "RM3", + "unit": "555/635", + "member": "88086", + "remaining": 15 + }, + { + "id": 54, + "sourceNo": 90, + "transferDate": "2024-03-19", + "name": "MR.TORU HERAI", + "room": "3212", + "purchasedType": "RM3", + "unit": "555/337", + "member": "88092", + "remaining": 15 + }, + { + "id": 55, + "sourceNo": 91, + "transferDate": "2024-03-19", + "name": "MR.QIAO JIALEI", + "room": "4714", + "purchasedType": "RM3", + "unit": "555/638", + "member": "88090", + "remaining": 15 + }, + { + "id": 56, + "sourceNo": 95, + "transferDate": "2024-03-25", + "name": "MRS.WU CHUNHUI", + "room": "3218", + "purchasedType": "RM3", + "unit": "555/343", + "member": "88096", + "remaining": 15 + }, + { + "id": 57, + "sourceNo": 96, + "transferDate": "2024-03-25", + "name": "MS.WENHONG LIU", + "room": "3611", + "purchasedType": "RM3", + "unit": "555/440", + "member": "88095", + "remaining": 15 + }, + { + "id": 58, + "sourceNo": 99, + "transferDate": "2024-03-26", + "name": "MR.SWAPNIL RAMAKANT DHOTE", + "room": "3518", + "purchasedType": "RM3", + "unit": "555/421", + "member": "88100", + "remaining": 15 + }, + { + "id": 59, + "sourceNo": 100, + "transferDate": "2024-03-26", + "name": "Ms.Tawapansorn Tangjareaukitsakul", + "room": "4308", + "purchasedType": "RM3", + "unit": "555/528", + "member": "88097", + "remaining": 15 + }, + { + "id": 60, + "sourceNo": 101, + "transferDate": "2024-03-26", + "name": "Ms.Tawapansorn Tangjareaukitsakul", + "room": "4309", + "purchasedType": "RM3", + "unit": "555/529", + "member": "88098", + "remaining": 15 + }, + { + "id": 61, + "sourceNo": 103, + "transferDate": "2024-03-29", + "name": "MR.NATEE PHASUTARNCHART", + "room": "3316", + "purchasedType": "RM3", + "unit": "555/367", + "member": "88105", + "remaining": 11 + }, + { + "id": 62, + "sourceNo": 105, + "transferDate": "2024-03-29", + "name": "MS.PORNSAWAN SIRISOMBAT", + "room": "4405", + "purchasedType": "RM3", + "unit": "555/551", + "member": "88103", + "remaining": 13 + }, + { + "id": 63, + "sourceNo": 109, + "transferDate": "2024-04-02", + "name": "MRS.VALENTINA GALIBUZOVA", + "room": "4314", + "purchasedType": "RM3", + "unit": "555/534", + "member": "88109", + "remaining": 15 + }, + { + "id": 64, + "sourceNo": 110, + "transferDate": "2024-04-03", + "name": "MRS.HAO BOLY", + "room": "4413", + "purchasedType": "RM3", + "unit": "555/559", + "member": "88110", + "remaining": 15 + }, + { + "id": 65, + "sourceNo": 111, + "transferDate": "2024-04-03", + "name": "MRS.HAO BOLY", + "room": "4415", + "purchasedType": "RM3", + "unit": "555/561", + "member": "88111", + "remaining": 15 + }, + { + "id": 66, + "sourceNo": 112, + "transferDate": "2024-04-03", + "name": "MRS.HAO BOLY", + "room": "4416", + "purchasedType": "RM3", + "unit": "555/562", + "member": "88112", + "remaining": 15 + }, + { + "id": 67, + "sourceNo": 113, + "transferDate": "2024-04-03", + "name": "MRS.HAO BOLY", + "room": "4417", + "purchasedType": "RM3", + "unit": "555/563", + "member": "88113", + "remaining": 15 + }, + { + "id": 68, + "sourceNo": 114, + "transferDate": "2024-04-03", + "name": "MRS.HAO BOLY", + "room": "4418", + "purchasedType": "RM3", + "unit": "555/564", + "member": "88114", + "remaining": 15 + }, + { + "id": 69, + "sourceNo": 116, + "transferDate": "2024-04-09", + "name": "MR.SHIYONG WANG", + "room": "3107", + "purchasedType": "RM3", + "unit": "555/313", + "member": "88116", + "remaining": 7 + }, + { + "id": 70, + "sourceNo": 122, + "transferDate": "2024-04-09", + "name": "MS.MINHUA CHEN", + "room": "4311", + "purchasedType": "RM3", + "unit": "555/531", + "member": "88118", + "remaining": 12 + }, + { + "id": 71, + "sourceNo": 123, + "transferDate": "2024-04-10", + "name": "MR.ZIBING YANG", + "room": "3102", + "purchasedType": "RM3", + "unit": "555/308", + "member": "88123", + "remaining": 15 + }, + { + "id": 72, + "sourceNo": 124, + "transferDate": "2024-04-10", + "name": "MS.QIU PING", + "room": "3111", + "purchasedType": "RM3", + "unit": "555/317", + "member": "88141", + "remaining": 15 + }, + { + "id": 73, + "sourceNo": 133, + "transferDate": "2024-04-10", + "name": "MR.ZIBING YANG", + "room": "4102", + "purchasedType": "RM3", + "unit": "555/485", + "member": "88124", + "remaining": 15 + }, + { + "id": 74, + "sourceNo": 142, + "transferDate": "2024-04-11", + "name": "MR.MICHAEL LING DONG", + "room": "3211", + "purchasedType": "RM3", + "unit": "555/336", + "member": "88142", + "remaining": 13 + }, + { + "id": 75, + "sourceNo": 143, + "transferDate": "2024-04-17", + "name": "MS.ZAN LIPING", + "room": "4407", + "purchasedType": "RM3", + "unit": "555/553", + "member": "88143", + "remaining": 15 + }, + { + "id": 76, + "sourceNo": 144, + "transferDate": "2024-04-17", + "name": "MS.ZAN LIPING", + "room": "4409", + "purchasedType": "RM3", + "unit": "555/555", + "member": "88144", + "remaining": 15 + }, + { + "id": 77, + "sourceNo": 145, + "transferDate": "2024-04-22", + "name": "MS.CAO TING", + "room": "4512", + "purchasedType": "RM3", + "unit": "555/584", + "member": "88145", + "remaining": 15 + }, + { + "id": 78, + "sourceNo": 146, + "transferDate": "2024-04-22", + "name": "MS.CAO TING", + "room": "4513", + "purchasedType": "RM3", + "unit": "555/585", + "member": "88146", + "remaining": 15 + }, + { + "id": 79, + "sourceNo": 147, + "transferDate": "2024-04-22", + "name": "MS.CAO TING", + "room": "4514", + "purchasedType": "RM3", + "unit": "555/586", + "member": "88147", + "remaining": 15 + }, + { + "id": 80, + "sourceNo": 148, + "transferDate": "2024-04-22", + "name": "MS.CAO TING", + "room": "4515", + "purchasedType": "RM3", + "unit": "555/587", + "member": "88148", + "remaining": 15 + }, + { + "id": 81, + "sourceNo": 149, + "transferDate": "2024-04-22", + "name": "MS.CAO TING", + "room": "4516", + "purchasedType": "RM3", + "unit": "555/588", + "member": "88149", + "remaining": 15 + }, + { + "id": 82, + "sourceNo": 150, + "transferDate": "2024-04-22", + "name": "MS.CAO TING", + "room": "4517", + "purchasedType": "RM3", + "unit": "555/589", + "member": "88150", + "remaining": 15 + }, + { + "id": 83, + "sourceNo": 151, + "transferDate": "2024-04-25", + "name": "MR.GUANG ZHENG", + "room": "3110", + "purchasedType": "RM3", + "unit": "555/316", + "member": "88151", + "remaining": 15 + }, + { + "id": 84, + "sourceNo": 152, + "transferDate": "2024-04-29", + "name": "MRS.LIN YAN", + "room": "3416", + "purchasedType": "RM3", + "unit": "555/393", + "member": "88153", + "remaining": 15 + }, + { + "id": 85, + "sourceNo": 153, + "transferDate": "2024-04-29", + "name": "MR.CHUNFA WANG", + "room": "3614", + "purchasedType": "RM3", + "unit": "555/443", + "member": "88154", + "remaining": 15 + }, + { + "id": 86, + "sourceNo": 155, + "transferDate": "2024-04-29", + "name": "MR.LAI CHEN and MRS.JEAN CHENG", + "room": "4518", + "purchasedType": "RM3", + "unit": "555/590", + "member": "88155", + "remaining": 15 + }, + { + "id": 87, + "sourceNo": 157, + "transferDate": "2024-05-03", + "name": "MS.Yol-a-pichaya Chereskin", + "room": "4109", + "purchasedType": "RM3", + "unit": "555/492", + "member": "88157", + "remaining": 15 + }, + { + "id": 88, + "sourceNo": 158, + "transferDate": "2024-05-08", + "name": "MR.SHIN MYAT AUNG", + "room": "3502", + "purchasedType": "RM3", + "unit": "555/405", + "member": "88158", + "remaining": 15 + }, + { + "id": 89, + "sourceNo": 165, + "transferDate": "2024-06-04", + "name": "Ms.Patchara Santiwongwanich", + "room": "4306", + "purchasedType": "RM3", + "unit": "555/526", + "member": "88165", + "remaining": 15 + }, + { + "id": 90, + "sourceNo": 166, + "transferDate": "2024-06-05", + "name": "MS.BAI MEIPING", + "room": "3509", + "purchasedType": "RM3", + "unit": "555/412", + "member": "88166", + "remaining": 15 + }, + { + "id": 91, + "sourceNo": 167, + "transferDate": "2024-06-05", + "name": "MR.Pradit Peetanonchai", + "room": "4111", + "purchasedType": "RM3", + "unit": "555/494", + "member": "88167", + "remaining": 15 + }, + { + "id": 92, + "sourceNo": 168, + "transferDate": "2024-06-10", + "name": "MR.Ekapat Chodgawanich", + "room": "4414", + "purchasedType": "RM3", + "unit": "555/560", + "member": "88168", + "remaining": 15 + }, + { + "id": 93, + "sourceNo": 169, + "transferDate": "2024-06-12", + "name": "Ms.PATTAMAWADEE PHUAPHROMYOD Ms.ANUSARA PHUAPHROMYOD", + "room": "4214", + "purchasedType": "RM3", + "unit": "555/508", + "member": "88169", + "remaining": 15 + }, + { + "id": 94, + "sourceNo": 173, + "transferDate": "2024-06-17", + "name": "MS.ZHU WENGE", + "room": "3407", + "purchasedType": "RM3", + "unit": "555/384", + "member": "88173", + "remaining": 15 + }, + { + "id": 95, + "sourceNo": 175, + "transferDate": "2024-06-25", + "name": "MR.CHEN HSI-CHIN", + "room": "3506", + "purchasedType": "RM3", + "unit": "555/409", + "member": "88175", + "remaining": 15 + }, + { + "id": 96, + "sourceNo": 176, + "transferDate": "2024-06-28", + "name": "MR.Wuthiphong Dechatiwong Na Ayutthaya", + "room": "4108", + "purchasedType": "RM3", + "unit": "555/491", + "member": "88179", + "remaining": 14 + }, + { + "id": 97, + "sourceNo": 182, + "transferDate": "2024-07-08", + "name": "MR.NA THYE GUAN", + "room": "4507", + "purchasedType": "RM3", + "unit": "555/579", + "member": "88249", + "remaining": 15 + }, + { + "id": 98, + "sourceNo": 183, + "transferDate": "2024-07-09", + "name": "MR.LIN SEN", + "room": "4615", + "purchasedType": "RM3", + "unit": "555/613", + "member": "88182", + "remaining": 15 + }, + { + "id": 99, + "sourceNo": 184, + "transferDate": "2024-07-09", + "name": "MR.LIN SEN", + "room": "4616", + "purchasedType": "RM3", + "unit": "555/614", + "member": "88183", + "remaining": 15 + }, + { + "id": 100, + "sourceNo": 185, + "transferDate": "2024-07-09", + "name": "MR.LIN SEN", + "room": "4617", + "purchasedType": "RM3", + "unit": "555/615", + "member": "88184", + "remaining": 15 + }, + { + "id": 101, + "sourceNo": 186, + "transferDate": "2024-07-11", + "name": "MS.OLGA NETREBKO", + "room": "4715", + "purchasedType": "RM3", + "unit": "555/639", + "member": "88185", + "remaining": 15 + }, + { + "id": 102, + "sourceNo": 187, + "transferDate": "2024-07-16", + "name": "MR.Jaratpon Pongpaitoon", + "room": "3411", + "purchasedType": "RM3", + "unit": "555/388", + "member": "88186", + "remaining": 15 + }, + { + "id": 103, + "sourceNo": 188, + "transferDate": "2024-07-16", + "name": "MR.ALEXANDER BORTSOV", + "room": "4215", + "purchasedType": "RM3", + "unit": "555/509", + "member": "88187", + "remaining": 15 + }, + { + "id": 104, + "sourceNo": 189, + "transferDate": "2024-07-18", + "name": "MRS.ME ME KYAW (คนเก่าชื่อ MR.LU KYONE SHONE)", + "room": "4618", + "purchasedType": "RM3", + "unit": "555/616", + "member": "88188", + "remaining": 15 + }, + { + "id": 105, + "sourceNo": 191, + "transferDate": "2024-07-23", + "name": "MS. WU XIAOHONG", + "room": "4107", + "purchasedType": "RM3", + "unit": "555/490", + "member": "88190", + "remaining": 15 + }, + { + "id": 106, + "sourceNo": 192, + "transferDate": "2024-07-23", + "name": "MS. WU XIAOHONG", + "room": "4110", + "purchasedType": "RM3", + "unit": "555/493", + "member": "88191", + "remaining": 15 + }, + { + "id": 107, + "sourceNo": 194, + "transferDate": "2024-07-26", + "name": "MS. SUPATTRA DUANGTIP", + "room": "4406", + "purchasedType": "RM3", + "unit": "555/552", + "member": "88193", + "remaining": 15 + }, + { + "id": 108, + "sourceNo": 196, + "transferDate": "2024-08-02", + "name": "MR.TEA KOK KEONG", + "room": "3405", + "purchasedType": "RM3", + "unit": "555/382", + "member": "88195", + "remaining": 15 + }, + { + "id": 109, + "sourceNo": 197, + "transferDate": "2024-08-15", + "name": "MS.LOU HAIXIA", + "room": "3507", + "purchasedType": "RM3", + "unit": "555/410", + "member": "88198", + "remaining": 15 + }, + { + "id": 110, + "sourceNo": 198, + "transferDate": "2024-08-15", + "name": "MS.CHEN HEQING", + "room": "3513", + "purchasedType": "RM3", + "unit": "555/416", + "member": "88199", + "remaining": 15 + }, + { + "id": 111, + "sourceNo": 201, + "transferDate": "2024-08-16", + "name": "Mr.Kosit Prunglertbuathong", + "room": "3406", + "purchasedType": "RM3", + "unit": "555/383", + "member": "88200", + "remaining": 6 + }, + { + "id": 112, + "sourceNo": 204, + "transferDate": "2024-08-26", + "name": "MR.YANG TENGFANG", + "room": "4606", + "purchasedType": "RM3", + "unit": "555/604", + "member": "88203", + "remaining": 13 + }, + { + "id": 113, + "sourceNo": 208, + "transferDate": "2024-09-03", + "name": "MS.PENG XINGYAN", + "room": "3615", + "purchasedType": "RM3", + "unit": "555/444", + "member": "88207", + "remaining": 15 + }, + { + "id": 114, + "sourceNo": 209, + "transferDate": "2024-09-03", + "name": "MRS.NATAPAT IRLBECK", + "room": "4509", + "purchasedType": "RM3", + "unit": "555/581", + "member": "88206", + "remaining": 15 + }, + { + "id": 115, + "sourceNo": 212, + "transferDate": "2024-09-16", + "name": "MRS.Natchitakan Thavornsart", + "room": "4101", + "purchasedType": "RM3", + "unit": "555/484", + "member": "88212", + "remaining": 12 + }, + { + "id": 116, + "sourceNo": 218, + "transferDate": "2024-10-16", + "name": "MR.PHILIP JAMES CRAMPTON MRS.SAMANTHA NATALIE CRAMPTON", + "room": "4313", + "purchasedType": "RM3", + "unit": "555/533", + "member": "88217", + "remaining": 15 + }, + { + "id": 117, + "sourceNo": 219, + "transferDate": "2024-10-21", + "name": "MR.Thanesvorn Siri-achawawath", + "room": "4506", + "purchasedType": "RM3", + "unit": "555/578", + "member": "88218", + "remaining": 14 + }, + { + "id": 118, + "sourceNo": 221, + "transferDate": "2024-11-04", + "name": "MR.Natthapol Udomsorn", + "room": "3117", + "purchasedType": "RM3", + "unit": "555/323", + "member": "88220", + "remaining": 15 + }, + { + "id": 119, + "sourceNo": 222, + "transferDate": "2024-11-18", + "name": "MR.Nut Wuthiphongworachok", + "room": "3114", + "purchasedType": "RM3", + "unit": "555/320", + "member": "88221", + "remaining": 15 + }, + { + "id": 120, + "sourceNo": 228, + "transferDate": "2025-01-06", + "name": "Ms.Yao Naijia", + "room": "4412", + "purchasedType": "RM3", + "unit": "555/558", + "member": "88226", + "remaining": 15 + }, + { + "id": 121, + "sourceNo": 232, + "transferDate": "2025-01-08", + "name": "Mrs.Xia Yanyi", + "room": "3213", + "purchasedType": "RM3", + "unit": "555/338", + "member": "88231", + "remaining": 15 + }, + { + "id": 122, + "sourceNo": 233, + "transferDate": "2025-01-08", + "name": "MS.WENYAN XU", + "room": "3305", + "purchasedType": "RM3", + "unit": "555/356", + "member": "88235", + "remaining": 15 + }, + { + "id": 123, + "sourceNo": 234, + "transferDate": "2025-01-08", + "name": "Mrs.Fang Wei", + "room": "3617", + "purchasedType": "RM3", + "unit": "555/446", + "member": "88233", + "remaining": 15 + }, + { + "id": 124, + "sourceNo": 235, + "transferDate": "2025-01-08", + "name": "Mrs.Fang Wei", + "room": "3618", + "purchasedType": "RM3", + "unit": "555/447", + "member": "88234", + "remaining": 15 + }, + { + "id": 125, + "sourceNo": 238, + "transferDate": "2025-01-13", + "name": "Ms.Apinya Supunnarach", + "room": "4705", + "purchasedType": "RM3", + "unit": "555/629", + "member": "88237", + "remaining": 13 + }, + { + "id": 126, + "sourceNo": 239, + "transferDate": "2025-01-15", + "name": "MR.SHUNTIAN XU", + "room": "3113", + "purchasedType": "RM3", + "unit": "555/319", + "member": "88238", + "remaining": 15 + }, + { + "id": 127, + "sourceNo": 240, + "transferDate": "2025-01-17", + "name": "Acting Sub Lt.Wichan Prachannuan", + "room": "3505", + "purchasedType": "RM3", + "unit": "555/408", + "member": "88239", + "remaining": 15 + }, + { + "id": 128, + "sourceNo": 243, + "transferDate": "2025-01-29", + "name": "MR.THANTHON THEERAJITHSUWAN", + "room": "4505", + "purchasedType": "RM3", + "unit": "555/577", + "member": "88242", + "remaining": 15 + }, + { + "id": 129, + "sourceNo": 244, + "transferDate": "2025-02-11", + "name": "MR.THANTHON THEERAJITHSUWAN", + "room": "1215", + "purchasedType": "RM3", + "unit": "555/19", + "member": "88243", + "remaining": 15 + }, + { + "id": 130, + "sourceNo": 245, + "transferDate": "2025-02-14", + "name": "MR.BENJAMIN PEI-JIE SHENG and MRS.HSIU LAN PENG", + "room": "1316", + "purchasedType": "RM2", + "unit": "555/44", + "member": "88245", + "remaining": 15 + }, + { + "id": 131, + "sourceNo": 247, + "transferDate": "2025-02-18", + "name": "MR.WANG HONGYU", + "room": "1207", + "purchasedType": "RM3", + "unit": "555/11", + "member": "88246", + "remaining": 15 + }, + { + "id": 132, + "sourceNo": 248, + "transferDate": "2025-02-18", + "name": "MR.HAN RONGGUO", + "room": "1407", + "purchasedType": "RM2", + "unit": "555/59", + "member": "88247", + "remaining": 15 + }, + { + "id": 133, + "sourceNo": 250, + "transferDate": "2025-02-24", + "name": "MR.CHEN XIN", + "room": "1311", + "purchasedType": "RM3", + "unit": "555/39", + "member": "88250", + "remaining": 15 + }, + { + "id": 134, + "sourceNo": 252, + "transferDate": "2025-03-04", + "name": "MR.XU ZILIANG", + "room": "4508", + "purchasedType": "RM3", + "unit": "555/580", + "member": "88252", + "remaining": 7 + }, + { + "id": 135, + "sourceNo": 253, + "transferDate": "2025-03-06", + "name": "MR. APHICHAI SAEJEW", + "room": "1206", + "purchasedType": "RM3", + "unit": "555/10", + "member": "88253", + "remaining": 15 + }, + { + "id": 136, + "sourceNo": 258, + "transferDate": "2025-03-21", + "name": "MR.NAOYA TAKEUCHI", + "room": "3115", + "purchasedType": "RM3", + "unit": "555/321", + "member": "88258", + "remaining": 15 + }, + { + "id": 137, + "sourceNo": 264, + "transferDate": "2025-04-04", + "name": "MR.YVES BUGMANN", + "room": "1211", + "purchasedType": "RM3", + "unit": "555/15", + "member": "88264", + "remaining": 15 + }, + { + "id": 138, + "sourceNo": 267, + "transferDate": "2025-04-22", + "name": "MRS.HUANG KAIKAI", + "room": "1307", + "purchasedType": "RM2", + "unit": "555/35", + "member": "88267", + "remaining": 15 + }, + { + "id": 139, + "sourceNo": 269, + "transferDate": "2025-04-28", + "name": "MS.JUAN WU , MR.XIUFENG WEI", + "room": "4211", + "purchasedType": "RM3", + "unit": "555/505", + "member": "88269", + "remaining": 15 + }, + { + "id": 140, + "sourceNo": 273, + "transferDate": "2025-05-02", + "name": "Miss Kamonluk Buntaem", + "room": "4411", + "purchasedType": "RM3", + "unit": "555/557", + "member": "88273", + "remaining": 15 + }, + { + "id": 141, + "sourceNo": 276, + "transferDate": "2025-05-08", + "name": "MR.YU QIONGLIN", + "room": "4608", + "purchasedType": "RM3", + "unit": "555/606", + "member": "88276", + "remaining": 15 + }, + { + "id": 142, + "sourceNo": 277, + "transferDate": "2025-05-14", + "name": "MR.KIM DAVID MIN SUNG", + "room": "3112", + "purchasedType": "RM3", + "unit": "555/318", + "member": "88277", + "remaining": 15 + }, + { + "id": 143, + "sourceNo": 281, + "transferDate": "2025-05-29", + "name": "Ms.Kwanruthai Jensirikan", + "room": "1212", + "purchasedType": "RM3", + "unit": "555/16", + "member": "88281", + "remaining": 8 + }, + { + "id": 144, + "sourceNo": 282, + "transferDate": "2025-05-29", + "name": "Mr.Kittivut Kittikunpituk", + "room": "1213", + "purchasedType": "RM3", + "unit": "555/17", + "member": "88282", + "remaining": 15 + }, + { + "id": 145, + "sourceNo": 283, + "transferDate": "2025-06-04", + "name": "MS.PANG JINCHUN", + "room": "3508", + "purchasedType": "RM3", + "unit": "555/411", + "member": "88283", + "remaining": 15 + }, + { + "id": 146, + "sourceNo": 286, + "transferDate": "2025-06-10", + "name": "MS. IRINA CHELDYSHKINA", + "room": "1314", + "purchasedType": "RM3", + "unit": "555/42", + "member": "88286", + "remaining": 15 + }, + { + "id": 147, + "sourceNo": 288, + "transferDate": "2025-06-30", + "name": "MS.CHEN-YI CHIANG", + "room": "1412", + "purchasedType": "RM3", + "unit": "555/64", + "member": "88288", + "remaining": 15 + }, + { + "id": 148, + "sourceNo": 290, + "transferDate": "2025-07-07", + "name": "MR.RALF MARKUS HENDELE", + "room": "3418", + "purchasedType": "RM3", + "unit": "555/395", + "member": "88290", + "remaining": 15 + }, + { + "id": 149, + "sourceNo": 293, + "transferDate": "2025-07-16", + "name": "Miss Chiraprapa Phompan", + "room": "1204", + "purchasedType": "RM3", + "unit": "555/8", + "member": "88293", + "remaining": 15 + }, + { + "id": 150, + "sourceNo": 297, + "transferDate": "2025-07-29", + "name": "Miss Nittaya Buppharat", + "room": "1406", + "purchasedType": "RM2", + "unit": "555/58", + "member": "88297", + "remaining": 11 + }, + { + "id": 151, + "sourceNo": 298, + "transferDate": "2025-07-29", + "name": "Mr.Vekeephat Maneechay", + "room": "3717", + "purchasedType": "RM3", + "unit": "555/472", + "member": "88298", + "remaining": 5 + }, + { + "id": 152, + "sourceNo": 299, + "transferDate": "2025-07-29", + "name": "Mr.Prasasana Sricharoen", + "room": "1416", + "purchasedType": "RM2", + "unit": "555/68", + "member": "88299", + "remaining": 12 + }, + { + "id": 153, + "sourceNo": 300, + "transferDate": "2025-08-04", + "name": "Miss Chiraprapa Phompan and Mr. Chudet Meenatoree", + "room": "1313", + "purchasedType": "RM3", + "unit": "555/41", + "member": "88300", + "remaining": 15 + }, + { + "id": 154, + "sourceNo": 303, + "transferDate": "2025-08-21", + "name": "MR.SHI JUNNING", + "room": "1315", + "purchasedType": "RM3", + "unit": "555/43", + "member": "88303", + "remaining": 15 + }, + { + "id": 155, + "sourceNo": 304, + "transferDate": "2025-08-22", + "name": "MR.THOMAS HOEGL", + "room": "1210", + "purchasedType": "RM3", + "unit": "555/14", + "member": "88304", + "remaining": 14 + }, + { + "id": 156, + "sourceNo": 305, + "transferDate": "2025-08-26", + "name": "MS. JIANG RUNYI", + "room": "1413", + "purchasedType": "RM3", + "unit": "555/65", + "member": "88305", + "remaining": 10 + }, + { + "id": 157, + "sourceNo": 307, + "transferDate": "2025-09-09", + "name": "Mrs. Sansanee Faengrit", + "room": "4312", + "purchasedType": "RM3", + "unit": "555/532", + "member": "88307", + "remaining": 15 + }, + { + "id": 158, + "sourceNo": 309, + "transferDate": "2025-09-22", + "name": "MR.WANG HSIEN-TE", + "room": "1404", + "purchasedType": "RM2", + "unit": "555/56", + "member": "88309", + "remaining": 10 + }, + { + "id": 159, + "sourceNo": 311, + "transferDate": "2025-09-29", + "name": "Mr.Watanachai Smittakorn", + "room": "1415", + "purchasedType": "RM3", + "unit": "555/67", + "member": "88311", + "remaining": 15 + }, + { + "id": 160, + "sourceNo": 312, + "transferDate": "2025-09-30", + "name": "MR.Supasin Ragpipray", + "room": "1216", + "purchasedType": "RM3", + "unit": "555/20", + "member": "88312", + "remaining": 9 + }, + { + "id": 161, + "sourceNo": 315, + "transferDate": "2025-10-14", + "name": "B2 Soft Co., Ltd.,", + "room": "3409", + "purchasedType": "RM3", + "unit": "555/386", + "member": "888315", + "remaining": 5 + }, + { + "id": 162, + "sourceNo": 317, + "transferDate": "2025-10-27", + "name": "MRS.ANILA BANO", + "room": "3109", + "purchasedType": "RM3", + "unit": "555/315", + "member": "888317", + "remaining": 15 + }, + { + "id": 163, + "sourceNo": 324, + "transferDate": "2025-12-04", + "name": "MISS WIPAWAN PHANTHOO", + "room": "1214", + "purchasedType": "RM3", + "unit": "555/18", + "member": "888324", + "remaining": 15 + }, + { + "id": 164, + "sourceNo": 325, + "transferDate": "2025-12-16", + "name": "MR.VIKTOR DANILOV", + "room": "4605", + "purchasedType": "RM3", + "unit": "555/603", + "member": "888325", + "remaining": 15 + }, + { + "id": 165, + "sourceNo": 328, + "transferDate": "2025-12-23", + "name": "Miss Kingpai Pokkasoot", + "room": "1304", + "purchasedType": "RM2", + "unit": "555/32", + "member": "888328", + "remaining": 15 + }, + { + "id": 166, + "sourceNo": 329, + "transferDate": "2026-01-06", + "name": "MR.Punyawat Apiwatanakul", + "room": "1515", + "purchasedType": "RM3", + "unit": "555/91", + "member": "888329", + "remaining": 15 + }, + { + "id": 167, + "sourceNo": 332, + "transferDate": "2026-01-09", + "name": "Miss Kwanmanus Hancharoenpaitoon", + "room": "1305", + "purchasedType": "RM2", + "unit": "555/33", + "member": "888332", + "remaining": 15 + }, + { + "id": 168, + "sourceNo": 333, + "transferDate": "2026-01-21", + "name": "MR.XIAOJIE SUN", + "room": "1312", + "purchasedType": "RM3", + "unit": "555/40", + "member": "888333", + "remaining": 15 + }, + { + "id": 169, + "sourceNo": 334, + "transferDate": "2026-01-27", + "name": "Miss Orapin Kaewha", + "room": "1414", + "purchasedType": "RM3", + "unit": "555/66", + "member": "888334", + "remaining": 15 + }, + { + "id": 170, + "sourceNo": 335, + "transferDate": "2026-02-03", + "name": "MR.Punyawat Apiwatanakul", + "room": "1516", + "purchasedType": "RM2", + "unit": "555/92", + "member": "888335", + "remaining": 15 + }, + { + "id": 171, + "sourceNo": 336, + "transferDate": "2026-02-05", + "name": "MS.PING YU", + "room": "1513", + "purchasedType": "RM3", + "unit": "555/89", + "member": "888336", + "remaining": 15 + }, + { + "id": 172, + "sourceNo": 345, + "transferDate": "2026-02-27", + "name": "MR. ZHANG KE", + "room": "1507", + "purchasedType": "RM2", + "unit": "555/83", + "member": "888345", + "remaining": 15 + }, + { + "id": 173, + "sourceNo": 347, + "transferDate": "2026-03-09", + "name": "MR. YUAN FEI", + "room": "3417", + "purchasedType": "RM3", + "unit": "555/394", + "member": "888347", + "remaining": 15 + }, + { + "id": 174, + "sourceNo": 349, + "transferDate": "2026-03-11", + "name": "MR. GAO RONGFENG", + "room": "1609", + "purchasedType": "RM2", + "unit": "555/109", + "member": "888349", + "remaining": 15 + }, + { + "id": 175, + "sourceNo": 351, + "transferDate": "2026-03-13", + "name": "Mr. Pongsak Swatdikiat", + "room": "3716", + "purchasedType": "RM3", + "unit": "555/471", + "member": "888351", + "remaining": 15 + }, + { + "id": 176, + "sourceNo": 352, + "transferDate": "2026-03-16", + "name": "Mr. Kittivut Kittikunpituk", + "room": "3713", + "purchasedType": "RM3", + "unit": "555/468", + "member": "888352", + "remaining": 15 + }, + { + "id": 177, + "sourceNo": 353, + "transferDate": "2026-03-16", + "name": "Mr. Kittivut Kittikunpituk", + "room": "3714", + "purchasedType": "RM3", + "unit": "555/469", + "member": "888353", + "remaining": 15 + }, + { + "id": 178, + "sourceNo": 357, + "transferDate": "2026-03-25", + "name": "MR. LI YONGDONG", + "room": "3706", + "purchasedType": "RM3", + "unit": "555/461", + "member": "888357", + "remaining": 15 + }, + { + "id": 179, + "sourceNo": 358, + "transferDate": "2026-03-25", + "name": "MR. LI YONGDONG", + "room": "3707", + "purchasedType": "RM3", + "unit": "555/462", + "member": "888358", + "remaining": 15 + }, + { + "id": 180, + "sourceNo": 361, + "transferDate": "2026-03-31", + "name": "Miss Daranee Selphusit", + "room": "4607", + "purchasedType": "RM3", + "unit": "555/605", + "member": "888360", + "remaining": 15 + }, + { + "id": 181, + "sourceNo": 365, + "transferDate": "2026-04-09", + "name": "MS. QIU GUIMIN", + "room": "3313", + "purchasedType": "RM3", + "unit": "555/364", + "member": "888365", + "remaining": 15 + }, + { + "id": 182, + "sourceNo": 366, + "transferDate": "2026-04-09", + "name": "MS. Woramon Sinsuwan", + "room": "4706", + "purchasedType": "RM3", + "unit": "555/630", + "member": "888366", + "remaining": 15 + }, + { + "id": 183, + "sourceNo": 369, + "transferDate": "2026-04-24", + "name": "MS. CHEN TIANNI", + "room": "3705", + "purchasedType": "RM3", + "unit": "555/460", + "member": "888369", + "remaining": 15 + }, + { + "id": 184, + "sourceNo": 370, + "transferDate": "2026-04-24", + "name": "MR. ZHANG HAO", + "room": "1505", + "purchasedType": "RM2", + "unit": "555/81", + "member": "888370", + "remaining": 15 + }, + { + "id": 185, + "sourceNo": 375, + "transferDate": "2026-05-11", + "name": "MS.NI XUWEN", + "room": "1310", + "purchasedType": "RM3", + "unit": "555/35", + "member": "888375", + "remaining": 15 + }, + { + "id": 186, + "sourceNo": 376, + "transferDate": "2026-05-11", + "name": "MR.ZHOU BAODIAN", + "room": "3709", + "purchasedType": "RM3", + "unit": "555/464", + "member": "888376", + "remaining": 15 + }, + { + "id": 187, + "sourceNo": 379, + "transferDate": "2026-05-25", + "name": "MR.XI LUWEI", + "room": "1606", + "purchasedType": "RM2", + "unit": "555/106", + "member": "88379", + "remaining": 13 + }, + { + "id": 188, + "sourceNo": 385, + "transferDate": "2026-06-25", + "name": "Mr. Phongphan Subprasret", + "room": "1504", + "purchasedType": "RM2", + "unit": "555/80", + "member": "88385", + "remaining": 15 + }, + { + "id": 189, + "sourceNo": 386, + "transferDate": "2026-06-29", + "name": "Mr.Pongsak Swatdikiat", + "room": "3718", + "purchasedType": "RM3", + "unit": "555/473", + "member": "88386", + "remaining": 15 + }, + { + "id": 190, + "sourceNo": 388, + "transferDate": "2026-06-30", + "name": "Mr. Wicha Jampawan", + "room": "4609", + "purchasedType": "RM3", + "unit": "555/607", + "member": "88388", + "remaining": 15 + } +]; diff --git a/runtime-config.js b/runtime-config.js new file mode 100644 index 0000000..d84679c --- /dev/null +++ b/runtime-config.js @@ -0,0 +1,7 @@ +window.CONDO_RUNTIME_CONFIG = Object.freeze({ + // The operational page reads owner accounts and usage history from the + // backend by default. Use ?mode=demo only for the standalone prototype. + defaultMode: "api", + apiBaseUrl: `${window.location.protocol}//${window.location.hostname}:3000`, + periodYear: new Date().getUTCFullYear() +}); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..833bf55 --- /dev/null +++ b/styles.css @@ -0,0 +1,568 @@ +:root { + color-scheme: light; + --ink: #172033; + --body: #3f4858; + --muted: #697386; + --faint: #697386; + --line: #e1e5eb; + --line-strong: #cbd1db; + --canvas: #f5f6f8; + --surface: #ffffff; + --surface-soft: #f8f9fb; + --nav: #142033; + --nav-muted: #aeb8ca; + --primary: #2454d3; + --primary-hover: #1d47ba; + --primary-soft: #eef3ff; + --error: #b42318; + --error-soft: #fff3f1; + --shadow: 0 18px 60px rgba(17, 24, 39, 0.14); + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --sidebar-width: 122px; + --mobile-sidebar-width: 244px; + font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; +} + +* { box-sizing: border-box; } +html { min-width: 320px; background: var(--canvas); } +body { margin: 0; color: var(--ink); background: var(--canvas); font-size: 14px; line-height: 1.45; } +button, input, select, textarea { font: inherit; } +button, select { cursor: pointer; } +button { color: inherit; } +svg { display: block; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +[hidden] { display: none !important; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +:focus-visible { outline: 3px solid rgba(36, 84, 211, .25); outline-offset: 2px; } + +.login-screen { position: relative; isolation: isolate; min-height: 100dvh; display: grid; grid-template-rows: auto 1fr auto; overflow: hidden; background: var(--canvas); } +.login-screen::before { content: ""; position: absolute; inset: 0 auto 0 0; z-index: -2; width: 46%; background: var(--nav); } +.login-screen::after { content: ""; position: absolute; inset: 0 auto 0 0; z-index: -1; width: 46%; opacity: .28; background-image: linear-gradient(rgba(255,255,255,.055) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.055) 1px, transparent 1px); background-size: 48px 48px; mask-image: linear-gradient(to bottom, black, transparent 92%); } +.login-header, .login-layout, .login-footer { width: min(100% - 48px, 1180px); margin-inline: auto; } +.login-header { min-height: 92px; display: flex; align-items: center; justify-content: space-between; gap: 24px; } +.login-brand { display: flex; align-items: center; gap: 12px; color: #fff; } +.login-brand-mark { width: 40px; height: 40px; flex: 0 0 auto; } +.login-brand-mark svg { width: 22px; height: 22px; } +.login-brand > span:last-child { display: grid; gap: 1px; } +.login-brand strong { font-size: 16px; font-weight: 680; letter-spacing: -.015em; } +.login-brand small { color: #aeb8ca; font-size: 10px; font-weight: 620; letter-spacing: .08em; text-transform: uppercase; } +.login-language-switch { display: flex; align-items: center; gap: 3px; padding: 3px; border: 1px solid var(--line); border-radius: 10px; background: rgba(255,255,255,.88); box-shadow: 0 8px 24px rgba(17,24,39,.06); } +.login-language-option { min-width: 54px; height: 34px; padding: 0 10px; border: 0; border-radius: 7px; background: transparent; color: var(--muted); font-size: 11px; font-weight: 680; transition: color .18s ease, background .18s ease, box-shadow .18s ease; } +.login-language-option:hover { color: var(--ink); background: var(--surface-soft); } +.login-language-option.active { color: var(--primary); background: #fff; box-shadow: 0 1px 5px rgba(17,24,39,.12); } +.login-language-option:focus-visible { outline-offset: 1px; } +.login-layout { min-height: 610px; display: grid; grid-template-columns: minmax(0, 1fr) 440px; align-items: center; gap: clamp(72px, 10vw, 160px); padding: 48px 0 72px; } +.login-intro { max-width: 470px; color: #fff; } +.login-kicker { display: inline-flex; align-items: center; min-height: 26px; padding: 0 9px; border: 1px solid rgba(255,255,255,.2); border-radius: 999px; color: #aebbd1; font-size: 9px; font-weight: 750; letter-spacing: .15em; } +.login-intro h1 { max-width: 440px; margin: 24px 0 18px; font-size: clamp(38px, 4vw, 56px); line-height: 1.04; letter-spacing: -.052em; font-weight: 610; } +.login-intro p { max-width: 410px; margin: 0; color: #b7c1d0; font-size: 15px; line-height: 1.65; } +.login-system-status { display: flex; align-items: center; gap: 9px; margin-top: 34px; color: #d6dce6; font-size: 11px; } +.login-status-dot { width: 7px; height: 7px; border-radius: 50%; background: #80a3ff; box-shadow: 0 0 0 5px rgba(126,160,255,.13); } +.login-card { width: 100%; padding: 38px; border: 1px solid var(--line); border-radius: 18px; background: #fff; box-shadow: 0 26px 80px rgba(17,24,39,.15); } +.login-card-heading h2 { margin: 8px 0 8px; font-size: 29px; line-height: 1.15; letter-spacing: -.035em; font-weight: 630; } +.login-card-heading p { margin: 0; color: var(--muted); font-size: 13px; } +.login-checking { min-height: 216px; display: flex; align-items: center; justify-content: center; gap: 10px; color: var(--muted); font-size: 12px; } +.login-spinner { width: 17px; height: 17px; border: 2px solid #dce2eb; border-top-color: var(--primary); border-radius: 50%; animation: loginSpin .8s linear infinite; } +@keyframes loginSpin { to { transform: rotate(360deg); } } +.login-form { display: grid; gap: 17px; margin-top: 28px; } +.login-field { gap: 8px; } +.login-field label { color: var(--body); font-size: 11px; font-weight: 670; } +.login-input-wrap { position: relative; } +.login-input-wrap > svg { position: absolute; top: 50%; left: 14px; z-index: 1; width: 17px; height: 17px; color: #788397; transform: translateY(-50%); pointer-events: none; } +.login-field .login-input-wrap input { height: 50px; padding: 0 44px 0 42px; border-color: var(--line-strong); font-size: 14px; } +.login-field .login-input-wrap input:hover { border-color: #adb6c5; } +.login-field .login-input-wrap input:focus { border-color: #6f91e7; box-shadow: 0 0 0 4px rgba(36,84,211,.1); } +.password-toggle { position: absolute; top: 5px; right: 5px; width: 40px; height: 40px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--muted); transition: color .18s ease, background .18s ease; } +.password-toggle:hover { color: var(--ink); background: var(--surface-soft); } +.password-toggle svg { width: 18px; height: 18px; } +.login-error { padding: 10px 12px; border: 1px solid #f1c3be; border-radius: 8px; background: var(--error-soft); color: var(--error); font-size: 11px; } +.login-submit { width: 100%; min-height: 50px; margin-top: 1px; } +.login-submit svg { margin-left: auto; } +.login-session-note { display: flex; align-items: center; gap: 9px; margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--line); color: var(--muted); font-size: 10px; } +.login-session-note svg { width: 17px; height: 17px; flex: 0 0 auto; color: var(--primary); } +.login-footer { min-height: 66px; display: flex; align-items: center; justify-content: space-between; gap: 20px; color: var(--muted); font-size: 10px; } +.login-footer span:first-child { color: rgba(255,255,255,.62); } + +.app-shell { min-height: 100dvh; display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); } +.sidebar { position: sticky; top: 0; width: var(--sidebar-width); height: 100dvh; z-index: 30; display: flex; flex-direction: column; padding: 18px 10px 14px; background: var(--nav); color: #fff; } +.brand { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 2px 0 24px; letter-spacing: -.01em; text-align: center; } +.brand-mark { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 10px; background: #fff; color: var(--nav); } +.brand-mark svg { width: 20px; height: 20px; } +.brand strong { font-size: 15px; font-weight: 650; line-height: 1.1; } + +.nav-list { display: grid; gap: 8px; } +.nav-item { width: 100%; min-height: 72px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; padding: 9px 6px; border: 0; border-radius: 10px; color: var(--nav-muted); background: transparent; font-size: 11px; font-weight: 570; line-height: 1.2; text-align: center; transition: color .18s ease, background .18s ease; } +.nav-item span { max-width: 100%; white-space: normal; } +.nav-item svg { width: 20px; height: 20px; flex: 0 0 auto; } +.nav-item:hover { color: #fff; background: rgba(255,255,255,.07); } +.nav-item.active { color: #fff; background: rgba(255,255,255,.12); box-shadow: inset 3px 0 0 #7ea0ff; } +.sidebar-bottom { min-height: 126px; margin-top: auto; padding: 0 2px 88px; } +.language-dock { position: fixed; left: 10px; bottom: 16px; z-index: 90; width: calc(var(--sidebar-width) - 20px); pointer-events: none; } +.language-switch { position: relative; isolation: isolate; display: grid; grid-template-columns: 1fr; grid-template-rows: repeat(3, 1fr); height: 108px; padding: 3px; overflow: hidden; border: 1px solid rgba(255,255,255,.16); border-radius: 12px; background: rgba(6,13,24,.34); box-shadow: inset 0 1px 0 rgba(255,255,255,.07), 0 10px 24px rgba(4,10,20,.16); pointer-events: auto; } +.language-switch::before { content: ""; position: absolute; top: 3px; left: 3px; right: 3px; z-index: 0; height: calc(33.3333% - 3px); border: 1px solid rgba(255,255,255,.55); border-radius: 9px; background: #f4f7fb; box-shadow: inset 0 1px 0 rgba(255,255,255,.8), 0 3px 10px rgba(3,8,18,.22); transition: transform .18s cubic-bezier(.2,.8,.2,1); } +[data-locale="zh"] .language-switch::before { transform: translateY(100%); } +[data-locale="th"] .language-switch::before { transform: translateY(200%); } +.language-option { position: relative; z-index: 1; min-width: 0; min-height: 34px; display: flex; align-items: center; justify-content: center; gap: 5px; padding: 0 6px; border: 0; border-radius: 9px; background: transparent; color: #b8c2d1; cursor: pointer; touch-action: manipulation; transition: color .15s ease, transform .12s ease; } +.language-code { color: #92a7db; font-size: 8px; font-weight: 760; letter-spacing: .08em; transition: color .15s ease; } +.language-name { overflow: hidden; font-size: 12px; font-weight: 650; letter-spacing: -.01em; text-overflow: ellipsis; white-space: nowrap; } +.language-option:hover:not(.active) { color: #f4f7fb; } +.language-option:hover:not(.active) .language-code { color: #b8c9f2; } +.language-option.active { color: var(--nav); } +.language-option.active .language-code { color: var(--primary); } +.language-option:active { transform: scale(.98); } +.language-option:focus-visible { outline: 2px solid #8ba7ef; outline-offset: -1px; } +.sidebar-footer { padding: 0 2px; overflow-wrap: anywhere; color: #8995a8; font-size: 9px; line-height: 1.4; text-align: center; } + +.main-column { min-width: 0; } +.topbar { position: sticky; top: 0; z-index: 20; height: 60px; display: flex; align-items: center; justify-content: flex-end; gap: 18px; padding: 0 28px; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.94); backdrop-filter: blur(12px); } +.mobile-menu { display: none !important; } +.period-display { margin-left: auto; min-width: 132px; height: 44px; display: grid; align-content: center; gap: 1px; padding: 6px 14px; border: 1px solid var(--line); border-radius: 10px; background: #fff; } +.period-display span { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; } +.period-display strong { font-size: 13px; } +.session-area { position: relative; display: flex; align-items: center; gap: 12px; } +.session-identity { display: grid; gap: 1px; padding-left: 2px; } +.session-identity span { color: var(--muted); font-size: 9px; letter-spacing: .05em; text-transform: uppercase; } +.session-identity strong { max-width: 150px; overflow: hidden; font-size: 11px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } +.session-logout { min-height: 38px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 0 11px; border: 1px solid var(--line); border-radius: 9px; background: #fff; color: var(--body); font-size: 11px; font-weight: 620; transition: border-color .18s ease, background .18s ease, color .18s ease; } +.session-logout:hover { border-color: #adb6c5; background: var(--surface-soft); color: var(--ink); } +.session-logout:disabled { cursor: wait; opacity: .6; } +.session-logout svg { width: 16px; height: 16px; } +.session-error { position: absolute; top: calc(100% + 10px); right: 0; width: max-content; max-width: min(320px, calc(100vw - 28px)); padding: 9px 11px; border: 1px solid #f1c3be; border-radius: 8px; background: var(--error-soft); box-shadow: 0 10px 28px rgba(17,24,39,.12); color: var(--error); font-size: 10px; } + +.content { width: 100%; max-width: 1540px; margin: 0 auto; padding: 22px 28px 64px; } +.connection-banner { min-height: 48px; display: flex; align-items: center; gap: 11px; margin: -10px 0 24px; padding: 9px 12px; border: 1px solid var(--line); border-radius: 10px; background: rgba(255,255,255,.78); } +.connection-banner > div { min-width: 0; display: grid; gap: 1px; } +.connection-banner strong { font-size: 12px; font-weight: 650; } +.connection-banner span:not(.connection-dot) { overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.connection-banner .text-button { margin-left: auto; padding: 7px 8px; } +.connection-dot { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; background: #7b8799; box-shadow: 0 0 0 4px rgba(123,135,153,.12); } +.connection-banner[data-state="loading"] .connection-dot { background: #d19213; box-shadow: 0 0 0 4px rgba(209,146,19,.14); animation: connectionPulse 1.2s ease-in-out infinite; } +.connection-banner[data-state="ready"] .connection-dot { background: #15803d; box-shadow: 0 0 0 4px rgba(21,128,61,.12); } +.connection-banner[data-state="error"] { border-color: #f1c3be; background: var(--error-soft); } +.connection-banner[data-state="error"] .connection-dot { background: var(--error); box-shadow: 0 0 0 4px rgba(180,35,24,.12); } +@keyframes connectionPulse { 50% { opacity: .45; } } +.page { display: none; } +.page.active { display: block; animation: pageIn .22s ease both; } +@keyframes pageIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } +.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 28px; } +.page-heading.compact { margin-bottom: 24px; } +.page-heading.compact h1 { margin-top: 0; } +.eyebrow { display: block; color: #68748a; font-size: 10px; font-weight: 700; letter-spacing: .14em; } +.page-heading h1 { margin: 7px 0 6px; font-size: clamp(27px, 2.4vw, 36px); line-height: 1.15; letter-spacing: -.035em; font-weight: 620; } +.page-heading p, .drawer-header p { margin: 0; color: var(--muted); } +.primary-button, .secondary-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; padding: 0 16px; border-radius: 9px; font-weight: 600; transition: background .18s ease, border-color .18s ease, color .18s ease; white-space: nowrap; } +.primary-button { border: 1px solid var(--primary); background: var(--primary); color: #fff; } +.primary-button:hover { border-color: var(--primary-hover); background: var(--primary-hover); } +.primary-button:disabled { cursor: not-allowed; border-color: #b9c3d8; background: #b9c3d8; } +.secondary-button { border: 1px solid var(--line-strong); background: #fff; color: var(--ink); } +.secondary-button:hover { border-color: #9da7b8; background: var(--surface-soft); } +.primary-button:active:not(:disabled), .secondary-button:active, .icon-button:active, .row-action:active { transform: translateY(1px); } +.primary-button svg, .secondary-button svg { width: 17px; height: 17px; } +.text-button { display: inline-flex; align-items: center; gap: 4px; padding: 7px 0; border: 0; background: transparent; color: var(--primary); font-size: 12px; font-weight: 600; } +.text-button:hover { color: var(--primary-hover); } +.text-button svg { width: 14px; height: 14px; } +.icon-button { width: 40px; height: 40px; display: grid; place-items: center; padding: 0; border: 1px solid var(--line); border-radius: 9px; background: #fff; } +.icon-button:hover { background: var(--surface-soft); } +.icon-button svg { width: 19px; height: 19px; } + +.metric-grid { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 14px; margin-bottom: 16px; } +.metric-card { position: relative; min-height: 158px; display: flex; flex-direction: column; padding: 20px; overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius-md); background: #fff; } +.metric-icon { position: absolute; top: 18px; right: 18px; width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 9px; color: var(--muted); background: var(--surface-soft); } +.metric-icon svg { width: 17px; height: 17px; } +.metric-card > span { color: var(--muted); font-size: 12px; font-weight: 550; } +.metric-card > strong { margin-top: auto; font-size: 30px; line-height: 1; letter-spacing: -.04em; font-weight: 620; font-variant-numeric: tabular-nums; } +.metric-card > small { margin-top: 8px; color: var(--faint); font-size: 11px; } +.panel { border: 1px solid var(--line); border-radius: var(--radius-md); background: #fff; } +.dashboard-grid { display: grid; grid-template-columns: minmax(0,1.55fr) minmax(310px,.8fr); gap: 16px; margin-bottom: 16px; } +.activity-panel, .room-mix-panel { min-height: 330px; padding: 22px; } +.room-mix-panel { display: flex; flex-direction: column; } +.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; } +.panel-heading h2 { margin: 0; font-size: 16px; line-height: 1.3; font-weight: 620; letter-spacing: -.012em; } +.panel-heading p { margin: 4px 0 0; color: var(--muted); font-size: 12px; } +.chart-summary { margin-top: 28px; display: flex; align-items: baseline; gap: 8px; } +.chart-summary strong { font-size: 30px; letter-spacing: -.04em; font-weight: 620; } +.chart-summary span { color: var(--muted); font-size: 12px; } +.bar-chart { height: 175px; display: grid; grid-template-columns: repeat(12, 1fr); align-items: end; gap: clamp(5px, 1vw, 11px); margin-top: 14px; padding-top: 12px; border-bottom: 1px solid var(--line); background: repeating-linear-gradient(to bottom, transparent 0, transparent 39px, #f1f3f6 40px); } +.bar-item { height: 100%; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 8px; } +.bar-item span { width: 100%; max-width: 24px; height: var(--height); min-height: 2px; border-radius: 5px 5px 0 0; background: #bac5d7; transition: height .25s ease, background .18s ease; } +.bar-item.current span { background: var(--primary); } +.bar-item.future span { background: #e8ebf0; } +.bar-item small { position: relative; top: 25px; color: var(--faint); font-size: 10px; } +.room-mix-legend { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px; margin: 20px 0 18px; padding-bottom: 16px; border-bottom: 1px solid var(--line); } +.room-mix-legend-item { min-width: 0; display: flex; align-items: center; gap: 9px; } +.room-mix-legend-item > span:last-child { min-width: 0; display: grid; gap: 2px; } +.room-mix-legend-item strong { overflow: hidden; color: var(--ink); font-size: 11px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } +.room-mix-legend-item small { color: var(--muted); font-size: 10px; font-variant-numeric: tabular-nums; } +.room-mix-swatch { width: 9px; height: 9px; flex: 0 0 auto; } +.room-mix-swatch-source { border-radius: 2px; background: var(--nav); } +.room-mix-swatch-used { border-radius: 50%; background: var(--primary); } +.room-mix-chart { display: grid; gap: 14px; } +.room-mix-row { display: grid; grid-template-columns: 40px minmax(0,1fr); align-items: start; gap: 10px; } +.room-mix-type { padding-top: 3px; color: var(--ink); font-size: 11px; font-weight: 700; letter-spacing: .02em; } +.room-mix-series-stack { min-width: 0; display: grid; gap: 6px; } +.room-mix-series { min-width: 0; display: grid; grid-template-columns: 7px minmax(0,1fr) 68px; align-items: center; gap: 8px; } +.room-mix-series::before { content: ""; width: 6px; height: 6px; } +.room-mix-series-source::before { border-radius: 1px; background: var(--nav); } +.room-mix-series-used::before { border-radius: 50%; background: var(--primary); } +.room-mix-track { height: 8px; overflow: hidden; border-radius: 999px; background: #edf0f4; } +.room-mix-fill { display: block; width: var(--share); height: 100%; transition: width .25s ease; } +.room-mix-fill.has-value { min-width: 4px; } +.room-mix-fill-source { border-radius: 2px; background: var(--nav); } +.room-mix-fill-used { border-radius: 999px; background: var(--primary); } +.room-mix-value { display: flex; justify-content: space-between; align-items: baseline; gap: 4px; color: var(--ink); font-size: 10px; font-variant-numeric: tabular-nums; } +.room-mix-value strong { font-size: 11px; font-weight: 650; } +.room-mix-value small { color: var(--muted); font-size: 9px; } +.room-mix-note { margin: auto 0 0; padding-top: 14px; color: var(--muted); font-size: 10px; line-height: 1.45; } + +.owner-workspace-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 16px; border-bottom: 1px solid var(--line); } +.owner-workspace-header .primary-button { margin-bottom: 8px; } +.owner-workspace-header .open-stay-drawer { + min-height: 30px; + gap: 5px; + padding: 0 8px; + border-radius: 6px; + font-size: 11px; +} +.owner-workspace-header .open-stay-drawer svg { width: 13px; height: 13px; } +.owner-workspace-tabs { min-width: 0; display: flex; align-items: flex-end; gap: 32px; margin: 0; } +.owner-workspace-tab { position: relative; min-height: 54px; display: inline-flex; align-items: center; gap: 9px; padding: 0 4px 2px; border: 0; background: transparent; color: var(--muted); font-size: 15px; font-weight: 650; } +.owner-workspace-tab::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 3px; border-radius: 3px 3px 0 0; background: transparent; } +.owner-workspace-tab:hover { color: var(--ink); } +.owner-workspace-tab.active { color: var(--ink); } +.owner-workspace-tab.active::after { background: var(--primary); } +.owner-workspace-tab > span:last-child { min-width: 26px; color: var(--faint); font-size: 11px; font-variant-numeric: tabular-nums; text-align: center; } +.owner-workspace-tab.active > span:last-child { color: var(--primary); } +.owner-workspace-tab .tab-label { min-width: 0; color: inherit; font-size: inherit; text-align: left; } +.owner-workspace-view { display: none; } +.owner-workspace-view.active { display: block; } + +.table-panel { overflow: hidden; } +.table-panel > .panel-heading { padding: 20px 22px 16px; } +.table-wrap { width: 100%; overflow-x: auto; } +.owners-panel .table-wrap, .usage-panel .table-wrap { max-height: calc(100dvh - 318px); overflow: auto; } +.owners-panel thead, .usage-panel thead { position: sticky; top: 0; z-index: 2; } +.owner-table { min-width: 960px; table-layout: fixed; } +.owner-table th { height: 48px; line-height: 1.2; white-space: normal; } +.owner-table th:nth-child(1) { width: 6%; } +.owner-table th:nth-child(2) { width: 14%; } +.owner-table th:nth-child(3) { width: 18%; } +.owner-table th:nth-child(4) { width: 10%; } +.owner-table th:nth-child(5) { width: 11%; } +.owner-table th:nth-child(6) { width: 11%; } +.owner-table th:nth-child(7) { width: 12%; } +.owner-table th:nth-child(8) { width: 14%; } +.owner-table th:nth-child(9) { width: 4%; } +.owner-table td:nth-child(3) { line-height: 1.35; white-space: normal; } +.usage-table { min-width: 1100px; table-layout: fixed; } +.usage-table th { height: 48px; line-height: 1.2; white-space: normal; } +.usage-table th, +.usage-table td { padding-right: 12px; padding-left: 12px; } +.usage-table th:nth-child(1), +.usage-table td:nth-child(1) { width: 10%; } +.usage-table th:nth-child(2), +.usage-table td:nth-child(2) { width: 20%; } +.usage-table th:nth-child(3), +.usage-table td:nth-child(3), +.usage-table th:nth-child(4), +.usage-table td:nth-child(4) { width: 9%; } +.usage-table th:nth-child(5), +.usage-table td:nth-child(5), +.usage-table th:nth-child(6), +.usage-table td:nth-child(6) { width: 6%; } +.usage-table th:nth-child(7), +.usage-table td:nth-child(7) { width: 10%; text-align: center; } +.usage-table th:nth-child(8), +.usage-table td:nth-child(8) { width: 14%; } +.usage-table th:nth-child(9), +.usage-table td:nth-child(9) { width: 12%; } +.usage-table th:nth-child(10), +.usage-table td:nth-child(10) { width: 4%; } +.usage-table td:nth-child(2), .usage-table td:nth-child(9) { white-space: normal; } +.history-room-type { + max-width: 100%; + justify-content: center; + padding-top: 4px; + padding-bottom: 4px; + border-radius: 8px; + line-height: 1.25; + overflow-wrap: break-word; + text-align: center; + white-space: normal; +} +.record-remark { display: -webkit-box; overflow: hidden; color: var(--muted); line-height: 1.35; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +table { width: 100%; border-collapse: collapse; } +th { height: 41px; padding: 0 16px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: var(--surface-soft); color: var(--muted); font-size: 11px; font-weight: 700; letter-spacing: .06em; text-align: left; text-transform: uppercase; white-space: nowrap; } +td { height: 64px; padding: 9px 16px; border-bottom: 1px solid #edf0f3; color: var(--body); font-size: 13px; vertical-align: middle; white-space: nowrap; } +tbody tr:last-child td { border-bottom: 0; } +tbody tr { transition: background .16s ease; } +tbody tr:hover { background: #fafbfd; } +td strong, .owner-name { color: var(--ink); font-weight: 610; } +.table-subline { display: block; margin-top: 3px; color: var(--muted); font-size: 11px; } +.mono { font-variant-numeric: tabular-nums; letter-spacing: .015em; } +.neutral-badge { display: inline-flex; align-items: center; min-height: 24px; padding: 0 8px; border: 1px solid var(--line); border-radius: 999px; background: #fff; color: var(--body); font-size: 11px; font-weight: 650; } +.align-right { text-align: right; } +.row-action { width: 32px; height: 32px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--muted); } +.row-action:hover { background: #eef1f5; color: var(--ink); } +.row-action svg { width: 16px; height: 16px; } +.clickable-row { cursor: pointer; } +.toolbar { min-height: 67px; display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); } +.input-with-icon { width: min(390px, 45vw); height: 40px; display: flex; align-items: center; gap: 9px; padding: 0 11px; border: 1px solid var(--line); border-radius: 8px; background: #fff; } +.input-with-icon:focus-within { border-color: #8ba7ef; } +.input-with-icon svg { width: 17px; height: 17px; color: var(--muted); } +.input-with-icon input { flex: 1; min-width: 0; border: 0; outline: 0; color: var(--ink); } +.toolbar select { height: 40px; min-width: 165px; padding: 0 32px 0 11px; border: 1px solid var(--line); border-radius: 8px; background: #fff; color: var(--body); } +.toolbar-count { margin-left: auto; color: var(--muted); font-size: 12px; } +.table-empty { min-height: 160px; display: grid; align-content: center; justify-items: center; gap: 5px; color: var(--muted); text-align: center; } +.table-empty strong { color: var(--ink); font-size: 13px; } +.table-empty span { font-size: 11px; } + +.drawer-scrim, .mobile-scrim { position: fixed; inset: 0; z-index: 70; background: rgba(10,16,26,.42); backdrop-filter: blur(2px); } +.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 80; width: min(560px, 100vw); display: flex; flex-direction: column; border-left: 1px solid var(--line); background: #fff; box-shadow: var(--shadow); transform: translateX(102%); visibility: hidden; transition: transform .24s ease, visibility .24s ease; } +.wide-drawer { width: min(1440px, 100vw); } +.drawer.open { transform: translateX(0); visibility: visible; } +.drawer-header { min-height: 104px; display: flex; justify-content: space-between; gap: 16px; padding: 24px 25px 20px; border-bottom: 1px solid var(--line); } +.drawer-header h2 { margin: 6px 0 4px; font-size: 22px; line-height: 1.2; letter-spacing: -.025em; font-weight: 620; } +.drawer-header p { font-size: 12px; } +.drawer form { min-height: 0; flex: 1; display: flex; flex-direction: column; } +.drawer-body { flex: 1; min-height: 0; overflow-y: auto; padding: 22px 25px 30px; } +.drawer-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 15px 25px; border-top: 1px solid var(--line); background: #fff; } +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px 14px; } +.field { display: grid; gap: 7px; } +.full-field { grid-column: 1 / -1; } +.field label { color: var(--body); font-size: 11px; font-weight: 650; } +.field label .label-text { color: inherit; } +.field label .required-mark { color: var(--primary); } +.field input, .field select, .field textarea { width: 100%; border: 1px solid var(--line-strong); border-radius: 8px; background: #fff; color: var(--ink); transition: border-color .18s ease, box-shadow .18s ease; } +.field input, .field select { height: 44px; padding: 0 11px; } +.field textarea { min-height: 78px; resize: vertical; padding: 10px 11px; } +.field input::placeholder, .field textarea::placeholder { color: var(--muted); } +.field input:focus, .field select:focus, .field textarea:focus { outline: 0; border-color: #6f91e7; box-shadow: 0 0 0 3px rgba(36,84,211,.1); } +.field input[readonly] { color: var(--muted); background: var(--surface-soft); cursor: default; } +.field select:disabled { opacity: 1; color: var(--ink); background: var(--surface-soft); cursor: not-allowed; } +.field small { color: var(--muted); font-size: 10px; } +.account-context { display: grid; grid-template-columns: repeat(4,1fr); gap: 8px; margin: 11px 0 20px; } +.context-empty { grid-column: 1 / -1; padding: 14px; border: 1px dashed var(--line-strong); border-radius: 9px; color: var(--muted); text-align: center; } +.context-item { padding: 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-soft); } +.context-item span { display: block; color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: .06em; } +.context-item strong { display: block; margin-top: 5px; overflow: hidden; color: var(--ink); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.calculation-card { margin-top: 22px; padding: 17px; border: 1px solid #d6dce6; border-radius: 10px; background: #f8f9fb; } +.calculation-title, .calculation-line, .balance-line { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.calculation-title { padding-bottom: 13px; border-bottom: 1px solid var(--line); color: var(--muted); font-size: 11px; font-weight: 650; } +.rule-source { padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px; background: #fff; color: var(--body); font-size: 9px; } +.calculation-line { padding: 15px 0; } +.calculation-line span { color: var(--muted); font-size: 12px; } +.calculation-line strong { color: var(--ink); font-size: 19px; letter-spacing: -.02em; } +.balance-line { padding-top: 13px; border-top: 1px solid var(--line); } +.balance-line > span { display: grid; gap: 3px; color: var(--muted); font-size: 10px; } +.balance-line > span:last-child { text-align: right; } +.balance-line > span > span { display: block; } +.balance-line b { color: var(--ink); font-size: 13px; } +.balance-line svg { width: 17px; height: 17px; color: var(--faint); } +.form-error { margin-top: 12px; padding: 11px 12px; border: 1px solid #f1c3be; border-radius: 8px; background: var(--error-soft); color: var(--error); font-size: 11px; } +.stay-drawer .drawer-header, .stay-drawer .drawer-footer { padding-left: max(25px, calc((100% - 1040px) / 2)); padding-right: max(25px, calc((100% - 1040px) / 2)); } +.stay-drawer .drawer-body > * { width: min(100%, 1040px); margin-left: auto; margin-right: auto; } +.detail-drawer { z-index: 81; } +.detail-balance { display: grid; grid-template-columns: .75fr 1.25fr; margin-bottom: 22px; overflow: hidden; border-radius: 12px; background: var(--nav); color: #fff; } +.detail-balance-main { padding: 22px; } +.detail-balance-main > span { color: #aebbd1; font-size: 9px; text-transform: uppercase; letter-spacing: .09em; } +.detail-balance-main > strong { display: block; margin-top: 8px; font-size: 38px; line-height: 1; letter-spacing: -.04em; font-weight: 590; } +.detail-balance-main > small { display: block; margin-top: 7px; color: #aebbd1; font-size: 10px; } +.detail-summary-grid { display: grid; grid-template-columns: repeat(2, 1fr); border-left: 1px solid rgba(255,255,255,.13); } +.detail-summary-grid > div { min-width: 0; display: flex; flex-direction: column; justify-content: flex-end; gap: 7px; padding: 18px 12px; border-left: 1px solid rgba(255,255,255,.1); } +.detail-summary-grid > div:first-child { border-left: 0; } +.detail-summary-grid span { color: #aebbd1; font-size: 9px; line-height: 1.35; } +.detail-summary-grid strong { font-size: 19px; font-weight: 580; font-variant-numeric: tabular-nums; } +.detail-section-heading { min-height: 28px; display: flex; align-items: baseline; justify-content: space-between; gap: 16px; } +.detail-section-heading h3 { margin: 0; font-size: 13px; } +.detail-section-heading > span { color: var(--muted); font-size: 10px; } +.detail-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin-bottom: 20px; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--line); } +.detail-grid > div { min-height: 72px; padding: 13px; background: #fff; } +.detail-grid span { color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: .06em; } +.detail-grid strong { display: block; margin-top: 6px; font-size: 12px; } +.detail-section { min-width: 0; } +.detail-history-wrap { margin-top: 10px; overflow-x: auto; border: 1px solid var(--line); border-radius: 10px; } +.detail-history-table { min-width: 1120px; table-layout: fixed; } +.detail-history-table th { width: 12.5%; height: 42px; border-top: 0; padding: 0 10px; white-space: nowrap; line-height: 1.2; } +.detail-history-table td { height: 58px; padding: 9px 10px; font-size: 11px; } +.detail-history-table td:nth-child(8) { white-space: normal; } +.detail-history-remark { display: block; color: var(--body); line-height: 1.4; overflow-wrap: anywhere; } +.detail-empty { min-height: 120px; display: grid; align-content: center; justify-items: center; gap: 4px; margin-top: 10px; border: 1px dashed var(--line-strong); border-radius: 10px; color: var(--muted); text-align: center; } +.detail-empty strong { color: var(--ink); font-size: 12px; } +.detail-empty span { font-size: 10px; } + +.toast { position: fixed; right: 24px; bottom: 24px; z-index: 100; min-width: 310px; display: flex; align-items: center; gap: 12px; padding: 14px 16px; border: 1px solid var(--line-strong); border-radius: 11px; background: #fff; box-shadow: 0 14px 38px rgba(17,24,39,.16); animation: toastIn .2s ease both; } +.toast > svg { width: 22px; height: 22px; padding: 4px; border-radius: 50%; background: var(--ink); color: #fff; } +.toast > div { display: grid; } +.toast strong { font-size: 12px; } +.toast span { margin-top: 2px; color: var(--muted); font-size: 10px; } +@keyframes toastIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } + +@media (max-width: 1120px) { + .metric-grid { grid-template-columns: repeat(3, minmax(0,1fr)); } + .metric-card { min-height: 135px; } + .dashboard-grid { grid-template-columns: 1fr; } + .room-mix-panel { min-height: auto; } +} + +@media (max-width: 900px) { + .app-shell { display: block; } + .sidebar { position: fixed; left: 0; width: var(--mobile-sidebar-width); padding: 22px 16px 18px; transform: translateX(-102%); transition: transform .22s ease; box-shadow: 16px 0 42px rgba(0,0,0,.16); } + .brand { flex-direction: row; gap: 12px; padding: 2px 8px 26px; text-align: left; } + .brand-mark { width: 36px; height: 36px; } + .brand-mark svg { width: 22px; height: 22px; } + .brand strong { font-size: 17px; } + .nav-list { gap: 6px; } + .nav-item { min-height: 44px; flex-direction: row; justify-content: flex-start; gap: 12px; padding: 0 12px; font-size: 14px; line-height: 1.45; text-align: left; } + .nav-item svg { width: 18px; height: 18px; } + .sidebar-bottom { min-height: 81px; padding: 56px 0 0; } + .sidebar-footer { padding: 12px 10px 0; font-size: 11px; line-height: 1.45; text-align: left; } + .sidebar.open { transform: translateX(0); } + .language-dock { left: 16px; bottom: 45px; width: calc(var(--mobile-sidebar-width) - 32px); opacity: 0; visibility: hidden; transform: translateX(-120%); transition: opacity .16s ease, transform .2s ease, visibility .2s ease; } + .language-switch { grid-template-columns: repeat(3, 1fr); grid-template-rows: 1fr; height: 44px; } + .language-switch::before { top: 3px; right: auto; bottom: 3px; left: 3px; width: calc(33.3333% - 3px); height: auto; } + [data-locale="zh"] .language-switch::before { transform: translateX(100%); } + [data-locale="th"] .language-switch::before { transform: translateX(200%); } + .language-option { min-height: 36px; gap: 6px; padding: 0 8px; } + .language-code { font-size: 9px; } + .sidebar.open + .language-dock, body:has(.drawer.open) .language-dock { opacity: 1; visibility: visible; transform: translateX(0); } + body:has(.drawer.open) .language-dock { bottom: 13px; width: min(192px, calc(100vw - 32px)); } + body:has(.drawer.open) .language-switch { background: var(--nav); box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 8px 20px rgba(4,10,20,.2); } + body:has(.drawer.open) .language-option { gap: 4px; padding: 0 4px; } + body:has(.drawer.open) .language-code { font-size: 8px; } + body:has(.drawer.open) .language-name { font-size: 11px; } + body:has(#stayDrawer.open) .language-dock { width: min(124px, calc(100vw - 32px)); } + body:has(#stayDrawer.open) .language-option { gap: 0; padding: 0 5px; } + body:has(#stayDrawer.open) .language-name { display: none; } + body:has(#stayDrawer.open) .language-code { display: inline; } + .mobile-scrim { z-index: 25; } + .mobile-menu { display: grid !important; flex: 0 0 auto; } + .topbar { height: 60px; padding: 0 20px; } + .mobile-menu { margin-right: auto; } + .content { padding: 24px 20px 52px; } +} + +@media (max-width: 820px) { + .login-screen { overflow: auto; } + .login-screen::before, .login-screen::after { width: 100%; height: 340px; bottom: auto; } + .login-header, .login-layout, .login-footer { width: min(100% - 36px, 560px); } + .login-header { min-height: 82px; } + .login-language-switch { border-color: rgba(255,255,255,.16); background: rgba(6,13,24,.34); box-shadow: inset 0 1px 0 rgba(255,255,255,.07); } + .login-language-option { color: #b8c2d1; } + .login-language-option:hover { color: #fff; background: rgba(255,255,255,.06); } + .login-language-option.active { color: var(--nav); background: #f4f7fb; } + .login-layout { min-height: auto; display: block; padding: 40px 0 48px; } + .login-intro { max-width: 500px; margin-bottom: 38px; } + .login-intro h1 { max-width: 500px; margin: 18px 0 12px; font-size: clamp(32px, 8vw, 44px); } + .login-intro p { font-size: 13px; } + .login-system-status { display: none; } + .login-card { max-width: 500px; margin-inline: auto; } + .login-footer { color: var(--muted); } + .login-footer span:first-child { color: var(--body); } +} + +@media (max-width: 680px) { + .topbar { gap: 10px; padding: 0 14px; } + .period-display { min-width: 58px; width: 58px; padding: 0; place-items: center; } + .period-display span { display: none; } + .session-identity { display: none; } + .session-area { gap: 0; } + .session-logout { width: 40px; height: 40px; padding: 0; } + .session-logout span { display: none; } + .content { padding: 18px 14px 44px; } + .connection-banner { align-items: flex-start; margin-top: -14px; } + .connection-banner span:not(.connection-dot) { white-space: normal; } + .page-heading { align-items: flex-start; } + .page-heading h1 { font-size: 28px; } + .page-heading p { max-width: 270px; } + .page-heading .primary-button, .page-heading .secondary-button { width: 44px; padding: 0; font-size: 0; flex: 0 0 auto; } + .page-heading .primary-button svg, .page-heading .secondary-button svg { width: 18px; height: 18px; } + .owner-workspace-header { align-items: stretch; flex-direction: column; gap: 10px; margin-bottom: 14px; } + .owner-workspace-tabs { gap: 20px; overflow-x: auto; } + .owner-workspace-tab { min-height: 48px; flex: 0 0 auto; font-size: 14px; } + .owner-workspace-header .primary-button { align-self: flex-end; margin-bottom: 0; } + .metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); gap: 10px; } + .metric-grid > :last-child:nth-child(odd) { grid-column: 1 / -1; } + .metric-card { min-height: 128px; padding: 16px; } + .metric-card > strong { font-size: 25px; } + .metric-icon { top: 14px; right: 14px; width: 30px; height: 30px; } + .activity-panel, .room-mix-panel { padding: 18px; } + .activity-panel { min-height: 310px; } + .bar-chart { gap: 4px; } + .bar-item small { font-size: 8px; } + .panel-heading .text-button { font-size: 0; } + .panel-heading .text-button svg { width: 18px; height: 18px; } + .toolbar { flex-wrap: wrap; } + .input-with-icon { width: 100%; } + .toolbar select { flex: 1; min-width: 150px; } + .toolbar-count { margin-left: 0; } + .table-wrap { padding: 0 12px 12px; } + .owners-panel .table-wrap, .usage-panel .table-wrap { max-height: none; } + .owner-table, .usage-table, .detail-history-table { min-width: 0; table-layout: auto; } + .owner-workspace-view .usage-table th, + .owner-workspace-view .usage-table td { width: 100%; } + .usage-table th:nth-child(2), .usage-table td:nth-child(2) { width: 100%; } + .usage-table .history-room-type, + .detail-history-table .history-room-type { + width: 68%; + max-width: 68%; + flex: 0 0 68%; + overflow-wrap: break-word; + } + table, tbody, tr, td { display: block; width: 100%; } + thead { display: none; } + tbody { display: grid; gap: 10px; } + tbody tr { position: relative; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: #fff; } + td { height: auto; min-height: 28px; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 5px 0; border: 0; white-space: normal; text-align: right; } + td::before { content: attr(data-label); color: var(--muted); font-size: 9px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; text-align: left; } + td.owner-cell { align-items: flex-start; } + td:last-child:not([data-label]) { position: absolute; top: 7px; right: 7px; width: 32px; } + .empty-table-row { padding: 0; } + .empty-table-row td { display: block; padding: 0; text-align: center; } + .empty-table-row td::before { display: none; } + .usage-table td:nth-child(7) { text-align: right; } + .align-right { text-align: right; } + .drawer { width: 100vw; } + .drawer-header { padding: 20px 18px 16px; } + .drawer-body { padding: 18px 18px 26px; } + .drawer-footer { padding: 13px 18px; } + .stay-drawer .drawer-header { padding: 20px 18px 16px; } + .stay-drawer .drawer-footer { padding: 13px 18px; } + .form-grid { grid-template-columns: 1fr; } + .account-context { grid-template-columns: repeat(2,1fr); } + .detail-balance { grid-template-columns: 1fr; } + .detail-summary-grid { border-top: 1px solid rgba(255,255,255,.13); border-left: 0; } + .detail-grid { grid-template-columns: repeat(2, 1fr); } + .detail-history-wrap { overflow: visible; border: 0; } + .balance-line { align-items: flex-start; } + .toast { left: 14px; right: 14px; bottom: 14px; min-width: 0; } +} + +@media (max-width: 480px) { + .login-header, .login-layout, .login-footer { width: min(100% - 28px, 440px); } + .login-header { min-height: 74px; } + .login-brand small { display: none; } + .login-language-option { min-width: 48px; padding: 0 8px; } + .login-layout { padding-top: 28px; } + .login-intro { margin-bottom: 28px; } + .login-intro h1 { font-size: 31px; } + .login-intro p { display: none; } + .login-card { padding: 26px 22px; border-radius: 15px; } + .login-card-heading h2 { font-size: 25px; } + .login-footer { min-height: 58px; } + .login-footer span:last-child { display: none; } +} + +@media (max-width: 410px) { + .metric-grid { grid-template-columns: 1fr; } + .metric-grid > :last-child:nth-child(odd) { grid-column: auto; } + .metric-card { min-height: 118px; } + .room-mix-legend { grid-template-columns: 1fr; gap: 9px; } + .room-mix-series { grid-template-columns: 7px minmax(0,1fr) 62px; gap: 7px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } +} diff --git a/tests/api-client.test.mjs b/tests/api-client.test.mjs new file mode 100644 index 0000000..c898e2c --- /dev/null +++ b/tests/api-client.test.mjs @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +const source = await readFile(new URL("../api-client.js", import.meta.url), "utf8"); + +function jsonResponse(payload, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + async json() { + return payload; + } + }; +} + +function loadClient({ search = "", config = {}, fetchImpl = async () => jsonResponse({}) } = {}) { + const windowObject = { + location: { search, origin: "http://127.0.0.1:4173" }, + CONDO_RUNTIME_CONFIG: { + defaultMode: "demo", + apiBaseUrl: "http://127.0.0.1:3000", + periodYear: 2026, + ...config + }, + fetch: fetchImpl, + setTimeout, + clearTimeout + }; + vm.runInNewContext(source, { + window: windowObject, + URL, + URLSearchParams, + AbortController, + Error, + Object, + Math, + Number, + String, + JSON + }); + return windowObject.CONDO_API; +} + +test("runtime requires an explicit api query mode", () => { + assert.equal(loadClient().runtime.mode, "demo"); + assert.equal(loadClient({ search: "?mode=api" }).runtime.mode, "api"); + assert.equal(loadClient({ search: "?mode=unexpected" }).runtime.mode, "demo"); +}); + +test("owner pagination collects every API page", async () => { + const requestedPages = []; + const api = loadClient({ + search: "?mode=api", + fetchImpl: async url => { + const page = Number(url.searchParams.get("page")); + requestedPages.push(page); + const itemCount = page === 1 ? 100 : 90; + return jsonResponse({ + items: Array.from({ length: itemCount }, (_, index) => ({ id: `${page}-${index}` })), + total: 190, + page, + pageSize: 100, + periodYear: 2026 + }); + } + }); + + const result = await api.client.listAllOwnerAccounts(); + assert.deepEqual(requestedPages, [1, 2]); + assert.equal(result.total, 190); + assert.equal(result.items.length, 190); +}); + +test("API failures expose stable status and code", async () => { + const api = loadClient({ + search: "?mode=api", + fetchImpl: async () => jsonResponse({ + error: { code: "INSUFFICIENT_BALANCE", message: "Insufficient balance" } + }, 422) + }); + + await assert.rejects( + api.client.getDashboard(), + error => error.name === "CondoApiError" + && error.status === 422 + && error.code === "INSUFFICIENT_BALANCE" + ); +}); + +test("authentication uses credentialed requests and the exact login contract", async () => { + const requests = []; + const api = loadClient({ + fetchImpl: async (url, options) => { + requests.push({ url, options }); + if (url.pathname === "/auth/session") { + return jsonResponse({ authenticated: false }); + } + if (url.pathname === "/auth/login") { + return jsonResponse({ + authenticated: true, + user: { username: "wyndhamcondon" }, + expiresAt: "2026-08-02T12:00:00.000Z" + }); + } + return jsonResponse({ authenticated: false }); + } + }); + + await api.client.getSession(); + await api.client.login({ + username: "wyndhamcondon", + password: "wyndhamcondon" + }); + await api.client.logout(); + + assert.deepEqual(requests.map(request => request.url.pathname), [ + "/auth/session", + "/auth/login", + "/auth/logout" + ]); + requests.forEach(request => assert.equal(request.options.credentials, "include")); + assert.equal(requests[0].options.method, "GET"); + assert.equal(requests[1].options.method, "POST"); + assert.deepEqual(JSON.parse(requests[1].options.body), { + username: "wyndhamcondon", + password: "wyndhamcondon" + }); + assert.equal(requests[2].options.method, "POST"); + assert.equal(requests[2].options.body, undefined); +}); + +test("usage creation sends only the supplied API contract", async () => { + let captured; + const api = loadClient({ + search: "?mode=api", + fetchImpl: async (url, options) => { + captured = { url, options }; + return jsonResponse({ id: "record-id" }, 201); + } + }); + const input = { + ownerAccountId: "00000000-0000-4000-8000-000000000001", + confirmationNo: "26090001", + checkIn: "2026-09-01", + checkOut: "2026-09-04", + usedRoomType: "SU1", + manualMultiplier: null, + remark: "", + idempotencyKey: "00000000-0000-4000-8000-000000000002" + }; + + await api.client.createUsageRecord(input); + assert.equal(captured.url.pathname, "/usage-records"); + assert.equal(captured.options.method, "POST"); + assert.equal(captured.options.credentials, "include"); + assert.deepEqual(JSON.parse(captured.options.body), input); + assert.equal("night" in JSON.parse(captured.options.body), false); + assert.equal("use" in JSON.parse(captured.options.body), false); + assert.equal("balance" in JSON.parse(captured.options.body), false); +}); diff --git a/tests/i18n.test.mjs b/tests/i18n.test.mjs new file mode 100644 index 0000000..ea0812f --- /dev/null +++ b/tests/i18n.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +const appSource = await readFile(new URL("../app.js", import.meta.url), "utf8"); +const htmlSource = await readFile(new URL("../index.html", import.meta.url), "utf8"); +const cssSource = await readFile(new URL("../styles.css", import.meta.url), "utf8"); + +function loadDictionaries() { + const start = appSource.indexOf("const I18N = "); + const end = appSource.indexOf("\n\nconst $ =", start); + assert.notEqual(start, -1, "I18N block should exist"); + assert.notEqual(end, -1, "I18N block should have a stable end marker"); + const expression = appSource.slice(start + "const I18N = ".length, end).trim().replace(/;$/, ""); + return vm.runInNewContext(`(${expression})`, { + formatNumber: value => String(value) + }); +} + +const dictionaries = loadDictionaries(); +const locales = Object.keys(dictionaries); + +function placeholderNames(value) { + if (typeof value === "function") { + const match = value.toString().match(/\(\s*\{([^}]*)\}\s*\)/); + return match + ? match[1].split(",").map(name => name.trim().split(/\s*=/)[0]).filter(Boolean).sort() + : []; + } + return [...String(value).matchAll(/\{(\w+)\}/g)].map(match => match[1]).sort(); +} + +test("all three locale dictionaries have identical keys", () => { + assert.deepEqual(locales, ["en", "zh", "th"]); + const expected = Object.keys(dictionaries.en).sort(); + assert.equal(expected.length, 195); + for (const locale of locales) { + assert.deepEqual(Object.keys(dictionaries[locale]).sort(), expected, `${locale} key parity`); + } +}); + +test("parameter placeholders stay aligned across locales", () => { + for (const key of Object.keys(dictionaries.en)) { + const expected = placeholderNames(dictionaries.en[key]); + for (const locale of locales.slice(1)) { + assert.deepEqual(placeholderNames(dictionaries[locale][key]), expected, `${locale}.${key} placeholders`); + } + } +}); + +test("all literal translation references resolve in every locale", () => { + const referenced = new Set(); + for (const match of appSource.matchAll(/\bt\(\s*["']([^"']+)["']/g)) referenced.add(match[1]); + for (const match of htmlSource.matchAll(/data-i18n(?:-placeholder|-aria-label)?="([^"]+)"/g)) referenced.add(match[1]); + for (const key of referenced) { + for (const locale of locales) assert.ok(key in dictionaries[locale], `${locale}.${key} is defined`); + } + assert.ok(referenced.size >= 100, `expected broad translation coverage, got ${referenced.size}`); +}); + +test("Thai locale metadata and three-way controls are present", () => { + assert.match(appSource, /th:\s*Object\.freeze\(\{ html: "th", intl: "th-TH-u-ca-gregory-nu-latn"/); + assert.equal((htmlSource.match(/data-login-language=/g) || []).length, 3); + assert.equal((htmlSource.match(/data-language=/g) || []).length, 3); + assert.match(cssSource, /grid-template-rows: repeat\(3, 1fr\)/); + assert.match(cssSource, /grid-template-columns: repeat\(3, 1fr\)/); + assert.match(cssSource, /\[data-locale="th"\][^{]*\{ transform: translateY\(200%\); \}/); + assert.match(cssSource, /\[data-locale="th"\][^{]*\{ transform: translateX\(200%\); \}/); +}); + +test("Thai messages do not fall back for representative runtime states", () => { + const keys = [ + "signIn", "invalidCredentials", "sessionExpired", "runtimeApiLoadingMessage", "accountsShown", + "recordsShown", "checkoutLater", "insufficientPrivileges", "usageSaveNetwork", "usageSavedMessage" + ]; + for (const key of keys) { + assert.notEqual(dictionaries.th[key], undefined, `Thai key ${key}`); + if (typeof dictionaries.th[key] === "string") assert.notEqual(dictionaries.th[key], dictionaries.en[key], `Thai copy for ${key}`); + } +}); diff --git a/tests/mock-api-server.mjs b/tests/mock-api-server.mjs new file mode 100644 index 0000000..be74143 --- /dev/null +++ b/tests/mock-api-server.mjs @@ -0,0 +1,298 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import http from "node:http"; + +const emptyScenario = process.argv.includes("--empty"); +const roomTypes = [ + ["RM1", 1], ["RM2", 1], ["RM3", 1], ["RM4", 1], ["UG1", 1], ["UG2", 1], + ["SU1", 2], ["SU2", 2], ["SU6", 2], ["SU3", 3], ["AC2", null] +].map(([code, entitlementTier]) => ({ + code, + entitlementTier, + requiresManualMultiplier: entitlementTier === null +})); + +const owners = emptyScenario ? [] : [ + { + id: "00000000-0000-4000-8000-000000000101", + accountNo: 101, + transferDate: "2024-01-08", + name: "API Test Owner", + roomNo: "A101", + purchasedRoomType: "RM1", + unitNo: "UNIT-A101", + memberNo: "M101", + remainingStayPrivileges: 13 + }, + { + id: "00000000-0000-4000-8000-000000000102", + accountNo: null, + transferDate: null, + name: "Nullable API Owner", + roomNo: "A102", + purchasedRoomType: "AC2", + unitNo: "UNIT-A102", + memberNo: "M102", + remainingStayPrivileges: 15 + } +]; + +let usageRecords = emptyScenario ? [] : [ + { + id: "00000000-0000-4000-8000-000000000201", + confirmationNo: "26070001", + ownerAccountId: owners[0].id, + ownerName: owners[0].name, + ownerRoomNo: owners[0].roomNo, + checkIn: "2026-07-10", + checkOut: "2026-07-12", + night: 2, + use: 2, + balance: 13, + usedRoomType: "RM1", + remark: "Mock API record", + appliedMultiplier: 1, + createdAt: "2026-07-12T08:00:00.000Z" + } +]; +const idempotentResponses = new Map(); +const sessions = new Map(); +const sessionTtlMs = 12 * 60 * 60 * 1_000; +const authUsername = "wyndhamcondon"; +const authPassword = "wyndhamcondon"; + +function send(response, status, payload, headers = {}) { + response.writeHead(status, { + "Access-Control-Allow-Origin": "http://127.0.0.1:4173", + "Access-Control-Allow-Credentials": "true", + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + ...headers + }); + response.end(JSON.stringify(payload)); +} + +function sessionToken(request) { + const cookie = request.headers.cookie || ""; + for (const part of cookie.split(";")) { + const [name, value] = part.trim().split("=", 2); + if (name === "condon_session" && value) return value; + } + return null; +} + +function activeSession(request) { + const token = sessionToken(request); + if (!token) return null; + const session = sessions.get(token); + if (!session) return null; + if (session.expiresAtMs <= Date.now()) { + sessions.delete(token); + return null; + } + return session; +} + +function paginated(items, url) { + const page = Math.max(1, Number(url.searchParams.get("page")) || 1); + const pageSize = Math.min(100, Math.max(1, Number(url.searchParams.get("pageSize")) || 25)); + const start = (page - 1) * pageSize; + return { items: items.slice(start, start + pageSize), total: items.length, page, pageSize }; +} + +function tier(code) { + return roomTypes.find(roomType => roomType.code === code)?.entitlementTier ?? null; +} + +function dashboard() { + const purchasedCounts = new Map(); + const usedCounts = new Map(); + const monthlyCounts = new Map(); + owners.forEach(owner => purchasedCounts.set(owner.purchasedRoomType, (purchasedCounts.get(owner.purchasedRoomType) || 0) + 1)); + usageRecords.forEach(record => { + usedCounts.set(record.usedRoomType, (usedCounts.get(record.usedRoomType) || 0) + record.night); + const month = Number(record.checkIn.slice(5, 7)); + monthlyCounts.set(month, (monthlyCounts.get(month) || 0) + record.use); + }); + return { + periodYear: 2026, + ownerRooms: owners.length, + remainingPrivileges: owners.reduce((total, owner) => total + (owner.remainingStayPrivileges || 0), 0), + used: usageRecords.reduce((total, record) => total + record.use, 0), + purchasedRoomTypes: [...purchasedCounts].map(([roomType, count]) => ({ roomType, count })), + usedRoomTypes: [...usedCounts].map(([roomType, count]) => ({ roomType, count })), + monthlyUse: [...monthlyCounts].map(([month, use]) => ({ month, use })) + }; +} + +async function readJson(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +const server = http.createServer(async (request, response) => { + if (request.method === "OPTIONS") { + response.writeHead(204, { + "Access-Control-Allow-Origin": "http://127.0.0.1:4173", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Methods": "GET,POST,OPTIONS" + }); + response.end(); + return; + } + + const url = new URL(request.url || "/", "http://127.0.0.1:3000"); + if (request.method === "GET" && url.pathname === "/auth/session") { + const session = activeSession(request); + send(response, 200, session ? { + authenticated: true, + user: { username: session.username }, + expiresAt: new Date(session.expiresAtMs).toISOString() + } : { authenticated: false }); + return; + } + if (request.method === "POST" && url.pathname === "/auth/login") { + try { + const input = await readJson(request); + if (input.username !== authUsername || input.password !== authPassword) { + send(response, 401, { error: { code: "INVALID_CREDENTIALS", message: "Invalid username or password" } }); + return; + } + const token = randomBytes(32).toString("base64url"); + const expiresAtMs = Date.now() + sessionTtlMs; + sessions.set(token, { username: authUsername, expiresAtMs }); + send(response, 200, { + authenticated: true, + user: { username: authUsername }, + expiresAt: new Date(expiresAtMs).toISOString() + }, { + "Set-Cookie": `condon_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200` + }); + } catch { + send(response, 400, { error: { code: "VALIDATION_ERROR", message: "Invalid JSON" } }); + } + return; + } + if (request.method === "POST" && url.pathname === "/auth/logout") { + const token = sessionToken(request); + if (token) sessions.delete(token); + send(response, 200, { authenticated: false }, { + "Set-Cookie": "condon_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0" + }); + return; + } + + if (!activeSession(request)) { + send(response, 401, { error: { code: "UNAUTHORIZED", message: "Authentication required" } }); + return; + } + + if (request.method === "GET" && url.pathname === "/health") { + send(response, 200, { status: "ok", database: "booking_test", schema: "condon", migrationVersion: "001" }); + return; + } + if (request.method === "GET" && url.pathname === "/room-types") { + send(response, 200, roomTypes); + return; + } + if (request.method === "GET" && url.pathname === "/owner-accounts") { + const q = (url.searchParams.get("q") || "").toLowerCase(); + const roomType = url.searchParams.get("roomType") || ""; + const filtered = owners.filter(owner => { + const searchable = `${owner.accountNo ?? ""} ${owner.name} ${owner.roomNo} ${owner.unitNo} ${owner.memberNo}`.toLowerCase(); + return (!q || searchable.includes(q)) && (!roomType || owner.purchasedRoomType === roomType); + }); + send(response, 200, { ...paginated(filtered, url), periodYear: 2026 }); + return; + } + if (request.method === "GET" && url.pathname.startsWith("/owner-accounts/")) { + const owner = owners.find(item => item.id === decodeURIComponent(url.pathname.slice("/owner-accounts/".length))); + send(response, owner ? 200 : 404, owner || { error: { code: "NOT_FOUND", message: "Owner account not found" } }); + return; + } + if (request.method === "GET" && url.pathname === "/usage-records") { + const ownerAccountId = url.searchParams.get("ownerAccountId") || ""; + const confirmationNo = url.searchParams.get("confirmationNo") || ""; + const usedRoomType = url.searchParams.get("usedRoomType") || ""; + const filtered = usageRecords.filter(record => ( + (!ownerAccountId || record.ownerAccountId === ownerAccountId) + && (!confirmationNo || record.confirmationNo.includes(confirmationNo)) + && (!usedRoomType || record.usedRoomType === usedRoomType) + )); + send(response, 200, paginated(filtered, url)); + return; + } + if (request.method === "GET" && url.pathname === "/dashboard") { + send(response, 200, dashboard()); + return; + } + if (request.method === "POST" && url.pathname === "/usage-records") { + try { + const input = await readJson(request); + if (idempotentResponses.has(input.idempotencyKey)) { + send(response, 201, idempotentResponses.get(input.idempotencyKey)); + return; + } + const owner = owners.find(item => item.id === input.ownerAccountId); + if (!owner) { + send(response, 404, { error: { code: "NOT_FOUND", message: "Owner account not found" } }); + return; + } + if (usageRecords.some(record => record.confirmationNo === input.confirmationNo)) { + send(response, 409, { error: { code: "CONFLICT", message: "Confirmation already exists" } }); + return; + } + const start = Date.parse(`${input.checkIn}T00:00:00Z`); + const end = Date.parse(`${input.checkOut}T00:00:00Z`); + const night = Math.round((end - start) / 86_400_000); + const purchasedTier = tier(owner.purchasedRoomType); + const usedTier = tier(input.usedRoomType); + const multiplier = purchasedTier === null || usedTier === null + ? Number(input.manualMultiplier) + : Math.max(1, usedTier - purchasedTier + 1); + const use = night * multiplier; + if (!Number.isInteger(night) || night <= 0 || !Number.isInteger(multiplier) || multiplier < 1 || multiplier > 3) { + send(response, 400, { error: { code: "BUSINESS_RULE_VIOLATION", message: "Invalid usage rule" } }); + return; + } + if (use > owner.remainingStayPrivileges) { + send(response, 422, { error: { code: "INSUFFICIENT_BALANCE", message: "Insufficient balance" } }); + return; + } + owner.remainingStayPrivileges -= use; + const created = { + id: randomUUID(), + confirmationNo: input.confirmationNo, + ownerAccountId: owner.id, + ownerName: owner.name, + ownerRoomNo: owner.roomNo, + checkIn: input.checkIn, + checkOut: input.checkOut, + night, + use, + balance: owner.remainingStayPrivileges, + usedRoomType: input.usedRoomType, + remark: input.remark || "", + appliedMultiplier: multiplier, + createdAt: new Date().toISOString() + }; + usageRecords = [created, ...usageRecords]; + idempotentResponses.set(input.idempotencyKey, created); + send(response, 201, created); + } catch { + send(response, 400, { error: { code: "VALIDATION_ERROR", message: "Invalid JSON" } }); + } + return; + } + + send(response, 404, { error: { code: "NOT_FOUND", message: "Route not found" } }); +}); + +server.listen(3000, "127.0.0.1", () => { + process.stdout.write(`MOCK_API_READY http://127.0.0.1:3000 scenario=${emptyScenario ? "empty" : "sample"}\n`); +}); + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.once(signal, () => server.close(() => process.exit(0))); +} diff --git a/业主账户.md b/业主账户.md new file mode 100644 index 0000000..e91cb94 --- /dev/null +++ b/业主账户.md @@ -0,0 +1,394 @@ +# 业主账户 + +共 388 条记录,来源于 `Total2024-2026`。 + +| Name | Room No. | Room Type | Unit No. | Member No. | +| --- | --- | --- | --- | --- | +| MS.Onuma Nakjakha | 3210 | RM3 | 555/335 | 88001 | +| Ms.Thamolwan Wu | 4320 | SU6 | 555/540 | 88002 | +| MR.ANDREY ZHURAVLEV | 4219 | SU1 | 555/513 | 88003 | +| MR.JUMRUS SENGHOO | 3503 | SU6 | 555/406 | 88004 | +| Mr.SHENGYUAN ZHU | 3307 | RM3 | 555/358 | 88005 | +| MR.THANAPON KANOKKARNJANANON | 3308 | RM3 | 555/359 | 88006 | +| MRS.VIPAPORN KANOKKANJANANON | 3309 | RM3 | 555/360 | 88007 | +| MS.POOWANEE JONGJAISOO | 3521 | UG2 | 555/424 | 88009 | +| Mr.MARK KEVIN OLSON | 4303 | SU2 | 555/523 | 88008 | +| Mrs.PING YU | 4404 | SU1 | 555/550 | 88010 | +| MR.CHENXUAN WANG | 4304 | SU1 | 555/524 | 88011 | +| MR.KANTHARAT SAKRUNGRUANG | 3510 | SU3 | 555/413 | 88016 | +| MR.KANTHARAT SAKRUNGRUANG | 3511 | RM3 | 555/414 | 88017 | +| MS.DUANGJAI BUTTAYOTEE | 3616 | RM3 | 555/445 | 88013 | +| MR.RASHED MOHAMMAD | 3621 | UG2 | 555/450 | 88014 | +| MR.Kitipong Tarathamathighon | 4305 | RM3 | 555/525 | 88012 | +| MS.PORNTHIP SALANOK | 4408 | RM3 | 555/554 | 88015 | +| Mr.NICHOLAS ALEXANDER FRANCIS FLEGEL | 3612 | RM4 | 555/441 | 88018 | +| Mr.NICHOLAS ALEXANDER FRANCIS FLEGEL | 3613 | RM3 | 555/442 | 88019 | +| Mr.XINZHI LI & Mrs.LING JIN | 4218 | RM3 | 555/512 | 88020 | +| MR.GEORGE PATSANTZOPOULOS | 3402 | UG2 | 555/379 | 88021 | +| Ms.WANG YING | 3310 | SU3 | 555/361 | 88022 | +| Ms.WANG YING | 3311 | RM3 | 555/362 | 88023 | +| Ms.WANG YING | 3317 | RM3 | 555/368 | 88024 | +| Ms.WANG YING | 3318 | RM3 | 555/369 | 88025 | +| Ms.WANG YING | 3412 | RM3 | 555/389 | 88026 | +| Ms.WANG YING | 3413 | RM3 | 555/390 | 88027 | +| Ms.WANG YING | 3414 | RM3 | 555/391 | 88028 | +| Ms.WANG YING | 3415 | RM3 | 555/392 | 88029 | +| MR.ZHU XINGLIN | 3205 | RM3 | 555/330 | 88030 | +| MR.ZHU XINGLIN | 3206 | RM3 | 555/331 | 88031 | +| MR.ZHU XINGLIN | 3207 | RM3 | 555/332 | 88032 | +| MR.ZHU XINGLIN | 3208 | RM3 | 555/333 | 88033 | +| MR.ZHU XINGLIN | 3209 | RM3 | 555/334 | 88034 | +| MR.DING QI | 4205 | RM3 | 555/499 | 88035 | +| MR.DING QI | 4206 | RM3 | 555/500 | 88036 | +| MR.DING QI | 4207 | RM3 | 555/501 | 88037 | +| MR.DING QI | 4208 | RM3 | 555/502 | 88038 | +| MR.DING QI | 4209 | RM3 | 555/503 | 88039 | +| MR.JACKY SAECHEN | 4510 | SU3 | 555/582 | 88040 | +| MR.JACKY SAECHEN | 4511 | RM3 | 555/583 | 88041 | +| MS.BUNYAPORN PHATCHINTE | 3104 | RM1 | 555/310 | 88045 | +| MRS.HE YAFEI | 3216 | RM3 | 555/341 | 88042 | +| MRS.HE YAFEI | 3217 | RM3 | 555/342 | 88043 | +| MR.CAI YUMING | 4217 | RM3 | 555/511 | 88044 | +| MRS.YUE MA | 3512 | RM3 | 555/415 | 88046 | +| MRS.YUE MA | 3514 | RM3 | 555/417 | 88047 | +| MRS.YUE MA | 3515 | RM3 | 555/418 | 88048 | +| MRS.CHENGFANG GU | 3516 | RM3 | 555/419 | 88053 | +| MRS.CHENGFANG GU | 3523 | UG2 | 555/426 | 88054 | +| MRS.YUE MA | 4223 | UG2 | 555/517 | 88049 | +| MRS.YUE MA | 4603 | SU2 | 555/601 | 88050 | +| MRS.YUE MA | 4610 | SU3 | 555/608 | 88051 | +| MRS.YUE MA | 4611 | RM3 | 555/609 | 88052 | +| MRS.HOU HAIXIA | 4307 | RM3 | 555/527 | 88055 | +| MRS.LIU TING | 3116 | RM3 | 555/322 | 88056 | +| MRS.Nataya Roorukdee | 4503 | SU2 | 555/575 | 88057 | +| MR.KLAUS MARTIN KNIRSCH | 3623 | UG2 | 555/452 | 88058 | +| MS.USA PUNBOONMEE | 4105 | RM1 | 555/488 | 88059 | +| MS.ZHANG XIUYUN | 3108 | RM3 | 555/314 | 88069 | +| MR.SHILIANG LU | 3214 | RM3 | 555/339 | 88060 | +| MR.SHILIANG LU | 3215 | RM3 | 555/340 | 88061 | +| MRS.HNIN NI LAR KHIN | 3321 | UG2 | 555/372 | 88062 | +| MR.WIN NAING | 3423 | UG2 | 555/400 | 88064 | +| MR.WIN NAING | 3620 | SU6 | 555/449 | 88063 | +| MS.JINGPING LIU | 4315 | RM3 | 555/535 | 88065 | +| Mr.Yang Fengmin (คนเก่าชื่อ MS.JINGPING LIU) | 4316 | RM3 | 555/536 | 88066 | +| MS.YANNI ZHENG | 4317 | RM3 | 555/537 | 88067 | +| MS.YANNI ZHENG | 4318 | RM3 | 555/538 | 88068 | +| MR.THANAPON KANOKKARNJANANON | 3304 | SU1 | 555/355 | 88070 | +| MR.SIRICHAI PAIPAE | 3312 | RM3 | 555/363 | 88071 | +| MR.RUAN JIANCONG | 3314 | RM3 | 555/365 | 88072 | +| MR.RUAN JIAJU | 3315 | RM3 | 555/366 | 88073 | +| MR.YANG AILIANG | 3517 | RM3 | 555/420 | 88075 | +| MRS. PRANEE ZANDBERGEN | 4220 | SU6 | 555/514 | 88076 | +| MR.HAN NING | 4325 | UG2 | 555/545 | 88074 | +| MRS.VASANA HANVIVATTANAKUL MR.KAMONCHAI HANVIVATTAKUL | 4602 | UG2 | 555/600 | 88077 | +| Mr.HUI HUANG | 3403 | SU6 | 555/380 | 88078 | +| Mr.HUI HUANG | 3404 | SU1 | 555/381 | 88079 | +| MR.NATTAPONG CHANMATIKORNKUL | 4321 | UG2 | 555/541 | 88080 | +| MS.NATTHIDA CHANMATIKORNKUL | 4322 | UG2 | 555/542 | 88081 | +| MRS.CHANYA YOSTHAISONG | 3302 | UG2 | 555/353 | 88082 | +| MR.TOSHIHIKO WAKUI | 3306 | RM3 | 555/357 | 88083 | +| Mr.JINGHUA LIU | 4212 | RM3 | 555/506 | 88084 | +| MRS.LIU YI | 4213 | RM3 | 555/507 | 88089 | +| MR.HTAN WIN | 4710 | SU3 | 555/634 | 88085 | +| MR.HTAN WIN | 4711 | RM3 | 555/635 | 88086 | +| MR.HTAN WIN | 4720 | SU6 | 555/644 | 88087 | +| MR.HTAN WIN | 4721 | UG2 | 555/645 | 88088 | +| MR.TORU HERAI | 3212 | RM3 | 555/337 | 88092 | +| MR.QIAO JIALEI | 4714 | RM3 | 555/638 | 88090 | +| MS.WANG SHUANG | 4724 | UG2 | 555/648 | 88091 | +| MR.CHEW SWEE LIM | 3322 | UG2 | 555/373 | 88094 | +| MR.Satit Termprayoon | 4526 | UG2 | 555/598 | 88093 | +| MRS.WU CHUNHUI | 3218 | RM3 | 555/343 | 88096 | +| MS.WENHONG LIU | 3611 | RM3 | 555/440 | 88095 | +| MR.EDWIN LIEP KOO และ MRS.INDRIANI KOO | 3221 | UG2 | 555/346 | 88101 | +| Mrs.SHIZUKA SEKINE , Mr.SHINYA JACK KATO | 3421 | UG2 | 555/398 | 88102 | +| MR.SWAPNIL RAMAKANT DHOTE | 3518 | RM3 | 555/421 | 88100 | +| Ms.Tawapansorn Tangjareaukitsakul | 4308 | RM3 | 555/528 | 88097 | +| Ms.Tawapansorn Tangjareaukitsakul | 4309 | RM3 | 555/529 | 88098 | +| Mr.Laifu Saewang , Ms.Siriporn Pongkitjarat | 4502 | UG2 | 555/574 | 88099 | +| MR.NATEE PHASUTARNCHART | 3316 | RM3 | 555/367 | 88105 | +| MR.ANAN SANG-IN | 4103 | RM1 | 555/486 | 88104 | +| MS.PORNSAWAN SIRISOMBAT | 4405 | RM3 | 555/551 | 88103 | +| MR.BEIER LIU | 3222 | UG2 | 555/347 | 88106 | +| MR.BEIER LIU | 3223 | UG2 | 555/348 | 88107 | +| MRS.VALENTINA GALIBUZOVA | 3202 | UG2 | 555/327 | 88108 | +| MRS.VALENTINA GALIBUZOVA | 4314 | RM3 | 555/534 | 88109 | +| MRS.HAO BOLY | 4413 | RM3 | 555/559 | 88110 | +| MRS.HAO BOLY | 4415 | RM3 | 555/561 | 88111 | +| MRS.HAO BOLY | 4416 | RM3 | 555/562 | 88112 | +| MRS.HAO BOLY | 4417 | RM3 | 555/563 | 88113 | +| MRS.HAO BOLY | 4418 | RM3 | 555/564 | 88114 | +| MR.UMNUEY PHASUKSATHIAN | 4501 | UG2 | 555/573 | 88115 | +| MR.SHIYONG WANG | 3107 | RM3 | 555/313 | 88116 | +| MR.NENGXIANG WANG | 3201 | UG2 | 555/326 | 88120 | +| MR.NENGXIANG WANG | 3325 | UG2 | 555/376 | 88121 | +| MR.NENGXIANG WANG | 3326 | UG2 | 555/377 | 88122 | +| MR.NENGXIANG WANG | 4301 | UG2 | 555/521 | 88119 | +| MS.MINHUA CHEN | 4310 | SU3 | 555/530 | 88117 | +| MS.MINHUA CHEN | 4311 | RM3 | 555/531 | 88118 | +| MR.ZIBING YANG | 3102 | RM3 | 555/308 | 88123 | +| MS.QIU PING | 3111 | RM3 | 555/317 | 88141 | +| MR.ZIBING YANG | 3224 | UG2 | 555/349 | 88133 | +| MR.ZIBING YANG | 3225 | UG2 | 555/350 | 88134 | +| MR.ZIBING YANG | 3524 | UG2 | 555/427 | 88135 | +| MR.ZIBING YANG | 3525 | UG2 | 555/428 | 88136 | +| MR.ZIBING YANG | 3526 | UG2 | 555/429 | 88137 | +| MR.ZIBING YANG | 3624 | UG2 | 555/453 | 88138 | +| MR.ZIBING YANG | 3625 | UG2 | 555/454 | 88139 | +| MR.ZIBING YANG | 3626 | UG2 | 555/455 | 88140 | +| MR.ZIBING YANG | 4102 | RM3 | 555/485 | 88124 | +| MR.ZIBING YANG | 4402 | UG2 | 555/548 | 88125 | +| MR.ZIBING YANG | 4420 | SU6 | 555/566 | 88126 | +| MR.ZIBING YANG | 4421 | UG2 | 555/567 | 88127 | +| MR.ZIBING YANG | 4422 | UG2 | 555/568 | 88128 | +| MR.ZIBING YANG | 4423 | UG2 | 555/569 | 88129 | +| MR.ZIBING YANG | 4424 | UG2 | 555/570 | 88130 | +| MR.ZIBING YANG | 4425 | UG2 | 555/571 | 88131 | +| MR.ZIBING YANG | 4426 | UG2 | 555/572 | 88132 | +| MR.MICHAEL LING DONG | 3211 | RM3 | 555/336 | 88142 | +| MS.ZAN LIPING | 4407 | RM3 | 555/553 | 88143 | +| MS.ZAN LIPING | 4409 | RM3 | 555/555 | 88144 | +| MS.CAO TING | 4512 | RM3 | 555/584 | 88145 | +| MS.CAO TING | 4513 | RM3 | 555/585 | 88146 | +| MS.CAO TING | 4514 | RM3 | 555/586 | 88147 | +| MS.CAO TING | 4515 | RM3 | 555/587 | 88148 | +| MS.CAO TING | 4516 | RM3 | 555/588 | 88149 | +| MS.CAO TING | 4517 | RM3 | 555/589 | 88150 | +| MR.GUANG ZHENG | 3110 | RM3 | 555/316 | 88151 | +| MRS.LIN YAN | 3416 | RM3 | 555/393 | 88153 | +| MR.CHUNFA WANG | 3614 | RM3 | 555/443 | 88154 | +| MR.GUO JIPING | 4302 | UG2 | 555/522 | 88152 | +| MR.LAI CHEN and MRS.JEAN CHENG | 4518 | RM3 | 555/590 | 88155 | +| MR.LAI CHEN and MRS.JEAN CHENG | 4519 | SU1 | 555/591 | 88156 | +| MS.Yol-a-pichaya Chereskin | 4109 | RM3 | 555/492 | 88157 | +| MR.SHIN MYAT AUNG | 3502 | RM3 | 555/405 | 88158 | +| MR.ITTIDECH NATEENANTASAWASD | 3103 | RM1 | 555/309 | 88159 | +| MS.CHANGJUAN PU | 3324 | UG2 | 555/375 | 88160 | +| MR.APHILAKSIRI THANASIRIPRASERT MS.CHAYANISA THANASIRIPRASERT | 3424 | UG2 | 555/401 | 88161 | +| MRSHIZHONG REN | 3602 | UG2 | 555/431 | 88162 | +| Ms.Panida Han | 4221 | UG2 | 555/515 | 88163 | +| Ms.Panida Han | 4222 | UG2 | 555/516 | 88164 | +| Ms.Patchara Santiwongwanich | 4306 | RM3 | 555/526 | 88165 | +| MS.BAI MEIPING | 3509 | RM3 | 555/412 | 88166 | +| MR.Pradit Peetanonchai | 4111 | RM3 | 555/494 | 88167 | +| MR.Ekapat Chodgawanich | 4414 | RM3 | 555/560 | 88168 | +| Ms.PATTAMAWADEE PHUAPHROMYOD Ms.ANUSARA PHUAPHROMYOD | 4214 | RM3 | 555/508 | 88169 | +| MS.BUPPA CHOMPOONUCH | 4523 | UG2 | 555/595 | 88172 | +| MS. APINPORN PORNCHAROENWIWAT | 4621 | UG2 | 555/619 | 88170 | +| MS. ACHIRAYA PORNCHAROENWIWAT | 4622 | UG2 | 555/620 | 88171 | +| MS.ZHU WENGE | 3407 | RM3 | 555/384 | 88173 | +| MS. NATTIDA SAIWONG | 3622 | UG2 | 555/451 | 88174 | +| MR.CHEN HSI-CHIN | 3506 | RM3 | 555/409 | 88175 | +| MR.Wuthiphong Dechatiwong Na Ayutthaya | 4108 | RM3 | 555/491 | 88179 | +| MR.RUILU HE | 4521 | UG2 | 555/593 | 88180 | +| MR.RUILU HE | 4522 | UG2 | 555/594 | 88181 | +| MS.Umaporn Pornjaroenwiwat | 4524 | UG2 | 555/596 | 88176 | +| MS.Juthamart Porncharoenwiwat | 4525 | UG2 | 555/597 | 88177 | +| MR.Jirasin Chen | 4625 | UG2 | 555/623 | 88178 | +| MR.NA THYE GUAN | 4507 | RM3 | 555/579 | 88249 | +| MR.LIN SEN | 4615 | RM3 | 555/613 | 88182 | +| MR.LIN SEN | 4616 | RM3 | 555/614 | 88183 | +| MR.LIN SEN | 4617 | RM3 | 555/615 | 88184 | +| MS.OLGA NETREBKO | 4715 | RM3 | 555/639 | 88185 | +| MR.Jaratpon Pongpaitoon | 3411 | RM3 | 555/388 | 88186 | +| MR.ALEXANDER BORTSOV | 4215 | RM3 | 555/509 | 88187 | +| MRS.ME ME KYAW (คนเก่าชื่อ MR.LU KYONE SHONE) | 4618 | RM3 | 555/616 | 88188 | +| MRS.ME ME KYAW (คนเก่าชื่อ MR.LU KYONE SHONE) | 4619 | SU1 | 555/617 | 88189 | +| MS. WU XIAOHONG | 4107 | RM3 | 555/490 | 88190 | +| MS. WU XIAOHONG | 4110 | RM3 | 555/493 | 88191 | +| MS.Manasawan Sarunvatchakul | 3204 | SU1 | 555/329 | 88192 | +| MS. SUPATTRA DUANGTIP | 4406 | RM3 | 555/552 | 88193 | +| PROUDY & PROGRESS DEVELOPMENT COMPANY LIMITED | 3219 | SU1 | 555/344 | 88194 | +| MR.TEA KOK KEONG | 3405 | RM3 | 555/382 | 88195 | +| MS.LOU HAIXIA | 3507 | RM3 | 555/410 | 88198 | +| MS.CHEN HEQING | 3513 | RM3 | 555/416 | 88199 | +| MRS.Nuttanun Linsen | 4203 | SU2 | 555/497 | 88196 | +| MRS.Nuttanun Linsen | 4725 | UG2 | 555/649 | 88197 | +| Mr.Kosit Prunglertbuathong | 3406 | RM3 | 555/383 | 88200 | +| Chailai Real Estate Co., Ltd. | 3410 | SU3 | 555/387 | 88201 | +| Ms.Natwara Pongtongcharoen | 3425 | UG2 | 555/402 | 88202 | +| MR.YANG TENGFANG | 4606 | RM3 | 555/604 | 88203 | +| MR.Uthai Tantrakul | 3501 | UG2 | 555/404 | 88204 | +| MR.Uthai Tantrakul | 4520 | SU6 | 555/592 | 88205 | +| MR. ANDREW JAMES WANNAN | 3522 | UG2 | 555/425 | 88208 | +| MS.PENG XINGYAN | 3615 | RM3 | 555/444 | 88207 | +| MRS.NATAPAT IRLBECK | 4509 | RM3 | 555/581 | 88206 | +| Mrs. Nattareeya Asvinvichit | 4201 | UG2 | 555/495 | 88209 | +| MR.Nattabut Chanmatikornkul | 4224 | UG2 | 555/518 | 88210 | +| MRS.Natchitakan Thavornsart | 4101 | RM3 | 555/484 | 88212 | +| MR.DIAN FU | 4226 | UG2 | 555/520 | 88211 | +| MR.JAMES GRAHAM HUBERT | 3401 | UG2 | 555/378 | 88213 | +| Miss Kamonluk Buntaem | 3105 | RM1 | 555/311 | 88214 | +| MR.Sathit Sirirat | 4403 | SU2 | 555/549 | 88215 | +| MR.Sathit Sirirat | 4701 | UG2 | 555/625 | 88216 | +| MR.PHILIP JAMES CRAMPTON MRS.SAMANTHA NATALIE CRAMPTON | 4313 | RM3 | 555/533 | 88217 | +| MR.Thanesvorn Siri-achawawath | 4506 | RM3 | 555/578 | 88218 | +| MR.Rohmad Reungprach MRS.Nusara Reungprach MR.Nasa-E Reungprach MRS.Auntika Reungprach MS.Asna Reungprach MS.Amna Reungprach MS.Ayna Reungprach | 4204 | SU1 | 555/498 | 88219 | +| MR.Natthapol Udomsorn | 3117 | RM3 | 555/323 | 88220 | +| MR.Nut Wuthiphongworachok | 3114 | RM3 | 555/320 | 88221 | +| MR.Puttirat Siriroughudomporn | 4620 | SU6 | 555/618 | 88222 | +| MR.Thanakorn Zhang | 4225 | UG2 | 555/519 | 88223 | +| Ms.Thitiwan Putprasert | 4104 | RM1 | 555/487 | 88224 | +| MR.Puttirat Siriroughudomporn | 3603 | SU6 | 555/432 | 88225 | +| Ms.Wang Hongmei | 3319 | SU1 | 555/370 | 88227 | +| Ms.Yao Naijia | 4412 | RM3 | 555/558 | 88226 | +| MR.YU YONG | 4623 | UG2 | 555/621 | 88228 | +| MR.YU YONG | 4624 | UG2 | 555/622 | 88229 | +| Ms.Difei Xie | 3118 | RM1 | 555/324 | 88232 | +| Mrs.Xia Yanyi | 3213 | RM3 | 555/338 | 88231 | +| MS.WENYAN XU | 3305 | RM3 | 555/356 | 88235 | +| Mrs.Fang Wei | 3617 | RM3 | 555/446 | 88233 | +| Mrs.Fang Wei | 3618 | RM3 | 555/447 | 88234 | +| Mrs.Zhang Yun | 4504 | SU1 | 555/576 | 88230 | +| MR. MAUNG SEIN TUN | 3226 | UG2 | 555/351 | 88236 | +| Ms.Apinya Supunnarach | 4705 | RM3 | 555/629 | 88237 | +| MR.SHUNTIAN XU | 3113 | RM3 | 555/319 | 88238 | +| Acting Sub Lt.Wichan Prachannuan | 3505 | RM3 | 555/408 | 88239 | +| MR.CHEN HAORUI | 3320 | SU6 | 555/371 | 88241 | +| Mrs.Nantawan Chayangsu | 4626 | UG2 | 555/624 | 88240 | +| MR.THANTHON THEERAJITHSUWAN | 4505 | RM3 | 555/577 | 88242 | +| MR.THANTHON THEERAJITHSUWAN | 1215 | RM3 | 555/19 | 88243 | +| MR.BENJAMIN PEI-JIE SHENG and MRS.HSIU LAN PENG | 1316 | RM2 | 555/44 | 88245 | +| Mr. Kriangsak Siriamornthep | 3301 | UG2 | 555/352 | 88244 | +| MR.WANG HONGYU | 1207 | RM3 | 555/11 | 88246 | +| MR.HAN RONGGUO | 1407 | RM2 | 555/59 | 88247 | +| MR.NAMGYAL D GHONGPA และ MRS.TSERING CHOEKYI | 4401 | UG2 | 555/547 | 88248 | +| MR.CHEN XIN | 1311 | RM3 | 555/39 | 88250 | +| MR.HE GUOQIANG | 1405 | SU6 | 555/57 | 88251 | +| MR.XU ZILIANG | 4508 | RM3 | 555/580 | 88252 | +| MR. APHICHAI SAEJEW | 1206 | RM3 | 555/10 | 88253 | +| MRS.WEN XUEQIN | 3220 | SU6 | 555/345 | 88254 | +| MR.PITIPHON LEEKUL | 4202 | UG2 | 555/496 | 88255 | +| MR.SUN YUAN | 3504 | SU1 | 555/407 | 88256 | +| MRS. LAKHANA CHAKKAPHAK | 3119 | RM1 | 555/325 | 88257 | +| MR.NAOYA TAKEUCHI | 3115 | RM3 | 555/321 | 88258 | +| MS.LIU CHUNXIA | 3601 | UG2 | 555/430 | 88260 | +| MR.CHENG XIANMING | 3426 | UG2 | 555/403 | 88259 | +| MS.HUANG YING | 3519 | SU1 | 555/422 | 88262 | +| MR.LI HAIBO | 3520 | SU6 | 555/423 | 88261 | +| MR.NARUETHEP KHANIJOH | 4323 | UG2 | 555/543 | 88263 | +| MR.YVES BUGMANN | 1211 | RM3 | 555/15 | 88264 | +| MS.ZHANG XU | 1419 | UG1 | 555/71 | 88265 | +| MRS.ZHENG LIJUN | 1420 | UG1 | 555/72 | 88266 | +| MRS.HUANG KAIKAI | 1307 | RM2 | 555/35 | 88267 | +| MS.JUAN WU , MR.XIUFENG WEI | 4210 | SU3 | 555/504 | 88268 | +| MS.JUAN WU , MR.XIUFENG WEI | 4211 | RM3 | 555/505 | 88269 | +| Mr. Xu Ziliang (คนเก่าชื่อ Miss Nittaya Khambun) | 1403 | SU1 | 555/55 | 88270 | +| MR.YIN JIAJUN | 3420 | SU6 | 555/397 | 88271 | +| Miss Kamonluk Buntaem | 4410 | SU3 | 555/556 | 88272 | +| Miss Kamonluk Buntaem | 4411 | RM3 | 555/557 | 88273 | +| MR.KANG YONGPING | 4419 | SU1 | 555/656 | 88274 | +| MS.Ayse Humeyra Ratanapant | 1402 | SU6 | 555/54 | 88275 | +| MR.YU QIONGLIN | 4608 | RM3 | 555/606 | 88276 | +| MR.KIM DAVID MIN SUNG | 3112 | RM3 | 555/318 | 88277 | +| MS.LIU WENHONG | 3303 | SU6 | 555/354 | 88278 | +| MR.ZHANJUN LIU | 4722 | UG2 | 555/646 | 88279 | +| MR.ZHANJUN LIU | 4723 | UG2 | 555/647 | 88280 | +| Ms.Kwanruthai Jensirikan | 1212 | RM3 | 555/16 | 88281 | +| Mr.Kittivut Kittikunpituk | 1213 | RM3 | 555/17 | 88282 | +| MS.PANG JINCHUN | 3508 | RM3 | 555/411 | 88283 | +| MR.WANG JIANZHONG | 4702 | UG2 | 555/626 | 88284 | +| MR.WANG JIANZHONG | 4703 | SU2 | 555/627 | 88285 | +| MS. IRINA CHELDYSHKINA | 1314 | RM3 | 555/42 | 88286 | +| Ms.Napatporn Khaosaard | 3719 | SU1 | 555/474 | 88287 | +| MS.CHEN-YI CHIANG | 1412 | RM3 | 555/64 | 88288 | +| Miss Napasanant Punpuing | 1421 | UG1 | 555/73 | 88289 | +| MR.RALF MARKUS HENDELE | 3418 | RM3 | 555/395 | 88290 | +| MR.YU CHANGHAI | 1223 | UG1 | 555/27 | 88291 | +| MS.YAO YAN | 1222 | UG1 | 555/26 | 88292 | +| Miss Chiraprapa Phompan | 1204 | RM3 | 555/8 | 88293 | +| MS.LIU SHIYU | 3203 | SU6 | 555/328 | 88294 | +| MS.Ayse Humeyra Ratanapant | 3610 | SU3 | 555/439 | 88295 | +| Mrs.Kusuma Kaewsirimongkol and Miss Pornpatch Suaysod | 4601 | UG2 | 555/599 | 88296 | +| Miss Nittaya Buppharat | 1406 | RM2 | 555/58 | 88297 | +| Mr.Vekeephat Maneechay | 3717 | RM3 | 555/472 | 88298 | +| Mr.Prasasana Sricharoen | 1416 | RM2 | 555/68 | 88299 | +| Miss Chiraprapa Phompan and Mr. Chudet Meenatoree | 1313 | RM3 | 555/41 | 88300 | +| Mr.Supat Tanglertsampan | 3703 | SU6 | 555/458 | 88301 | +| MS.JIAN LI HELEN CHEN | 1317 | SU1 | 555/45 | 88302 | +| MR.SHI JUNNING | 1315 | RM3 | 555/43 | 88303 | +| MR.THOMAS HOEGL | 1210 | RM3 | 555/14 | 88304 | +| MS. JIANG RUNYI | 1413 | RM3 | 555/65 | 88305 | +| MR. JAMES GRAHAM HUBERT | 4704 | SU1 | 555/628 | 88306 | +| Mrs. Sansanee Faengrit | 4312 | RM3 | 555/532 | 88307 | +| Miss Siriyakorn Wongsawat | 1417 | SU1 | 555/69 | 88308 | +| MR.WANG HSIEN-TE | 1404 | RM2 | 555/56 | 88309 | +| MR.PENG WEI | 3101 | AC2 | 555/307 | 88310 | +| Mr.Watanachai Smittakorn | 1415 | RM3 | 555/67 | 88311 | +| MR.Supasin Ragpipray | 1216 | RM3 | 555/20 | 88312 | +| MR.ROBIN | 1219 | UG1 | 555/23 | 888313 | +| Miss Supattra Wiwattanachaiyapong | 4324 | UG2 | 555/544 | 888314 | +| B2 Soft Co., Ltd., | 3409 | RM3 | 555/386 | 888315 | +| MR.PENGYUAN XU | 1303 | SU1 | 555/31 | 888315 | +| MRS.ANILA BANO | 3109 | RM3 | 555/315 | 888317 | +| Mr.Chanchai Keawsirimongkol | 3619 | SU1 | 555/448 | 888318 | +| MR.TAKAYUKI KANDA | 1418 | SU2 | 555/70 | 888319 | +| MR.BEIER LIU | 3704 | SU1 | 555/459 | 888320 | +| MR.LUCAS EBINI | 3419 | SU1 | 555/396 | 888321 | +| MR.MD KAMRUL HASAN KENEDY | 1323 | UG1 | 555/51 | 888322 | +| Miss Rinnisa Sasisuriyapun | 1322 | UG1 | 555/50 | 888323 | +| MISS WIPAWAN PHANTHOO | 1214 | RM3 | 555/18 | 888324 | +| MR.VIKTOR DANILOV | 4605 | RM3 | 555/603 | 888325 | +| MR.ZHANG WEI | 4719 | SU1 | 555/643 | 888326 | +| MS.WANG XINYU | 4604 | SU1 | 555/602 | 888327 | +| Miss Kingpai Pokkasoot | 1304 | RM2 | 555/32 | 888328 | +| MR.Punyawat Apiwatanakul | 1515 | RM3 | 555/91 | 888329 | +| MRS.TAHMIDA BEGUM | 1521 | UG1 | 555/97 | 888330 | +| MR.LU ZHONGNAN | 1518 | SU2 | 555/94 | 888331 | +| Miss Kwanmanus Hancharoenpaitoon | 1305 | RM2 | 555/33 | 888332 | +| MR.XIAOJIE SUN | 1312 | RM3 | 555/40 | 888333 | +| Miss Orapin Kaewha | 1414 | RM3 | 555/66 | 888334 | +| MR.Punyawat Apiwatanakul | 1516 | RM2 | 555/92 | 888335 | +| MS.PING YU | 1513 | RM3 | 555/89 | 888336 | +| Mr.Rachata Tachavijitjaru | 3723 | UG2 | 555/89 | 888337 | +| MR.CHEN JINGEN | 3725 | UG2 | 555/480 | 888338 | +| MR.SHI KAIYU | 1302 | SU6 | 555/30 | 888339 | +| Miss Palini Saejiam | 1320 | UG1 | 555/48 | 888340 | +| MR.Kiattisak Sakdikul | 1422 | UG1 | 555/74 | 888341 | +| MR.Kiattisak Sakdikul | 1423 | UG1 | 555/75 | 888342 | +| MR.Kiattisak Sakdikul | 1424 | UG1 | 555/76 | 888343 | +| MR. HUANG SHENGXUAN | 1517 | SU1 | 555/93 | 888344 | +| MR. ZHANG KE | 1507 | RM2 | 555/83 | 888345 | +| MS. DAI GUANGRUI | 3604 | SU1 | 555/433 | 888346 | +| MR. YUAN FEI | 3417 | RM3 | 555/394 | 888347 | +| MR. GAO RONGFENG | 1608 | SU3 | 555/108 | 888348 | +| MR. GAO RONGFENG | 1609 | RM2 | 555/109 | 888349 | +| Miss Chayanit Pichaichanlert | 3323 | UG2 | 555/374 | 888350 | +| Mr. Pongsak Swatdikiat | 3716 | RM3 | 555/471 | 888351 | +| Mr. Kittivut Kittikunpituk | 3713 | RM3 | 555/468 | 888352 | +| Mr. Kittivut Kittikunpituk | 3714 | RM3 | 555/469 | 888353 | +| MR. YANG ZHEN | 1202 | SU6 | 555/6 | 888354 | +| MRS. WANG JUNHUA and MR.SHI KEQIAN | 3726 | UG2 | 555/481 | 888355 | +| MR. STANLEY STEVEN HTUN | 1201 | UG1 | 555/5 | 888356 | +| MR. LI YONGDONG | 3706 | RM3 | 555/461 | 888357 | +| MR. LI YONGDONG | 3707 | RM3 | 555/462 | 888358 | +| MR. SHAU CHUNG CHAU | 3702 | UG2 | 555/457 | 888361 | +| Mrs. Thanaphacharaphit Suphakiatkamon | 3106 | RM1 | 555/312 | 888359 | +| Miss Daranee Selphusit | 4607 | RM3 | 555/605 | 888360 | +| Miss Chayanit Pichaichanlert | 1719 | UG1 | 555/143 | 888362 | +| MR. YANG ZHEN | 1522 | UG1 | 555/98 | 888363 | +| MR. XU CHANGSONG | 1523 | UG1 | 555/99 | 888364 | +| MS. QIU GUIMIN | 3313 | RM3 | 555/364 | 888365 | +| MS. Woramon Sinsuwan | 4706 | RM3 | 555/630 | 888366 | +| Miss Arying Sae-lee | 3721 | UG2 | 555/476 | 888367 | +| MR. ZHANG XUENENG | 4326 | UG2 | 555/546 | 888368 | +| MS. CHEN TIANNI | 3705 | RM3 | 555/460 | 888369 | +| MR. ZHANG HAO | 1505 | RM2 | 555/81 | 888370 | +| MR. WU CHENGJIAN | 1701 | UG1 | 555/125 | 888371 | +| Arion Investments B.V., | 3724 | UG2 | 555/479 | 888372 | +| Mr.A-RONGKRON TANYAKAN | 4106 | RM1 | 555/489 | 888373 | +| Miss Arying Sae-lee | 1321 | UG1 | 555/49 | 888374 | +| MS.NI XUWEN | 1310 | RM3 | 555/35 | 888375 | +| MR.ZHOU BAODIAN | 3709 | RM3 | 555/464 | 888376 | +| MS.JING WANG | 1217 | SU1 | 555/21 | 888377 | +| Miss Olivia Sakdikul | 1319 | UG1 | 555/47 | 88378 | +| MR.XI LUWEI | 1606 | RM2 | 555/106 | 88379 | +| MR.WU RUJUN | 1324 | UG1 | 555/52 | 88380 | +| MS. GAO YANFEI | 4319 | SU1 | 555/539 | 88381 | +| Mr.Supat Tanglertsampan | 3701 | UG2 | 555/456 | 88382 | +| Ms.Ornuma Nakjakhe | 2508 | SU3 | 555/233 | 88383 | +| Ms.Ornuma Nakjakhe | 2509 | RM4 | 555/234 | 88384 | +| Mr. Phongphan Subprasret | 1504 | RM2 | 555/80 | 88385 | +| Mr.Pongsak Swatdikiat | 3718 | RM3 | 555/473 | 88386 | +| Miss Wanthana Tidchom | 1301 | UG1 | 555/29 | 88387 | +| Mr. Wicha Jampawan | 4609 | RM3 | 555/607 | 88388 | diff --git a/使用记录.md b/使用记录.md new file mode 100644 index 0000000..7a229c6 --- /dev/null +++ b/使用记录.md @@ -0,0 +1,704 @@ +# 使用记录 + +共 65 个房号子表、114 条实际使用记录;空白占位行已省略。 + +覆盖说明:`Total2024-2026` 共 388 个唯一房号;现有子表覆盖 77 个房号,另有 311 个房号无对应子表,故未生成使用记录。 + +## Room No. 1201 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. STANLEY STEVEN HTUN | 1201 | UG1 | 2026-03-23 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299599550 | 2026-03-23 | 2026-03-24 | 1 | 1 | 1 | 14 | Deluxe Room | R.2423 | + +## Room No. 1210 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. THOMAS HOEGL | 1210 | RM3 | 2025-08-22 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299529978 | 2026-03-14 | 2026-03-15 | 1 | 1 | 1 | 14 | Junior Suite (One Bedroom) | R.1407 | + +## Room No. 1212 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MS. SAMART JENSIRIKAN | 1212 | RM3 | 2025-05-29 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299989123 | 2026-06-05 | 2026-06-07 | 2 | 1 | 6 | 9 | FAMILY ROOM | 1408 | +| 300134360 | 2026-06-27 | 2026-06-28 | 1 | 1 | 1 | 8 | Superior Room | 2615 | + +## Room No. 1216 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. SUPASIN RAKPIPRAY | 1216 | RM3 | 2025-09-30 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300135306 | 2026-06-26 | 2026-06-28 | 2 | 1 | 6 | 9 | FAM | R.4610 | + +## Room No. 1222 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MS.YAO YAN | 1222 | UG1 | 2025-09-30 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299912948 | 2026-05-20 | 2026-05-22 | 2 | 1 | 6 | 9 | Deluxe Room | 1319 | + +## Room No. 1320 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| Miss Palini Saejiam | 1320 | UG1 | 2026-02-19 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300207716 | 2026-07-12 | 2026-07-14 | 2 | 1 | 2 | 13 | Deluxe Room | R.2501 | + +## Room No. 1321/ 3721 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MISS ARYING SAE - LEE | 1321/ 3721 | UG1 / UG2 | 21 APR 2026/ 5 MAY 2026 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300208942 | 2026-07-10 | 2026-07-15 | 5 | 2 | 10 | 20 | Deluxe Room | R1617/1618 | + +## Room No. 1323 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. MD KAMRUL HASAN KENEDY | 1323 | UG1 | 2025-11-24 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300096524 | 2026-06-21 | 2026-06-25 | 4 | 1 | 4 | 11 | Deluxe Room | R.1523 | + +## Room No. 1404 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| WANG HSIEN-TE | 1404 | RM2(Superior Room) | 2025-09-22 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299344511 | 2026-03-20 | 2026-03-23 | 3 | 1 | 3 | 12 | UG1 | R.1421 | +| 299818175 | 2026-05-28 | 2026-05-30 | 2 | 1 | 2 | 10 | RM2 | R.1516 | + +## Room No. 1406 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MISS. NITTAYA BUPPHARAT | 1406 | RM2 | 2025-07-29 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299432126 | 2026-03-06 | 2026-03-07 | 1 | 1 | 1 | 14 | Superior Room | 1407 | +| 299831358 | 2026-05-05 | 2026-05-06 | 1 | 1 | 1 | 13 | Superior Room | 1507 | +| 299873660 | 2026-05-13 | 2026-05-14 | 1 | 1 | 1 | 12 | Superior Room | 1716 | +| 300037203 | 2026-06-10 | 2026-06-11 | 1 | 1 | 1 | 11 | Superior Room | 1521 | + +## Room No. 1413 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MS. JIANG RUNYI | 1413 | RM3 | 2025-08-26 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299399570 | 2026-03-01 | 2026-03-04 | 3 | 1 | 3 | 12 | Superior Room | R.2316 | +| 299545030 | 2026-03-22 | 2026-03-24 | 2 | 1 | 2 | 10 | Superior Room | R.1604 | + +## Room No. 1416 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. PRASASANA SRICHAROEN | 1416 | RM2 | 2025-07-29 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299495106 | 2026-03-07 | 2026-03-08 | 1 | 1 | 1 | 14 | Superior Room | 1516 | +| 299802780 | 2026-05-01 | 2026-05-02 | 1 | 1 | 1 | 13 | Superior Room | 1605 | +| 300030049 | 2026-06-13 | 2026-06-14 | 1 | 1 | 1 | 12 | Superior Room | 1409 | + +## Room No. 1420 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MRS.ZHENG LIJUN | 1420 | UG1 | 2025-04-11 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299436401 | 2026-03-02 | 2026-03-17 | 15 | 1 | 15 | 0 | Deluxe Room | 1521 | + +## Room No. 1521 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MRS. TAHMIDA BEGUM | 1521 | Deluxe Room | 2026-01-06 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300238780 | 2026-07-11 | 2026-07-14 | 3 | 1 | 3 | 12 | Deluxe Room | R.3501 | + +## Room No. 1606 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR.XI LUWEI | 1606 | Deluxe Room | 2026-05-25 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300111397 | 2026-07-07 | 2026-07-09 | 2 | 1 | 2 | 13 | Deluxe Room | 2223 | + +## Room No. 3103 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. ITTIDECH NATEENANTASAWASD | 3103 | RM1 | 2024-05-13 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299595858 | 2026-03-24 | 2026-03-25 | 1 | 1 | 1 | 14 | Family Suite (Two Bedroom) | R.1608 | + +## Room No. 3105-4411 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MISS KOMONLUK BUNTAEM | 3105-4411 | RM1 / RM3 | 20 SEP 2024/ 2 MAY 2025 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300057774 | 2026-06-20 | 2026-06-21 | 1 | 2 | 2 | 28 | Superior Room | R.1609-4105 | + +## Room No. 3107 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. SHIYONG WANG | 3107 | RM3 | 2024-04-09 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300091469 | 2026-06-18 | 2026-06-26 | 8 | 1 | 8 | 7 | Superior Room | R.1416 | + +## Room No. 3210 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MS. ONUMA NAKJAKHA | 3210 | RM3 | 2024-01-08 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300066972 | 2026-06-21 | 2026-06-22 | 1 | 1 | 1 | 14 | Family Suite Two bedroom | R.2408 | +| 300123107 | 2026-07-11 | 2026-07-12 | 1 | 1 | 1 | 13 | Family Suite Two bedroom | R.3210 | + +## Room No. 3211 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. MICHAEL LING DONG | 3211 | RM3 | 2024-04-11 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300014293 | 2026-06-20 | 2026-06-22 | 2 | 1 | 2 | 13 | Superior Room | R.2415 | + +## Room No. 3226 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. SEIN TUN,Maung | 3226 | UG2 | 2024-02-06 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299430988 | 2026-03-01 | 2026-03-08 | 7 | 1 | 7 | 8 | Deluxe Room | R.2720 | + +## Room No. 3302 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MRS. CHANYA YOSTHAISONG | 3302 | UG2 | 2024-03-08 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300031804 | 2026-06-10 | 2026-06-13 | 3 | 1 | 3 | 12 | Deluxe Room | R.3302 | + +## Room No. 3306 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. TOSHIHIKO WAKUL | 3306 | RM3 | 2024-03-08 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300119048 | 2026-07-04 | 2026-07-09 | 5 | 1 | 5 | 10 | SU1
Junior Suite (One Bedroom) | R.2503 | +| 299802898 | 2026-05-12 | 2026-05-16 | 4 | 1 | 4 | 6 | RM2 | 1416 | + +## Room No. 3307 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. SHENGYUAN ZHU | 3307 | RM3 | 2024-01-24 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300147347 | 2026-06-28 | 2026-07-02 | 4 | 1 | 4 | 11 | superior room | R.3307 | + +## Room No. 3312 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. SIRIICHAI PAIPAE | 3312 | RM3 | 2024-02-28 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299526352 | 2026-03-12 | 2026-03-13 | 1 | 1 | 1 | 14 | RM3 | 2205 | +| 299526833 | 2026-03-12 | 2026-03-13 | 1 | 1 | 1 | 13 | RM3 | 2206 | +| 300165577 | 2026-07-01 | 2026-07-02 | 1 | 1 | 1 | 12 | RM2 | 1604 | + +## Room No. 3316 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. NATEE PHASUTARNCHART | 3316 | RM3 | 2024-03-29 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 233473561 | 2026-03-07 | 2026-03-08 | 1 | 1 | 1 | 14 | Superior Room | R.2521 | +| 299905242 | 2026-05-30 | 2026-06-01 | 2 | 1 | 2 | 12 | Superior Room | 1423 | +| 300055875 | 2026-06-21 | 2026-06-22 | 1 | 1 | 1 | 11 | Superior Room | 2521 | + +## Room No. 3319 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MS. WANG HONGMEI | 3319 | SU1 | 2025-01-06 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299565733 | 2026-03-17 | 2026-03-20 | 3 | 1 | 3 | 12 | Junior Suite (One Bedroom) | 2602 | +| 299650128 | 2026-04-02 | 2026-04-05 | 3 | 1 | 3 | 9 | Junior Suite (One Bedroom) | 3503 | +| 300200320 | 2026-07-07 | 2026-07-10 | 3 | 1 | 3 | 6 | Junior Suite (One Bedroom) | 2603 | + +## Room No. 3323 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MISS CHAYANIT PICHAICHANLERT | 3323 | UG2 | 2026-03-13 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299613672 | 2026-03-27 | 2026-03-28 | 1 | 1 | 1 | 14 | Deluxe Room | 1319 | +| 299740414 | 2026-04-27 | 2026-04-28 | 1 | 1 | 1 | 13 | Deluxe Room | 2723 | +| 299900486 | 2026-05-17 | 2026-05-18 | 1 | 1 | 1 | 12 | Deluxe Room | 3323 | +| 300110913 | 2026-06-23 | 2026-06-24 | 1 | 1 | 1 | 11 | Deluxe Room | 2623 | +| 300265665 | 2026-07-16 | 2026-07-17 | 1 | 1 | 1 | 10 | Deluxe Room | 2601 | + +## Room No. 3324 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MS. CHANGJUAN PU | 3324 | UG2 | 2024-05-13 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300001452 | 2026-06-15 | 2026-06-19 | 4 | 1 | 4 | 11 | Deluxe Room | R.2401 | + +## Room No. 3406 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| Khun.Kosit Prunglertbuathong | 3406 | Superior Room (RM3) | 2025-10-14 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299589509 | 2026-03-28 | 2026-03-29 | 1 | 1 | 3 | 12 | (SU3 Family Room) | R.2208 | +| 299517849 | 2026-04-26 | 2026-04-28 | 2 | 1 | 6 | 6 | (SU3 Family Room) | R.2608 | + +## Room No. 3409 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| B2 Solf Co., Ltd., | 3409 | RM3 | 2025-10-14 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299422551 | 2026-03-04 | 2026-03-14 | 10 | 1 | 10 | 5 | Superior Room | R.2611 | + +## Room No. 3419 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. LUCAS EBINI | 3419 | SU1 | 2025-11-10 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300139742 | 2026-06-26 | 2026-06-29 | 3 | 1 | 3 | 12 | Junior Suite one bedroom | R.1417 | +| 300279725 | 2026-07-17 | 2026-07-19 | 2 | 1 | 2 | 10 | Junior Suite one bedroom | R.1403 | + +## Room No. 3420 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. YIN JIAJUN | 3420 | SU6 | 2025-05-02 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300048618 | 2026-06-12 | 2026-06-14 | 2 | 1 | 2 | 13 | Junior Suite (one bedroom) | R.1518 | +| 300070132 | 2026-06-16 | 2026-06-19 | 3 | 1 | 3 | 10 | Junior Suite (one bedroom) | R.2618 | + +## Room No. 3425 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MS. NATWARA PONGTONGCHAROEN | 3425 | UG2 | 2024-08-19 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299468403 | 2026-03-01 | 2026-03-02 | 1 | 1 | 1 | 14 | Deluxe Room | R.3425 | + +## Room No. 3503 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR.JUMRUS SENGHOO | 3503 | SU6 | 2024-01-18 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299919526 | 2026-06-24 | 2026-06-26 | 2 | 1 | 2 | 13 | Family Suite two bedroom | R.2508 | + +## Room No. 3423 - 3620 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. WIN NAING | 3423 - 3620 | UG2 - SU6 | 2024-02-27 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299579669 | 2026-03-20 | 2026-03-30 | 10 | 1 | 10 | 20 | SU2 | 2618 | +| 29989905 | 2026-06-02 | 2026-06-03 | 1 | 1 | 1 | 19 | UG1 | 1319 | +| 299989734 | 2026-06-02 | 2026-06-03 | 1 | 1 | 1 | 18 | SU2 | 1318 | +| 300243769 | 2026-07-14 | 2026-07-15 | 1 | 1 | 1 | 17 | SU6 | 3620 | + +## Room No. 3619 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| Mr.Chanchai Keawsirimongkol | 3619 | SU1 | 2025-10-31 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300187119 | 2026-07-17 | 2026-07-18 | 1 | 1 | 2 | 13 | SU3 | 2708 | + +## Room No. 3709 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. ZHOU BAODIAN | 3709 | RM3 | 2026-05-11 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299964872 | 2026-06-03 | 2026-06-18 | 15 | 1 | 15 | 0 | Superior room | R.1416 | + +## Room No. 3716/3718 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. PONGSAK SWATDIKIAT | 3716/3718 | RM3 | 13/3/2026 - 29/6/2026 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300200723 | 2026-07-11 | 2026-07-12 | 1 | 1 | 1 | 29 | superior room | R.2715 | + +## Room No. 3717 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. VEKEEPHAT MANEECHAY | 3717 | RM3 | 2025-07-29 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299633306 | 2026-04-04 | 2026-04-05 | 1 | 1 | 4 | 11 | Family Suite Two Bed room | 1608 | +| 300190170 | 2026-07-17 | 2026-07-19 | 2 | 1 | 6 | 5 | Family Suite Two Bed room | 3510 | + +## Room No. 3719 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MS. NAPATPORN KHAOSAARD | 3719 | SU1 | 2025-06-17 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300002234 | 2026-06-15 | 2026-06-16 | 1 | 1 | 1 | 14 | Junior Suite one bedroom | R.1417 | +| 300140774 | 2026-07-07 | 2026-07-08 | 1 | 1 | 1 | 13 | Superior Room | R.1622 | + +## Room No. 3724 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| บริษัท เอรีออน อินเวสเม้นท์ จำกัด | 3724 | UG2 | 2026-04-29 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299807177 | 2026-07-07 | 2026-07-13 | 6 | 1 | 6 | 9 | Deluxe Room | R.3724 | + +## Room No. 4101 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MRS. NATCHITAKAN THAVORNSART | 4101 | RM2 | 2024-09-16 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299923244 | 2026-05-21 | 2026-05-23 | 2 | 1 | 2 | 13 | RM2 | 1706 | +| 300176656 | 2026-07-11 | 2026-07-12 | 1 | 1 | 1 | 12 | UG1 | 1601 | + +## Room No. 4103 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. ANAN SANG-IN | 4103 | RM1 | 2024-03-29 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300191357 | 2026-07-05 | 2026-07-06 | 1 | 1 | 1 | 14 | superior room | R.1513 | + +## Room No. 4106 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. A-RONGKRON TANYAKAN | 4106 | RM1 | 2026-04-29 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299974677 | 2026-05-30 | 2026-05-31 | 1 | 1 | 1 | 14 | Deluxe Room | 3223 | +| 300090401 | 2026-06-19 | 2026-06-21 | 2 | 1 | 2 | 12 | Superior Room | 4106 | +| 300193104 | 2026-07-04 | 2026-07-05 | 1 | 1 | 1 | 11 | Superior Room | 3105 | + +## Room No. 4107/4110 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MS. WU XIAOHONG | 4107/4110 | RM3 | 2024-07-23 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300227606 | 2026-07-11 | 2026-07-13 | 2 | 1 | 2 | 28 | Superior Room | R.1711 | + +## Room No. 4108 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| KHUN WUTHIPHONG DECHATIWONG NA AYUTTHAYA | 4108 | RM3 | 2024-06-28 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300057133 | 2026-06-13 | 2026-06-14 | 1 | 1 | 1 | 14 | Superior Room | R.4108 | + +## Room No. 4201 4224 4321 4322 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MRS NATTAREEYA ASVINVICHIT MR.NATTAPONG MR.NATTNIDA MR.NATTABUT CHANMATIKORNKUL | 4201 4224 4321 4322 | UG2 | 6/MAR/2024 - 10/SEP/2024 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299997216-299997228-299997239-299997015 | 2026-06-16 | 2026-06-18 | 2 | 4 | 8 | 52 | Deluxe room | R.2519-2520-2521-2522 | +| 300141942 | 2026-06-27 | 2026-06-28 | 1 | 1 | 1 | 51 | Deluxe room | R.4424 | +| 300142270 | 2026-06-27 | 2026-06-28 | 1 | 1 | 1 | 50 | Deluxe room | R.4425 | +| 300190095 | 2026-07-11 | 2026-07-12 | 1 | 3 | 3 | 47 | Deluxe room | R.2501/2519/2520 | +| 300234164 | 2026-07-18 | 2026-07-19 | 1 | 3 | 3 | 44 | Deluxe room | R.2601/2621/2622 | + +## Room No. 4204 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MRS NUSARA REUNGPRACH | 4204 | SU1 | 2024-10-25 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299690280 | 2026-04-10 | 2026-04-11 | 1 | 1 | 1 | 14 | SU2 | 2218 | +| 299736822 | 2026-04-26 | 2026-04-27 | 1 | 1 | 2 | 12 | SU3 | 1208 | +| 299736823 | 2026-04-27 | 2026-04-28 | 1 | 1 | 2 | 10 | SU3 | 4210 | +| 299869588 | 2026-05-13 | 2026-05-14 | 1 | 1 | 1 | 9 | SU2 | 2318 | +| 299855509 | 2026-05-23 | 2026-05-24 | 1 | 1 | 1 | 8 | SU2 | 2318 | +| 299958717 | 2026-05-28 | 2026-05-29 | 1 | 1 | 1 | 7 | SU2 | 4303 | +| 300134444 | 2026-06-27 | 2026-06-28 | 1 | 1 | 1 | 6 | SU2 | 2518 | +| 300112763 | 2026-07-06 | 2026-07-07 | 1 | 1 | 1 | 5 | SU2 | 2518 | +| 300092572 | 2026-07-07 | 2026-07-08 | 1 | 1 | 1 | 4 | SU2 | 2518 | +| 300216959 | 2026-07-15 | 2026-07-16 | 1 | 1 | 1 | 3 | SU1 | 2418 | + +## Room No. 4304 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. CHENXUAN WANG | 4304 | SU1 | 2024-01-26 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300038423 | 2026-06-19 | 2026-06-23 | 4 | 1 | 4 | 11 | Junior Suite one bed room | R.1717 | + +## Room No. 4311 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MS. NATWARA PONGTONGCHAROEN | 4311 | RM3 | 2024-08-19 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299552430 | 2025-03-17 | 2025-03-20 | 3 | 1 | 3 | 12 | Superior Room | R.2712 | + +## Room No. 4325 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. HAN NING | 4325 | UG2 | 2024-02-29 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300139402 | 2026-06-26 | 2026-06-28 | 2 | 1 | 2 | 13 | Junior Suite one bedroom | R.1402 | + +## Room No. 4404 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MS. NATWARA PONGTONGCHAROEN | 4404 | SU1 | 2024-01-25 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299288726 | 2026-03-01 | 2026-03-11 | 10 | 1 | 10 | 5 | Junior Suite (One Bedroom) | R.1417 | + +## Room No. 4405 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. PORNSAWAN SIRISOMBAT | 4405 | RM3 | 2024-03-29 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299807671 | 2026-05-04 | 2026-05-05 | 1 | 1 | 1 | 14 | Superior Room | 1711 | +| 300083974 | 2026-07-10 | 2026-07-12 | 2 | 1 | 2 | 12 | Superior Room | 1511 | + +## Room No. 4410 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MISS. KAMONLUK BUNTAEM | 4410 | SU3 | 2025-05-02 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299248391 | 2026-03-01 | 2026-03-16 | 15 | 1 | 15 | 0 | Family Suite (Two Bedroom) | R.4410 | + +## Room No. 4506 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. WICHA JAMPAWAN | 4506 | RM3 | 2024-10-21 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300128257 | 2026-06-29 | 2026-06-30 | 1 | 1 | 1 | 14 | Superior Room | R.4609 | + +## Room No. 4508 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. XU ZILIANG | 4508 | RM3 | 2025-03-04 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299469150 | 2026-03-02 | 2026-03-05 | 3 | 1 | 3 | 12 | SU1
Junior Suite (One Bedroom) | 4619 | +| 299469150 | 2026-03-02 | 2026-03-05 | 3 | 1 | 3 | 9 | SU1
Deluxe Room | 2521 | +| 300030527 | 2026-06-11 | 2026-06-13 | 2 | 1 | 2 | 7 | Superior Room | 1707 | + +## Room No. 4512 / 4513/4514/4517 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MS. CAO TING | 4512 / 4513/4514/4517 | RM3 | 2024-04-22 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300258647 | 2026-07-14 | 2026-07-19 | 5 | 3 | 15 | 45 | superior room | R. 2510/2514/2515 | +| 300075971 | 2026-07-14 | 2026-07-19 | 5 | 3 | 15 | 30 | Deluxe room | R.2221/2222/2223 | + +## Room No. 4521 4522 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. RUILU HE | 4521 4522 | UG2 | 2024-06-28 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300103659 | 2026-06-21 | 2026-06-23 | 2 | 1 | 2 | 28 | Family Suite two bedroon | R.1508 | + +## Room No. 4601 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MRS. KUSUMA / MISS PORNPATCH SUAYSOD | 4601 | UG2 | 2025-07-24 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299818660 | 2026-05-09 | 2026-05-10 | 1 | 2 | 2 | 13 | UG2 | 2419 | + +## Room No. 4606 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. YANG TENGFANG | 4606 | RM3 | 2024-08-26 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300247708 | 2026-07-13 | 2026-07-15 | 2 | 1 | 2 | 13 | Superior Room | R.3622 | + +## Room No. 4702 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. WANG JIANZHONG | 4702 | UG2 | 2026-06-05 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300107315 | 2026-06-24 | 2026-06-25 | 1 | 1 | 1 | 14 | Superior Room | R.1604 | + +## Room No. 4705 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MS. APINYA SUPUNNARACH | 4705 | RM3 | 2025-01-13 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299468445 | 2026-03-01 | 2026-03-03 | 2 | 1 | 2 | 13 | Superior Room | R.1614 | + +## Room No. 4719 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. ZHANG WEI | 4719 | SU1 | 2025-12-18 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 299406662 | 2026-03-01 | 2026-03-05 | 4 | 1 | 4 | 11 | Junior Suite (One Bedroom) | R.4519 | + +## Room No. 4720 + +| Owenr Name | Room No. | PURCHASED ROOM TYPE | Tranfer on | +| --- | --- | --- | --- | +| MR. WIN HTAN | 4720 | SU6 | 2024-03-18 | + +| Confirmation No. | Check -IN | Check-OUT | Night | Room | Use | Balance | Used Room Type | Remark | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 300134310 | 2026-06-26 | 2026-06-28 | 2 | 1 | 2 | 13 | Junior Suite (One Bedroom) | R.3203 | +| 300200290 | 2026-07-07 | 2026-07-10 | 3 | 1 | 3 | 10 | Junior Suite (One Bedroom) | R.2318 |