速水拓真 営業×AI 実践シリーズ
書き下ろし

GAS×AI — スプレッドシートに届く請求書を自動で仕訳する

書き下ろし(当サイト初出)

はじめに

毎月の請求書を1件ずつ開いて、金額を転記して、勘定科目を選んで、消費税区分を確認する。件数が20を超えたあたりから、この作業は「考える仕事」ではなく「写す仕事」になります。

この記事では、Google Apps Script(GAS)からAI APIを呼び、請求書のテキストから勘定科目と税区分を判定してスプレッドシートに書き戻すところまでを実装します。

ポイントは、AIに「自由に答えて」と頼まないことです。答えの候補を閉じた集合として渡し、必ずその中から選ばせる。この設計にすると、出力が崩れず、後続の処理が壊れません。

  • 前提: GASの基本的な書き方(SpreadsheetApp が使えること)
  • 題材: Gmailに届く請求書PDF を テキスト化し、仕訳候補を自動生成する
  • ゴール: 人間は「確認して直す」だけの状態にする

全体像

処理は3段階に分けます。

  1. 取り込み — Gmailの添付PDFをDriveのOCRでテキスト化し、シートに1行1件で並べる
  2. 判定 — 20件をまとめてAIに投げ、勘定科目・税区分・確信度をJSONで受け取る
  3. 書き戻し — 判定結果をシートに書き、確信度が低い行にレビュー用フラグを立てる

「1件ずつAIに聞く」をやらないのが重要です。理由は後述します。

Step 1: 質問を「選択問題」にする

最初に、シートとは別のタブに勘定科目のマスタを用意します。

科目コード 科目名
5010 仕入高
5020 外注費
6010 通信費
6020 旅費交通費
6030 消耗品費
6040 広告宣伝費
9999 判定不能

プロンプトには、この一覧をそのまま埋め込みます。

function buildPrompt(rows, accounts) {
  const list = accounts.map(a => a.code + ':' + a.name).join('\n');
  const items = rows.map(r => 'row=' + r.row + ' | ' + r.text).join('\n');

  return 'あなたは経理の補助者です。以下の請求書テキストを読み、'
    + '必ず次の勘定科目リストの中から1つ選んでください。\n\n'
    + list + '\n\n'
    + '重要:\n'
    + '- リストに無い科目名を作ってはいけない。判断できない場合は 9999 を選ぶ。\n'
    + '- tax_rate は 0 / 8 / 10 のいずれか。\n'
    + '- confidence は 0.0 から 1.0 の範囲。少しでも迷ったら 0.5 以下にすること。\n\n'
    + '# 請求書\n' + items;
}

「該当なしは 9999」という逃げ道を用意するのがコツです。逃げ道がないと、AIは無理に近い科目を選びます。逃げ道の件数は、そのままプロンプトの改善指標になります。

Step 2: GASからGemini APIを呼ぶ

UrlFetchApp で呼びます。APIキーはコードに書かず、スクリプトプロパティに置きます。

const PROPS = PropertiesService.getScriptProperties();

function callGemini(prompt, schema) {
  const apiKey = PROPS.getProperty('GEMINI_API_KEY');
  const url = 'https://generativelanguage.googleapis.com/v1beta/models/'
    + 'gemini-2.0-flash:generateContent?key=' + apiKey;

  const payload = {
    contents: [{ parts: [{ text: prompt }] }],
    generationConfig: {
      temperature: 0,
      responseMimeType: 'application/json',
      responseSchema: schema
    }
  };

  const res = UrlFetchApp.fetch(url, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  });

  if (res.getResponseCode() !== 200) {
    throw new Error('API ' + res.getResponseCode() + ': ' + res.getContentText());
  }
  const body = JSON.parse(res.getContentText());
  return JSON.parse(body.candidates[0].content.parts[0].text);
}

muteHttpExceptions: true を忘れると、429のときにGAS側の例外メッセージが「HTTP 429」しか出ず、原因調査が難しくなります。レスポンス本文を自分で読むために必ず付けます。

Step 3: スキーマで出力を縛る

responseSchema を使うと、モデルの出力がこの形に強制されます。

const SCHEMA = {
  type: 'ARRAY',
  items: {
    type: 'OBJECT',
    properties: {
      row:        { type: 'INTEGER' },
      account:    { type: 'STRING',  enum: ACCOUNTS.map(a => a.code) },
      tax_rate:   { type: 'INTEGER', enum: [0, 8, 10] },
      confidence: { type: 'NUMBER' },
      reason:     { type: 'STRING' }
    },
    required: ['row', 'account', 'tax_rate', 'confidence', 'reason']
  }
};

enum の効きがこの設計の肝です。自由記述だと「通信費(インターネット)」のような表記ゆれが出て、後続の突合が壊れます。enum に入れておけば、存在しない科目コードが返ってくる可能性を構造的に消せます

