コピペで作る月次レポートにサヨナラ—集計+整形+凍結保管を自動化する

実際のビジネスシーンで使える自動化100例をGASで実装するシリーズ「GAS自働化100本ノック」。その7本目

ピボットテーブルの強みと弱み

売上明細の例

データ集計にピボットテーブルを使っている会社、多いですよね。
確かに、便利で優秀です。

例えば、上記のような売上明細シートから、1月のカテゴリ別売上合計を出すには、ピボットテーブルの行にカテゴリ、値に売上金額を指定して、1月でフィルタリングすれば、下図のように、秒で集計してくれます。

ピボットテーブルの集計結果

かしこい!

ただ、ここから、1月の月次レポートを作成する時、どうしていますか?

ピボットテーブルは元データをリアルタイムに計算するのは強いですが、過去のある時点の状態を保持するのは弱い、不向きです。

結局、集計結果を切り貼りして手作業でレポート作成することになってはいませんか?

集計・整形・凍結保管まで自動化

GASなら、データの集計はもちろん、レポートの整形、凍結保管まで自動化できます。

レポートのセクション見出し、色付け、枠線の調整など、体裁の作り込みを手作業でやると、それだけで半日かかったりしますね。

GASなら、ワンクリックです!

GASでできること

  1. 売上明細から指定月のデータを抽出し、サマリーカテゴリ別売上商品トップ10を自動集計

  2. 集計結果を月次レポート作成時点で凍結し、3つのセクションに整形して「月次レポート_YYYY-MM」シートを自動作成

シート名に対象月が入るので、何ヶ月分でも履歴として並べて残せます。
元データが後から増減しても、過去のレポートシートはその時点の値で凍結されます。

そのコードがこちら。

コード.gs

// コード.gs
/**
 * 売上明細から指定月の月次レポートを自動生成する
 *   - サマリー(合計売上・取引件数・平均客単価)
 *   - カテゴリ別売上(金額・件数・構成比)
 *   - 商品トップ10(売上金額順)
 * レポートは「月次レポート_YYYY-MM」名のシートとして出力(履歴として残る)
 */

const SOURCE_SHEET_NAME = '売上明細';
const COL_DATE = '日付';
const COL_CATEGORY = 'カテゴリ';
const COL_PRODUCT = '商品名';
const COL_AMOUNT = '売上金額';

const REPORT_COLS = 5;
const COLOR_SECTION = '#cfe2f3';
const COLOR_TABLE_HEAD = '#f3f3f3';

