業務自動化

GASでGoogleフォーム打刻→残業・打刻漏れを自動検知 月¥0の勤怠管理アラート

紙のタイムカードやExcelで勤怠を管理している美容室・整体院・士業事務所・小規模製造業・飲食店などの中小企業/個人事業主 の業務自動化レシピ
業種紙のタイムカードやExcelで勤怠を管理している美容室・整体院・士業事務所・小規模製造業・飲食店などの中小企業/個人事業主
ツールGoogle Apps Script
難易度★★☆ 中
設定時間約 45 分

「誰が退勤打刻を忘れたか」「今月あの人は残業何時間か」を月末にまとめて確認していませんか。Googleフォームでの打刻とGoogle Apps Scriptを組み合わせれば、打刻漏れと残業超過を毎日自動でチェックし、担当者にメールで知らせる仕組みを月¥0で作れます。

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

編集長の見解:勤怠管理でありがちな失敗は「打刻漏れも残業超過も、月末にExcelを見て初めて気づく」ことです。気づいたときには本人の記憶も曖昧で、修正申請の往復に余計な時間がかかります。ここで重要なのは、高額な勤怠管理クラウドサービスに乗り換える前に、Googleが無料で提供している実行環境(GAS)で「毎日その場で気づける」仕組みに変えることです。従業員10〜20名規模なら、これだけで十分実用に耐えます。

🔍 なぜ勤怠チェックは「月末にまとめて」になりがちなのか

編集部のシミュレーションでは、従業員15名の小規模事業者が紙のタイムカードやExcelで勤怠を管理している場合、月末に「打刻漏れの確認」「残業時間の集計」「本人への確認連絡」に平均2〜3時間かかると試算しています。これは実在企業の実測値ではなく、一般的な作業内容から編集部が見積もったものです。

この作業が月末に集中する理由は、日々の打刻データを誰も毎日見ていないことにあります。

Google Apps Script(以下GAS)は、Googleアカウントに無料で付属するプログラム実行環境です。フォームの回答が記録されるスプレッドシートを毎日決まった時刻にチェックし、異常があれば即座にメールで知らせる処理を組めます。

必要なのは「フォームとスプレッドシートを整えてコードを貼る」という最初の一手間だけです。次のセクションで全体の流れを確認します。

🗺 全体フロー — 打刻からアラートまで自動化

構造はシンプルです。従業員がフォームで打刻すると回答がスプレッドシートに溜まり、GASが毎日決まった時刻にそれを読み取って、打刻漏れと残業超過をチェックしてメールを送ります。

打刻→自動チェック→アラート通知の全体像
📱
01
フォームで打刻
氏名と出勤/退勤を選ぶだけ
📊
02
シートに自動記録
回答が自動で行に追加
⚙️
03
GASが毎日チェック
打刻漏れと残業を集計
📧
04
メールで通知
担当者へ自動アラート

打刻はフォーム入力のみ、チェックと通知はすべて自動

登場する部品を整理します。

部品役割公式ドキュメント
Googleフォーム出退勤の打刻入力。回答はスプレッドシートに保存する設定にするGoogle フォームの使い方
SpreadsheetApp打刻ログの読み取り、集計シートへの書き込みSpreadsheetApp リファレンス
時間主導型トリガー毎日決まった時刻にチェック処理を起動(エディタの画面から設定)インストール型トリガーの設定ガイド
PropertiesService通知先メールアドレスなどの設定値をコードの外で保持PropertiesService リファレンス
MailAppチェック結果をメールで担当者へ通知MailApp リファレンス
UrlFetchApp(任意)Slackへの通知を送るUrlFetchApp リファレンス

これらをつなぐコードは、このあとのセクションでそのまま貼り付けられる形で公開します。まずは土台となるスプレッドシートとフォームの形を確認します。

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

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

必要なもの役割費用の目安
Googleアカウント(無料 or Workspace)フォーム・スプレッドシート・GASの実行無料(Workspaceは既存契約で可)
Googleフォーム出退勤の打刻入力画面無料
Google Apps Script打刻チェック・集計・メール送信の実行環境無料
(任意)Slackメールに加えてチャットにも通知したい場合無料プランで可

