Google Workspaceでお仕事している人向けの記事です。
受領した請求書が「適格請求書(インボイス)」として有効なのかどうかのチェックを自動化するツールを作りました。具体的には、Google スプレッドシート、Apps Script、そして生成AI(Gemini)を活用します。
業務に生成AI(Gemini)の利用が許可されている場合にのみ、ご利用ください。
コピーして使えるGoogleスプレッドシートも準備しています。必要なコードも一緒にコピーされるのですぐに試せます。面倒な請求書チェックよ、さらば!
コピーして使えるスプレッドシート
下記リンクをクリックすると、スプレッドシートをコピーできます。
上記スプレッドシートにアクセスできない場合
所属組織のGoogle Workspaceで、組織外のファイルへのアクセスを禁止しているかもしれません。
まずは、ブラウザーのシークレットウィンドウで開いてみてください。
それでもダメな場合、次の手順で組織内に新しいスプレッドシートを作ります。
「スプレッドシートの作り方」を展開する
Step1 PDFAppを組織内で使えるようにする
複数ページの請求書PDFファイルに対応するため、Apps Scriptのライブラリー PDFApp(MITライセンス) を利用しています。
組織内にApps Scriptを作成してライブラリーとして使えるようにします。
- Apps Scriptを作成する
- PDFApp.jsを上記Apps Scriptにコピペして保存する
- スクリプトエディターの右上にある[デプロイ]ボタンを押し、
新しいデプロイ
を選ぶ - ギアアイコンで
ライブラリー
を選び、右下の[デプロイ]ボタンを押す - スクリプトエディターの設定で、スクリプトIDをコピーしておく
Step2 スプレッドシートを作成しセットアップする
- Googleスプレッドシートを作成する
- スプレッドシートのメニュー
拡張機能 > Apps Script
でスクリプトエディターを開く - スクリプトエディターの左ペイン
ライブラリ
にPDF App
を追加する(Step1のスクリプトIDで検索して追加) - スクリプトエディターの左ペイン
サービス
でDrive API
のv3
を追加する - 後述する
Code.gs
をコピペする -
setup()
を実行する
スクリプトエディターの左ペインには、ライブラリーにPDFApp
が、サービスにDrive
が追加された状態になります。
setup()
によって、必要なシートが自動生成されています。
背景とポイント
請求書が適格請求書として認められるには、以下の6つの要件を満たす必要があります。
- 適格請求書発行事業者の氏名または名称、登録番号
- 取引年月日
- 取引内容
- 税率ごとに区分した対価額・消費税額等
- 適用税率
- 取引先の氏名または名称
本記事では、Google スプレッドシートとApps Script、そしてGemini(生成AI)を活用し、
フォルダー内のPDF請求書を自動で抽出 → 6要件チェック → スプレッドシート出力
を行う方法を紹介します。
GeminiはPDFデータを直接扱えないため、
- PDF → Google ドキュメント(OCR)によるテキスト化(テキスト方式)
- PDF → サムネイル画像(1ページ目のみ抽出)による画像識別(画像方式)
の2パターンを実装してみました。どちらの方式が適しているか試してみてください。
なお、2ページ以降のPDFは画像変換できません。Googleドライブのサムネイル画像を作る機能で画像方式を実装しています。
事前準備
- Googleドライブで請求書PDF用のフォルダーを作成
- 作成したフォルダーのURLをスプレッドシートの「設定」シートの該当セルに入力
- Geminiを使う準備
- https://aistudio.google.com/app/apikey でAPI Keyを作成
- スプレッドシートのメニュー「拡張機能 > Apps Script」でスクリプトエディターを表示
- スクリプトエディターの設定でスクリプトプロパティ
GEMINI_API_KEY
を追加し、API Keyを保存
API Keyは絶対に公開しないでください
実行方法
- PDFの請求書を、先ほど用意したフォルダーにアップロード
- スプレッドシート上部メニュー「インボイス要件 > 要件をチェック」をクリック
- 「テキスト」はPDFをGoogleドキュメントに変換してチェック
- 「画像」はPDFのサムネイル画像からチェック(1ページ目のみ)
- 「請求書一覧」シートで結果を確認
- 「テキスト」「画像」で別シートに出力されるので、比較可能
検証例(無料テンプレートでテスト)
堺みらい税理士事務所さんのインボイステンプレートを使ってチェックしたところ、テキスト方式と画像方式で結果に差がありました。
実際の請求書で試してみて、うまくいく方式をご利用ください。
テキスト方式の結果
NG箇所が多く指摘されました。
ファイル名 | 要件1 | 要件2 | 要件3 | 要件4 | 要件5 | 要件6 | NGの理由 |
---|---|---|---|---|---|---|---|
tempinvoice.invoice001.pdf | OK | NG | NG | OK | OK | OK | 要件2の取引年月日が曖昧です。要件3の取引内容が曖昧で、詳細な内訳が不足しています。 |
tempinvoice.invoice002.pdf | OK | NG | NG | OK | OK | OK | 要件2の取引年月日が曖昧です。要件3の取引内容が詳細に記載されていません。 |
tempinvoice.invoice004.pdf | OK | OK | NG | OK | OK | OK | 要件3の取引内容が曖昧で、適格請求書として必要な詳細な情報が不足しています。具体的には、作業内容、数量、単価などが明確に記載されていません。 |
画像方式の結果
すべてOK判定でした。
ただ、NG箇所をうまく判定できていない可能性があります。実際の請求書で試してみてください。
ポイント
- 結果が思わしくない場合、プロンプトを修正するか(「設定」シートで編集可)、別方式を試してください。
- 再実行するときは「請求書一覧」から対象ファイル行を削除してから「要件をチェック」を再度実行してください。
Apps Scriptコード
次のコードを「拡張機能 > Apps Script」で開き、GEMINI_API_KEY
をスクリプトプロパティに設定してください。
複数ページの請求書PDFファイルに対応するため、Apps Scriptのライブラリー PDFApp(MITライセンス) を利用しています。とても便利でありがたいです!
Code.gsを展開する
// シート名・セル位置
const SETTINGS_SHEET = "設定"; // 設定シート名
const FOLDER_URL_RANGE = SETTINGS_SHEET + "!B1"; // 請求書PDFが格納されたフォルダURLのセル範囲
const PROMPT_RANGE = SETTINGS_SHEET + "!B2"; // プロンプトのセル範囲
const INVOICE_LIST_PREFIX = "請求書一覧"; // チェック結果表示用シートの接頭詞
const TEXT_POSTFIX = "(テキスト)";
const IMAGE_POSTFIX = "(画像)";
const CHECK_MENU_NAME = "インボイス要件";
const RUN_MENU = "要件をチェック";
const INVOICE_SHEET_HEADERS = ["ファイルID", "ファイル名", "ファイルURL", "1. 適格請求書発行事業者", "2. 取引年月日", "3. 取引内容", "4. 税率別対価と消費税", "5. 適用税率", "6. 取引先", "理由"];
// シート出力時の列順序(ファイルID、ファイル名、URL、要件1~6、NGの理由)
const OUTPUT_KEYS = ["要件1", "要件2", "要件3", "要件4", "要件5", "要件6", "NGの理由"];
// メニュー作成
function onOpen() {
const ui = SpreadsheetApp.getUi();
ui.createMenu(CHECK_MENU_NAME)
.addItem(RUN_MENU + TEXT_POSTFIX, "checkInvoicesByText")
.addItem(RUN_MENU + IMAGE_POSTFIX, "checkInvoicesByImage")
.addToUi();
}
// メイン処理起動関数
async function checkInvoicesByText() {
await checkInvoices_("text", INVOICE_LIST_PREFIX + '(テキスト)');
}
async function checkInvoicesByImage() {
await checkInvoices_("image", INVOICE_LIST_PREFIX + '(画像)');
}
// 請求書チェック実行関数
async function checkInvoices_(mode, sheetName) {
const { folder, prompt, invoiceSheet } = prepareEnvironment_(sheetName);
const files = folder.getFilesByType(MimeType.PDF);
const existingFileIds = getAlreadyListedFileIds_(invoiceSheet);
while (files.hasNext()) {
const file = files.next();
if (existingFileIds.includes(file.getId())) continue;
const result = await checkInvoice_(prompt, file, mode);
appendCheckResult_(invoiceSheet, file, result);
}
}
// 環境準備関連
function prepareEnvironment_(sheetName) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const settingsSheet = ss.getSheetByName(SETTINGS_SHEET);
if (!settingsSheet) throw new Error(`${SETTINGS_SHEET}が見つかりません。`);
const folderUrl = settingsSheet.getRange(FOLDER_URL_RANGE).getValue();
const folder = getFolderFromUrl_(folderUrl);
const prompt = settingsSheet.getRange(PROMPT_RANGE).getValue();
const invoiceSheet = ss.getSheetByName(sheetName);
if (!invoiceSheet) throw new Error(`${sheetName} が見つかりません。`);
return { folder, prompt, invoiceSheet };
}
function getFolderFromUrl_(folderUrl) {
const folderId = folderUrl.split("/folders/")[1];
if (!folderId) throw new Error("フォルダーURLが正しくありません。");
return DriveApp.getFolderById(folderId);
}
// シート関連(既存ファイル一覧取得・結果追記)
function getAlreadyListedFileIds_(sheet) {
const data = sheet.getDataRange().getValues();
return data.slice(1).map(row => row[0]); // 1列目(ファイルID)を取得
}
function appendCheckResult_(sheet, file, result) {
const rowData = [
file.getId(),
file.getName(),
file.getUrl(),
...OUTPUT_KEYS.map(key => result[key] || "")
];
sheet.appendRow(rowData);
}
// 単一PDFファイルの請求書チェック処理
async function checkInvoice_(prompt, file, mode) {
if (mode === "text") {
return await checkInvoiceByText_(prompt, file);
} else if (mode === "image") {
// return checkInvoiceByImage_(prompt, file);
return await checkInvoiceByImages_(prompt, file);
} else {
throw new Error(`無効な処理モード: ${mode}`);
}
}
async function checkInvoiceByText_(prompt, file) {
const doc = convertPdfToGoogleDoc_(file.getId());
if (!doc) throw new Error("Googleドキュメントへの変換に失敗しました。");
const content = getGoogleDocText_(doc.id);
DriveApp.getFileById(doc.id).setTrashed(true); // 一時的なGoogleドキュメントを削除
return requestInvoiceCheck_(prompt, content);
}
// async function checkInvoiceByImage_(prompt, file) {
// const image = file.getThumbnail();
// if (!image) throw new Error("サムネイル画像の取得に失敗しました。");
// return requestInvoiceCheckWithImages_(prompt, [image]);
// }
async function checkInvoiceByImages_(prompt, file) {
const images = await PDFApp.setPDFBlob(file.getBlob()).convertPDFToPng();
return requestInvoiceCheckWithImages_(prompt, images);
}
// PDF -> GoogleDoc 変換 & テキスト取得
function convertPdfToGoogleDoc_(pdfFileId) {
const resource = {
name: `Converted_${new Date().getTime()}`,
mimeType: MimeType.GOOGLE_DOCS
};
const options = {
ocr: true,
ocrLanguage: 'ja',
};
return Drive.Files.copy(resource, pdfFileId, options);
}
function getGoogleDocText_(docId) {
const doc = DocumentApp.openById(docId);
return doc.getBody().getText();
}
// インボイス要件チェック(AI API 呼び出し)
function requestInvoiceCheck_(prompt, content) {
return callGeminiApi_({ prompt: `${prompt}\n${content}`, jsonMode: true });
}
function requestInvoiceCheckWithImages_(prompt, images) {
return callGeminiApi_({ prompt, images, jsonMode: true });
}
// AI API呼び出し共通関数
function callGeminiApi_({ prompt, images, jsonMode = false } ) {
const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
if (!apiKey) throw new Error("GEMINI_API_KEYが設定されていません。");
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:generateContent?key=${apiKey}`;
const payload = {
contents: [
{
parts: [
{ text: prompt }
]
}
]
};
// 画像があればinlineDataを追加
if (images && images.length > 0) {
payload.contents[0].parts.inlineData = {
mimeType: images[0].getContentType(),
data: Utilities.base64Encode(images[0].getBytes()),
};
for (let i = 1; i < images.length; i++) {
payload.contents.push({
parts: { inlineData : {
mimeType: images[i].getContentType(),
data: Utilities.base64Encode(images[i].getBytes()),
}}
})
}
}
// JSONモードの場合、JSON形式でレスポンスを期待
if (jsonMode) {
payload.generation_config = {
response_mime_type: "application/json"
};
}
const options = {
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(url, options);
const json = JSON.parse(response.getContentText());
const candidateText = json?.candidates?.[0]?.content?.parts?.[0]?.text;
if (!candidateText) throw new Error("生成AIから有効な応答を取得できませんでした");
return jsonMode ? JSON.parse(candidateText) : candidateText;
}
const INITIAL_PROMPT = `# 適格請求書(インボイス)の6つの要件
適格請求書(インボイス)には、以下の6つの要件を満たす必要があります。
1. **適格請求書発行事業者の氏名または名称・登録番号**
2. **取引年月日**
3. **取引内容**
4. **税率ごとに区分して合計した対価の額および消費税額等**
5. **適用税率**
6. **取引先の氏名または名称**
# 指示
次のコンテンツは適格請求書の6要件を満たしているかを確認してください。
結果をJSON形式で返してください。
満たさない要件がある場合、「NGの理由」に具体的な内容を出力してください。
すべての要件を満たす場合、「NGの理由」は空欄にしてください。
# 結果のJSON形式の例
{
"要件1": "OK",
"要件2": "OK",
"要件3": "NG",
"要件4": "OK",
"要件5": "OK",
"要件6": "OK",
"NGの理由": "要件3の取引内容が記載されていません。"
}`;
function setup() {
const ss = SpreadsheetApp.getActive();
let settingsSheet = ss.getSheetByName(SETTINGS_SHEET);
if (!settingsSheet) {
settingsSheet = ss.insertSheet(SETTINGS_SHEET);
settingsSheet.getRange(SETTINGS_SHEET + "!A1").setValue("フォルダーのURL");
settingsSheet.getRange(SETTINGS_SHEET + "!A2").setValue("プロンプト");
settingsSheet.getRange(PROMPT_RANGE).setValue(INITIAL_PROMPT);
}
let textSheet = ss.getSheetByName(INVOICE_LIST_PREFIX + TEXT_POSTFIX);
if (!textSheet) {
textSheet = ss.insertSheet(INVOICE_LIST_PREFIX + TEXT_POSTFIX);
textSheet.appendRow(INVOICE_SHEET_HEADERS);
}
let imageSheet = ss.getSheetByName(INVOICE_LIST_PREFIX + IMAGE_POSTFIX);
if (!imageSheet) {
imageSheet = ss.insertSheet(INVOICE_LIST_PREFIX + IMAGE_POSTFIX);
imageSheet.appendRow(INVOICE_SHEET_HEADERS);
}
}
まとめ
この仕組みを使えば、PDFの請求書がインボイス要件を満たしているかを自動判定できます。
- 手作業でチェックしていた業務を自動化
- 請求書が多い場合でも簡単に確認可能
- Apps Script初心者でもスプレッドシートをコピーして導入OK
少しでもお仕事が楽になりますように!