function generateMonthlyReport() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const ui = SpreadsheetApp.getUi();

  const source = ss.getSheetByName(SOURCE_SHEET_NAME);
  if (!source) {
    ui.alert('「' + SOURCE_SHEET_NAME + '」シートが必要です。');
    return;
  }

  const data = source.getDataRange().getValues();
  if (data.length < 2) {
    ui.alert('「' + SOURCE_SHEET_NAME + '」にヘッダー+データ行が必要です。');
    return;
  }

  const header = data[0];
  const dateIdx = header.indexOf(COL_DATE);
  const categoryIdx = header.indexOf(COL_CATEGORY);
  const productIdx = header.indexOf(COL_PRODUCT);
  const amountIdx = header.indexOf(COL_AMOUNT);
  const missing = [];
  if (dateIdx === -1) missing.push(COL_DATE);
  if (categoryIdx === -1) missing.push(COL_CATEGORY);
  if (productIdx === -1) missing.push(COL_PRODUCT);
  if (amountIdx === -1) missing.push(COL_AMOUNT);
  if (missing.length > 0) {
    ui.alert('「' + SOURCE_SHEET_NAME + '」に必要な列がありません: ' + missing.join(' / '));
    return;
  }

  // 既定の対象月: データ内の最新月
  const allMonths = [];
  for (let i = 1; i < data.length; i++) {
    const m = toMonth_(data[i][dateIdx]);
    if (m) allMonths.push(m);
  }
  if (allMonths.length === 0) {
    ui.alert('日付列に有効な日付がありません。');
    return;
  }
  allMonths.sort();
  const defaultMonth = allMonths[allMonths.length - 1];

  const resp = ui.prompt(
    '月次レポート生成',
    '対象月を YYYY-MM 形式で入力してください。\n(空欄なら既定値「' + defaultMonth + '」を使います)',
    ui.ButtonSet.OK_CANCEL
  );
  if (resp.getSelectedButton() !== ui.Button.OK) return;
  const targetMonth = (resp.getResponseText() || '').trim() || defaultMonth;
  if (!/^\d{4}-\d{2}$/.test(targetMonth)) {
    ui.alert('月の形式は YYYY-MM です。例: 2026-04');
    return;
  }

  const monthRows = [];
  for (let i = 1; i < data.length; i++) {
    if (toMonth_(data[i][dateIdx]) === targetMonth) monthRows.push(data[i]);
  }
  if (monthRows.length === 0) {
    ui.alert('「' + targetMonth + '」のデータがありません。');
    return;
  }

  const totalAmount = monthRows.reduce(function(s, r) {
    return s + (Number(r[amountIdx]) || 0);
  }, 0);
  const transactionCount = monthRows.length;
  const avgPerTransaction = Math.round(totalAmount / transactionCount);

  const byCategory = {};
  monthRows.forEach(function(r) {
    const cat = String(r[categoryIdx]);
    if (!byCategory[cat]) byCategory[cat] = { amount: 0, count: 0 };
    byCategory[cat].amount += Number(r[amountIdx]) || 0;
    byCategory[cat].count += 1;
  });
  const categoryRows = Object.keys(byCategory).map(function(cat) {
    return [cat, byCategory[cat].amount, byCategory[cat].count, byCategory[cat].amount / totalAmount];
  }).sort(function(a, b) { return b[1] - a[1]; });

  const byProduct = {};
  monthRows.forEach(function(r) {
    const key = r[productIdx];
    if (!byProduct[key]) byProduct[key] = { name: r[productIdx], category: r[categoryIdx], amount: 0, count: 0 };
    byProduct[key].amount += Number(r[amountIdx]) || 0;
    byProduct[key].count += 1;
  });
  const productRanking = Object.keys(byProduct).map(function(k) { return byProduct[k]; })
    .sort(function(a, b) { return b.amount - a.amount; })
    .slice(0, 10)
    .map(function(p, i) { return [i + 1, p.name, p.category, p.amount, p.count]; });

  writeReport_(ss, targetMonth, totalAmount, transactionCount, avgPerTransaction, categoryRows, productRanking);

  SpreadsheetApp.flush();
  ui.alert(
    '月次レポートを生成しました。\n\n' +
    '対象月: ' + targetMonth + '\n' +
    '合計売上: ¥' + totalAmount.toLocaleString() + '\n' +
    '取引件数: ' + transactionCount + ' 件\n\n' +
    '「月次レポート_' + targetMonth + '」シートを確認してください。'
  );
}

function toMonth_(value) {
  if (value instanceof Date) {
    return Utilities.formatDate(value, Session.getScriptTimeZone(), 'yyyy-MM');
  }
  const s = String(value);
  return /^\d{4}-\d{2}/.test(s) ? s.substring(0, 7) : '';
}

