🧾事務自動化ラボ

スプレッドシートの日報を月報へ自動集計する(GAS・全文コピペ配布)

日報シートを年月×担当者×案件で集計して月報シートを作り直すGASのコードです。日付が文字列で入っている、数値にカンマが入っている、月がひとつずれる。集計が合わなくなる原因と対処を、公式の記載を確認したうえで書いています。

日報はシートに溜まっているのに、月報だけ毎月手で作っている。これは事務作業の中でもかなり多い形です。日報が1日20行あれば1か月で400行を超え、担当者ごと・案件ごとに拾い直すのは月末の半日仕事になります。

この記事では、日報シートを「年月 × 担当者 × 案件」で集計して、月報シートを作り直すGoogle Apps Script(GAS)を作ります。コードは全文このページに置いています。

先に正直なことを書いておきます。日付と数値の入力が完全にそろっているなら、ピボットテーブルで足ります。 スクリプトが要るのは、入力が人の手で行われていて表記がそろわない場合、月報を毎日決まった時刻に更新したい場合、月報を数式ではなく確定した値として残したい場合です。この記事の後半は、ほぼその「そろわない入力」の話になります。

作るもの

日報シートに1行1作業で記録していくと、メニューを1回押すだけで月報シートがこうなります。

年月 担当者 案件 行数 数量合計 金額合計
2026-08 佐藤 A社 12 34 156000
2026-08 鈴木 B社 8 19 92000
2026-07 佐藤 A社 21 55 248000

仕様として決めたことは3つです。

  • 月報シートは毎回まるごと作り直す。 追記式にすると、実行するたびに前月分が積み増されて二重集計になります。作り直しなら何度押しても結果が同じになります
  • 日付が読めない行は集計に入れず、行番号を出す。 黙って0として足すと、合計が合わないことに気づけません
  • 月報に必要なのは年と月だけなので、日付を Date に戻さず数字のまま持つ。 時刻を持ち回るとタイムゾーンの分だけ月がずれます(後述)

シート構成

日報シートは1行目を見出しにして、2行目から記録します。列の位置はコード先頭の設定で変えられます。

列 内容 例
A 日付 2026-08-01
B 担当者 佐藤
C 案件 A社
D 数量(件数・個数・時間など) 3
E 金額 12000

F列以降に備考などを足しても構いません。集計では使いませんが、読み込みの邪魔にもなりません。

月報シートは無ければ自動で作られます。手で書き足さないでください。 実行のたびに内容が消えます。

コード

スプレッドシートを開いて 拡張機能 > Apps Script を選び、エディタの中身をすべて消してから、下のコードを貼って保存します。

/**
 * 日報シート → 月報シート 自動集計(Google Apps Script)
 * 事務自動化ラボ  https://jimu-jidoka.vercel.app/
 *
 * 日報の「1行 = 1作業」を、年月 × 担当者 × 案件 で集計して月報シートへ書き出します。
 * 月報シートは毎回まるごと作り直すので、何度実行しても結果は同じになります(二重集計しません)。
 */

// ===== 設定 =====
// 列番号は A=1, B=2 ... です。自分のシートに合わせて数字だけ変えてください。
const CFG = {
  DAILY_SHEET: '日報',      // 元データのシート名
  REPORT_SHEET: '月報',     // 出力先のシート名(無ければ作ります)
  HEADER_ROWS: 1,           // 日報シートの見出し行数
  COL: {
    DATE: 1,                // A列: 日付
    PERSON: 2,              // B列: 担当者
    PROJECT: 3,             // C列: 案件
    QTY: 4,                 // D列: 数量(件数・個数など。空でも可)
    AMOUNT: 5,              // E列: 金額(空でも可)
  },
  REPORT_HEADER: ['年月', '担当者', '案件', '行数', '数量合計', '金額合計'],
  MAX_ERROR_SAMPLES: 20,    // 読めなかった行を何行までメニューに出すか
};

// 集計キーの区切り文字。担当者名や案件名に現れない文字を使います。
// ハイフンやスラッシュで連結すると、「A-1」+「B」と「A」+「1-B」が同じキーになって混ざります。
const SEP = '\u0001';

