NaN/NaN/NaN

「毎月スプレッドシートを開いてPDFにエクスポートして、メールに添付して送る」という作業を繰り返していませんか?

Google Apps Script(GAS)とFUNBREW PDF APIを組み合わせれば、スプレッドシートのデータからPDFを自動生成し、Googleドライブへの保存やメール送信まで一気に自動化できます。

この記事では、GASプロジェクトのセットアップから、UrlFetchAppでのAPI呼び出し、スプレッドシートデータのHTMLテンプレートへの埋め込み、Googleドライブ保存、メール自動送信、トリガーによる定期実行まで、実践コードとともに解説します。最後に月次請求書の完全自動フローのサンプルも紹介します。

API自体の基本的な使い方はクイックスタートガイドを参照してください。

なぜGASとPDF APIを組み合わせるのか

Google組み込みのPDF出力の限界

Google スプレッドシートには標準でPDF出力機能がありますが、以下の点で限界があります。

  • レイアウトが固定: スプレッドシートのグリッドそのままで、請求書らしい見た目にできない
  • 自動化しにくい: Drive APIを使えばある程度できるが、ページ設定の細かな制御が難しい
  • デザインの自由度がない: ロゴ、カラー、フォントのカスタマイズに限界がある
  • 一括処理が複雑: 複数シートから複数PDFを生成する処理を組むのが大変

FUNBREW PDF API + GASの利点

  • HTMLでPDFをデザイン: CSSで自由にレイアウトできるため、ブランドに合った美しいPDFを生成できる
  • GASから直接呼び出せる: UrlFetchAppで簡単にHTTPリクエストを送れる
  • スプレッドシートデータを自動でHTMLに変換: セルの値を読み取ってテンプレートに流し込むだけ
  • Googleドライブとメールと連携: 生成したPDFをそのままDriveに保存したりGmailで送信できる
  • トリガーで完全自動化: 時間ベースのトリガーで月末に自動実行できる

ユースケース

GAS + PDF APIの組み合わせが特に役立つシーンをまとめます。

ユースケース スプレッドシートの役割 PDF化のトリガー
月次請求書 顧客情報・明細データ 月末の定期実行
見積書 商品リスト・数量・金額 スプレッドシート更新時
月次レポート 売上・KPIの集計 毎月1日の定期実行
在庫報告書 在庫数・発注点 特定条件達成時
勤怠サマリー 出勤・残業データ 月次締め処理

Phase 1: GASプロジェクトのセットアップ

スクリプトエディタを開く

Google スプレッドシートで「拡張機能」→「Apps Script」を選択します。新しいスクリプトエディタが開きます。

APIキーの保存

APIキーはスクリプトに直接書かずに、GASの「スクリプトプロパティ」に保存します。

// 初回のみ実行してAPIキーを登録する関数
function setApiKey() {
  const scriptProperties = PropertiesService.getScriptProperties();
  scriptProperties.setProperty('FUNBREW_API_KEY', 'sk-your-api-key-here');
  Logger.log('APIキーを保存しました');
}

// APIキーを取得する共通関数
function getApiKey() {
  return PropertiesService.getScriptProperties().getProperty('FUNBREW_API_KEY');
}

setApiKey()を一度実行してAPIキーを登録した後は、コード中からgetApiKey()で安全に参照できます。APIキーのセキュリティについてはPDF APIセキュリティガイドで詳しく解説しています。

基本的なAPI呼び出し関数

/**
 * FUNBREW PDF APIを呼び出してPDFを生成する基本関数
 * @param {string} html - PDF化するHTMLコンテンツ
 * @param {Object} options - オプション(format, marginなど)
 * @returns {Blob} - 生成されたPDFのBlob
 */
