請求書処理を仕組みにする
- 「請求書が増えるほど保存・台帳・仕分けが面倒」
- 「しかもエラーで止まると気づけない」
- 放置で回る実務版を作った、の流れ
この記事でできるようになること
- INBOXフォルダに入った請求書PDFを自動処理できる
- 台帳へ自動記録(fileIdで重複防止)できる
- DONE/ERRORへ自動で仕分けできる
- 5分間隔の自動トリガー+二重起動防止で放置運用できる
- 月次集計(件数/合計)を自動更新できる
仕組みの全体像
処理の流れはシンプルです。
- INBOXフォルダを監視(5分間隔)
- PDFのみ対象
- ファイル名から「会社名/金額/日付」を抽出
- 台帳へ記録(重複はfileIdで判定)
- リネームしてDONEへ移動
- 解析失敗・例外は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の第一歩
こういった案件にそのまま提案できるレベルです。
しかも、この仕組みは横展開ができます。
- 見積書管理
- 契約書管理
- 顧客データ整理
- レポート自動生成
ベースは同じです。
一度構造を作れるようになると、
あとは応用するだけ。
スキルは「知識」ではなく、
動く仕組みを持っているかどうかで価値が決まります。
今回のコードは、その土台になります。
あとはこれを、自分の武器にするだけです。
ご相談方法
お問い合わせフォームよりご連絡ください。


コメント