// ===== メニュー =====
function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('月報')
    .addItem('月報を作り直す(全期間)', 'buildMonthlyReport')
    .addItem('月報を作り直す(今月だけ)', 'buildMonthlyReportThisMonth')
    .addSeparator()
    .addItem('タイムゾーン設定を確認', 'checkTimeZone')
    .addToUi();
}

// ===== エントリポイント =====

/** 日報の全期間を集計して月報シートを作り直します。 */
function buildMonthlyReport() {
  run_(null);
}

/** 今月分だけを集計します(行数が多くなってきたとき用)。 */
function buildMonthlyReportThisMonth() {
  const tz = SpreadsheetApp.getActiveSpreadsheet().getSpreadsheetTimeZone();
  run_(Utilities.formatDate(new Date(), tz, 'yyyy-MM'));
}

/**
 * スプレッドシートとスクリプトのタイムゾーンは別々の設定です。
 * ずれていると、月初・月末の日付が1日前後して、集計先の月が変わることがあります。
 */
function checkTimeZone() {
  const ui = SpreadsheetApp.getUi();
  const ssTz = SpreadsheetApp.getActiveSpreadsheet().getSpreadsheetTimeZone();
  const scriptTz = Session.getScriptTimeZone();
  const msg = 'スプレッドシート: ' + ssTz + '\nスクリプト: ' + scriptTz + '\n\n'
    + (ssTz === scriptTz
      ? '一致しています。'
      : '一致していません。スプレッドシートの [ファイル > 設定 > タイムゾーン] と、'
        + 'スクリプトエディタの [プロジェクトの設定 > タイムゾーン] を同じにしてください。');
  ui.alert('タイムゾーン', msg, ui.ButtonSet.OK);
}

// ===== 本体 =====

function run_(monthFilter) {
  const ui = SpreadsheetApp.getUi();
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const tz = ss.getSpreadsheetTimeZone();

  const daily = ss.getSheetByName(CFG.DAILY_SHEET);
  if (!daily) {
    ui.alert('「' + CFG.DAILY_SHEET + '」という名前のシートが見つかりません。');
    return;
  }

  const rows = readDailyRows_(daily);
  if (rows.length === 0) {
    ui.alert('「' + CFG.DAILY_SHEET + '」に集計できるデータ行がありません。');
    return;
  }

  const result = aggregate_(rows, tz, monthFilter);
  writeReport_(ss, result.records, monthFilter, tz);

  let msg = result.records.length + ' 行の月報を書き出しました。'
    + '(読み取り ' + rows.length + ' 行 / 集計対象 ' + result.counted + ' 行)';
  if (result.skipped.length > 0) {
    msg += '\n\n日付が読めずに飛ばした行が ' + result.skipped.length + ' 行あります:\n'
      + result.skipped.slice(0, CFG.MAX_ERROR_SAMPLES).join('\n');
    if (result.skipped.length > CFG.MAX_ERROR_SAMPLES) msg += '\n…ほか';
  }
  ui.alert('月報', msg, ui.ButtonSet.OK);
}

/**
 * 日報シートを1回の getValues() で読み込みます。
 * 1セルずつ getValue() で回すと、行数が増えたところで実行時間の上限に当たります。
 */
function readDailyRows_(sheet) {
  const lastRow = sheet.getLastRow();
  const lastCol = Math.max(
    sheet.getLastColumn(),
    CFG.COL.DATE, CFG.COL.PERSON, CFG.COL.PROJECT, CFG.COL.QTY, CFG.COL.AMOUNT
  );
  const numRows = lastRow - CFG.HEADER_ROWS;
  if (numRows <= 0) return [];

  const values = sheet.getRange(CFG.HEADER_ROWS + 1, 1, numRows, lastCol).getValues();

  // getLastRow() は「内容のある最後の行」を返します。数式が空文字を返しているだけの行も
  // 内容ありと数えられるため、全列が空の行はここで捨てます。
  const rows = [];
  for (let i = 0; i < values.length; i++) {
    const row = values[i];
    if (row.every(function (v) { return v === '' || v === null; })) continue;
    rows.push({ rowNumber: CFG.HEADER_ROWS + 1 + i, values: row });
  }
  return rows;
}