function generatePdf(html, options = {}) {
  const apiKey = getApiKey();
  const apiUrl = 'https://pdf.funbrew.cloud/api/pdf/from-html';

  const payload = {
    html: html,
    engine: options.engine || 'quality',
    format: options.format || 'A4',
    margin: options.margin || { top: '20mm', right: '20mm', bottom: '20mm', left: '20mm' },
  };

  const requestOptions = {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer ' + apiKey,
      'Content-Type': 'application/json',
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  };

  const response = UrlFetchApp.fetch(apiUrl, requestOptions);

  if (response.getResponseCode() !== 200) {
    const errorBody = response.getContentText();
    throw new Error('PDF生成エラー: ' + response.getResponseCode() + ' - ' + errorBody);
  }

  return response.getBlob().setName('output.pdf');
}

Phase 2: スプレッドシートのデータを読み取る

シートからデータを取得する

/**
 * アクティブなスプレッドシートから請求書データを取得する
 * @returns {Object} - 請求書データオブジェクト
 */
function getInvoiceData() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('請求書データ');

  // 基本情報(B列の値を読む想定)
  const invoiceData = {
    invoiceNumber: sheet.getRange('B1').getValue(),
    issueDate: Utilities.formatDate(sheet.getRange('B2').getValue(), 'Asia/Tokyo', 'yyyy/MM/dd'),
    dueDate: Utilities.formatDate(sheet.getRange('B3').getValue(), 'Asia/Tokyo', 'yyyy/MM/dd'),
    customerName: sheet.getRange('B4').getValue(),
    customerEmail: sheet.getRange('B5').getValue(),
  };

  // 明細行(A〜D列、7行目以降)
  const itemsSheet = ss.getSheetByName('明細');
  const lastRow = itemsSheet.getLastRow();
  const items = [];

  for (let row = 2; row <= lastRow; row++) {
    const name = itemsSheet.getRange(row, 1).getValue();
    if (!name) break; // 空行で終了

    items.push({
      name: name,
      quantity: itemsSheet.getRange(row, 2).getValue(),
      unitPrice: itemsSheet.getRange(row, 3).getValue(),
      subtotal: itemsSheet.getRange(row, 4).getValue(),
    });
  }

  // 合計金額
  const summarySheet = ss.getSheetByName('集計');
  invoiceData.subtotal = summarySheet.getRange('B1').getValue();
  invoiceData.tax = summarySheet.getRange('B2').getValue();
  invoiceData.total = summarySheet.getRange('B3').getValue();
  invoiceData.items = items;

  return invoiceData;
}

データの整形(数値・日付フォーマット)

スプレッドシートから取得したデータは、HTML埋め込み前に見やすく整形します。

/**
 * 数値を金額フォーマット(カンマ区切り)に変換
 * @param {number} value
 * @returns {string}
 */
function formatCurrency(value) {
  return Number(value).toLocaleString('ja-JP');
}

/**
 * HTMLの特殊文字をエスケープ(XSS対策)
 * @param {string} str
 * @returns {string}
 */
