Google Apps Script x AIでインボイス要件を自動チェック（スプレッドシート付き）

Google Workspaceでお仕事している人向けの記事です。

受領した請求書が「適格請求書（インボイス）」として有効なのかどうかのチェックを自動化するツールを作りました。具体的には、Google スプレッドシート、Apps Script、そして生成AI（Gemini）を活用します。

:::note warn
業務に生成AI（Gemini）の利用が許可されている場合にのみ、ご利用ください。
:::

コピーして使えるGoogleスプレッドシートも準備しています。必要なコードも一緒にコピーされるのですぐに試せます。面倒な請求書チェックよ、さらば！

# コピーして使えるスプレッドシート

下記リンクをクリックすると、スプレッドシートをコピーできます。  

https://docs.google.com/spreadsheets/d/1QJY6tX5-mDrmbuAp0H3kk4hK_XqWVMmxmJdDiZx7x_Q/copy

## 上記スプレッドシートにアクセスできない場合

所属組織のGoogle Workspaceで、組織外のファイルへのアクセスを禁止しているかもしれません。

まずは、ブラウザーのシークレットウィンドウで開いてみてください。

それでもダメな場合、次の手順で組織内に新しいスプレッドシートを作ります。

<details><summary>「スプレッドシートの作り方」を展開する</summary>

### Step1 PDFAppを組織内で使えるようにする

複数ページの請求書PDFファイルに対応するため、Apps Scriptのライブラリー [PDFApp](https://github.com/tanaikech/PDFApp?tab=readme-ov-file#convertpdftopng
)（MITライセンス） を利用しています。

組織内にApps Scriptを作成してライブラリーとして使えるようにします。

