「毎月スプレッドシートを開いて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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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からトリガーを設定する方法
- スクリプトエディタ左側の時計アイコン(トリガー)をクリック
- 「トリガーを追加」をクリック
- 実行する関数:
sendInvoicesToAllCustomers - イベントソース: 時間主導型
- 時間ベースのトリガーのタイプ: 月タイマー
- 実行する日: 月末日
- 実行する時刻: 午前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を自動生成できます。
- GASだけで完結: Google以外のツールを追加契約する必要なし
- HTMLでフルデザイン: スプレッドシートのデフォルト出力とは異なるブランドデザインのPDF
- ドライブ保存 + メール送信を1スクリプトで: 保存と送付を同時に自動化
- トリガーで完全放置: 月次処理を設定したら毎月自動で実行
まずは無料プラン(月30件)でAPIキーを取得して、スクリプトエディタで試してみてください。PlaygroundでHTMLのPDF変換を確認しながら開発を進めると効率的です。
関連リンク
- APIリファレンス — エンドポイントの詳細仕様
- PDF生成APIクイックスタート — Node.js・Python・PHPのコード例
- PDFテンプレートエンジン入門 — 変数・ループ・条件分岐の使い方
- 請求書PDFをAPIで自動生成する方法 — JavaScript・Python・PHPでの実装例
- PDF APIエラーハンドリング完全ガイド — 生成失敗時のリトライ戦略
- PDF API本番運用チェックリスト — 本番環境での安定運用ノウハウ
- PDF APIセキュリティガイド — APIキー・データの安全な取り扱い
- 月次レポートPDF生成ガイド — レポート自動化との組み合わせ
- PDF APIバッチ処理ガイド — 大量PDFの効率的な一括生成