function escapeHtml(str) {
  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

Phase 3: HTMLテンプレートへの埋め込み

請求書HTMLテンプレートの作成

/**
 * 請求書データからHTMLを生成する
 * @param {Object} data - getInvoiceData()の戻り値
 * @returns {string} - PDF化するHTML文字列
 */
function buildInvoiceHtml(data) {
  // 明細行のHTML生成
  const lineItemsHtml = data.items.map(item => `
    <tr>
      <td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb;">${escapeHtml(item.name)}</td>
      <td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;">${item.quantity}</td>
      <td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;">¥${formatCurrency(item.unitPrice)}</td>
      <td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;">¥${formatCurrency(item.subtotal)}</td>
    </tr>
  `).join('');

  return `<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <style>
    body {
      font-family: 'Noto Sans JP', 'Hiragino Sans', 'Yu Gothic', sans-serif;
      color: #1f2937;
      max-width: 800px;
      margin: 0 auto;
      padding: 40px;
    }
    .header {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      margin-bottom: 40px;
    }
    .invoice-title {
      font-size: 32px;
      font-weight: 700;
      color: #111827;
      margin: 0 0 8px;
    }
    .meta-label {
      color: #6b7280;
      font-size: 13px;
    }
    .company-info {
      text-align: right;
      font-size: 13px;
      color: #374151;
    }
    .customer-name {
      font-size: 20px;
      font-weight: 700;
      margin-bottom: 32px;
      padding-bottom: 16px;
      border-bottom: 2px solid #e5e7eb;
    }
    table {
      width: 100%;
      border-collapse: collapse;
      margin-bottom: 32px;
    }
    thead tr {
      background: #f8fafc;
    }
    th {
      padding: 12px;
      text-align: left;
      font-size: 13px;
      font-weight: 600;
      color: #374151;
      border-bottom: 2px solid #e5e7eb;
    }
    th:not(:first-child) { text-align: right; }
    .total-section {
      text-align: right;
      margin-bottom: 40px;
    }
    .total-row {
      display: flex;
      justify-content: flex-end;
      gap: 24px;
      margin-bottom: 8px;
      font-size: 14px;
    }
    .total-label { color: #6b7280; }
    .grand-total {
      font-size: 22px;
      font-weight: 700;
      color: #111827;
      margin-top: 16px;
      padding-top: 16px;
      border-top: 2px solid #111827;
    }
    .footer {
      border-top: 1px solid #e5e7eb;
      padding-top: 20px;
      font-size: 12px;
      color: #9ca3af;
    }
  </style>
</head>
<body>
  <div class="header">
    <div>
      <h1 class="invoice-title">請求書</h1>
      <p class="meta-label">請求番号: ${escapeHtml(String(data.invoiceNumber))}</p>
      <p class="meta-label">発行日: ${escapeHtml(data.issueDate)}</p>
    </div>
    <div class="company-info">
      <p style="font-weight: 700; font-size: 15px;">株式会社サンプル</p>
      <p>東京都渋谷区〇〇1-2-3</p>
      <p>TEL: 03-1234-5678</p>
    </div>
  </div>

  <p class="customer-name">${escapeHtml(data.customerName)} 御中</p>

  <table>
    <thead>
      <tr>
        <th style="width: 45%;">品目・サービス</th>
        <th style="width: 15%;">数量</th>
        <th style="width: 20%;">単価</th>
        <th style="width: 20%;">小計</th>
      </tr>
    </thead>
    <tbody>
      ${lineItemsHtml}
    </tbody>
  </table>

  <div class="total-section">
    <div class="total-row">
      <span class="total-label">小計</span>
      <span>¥${formatCurrency(data.subtotal)}</span>
    </div>
    <div class="total-row">
      <span class="total-label">消費税(10%)</span>
      <span>¥${formatCurrency(data.tax)}</span>
    </div>
    <div class="grand-total">
      合計金額: ¥${formatCurrency(data.total)}
    </div>
  </div>

  <div class="footer">
    <p>お支払い期限: ${escapeHtml(data.dueDate)}</p>
    <p>振込先: サンプル銀行 渋谷支店 普通 1234567(カ)サンプル</p>
    <p>※ お振込手数料はご負担ください。</p>
  </div>
</body>
</html>`;
}

Phase 4: PDFを生成してGoogleドライブに保存

/**
 * スプレッドシートデータからPDFを生成してGoogleドライブに保存する
 * @returns {DriveFile} - 保存されたファイル
 */
function generateAndSavePdf() {
  // データ取得
  const data = getInvoiceData();

  // HTML生成
  const html = buildInvoiceHtml(data);

  // PDF生成
  Logger.log('PDF生成中: ' + data.invoiceNumber);
  const pdfBlob = generatePdf(html, {
    engine: 'quality',
    format: 'A4',
    margin: { top: '15mm', right: '15mm', bottom: '15mm', left: '15mm' },
  });

  // ファイル名の設定
  const fileName = '請求書_' + data.invoiceNumber + '_' + data.issueDate.replace(/\//g, '') + '.pdf';
  pdfBlob.setName(fileName);

  // 保存先フォルダの取得(なければ作成)
  const folderName = '請求書PDF';
  let folder;
  const folders = DriveApp.getFoldersByName(folderName);
  if (folders.hasNext()) {
    folder = folders.next();
  } else {
    folder = DriveApp.createFolder(folderName);
    Logger.log('フォルダを作成しました: ' + folderName);
  }

  // PDFをGoogleドライブに保存
  const file = folder.createFile(pdfBlob);
  Logger.log('保存完了: ' + file.getUrl());

  return file;
}

実行確認

スクリプトエディタでgenerateAndSavePdfを選択して「実行」ボタンを押します。初回実行時はGoogleドライブへのアクセス権限を要求されるので「許可」してください。

ログに「保存完了」と表示され、GoogleドライブのMy Drive直下に「請求書PDF」フォルダとPDFファイルが作成されれば成功です。

Phase 5: PDFをメールで自動送信

/**
 * PDFを生成して顧客にメール送信する
 */
function generateAndSendInvoice() {
  // データ取得 + PDF生成
  const data = getInvoiceData();
  const html = buildInvoiceHtml(data);
  const pdfBlob = generatePdf(html, { engine: 'quality', format: 'A4' });

  const fileName = '請求書_' + data.invoiceNumber + '.pdf';
  pdfBlob.setName(fileName);

  // メール送信
  const subject = '【請求書】' + data.invoiceNumber + ' - ' + data.issueDate + ' 発行分';
  const body = `${data.customerName} 御中

お世話になっております。
${data.issueDate}付の請求書をお送りします。

請求金額: ¥${formatCurrency(data.total)}(消費税込み)
お支払い期限: ${data.dueDate}

詳細は添付のPDFをご確認ください。
ご不明な点がございましたら、お気軽にご連絡ください。

株式会社サンプル
担当: 山田太郎
TEL: 03-1234-5678`;

  GmailApp.sendEmail(
    data.customerEmail,
    subject,
    body,
    {
      attachments: [pdfBlob],
      name: '株式会社サンプル',
      cc: 'accounting@sample.co.jp', // 社内控え
    }
  );

  Logger.log('送信完了: ' + data.customerEmail);

  // Googleドライブにも保存
  const folders = DriveApp.getFoldersByName('請求書PDF');
  const folder = folders.hasNext() ? folders.next() : DriveApp.createFolder('請求書PDF');
  folder.createFile(pdfBlob.setName(fileName));
}

送信後にシートをアーカイブする

/**
 * 送信済みフラグをシートに書き込む
 */
function markAsSent() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('請求書データ');
  sheet.getRange('B6').setValue('送信済み');
  sheet.getRange('B7').setValue(new Date());
}

Phase 6: 複数顧客への一括送信

月末に全顧客へ一括でPDF請求書を送る場合は、顧客リストシートをループ処理します。

/**
 * 顧客リストシートから全顧客に請求書PDFを一括生成・送信する
 */
function sendInvoicesToAllCustomers() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const customersSheet = ss.getSheetByName('顧客リスト');
  const lastRow = customersSheet.getLastRow();

  const results = {
    success: [],
    failed: [],
  };

  // 2行目からループ(1行目はヘッダー)
  for (let row = 2; row <= lastRow; row++) {
    const status = customersSheet.getRange(row, 7).getValue();
    if (status === '送信済み') continue; // スキップ

    const customerData = {
      invoiceNumber: customersSheet.getRange(row, 1).getValue(),
      customerName: customersSheet.getRange(row, 2).getValue(),
      customerEmail: customersSheet.getRange(row, 3).getValue(),
      total: customersSheet.getRange(row, 4).getValue(),
      tax: customersSheet.getRange(row, 5).getValue(),
      issueDate: Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd'),
      dueDate: Utilities.formatDate(
        new Date(new Date().getFullYear(), new Date().getMonth() + 1, 25),
        'Asia/Tokyo',
        'yyyy/MM/dd'
      ),
      subtotal: customersSheet.getRange(row, 6).getValue(),
      items: [], // 明細は別シートから取得する想定
    };

    try {
      const html = buildInvoiceHtml(customerData);
      const pdfBlob = generatePdf(html, { engine: 'quality', format: 'A4' });
      pdfBlob.setName('請求書_' + customerData.invoiceNumber + '.pdf');

      GmailApp.sendEmail(
        customerData.customerEmail,
        '【請求書】' + customerData.invoiceNumber,
        '請求書を送付します。添付PDFをご確認ください。',
        { attachments: [pdfBlob] }
      );

      // 送信済みフラグを立てる
      customersSheet.getRange(row, 7).setValue('送信済み');
      customersSheet.getRange(row, 8).setValue(new Date());
      results.success.push(customerData.invoiceNumber);

      // API制限を避けるため少し待機
      Utilities.sleep(500);

    } catch (error) {
      Logger.log('エラー (' + customerData.invoiceNumber + '): ' + error.message);
      customersSheet.getRange(row, 7).setValue('エラー: ' + error.message);
      results.failed.push({ id: customerData.invoiceNumber, error: error.message });
    }
  }

  // 結果サマリーをログに出力
  Logger.log('完了 - 成功: ' + results.success.length + '件, 失敗: ' + results.failed.length + '件');
  if (results.failed.length > 0) {
    Logger.log('失敗リスト: ' + JSON.stringify(results.failed));
    // Slackや管理者メールへの通知もここで送る
    MailApp.sendEmail('admin@sample.co.jp', '請求書送信エラー', JSON.stringify(results.failed, null, 2));
  }
}

