🧾事務自動化ラボ

Google Driveの領収書を月別フォルダへ自動整理する(GAS・全文コピペ配布)

Driveの整理前フォルダに入れた領収書PDF・画像を、ファイル名の日付から月別フォルダへ移動するGASです。最初は移動せず結果だけ確認でき、日付不明・同名フォルダ・権限不足をログに残します。

メールやスマートフォンから保存した領収書をGoogle Driveへ入れていると、ファイル自体は残っていても、月末に探せない状態になりがちです。PDF、撮影したJPEG、ダウンロードしたPNGが同じフォルダに並び、ファイル名もばらつきます。

この記事では、整理前フォルダに入れた領収書を、ファイル名の日付から 2026-09 のような月別フォルダへ移動するGoogle Apps Script(GAS)を作ります。コードは全文掲載しています。

先に限界を書いておきます。このコードは領収書の中身をOCRで読みません。ファイル名に日付があるものを月別に分け、日付を判定できないものは「日付要確認」へ集める仕組みです。日付や経費区分を推測して自動確定するものではありません。

このツールでできること・できないこと

できること

  • PDF・JPEG・PNG・HEICを、ファイル名の日付から月別フォルダへ移動する
  • 移動前に「確認のみ」で判定日と移動先を一覧にする
  • 日付不明、対象外形式、権限エラー、移動済みをログへ残す
  • 同じ月の既存フォルダを再利用し、必要な月だけ作る

できないこと

  • 領収書の画像やPDF本文をOCRで読み、取引日・金額・経費区分を判定する
  • ファイル名にない取引日を推測する
  • 権限のないファイルを移動する
  • 同名フォルダを自動で統合・削除する
  • 会計ソフトへ仕訳を登録する

作るもの

Google Driveに次の2フォルダを用意します。

フォルダ 役割
領収書_整理前 スマートフォンやメールから保存したファイルを入れる
領収書_保管 2026-08、2026-09、日付要確認 を自動作成する親フォルダ

たとえば整理前フォルダが次の状態だとします。

20260901_文具店.pdf
カード明細_2026-09-02.jpg
2026年9月3日_駐車場.png
scan_0042.pdf
案内文.docx

実行後は次のように分かれます。

領収書_保管/
├─ 2026-09/
│  ├─ 20260901_文具店.pdf
│  ├─ カード明細_2026-09-02.jpg
│  └─ 2026年9月3日_駐車場.png
└─ 日付要確認/
   └─ scan_0042.pdf

Wordファイルなど、対象外の形式は動かしません。結果はスプレッドシートの「領収書整理ログ」に、ファイルID・元の名前・URL・判定日・移動先・成否とともに残します。

このコードの安全策

ファイル移動をいきなり自動化すると、日付の読み違いに気づきにくくなります。そのため、次の順で動くようにしました。

  1. 最初は「移動せずに確認」を実行する
  2. ログで判定日と移動先を確認する
  3. 問題がなければ「月別フォルダへ移動」を実行する

ほかにも、次の条件では勝手に進めません。

  • 整理前と整理先に同じフォルダIDを指定したら停止する
  • 同じ月名のフォルダが2つあれば、どちらかを推測せずエラーにする
  • 同名のログシートが別用途の見出しなら、ファイルを動かす前に停止する
  • 前回「移動済み」と記録したファイルIDは再処理しない
  • 同時実行された場合はロックを取得した1回だけ進める
  • ファイル移動に失敗しても、残りのファイルを続けて処理し、理由をログへ残す

準備

  1. Google Driveで「領収書_整理前」と「領収書_保管」を作ります
  2. 各フォルダを開き、URLの /folders/ より後ろにある文字列を控えます
  3. 新しいGoogleスプレッドシートを1つ作ります
  4. 拡張機能 > Apps Script を開き、最初から入っているコードを消します
  5. 下のコードを貼り付けます
  6. コード先頭の INBOX_FOLDER_ID と ARCHIVE_FOLDER_ID を書き換えます

フォルダURLが次の形なら、IDは 1AbC...XyZ の部分です。

https://drive.google.com/drive/folders/1AbC...XyZ

コード

