GAS×AI — スプレッドシートに届く請求書を自動で仕訳する
はじめに
毎月の請求書を1件ずつ開いて、金額を転記して、勘定科目を選んで、消費税区分を確認する。件数が20を超えたあたりから、この作業は「考える仕事」ではなく「写す仕事」になります。
この記事では、Google Apps Script(GAS)からAI APIを呼び、請求書のテキストから勘定科目と税区分を判定してスプレッドシートに書き戻すところまでを実装します。
ポイントは、AIに「自由に答えて」と頼まないことです。答えの候補を閉じた集合として渡し、必ずその中から選ばせる。この設計にすると、出力が崩れず、後続の処理が壊れません。
- 前提: GASの基本的な書き方(
SpreadsheetAppが使えること) - 題材: Gmailに届く請求書PDF を テキスト化し、仕訳候補を自動生成する
- ゴール: 人間は「確認して直す」だけの状態にする
全体像
処理は3段階に分けます。
- 取り込み — Gmailの添付PDFをDriveのOCRでテキスト化し、シートに1行1件で並べる
- 判定 — 20件をまとめてAIに投げ、勘定科目・税区分・確信度をJSONで受け取る
- 書き戻し — 判定結果をシートに書き、確信度が低い行にレビュー用フラグを立てる
「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に下書きさせて人間が確定する分担です。この線引きを最初に決めておくと、運用が破綻しません。
ハマりどころ
-
PDFのテキスト化 — GAS単体ではPDFを読めません。DriveのOCR(
Drive.Files.copyのocr: true, convert: true)でGoogleドキュメント化してからDocumentAppで読み出すとラクです。レイアウトが複雑な請求書では数字の桁が崩れることがあるので、必ずサンプルで確認してください。 -
APIキーの置き場所 —
PropertiesService.getScriptProperties()に保存し、コードに直書きしない。共有時に事故ります。 -
JSON.parse の失敗 —
responseMimeType: 'application/json'を指定し忘れると、コードフェンスが混ざって失敗します。 - タイムゾーン — 日付を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会員は読み放題対象です。