1. Apps Scriptを作成する
1. [PDFApp.js](https://github.com/tanaikech/PDFApp/blob/master/PDFApp.js)を上記Apps Scriptにコピペして保存する
1. スクリプトエディターの右上にある[デプロイ]ボタンを押し、`新しいデプロイ`を選ぶ
1. ギアアイコンで`ライブラリー`を選び、右下の[デプロイ]ボタンを押す
1. スクリプトエディターの設定で、スクリプトIDをコピーしておく

<img width="400" alt="ライブラリとしてデプロイする" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/13c3210c-16a8-b08c-9d24-e64988b17c2d.png">

### Step2 スプレッドシートを作成しセットアップする

1. Googleスプレッドシートを作成する
1. スプレッドシートのメニュー `拡張機能 > Apps Script` でスクリプトエディターを開く
1. スクリプトエディターの左ペイン`ライブラリ`に`PDF App`を追加する（Step1のスクリプトIDで検索して追加）
1. スクリプトエディターの左ペイン`サービス`で`Drive API`の`v3`を追加する
1. 後述する`Code.gs`をコピペする
1. `setup()`を実行する

スクリプトエディターの左ペインには、ライブラリーに`PDFApp`が、サービスに`Drive`が追加された状態になります。

<img width="512" alt="スクリプトエディターを設定した結果" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/06693964-79ea-4abe-f06d-df99da24fb72.png">

`setup()`によって、必要なシートが自動生成されています。

</details>


# 背景とポイント

請求書が適格請求書として認められるには、以下の6つの要件を満たす必要があります。

https://bill-one.com/knowledge/qualified-invoice/

1. 適格請求書発行事業者の氏名または名称、登録番号  
2. 取引年月日  
3. 取引内容  
4. 税率ごとに区分した対価額・消費税額等  
5. 適用税率  
6. 取引先の氏名または名称

本記事では、Google スプレッドシートとApps Script、そしてGemini（生成AI）を活用し、  
**フォルダー内のPDF請求書を自動で抽出** → **6要件チェック** → **スプレッドシートに出力**  
する方法を紹介します。

GeminiはPDFデータを直接扱えないため、  
- PDF → Google ドキュメント（OCR）によるテキスト化（テキスト方式）
- PDF → サムネイル画像（1ページ目のみ抽出）による画像識別（画像方式）
- 上記の組み合わせ（テキストと画像の両方をAIに送信）
の3パターンを実装してみました。どちらの方式が適しているか試してみてください。

# 事前準備

1. Googleドライブで請求書PDF用のフォルダーを作成
1. 作成したフォルダーのURLをスプレッドシートの「設定」シートの該当セルに入力
1. Geminiを使う準備
   1. https://aistudio.google.com/app/apikey でAPI Keyを作成
   1. スプレッドシートのメニュー「拡張機能 > Apps Script」でスクリプトエディターを表示
   1. スクリプトエディターの設定でスクリプトプロパティ`GEMINI_API_KEY`を追加し、API Keyを保存

:::note alert
API Keyは絶対に公開しないでください
:::


# 実行方法

1. PDFの請求書を、先ほど用意したフォルダーにアップロード  
1. スプレッドシート上部メニュー「インボイス要件 > 要件をチェック」をクリック  
   - 「テキスト」はPDFをGoogleドキュメントに変換してチェック  
   - 「画像」はPDFのサムネイル画像からチェック
   - 「テキストと画像」は上記の組み合わせ
1. 「請求書一覧」シートで結果を確認  
   - 「テキスト」「画像」で別シートに出力されるので、比較可能

# 検証例（無料テンプレートでテスト）

[堺みらい税理士事務所さんのインボイステンプレート](https://sakaimirai.com/freetemplate/invoice/)を使ってチェックしたところ、テキスト方式と画像方式で結果に差がありました。  
実際の請求書で試してみて、うまくいく方式をご利用ください。

## テキスト方式の結果

NG箇所が多く指摘されました。

| ファイル名 | 要件1 | 要件2 | 要件3 | 要件4 | 要件5 | 要件6 | NGの理由 |
|---|---|---|---|---|---|---|---|
| [tempinvoice.invoice001.pdf](https://sakaimirai.com/wp-content/uploads/tempinvoice.invoice001.pdf) | OK | NG | NG | OK | OK | OK | 要件2の取引年月日が曖昧です。要件3の取引内容が曖昧で、詳細な内訳が不足しています。 |
| [tempinvoice.invoice002.pdf](https://sakaimirai.com/wp-content/uploads/tempinvoice.invoice002.pdf) | OK | NG | NG | OK | OK | OK | 要件2の取引年月日が曖昧です。要件3の取引内容が詳細に記載されていません。|
| [tempinvoice.invoice004.pdf](https://sakaimirai.com/wp-content/uploads/tempinvoice.invoice004.pdf) | OK | OK | NG | OK | OK | OK | 要件3の取引内容が曖昧で、適格請求書として必要な詳細な情報が不足しています。具体的には、作業内容、数量、単価などが明確に記載されていません。|

## 画像方式の結果

すべてOK判定でした。

ただ、NG箇所をうまく判定できていない可能性があります。実際の請求書で試してみてください。

## ポイント

- 結果が思わしくない場合、プロンプトを修正するか（「設定」シートで編集可）、別方式を試してください。
- 再実行するときは「請求書一覧」から対象ファイル行を削除してから「要件をチェック」を再度実行してください。

## Apps Scriptコード

次のコードを「拡張機能 > Apps Script」で開き、`GEMINI_API_KEY`をスクリプトプロパティに設定してください。

複数ページの請求書PDFファイルに対応するため、Apps Scriptのライブラリー [PDFApp](https://github.com/tanaikech/PDFApp?tab=readme-ov-file#convertpdftopng
)（MITライセンス） を利用しています。とても便利でありがたいです！

<details><summary>Code.gsを展開する</summary>

```javascript: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_AND_IMAGE_POSTFIX = "（テキストと画像）"
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_AND_IMAGE_POSTFIX, "checkInvoicesByTextAndImage")
    .addItem(RUN_MENU + TEXT_POSTFIX, "checkInvoicesByText")
    .addItem(RUN_MENU + IMAGE_POSTFIX, "checkInvoicesByImage")
    .addToUi();
}

// メイン処理起動関数
async function checkInvoicesByTextAndImage() {
  await checkInvoices_("text_and_image", INVOICE_LIST_PREFIX + TEXT_AND_IMAGE_POSTFIX);
}

async function checkInvoicesByText() {
  await checkInvoices_("text", INVOICE_LIST_PREFIX + TEXT_POSTFIX);
}

async function checkInvoicesByImage() {
  await checkInvoices_("image", INVOICE_LIST_PREFIX + IMAGE_POSTFIX);
}

// 請求書チェック実行関数
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 extractFolderId(url) {
  // 正規表現を使用してフォルダIDを抽出します。
  const regex = /folders\/([a-zA-Z0-9_-]+)/; 
  const match = url.match(regex);
  if (match && match[1]) {
    return match[1];
  } else {
    return null; // フォルダIDが見つからない場合
  }
}

function getFolderFromUrl_(folderUrl) {
  const folderId = extractFolderId(folderUrl);
  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_and_image') {
    return await checkInvoiceByTextAndImage_(prompt, file);
  }
  if (mode === "text") {
    return await checkInvoiceByText_(prompt, file);
  }
  if (mode === "image") {
    // return checkInvoiceByImage_(prompt, file);
    return await checkInvoiceByImages_(prompt, file);
  }
  throw new Error(`無効な処理モード: ${mode}`);
}

async function checkInvoiceByTextAndImage_(prompt, file) {
  const doc = convertPdfToGoogleDoc_(file.getId());
  if (!doc) throw new Error("Googleドキュメントへの変換に失敗しました。");
  const images = await PDFApp.setPDFBlob(file.getBlob()).convertPDFToPng();

  const textContent = getGoogleDocText_(doc.id);
  return requestInvoiceCheckWithImages_(prompt + '# テキスト化した内容\n\n' + textContent, images);
}

async function checkInvoiceByText_(prompt, file) {
  const doc = convertPdfToGoogleDoc_(file.getId());
  if (!doc) throw new Error("Googleドキュメントへの変換に失敗しました。");

  const content = getGoogleDocText_(doc.id);
  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);
  const text = doc.getBody().getText();
  DriveApp.getFileById(docId).setTrashed(true); // 一時的なGoogleドキュメントを削除
  return text;
}

// インボイス要件チェック(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 textAndImageSheet = ss.getSheetByName(INVOICE_LIST_PREFIX + TEXT_AND_IMAGE_POSTFIX);
  if (!textAndImageSheet) {
    textAndImageSheet = ss.insertSheet(INVOICE_LIST_PREFIX + TEXT_AND_IMAGE_POSTFIX);
    textAndImageSheet.appendRow(INVOICE_SHEET_HEADERS);
  }

  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);
  }
}
```

</details>

## まとめ

この仕組みを使えば、PDFの請求書がインボイス要件を満たしているかを自動判定できます。  
- 手作業でチェックしていた業務を自動化
- 請求書が多い場合でも簡単に確認可能
- Apps Script初心者でもスプレッドシートをコピーして導入OK

少しでもお仕事が楽になりますように！