請求書処理の仕組み化

業務自動化

請求書処理を仕組みにする

  • 「請求書が増えるほど保存・台帳・仕分けが面倒」
  • 「しかもエラーで止まると気づけない」
  • 放置で回る実務版を作った、の流れ

この記事でできるようになること

  • INBOXフォルダに入った請求書PDFを自動処理できる
  • 台帳へ自動記録(fileIdで重複防止)できる
  • DONE/ERRORへ自動で仕分けできる
  • 5分間隔の自動トリガー+二重起動防止で放置運用できる
  • 月次集計(件数/合計)を自動更新できる

仕組みの全体像

処理の流れはシンプルです。

  1. INBOXフォルダを監視(5分間隔)
  2. PDFのみ対象
  3. ファイル名から「会社名/金額/日付」を抽出
  4. 台帳へ記録(重複はfileIdで判定)
  5. リネームしてDONEへ移動
  6. 解析失敗・例外はERRORへ退避(メール通知)

前提(準備するもの)

用意するもの目的
Googleドライブ(INBOX/DONE/ERROR)仕分け先
Googleスプレッドシート(台帳/LOG/月次集計)記録と監査
Apps Script(高度なGoogleサービス:Drive API)共有ドライブ/ショートカット対応

フォルダ構成

  • 受信01_INBOX:処理前PDFを置く
  • 処理済99_DONE:処理後PDFが移動する
  • 失敗98_ERROR:解析失敗・例外が退避する

ファイル名ルール

この形式を前提にしています。

会社名_金額_YYYYMMDD.pdf

例:

テスト株式会社_12000_20260218.pdf

※金額は「12,000」でもOK(カンマ除去)


セットアップ手順

ステップ1:Drive APIを有効化

Apps Scriptの「サービス(+)」から Drive API を追加します。

ステップ2:フォルダIDを設定

コード内の以下を自分のIDに差し替えます。

  • INBOX_FOLDER_ID
  • DONE_FOLDER_ID
  • ERROR_FOLDER_ID

ステップ3:疎通確認

testDriveFolderAccess() を1回実行します。

ステップ4:トリガー設定

setupTriggers() を1回実行します(5分間隔の監視が開始されます)


コード

/************* ===== 設定 ===== *************/

const INBOX_FOLDER_ID = “1OYAq7CqGGGfSGAz92C_3qgnpHbon45Uu”;

const DONE_FOLDER_ID  = “1OpIYimHmfxP5yo61TI6JV3c4gByayWOa”;

const ERROR_FOLDER_ID = “1Nl5rHQBKcMuSwdI67Y_zDmpzc9NKcxHM”;

const LEDGER_SHEET_NAME = “台帳”;

const MONTHLY_SHEET_NAME = “月次集計”;

const NOTIFY_EMAIL = “hkentikushi@gmail.com”;

const TRIGGER_INTERVAL_MIN = 5;

const LOCK_TIMEOUT_MS = 20000;

/************* ===== フォルダ取得 ===== *************/

function getFolderSmart_(id) {

  const file = Drive.Files.get(id, {

    supportsAllDrives: true,

    fields: “id,name,mimeType,shortcutDetails”

  });

  let targetId = file.id;

  if (

    file.mimeType === “application/vnd.google-apps.shortcut” &&

    file.shortcutDetails &&

    file.shortcutDetails.targetId

  ) {

    targetId = file.shortcutDetails.targetId;

  }

  const target = Drive.Files.get(targetId, {

    supportsAllDrives: true,

    fields: “id,name,mimeType”

  });

  if (target.mimeType !== “application/vnd.google-apps.folder”) {

    throw new Error(“指定IDはフォルダではありません”);

  }

  return DriveApp.getFolderById(targetId);

}

/************* ===== メイン処理 ===== *************/

