0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

0
Posted at

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 で公開中

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?