ワークフローの最適化:代替休暇日の自動提案

前回の記事では、上長による確認に先立ち、スクリプトタスクを用いてカレンダーの空き状況を自動的に確認できるよう休暇申請ワークフローを拡張する方法を取り上げました。

中核となるロジックは、以下のように定義していました。

  • 希望日に空きがある場合 → 承認へ進む
  • 空きがない場合 → 申請者に差し戻す

この方法は効果的である一方、利便性に課題がありました。

予定が埋まっているという理由で申請が却下されると、申請者自身で別の日程を探し、再申請しなければならなかったためです

では、ワークフロー側で代替日を自動提案することはできないでしょうか?

結論から言えば、可能です。本記事では、この自動提案機能を実装する過程で得られた、ワークフロー設計における実践的な知見を共有します。


「確認」から「提案」への移行

もともとのスクリプトタスクは、単純なYES/NOの結果を返すことに主眼を置いていました。

空き状況:YES

空き状況:NO

このプロセスを改善するには、希望期間に空きがない場合に、具体的にどの日程なら空いているのかをワークフロー自身が特定できる必要がありました。

これにより、スクリプトタスクの役割は『単純な確認』から『簡易的な提案エンジン』へと再定義されました。


最初の試み

ワークフローの当初のバージョンは、希望した休暇日程に空きがあるかどうかを確認するだけのものでした。

カレンダー上の予定件数が定めたしきい値を超えていた場合、申請は却下され、申請者に差し戻されます。

この方法は、申請者にとって負担の大きいフローでした。

申請を却下された利用者は、その後、次のような対応を迫られることになります。

  • 自分で代わりの日程を探す
  • どの期間なら空いていそうか見当をつける
  • うまくいくまで何度も申請をやり直す

ワークフローは問題を見つけることはできても、その解決をサポートするまでには至っていませんでした。

そこで、次のような改善が必要であると考えました。

単純に却下するのではなく、代わりの休暇期間を自動的に提案するべきである、というものです。

最初は個々の日付を1件ずつ提案する方式を試しましたが、複数日にまたがる申請に対してはうまく機能しないことが分かりました。

例えば、次のようなケースです。

希望する休暇期間:

2026-06-01 → 2026-06-14

単発の日付をばらばらに提案しても、申請者は依然として同じ長さの連続した休暇期間を必要としているため、あまり実用的ではありませんでした。

そこで、ワークフローは以下のように更新されました。

  • もとの休暇日数を維持する
  • 近い時期の期間を検索する
  • 連続した代替休暇期間を提案する

例えば、次のようになります。

提案された代替期間:

2026-06-03 → 2026-06-16

この改善により、利用者から見たワークフローの効率性と実用性が高まりました。

単純に申請を却下するのではなく、ワークフローは利用可能な代替案を自動的に見つけ、積極的に利用者を手助けできるようになりました。


ロジックの見直し

当初のスクリプトのロジックは、Google Calendar APIへの複数回のリクエストに依存していました。具体的には、候補となる日付ごとに1回ずつリクエストを送信する形になっていました。

この方式には、以下のような限界がありました。

  • 不要なAPI呼び出しが発生する
  • 休暇期間が長くなるほどスケーラビリティの問題が生じる
  • 連続した複数日のブロックをうまく扱えない

補足:スクリプトタスクは、1回の自動実行につきHTTPリクエストを10回までしか送信できません。

カレンダーの各日付を1件ずつ順番に確認していく方式では、このHTTPリクエスト数の上限に頻繁に達してしまっていました。

最適化後のバージョンでは、以下の処理を行っています。

  1. 検索対象期間全体について、カレンダーの予定を一度だけ取得する
  2. 取得した結果をもとに、1日ごとの予定件数をローカルでカウントする
  3. 候補となる休暇期間を、空き状況のしきい値と照らし合わせて判定する

これらの変更により、ワークフローは以下のような特性を備えるようになりました。

  • 効率性の向上
  • スケーラビリティの確保
  • 長期の休暇申請への対応
  • 単日申請への継続的なサポート

希望日の前後を検索する

検索範囲を広げたことも、さらなる設計上の改善点のひとつでした。

現在のワークフローでは、希望期間より後の日付だけに検索を限定するのではなく、次のような工夫を行っています。

  • 希望日の前後の両方を検索
  • 過去の日付の除外
  • 当日から始まる休暇の除外
  • 元の休暇日数の維持

例えば、次のようになります。

希望する休暇期間:

2026-06-10 → 2026-06-14

これらの工夫により、より実用的な利用者体験が実現しました。

完成したスクリプトは、以下のコードブロックの通りです。

スクリプト(クリックして開く)

/*
 * Questetra スクリプトタスク
 * 代替の取得可能休暇期間を提案
 *
 * 以下の両方に対応:
 * - 単日(1日のみ)の休暇申請
 * - 複数日の休暇申請
 *
 * Input:
 *   q_start_date
 *   q_end_date
 *
 * Output:
 *   q_suggested_dates  String / Text data item
 */
