業務自動化

GASでGoogleカレンダーから勤怠集計を自動化 月末の集計作業を月2時間削減する

制作会社・士業事務所・小規模チームなど、勤怠管理システムを導入せず月末に手作業で稼働時間を集計している中小企業/個人事業主 の業務自動化レシピ
業種制作会社・士業事務所・小規模チームなど、勤怠管理システムを導入せず月末に手作業で稼働時間を集計している中小企業/個人事業主
ツールGoogle Apps Script
難易度★★☆ 中
設定時間約 40 分

月末になると、メンバーのカレンダーを1人ずつ開いて稼働時間を数え、スプレッドシートに打ち込む——この作業が毎月2〜3時間かかっているなら、Google Apps Script に丸ごと任せられます。追加費用¥0で、稼働時間と所定外時間を担当者別・日別に自動集計する方法を、動くコードと労務上の注意つきで公開します。

読了時間 約9分 / 設定所要時間 約40分 / 月額固定費 ¥0

編集長の見解:このレシピの価値は「勤怠管理システムを置き換えること」ではありません。置き換えを狙うと必ず労務リスクに突き当たります。狙うべきは、月末に人手で数えている「稼働時間の下書き」を機械に作らせ、経営者は差分の確認だけに集中する状態です。ゼロから数える作業と、出てきた数字を確認する作業では、かかる時間が一桁違います。

🔍 「朝会通知」ではなく「勤怠集計」を自動化する理由

カレンダー連携の自動化というと、まず思いつくのは予定のリマインド通知です。当サイトでもGASでGoogleカレンダー予定をSlackへ朝会リマインドするレシピを公開しています。ただし通知の自動化は「当日の情報共有」を助けるだけで、月末の集計作業には効きません。

本記事が扱うのは、その逆側です。すでにカレンダーに入っている予定を元データとして、月に1回まとめて集計する——ここだけに絞ります。新しく打刻の仕組みを作る話ではなく、日々の見張りやリアルタイム通知の話でもありません。既存の予定を資産として使い切るレシピだと考えてください。

集計作業がしんどいのは、次の3つが同時に発生するからです。

いずれも人間が最も苦手で、プログラムが最も得意な処理です。次のセクションで、その全体像を確認します。

🗺 全体フロー — 月初に自動で「集計済みシート」ができる

構造はシンプルです。毎月1回スクリプトが起動し、メンバーごとのカレンダーから前月分の予定を読み取り、日別に合計してスプレッドシートへ書き出します。

カレンダー予定 → 稼働時間集計シートの全体像
🗓
01
月次トリガー起動
毎月1日に GAS が自動実行
📅
02
前月の予定を取得
メンバー別カレンダーから前月分を抽出
🧮
03
日別に稼働時間を合計
終日予定を除外し所定外時間を算出
📊
04
スプレッドシートへ出力
月ごとのシートに担当者別で書き出し

設定後は毎月自動実行、人の操作は確認のみ

登場する部品は3つだけです。役割と一次情報源を整理します。

部品役割公式ドキュメント
時間主導型トリガー毎月決まった日に関数を自動実行インストール可能なトリガー
CalendarApp サービス指定期間の予定を取得Calendar クラス リファレンス
SpreadsheetApp サービス集計結果をシートへ書き込みSpreadsheetApp リファレンス

この3つをつなぐコードは、次のセクションのものをコピーするだけで動きます。

💰 必要なものと費用の目安

追加の支出は発生しません。用意するものを表で確認してください。

必要なもの役割費用の目安
Googleアカウント(無料 or Workspace)カレンダー・GAS の実行無料(Workspace は既存契約で可)
Googleスプレッドシート集計結果の出力先無料
メンバーの共有カレンダー稼働時間の元データ無料
集計対象メンバーの閲覧権限予定の読み取り無料(共有設定のみ)

💡 編集部の判断材料:すでに勤怠管理サービスを契約しているなら、乗り換える必要はまったくありません。このレシピが向くのは「従業員数名で専用サービスの月額が割に合わない」「業務委託メンバーの稼働時間を請求根拠として把握したい」「プロジェクト別の工数を知りたい」ケースです。逆に打刻・有給管理・給与計算まで必要なら、専用サービスのほうが確実に安く済みます。

判断がついたら、構築に進みます。

🛠 設定手順 — 約40分で完成

作業は「シートとカレンダーの準備」「コード貼り付け」「月次トリガー設定」の3段階です。プログラミングの知識は不要です。