スキーマはプロンプトの一部でもあります。プロンプトで指示した内容と矛盾させないでください。

Step 4: 1件ずつ聞かない

20件を1件ずつ投げると20リクエストです。配列でまとめて投げれば1リクエストで済みます。

function classifyBatch(rows, accounts) {
  const prompt = buildPrompt(rows, accounts);
  const result = callGemini(prompt, SCHEMA);
  if (!Array.isArray(result)) throw new Error('not an array');
  return result;
}

function main() {
  const sheet = SpreadsheetApp.getActive().getSheetByName('請求書');
  const rows = readRows(sheet);              // row / text を持つ配列
  const accounts = readAccounts();
  const BATCH = 20;

  for (let i = 0; i < rows.length; i += BATCH) {
    const slice = rows.slice(i, i + BATCH);
    const judged = classifyBatch(slice, accounts);
    writeBack(sheet, judged);                // 判定結果をシートへ
    Utilities.sleep(1500);                   // レート制限に余裕を持たせる
  }
}

リクエスト数は「件数をバッチ数で割った数」になります。バッチを大きくするほど安く速くなりますが、1リクエスト内の件数が増えるほど1件あたりの精度は落ちる傾向があります。まずは20件で始めて、9999(判定不能)の割合を見ながら調整するのが現実的です。

精度を測る指標は「9999の件数」と「confidenceの平均」です。どちらもシートに数式を1本置くだけで毎月見られます。

Step 5: 失敗する前提で作る

429(レート制限)に備える

function callWithRetry(fn, maxTry) {
  for (let i = 0; i < maxTry; i++) {
    try {
      return fn();
    } catch (e) {
      if (i === maxTry - 1) throw e;
      Utilities.sleep(Math.pow(2, i) * 3000 + Math.random() * 1000);
    }
  }
}

指数バックオフにジッターを足すと、同時実行時の衝突を避けられます。

6分の実行時間制限に備える

GASの1実行は最大6分です。件数が多い場合は、処理済み行数をプロパティに書き、時間主導トリガーで続きから再開します。

function resume() {
  const start = Number(PROPS.getProperty('CURSOR') || 0);
  const startTime = new Date().getTime();
  // start から処理し、4.5分を超えたら CURSOR を更新して終了する
}

「続きから実行」を最初から入れておけば、件数が増えても設計を変えずに済みます。

確信度が低い行は人間に返す

confidence が閾値未満の行にはレビュー列にフラグを立てます。AIに決めさせるのではなく、AIに下書きさせて人間が確定する分担です。この線引きを最初に決めておくと、運用が破綻しません。

ハマりどころ

  1. PDFのテキスト化 — GAS単体ではPDFを読めません。DriveのOCR(Drive.Files.copyocr: true, convert: true)でGoogleドキュメント化してから DocumentApp で読み出すとラクです。レイアウトが複雑な請求書では数字の桁が崩れることがあるので、必ずサンプルで確認してください。
  2. APIキーの置き場所PropertiesService.getScriptProperties() に保存し、コードに直書きしない。共有時に事故ります。
  3. JSON.parse の失敗responseMimeType: 'application/json' を指定し忘れると、コードフェンスが混ざって失敗します。
  4. タイムゾーン — 日付をAIに解釈させるときは、プロンプトに「今日はYYYY-MM-DD」と明示するほうが安定します。

まとめ

  • 請求書の仕訳は「転記」と「判断」に分けられる。転記はGAS、判断はAIが向く
  • 自由記述で答えさせない。候補を閉じた集合にして選ばせる(enum と逃げ道)
  • responseSchema で出力形式を構造的に保証する
  • 1件ずつ聞かない。まとめて投げてリクエスト数を減らす
  • 429・6分制限・低確信度行の返却を、最初から設計に入れる

「AIに決めさせる」のではなく「AIに下書きさせて人間が確定する」。この分担に落とせたとき、自動化はようやく運用に乗ります。

読者特典(無料)

本記事のような実装パターンを横断的にまとめたチートシートと、Obsidian・Notionのテンプレート集を無料配布しています。8冊の内容から「何をどのツールでやるか」の判断チャートも含みます。

🎁 読者特典を受け取る(無料・メール登録)

📗 Google Apps Script × AI 実践入門 — スプレッドシートで動かすAIワークフロー(Kindle・読み放題対象)

この記事の請求書仕訳は、書籍の中ではさらに踏み込んで「経理の月次フロー全体」に組み込む手順まで解説しています。GASからAIを呼ぶ部分のテンプレートも収録しています。Kindle Unlimited会員は読み放題対象です。


著者: 葉山悠希 — 書籍シリーズは Zenn / Amazon で公開中

営業×AI実践シリーズ 全3作

Kindle Unlimited 読み放題対象 — 追加料金なしで読めます

← 記事一覧へ

← 営業リストの名寄せを自動化する — 会社名の表記…想定質問を100個もらっても、商談で言葉に詰まる… →