/**
 * 年月 × 担当者 × 案件 で合計します。
 * monthFilter に 'yyyy-MM' を渡すとその月だけ集計します(null なら全期間)。
 */
function aggregate_(rows, tz, monthFilter) {
  const map = {};
  const order = [];
  const skipped = [];
  let counted = 0;

  for (let i = 0; i < rows.length; i++) {
    const cells = rows[i].values;
    const ymd = normalizeYmd_(cells[CFG.COL.DATE - 1], tz);
    if (!ymd) {
      skipped.push(rows[i].rowNumber + '行目: ' + describeCell_(cells[CFG.COL.DATE - 1]));
      continue;
    }

    const month = ymd.y + '-' + pad2_(ymd.m);
    if (monthFilter && month !== monthFilter) continue;

    const person = String(cells[CFG.COL.PERSON - 1] || '').trim() || '(担当者なし)';
    const project = String(cells[CFG.COL.PROJECT - 1] || '').trim() || '(案件なし)';
    const key = month + SEP + person + SEP + project;

    if (!map[key]) {
      map[key] = { month: month, person: person, project: project, count: 0, qty: 0, amount: 0 };
      order.push(key);
    }
    const rec = map[key];
    rec.count += 1;
    rec.qty += toNumber_(cells[CFG.COL.QTY - 1]) || 0;
    rec.amount += toNumber_(cells[CFG.COL.AMOUNT - 1]) || 0;
    counted += 1;
  }

  const records = order.map(function (k) { return map[k]; });
  records.sort(function (a, b) {
    if (a.month !== b.month) return a.month < b.month ? 1 : -1; // 新しい月を上に
    if (a.person !== b.person) return a.person < b.person ? -1 : 1;
    return a.project < b.project ? -1 : (a.project > b.project ? 1 : 0);
  });
  return { records: records, skipped: skipped, counted: counted };
}

/**
 * セルの値から { y, m, d } を取り出します。Date へ戻さず数字のまま持つのは、
 * 月報に必要なのが年と月だけで、時刻を持ち回るとタイムゾーンの分だけ月がずれるためです。
 * 対応: Date / 日付書式が外れたシリアル値 / '2026-08-01' '2026/8/1' '2026年8月1日' '2026-08'
 */
function normalizeYmd_(value, tz) {
  if (value === '' || value === null || value === undefined) return null;

  if (Object.prototype.toString.call(value) === '[object Date]') {
    if (isNaN(value.getTime())) return null;
    // 画面に見えている日付に合わせるため、スプレッドシートのタイムゾーンで文字列化して読み直す
    const s = Utilities.formatDate(value, tz, 'yyyy-MM-dd').split('-');
    return { y: Number(s[0]), m: Number(s[1]), d: Number(s[2]) };
  }

  if (typeof value === 'number' && isFinite(value)) {
    // 日付書式が外れているセルはシリアル値の数値で返ります。Sheets ではシリアル 0 が 1899-12-30。
    if (value < 1 || value > 402133) return null; // 1899-12-31 〜 3000-12-31 の外は日付とみなさない
    const d = new Date(Date.UTC(1899, 11, 30) + Math.floor(value) * 86400000);
    return { y: d.getUTCFullYear(), m: d.getUTCMonth() + 1, d: d.getUTCDate() };
  }

  const text = toHalfWidth_(String(value)).trim();
  let m = text.match(/^(\d{4})\D{1,2}(\d{1,2})\D{1,2}(\d{1,2})/);
  if (m) return validYmd_(Number(m[1]), Number(m[2]), Number(m[3]));
  m = text.match(/^(\d{4})\D{1,2}(\d{1,2})\D?$/);
  if (m) return validYmd_(Number(m[1]), Number(m[2]), 1);
  return null;
}

function validYmd_(y, mo, d) {
  if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
  return { y: y, m: mo, d: d };
}

/**
 * 数量・金額のセルを数値にします。
 * 「1,200」「¥1,200」「1200円」「1200」のような入力が実際に混ざります。
 */
