ブライダル

ホテル・式場の宴会場の仮押さえを、順位と期限つきでシステムにする。婚礼と一般宴会の取り合い、分割して使う宴会場、転換の時間、2番手の繰り上げを、同時の申込のテストまで

ホテルや式場の宴会場の仮押さえを、Excelの台帳から予約管理のシステムに移すときの作りです。1番手・2番手の順位、返事の期限と繰り上げ、仕切りで分けて使う宴会場、宴会の間の転換の時間を、テストと同時の申込で確かめました。

この記事の結論:宴会場の仮押さえをシステムにするときは、順位を数字で書き込まず、「前に入った、重なる仮押さえの数+1」として毎回数えます。重なりは部屋の名前ではなく、部屋がふさぐ区画と、宴会の間に空ける転換の時間を含めた時間帯で判定します。返事の期限は1番手にだけ付け、期限切れ・辞退・本予約のどれでも、同じ処理の中で次の押さえを繰り上げるか外すかまで行います。手元のテストでは、確かめてから書く作りは同時の申込で順位が重なり、書き込みのロックを先に取って数えて書く作りでは50回とも1番手と2番手が1件ずつでした。

この記事の内容のご相談は株式会社bundlyzeへbundlyzeのWEB戦略チーム代行について見てみる

この記事は、ホテルの婚礼・宴会部門や結婚式場で、宴会場の押さえを台帳で管理している支配人・予約の担当の方と、その予約管理のシステムを作る開発の方に向けています。扱うのは、婚礼と一般宴会(法人の会議や懇親会など)が同じ宴会場を取り合う場面の、仮押さえの順位・期限・繰り上げの作りです。見積もりや案件の記録までを含めた全体の設計は、予約管理と顧客管理を一つにまとめる記事で扱いました。お客様の側に席を取り置く「キャンセル待ち」の作りは、予約のキャンセル待ちを実装する記事にまとめています。コードとテストは2026年10月6日に、手元のMacで実際に動かしています。

宴会場の仮押さえで台帳が崩れるのは、順位・区画・期限の3つ

宴会場の仮押さえの台帳が崩れる場面は、おおむね3つに分けられます。どれも、台帳の1行を見ただけでは気づけない、行と行の関係で起きる崩れです。

崩れ方 起きる場面 台帳で見落とす理由
順位が重なる 2人の担当者が、同じ日の同じ部屋に同時に仮押さえを入れる 2人とも「空いている」と見てから書き込む
区画の重なりを見落とす 大宴会場の全体と、仕切った東側に、別々の押さえが入る 部屋の名前が違うので、同じ行として並ばない
繰り上げが漏れる 1番手の期限が切れたのに、2番手の担当者に知らせが行かない 期限は日付の列で、誰も毎日は見ていない

この3つは、婚礼と一般宴会の両方を受けている会場ほど起きやすいと考えられます。婚礼の担当と宴会の担当が別の課にいて、同じ宴会場を別々の台帳や別々のシートで押さえていると、重なりに気づくのは、どちらかが本予約にしようとした時点になります。

順位は書き込まず、「前に入った、重なる仮押さえの数」から数える

仮押さえの順位は、列に「1」「2」と書き込むと、前の押さえが外れたときに書き換え漏れが起きます。この記事の作りでは、順位を列に持たず、ある押さえより前に入っていて、部屋と時間が重なる仮押さえの数に1を足して、毎回数えます。

テーブルは、部屋・部屋がふさぐ区画・押さえ・送る連絡の控えの4つにしました。押さえの id が小さいほど先に入った押さえで、これが順位の元になります。

sql
-- 売る単位の部屋(全体・分割した部屋)
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);

開催日と開始・終了の時刻は、日本時間の「その日の何時何分」として持ちます。宴会の時刻は、お客様と決めた会場の壁の時計の時刻で、タイムゾーンをまたいで動くものではないからです。一方、返事の期限は「いつまでに返事がなければ外すか」という、処理が判定する時点なので、UTCのミリ秒で持ちます。日本時間とUTCの境目の扱いは、予約の空き枠と締め切りを日本時間で扱う記事にまとめています。

重なりは、区画と転換の時間を含めて判定する

仕切りで分けて使える宴会場は、売る単位の部屋(全体・東・西)と、部屋がふさぐ区画(東・西)を分けて持ちます。全体は東と西の区画を、東は東の区画だけをふさぐと表に書いておけば、区画が1つでも重なる押さえを、同じ部屋の押さえとして数えられます。

時間の重なりには、前後の宴会との間に空ける転換の時間(片付け・設営・清掃)を含めます。この例では60分にしました。11時〜14時の婚礼の後に、14時30分から始まる宴会を入れようとすると、転換の時間が30分しかないので、重なりとして扱います。

js
// 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;
}

区画で判定すると、分けた部屋に特有の順位の並び方が出てきます。東と西に別々の仮押さえが入っていると、どちらも1番手になれますが、その後に全体を押さえようとすると、東と西の両方の後ろに付くので3番手になります。この記事の作りでは2番手までしか受けないので、全体の押さえは断られます。逆に、東に1番手がいて、全体が2番手に付いた後で西を押さえると、西は全体の後ろの2番手になります。この並び方を担当者に画面で見せられるかは、作る前に決めておきたい点です。