/**
 * Google Driveの領収書を月別フォルダへ整理するGAS
 * 事務自動化ラボ https://jimu-jidoka.vercel.app/
 *
 * 使い方:
 * 1. INBOX_FOLDER_ID / ARCHIVE_FOLDER_ID を設定
 * 2. スプレッドシートに貼り付け、organizeReceiptsDryRun() を実行
 * 3. ログを確認後、organizeReceipts() を実行
 */

const RECEIPT_CFG = {
  INBOX_FOLDER_ID: 'ここに整理前フォルダのID',
  ARCHIVE_FOLDER_ID: 'ここに整理先フォルダのID',
  LOG_SHEET_NAME: '領収書整理ログ',
  UNKNOWN_FOLDER_NAME: '日付要確認',
  // trueにすると、ファイル名に日付がない場合だけDrive上のファイル作成日を使います。
  // ファイル作成日は領収日とは限らないため、初期値はfalseです。
  USE_CREATED_DATE_AS_FALLBACK: false,
  ALLOWED_MIME_TYPES: ['application/pdf', 'image/jpeg', 'image/png', 'image/heic'],
};

const RECEIPT_LOG_HEADERS = [
  '実行日時', 'モード', 'ファイルID', 'ファイル名', 'ファイル参照先', 'MIMEタイプ', '判定日', '移動先', '結果・補足',
];

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('領収書整理')
    .addItem('移動せずに確認', 'organizeReceiptsDryRun')
    .addItem('月別フォルダへ移動', 'organizeReceipts')
    .addToUi();
}

function organizeReceiptsDryRun(event) {
  return runReceiptOrganizer_(true, isTimeTriggerEvent_(event));
}

function organizeReceipts(event) {
  return runReceiptOrganizer_(false, isTimeTriggerEvent_(event));
}

function runReceiptOrganizer_(dryRun, silent) {
  validateConfig_();
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(5000)) {
    throw new Error('別の整理処理が実行中です。終了してから再実行してください。');
  }

  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const timeZone = ss.getSpreadsheetTimeZone();
    const inbox = DriveApp.getFolderById(RECEIPT_CFG.INBOX_FOLDER_ID);
    const archive = DriveApp.getFolderById(RECEIPT_CFG.ARCHIVE_FOLDER_ID);
    if (inbox.getId() === archive.getId()) {
      throw new Error('整理前フォルダと整理先フォルダに同じIDは指定できません。');
    }

    const logSheet = getOrCreateLogSheet_(ss);
    const completedIds = getCompletedFileIds_(logSheet);
    const files = inbox.getFiles();
    const folderCache = {};
    const rows = [];
    const summary = { moved: 0, preview: 0, skipped: 0, error: 0 };

    while (files.hasNext()) {
      const file = files.next();
      const row = inspectReceiptFile_(file, timeZone, completedIds);

      if (row.status === '対象外' || row.status === '処理済み') {
        summary.skipped++;
        rows.push(toLogRow_(row, dryRun));
        continue;
      }

      const folderName = row.folderName;
      try {
        if (dryRun) {
          row.status = '確認のみ';
          summary.preview++;
        } else {
          const destination = getOrCreateChildFolder_(archive, folderName, folderCache);
          file.moveTo(destination);
          row.status = '移動済み';
          summary.moved++;
        }
      } catch (error) {
        row.status = 'エラー';
        row.note = error.message;
        summary.error++;
      }
      rows.push(toLogRow_(row, dryRun));
    }

    appendLogRows_(logSheet, rows);
    const message = dryRun
      ? '確認 ' + summary.preview + '件 / 対象外・処理済み ' + summary.skipped + '件 / エラー ' + summary.error + '件'
      : '移動 ' + summary.moved + '件 / 対象外・処理済み ' + summary.skipped + '件 / エラー ' + summary.error + '件';
    // 時間主導型トリガーには表示先のUIがないため、手動実行時だけダイアログを出す。
    if (!silent) {
      SpreadsheetApp.getUi().alert('領収書整理', message, SpreadsheetApp.getUi().ButtonSet.OK);
    }
    return summary;
  } finally {
    lock.releaseLock();
  }
}

function isTimeTriggerEvent_(event) {
  return Boolean(event && event.triggerUid);
}