💡 編集部の判断材料:すでにSmartHRやマネーフォワードクラウド勤怠、KING OF TIMEなどの勤怠管理クラウドサービスを契約中なら、無理に乗り換える必要はありません。GASが向くのは「まずは打刻漏れと残業超過の“気づき”だけを自動化したい」「従業員数が少なく専用サービスの契約が割高に感じる」ケースです。人数が30名を超え、給与計算や有給管理まで一体で自動化したい段階になったら、専用クラウドサービスへの移行を検討する目安になります。

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

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

作業は「フォームとスプレッドシートの土台を作る」「GAS側でコードを貼ってトリガーを設定する」の2段階です。プログラミングの知識は不要で、コードは本記事のものをそのまま貼り付ければ動きます。

GASで勤怠アラートを構築する手順
STEP 1
Googleフォームを作成
「氏名」(プルダウンまたは記述式)と「種別」(出勤・退勤の2択)の2項目だけのシンプルなフォームを作ります。
STEP 2
回答先スプレッドシートを作成
フォームの「回答」タブから「スプレッドシートの作成」を選び、自動生成されたシート名を「打刻ログ」に変更します。列は自動でタイムスタンプ・氏名・種別の3列になります。
STEP 3
従業員名簿シートを追加
同じスプレッドシート内に「従業員名簿」シートを追加し、A1に見出しとして「氏名」、A2以降に対象従業員の氏名を1行ずつ入力します。コードは2行目から読み込むため、A1を空欄や氏名にすると先頭の1名が判定対象から漏れます。
STEP 4
エディタを開きタイムゾーンを確認
「拡張機能」→「Apps Script」でエディタを開き、歯車アイコン(プロジェクトの設定)でタイムゾーンが日本標準時になっているか確認します。新規プロジェクトの初期値は日本時間ではありません。
STEP 5
コードを貼り付け、通知先を登録
下記のコードを丸ごと貼り付け、スクリプトプロパティに通知先メールアドレスと残業アラートの目安時間を登録します(手順は次項)。
STEP 6
時間主導型トリガーを設定
エディタ左の時計アイコン(トリガー)から「トリガーを追加」し、checkKintaiAndAlert を「時間主導型」→「日付ベースのタイマー」→希望の時間帯(例: 午後8時〜9時)で設定します。
STEP 7
テスト実行で確認
打刻ログに自分のテストデータを数行入力し、checkKintaiAndAlert を手動実行してメールの内容を確認します。当日分は実行のたびに上書きされる作りなので、動作確認で何度実行しても残業時間が水増しされることはありません。

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

以下をApps Scriptエディタの コード.gs に貼り付けてください。毎日1回実行する checkKintaiAndAlert が本体です。

/**
 * 「打刻ログ」シートを読み、当日分の打刻漏れ・退勤忘れ・残業超過をチェックして
 * メールで通知する。時間主導型トリガー(日付ベースのタイマー)で毎日実行する想定。
 * 同じ日に何度実行しても集計結果は変わらない(当日分を上書きする方式)。
 */
