ホテル・式場の宴会場の仮押さえを、順位と期限つきでシステムにする。婚礼と一般宴会の取り合い、分割して使う宴会場、転換の時間、2番手の繰り上げを、同時の申込のテストまで
ホテルや
この
この
この
宴会場の仮押さえで台帳が崩れるのは、順位・区画・期限の3つ
宴会場の
| 崩れ方 | 起きる場面 | 台帳で |
|---|---|---|
| 順位が |
2人の |
2人とも |
| 区画の |
大宴会場の |
部屋の |
| 繰り |
1番手の |
期限は |
この
順位は書き込まず、「前に入った、重なる仮押さえの数」から数える
仮押さえの
テーブルは、id が
-- 売る単位の部屋(全体・分割した部屋)
CREATE TABLE rooms (id TEXT PRIMARY KEY, name TEXT NOT NULL);
-- 部屋がふさぐ区画。「A」は A1 と A2 の両方をふさぐ
CREATE TABLE room_parts (room_id TEXT NOT NULL REFERENCES rooms(id), part TEXT NOT NULL,
PRIMARY KEY (room_id, part));
CREATE TABLE holds (
id INTEGER PRIMARY KEY, -- 小さいほど先に押さえた
room_id TEXT NOT NULL REFERENCES rooms(id),
date TEXT NOT NULL, -- 開催日(日本時間の日付)
start_min INTEGER NOT NULL, -- 開始(その日の0時からの分)
end_min INTEGER NOT NULL, -- 終了(同上)
kind TEXT NOT NULL, -- wedding(婚礼)/ banquet(一般宴会)
customer TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'tentative', -- tentative / confirmed / released / expired
expires_at INTEGER -- 1番手の返事の期限(UTCのミリ秒)。2番手は NULL
);
-- 送る連絡の控え(担当者・お客様へ)。確定した後に別の処理が送る
CREATE TABLE outbox (id INTEGER PRIMARY KEY, hold_id INTEGER NOT NULL, kind TEXT NOT NULL);開催日と
重なりは、区画と転換の時間を含めて判定する
仕切りで
時間の
// hold.mjs(抜粋)。区画が1つでも重なり、転換の時間を含めて時間が重なる、生きている押さえ
export const BUFFER_MIN = 60; // 前後の宴会との間に空ける転換の時間(分)
const CONFLICTS = `
SELECT DISTINCT h.id, h.status FROM holds h
JOIN room_parts mine ON mine.room_id = :room
JOIN room_parts other ON other.room_id = h.room_id AND other.part = mine.part
WHERE h.date = :date
AND h.status IN ('tentative', 'confirmed')
AND h.start_min < :end + ${BUFFER_MIN}
AND h.end_min + ${BUFFER_MIN} > :start`;
// 押さえ h より前に入った、重なる仮押さえの数 + 1 が、h の順位
export function rankOf(db, h) {
const ahead = conflicts(db, { room: h.room_id, date: h.date, start: h.start_min, end: h.end_min })
.filter((c) => c.id < h.id && c.status === "tentative").length;
return ahead + 1;
}区画で
仮押さえの申込・本予約・期限切れは、同じトランザクションの中で次の押さえまで動かす
押さえをBEGIN IMMEDIATE で
// hold.mjs(抜粋)
export const MAX_RANK = 2; // 受ける仮押さえは2番手まで
export const HOLD_DAYS = { wedding: 7, banquet: 3 }; // 1番手になってから返事を待つ日数(例)
// 2番手以下で、前の押さえが無くなった押さえを1番手にし、期限を付ける
function promote(db, now) {
const waiting = db.prepare("SELECT * FROM holds WHERE status = 'tentative' AND expires_at IS NULL ORDER BY id").all();
const promoted = [];
for (const h of waiting) {
if (rankOf(db, h) !== 1) continue;
db.prepare("UPDATE holds SET expires_at = ? WHERE id = ?").run(now + HOLD_DAYS[h.kind] * DAY, h.id);
db.prepare("INSERT INTO outbox (hold_id, kind) VALUES (?, 'promoted')").run(h.id);
promoted.push(h.customer);
}
return promoted;
}
// 仮押さえを申し込む
export function requestHold(db, { room, date, start, end, kind, customer, now }) {
return tx(db, () => {
if (kind === "banquet" && isWeekend(date) && daysUntil(date, now) > BANQUET_OPEN_DAYS)
return { ok: false, reason: "土日の一般宴会は、まだ受け付けていない日付" };
const cs = conflicts(db, { room, date, start, end });
if (cs.some((c) => c.status === "confirmed")) return { ok: false, reason: "本予約あり" };
const rank = cs.length + 1; // 重なる仮押さえはすべて、これより前に入ったもの
if (rank > MAX_RANK) return { ok: false, reason: `${rank}番手になるため受けない` };
const expires = rank === 1 ? now + HOLD_DAYS[kind] * DAY : null;
const { id } = db
.prepare(`INSERT INTO holds (room_id, date, start_min, end_min, kind, customer, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`)
.get(room, date, start, end, kind, customer, expires);
return { ok: true, id, rank };
});
}
// 本予約にする。期限内の1番手だけが通る
export function confirm(db, { hold, now }) {
return tx(db, () => {
const h = db.prepare("SELECT * FROM holds WHERE id = ? AND status = 'tentative' AND expires_at > ?").get(hold, now);
if (!h || rankOf(db, h) !== 1) return { ok: false, reason: "1番手ではない、または期限切れ" };
db.prepare("UPDATE holds SET status = 'confirmed', expires_at = NULL WHERE id = ?").run(hold);
// 重なっていた仮押さえは外し、外れたことを知らせる
const lost = conflicts(db, { room: h.room_id, date: h.date, start: h.start_min, end: h.end_min })
.filter((c) => c.status === "tentative");
for (const c of lost) {
db.prepare("UPDATE holds SET status = 'released', expires_at = NULL WHERE id = ?").run(c.id);
db.prepare("INSERT INTO outbox (hold_id, kind) VALUES (?, 'lost')").run(c.id);
}
return { ok: true, released: lost.map((c) => c.id), promoted: promote(db, now) };
});
}
// 期限を過ぎた1番手を外し、次の押さえを繰り上げる(定期の処理)
export function sweep(db, { now }) {
return tx(db, () => {
const expired = db
.prepare(`UPDATE holds SET status = 'expired' WHERE status = 'tentative' AND expires_at <= ?
RETURNING id, customer`)
.all(now);
for (const e of expired) db.prepare("INSERT INTO outbox (hold_id, kind) VALUES (?, 'expired')").run(e.id);
return { expired: expired.map((e) => e.customer), promoted: promote(db, now) };
});
}返事のexpires_at は
本予約が
HOLD_DAYS の
婚礼の日を守るなら、一般宴会を受け始める日を決めておく
婚礼と
この
// hold.mjs(抜粋)
export const BANQUET_OPEN_DAYS = 90; // 土日の一般宴会は、この日数より先の日付では押さえない(例)
const isWeekend = (date) => [0, 6].includes(new Date(`${date}T00:00:00Z`).getUTCDay());
const daysUntil = (date, now) => (Date.parse(`${date}T00:00:00+09:00`) - now) / DAY;この
7つのテストで、順位・区画・転換・繰り上げを確かめた
Node.jsのnode:test)で、
$ node --test --test-reporter=spec
✔ 同じ部屋・同じ時間は、1番手・2番手まで受けて、3件目は断る (2.274125ms)
✔ 分割した部屋は別々に1番手になれるが、全体の押さえは両方の後ろに付く (4.450875ms)
✔ 転換の60分を空けないと重なりとみなす (1.056875ms)
✔ 本予約にできるのは期限内の1番手だけ。決まったら2番手を外して知らせる (1.1135ms)
✔ 1番手の期限が切れたら、2番手を繰り上げて、その時点から期限を付ける (0.876208ms)
✔ 全体の押さえは、分割の押さえが両方なくなるまで繰り上がらない (0.949ms)
✔ 土日の一般宴会は、90日より先の日付では受けない(婚礼は受ける) (1.016417ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 126.506541テストを
期限切れと
8人が同時に押さえるテストで、確かめてから書く作りと比べた
同時のnode:worker_threads で
比べるBEGIN IMMEDIATE の
素朴な作り
43回 受けた押さえ8件(順位 1,1,1,1,1,1,1,1)、断った0件
2回 受けた押さえ8件(順位 1,2,2,2,2,2,2,2)、断った0件
1回 受けた押さえ7件(順位 1,1,1,1,1,1,2)、断った1件
2回 受けた押さえ7件(順位 1,1,1,1,1,1,1)、断った1件
1回 受けた押さえ5件(順位 1,1,1,1,1)、断った3件
1回 受けた押さえ3件(順位 1,1,1)、断った5件
BEGIN IMMEDIATE の中で数えて書く作り
50回 受けた押さえ2件(順位 1,2)、断った6件上はBEGIN IMMEDIATE の
SQLiteのBEGIN IMMEDIATE はSQLITE_BUSY にPRAGMA busy_timeout = 5000 をSQLITE_BUSY を
作る前に、会場の側で決めておくこと
仮押さえの
| 決めること | 決める |
|---|---|
| 何番手まで |
1番手だけ/2番手まで |
| 返事の期限 | 婚礼と |
| 期限が |
自動で |
| 分けて |
どの |
| 転換の時間 | 部屋ごとに |
| 婚礼を |
どの |
| 押さえの |
婚礼の |
いちばん
本番に入れる前に確かめること
テストが
- 今の
台帳の :未来の押さえを 移した 日付の 押さえを、 婚礼と 宴会の 両方の 台帳から 移し、 順位が 今の 台帳の 約束と 同じに なっている - 区画の表:仕切りで
分けて 使う 部屋と、 区画の 対応が、 会場の 図面と 一致している - 期限切れの
処理を :sweep を定期で 起こしている 数分 おきなどに 動かし、 止まった ときに 担当者が 気づける - 連絡の
控えから :繰り上げ・外れ・期限切れの送っている 連絡が、 担当者と お客様の 両方に 届く - 画面に
順位と :担当者が、期限が 出る 押さえの 一覧で 「何番手か」 「いつまでか」を 見られる - 決まりを
外した :婚礼を記録 守る 日の 決まりを 外した 押さえに、 誰が いつ外したかが 残る
自社でやる場合と、任せる場合
自社で
任せる
動作確認した環境
確認日は
- macOS 26.6.2、
Node.js 22.23.2 ( node:sqlite、node:test、node:worker_threads) node:sqliteに組み込まれた SQLiteの バージョン:3.51.3 ( SELECT sqlite_version()で確認)- テストは
メモリの 上の データベース、 同時の 申込の テストは ファイルの データベース (WALモード、 各接続で PRAGMA busy_timeout = 5000) - 実行の
たびに node:sqliteの実験的な 機能である ことを 知らせる 警告 (ExperimentalWarning)が 出ますが、 上の 出力からは 省いています
確かめていない
- PostgreSQLなど、
ほかの データベースでの 同時の 動き - 複数台の
サーバーや、 サーバーレスの 環境での 動き (手元の 1台で、 スレッドを 8つ立てただけです) - 連絡の
控え (outbox)から、 実際に メールや LINEで 送る 処理 - 期限切れを
外す処理を、 Cron などで 定期的に 起こす部分 (テストでは 直接呼んでいます) - 部屋や
押さえの 数が 多い ときの 速さ (繰り上げの 処理は、 2番手以下の 押さえを 1件ずつ 数え直しています)
出典(一次情報)
SQLiteと
BEGIN IMMEDIATEがトランザクションの 最初から 書き込みを 始める こと、 ほかの 接続が 書き込み中なら SQLITE_BUSYになり得る こと → SQLite 「Transaction」 busy_timeoutで、ロックされている ときに 合わせて 指定した 時間まで 待ち、 過ぎたら SQLITE_BUSYを返す こと → SQLite 「PRAGMA busy_timeout」「Set A Busy Timeout」 node:sqliteの安定度 (Stability: 1.1 - Active development)と、 v22.13.0 から フラグなしで 使えるが まだ 実験的である こと → Node.js 「SQLite」
よくある質問
宴会場の仮押さえの順位(1番手・2番手)は、システムでどう持てばよいですか?
順位の
2番手の仮押さえの期限は、いつから数えますか?
この
仕切りで分けて使える宴会場は、空きの判定をどうすればよいですか?
売る
Excelの台帳でも、仮押さえの順位と期限は管理できますか?
件数が