function validateConfig_() {
  const ids = [RECEIPT_CFG.INBOX_FOLDER_ID, RECEIPT_CFG.ARCHIVE_FOLDER_ID];
  if (ids.some(function (id) { return !id || /^ここに/.test(id); })) {
    throw new Error('RECEIPT_CFGのフォルダIDを2か所とも設定してください。');
  }
}

function inspectReceiptFile_(file, timeZone, completedIds) {
  const id = file.getId();
  const name = file.getName();
  const mimeType = file.getMimeType();
  const base = {
    id: id,
    name: name,
    url: file.getUrl(),
    mimeType: mimeType,
    dateText: '',
    folderName: '',
    status: '',
    note: '',
  };

  if (completedIds[id]) {
    base.status = '処理済み';
    base.note = 'ログに移動済みの記録があります';
    return base;
  }
  if (RECEIPT_CFG.ALLOWED_MIME_TYPES.indexOf(mimeType) === -1) {
    base.status = '対象外';
    base.note = 'PDF・JPEG・PNG・HEIC以外です';
    return base;
  }

  let date = extractDateFromFileName_(name);
  if (!date && RECEIPT_CFG.USE_CREATED_DATE_AS_FALLBACK) {
    const created = file.getDateCreated();
    const parts = Utilities.formatDate(created, timeZone, 'yyyy-MM-dd').split('-');
    date = { year: Number(parts[0]), month: Number(parts[1]), day: Number(parts[2]) };
    base.note = 'ファイル名に日付がないためDrive上のファイル作成日を使用';
  }

  if (date) {
    base.dateText = date.year + '-' + pad2_(date.month) + '-' + pad2_(date.day);
    base.folderName = date.year + '-' + pad2_(date.month);
  } else {
    base.folderName = RECEIPT_CFG.UNKNOWN_FOLDER_NAME;
    base.note = base.note || 'ファイル名から日付を判定できません';
  }
  base.status = '移動対象';
  return base;
}

/**
 * 対応例: 20260901_店名.pdf / 2026-09-01 店名.jpg / 2026年9月1日_店名.png
 * ファイル名の途中にある最初の有効な8桁日付を使います。
 */
function extractDateFromFileName_(name) {
  const normalized = toHalfWidth_(String(name));
  const patterns = [
    /(?:^|\D)(\d{4})[\-_./年](\d{1,2})[\-_./月](\d{1,2})(?:日|\D|$)/,
    /(?:^|\D)(\d{4})(\d{2})(\d{2})(?:\D|$)/,
  ];
  for (let i = 0; i < patterns.length; i++) {
    const match = normalized.match(patterns[i]);
    if (!match) continue;
    const date = validDateParts_(Number(match[1]), Number(match[2]), Number(match[3]));
    if (date) return date;
  }
  return null;
}

function validDateParts_(year, month, day) {
  if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) return null;
  const d = new Date(Date.UTC(year, month - 1, day));
  if (d.getUTCFullYear() !== year || d.getUTCMonth() + 1 !== month || d.getUTCDate() !== day) return null;
  return { year: year, month: month, day: day };
}

function toHalfWidth_(text) {
  return text.replace(/[0-9]/g, function (char) {
    return String.fromCharCode(char.charCodeAt(0) - 0xFEE0);
  });
}

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

function getOrCreateChildFolder_(parent, name, cache) {
  if (cache[name]) return cache[name];
  const matches = parent.getFoldersByName(name);
  if (matches.hasNext()) {
    const folder = matches.next();
    if (matches.hasNext()) {
      throw new Error('整理先に同名フォルダ「' + name + '」が複数あります');
    }
    cache[name] = folder;
  } else {
    cache[name] = parent.createFolder(name);
  }
  return cache[name];
}

function getOrCreateLogSheet_(ss) {
  let sheet = ss.getSheetByName(RECEIPT_CFG.LOG_SHEET_NAME);
  if (!sheet) {
    sheet = ss.insertSheet(RECEIPT_CFG.LOG_SHEET_NAME);
    sheet.getRange(1, 1, 1, RECEIPT_LOG_HEADERS.length).setValues([RECEIPT_LOG_HEADERS]);
    sheet.setFrozenRows(1);
  } else {
    validateLogHeaders_(sheet);
  }
  return sheet;
}