Phase 7: トリガーによる定期実行

時間ベースのトリガーを設定する

GASには「トリガー」機能があり、月末に自動で関数を実行できます。

GUIからトリガーを設定する方法

  1. スクリプトエディタ左側の時計アイコン(トリガー)をクリック
  2. 「トリガーを追加」をクリック
  3. 実行する関数: sendInvoicesToAllCustomers
  4. イベントソース: 時間主導型
  5. 時間ベースのトリガーのタイプ: 月タイマー
  6. 実行する日: 月末日
  7. 実行する時刻: 午前9時〜10時

コードでトリガーを設定する方法

/**
 * 月末自動実行トリガーをコードで作成する
 * ※ このスクリプトは一度だけ手動で実行する
 */
function createMonthlyTrigger() {
  // 既存のトリガーを削除(重複防止)
  const triggers = ScriptApp.getProjectTriggers();
  triggers.forEach(trigger => {
    if (trigger.getHandlerFunction() === 'sendInvoicesToAllCustomers') {
      ScriptApp.deleteTrigger(trigger);
    }
  });

  // 毎月27日の午前9時に実行(月末処理)
  ScriptApp.newTrigger('sendInvoicesToAllCustomers')
    .timeBased()
    .onMonthDay(27)
    .atHour(9)
    .create();

  Logger.log('月次トリガーを設定しました(毎月27日 09:00)');
}