GAS × カレンダー勤怠集計の構築手順
STEP 1
集計用スプレッドシートを作成
新しいスプレッドシートを1つ作り、URL に含まれる ID 部分(/d//edit の間)を控えます。
STEP 2
対象カレンダーの ID を集める
各メンバーのカレンダー設定画面を開き、「カレンダーの統合」欄にあるカレンダーIDを控えます。自分の予定だけなら自分のメールアドレスがそのまま ID です。
STEP 3
閲覧権限を確認する
スクリプトを実行するアカウントが、対象カレンダーを閲覧できる状態にします。権限がないカレンダーは取得できず、集計から静かに漏れます。
STEP 4
Apps Script プロジェクトを作成
script.google.com で新規プロジェクトを作成し、左の「プロジェクト設定」のタイムゾーン欄が日本標準時になっているか確認します。
STEP 5
コードを貼り付けて保存
下記のコードを丸ごと貼り付け、シート ID とメンバー一覧をスクリプトプロパティに登録します(手順は次項)。
STEP 6
手動実行でテスト
エディタ上部の実行ボタンで aggregateMonthlyWorkHours を1回実行し、前月分のシートが生成されるか確認します。
STEP 7
月次トリガーを設定
左の目覚まし時計アイコン(トリガー)から「トリガーを追加」、イベントのソースに「時間主導型」、時間ベースのトリガーのタイプに「月ベースのタイマー」を選び、毎月1日に実行するよう保存します。

STEP 7 の画面の文言は、Google 側の更新で変わることがあります。ラベルが見当たらない場合は、次の1行を関数として追加して一度だけ実行すれば、同じ月次トリガーがコードから作れます(onMonthDayatHour は Apps Script の公式リファレンスに載っている正式なメソッドです)。

function createMonthlyTrigger() {
  ScriptApp.newTrigger('aggregateMonthlyWorkHours')
    .timeBased()
    .onMonthDay(1)   // 毎月1日
    .atHour(4)       // 午前4時台に実行
    .create();
}

貼り付けるコード(そのまま動きます)

以下を Apps Script エディタに貼り付けてください。トリガーから呼ばれる aggregateMonthlyWorkHours が本体です。

/**
 * Googleカレンダー → 勤怠集計(稼働時間・所定外時間)をスプレッドシートへ出力
 * トリガー: 時間主導型(月ベースのタイマー、毎月1日)で
 *           aggregateMonthlyWorkHours を実行
 */

// 1日の所定労働時間(時間)。これを超えた分を「所定外」として集計します。
const STANDARD_HOURS_PER_DAY = 8;

// タイトルにこれらを含む予定は稼働時間から除外します。
// 部分一致で判定するため、短すぎる語(「仮」1文字など)は
// 意図しない予定まで除外してしまいます。2文字以上を推奨します。
const EXCLUDE_KEYWORDS = ['休憩', 'ランチ', '私用', '仮予約'];

function aggregateMonthlyWorkHours() {
  const props = PropertiesService.getScriptProperties();
  const sheetId = props.getProperty('TARGET_SHEET_ID');
  const memberJson = props.getProperty('MEMBER_CALENDARS');

  if (!sheetId || !memberJson) {
    throw new Error('TARGET_SHEET_ID と MEMBER_CALENDARS を先に設定してください');
  }

  const members = JSON.parse(memberJson);
  const period = getPreviousMonthRange_();
  const rows = [];

  members.forEach(function (member) {
    const calendar = CalendarApp.getCalendarById(member.calendarId);
    if (!calendar) {
      console.warn('カレンダーを取得できません: ' + member.calendarId);
      return;
    }

    // 注意: getEvents は「期間内に始まる/期間内に終わる/期間をまたぐ」
    //       予定をすべて返すため、後段で期間内に切り詰めます。
    const events = calendar.getEvents(period.start, period.end);
    const dailyMinutes = {};

    events.forEach(function (ev) {
      if (ev.isAllDayEvent()) return;             // 終日予定は稼働時間に含めない
      if (isExcluded_(ev.getTitle())) return;     // 休憩などを除外

      const startMs = Math.max(ev.getStartTime().getTime(), period.start.getTime());
      const endMs = Math.min(ev.getEndTime().getTime(), period.end.getTime());
      const minutes = (endMs - startMs) / 60000;
      if (minutes <= 0) return;

      const dayKey = Utilities.formatDate(new Date(startMs), scriptTz_(), 'yyyy-MM-dd');
      dailyMinutes[dayKey] = (dailyMinutes[dayKey] || 0) + minutes;
    });

    Object.keys(dailyMinutes).sort().forEach(function (day) {
      const hours = dailyMinutes[day] / 60;
      const overtime = Math.max(0, hours - STANDARD_HOURS_PER_DAY);
      rows.push([day, member.name, round2_(hours), round2_(overtime)]);
    });
  });

  writeToSheet_(sheetId, period.label, rows);
}

/**
 * スクリプトのタイムゾーンを返す。
 * プロジェクト設定の「タイムゾーン」(appsscript.json の timeZone)が正本です。
 * ここを固定文字列にすると、月境界の計算と日付キーの基準がずれます。
 */
function scriptTz_() {
  return Session.getScriptTimeZone();
}

/** 前月の 1 日 0:00 から当月 1 日 0:00 までを返す */
function getPreviousMonthRange_() {
  const now = new Date();
  const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
  const end = new Date(now.getFullYear(), now.getMonth(), 1);
  const label = Utilities.formatDate(start, scriptTz_(), 'yyyy-MM');
  return { start: start, end: end, label: label };
}

function isExcluded_(title) {
  return EXCLUDE_KEYWORDS.some(function (keyword) {
    return title.indexOf(keyword) !== -1;
  });
}

function round2_(num) {
  return Math.round(num * 100) / 100;
}

function writeToSheet_(sheetId, label, rows) {
  const ss = SpreadsheetApp.openById(sheetId);
  const sheet = ss.getSheetByName(label) || ss.insertSheet(label);

  sheet.clearContents();
  sheet.appendRow(['日付', '担当者', '稼働時間(h)', '所定外(h)']);

  if (rows.length > 0) {
    sheet.getRange(2, 1, rows.length, rows[0].length).setValues(rows);
  }
}

シート ID とメンバー一覧の登録(コードに直書きしない)

STEP 1・2 で控えた値は、コードに直接書かず「スクリプトプロパティ」に保存します。従業員の氏名やカレンダーID は個人情報にあたるため、コードを共有した相手に見えてしまう状態を避けるのが安全です。

  1. Apps Script エディタ左の**歯車アイコン(プロジェクト設定)**を開く
  2. 最下部の「スクリプト プロパティ」で「スクリプト プロパティを追加」をクリック
  3. プロパティ名 TARGET_SHEET_ID に、STEP 1 で控えたスプレッドシートの ID を入力
  4. プロパティ名 MEMBER_CALENDARS に、下記の形式で対象メンバーを入力
[
  { "name": "山田", "calendarId": "yamada@example.com" },
  { "name": "佐藤", "calendarId": "sato@example.com" }
]

保存したら、必ず STEP 6 の手動実行でテストしてください。次のセクションが、この記事でいちばん重要な部分です。

⚠️ 集計を静かに狂わせる3つの罠

このレシピが厄介なのは、設定を間違えてもエラーが出ず、それらしい数字が出てしまう点です。編集部が特に注意すべきと考える3つを挙げます。

⚠️ 罠1:終日予定を含めると1日24時間として計上される。Googleカレンダーの終日予定は、終了日が「翌日の0時」として扱われます。Apps Script の CalendarEvent リファレンスは、終日予定の日付を返す getAllDayEndDate() の説明で、返る値が「予定が終わる日の翌日の始まりの0時(スクリプトのタイムゾーン基準)」であると明記しています。つまり1日だけの終日予定でも終了時刻マイナス開始時刻が24時間になり、有給や出張の終日予定を混ぜたまま集計すると稼働時間が跳ね上がります。上記コードが isAllDayEvent() で除外しているのはこのためです。

⚠️ 罠2:月をまたぐ予定が二重計上される。期間指定で予定を取る仕組みは、Googleカレンダー API の絞り込み条件を見ると理解できます。API の timeMin は「予定の終了時刻の下限」、timeMax は「予定の開始時刻の上限」として定義されています。つまり期間に少しでも重なる予定は返ってくる、ということです。切り詰めずに終了時刻マイナス開始時刻で計算すると、月末から翌月にかかる予定の時間が前月分にも翌月分にも入ります。上記コードで Math.maxMath.min により期間内へ切り詰めているのは、この二重計上を防ぐためです。

なお切り詰めた時間は、その予定の開始日に全額まとめて計上されます。日をまたぐ予定(22時から翌2時など)は、4時間すべてが開始日の分として記録されます。月合計は正しく出ますが、夜勤や深夜作業がある事業所では日別の内訳が実態とずれるため、その点は割り切って使ってください。

3つ目はタイムゾーンです。Apps Script のプロジェクトにはタイムゾーン設定があり(appsscript.jsontimeZone フィールドに Asia/Tokyo のような形式で保存されます)、new Date(年, 月, 1) はこの設定に従って解釈されます。プロジェクトが日本標準時以外になっていると、月の境界が数時間ずれて集計対象が変わります。上記コードが日付の書式変換にも Session.getScriptTimeZone() を使い、'Asia/Tokyo' と直接書かないのは、月境界と日付キーの基準を1つに揃えるためです。STEP 4 で設定を確認するのは、この理由からです。

症状起こりやすい原因編集部の対策
特定の人だけ稼働時間が異常に多い終日予定(有給・出張)が混入isAllDayEvent() の除外が効いているか確認
月初・月末だけ数字が合わない月またぎ予定の二重計上、またはタイムゾーンのずれ期間の切り詰め処理とプロジェクト設定のタイムゾーンを確認
夜勤の人の日別内訳だけ合わない日をまたぐ予定が開始日に全額計上される仕様月合計は正しいので、日別が要る場合は予定を日ごとに分けて入力
特定メンバーが1行も出ないカレンダーの閲覧権限がない共有設定を確認(権限がないと静かに空になる)
予定はあるのに時間が短い除外キーワードに引っかかっているEXCLUDE_KEYWORDS の語を見直す
実績と乖離しているそもそも予定どおりに動いていない後述のとおり、実績記録の代わりにはなりません

なお Apps Script には実行時間の上限があり、1回の実行は6分、トリガー経由の合計実行時間は無料アカウントで1日90分、Workspace アカウントで1日6時間です。作れるトリガーの数も1ユーザー1スクリプトあたり20個までです(割り当てと制限)。数名から数十名規模の月次集計であれば、いずれの上限にも届くことはまずありません。

⚖️ 労務上の位置づけ — ここを外すと危険

ここが本記事で最も重要な線引きです。カレンダーの予定は「実績の記録」ではありません。

厚生労働省の「労働時間の適正な把握のために使用者が講ずべき措置に関するガイドライン」(平成29年1月20日策定)は、始業・終業時刻の確認と記録の原則的な方法を次の2つに限定しています(ガイドライン本文 PDF)。

カレンダーに入っている「16時から17時 打ち合わせ」は、その時間に本当に働いたことの記録ではなく、働く予定です。したがって、これをそのまま始業・終業時刻の記録として使うのは、上記のアともイとも異なります。

⚠️ やってはいけない使い方:このスクリプトの出力を、そのまま賃金台帳の労働時間数の根拠にすること。労働基準法第108条および同法施行規則第54条により、賃金台帳には労働日数・労働時間数・時間外労働時間数などを適正に記入する義務があり、同法第120条により、記入していない場合や故意に虚偽の労働時間数を記入した場合は30万円以下の罰金の対象です。カレンダー由来の推計値を実績として記入する運用は、この義務と正面から衝突します。

では何に使えるのか。編集部としては、次の3つを推奨します。

💡 編集部メモ:労働時間の記録に関する書類の保存年数にも注意が必要です。労働基準法第109条は「五年間保存しなければならない」と定めていますが、附則第143条第1項により「当分の間」は三年間と読み替えられます。手元の記事や解説で3年と5年が混在しているのはこのためで、現時点の実務上の下限は3年です。スプレッドシートで残す場合も、月ごとのシートを消さずに積み上げる運用にしておくのが安全です。

あわせて、労働安全衛生法第66条の8の3は、医師による面接指導を実施するため、厚生労働省令で定める方法により労働者の労働時間の状況を把握することを事業者に義務づけています。長時間労働の把握は「やったほうがよいこと」ではなく法律上の義務であり、この記事の仕組みはその義務を果たすための補助であって代替ではない、と理解してください。

🚀 経営者はこの40分をどう回収するか

編集部の想定シナリオを1つ挙げます。従業員6名の制作会社で、管理者が毎月末にメンバー全員のカレンダーを開いて稼働時間を数え、スプレッドシートへ転記しているケースです(実在の企業の事例ではありません)。

基本形が動いたら、次の応用も同じコードの延長で組めます。

当サイトでは、GAS とスプレッドシートを組み合わせた自動化を複数紹介しています。あわせて読むと設計の引き出しが増えます。

編集部のまとめ:このレシピの本質は「数える作業を機械に、判断する作業を人に」振り分け直すことです。カレンダーは法定の労働時間記録にはなりませんが、月末に人が電卓を叩く下書きとしては十分に使えます。まず前月分を1回出力してみて、手集計の結果とどれくらい合うかを確かめるところから始めてください。その乖離こそが、自社の予定と実績のズレを映した最初のデータになります。

出典・参考情報


Mira / AI経営ラボ 編集長