function toNumber_(value) {
  if (typeof value === 'number') return isFinite(value) ? value : null;
  if (value === '' || value === null || value === undefined) return null;
  const text = toHalfWidth_(String(value))
    .replace(/[,\s]/g, '')
    .replace(/[¥¥]/g, '')
    .replace(/(円|件|個|本|時間|h)$/i, '');
  if (!/^-?\d+(\.\d+)?$/.test(text)) return null;
  const n = Number(text);
  return isFinite(n) ? n : null;
}

/** 全角の数字・記号を半角へ寄せます。 */
function toHalfWidth_(s) {
  return s.replace(/[0-9.,-ー─]/g, function (c) {
    if (c === 'ー' || c === '─') return '-';
    return String.fromCharCode(c.charCodeAt(0) - 0xFEE0);
  });
}

function pad2_(n) {
  return (n < 10 ? '0' : '') + n;
}

function describeCell_(v) {
  if (v === '' || v === null || v === undefined) return '(空欄)';
  return '「' + String(v).slice(0, 40) + '」';
}

/** 月報シートを作り直します。書き込みは setValues() で1回だけ行います。 */
function writeReport_(ss, records, monthFilter, tz) {
  let sheet = ss.getSheetByName(CFG.REPORT_SHEET);
  if (!sheet) sheet = ss.insertSheet(CFG.REPORT_SHEET);

  // 前回の内容を消す。前回の方が行数・列数が多いことがあるので、実際の使用範囲で消します。
  const lastRow = sheet.getLastRow();
  const lastCol = sheet.getLastColumn();
  if (lastRow > 0 && lastCol > 0) {
    sheet.getRange(1, 1, lastRow, lastCol).clearContent();
  }

  // setValues() に渡す配列は、全行の長さがそろっている必要があります。
  const table = [CFG.REPORT_HEADER.slice()];
  for (let i = 0; i < records.length; i++) {
    const r = records[i];
    table.push([r.month, r.person, r.project, r.count, r.qty, r.amount]);
  }
  sheet.getRange(1, 1, table.length, CFG.REPORT_HEADER.length).setValues(table);

  sheet.getRange(1, 1, 1, CFG.REPORT_HEADER.length).setFontWeight('bold');
  sheet.setFrozenRows(1);
  sheet.getRange(1, CFG.REPORT_HEADER.length + 2).setValue(
    '最終更新 ' + Utilities.formatDate(new Date(), tz, 'yyyy-MM-dd HH:mm')
      + (monthFilter ? '(' + monthFilter + ' のみ)' : '(全期間)')
  );
}

保存したらスプレッドシートを開き直してください。上部に「月報」というメニューが増えます。初回だけ承認画面が出るので、自分のアカウントで許可します。

動かす前に、タイムゾーンを合わせる

メニューの 月報 > タイムゾーン設定を確認 を先に押してください。ここが合っていないと、月初・月末の1日が隣の月に入ります。

スプレッドシートのタイムゾーンと、スクリプトのタイムゾーンは別の設定です。 それぞれ次の場所にあります。

  • スプレッドシート: ファイル > 設定 > タイムゾーン
  • スクリプト: Apps Scriptエディタの プロジェクトの設定 > タイムゾーン

スクリプト側は、プロジェクトのマニフェスト(appsscript.json)の timeZone フィールドとして保存されています。公式リファレンスでは「America/Denver のような ZoneId 値で指定するスクリプトのタイムゾーン」と説明されています(Manifest structure)。日本で使うなら両方 Asia/Tokyo です。

新規のスプレッドシートは作成時のロケールに引きずられて、片方だけ America/New_York のまま、ということが起こります。8月1日 00:00 の日報が7月31日として集計されていたら、まずここを疑ってください。

つまずいた箇所と、その原因

ここからが本題です。集計スクリプトが合わなくなる原因は、だいたいこの8つのどれかでした。

1. YYYY と yyyy は違うものを返す

年月の文字列を作るとき Utilities.formatDate(d, tz, 'YYYY-MM') と書いてしまうと、年末年始の数日だけ、暦の年と1年ずれた値が出ます。