function validateLogHeaders_(sheet) {
  const actual = sheet.getRange(1, 1, 1, RECEIPT_LOG_HEADERS.length).getDisplayValues()[0];
  for (let i = 0; i < RECEIPT_LOG_HEADERS.length; i++) {
    if (actual[i] !== RECEIPT_LOG_HEADERS[i]) {
      throw new Error(
        '「' + RECEIPT_CFG.LOG_SHEET_NAME + '」の見出しが違います。' +
        (i + 1) + '列目は「' + RECEIPT_LOG_HEADERS[i] + '」にしてください。'
      );
    }
  }
}

function getCompletedFileIds_(sheet) {
  const result = {};
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return result;
  const values = sheet.getRange(2, 1, lastRow - 1, 9).getValues();
  for (let i = 0; i < values.length; i++) {
    const id = String(values[i][2] || '');
    const status = String(values[i][8] || '');
    if (id && status.indexOf('移動済み') === 0) result[id] = true;
  }
  return result;
}

function toLogRow_(row, dryRun) {
  return [
    new Date(),
    dryRun ? '確認のみ' : '移動',
    row.id,
    row.name,
    row.url,
    row.mimeType,
    row.dateText,
    row.folderName,
    row.status + (row.note ? ': ' + row.note : ''),
  ];
}

function appendLogRows_(sheet, rows) {
  if (rows.length === 0) return;
  sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, 9).setValues(rows);
}

保存したらスプレッドシートを再読み込みします。メニューに「領収書整理」が出ます。

最初は 領収書整理 > 移動せずに確認 を押してください。初回はDriveとスプレッドシートへのアクセス許可が出ます。ログの「判定日」と「移動先」を読み、問題がなければ 領収書整理 > 月別フォルダへ移動 を実行します。

ファイル名の日付ルール

次の形式を認識します。全角数字も半角へ直してから判定します。

ファイル名の例 判定
20260901_店名.pdf 2026-09-01
領収書_2026-9-1_店名.jpg 2026-09-01
2026.09.01 店名.png 2026-09-01
2026年9月1日_店名.pdf 2026-09-01
20260229.pdf 無効(2026年はうるう年ではない)
日付なし.pdf 日付要確認

ファイル名に日付がない場合、Driveへ追加した日を使う設定も用意しています。

USE_CREATED_DATE_AS_FALLBACK: true,

ただし初期値は false です。Google公式リファレンスの File.getDateCreated() が返すのはDrive上のファイル作成日時であり、領収書に記載された取引日ではありません。あとからまとめてアップロードした場合、全件がアップロード月へ入る可能性があるためです。

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

1. moveTo() は、移動先を指定するだけでは通らないことがある

Google公式リファレンスでは、File.moveTo(destination) でファイルを移動するには、実行者がファイルの所有者であるか、少なくとも現在の親フォルダを編集できる必要があるとされています。

共有されたファイルだけを整理前フォルダへ表示している場合や、現在の親フォルダを編集できない場合は、移動で例外になります。このコードはそのファイルだけ「エラー」にして、ログへ例外メッセージを残します。権限を確認せず、コピー作成へ自動で切り替えることはしません。コピーだと原本と複製が両方残り、どちらが保存対象か分からなくなるためです。

参照: Class File - moveTo(destination) | Google for Developers

2. ファイル名の日付を正規表現で見つけても、実在する日付とは限らない

20260229 は8桁なので、正規表現だけなら日付に見えます。しかし2026年2月29日は存在しません。

コードでは年・月・日をいったんUTCの日付へ変換し、変換後の年月日が入力と一致するかを確認します。JavaScriptは不正な日付を翌月へ繰り上げるため、一致確認をしないと 2026-02-29 が3月として処理されます。

3. 202609011234 のような連続した数字を、先頭8桁の日付と誤認する

注文番号やタイムスタンプには、8桁以上の数字が連続することがあります。単に \d{8} を探すと、202609011234 の先頭だけを日付として採用します。

コードでは8桁の直前と直後が数字ではない場合だけ、連続形式の日付として扱います。区切り付きの 2026-09-01 は別の規則で判定します。

4. 月別フォルダが同名で2つある

Driveでは、同じ親フォルダ内に同名フォルダを複数作れます。getFoldersByName('2026-09') の最初の1件だけを使うと、どちらへ入ったか人間側から見分けにくくなります。