function checkKintaiAndAlert() {
  const props = PropertiesService.getScriptProperties();
  const notifyTo = props.getProperty('NOTIFY_EMAIL');
  const overtimeAlertHours = Number(props.getProperty('OVERTIME_ALERT_HOURS') || '45');
  if (!notifyTo) {
    throw new Error('NOTIFY_EMAIL がスクリプトプロパティに未設定です');
  }

  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const logSheet = ss.getSheetByName('打刻ログ');
  const rosterSheet = ss.getSheetByName('従業員名簿');
  if (!logSheet) throw new Error('「打刻ログ」シートが見つかりません。シート名を確認してください');
  if (!rosterSheet) throw new Error('「従業員名簿」シートが見つかりません。シート名を確認してください');
  const summarySheet = getOrCreateSummarySheet_(ss);

  // 日付も曜日もすべて日本時間で判定する(プロジェクトのタイムゾーン設定に左右されないようにするため)
  const today = new Date();
  const todayStr = Utilities.formatDate(today, 'Asia/Tokyo', 'yyyy-MM-dd');
  const monthKey = todayStr.slice(0, 7); // 'yyyy-MM'

  // 土日はスキップ(就業カレンダーが必要な場合はここを拡張する)
  const dow = new Date(todayStr + 'T00:00:00Z').getUTCDay(); // 0=日曜, 6=土曜
  if (dow === 0 || dow === 6) return;

  // 従業員名簿を取得(1行目は見出し行として読み飛ばす)
  const rosterLastRow = rosterSheet.getLastRow();
  const roster = rosterLastRow > 1
    ? rosterSheet.getRange(2, 1, rosterLastRow - 1, 1)
        .getValues().map((r) => String(r[0]).trim()).filter(String)
    : [];

  // 打刻ログから当日分を抽出し、氏名ごとに出勤/退勤の最早・最遅時刻を集計
  const data = logSheet.getDataRange().getValues();
  // 列: 0=タイムスタンプ 1=氏名 2=種別(Googleフォームのデフォルト列順)
  const todayPunches = {}; // { 氏名: { in: Date|null, out: Date|null } }

  for (let i = 1; i < data.length; i++) {
    const timestamp = data[i][0];
    if (!timestamp) continue;
    const name = String(data[i][1] || '').trim();
    const type = String(data[i][2] || '').trim();
    if (!name) continue;

    const punchTime = new Date(timestamp);
    const dateStr = Utilities.formatDate(punchTime, 'Asia/Tokyo', 'yyyy-MM-dd');
    if (dateStr !== todayStr) continue;

    if (!todayPunches[name]) todayPunches[name] = { in: null, out: null };
    if (type === '出勤') {
      if (!todayPunches[name].in || punchTime < todayPunches[name].in) todayPunches[name].in = punchTime;
    } else if (type === '退勤') {
      if (!todayPunches[name].out || punchTime > todayPunches[name].out) todayPunches[name].out = punchTime;
    }
  }

  const noPunch = [];      // 出勤・退勤とも記録なし
  const missingOut = [];   // 出勤はあるが退勤記録なし(退勤忘れの疑い)
  const overtimeToday = []; // 本日残業が発生した人(参考情報)
  const overLimit = [];    // 月間累計が目安時間を超えた人

  roster.forEach((name) => {
    const punch = todayPunches[name];
    if (!punch || (!punch.in && !punch.out)) {
      noPunch.push(name);
      return;
    }
    if (punch.in && !punch.out) {
      missingOut.push(name);
      return;
    }
    if (punch.in && punch.out) {
      const workedMs = punch.out.getTime() - punch.in.getTime();
      let workedHours = workedMs / (1000 * 60 * 60);
      // 労働基準法第34条: 6時間超で45分、8時間超で60分の休憩を控除する目安
      if (workedHours > 8) workedHours -= 1;
      else if (workedHours > 6) workedHours -= 0.75;

      const overtimeHoursToday = Math.max(0, workedHours - 8);
      if (overtimeHoursToday > 0) {
        overtimeToday.push(name + '(本日 約' + overtimeHoursToday.toFixed(1) + '時間)');
      }

      const cumulative = upsertDailyOvertime_(summarySheet, name, todayStr, monthKey, overtimeHoursToday);
      if (cumulative >= overtimeAlertHours) {
        overLimit.push(name + '(今月累計 約' + cumulative.toFixed(1) + '時間)');
      }
    }
  });

  if (noPunch.length === 0 && missingOut.length === 0 && overLimit.length === 0) {
    return; // 異常なしの日はメールを送らない
  }

  const bodyLines = [
    todayStr + ' の勤怠チェック結果です。',
    '',
    '■ 本日打刻記録なし(' + noPunch.length + '名): ' + (noPunch.join('、') || 'なし'),
    '■ 退勤打刻の記録なし(' + missingOut.length + '名): ' + (missingOut.join('、') || 'なし'),
    '■ 今月の残業が目安(' + overtimeAlertHours + '時間)に達した人(' + overLimit.length + '名): ' + (overLimit.join('、') || 'なし'),
    '',
    '[参考]本日残業が発生した人(' + overtimeToday.length + '名): ' + (overtimeToday.join('、') || 'なし'),
    '',
    '※目安時間は36協定の原則的な上限(月45時間)を参考にした設定値です。実際の協定内容に合わせてスクリプトプロパティで調整してください。',
  ];
  const body = bodyLines.join('\n');

  MailApp.sendEmail(notifyTo, '【勤怠アラート】' + todayStr + ' の確認事項', body);

  const slackWebhook = props.getProperty('SLACK_WEBHOOK_URL');
  if (slackWebhook) {
    postToSlack_(slackWebhook, body);
  }
}