Utilities.formatDate() の書式は、公式リファレンスに「Java SE の SimpleDateFormat クラスで説明されている仕様に従う」と明記されています(Utilities.formatDate)。そのSimpleDateFormatの記号表では、

  • y は Year(暦の年)
  • Y は Week year(その日が属する「週」がどの年に数えられるか)
  • M は Month in year(月)
  • m は Minute in hour(分)

と定義されています(SimpleDateFormat)。

Y は年をまたぐ週の扱いが週の定義に依存するため、12月末〜1月初の数日で暦年と食い違います。ずれる日付はロケール依存なので「この日がずれる」とは書けませんが、yyyy を使えばそもそも起きません。 同じ理由で yyyy-mm と書くと月のはずの場所に分が入り、2026-37 のような値になります。

小文字の yyyy-MM。ここだけです。

2. 日付列に文字列が混ざる

日付列に見えていても、実際に入っているのが Date とは限りません。手入力の 2026年8月1日、他システムからの貼り付けで文字列になった 2026/8/1、フォーム経由で入った日時。見た目はどれも日付です。

getValues() が返す値の型は、公式リファレンスに「セルの値に応じて Number、Boolean、Date、String のいずれか」「空のセルは空文字列で表される」と書かれています(Range.getValues())。セルの中身次第で型が変わるので、value.getMonth() のように Date 前提で書くと、文字列の行で落ちます。

このコードでは normalizeYmd_() が Date / 数値 / 文字列を全部受けて { y, m, d } に直しています。読めなかった行は合計に入れず、行番号を通知に出します。

3. 日付書式が外れると、数値のシリアル値で返ってくる

セルの表示形式が「日付」から「数値」に変わっていると、getValues() は 46262 のような数値を返します。これは日付ではなくシリアル値で、Google スプレッドシートでは 1899年12月30日が 0 です。

46262(2026年8月28日)を金額と間違えて足すと、その行だけ桁が違う合計になります。コードでは「シリアル1(1899年12月31日)から402133(3000年12月31日)までの数値は日付として解釈する」という範囲を決めて、それ以外は日付とみなさないようにしています。

const d = new Date(Date.UTC(1899, 11, 30) + Math.floor(value) * 86400000);
return { y: d.getUTCFullYear(), m: d.getUTCMonth() + 1, d: d.getUTCDate() };

Date.UTC と getUTC* でそろえているのは、ここでローカル時刻を混ぜると実行環境の時差の分だけ1日ずれるためです。

4. 数値にカンマ・単位・全角が入る

1,200 ¥1,200 1200円 1200。金額欄と数量欄には、これが本当に混ざります。Number('1,200') は NaN を返し、NaN を足した合計はそこから先すべて NaN になります。1セルの表記ゆれで、列全体の合計が壊れます。

コードの toNumber_() で、全角を半角に寄せ、カンマ・空白・通貨記号・末尾の単位を落としてから数値にしています。それでも数値にならない値は null として合計に足しません。

5. getLastRow() は、空に見える行を拾うことがある

getLastRow() は「内容のある最後の行」を返します。問題は、数式が空文字を返しているだけのセルも「内容あり」と数えられることです。=IF(A2="","",...) を下まで引いてあるシートだと、最終行が数千行目になります。

そのまま集計すると空行が大量に「日付が読めない行」として通知に並びます。読み込んだ後に、全列が空の行を捨てています。

if (row.every(function (v) { return v === '' || v === null; })) continue;

6. setValues() は行の長さがそろっていないと通らない

書き込みは setValues() に二次元配列を渡しますが、指定した範囲の行数・列数と、配列の形が一致していないとエラーで止まります。 集計結果を組み立てるとき、ある行だけ列を1つ足し忘れる、というのがやりがちです。

このコードでは見出しと本文を同じ列数で組んでから、行数を配列の長さから決めて渡しています。

sheet.getRange(1, 1, table.length, CFG.REPORT_HEADER.length).setValues(table);

7. 前回より行数が減ると、古い行が残る

月報を作り直すとき、書き込みだけして消していないと、前回の方が行数が多かった場合に古い行が下に残ります。 先月まで3人いた担当者が2人になった月に、いない人の行が残り続けます。