/**
 * 既存のトリガーを確認する
 */
function listTriggers() {
  const triggers = ScriptApp.getProjectTriggers();
  triggers.forEach(trigger => {
    Logger.log(
      trigger.getHandlerFunction() + ' - ' +
      trigger.getEventType() + ' - ' +
      trigger.getTriggerSource()
    );
  });
}

スプレッドシート更新時のトリガー

「見積書シートが更新されたらPDFを自動生成」したい場合はonEditトリガーを使います。

/**
 * スプレッドシートの特定セルが編集されたらPDFを生成する
 * ※ Apps Scriptのスペシャルトリガーとして自動実行される
 */
function onEdit(e) {
  const sheet = e.source.getActiveSheet();
  const range = e.range;

  // 「見積書データ」シートのB8セル(承認フラグ)が「承認」になったらPDF生成
  if (sheet.getName() === '見積書データ' && range.getA1Notation() === 'B8') {
    if (e.value === '承認') {
      try {
        generateAndSendInvoice();
        SpreadsheetApp.getActiveSpreadsheet().toast('PDFを送信しました', '完了', 5);
      } catch (error) {
        SpreadsheetApp.getActiveSpreadsheet().toast('エラー: ' + error.message, 'エラー', 10);
      }
    }
  }
}

Phase 8: テンプレートAPIを使った高度な実装