function writeReport_(ss, month, totalAmount, transactionCount, avgPerTransaction, categoryRows, productRanking) {
  const sheetName = '月次レポート_' + month;
  let sheet = ss.getSheetByName(sheetName);
  if (sheet) {
    sheet.clear();
    sheet.clearConditionalFormatRules();
  } else {
    sheet = ss.insertSheet(sheetName);
  }

  // 全セクションを1つの2次元配列にまとめて、最後に setValues で一括書き込み
  const rows = [];
  const fmt = []; // [行, 列, 列数, 種別] の指示リスト

  // タイトル
  rows.push(['月次レポート ' + month, '', '', '', '']);
  rows.push(['', '', '', '', '']);

  // サマリー
  fmt.push({ row: rows.length + 1, type: 'section' });
  rows.push(['■ サマリー', '', '', '', '']);
  fmt.push({ row: rows.length + 1, type: 'summaryAmount' });
  rows.push(['対象期間', month, '', '', '']);
  fmt.push({ row: rows.length + 1, type: 'summaryAmount' });
  rows.push(['合計売上', totalAmount, '', '', '']);
  fmt.push({ row: rows.length + 1, type: 'summaryNumber' });
  rows.push(['取引件数', transactionCount, '', '', '']);
  fmt.push({ row: rows.length + 1, type: 'summaryAmount' });
  rows.push(['平均客単価', avgPerTransaction, '', '', '']);
  rows.push(['', '', '', '', '']);

  // カテゴリ別
  fmt.push({ row: rows.length + 1, type: 'section' });
  rows.push(['■ カテゴリ別売上', '', '', '', '']);
  fmt.push({ row: rows.length + 1, type: 'tableHead' });
  rows.push(['カテゴリ', '売上金額', '件数', '構成比', '']);
  const categoryStartRow = rows.length + 1;
  categoryRows.forEach(function(r) { rows.push([r[0], r[1], r[2], r[3], '']); });
  const categoryEndRow = rows.length;
  rows.push(['', '', '', '', '']);

  // 商品トップ10
  fmt.push({ row: rows.length + 1, type: 'section' });
  rows.push(['■ 商品トップ10', '', '', '', '']);
  fmt.push({ row: rows.length + 1, type: 'tableHead' });
  rows.push(['順位', '商品名', 'カテゴリ', '売上金額', '件数']);
  const productStartRow = rows.length + 1;
  productRanking.forEach(function(r) { rows.push(r); });
  const productEndRow = rows.length;

  sheet.getRange(1, 1, rows.length, REPORT_COLS).setValues(rows);

  // 体裁
  sheet.getRange(1, 1).setFontSize(14).setFontWeight('bold');
  fmt.forEach(function(f) {
    if (f.type === 'section') {
      sheet.getRange(f.row, 1, 1, REPORT_COLS)
        .setFontWeight('bold')
        .setBackground(COLOR_SECTION);
    } else if (f.type === 'tableHead') {
      sheet.getRange(f.row, 1, 1, REPORT_COLS)
        .setFontWeight('bold')
        .setBackground(COLOR_TABLE_HEAD)
        .setHorizontalAlignment('center');
    } else if (f.type === 'summaryAmount') {
      sheet.getRange(f.row, 1).setFontWeight('bold');
    } else if (f.type === 'summaryNumber') {
      sheet.getRange(f.row, 1).setFontWeight('bold');
    }
  });

  // 数値フォーマット
  // サマリー: 合計売上(行3+B), 取引件数(B), 平均客単価(B)
  sheet.getRange(5, 2).setNumberFormat('¥#,##0'); // 合計売上
  sheet.getRange(7, 2).setNumberFormat('¥#,##0'); // 平均客単価
  // カテゴリ: 売上金額(B列)、構成比(D列)
  if (categoryEndRow >= categoryStartRow) {
    sheet.getRange(categoryStartRow, 2, categoryEndRow - categoryStartRow + 1, 1).setNumberFormat('¥#,##0');
    sheet.getRange(categoryStartRow, 4, categoryEndRow - categoryStartRow + 1, 1).setNumberFormat('0.0%');
  }
  // 商品トップ10: 売上金額(D列)
  if (productEndRow >= productStartRow) {
    sheet.getRange(productStartRow, 4, productEndRow - productStartRow + 1, 1).setNumberFormat('¥#,##0');
  }

  // 列幅
  sheet.setColumnWidth(1, 130);
  sheet.setColumnWidth(2, 140);
  sheet.setColumnWidth(3, 90);
  sheet.setColumnWidth(4, 110);
  sheet.setColumnWidth(5, 80);
}

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('自動ボタン')
    .addItem('月次レポート生成', 'generateMonthlyReport')
    .addToUi();
}

使い方

① 売上明細シートを用意
新規スプレッドシートを開き、「売上明細」シートに以下の列を含むデータを入れます。
`日付` `商品コード` `商品名` `カテゴリ` `数量` `単価` `売上金額` `担当者`

列の並び順は変えてもOK(列名で位置を判定するため)。
日付は文字列「2026-01-23」でもDate型でも構いません。

② 拡張機能 → Apps Script にコードを貼り付けて保存

③ スプレッドシートをリロード
メニューバーに「自動ボタン」が追加されます。

④ 「自動ボタン」→「月次レポート生成」を実行
初めて実行すると承認ダイアログが表示されます。手順は第1回と同じです。