const CALENDAR_ID = "INSERT_YOUR_CALENDAR_ID";
const AUTH_SETTING_NAME = "INSERT_YOUR_AUTH_SETTING_NAME";
const THRESHOLD = 3;          // fewer than 3 events per day = available
const DAYS_BEFORE = 14;        // search up to 14 days before requested start date
const DAYS_AFTER = 30;         // search up to 30 days after requested start date
const MAX_SUGGESTIONS = 5;    // return up to 5 suggested periods
// 1) 日付の読み込み
const startDate = engine.findDataByVarName("q_start_date");
const endDate   = engine.findDataByVarName("q_end_date");
if (startDate === null || endDate === null) {
  throw new Error("q_start_date or q_end_date is empty.");
}
// 2) ヘルパー関数
function toJsDate(qDate) {
  return new Date(qDate.toString() + "T00:00:00+09:00");
}
function formatDate(date) {
  const y = date.getFullYear();
  const m = ("0" + (date.getMonth() + 1)).slice(-2);
  const d = ("0" + date.getDate()).slice(-2);
  return y + "-" + m + "-" + d;
}
function addDays(date, days) {
  const newDate = new Date(date.getTime());
  newDate.setDate(newDate.getDate() + days);
  return newDate;
}
function daysBetweenInclusive(start, end) {
  return Math.round((end - start) / (1000 * 60 * 60 * 24)) + 1;
}
function isWeekend(date) {
  const day = date.getDay();
  return day === 0 || day === 6;
}
// 3) 申請日付の変換
const requestedStart = toJsDate(startDate);
const requestedEnd   = toJsDate(endDate);
if (requestedEnd < requestedStart) {
  throw new Error("q_end_date is earlier than q_start_date.");
}
const leaveLengthDays = daysBetweenInclusive(requestedStart, requestedEnd);
// 4) 検索範囲の決定
const MIN_ADVANCE_FOR_BACKWARD_SEARCH = 5;
const today = new Date();
today.setHours(0, 0, 0, 0);
const earliestAllowedSuggestion = addDays(today, 1);
// Only search backwards if the requested leave starts
// at least MIN_ADVANCE_FOR_BACKWARD_SEARCH days from today
const thresholdDate =
  addDays(today, MIN_ADVANCE_FOR_BACKWARD_SEARCH);
let searchStart;
if (requestedStart >= thresholdDate) {
  searchStart = addDays(requestedStart, -DAYS_BEFORE);
  // Never suggest dates in the past
  if (searchStart < earliestAllowedSuggestion) {
    searchStart = earliestAllowedSuggestion;
  }
} else {
  searchStart = requestedStart;
}
// Need to extend search end enough to fit a full replacement leave period
const latestCandidateStart = addDays(requestedStart, DAYS_AFTER);
const searchEnd = addDays(latestCandidateStart, leaveLengthDays - 1);
engine.log("Requested leave length: " + leaveLengthDays + " day(s)");
engine.log("Search start: " + formatDate(searchStart));
engine.log("Search end: " + formatDate(searchEnd));
// 5) 検索範囲全体の全イベントを一括取得
const timeMin = formatDate(searchStart) + "T00:00:00+09:00";
const timeMax = formatDate(searchEnd)   + "T23:59:59+09:00";
const url =
  "https://www.googleapis.com/calendar/v3/calendars/" +
  encodeURIComponent(CALENDAR_ID) +
  "/events";
const authSetting = httpClient.findAuthSetting(AUTH_SETTING_NAME, false);
let request = httpClient.begin();
request = request.authSetting(authSetting);
request = request.queryParam("timeMin", timeMin);
request = request.queryParam("timeMax", timeMax);
request = request.queryParam("singleEvents", "true");
request = request.queryParam("orderBy", "startTime");
const response = request.get(url);
const status = response.getStatusCode() + "";
const body = response.getResponseAsString() + "";
engine.log("Calendar API status: " + status);
if (status !== "200") {
  throw new Error("Google Calendar API error: " + status + "\n" + body);
}
const json = JSON.parse(body);
const events = json.items || [];
engine.log("Total events found: " + events.length);
// 6) 1日ごとのイベント数を集計
const countsByDate = {};
function incrementDateCount(dateString) {
  if (!countsByDate[dateString]) {
    countsByDate[dateString] = 0;
  }
  countsByDate[dateString]++;
}
for (let i = 0; i < events.length; i++) {
  const event = events[i];
  let eventDate;
  if (event.start && event.start.date) {
    eventDate = event.start.date;
  } else if (event.start && event.start.dateTime) {
    eventDate = event.start.dateTime.substring(0, 10);
  } else {
    continue;
  }
  incrementDateCount(eventDate);
}
// 7) 候補期間が取得可能か確認
function isPeriodAvailable(candidateStart, candidateEnd) {
  let cursor = new Date(candidateStart.getTime());
  while (cursor <= candidateEnd) {
    const dateString = formatDate(cursor);
    // Optional: reject leave periods that include weekends
    if (isWeekend(cursor)) {
      return false;
    }
    const count = countsByDate[dateString] || 0;
    if (count >= THRESHOLD) {
      return false;
    }
    cursor = addDays(cursor, 1);
  }
  return true;
}
// 8) 候補の休暇期間を検索
const suggestions = [];
const candidateStartEnd = latestCandidateStart;
let candidateStart = new Date(searchStart.getTime());
while (candidateStart <= candidateStartEnd) {
  const candidateEnd = addDays(candidateStart, leaveLengthDays - 1);
  if (isPeriodAvailable(candidateStart, candidateEnd)) {
    if (leaveLengthDays === 1) {
      suggestions.push(formatDate(candidateStart));
    } else {
      suggestions.push(formatDate(candidateStart) + " to " + formatDate(candidateEnd));
    }
  }
  if (suggestions.length >= MAX_SUGGESTIONS) {
    break;
  }
  candidateStart = addDays(candidateStart, 1);
}
// 9) 提案された日付の保存
let outputText;
if (suggestions.length === 0) {
  outputText =
     formatDate(searchStart) + " から " + formatDate(searchEnd) + " の間に利用可能な休暇期間は見つかりませんでした。";
} else {
  outputText =
    "利用可能な休暇期間の候補:\n- " +
    suggestions.join("\n- ");
}
engine.setDataByVarName("q_suggested_dates", outputText);
engine.log(outputText);