FUNBREW PDFのテンプレートAPIを使うと、HTMLの生成をサーバーサイドに任せられます。ダッシュボードでテンプレートを事前登録しておく方法です。

/**
 * テンプレートAPIを使ってPDFを生成する
 * ダッシュボードでテンプレートを「invoice-ja」という名前で登録しておく
 */
function generatePdfFromTemplate(data) {
  const apiKey = getApiKey();
  const apiUrl = 'https://pdf.funbrew.cloud/api/pdf/generate-from-template';

  const payload = {
    template: 'invoice-ja',
    variables: {
      invoice_number: String(data.invoiceNumber),
      issue_date: data.issueDate,
      due_date: data.dueDate,
      customer_name: data.customerName,
      subtotal: formatCurrency(data.subtotal),
      tax: formatCurrency(data.tax),
      total: formatCurrency(data.total),
      line_items: data.items.map(item => ({
        name: item.name,
        quantity: item.quantity,
        unit_price: formatCurrency(item.unitPrice),
        subtotal: formatCurrency(item.subtotal),
      })),
    },
    options: { engine: 'quality', format: 'A4' },
  };

  const response = UrlFetchApp.fetch(apiUrl, {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer ' + apiKey,
      'Content-Type': 'application/json',
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  });

  if (response.getResponseCode() !== 200) {
    throw new Error('テンプレートAPI エラー: ' + response.getContentText());
  }

  const result = JSON.parse(response.getContentText());
  return result.data.download_url;
}

テンプレートの詳しい作成方法はPDFテンプレートエンジン入門を参照してください。

実践例:月次請求書の完全自動化フロー

ここまで解説してきた機能を組み合わせた、実際に使える月次請求書自動化の完全フローを紹介します。

スプレッドシート構成

請求書マスター(スプレッドシート)
├── 顧客リスト(シート)
│   ├── A列: 請求書番号
│   ├── B列: 顧客名
│   ├── C列: メールアドレス
│   ├── D列: 月額料金
│   ├── E列: 消費税
│   ├── F列: 合計
│   ├── G列: 送信ステータス
│   └── H列: 送信日時
├── 設定(シート)
│   ├── B1: 支払期限(翌月25日など)
│   └── B2: 送信元名
└── ログ(シート)
    └── 実行履歴

メインスクリプト

/**
 * 月次請求書自動化のメイン関数
 * 毎月27日の午前9時に自動実行される
 */