⑤ 対象月をプロンプトで入力
`YYYY-MM` 形式で入力します(例: `2026-01`)。
空欄で OK を押すと、データ内の最新月が既定値として使われます。

⑥ 実行結果を確認
「月次レポート_2026-01」のような名前のシートが自動生成されます。
中身は3つのセクションが整形済みで並んでいます。

月次レポートの例

来月になったら同じ操作を「2026-02」で実行するだけ。
月次レポート_2026-02 が新しく追加され、過去のシートはそのまま残ります。
「先月の月報をもう一度確認したい」と言われたら、シートタブを切り替えるだけで終わります。

ワンポイント・レッスン

setValues() — 2次元配列でシートに一気に書き出す

184行目のこの1行が今回の主役です。

sheet.getRange(1, 1, rows.length, REPORT_COLS).setValues(rows);

ここまでに `rows` という変数に、レポート全体の中身を 2次元配列(行の配列) として組み上げてあります。

const rows = [];
rows.push(['月次レポート ', '', '', '', '']);  // タイトル行
rows.push(['', '', '', '', '']);                       // 空行
rows.push(['■ サマリー', '', '', '', '']);
rows.push(['対象期間', month, '', '', '']);
rows.push(['合計売上', totalAmount, '', '', '']);
// ... カテゴリ別、商品トップ10と続く

最後に `setValues(rows)` を1回呼ぶだけで、何十行ものデータがまとめてシートに書き込まれます。

なぜ「まとめて書く」のか?

GASからスプレッドシートを操作するときは、APIを呼ぶたびに通信が発生します。
1セルずつ `setValue()` を呼ぶと、30行のレポートで30回の通信。`setValues()` で2次元配列を渡せば 通信は1回
同じ結果でも、出力規模が大きくなるほど速度差がはっきり出ます。

✗ 遅い:  for (...) { sheet.getRange(行, 列).setValue(値); }   ← 30回通信
○ 速い:  sheet.getRange(1, 1, 30, 5).setValues(rows);         ← 1回通信

`setValues()` を使うときの約束は2つだけです。

  • 2次元配列を渡す: `rows = [['A1の値’, 'B1の値’], ['A2の値’, 'B2の値’]]` の形

  • getRange の行数・列数を配列のサイズに合わせる: `rows.length` 行、`rows[0].length` 列

このパターンは「集計結果をまとめて書き出す系」の処理すべてに使えます。
月次レポート、ランキング、ピボット風の集計表、フィルタ後の抽出結果—どれも 「①集計でデータを2次元配列に組み立てる → ② setValues で1回書く」 という同じ形になります。
コードの後半が「シート操作のループ」だらけになっているなら、配列に積み上げてから1回で書く ように書き換えるだけで、見通しも速度も一段上がります。

まとめ

  • 売上明細から指定月のデータを抽出し、サマリー・カテゴリ別売上・商品トップ10を整形済みで月次レポートシートに出力。

  • シート名に対象月が入るので、何ヶ月分でも履歴として並べて残せます。

  • 元データが後から変わっても、過去のレポートはその時点の値で凍結されます。


ところで、実際のビジネスシーンでは、単月レポートを作ったらそれでお仕舞い、とはならないですね。

「月を跨いでの推移」が必要になってくるのが常です。

・各月のカテゴリ別売上を一目で知りたい
・月ごとの合計と前月比を知りたい
・表よりグラフの方が推移が分かりやすい …
といったニーズが出てきます。

拡張コードでこれらを解決しましょう!

拡張コードでできること

  1. 月×カテゴリのクロス集計 — 期間内の各月のカテゴリ別売上を1枚の表にまとめる

  2. 月別合計+前月比 — 月ごとの合計と前月比(+/-付き%)を自動計算

  3. 積み上げ縦棒グラフを自動配置 — 月別推移が一目で分かるグラフを同じシートに自動挿入

  4. 設定シートで期間を指定 — プロンプト不要、設定シートのB1・B2で開始月・終了月を指定するだけ

  5. 既存シートにそのまま被せて使える — 設定シートで参照先のシート名・列名を上書きできるので、すでに運用中のスプレッドシートに対して新規作成なしで実行できる

この記事の続きはnoteで公開しています。

noteで続きを読む

GAS,自動化自動化

Posted by botw