差分ハイライトだけじゃない — 差分レポートまで作成

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

ハイライトして満足してませんか?

旧データの例
新データの例

旧データと新データの差分チェック。
条件付き書式などを駆使して色分けすると、見付けやすくて、分かりやすいですよね。

ハイライトを付けると、妙に仕事やった感がするのは私だけでしょうか?

でも、ちょっと待ってください!
「先月と今月の差分を報告して」と上司から言われた時、どうしてます?

えーーっと、
クリアファイルが50から48に減って、
ホチキス針が200から195に減って、
それで金額が、、、

と、2つの表の間を何度もキョロキョロと見比べて確認することになってはいませんか?

結局、人の目で確認する前提だと、まとめるのに時間がかかる、その過程でエラーが生じ得る、ということになってしまいます。

差分レポートまで自動作成

GASなら、「何がどう変わったか」の差分レポートまでワンクリックで作成可能です。
人の目で表の間を何度も往復して確認する必要はありません。
テキストにまとめる手間も、エラーもスキップです。

差分レポートの例
  1. 差分レポートが自動で追加される

  2. 商品コードで旧新データを突き合わせ、変更された全ての行の差分を自動記録する

  3. 状態で変更・追加・削除を判別

  4. 詳細でテキスト出力されるので、そのまま月報やメールにコピペできる

コード.gs

そのコードがこちらです。

// コード.gs
/**
 * 旧データ・新データの2シートを突合し、差分を行単位で色分けハイライトする
 * キー列で照合し、変更/追加/削除の3状態を可視化 + 差分レポートシートを生成
 */

const OLD_SHEET_NAME = '旧データ';
const NEW_SHEET_NAME = '新データ';
const REPORT_SHEET_NAME = '差分レポート';
const KEY_COL_NAME = '商品コード';

const COLOR_CHANGED = '#fff2cc';
const COLOR_ADDED = '#d9ead3';
const COLOR_REMOVED = '#f4cccc';

function toKey_(value) {
  if (value instanceof Date) {
    return Utilities.formatDate(value, Session.getScriptTimeZone(), 'yyyy-MM-dd');
  }
  return String(value).trim();
}

function buildMap_(data, keyIdx) {
  const map = {};
  const dups = [];
  for (let i = 1; i < data.length; i++) {
    const key = toKey_(data[i][keyIdx]);
    if (!key) continue;
    if (map[key]) {
      dups.push({ key: key, rowIdx: i + 1, prevRowIdx: map[key].rowIdx });
    }
    map[key] = { rowIdx: i + 1, row: data[i] };
  }
  return { map: map, dups: dups };
}

function findHeaderGaps_(oldHeader, newHeader, keyColName) {
  const missingInOld = [];
  const missingInNew = [];
  newHeader.forEach(function(h) {
    if (!h || h === keyColName) return;
    if (oldHeader.indexOf(h) === -1) missingInOld.push(h);
  });
  oldHeader.forEach(function(h) {
    if (!h || h === keyColName) return;
    if (newHeader.indexOf(h) === -1) missingInNew.push(h);
  });
  return { missingInOld: missingInOld, missingInNew: missingInNew };
}