書き込みの前に、実際の使用範囲を消しています。clearContent() の範囲を固定値で書かず getLastRow() / getLastColumn() から取っているのは、前回どこまで書いたかを覚えていなくても消せるようにするためです。

8. 集計キーの区切り文字

年月・担当者・案件をつないでキーにするとき、区切りをハイフンやスラッシュにすると、担当者「A-1」+案件「B」と、担当者「A」+案件「1-B」が同じキーになって合算されます。案件コードにハイフンを使っている現場では普通に起きます。

コードでは、キーボードから入力されることのない制御文字 \u0001 を区切りにしています。担当者名や案件名に現れないためです。

おまけ: 1セルずつ読むと遅い

これは正しさではなく速度の話ですが、getValue() / setValue() を行数ぶんループで回すと、行が増えたところで実行時間の上限に当たります。

公式のベストプラクティスには、1万セルを1セルずつ書き込む例と、配列にためて1回で書き込む例が並べて載っていて、前者は約70秒、後者は約1秒と書かれています(Best Practices)。読み込みも書き込みも1回にまとめるのが、GASでは正しさと同じくらい効きます。

同じページには、スプレッドシートが1000万セルの上限に近づいている場合や、連携フォームが10個以上あって複雑なシート間数式がある場合は、データベースの利用を検討するようにとも書かれています。日報が数万行に達したら、スプレッドシートで粘るより移す判断が要ります。

毎日自動で更新する

メニューを押さずに回すなら、時間主導型のトリガーで buildMonthlyReportThisMonth を1日1回動かします。全期間の集計は行が増えるほど重くなるので、定期実行は当月分だけにするほうが安全です。

ただし、トリガーで動かした処理は、失敗しても画面には何も出ません。 月報が3日前の内容のまま止まっていても、見た目では分かりません。定期実行の失敗をどう検知するかは別記事に分けてあります。

→ GASのトリガーが止まっても気づけるようにする

日報の入力自体を楽にする

日報の入力を各自にスプレッドシートへ直接書いてもらうと、行の挿入位置を間違えたり、書式ごと貼り付けられたりします。Googleフォームで受けて回答シートを日報シートとして使うと、日付と担当者の型がそろうので、この記事の表記ゆれ対応はかなり出番が減ります。

フォームの受付側(自動返信・担当者通知・受付台帳)はこちらに書いています。

→ Googleフォームの受付を自動化する

検証した範囲

  • コードは構文チェックを通したうえで、SpreadsheetApp / Utilities / Session をスタブしたNode環境で13分類・56アサーションを通しています。内訳は、Date / シリアル値 / 6種類の文字列表記 / 不正値の日付解釈、カンマ・通貨記号・単位・全角を含む10種類の数値解釈、空行の除外、集計とソートと月フィルタ、キー衝突、書き出し配列の形、2回実行しても結果が変わらないこと、前回より行数が減ったとき古い行が残らないこと、シート不在とデータ0件での停止、通し実行の出力です
  • 実アカウントでの通し実行はしていません。 Googleアカウントの承認が要る領域のため、ここでは「実機で確認した」とは書けません。書式・タイムゾーン・型の扱いについては、この記事で挙げた公式リファレンスの記載を確認したうえで実装しています
  • タイムゾーンについて確認したのは「スプレッドシートとスクリプトで別々の設定である」「マニフェストの timeZone に ZoneId で入る」「formatDate は SimpleDateFormat の仕様に従う」という3点です。両者がずれたときの内部的な変換順序までは公式に記載がなかったため、設定を一致させるという対処にとどめています

まとめ

集計スクリプトが合わなくなる原因は、集計のロジックではなく、ほぼ入力値の型でした。日付が文字列で入っている、数値にカンマが入っている、書式が外れてシリアル値になっている。この3つを先に正規化してしまえば、あとの集計は素直です。

そして、合わない行を黙って0にしないこと。「12行を飛ばしました」と出るスクリプトのほうが、何も言わずに合計を出すスクリプトより信用できます。

請求書の発行まで同じ考え方で組んだものは、こちらに置いています。

→ スプレッドシートで請求書を自動発行する仕組みの作り方

← 記事一覧へ