function driveWatchJob() {

  const lock = LockService.getScriptLock();

  if (!lock.tryLock(LOCK_TIMEOUT_MS)) {

    writeLog(“WARN”, “スキップ”, “既に実行中”);

    return;

  }

  const runAt = new Date();

  try {

    const inbox = getFolderSmart_(INBOX_FOLDER_ID);

    const done  = getFolderSmart_(DONE_FOLDER_ID);

    const error = getFolderSmart_(ERROR_FOLDER_ID);

    const ss = SpreadsheetApp.getActiveSpreadsheet();

    const ledger = ss.getSheetByName(LEDGER_SHEET_NAME);

    if (!ledger) {

      writeLog(“ERROR”, “台帳なし”, LEDGER_SHEET_NAME);

      return;

    }

    ensureLedgerHeader_(ledger);

    const files = inbox.getFiles();

    let processed = 0;

    let duplicate = 0;

    let parseFailed = 0;

    let errored = 0;

    let skipped = 0;

    while (files.hasNext()) {

      const file = files.next();

      try {

        const name = file.getName();

        const fileId = file.getId();

        if (!/\.pdf$/i.test(name)) {

          skipped++;

          continue;

        }

        const parsed = parseFileName_(name);

        if (!parsed) {

          moveToFolder_(file, inbox, error);

          writeLedgerRow_(ledger, “”, “”, “”, fileId, name, “”, new Date(), “PARSE_FAIL”);

          parseFailed++;

          continue;

        }

        const { company, amount, isoDate } = parsed;

        const date = isoDate ? new Date(isoDate) : new Date();

        const isNew = appendLedgerIfNotDuplicate_(

          ledger, company, date, amount, fileId, name, “”, new Date(), “PROCESSED_PRE”

        );

        if (!isNew) {

          moveToFolder_(file, inbox, done);

          duplicate++;

          continue;

        }

        const newName = `${company}_${formatYmdNoSlash_(date)}_${amount}.pdf`;

        file.setName(newName);

        moveToFolder_(file, inbox, done);

        finalizeLedgerByFileId_(ledger, fileId, newName, “PROCESSED”);

        processed++;

      } catch (e) {

        try { moveToFolder_(file, inbox, error); } catch (_) {}

        errored++;

      }

    }

    writeLog(“INFO”, “driveWatchJob 完了”,

      `processed=${processed}, duplicate=${duplicate}, parseFailed=${parseFailed}, errored=${errored}, skipped=${skipped}`);

    if (parseFailed + errored > 0) {

      sendErrorMail_(runAt, processed, duplicate, parseFailed, errored, skipped);

    }

    if (processed + duplicate + parseFailed + errored > 0) {

      updateMonthlySummary_();

    }

  } catch (err) {

    writeLog(“ERROR”, “致命的エラー”, err.message);

    throw err;

  } finally {

    lock.releaseLock();

  }

}

/************* ===== 台帳処理 ===== *************/

function ensureLedgerHeader_(sheet) {

  const expected = [“会社名”,”日付”,”金額”,”fileId”,”元ファイル名”,”新ファイル名”,”処理日時”,”ステータス”];

  sheet.getRange(3,1,1,8).setValues([expected]);

}

function appendLedgerIfNotDuplicate_(sheet, company, date, amount, fileId, originalName, newName, processedAt, status) {

  const startRow = 4;

  const lastRow = sheet.getLastRow();

  if (lastRow >= startRow) {

    const ids = sheet.getRange(startRow,4,lastRow-(startRow-1),1).getValues();

    for (const r of ids) {

      if (String(r[0]) === String(fileId)) return false;

    }

  }

  writeLedgerRow_(sheet, company, date, amount, fileId, originalName, newName, processedAt, status);

  return true;

}

function writeLedgerRow_(sheet, company, date, amount, fileId, originalName, newName, processedAt, status) {

  const row = Math.max(sheet.getLastRow()+1, 4);

  sheet.getRange(row,1,1,8).setValues([[

    company,date,amount,fileId,originalName,newName,processedAt,status

  ]]);

}

function finalizeLedgerByFileId_(sheet, fileId, newName, status) {

  const startRow = 4;

  const lastRow = sheet.getLastRow();

  if (lastRow < startRow) return;

  const ids = sheet.getRange(startRow,4,lastRow-(startRow-1),1).getValues();

  for (let i=0;i<ids.length;i++) {

    if (String(ids[i][0]) === String(fileId)) {

      const row = startRow + i;

      sheet.getRange(row,6).setValue(newName);

      sheet.getRange(row,8).setValue(status);

      sheet.getRange(row,7).setValue(new Date());

      return;

    }

  }

}

/************* ===== 月次集計 ===== *************/

function updateMonthlySummary_() {

  const ss = SpreadsheetApp.getActiveSpreadsheet();

  const ledger = ss.getSheetByName(LEDGER_SHEET_NAME);

  const sh = ss.getSheetByName(MONTHLY_SHEET_NAME) || ss.insertSheet(MONTHLY_SHEET_NAME);

  const startRow = 4;

  const lastRow = ledger.getLastRow();

  sh.clear();

  sh.getRange(1,1,1,4).setValues([[“年月”,”件数”,”合計金額”,”最終更新”]]);

  if (lastRow < startRow) return;

  const values = ledger.getRange(startRow,1,lastRow-(startRow-1),8).getValues();

  const tz = Session.getScriptTimeZone();

  const map = new Map();

  for (const r of values) {

    if (r[7] !== “PROCESSED”) continue;

    if (!r[1]) continue;

    const ym = Utilities.formatDate(new Date(r[1]), tz, “yyyy-MM”);

    const cur = map.get(ym) || {count:0,sum:0};

    cur.count++;

    cur.sum += Number(r[2])||0;

    map.set(ym, cur);

  }

  const rows = Array.from(map.entries())

    .sort((a,b)=>a[0]<b[0]?1:-1)

    .map(([ym,v])=>[ym,v.count,v.sum,new Date()]);

  if (rows.length>0){

    sh.getRange(2,1,rows.length,4).setValues(rows);

  }

}