function formatDupMsg_(sheetName, dups) {
  const lines = dups.slice(0, 5).map(function(d) {
    return '  ' + sheetName + ' ' + d.prevRowIdx + '行目 と ' + d.rowIdx + '行目: ' + d.key;
  });
  if (dups.length > 5) lines.push('  ...他 ' + (dups.length - 5) + ' 件');
  return lines.join('\n');
}

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

  const oldSheet = ss.getSheetByName(OLD_SHEET_NAME);
  const newSheet = ss.getSheetByName(NEW_SHEET_NAME);
  if (!oldSheet || !newSheet) {
    ui.alert('「' + OLD_SHEET_NAME + '」と「' + NEW_SHEET_NAME + '」の2シートが必要です。');
    return;
  }

  const oldData = oldSheet.getDataRange().getValues();
  const newData = newSheet.getDataRange().getValues();
  if (oldData.length < 2 || newData.length < 2) {
    ui.alert('各シートにヘッダー+データ行が必要です。');
    return;
  }

  const oldHeader = oldData[0];
  const newHeader = newData[0];
  const oldKeyIdx = oldHeader.indexOf(KEY_COL_NAME);
  const newKeyIdx = newHeader.indexOf(KEY_COL_NAME);
  if (oldKeyIdx === -1 || newKeyIdx === -1) {
    ui.alert('「' + KEY_COL_NAME + '」列が両方のシートに必要です。');
    return;
  }

  // ── 列名の揺れを事前チェック(silent skip を防ぐ)──
  const gaps = findHeaderGaps_(oldHeader, newHeader, KEY_COL_NAME);
  if (gaps.missingInOld.length > 0 || gaps.missingInNew.length > 0) {
    let msg = '列名の不一致を検出しました。\n\n';
    if (gaps.missingInOld.length > 0) {
      msg += '旧データに無い列(新→旧を比較できません):\n  ' + gaps.missingInOld.join(' / ') + '\n\n';
    }
    if (gaps.missingInNew.length > 0) {
      msg += '新データに無い列(旧→新を比較できません):\n  ' + gaps.missingInNew.join(' / ') + '\n\n';
    }
    msg += 'これらの列はスキップされます。続行しますか?';
    const resp = ui.alert('列名の不一致', msg, ui.ButtonSet.OK_CANCEL);
    if (resp !== ui.Button.OK) return;
  }

  // ── キー列の重複チェック(マップ化すると上書きで行が失われるため)──
  const oldBuilt = buildMap_(oldData, oldKeyIdx);
  const newBuilt = buildMap_(newData, newKeyIdx);
  if (oldBuilt.dups.length > 0 || newBuilt.dups.length > 0) {
    let msg = 'キー列「' + KEY_COL_NAME + '」に重複があります。\nキーが重複していると行が正しく突合できません。\n\n';
    if (oldBuilt.dups.length > 0) {
      msg += '旧データの重複:\n' + formatDupMsg_('旧データ', oldBuilt.dups) + '\n\n';
    }
    if (newBuilt.dups.length > 0) {
      msg += '新データの重複:\n' + formatDupMsg_('新データ', newBuilt.dups) + '\n\n';
    }
    msg += 'キー列を見直してから再実行してください。';
    ui.alert(msg);
    return;
  }
  const oldMap = oldBuilt.map;
  const newMap = newBuilt.map;

  oldSheet.getRange(2, 1, oldData.length - 1, oldHeader.length).setBackground(null);
  newSheet.getRange(2, 1, newData.length - 1, newHeader.length).setBackground(null);

  const report = [['キー', '状態', '詳細']];
  let changedCount = 0;
  let addedCount = 0;
  let removedCount = 0;

  Object.keys(newMap).forEach(function(key) {
    const nEntry = newMap[key];
    const oEntry = oldMap[key];
    if (!oEntry) {
      newSheet.getRange(nEntry.rowIdx, 1, 1, newHeader.length).setBackground(COLOR_ADDED);
      addedCount++;
      report.push([key, '追加', formatRow_(newHeader, nEntry.row)]);
      return;
    }
    const diffs = [];
    for (let i = 0; i < newHeader.length; i++) {
      if (i === newKeyIdx) continue;
      const colName = newHeader[i];
      const oi = oldHeader.indexOf(colName);
      if (oi === -1) continue;
      const nVal = nEntry.row[i];
      const oVal = oEntry.row[oi];
      if (String(nVal) !== String(oVal)) {
        diffs.push(colName + ': ' + oVal + ' → ' + nVal);
      }
    }
    if (diffs.length > 0) {
      newSheet.getRange(nEntry.rowIdx, 1, 1, newHeader.length).setBackground(COLOR_CHANGED);
      oldSheet.getRange(oEntry.rowIdx, 1, 1, oldHeader.length).setBackground(COLOR_CHANGED);
      changedCount++;
      report.push([key, '変更', diffs.join(' / ')]);
    }
  });

  Object.keys(oldMap).forEach(function(key) {
    if (newMap[key]) return;
    const oEntry = oldMap[key];
    oldSheet.getRange(oEntry.rowIdx, 1, 1, oldHeader.length).setBackground(COLOR_REMOVED);
    removedCount++;
    report.push([key, '削除', formatRow_(oldHeader, oEntry.row)]);
  });

  writeReport_(ss, report);

  SpreadsheetApp.flush();
  ui.alert(
    '差分チェック完了。\n\n' +
    '変更: ' + changedCount + ' 件(黄)\n' +
    '追加: ' + addedCount + ' 件(緑)\n' +
    '削除: ' + removedCount + ' 件(赤)\n\n' +
    '詳細は「' + REPORT_SHEET_NAME + '」シートを参照してください。'
  );
}

function formatRow_(header, row) {
  const parts = [];
  for (let i = 0; i < header.length; i++) {
    parts.push(header[i] + '=' + row[i]);
  }
  return parts.join(' / ');
}