function monthlyInvoiceJob() {
  const startTime = new Date();
  Logger.log('=== 月次請求書ジョブ 開始: ' + startTime + ' ===');

  try {
    // 1. 今月の請求データを準備
    const currentMonth = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy年MM月');
    Logger.log('対象月: ' + currentMonth);

    // 2. 顧客リストを取得
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const customersSheet = ss.getSheetByName('顧客リスト');
    const settingSheet = ss.getSheetByName('設定');
    const logSheet = ss.getSheetByName('ログ');

    const dueDate = Utilities.formatDate(settingSheet.getRange('B1').getValue(), 'Asia/Tokyo', 'yyyy/MM/dd');
    const issueDate = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd');

    const lastRow = customersSheet.getLastRow();
    const results = { success: 0, skip: 0, error: 0 };

    // 3. 全顧客を処理
    for (let row = 2; row <= lastRow; row++) {
      const invoiceNumber = 'INV-' + Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyyMM') + '-' + String(row - 1).padStart(3, '0');
      const customerName = customersSheet.getRange(row, 2).getValue();
      const customerEmail = customersSheet.getRange(row, 3).getValue();
      const monthlyFee = customersSheet.getRange(row, 4).getValue();
      const status = customersSheet.getRange(row, 7).getValue();

      if (!customerName || !customerEmail) continue;
      if (status === '送信済み') { results.skip++; continue; }

      const tax = Math.round(monthlyFee * 0.1);
      const total = monthlyFee + tax;

      const customerData = {
        invoiceNumber: invoiceNumber,
        issueDate: issueDate,
        dueDate: dueDate,
        customerName: customerName,
        customerEmail: customerEmail,
        subtotal: monthlyFee,
        tax: tax,
        total: total,
        items: [
          { name: 'サービス利用料(' + currentMonth + '分)', quantity: 1, unitPrice: monthlyFee, subtotal: monthlyFee }
        ],
      };

      try {
        // HTML生成 & PDF生成
        const html = buildInvoiceHtml(customerData);
        const pdfBlob = generatePdf(html, { engine: 'quality', format: 'A4' });
        pdfBlob.setName('請求書_' + invoiceNumber + '.pdf');

        // Googleドライブに保存
        const folders = DriveApp.getFoldersByName('請求書PDF/' + Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyyMM'));
        const folder = folders.hasNext() ? folders.next() : DriveApp.createFolder('請求書PDF/' + Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyyMM'));
        const savedFile = folder.createFile(pdfBlob);

        // メール送信
        GmailApp.sendEmail(
          customerEmail,
          '【ご請求書】' + currentMonth + 'ご利用分 - ' + invoiceNumber,
          customerName + ' 御中\n\n' + currentMonth + 'ご利用分の請求書をお送りします。\n\n合計: ¥' + formatCurrency(total) + '\nお支払い期限: ' + dueDate + '\n\n添付のPDFをご確認ください。',
          { attachments: [pdfBlob], name: '株式会社サンプル 経理部' }
        );

        // ステータス更新
        customersSheet.getRange(row, 1).setValue(invoiceNumber);
        customersSheet.getRange(row, 7).setValue('送信済み');
        customersSheet.getRange(row, 8).setValue(new Date());
        results.success++;

        Utilities.sleep(300); // レート制限対策

      } catch (rowError) {
        Logger.log('行' + row + ' エラー: ' + rowError.message);
        customersSheet.getRange(row, 7).setValue('エラー');
        results.error++;
      }
    }

    // 4. 実行ログを記録
    const endTime = new Date();
    const duration = Math.round((endTime - startTime) / 1000);
    logSheet.appendRow([
      startTime,
      '月次請求書ジョブ',
      '完了',
      '成功: ' + results.success + '件, スキップ: ' + results.skip + '件, エラー: ' + results.error + '件',
      duration + '秒',
    ]);

    Logger.log('=== 完了: 成功 ' + results.success + '件 / スキップ ' + results.skip + '件 / エラー ' + results.error + '件 ===');

    // 5. エラーがあれば管理者に通知
    if (results.error > 0) {
      MailApp.sendEmail(
        'admin@sample.co.jp',
        '【要確認】月次請求書ジョブでエラーが発生',
        results.error + '件のエラーが発生しました。スプレッドシートのステータスを確認してください。'
      );
    }

  } catch (fatalError) {
    Logger.log('致命的エラー: ' + fatalError.message);
    MailApp.sendEmail('admin@sample.co.jp', '【緊急】月次請求書ジョブが失敗', fatalError.message);
  }
}

エラーハンドリングとデバッグ

よくあるエラーと対処法

「Authorization failed」エラー

// APIキーが正しく設定されているか確認する
function checkApiKey() {
  const apiKey = getApiKey();
  if (!apiKey) {
    throw new Error('APIキーが設定されていません。setApiKey()を実行してください。');
  }
  Logger.log('APIキー: ' + apiKey.substring(0, 8) + '...' + apiKey.slice(-4));
}

「Exception: Quota exceeded」エラー(GmailAppの送信上限)

GASのGmailAppは1日の送信上限が約100通(無料Google Workspace)または1,500通(有料プラン)です。大量送信の場合はバッチを分割して実行します。

// 1回の実行で処理する最大件数を制限する
const MAX_PER_RUN = 50;
let processedCount = 0;

for (let row = 2; row <= lastRow; row++) {
  if (processedCount >= MAX_PER_RUN) {
    Logger.log('上限に達したため今回はここで停止。次回の実行で続きを処理します。');
    break;
  }
  // ... 処理 ...
  processedCount++;
}

PDF生成のタイムアウト

GASの実行時間は最大6分です。大量のPDF生成は複数回に分けて実行します。

// 実行開始時間を記録して、5分が近づいたら安全に終了する
function generatePdfsWithTimeout() {
  const startTime = new Date();
  const TIMEOUT_MS = 5 * 60 * 1000; // 5分

  // ... ループ処理の中で ...
  if (new Date() - startTime > TIMEOUT_MS) {
    Logger.log('タイムアウト防止のため処理を中断。次のトリガーで続きを実行します。');
    break;
  }
}

詳細なエラーハンドリングの実装についてはPDF APIエラーハンドリング完全ガイドを参照してください。

GAS + PDF APIを本番で安定運用するポイント

1. ドライラン機能を実装する

本番送信前に動作確認できるドライランモードを設けましょう。

/**
 * ドライランモード(実際には送信せず、ログ確認のみ)
 */
function dryRunInvoiceJob() {
  const DRY_RUN = true; // falseにすると本番実行
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('顧客リスト');

  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const name = sheet.getRange(row, 2).getValue();
    const email = sheet.getRange(row, 3).getValue();
    const total = sheet.getRange(row, 6).getValue();

    if (!name || !email) continue;

    Logger.log('[DRY RUN] ' + name + ' <' + email + '> 合計: ¥' + formatCurrency(total));

    if (!DRY_RUN) {
      // 実際の送信処理
    }
  }
  Logger.log('[DRY RUN] 完了 - 実際には何も送信していません');
}

2. 再実行に安全な冪等性設計

同じ請求書が二重送信されないよう、送信済みフラグを必ず確認してから処理します。

3. スプレッドシートの保護

顧客データ・送信ステータスが含まれるシートに「シートの保護」を設定し、誤操作を防ぎます。

4. 本番運用チェックリスト

  • スクリプトプロパティにAPIキーを保存済み
  • テスト用メールアドレスでドライランを確認
  • Googleドライブの保存フォルダを事前作成
  • エラー通知先メールアドレスを設定
  • トリガーの実行時刻を営業時間内に設定
  • GmailAppの送信上限に余裕があることを確認

本番運用の詳細はPDF API本番運用チェックリストも参考にしてください。

まとめ

Google Apps ScriptとFUNBREW PDF APIを組み合わせることで、スプレッドシートのデータからプロ品質のPDFを自動生成できます。

  1. GASだけで完結: Google以外のツールを追加契約する必要なし
  2. HTMLでフルデザイン: スプレッドシートのデフォルト出力とは異なるブランドデザインのPDF
  3. ドライブ保存 + メール送信を1スクリプトで: 保存と送付を同時に自動化
  4. トリガーで完全放置: 月次処理を設定したら毎月自動で実行

まずは無料プラン(月30件)でAPIキーを取得して、スクリプトエディタで試してみてください。PlaygroundでHTMLのPDF変換を確認しながら開発を進めると効率的です。

関連リンク

Powered by FUNBREW PDF