本記事は、プログラミングの専門知識がない方にもスクリプトの有用性を理解いただけるよう、エンジニア・非エンジニアを問わず幅広い層に向けて書かれています。ECMAScriptの内容が理解できない場合でも、このコードをワークフロー基盤に組み込んで実際に試してみることができます。実行時にエラーが発生した場合は、処理ログをAIに解析させることで、原因の特定に役立てることができます。


ワークフロー設計の観点から

ワークフローの利便性は、全体構造の分かりやすさを保ったまま向上させることができました。

この構成では、ゲートウェイを挟んで2つのスクリプトタスクを配置しています。

休暇日を入力

↓

空き状況を確認

↓

ゲートウェイ

├─ 空きあり → 上長承認

└─ 空きなし → 代替日を提案 → 申請者に差し戻す

最初のスクリプトタスクは、希望した休暇期間に空きがあるかどうかを確認します。

続いてゲートウェイが、その結果に応じて処理の流れを制御します。

希望日に空きがない場合は、2つ目のスクリプトタスクが代わりの期間を特定し、その提案内容を該当するデータ項目に記録します。

これにより、プロセスの中には2つの自動判断ポイントが存在することになります。

  • 1つ目は空き状況の確認
  • 2つ目は代替案の提案

このように役割を分けることは、ワークフロー設計の観点から見て有益です。各スクリプトタスクの責務が明確になり、ゲートウェイは空き状況の結果に応じてプロセスを振り分けます。

それぞれの部品の役割が専門化されていることで、分かりやすさを保ったまま、システム全体の能力を高めることができます。


スクリプト開発とトラブルシューティングに関する補足

AIを活用したスクリプト開発やワークフローの自動化は、多くの場合、試行錯誤を繰り返すイテレーション(反復型)のプロセスになります。

最終的なスクリプトも、現在の形に至るまでに何段階もの改良を重ねる必要がありました。

その過程では、以下のような課題に対応しました。

  • 認証にまつわる問題
  • APIリクエストに関する問題
  • ロジックの改良
  • 日付範囲まわりのエッジケース
  • 複数回にわたるテストと調整

特に、単純な空き状況の確認から、代わりの休暇期間をまとめて提案する仕組みへと発展させる過程では、スクリプトの構造と検索ロジックの両方に何度も手を加える必要がありました。

この反復的なプロセスは、大きな価値をもたらしました。

AIが生成したコードは、完成品としてではなく、迅速なプロトタイピングとトラブルシューティングのための道具として扱いました。

  • 協働による開発
  • 迅速なプロトタイピング
  • 技術的なトラブルシューティング

開発サイクルを重ねるたびに、ワークフローの設計と実装の両方が洗練されていきました。

Questetraにおけるカレンダー確認スクリプトに関するトラブルシューティングの経験については、以前公開した記事で詳しく取り上げています。

出典:カレンダー確認の自動化:Questetraワークフローにおけるスクリプト実装

スクリプトタスクとAPIを用いた同様の連携を構築する際の試行錯誤の過程については、そちらの記事も参考にしていただけます。

前述のドキュメントに目を通しておくことで、技術的なデバッグにかかる時間を短縮できる場合があります。


まとめ

標準的な休暇申請ワークフローは、以下の特徴を備えた動的な仕組みへと生まれ変わりました。

  • 自動での空き状況確認
  • カレンダーとの連携
  • 代替休暇日の提案
  • 状況に応じた柔軟な処理の振り分け

ここで最も重要なのは、これだけの処理を行いながらも「ワークフロー全体の構造がシンプルに保たれている」という点です。機能面の複雑さはバックグラウンドの自動化レイヤーに隠蔽されているため、利用者が迷うことはありません。

この実装により、休暇申請が却下される件数が減少し、業務全体の効率も向上しました。

Questetra Supportをもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む