function writeReport_(ss, report) {
  let sheet = ss.getSheetByName(REPORT_SHEET_NAME);
  if (sheet) {
    sheet.clear();
  } else {
    sheet = ss.insertSheet(REPORT_SHEET_NAME);
  }
  sheet.getRange(1, 1, report.length, 3).setValues(report);
  sheet.getRange(1, 1, 1, 3)
    .setBackground('#f3f3f3')
    .setFontWeight('bold')
    .setHorizontalAlignment('center');
  sheet.setColumnWidth(1, 120);
  sheet.setColumnWidth(2, 80);
  sheet.setColumnWidth(3, 520);
}

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('自動ボタン')
    .addItem('差分ハイライト', 'highlightDiff')
    .addToUi();
}

使い方

① 新規スプレッドシートを用意し、比較する2シートを準備
シート名は「旧データ」と「新データ」。比較したいデータを2つのシートに作成します。
どちらのシートにも 「商品コード」列 を用意します(これが突合せのキーになります)。行順は揃っていなくてOK、行数も違っていてOKです。

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

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

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

⑤ 実行結果を確認
差分レポート」シートが自動で作成されます。

旧・新データがハイライトされます。
黄:変更
緑:追加
赤:削除
無色:変更なし

ハイライトは「どこを見るか」の道しるべ、差分レポートが「何がどう変わったか」の記録。2つで1セットの設計です。

ワンポイント・レッスン

オブジェクト `{}` を「辞書(マップ)」として使う

コードの中の `buildMap_` 関数から、辞書を作っている部分だけを抜き出した簡略版がこちらです。

function buildMap_(data, keyIdx) {
  const map = {};
  for (let i = 1; i < data.length; i++) {
    const key = toKey_(data[i][keyIdx]);
    if (!key) continue;
    map[key] = { rowIdx: i + 1, row: data[i] };
  }
  return map;
}

GASでは、オブジェクト `{}` を 「キー → 値」の対応表(辞書) として使うのが定番パターンです。
配列 `[]` は順番にデータを並べるのに使うのに対して、オブジェクトは 「このキーに対応する値は何?」 を一瞬で引き出すのに使います。

map = {
  "P001": { rowIdx: 2, row: ["P001", "ボールペン黒", 100, 5000] },
  "P002": { rowIdx: 3, row: ["P002", "クリアファイル", 50, 2500] },
  "P003": { rowIdx: 4, row: ["P003", "コピー用紙A4", 30, 9000] }
}

こう作っておくと、後から `map[“P002"]` と書くだけで該当する行データ(商品名・数量・金額の全項目)が取り出せます。

  • 値を入れる: `map[key] = value`

  • 値を取り出す: `map[key]`

  • 存在チェック: `if (map[key]) { … }` — 無ければ `undefined`

なぜ差分レポート作成でこれを使うかというと、ビフォー値とアフター値をペアで保持して、「50 → 48」のような文字列として書き出せる形にするため です。
VLOOKUP方式だと「今の値と違う」は検知できても、旧値は数式の裏に隠れていて文字として取り出せません。
辞書なら `oldMap[key]` と `newMap[key]` の両方から元の行データをまるごと取り出せるので、列ごとに `旧値 + ' → ' + 新値` と文字列連結するだけでレポート行ができます。

追加・削除の検知も同じ発想です。
`newMap[key]` が存在して `oldMap[key]` が無ければ「追加」、逆なら「削除」。存在チェックと値取り出しを一体化できるのが辞書の強みです。

このパターンは突合・集計・重複チェック・カウントなど、GASで書くあらゆる「キーで対応を取る」処理の土台です。
配列を2重ループで回す発想から卒業すると、組み込み関数では書けない “値を文字で残す系" の処理が一気に書けるようになります。

まとめ

  • ハイライトだけだと、結局、人の目で差分をまとめることになり、負担とエラーは避けられない。

  • GASで差分レポートまで自動作成することでこの課題を解決できる。

  • データのbefore/afterが文字として残るので、月報・メール・監査資料にそのまま貼って時短実現。


シート名、キー列を自由に設定

コード.gsでは、シート名が「旧データ」「新データ」で、突合せキー列が「商品コード」で固定されていました。

拡張コードでは、これらを自由に設定できるようにして利便性を上げましょう。

拡張コードでできること

  • 「先月在庫」「今月在庫」「申告値」「実績値」など、既存ブックで使っているシート名をそのまま指定できる

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

    noteで続きを読む

GAS,自動化自動化

Posted by botw