/**
 * 「残業集計」シートの 氏名×日付 の行を上書き更新し、同じ月の合計残業時間を返す。
 * 加算ではなく上書きなので、同じ日に何度実行しても二重計上されない。
 */
function upsertDailyOvertime_(summarySheet, name, dateStr, monthKey, hoursToday) {
  const data = summarySheet.getDataRange().getValues();
  let matchedRow = 0;
  let monthlyTotal = 0;

  for (let i = 1; i < data.length; i++) {
    if (String(data[i][0]).trim() !== name) continue;
    const rowDate = normalizeDateCell_(data[i][1]);
    if (rowDate === dateStr) {
      matchedRow = i + 1;       // 実際のシート行番号(見出し行の分だけずれる)
      data[i][2] = hoursToday;  // 当日分は最新の計算結果で置き換える
    }
    if (rowDate.slice(0, 7) === monthKey) {
      monthlyTotal += Number(data[i][2] || 0);
    }
  }

  if (matchedRow) {
    summarySheet.getRange(matchedRow, 3).setValue(hoursToday);
  } else {
    summarySheet.appendRow([name, dateStr, hoursToday]);
    monthlyTotal += hoursToday;
  }
  return monthlyTotal;
}

/**
 * 日付列の値を 'yyyy-MM-dd' の文字列に揃える。
 * スプレッドシートは '2026-07-30' のような文字列を日付型として保存し直すことがあり、
 * そのまま文字列比較すると一致せず、毎日新しい行が増えて累計が正しく出なくなるため。
 */
function normalizeDateCell_(value) {
  if (value instanceof Date) {
    return Utilities.formatDate(value, 'Asia/Tokyo', 'yyyy-MM-dd');
  }
  return String(value).trim();
}

/**
 * 「残業集計」シートを取得、なければヘッダー付きで新規作成する。
 */
function getOrCreateSummarySheet_(ss) {
  let sheet = ss.getSheetByName('残業集計');
  if (!sheet) {
    sheet = ss.insertSheet('残業集計');
    sheet.appendRow(['氏名', '日付', '当日の残業時間']);
  }
  return sheet;
}

/**
 * Slack Incoming Webhook にテキストを投稿する(SLACK_WEBHOOK_URL 未設定時は呼ばれない)。
 */
function postToSlack_(webhookUrl, text) {
  UrlFetchApp.fetch(webhookUrl, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({ text: text }),
    muteHttpExceptions: true,
  });
}

スクリプトプロパティの登録(コードに直書きしない)

通知先メールアドレスなどは、コードに直接書かず「スクリプトプロパティ」に保存します。

  1. Apps Scriptエディタ左の**歯車アイコン(プロジェクトの設定)**を開く
  2. 最下部の「スクリプト プロパティ」で「スクリプト プロパティを追加」をクリック
  3. 以下の3件を登録する(SLACK_WEBHOOK_URL は任意)
プロパティ名値の例必須
NOTIFY_EMAILroumu@example.com必須
OVERTIME_ALERT_HOURS45任意(未設定時は45)
SLACK_WEBHOOK_URLSlack Incoming Webhook の URL任意

💡 編集部メモ:「本日打刻記録なし」は有給・休日出勤なしの従業員も含まれます。誤検知が気になる場合は、従業員名簿シートに「本日出勤予定」列を追加し、コードの判定条件に組み込むと精度が上がります。まずはシンプルな形で運用を始め、現場の実態に合わせて拡張するのが現実的です。

保存とトリガー設定が終わったら、次のセクションでつまずきやすい点を確認します。

⚠️ 失敗パターンと運用上の注意

GASは無料で強力ですが、勤怠データは労務管理に直結するため、通知系のレシピよりも慎重な運用が必要です。編集部が特に注意すべきと考える2点を挙げます。

⚠️ 失敗パターン1:半休・遅刻・出張などの例外を考慮せず誤検知が量産される。本記事のコードは「出勤→退勤」のシンプルな1日1組を前提にしています。休憩を挟んで一度退勤打刻をするような運用や、直行直帰の営業担当がいる場合はそのままでは誤検知が増えます。まずは事務所勤務者など運用がシンプルなグループに絞って試験導入し、例外パターンが見えてから条件分岐を追加してください。