/************* ===== 補助 ===== *************/

function parseFileName_(name) {

  const base = name.replace(/\.[^/.]+$/, “”);

  const parts = base.split(“_”);

  if (parts.length < 2) return null;

  const company = parts[0];

  const amount = Number(parts[1].replace(/,/g,””));

  const dateStr = parts[2] || “”;

  if (!company || !amount) return null;

  let isoDate = “”;

  if (/^\d{8}$/.test(dateStr)) {

    isoDate = `${dateStr.slice(0,4)}-${dateStr.slice(4,6)}-${dateStr.slice(6,8)}`;

  }

  return {company, amount, isoDate};

}

function moveToFolder_(file, fromFolder, toFolder) {

  toFolder.addFile(file);

  fromFolder.removeFile(file);

}

function formatYmdNoSlash_(d) {

  return Utilities.formatDate(d, Session.getScriptTimeZone(), “yyyyMMdd”);

}

/************* ===== ログ ===== *************/

function writeLog(level, message, detail) {

  const ss = SpreadsheetApp.getActiveSpreadsheet();

  const sh = ss.getSheetByName(“LOG”) || ss.insertSheet(“LOG”);

  if (sh.getLastRow() === 0) {

    sh.getRange(1,1,1,5).setValues([[“timestamp”,”level”,”message”,”detail”,”user”]]);

  }

  sh.appendRow([new Date(), level, message, detail||””, Session.getActiveUser().getEmail()]);

}

/************* ===== メール通知 ===== *************/

function sendErrorMail_(runAt, processed, duplicate, parseFailed, errored, skipped) {

  const subject = “[請求書処理] ERROR発生”;

  const body =

`実行時刻: ${runAt}

処理成功: ${processed}

重複: ${duplicate}

解析失敗: ${parseFailed}

例外: ${errored}

非PDF: ${skipped}

ERRORフォルダを確認してください。`;

  MailApp.sendEmail(NOTIFY_EMAIL, subject, body);

}

/************* ===== トリガー設定(1回実行)===== *************/

function setupTriggers() {

  ScriptApp.getProjectTriggers().forEach(t => ScriptApp.deleteTrigger(t));

  ScriptApp.newTrigger(“driveWatchJob”)

    .timeBased()

    .everyMinutes(TRIGGER_INTERVAL_MIN)

    .create();

}


台帳仕様

3行目:ヘッダー
4行目〜:データ

  • 会社名 / 日付 / 金額 / fileId
  • 元ファイル名 / 新ファイル名
  • 処理日時 / ステータス(PROCESSED / PARSE_FAIL など)

月次集計について

「PROCESSEDのみ」を年月ごとに集計し、月次集計シートを更新します。

  • 年月
  • 件数
  • 合計金額
  • 最終更新

よくあるエラーと対処

File not found(フォルダIDが違う)

URLの folders/xxxxx とコードのIDが完全一致しているか確認します。

解析失敗が増える(ファイル名ルール違反)

会社名_金額_YYYYMMDD.pdf の形になっているか確認します。

作れる人になると、仕事は取りにいける

今回作ったのは、ただの自動化ツールではありません。

  • フォルダ監視
  • 重複防止(fileId管理)
  • エラー退避
  • ログ管理
  • 月次集計
  • 二重起動防止

ここまで作り込めると、
それは「作業効率化」ではなく仕組み設計になります。

副業案件で求められるのは、

「GASが書ける人」ではなく、
止まらない運用を設計できる人です。

今回の仕組みは、

  • 経理業務の自動化
  • バックオフィス改善
  • 小規模事業者の業務整理
  • 社内DXの第一歩

こういった案件にそのまま提案できるレベルです。

しかも、この仕組みは横展開ができます。

  • 見積書管理
  • 契約書管理
  • 顧客データ整理
  • レポート自動生成

ベースは同じです。

一度構造を作れるようになると、
あとは応用するだけ。

スキルは「知識」ではなく、
動く仕組みを持っているかどうかで価値が決まります。

今回のコードは、その土台になります。

あとはこれを、自分の武器にするだけです。

ご相談方法

お問い合わせフォームよりご連絡ください。

コメント

タイトルとURLをコピーしました