このコードは一致が2件以上ならエラーにします。既存フォルダを整理して1件にしてから再実行してください。自動削除や自動統合はしません。

5. 時間トリガーが重なり、同じファイルを2回処理する

ファイル数が多い状態で手動実行と定期実行が重なると、同じ整理前フォルダを2つの処理が同時に読みます。

GoogleのLock serviceは、共有リソースを複数の処理が同時に変更する衝突を防ぐための仕組みです。このコードは getScriptLock() と tryLock(5000) を使い、5秒以内にロックを取れなければ停止します。

参照: Lock Service | Google for Developers

6. ショートカットを領収書本体として処理してしまう

Driveのショートカットは元ファイルとは別のMIMEタイプです。このコードはPDF・JPEG・PNG・HEICだけを対象にするため、ショートカットは「対象外」になります。ショートカット先へたどって元ファイルを動かす処理は入れていません。見えている場所と原本の場所が違う状態で、原本を意図せず移動しないためです。

Google公式のDrive serviceでは、getMimeType() でファイル形式を取得し、ショートカットについては対象IDや対象MIMEタイプを取得する別メソッドが用意されています。

参照: Drive Service | Google for Developers

毎日自動で動かす場合

確認モードで何回か結果を見て、ファイル名の付け方が安定してから時間主導型トリガーを設定します。

  1. Apps Script左側の時計アイコン「トリガー」を開く
  2. 「トリガーを追加」を押す
  3. 実行する関数に organizeReceipts を選ぶ
  4. イベントのソースを「時間主導型」にする
  5. 実行間隔を選んで保存する

トリガーで失敗するとスプレッドシート上にダイアログは出ません。定期実行まで進める場合は、GASのトリガーが止まっても気づけるようにする の実行ログと失敗通知も一緒に入れてください。

時間主導型トリガーには表示先のスプレッドシートUIがないため、コードはトリガーイベントを受け取った場合だけ結果ダイアログを出さず、移動とログ記録で終了します。手動実行時は従来どおり結果を表示します。

複数人が整理ログを触る場合は、数式や見出しの誤削除を防ぐため、スプレッドシートで入力セルだけ編集可にする の保護設定も使えます。

検証した範囲

2026年9月13日に、コード全文をJavaScriptとして構文確認し、Google Apps Script固有APIをモックへ差し替えたテストで次を確認しました。

確認項目 結果
区切り4形式・全角数字・うるう年・不正日付・長い連番の日付判定 PASS
PDF・JPEG・PNGを対象にし、許可外MIMEタイプを除外する PASS
日付不明を「日付要確認」へ分ける PASS
確認モードではファイルもフォルダも変更しない PASS
同じ月の既存フォルダを再利用し、必要なフォルダだけ作る PASS
手動実行では結果を表示し、時間主導型トリガーではUIを呼ばない PASS
移動失敗を成功件数へ含めず、理由をログへ残す PASS
移動済みID、同一フォルダ指定、同時実行、同名フォルダ重複で安全停止する PASS
同名ログシートの見出し不一致では、ファイルを動かす前に停止する PASS
複数件のログを1回の setValues() で書く PASS

合計36アサーションが通っています。実際のGoogleアカウントでは、フォルダの所有者・共有設定・組織ポリシーによって移動可否が変わるため、実ファイルを使った自動移動までは実行していません。最初にコピーしたテスト用ファイルを2〜3件入れ、確認モードから試してください。

まとめ

領収書整理で先に自動化するのは、内容の推測ではなく、日付を判定できるものを月別に分け、判定できないものを1か所へ集めるところまでです。

最初は移動せずログだけ出す。取引日とDriveへの追加日を混同しない。同名フォルダや権限エラーを推測で処理しない。この線を守れば、月末に確認する対象を減らしながら、誤分類にも気づける状態を作れます。

ここまでで足りなくなったら

この無料版は、Drive内の領収書を月別に分けるところまでです。請求書の作成、PDF保存、発行履歴、二重発行の警告までまとめて扱う場合は、請求書自動作成スプレッドシート(GAS)の詳細に機能と検証範囲をまとめています。領収書整理だけで足りる場合は、このコードをそのまま使ってください。

← 記事一覧へ