⚠️ 失敗パターン2:タイムゾーンのずれで日付や曜日の判定がずれる。日付の境界を決めているのはApps Script側のタイムゾーン(エディタの歯車アイコン →「プロジェクトの設定」)であり、スプレッドシート側の設定(ファイル > 設定)ではありません。新規プロジェクトの初期値は日本時間ではないため、ここが未設定のままだと「月曜なのに土日と判定されてスキップされる」といったずれが起こります。STEP 4 で必ず日本標準時に変更してください(本記事のコードは日付・曜日判定を日本時間に固定しているため、設定を直せばずれません)。スプレッドシート側の設定は画面表示用として、あわせて「日本」に揃えておくと混乱がありません。

初回実行時には、Googleから「このアプリは確認されていません」という趣旨の警告が出ます。これは自分で書いたスクリプトに、自分のデータへのアクセスを許可するための画面です。詳細を開いてプロジェクト名が自分のものであることを確認し、承認すれば先へ進めます。

トラブル起こりやすい原因編集部の対策
メールが届かないNOTIFY_EMAIL の未設定・入力ミススクリプトプロパティの値を再確認
全員が「打刻記録なし」になる「従業員名簿」の氏名と「打刻ログ」の氏名表記が不一致全角/半角・姓名の間のスペース等を統一
残業時間が実態と合わない休憩控除ルールが実際の就業規則と異なるコード中の休憩控除(45分/60分)の分岐を自社規定に合わせて調整
Slack通知だけ届かないSLACK_WEBHOOK_URL のURLが誤り、または未発行Slack側でIncoming Webhookを再発行し貼り直す

なおGASには1日あたりの上限がありますが、本レシピの負荷は「1日1通のメール・数秒の実行」なので、通常の従業員規模では届きません。公式の割り当て表で確認できる主な数値は次のとおりです。

項目無料のGoogleアカウントGoogle Workspace本レシピの使用量
メール送信先100件/日1,500件/日1日1通
トリガーの合計実行時間90分/日6時間/日1日数秒
1回あたりの実行時間6分/回6分/回数秒
外部通信(URL Fetch)の回数20,000回/日100,000回/日0〜1回/日(Slack通知時のみ)

数値はApps Scriptの割り当てと制限(公式)に基づきます。次のセクションで、この45分の投資が経営上どう返ってくるかを整理します。

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

編集部の想定シナリオを1つ挙げます。従業員15名の整体院チェーンが、紙のタイムカードと目視確認で勤怠を管理しているケースです(実在の企業の事例ではありません)。

導入前後の勤怠チェック工数(編集部の想定シナリオ)
導入前(月末にまとめて確認)
  • 打刻漏れの洗い出し(月末に約1時間)
  • 残業時間の手計算(約1時間)
  • 本人への確認連絡(約30分〜、記憶が曖昧で難航しがち)
導入後(毎日自動チェック)
  • 打刻漏れは当日中にメールで判明
  • 残業累計は自動集計、目安超過も自動検知
  • 月末の確認作業は最終チェックのみ(約20分)

月あたり約2時間分の確認作業を圧縮する編集部の試算

かかった費用は¥0、かかった時間は初回設定の約45分だけです。基本形が動いたら、次の応用も同じコードの延長で組めます。

💡 編集部おすすめ:36協定の上限規制や休憩時間のルールなど、労務管理の基礎知識を体系的に押さえておきたい方には、実務書での学習もおすすめです。

Amazon で「労務管理 実務 中小企業」の書籍を探す → ※ PR・アフィリエイトリンクを含みます

当サイトでは、労務・勤怠まわりの自動化を複数紹介しています。あわせて読むと設計の引き出しが増えます。

編集部のまとめ:このレシピの本質は「打刻漏れと残業超過に、当日気づける仕組みに変える」ことです。GASを選べば、その仕組みを月¥0で、従業員数が増えても値上がりしない形で持てます。まずは事務所勤務者など運用がシンプルな一部のメンバーで試験導入し、例外パターンを洗い出したうえで対象を広げるのが堅実な順序です。なお本記事のコードが数えているのは「1日8時間を超えた分」の積み上げであり、週40時間を超えた分などは含みません。あくまで早めに気づくための目安として使い、36協定の目安時間や休憩時間のルールは実際の就業規則・労使協定と必ず突き合わせてください。最終的な労務判断は社会保険労務士等の専門家にも確認することをおすすめします。

出典・参考情報


Mira / AI経営ラボ 編集長