仮押さえの申込・本予約・期限切れは、同じトランザクションの中で次の押さえまで動かす

押さえを動かす処理は、仮押さえの申込(requestHold)・本予約(confirm)・取り消し(release)・期限切れを外す定期の処理(sweep)の4つです。どれも BEGIN IMMEDIATE で書き込みのロックを先に取り、重なりを数えるところから、次の押さえの繰り上げと連絡の控えまでを、1つのトランザクションで行います。

js
// 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) };
  });
}

返事の期限は、1番手にだけ付けます。2番手の expires_at は空のままにしておき、繰り上がった時点から数え始めます。2番手でいる間に期限を進めてしまうと、1番手が期限の直前に辞退したとき、繰り上がった2番手には返事を考える時間がほとんど残りません。

本予約が決まったときは、重なっていた仮押さえを外すところまでを同じ処理で行います。分けた部屋では、外した押さえの後ろにいた別の押さえが1番手になることがあるので、本予約の処理の最後にも繰り上げの処理を呼びます。全体の押さえが本予約になって、西の2番手が外れる、という動きは、下のテストで確かめています。

HOLD_DAYS の7日・3日と、2番手まで受けるという決まりは、この記事の例として置いた値です。何日待つか、何番手まで受けるかは、会場の運用で決めてから値に入れます。

婚礼の日を守るなら、一般宴会を受け始める日を決めておく

婚礼と一般宴会が同じ宴会場を使う会場では、婚礼の申込が多い日に、先に一般宴会の仮押さえが入ってしまうことがあります。システムで守るなら、「どの日の、どの種類の押さえを、何日前から受けるか」を決まりとして持たせます。

この記事の例では、土日の一般宴会は、開催日の90日前より先の日付では仮押さえを受けないようにしました。婚礼の仮押さえは、同じ日でも受けます。祝日も同じ扱いにするなら、祝日の一覧を表に持って判定に加えます(この記事のコードは土日だけを見ています)。

js
// 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)で、場面ごとに7つのテストを書き、すべて通りました。どれも、メモリの上に作ったデータベースで、毎回データを入れ直して動かしています。

$ 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

テストを書く途中で、2つ失敗しました。1つは、転換の時間のテストで、14時30分からの押さえを入れた後に15時からの押さえを同じデータベースに入れたため、15時からの押さえが14時30分からの押さえと重なって2番手になったものです。作りの誤りではなく、テストの前提の誤りだったので、場面ごとにデータベースを作り直す形に直しました。もう1つは、繰り上げのテストの開催日を土曜にしていたため、2番手の一般宴会の押さえが「土日の一般宴会は90日より先は受けない」の決まりで断られていたものです。開催日を水曜に直しました。決まりを足すと、別の場面のテストの前提が変わる、という例です。

期限切れと繰り上げのテストでは、2番手の一般宴会の期限が、繰り上がった時点(1番手の期限が切れた時点=申込から7日後)から3日後になっていることを確かめています。連絡の控えには、1番手の「expired」と2番手の「promoted」が、この順番で残りました。

8人が同時に押さえるテストで、確かめてから書く作りと比べた

同時の申込で順位が重ならないかを、8人の担当者が同じ部屋・同じ日・同じ時間を同時に押さえる場面で確かめました。node:worker_threads で8つのスレッドを立て、それぞれが別の接続でSQLiteのファイルを開き、スレッドの起動を150ミリ秒待ってから、一斉に合図を送って申し込ませます。1回ごとにデータベースを作り直し、50回くり返しました。

