ジム・スタジオの「稼働率」を、予約と会員管理のデータから出す。枠・トレーナー・会員の3つの定義、休講・取り消し・無断欠席・体験の入れ方と、同じデータで数字が変わる例
ジムや
この
この
この
「稼働率」は3つに分けて、何を知りたいかで使い分ける
稼働率と
| 名前 | 分子 ÷ 分母 | 分かること | 使う |
|---|---|---|---|
| 枠の稼働率 | 予約 |
時間割の |
クラスの |
| トレーナーの |
来館の |
トレーナーの |
シフトの |
| 会員の |
期間内に |
在籍しているのに |
退会の |
枠の
分母と分子に入れるもの・外すものを、先に表にする
数字が
| 境目 | この |
理由 |
|---|---|---|
| 休講の枠 | 分母から |
店の |
| 予約が |
分母に |
開いたのに |
| 期限内の |
予約にも |
ほかの |
| 期限を |
予約には |
ほかの |
| 体験 | 枠の |
枠は |
| 期間の |
含めない |
月を |
この
予約と会員のデータは、最小で4つの表にする
集計に
// ジム・スタジオの「稼働率」を出すための最小のテーブル(node:sqlite)
import { DatabaseSync } from 'node:sqlite';
export function open(file = ':memory:') {
const db = new DatabaseSync(file);
db.exec(`
CREATE TABLE IF NOT EXISTS slots ( -- クラス・パーソナルの枠
id INTEGER PRIMARY KEY,
starts_at TEXT NOT NULL, -- 日本時間 'YYYY-MM-DD HH:MM'
minutes INTEGER NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('class','personal')),
trainer TEXT NOT NULL,
capacity INTEGER NOT NULL CHECK (capacity > 0),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','cancelled')) -- cancelled=休講
);
CREATE TABLE IF NOT EXISTS bookings ( -- 予約1件=1行
id INTEGER PRIMARY KEY,
slot_id INTEGER NOT NULL REFERENCES slots(id),
member_id TEXT NOT NULL,
is_trial INTEGER NOT NULL DEFAULT 0, -- 体験の予約
status TEXT NOT NULL CHECK (status IN ('booked','cancelled','late_cancel','no_show','attended'))
-- cancelled=期限内の取り消し / late_cancel=期限を過ぎた取り消し / no_show=無断欠席 / attended=来館
);
CREATE TABLE IF NOT EXISTS shifts ( -- トレーナーの勤務
trainer TEXT NOT NULL, starts_at TEXT NOT NULL, minutes INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS members ( -- 在籍の期間
member_id TEXT PRIMARY KEY, joined_on TEXT NOT NULL, left_on TEXT -- left_on は退会日=その日から在籍しない日(在籍中は NULL)
);`);
return db;
}日時は、'YYYY-MM-DD HH:MM' の
枠の稼働率を出すSQL
枠の
// 稼働率の計算。分母と分子に「何を入れ、何を外すか」をSQLに書いておく
// 期間は [from, to)(to の日は含まない)。日時は日本時間の文字列のまま比べる
export function slotUtilization(db, from, to, kind = 'class') {
return db.prepare(`
SELECT
COUNT(*) AS slots, -- 休講を除いた枠の数
SUM(s.capacity) AS seats, -- 定員の合計(分母)
SUM(b.reserved) AS reserved, -- 予約(期限内の取り消しを除く。体験は除く)
SUM(b.attended) AS attended, -- 来館(体験は除く)
SUM(b.trial_attended) AS trial_attended, -- 体験の来館(別に数える)
ROUND(100.0 * SUM(b.reserved + b.trial_reserved) / SUM(s.capacity), 1) AS booked_pct, -- 予約の稼働率(体験を含む)
ROUND(100.0 * SUM(b.attended + b.trial_attended) / SUM(s.capacity), 1) AS attended_pct, -- 来館の稼働率(体験を含む)
ROUND(100.0 * SUM(b.no_show) / NULLIF(SUM(b.reserved), 0), 1) AS no_show_pct -- 会員の予約のうち無断欠席
FROM slots s
JOIN (
SELECT slot_id,
SUM(is_trial = 0 AND status IN ('booked','late_cancel','no_show','attended')) AS reserved,
SUM(is_trial = 1 AND status IN ('booked','late_cancel','no_show','attended')) AS trial_reserved,
SUM(is_trial = 0 AND status = 'attended') AS attended,
SUM(is_trial = 1 AND status = 'attended') AS trial_attended,
SUM(is_trial = 0 AND status = 'no_show') AS no_show
FROM bookings GROUP BY slot_id
UNION ALL -- 予約が1件もない枠も、分母には入れる
SELECT id, 0, 0, 0, 0, 0 FROM slots WHERE id NOT IN (SELECT slot_id FROM bookings)
) b ON b.slot_id = s.id
WHERE s.status = 'open' AND s.kind = ? AND s.starts_at >= ? AND s.starts_at < ?`).get(kind, from, to);
}同じ
// トレーナーの稼働率:勤務の時間のうち、パーソナルで来館があった枠の時間の割合
export function trainerUtilization(db, from, to) {
return db.prepare(`
SELECT w.trainer, w.work_min,
COALESCE(p.used_min, 0) AS used_min,
ROUND(100.0 * COALESCE(p.used_min, 0) / w.work_min, 1) AS pct
FROM (SELECT trainer, SUM(minutes) AS work_min FROM shifts
WHERE starts_at >= ? AND starts_at < ? GROUP BY trainer) w
LEFT JOIN (SELECT s.trainer, SUM(s.minutes) AS used_min FROM slots s
WHERE s.kind = 'personal' AND s.status = 'open' AND s.starts_at >= ? AND s.starts_at < ?
AND EXISTS (SELECT 1 FROM bookings b WHERE b.slot_id = s.id AND b.status = 'attended')
GROUP BY s.trainer) p ON p.trainer = w.trainer
ORDER BY w.trainer`).all(from, to, from, to);
}
// 会員の利用率:期間の最終日に在籍していた会員のうち、期間内に1回以上来館した人の割合
export function memberActiveRate(db, from, to) {
return db.prepare(`
SELECT COUNT(*) AS members,
SUM(EXISTS (SELECT 1 FROM bookings b JOIN slots s ON s.id = b.slot_id
WHERE b.member_id = m.member_id AND b.status = 'attended' AND b.is_trial = 0
AND s.starts_at >= ? AND s.starts_at < ?)) AS active,
ROUND(100.0 * SUM(EXISTS (SELECT 1 FROM bookings b JOIN slots s ON s.id = b.slot_id
WHERE b.member_id = m.member_id AND b.status = 'attended' AND b.is_trial = 0
AND s.starts_at >= ? AND s.starts_at < ?)) / COUNT(*), 1) AS pct
FROM members m
WHERE m.joined_on < ? AND (m.left_on IS NULL OR m.left_on >= ?)`).get(from, to, from, to, to, to);
}この
境目の扱いを、テストで固定する
境目の
function setup() {
const db = open();
const slot = db.prepare('INSERT INTO slots (id, starts_at, minutes, kind, trainer, capacity, status) VALUES (?, ?, ?, ?, ?, ?, ?)');
const book = db.prepare('INSERT INTO bookings (slot_id, member_id, is_trial, status) VALUES (?, ?, ?, ?)');
slot.run(1, '2026-11-02 19:00', 60, 'class', 'A', 10, 'open');
slot.run(2, '2026-11-03 19:00', 60, 'class', 'A', 10, 'cancelled'); // 休講
slot.run(3, '2026-11-04 07:00', 60, 'class', 'B', 10, 'open'); // 予約0件
for (const [i, s] of ['attended', 'attended', 'attended', 'no_show', 'late_cancel', 'cancelled', 'cancelled'].entries()) book.run(1, `m${i}`, 0, s);
book.run(1, 't1', 1, 'attended'); // 体験
for (let i = 0; i < 8; i++) book.run(2, `x${i}`, 0, 'booked'); // 休講の枠の予約
return db;
}
test('期限内の取り消しは予約に数えず、期限後の取り消しと無断欠席は予約に数える', () => {
const r = slotUtilization(setup(), '2026-11-02', '2026-11-09');
assert.equal(r.reserved, 5); // 来館3+無断欠席1+期限後の取り消し1
assert.equal(r.attended, 3);
assert.equal(r.booked_pct, 30); // (会員5+体験1) / 20
assert.equal(r.attended_pct, 20); // (来館3+体験1) / 20
assert.equal(r.no_show_pct, 20); // 1 / 5
});テストは
$ node --test --test-reporter=spec metrics.test.mjs
✔ 休講の枠は分母から外し、予約0件の枠は分母に入れる
✔ 期限内の取り消しは予約に数えず、期限後の取り消しと無断欠席は予約に数える
✔ 体験は会員の数字と分けて数える
✔ 期間の終わりの日は含まない
✔ トレーナーの稼働率は、来館のあったパーソナルの時間 ÷ 勤務の時間
✔ 会員の利用率は、期間の最終日に在籍していた会員が分母
ℹ tests 6
ℹ pass 6
ℹ fail 0同じ1週間のデータでも、決め方で数字が変わる
決め方の
$ node demo.mjs
この記事の定義 [Object: null prototype] {
slots: 27,
seats: 324,
reserved: 165,
attended: 144,
trial_attended: 9,
booked_pct: 54,
attended_pct: 47.2,
no_show_pct: 4.8
}
期限内の取り消しも予約に数えた場合 63.3
休講の枠も分母に入れた場合 52.1
予約の稼働率(%) 月 火 水 木 金 土 日
07時 50 50 50 50 42 50 33
10時 42 42 25 42 33 33 42
19時 92 83 休 100 92 50 50
21時 67 67 67 67 42 50 50この
曜日×時間帯の
数字を見て、次に何を決めるか
稼働率は、
| 見えた形 | 考えられる |
次に |
|---|---|---|
| 予約の |
期限後の |
取り消しの |
| 特定の |
時間帯と、 |
数か |
| トレーナーの |
指名の |
指名の |
| 会員の |
在籍したまま |
最後に |
前日の
自社でやる場合と、任せる場合
自社で
- この
記事の 「入れる もの・外すもの」の 表を、 自分の 店の 決まりで 埋めて 書き残す。 - 予約の
仕組みから、 取り消しを 消さずに 状態と して 出せるか (期限内か 期限後か、 無断欠席か)を 確かめる。 出せない 場合は、 取り消しの 記録の 持ち方から 直す。 - 1か
月分を 集計し、 予約システムの 画面の 数字と 並べて、 違えばどの 境目が 違うかを 書き残す。 - 毎月
同じ 集計を 回し、 曜日×時間帯の 表と、 来館の 稼働率との 差を 見る。
任せる
動作確認した環境
確認日は
- macOS 26.6.2、
Node.js 22.23.2 ( node:sqlite、node:test) node:sqliteに組み込まれた SQLiteの バージョン:3.51.3 ( SELECT sqlite_version()で確認)- データは
すべて、 説明の ために 作った 仮の もので、 メモリ上の データベースに 入れています
確かめていない
- 実際の
予約システムや 会員管理システムから データを 取り出す部分 (エクスポートの 形式や APIは 製品ごとに 違います) - 数か
月分・複数店舗の データでの 速さ - PostgreSQLなど、
ほかの データベースでの 動き。 この 記事の SQLは、 SQLiteで 真偽の 式を 数値と して 足し合わせる 書き方 ( SUM(is_trial = 0 AND ...))を使っているので、 ほかの データベースでは CASEなどに書き換えが 要る ことがあります
Node.js 22のnode:sqlite は、
出典(確認日:2026年10月6日)
SQLiteと
strftime('%w', ...)が曜日を 0〜6 (日曜が 0)で 返すこと → SQLite 「Date And Time Functions」 - 窓関数(
ROW_NUMBER() OVER ...。仮の データで 定員を 超えた 予約が 出た 場合に 取り消し扱いに する ために 使用)が SQLite 3.25.0 (2018-09-15)から 使える こと → SQLite 「Window Functions」 node:sqliteの安定度 (Stability: 1.1 - Active development)、 v22.13.0 から フラグなしで 使えるが まだ 実験的である こと → Node.js 「SQLite」
よくある質問
ジムの稼働率は、どの数字のことを言えばよいですか?
決まった
当日のキャンセルや無断欠席は、稼働率に入れますか?
この
休講にしたクラスは、稼働率の分母に入れますか?
この
予約システムの画面に出ている稼働率と、自分で集計した数字が合いません。
まず、