比べるために、同じ申込を、トランザクションを使わずに「重なりをSELECTで数え、20ミリ秒の処理を挟んでからINSERTする」作りでも書きました。20ミリ秒は、本番で通知の文面を作ったり、ほかのシステムに問い合わせたりする時間の代わりで、素朴な作りにだけ入れています(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件

上は1回目の出力です。同じ日に続けて2回流したときは、素朴な作りは2回とも50回すべてが「8件とも1番手」で、BEGIN IMMEDIATE の作りは2回とも50回すべてが「1番手と2番手が1件ずつ、6件を断る」でした。素朴な作りでは、8人とも、ほかの人が書き込む前に「重なる押さえは0件」と数えたため、全員が1番手として受け付けられました。台帳で言えば、8人の担当者がそれぞれ、お客様に「1番手で押さえました」と伝えてしまう状態です。

SQLiteのドキュメントでは、BEGIN IMMEDIATE はトランザクションの最初から書き込みを始める指定で、ほかの接続が書き込み中なら SQLITE_BUSY になり得ると説明されています。この記事の作りでは、各接続で PRAGMA busy_timeout = 5000 を設定し、ロックされていたら合わせて5秒まで待ち、それでも空かなければ SQLITE_BUSY を返すようにしています。同じ枠への同時の申込を、一意制約や排他制約で防ぐ方法との比べ方は、予約の二重受付を防ぐ記事にまとめました。宴会場の押さえは、2番手まで受けるので「1件だけ」の制約では表しにくく、この記事ではロックを先に取って数える形にしています。

作る前に、会場の側で決めておくこと

仮押さえの作りは、決まりが決まれば小さく作れますが、決めることの多くは会場の運用の側にあります。開発の側に渡す前に、次の表を埋めておくと、作ってからの手戻りが減ります。

決めること 決める内容の例
何番手まで受けるか 1番手だけ/2番手まで/それ以上
返事の期限 婚礼と一般宴会で何日か。2番手が繰り上がったときに、いつから数えるか
期限が切れたとき 自動で外すか、担当者が確かめてから外すか。お客様への連絡は誰がするか
分けて使う部屋 どの部屋がどの区画をふさぐか。全体と分割の押さえが並んだときの順位の見せ方
転換の時間 部屋ごとに何分か。婚礼と宴会で変えるか
婚礼を守る日 どの日の一般宴会を、何日前から受けるか。決まりを外せる人と記録
押さえの入口 婚礼の課・宴会の課・電話・Webのどこから押さえても、同じ処理を通るか

いちばん見落としやすいのは、最後の行の押さえの入口です。婚礼の課と宴会の課が別のシートに書き込む運用のままシステムを入れると、片方の押さえがシステムの外に残ります。入口がいくつあっても、同じ申込の処理を通る形にします。

本番に入れる前に確かめること

テストが通っても、本番のデータと運用で確かめることが残ります。次の項目を、切り替えの前に上から順に確かめます。

  • 今の台帳の押さえを移した:未来の日付の押さえを、婚礼と宴会の両方の台帳から移し、順位が今の台帳の約束と同じになっている
  • 区画の表:仕切りで分けて使う部屋と、区画の対応が、会場の図面と一致している
  • 期限切れの処理を定期で起こしている:sweep を数分おきなどに動かし、止まったときに担当者が気づける
  • 連絡の控えから送っている:繰り上げ・外れ・期限切れの連絡が、担当者とお客様の両方に届く
  • 画面に順位と期限が出る:担当者が、押さえの一覧で「何番手か」「いつまでか」を見られる
  • 決まりを外した記録:婚礼を守る日の決まりを外した押さえに、誰がいつ外したかが残る

自社でやる場合と、任せる場合

自社で作る場合は、予約管理のシステムを作れる開発者と、婚礼・宴会の両方の課の決まりをまとめられる担当者の2つの役目が要ります。この記事の作りは小さなものですが、区画の表、期限と繰り上げの決まり、連絡の文面、定期の処理の見張りまでを決め、今の台帳から押さえを移す作業も伴います。課ごとに決まりが違う場合は、それをそろえる話し合いに、作る作業より時間がかかることがあります。

任せる場合、株式会社bundlyzeでは、業種を問わずに使える予約管理の仕組みを企画から開発・運用まで自社で手がけてきた経験をもとに、宴会場の押さえのような予約管理の業務システムを、要件の整理から保守運用まで受け持っています。結婚式場のWeb集客を外部のWeb責任者として受け持つWEB戦略代行も行っており、フェアの予約から成約までの流れとつなげて設計することもできます。大阪・兵庫を中心とした関西のほか、遠方のご相談もオンラインでお受けしています。受け持つ範囲はWEB戦略代行とシステム開発のページで紹介しています。

動作確認した環境

確認日は2026年10月6日です。

  • 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とNode.jsの動きについての説明は、次の公式のドキュメントに基づいています。確認日はどれも2026年10月6日です。

  • 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番手)は、システムでどう持てばよいですか?

順位の数字を列に書き込むより、押さえた順番(登録の番号)から毎回数える形が扱いやすくなります。この記事の作りでは、ある押さえより前に入っていて、部屋と時間が重なる仮押さえの数に1を足したものを順位にしています。前の押さえが外れれば、数え直すだけで繰り上がるので、順位の書き換え漏れが起きません。

2番手の仮押さえの期限は、いつから数えますか?

この記事の作りでは、1番手に繰り上がった時点から数えます。2番手でいる間に期限を進めると、繰り上がった日に期限が残っていない、ということが起きるためです。繰り上がったときには、担当者とお客様への連絡の控えを同じ処理の中で書いておきます。

仕切りで分けて使える宴会場は、空きの判定をどうすればよいですか?

売る単位の部屋(全体・東・西など)とは別に、部屋がふさぐ区画を表に持ちます。全体は東と西の両方の区画を、東は東の区画だけをふさぐ、と書いておけば、区画が1つでも重なる押さえを「重なり」として数えられます。部屋の名前どうしを比べる作りでは、全体と東の重なりを見落とします。

Excelの台帳でも、仮押さえの順位と期限は管理できますか?

件数が少なく、台帳を触る人が1人なら回せます。担当者が複数いて同じ日の同じ部屋を同時に押さえる場面があるなら、確かめてから書くまでの間に別の人が書き込むことを防げません。台帳と同じ「確かめてから書く」作りのコードで試すと、8人が同時に押さえる場面を50回くり返して、8件とも1番手として受けた回が、3度流して43回・50回・50回ありました。

この記事の書き方と確認の方針