「毎月の請求書作成、面倒だな…」「月末になると請求書の送り忘れがないか不安…」
もしあなたが個人事業主や中小企業の経営者、経理担当者で、このような悩みを抱えているなら、もう請求書業務に貴重な時間を奪われる必要はありません。
この記事を最後まで読めば、あなたはAIアシスタント「Claude」とGoogleの無料ツール「Google Apps Script(GAS)」を使い、たった10分で請求書の作成からPDF化、さらには顧客へのメール送信までを完全に自動化するシステムを、自分の手で構築できるようになります。
プログラミングの経験は一切不要です。この記事の手順どおりにコピペするだけで、誰でも簡単に実装できます。まるで優秀な経理担当者を一人雇ったかのような、革命的な業務効率化が実現します。
この記事で紹介するシステムを導入すれば、あなたは…
- 請求書作成業務から永久に解放される
- 送信漏れや金額の間違いといった人為的ミスがゼロになる
- 毎月決まった日に自動で請求書が送られるため、催促の手間が激減する
…といった未来を手に入れることができます。空いた時間で、あなたはもっと創造的で、事業の成長に直結する仕事に集中できるでしょう。
さあ、一緒に面倒な請求書業務に終止符を打ち、ビジネスを次のステージへと加速させましょう!
準備するもの
| 必要なもの | 説明 |
|---|---|
| Googleアカウント | 無料で作成できます。スプレッドシート・Gmail・Googleドライブを使います。 |
| Claudeのアカウント | 無料プランで十分です。有料プランの「Opus」モデルが最強ですが、無料の「Sonnet」モデルでも問題なく動作します。 |
| 普段お使いの請求書(PDF形式) | AIがお手本として読み込むために使います。なければサンプルでも構いません。 |
GAS(Google Apps Script)とは?
聞き慣れない言葉かもしれませんが、難しく考える必要はありません。GASとは、一言で言えば「Googleの各種サービス(スプレッドシート、Gmail、Googleドライブなど)を自動化するための仕組み」です。Excelのマクロのようなもの、とイメージすると分かりやすいかもしれません。
皆さんが普段使っているスプレッドシートには、実はプログラムのコードを貼り付けることができます。そこにコードを貼ると、スプレッドシートを自動で動かすことができるのです。今回は、このGASのコードをAI(Claude)に書いてもらい、スプレッドシートに貼り付けるだけで、複雑な自動化システムを構築していきます。
【基本編】請求書の自動作成&メール送信(v1)
STEP1: Claudeにプロンプトを送り、自動化コードを作成してもらう
まずは、自動化システムの心臓部となるGASコードをClaudeに作成してもらいましょう。
1. Claudeを開く
Claudeの公式サイトにアクセスします。著者は最強モデルの「Opus 4.6」を使用していますが、無料の「Sonnet」モデルでも問題ありません。ChatGPTやGeminiでも同様のことは可能ですが、コード生成の精度においてClaudeが最もおすすめです。

2. 請求書PDFを添付し、プロンプトを送信する
クリップのアイコンをクリックし、普段お使いの請求書PDFファイルを添付します。これは、AIが請求書のフォーマットを理解するための「お手本」となります。
次に、以下のプロンプト(指示文)をコピーして、メッセージ入力欄に貼り付け、送信してください。
📋 コピペ用プロンプト(基本編)
請求書の作成を自動化するGAS(Google Apps Script)を作成したいです。添付の請求書PDFを参考に、スプレッドシート上で宛名や宛先項目などをすべて変数化し、内容を調整することで請求書を自動発行できる仕組みを構築してください。また、以下の機能も追加してほしいです:
- 自動送信機能:作成した請求書をお客様のメールアドレス宛に自動で送信する。

3. Claudeの応答を確認する
プロンプトを送信すると、Claudeがわずか2〜3分でGASコードとセットアップ手順を生成してくれます。「請求書自動発行 gas」というJSファイルが作成され、ダウンロードボタンが表示されていることを確認してください。このファイルに、自動化のためのコードがすべて書かれています。
Claudeの応答には、セットアップ手順も丁寧に記載されています。もしこの記事の説明で分かりにくい部分があれば、Claudeに「もっと分かりやすく説明して」と追加で聞いてみてください。取扱説明書のように詳しく教えてくれます。

これで、システムの設計図と材料が手に入りました。次のステップから、この設計図通りに組み立てていきましょう。
STEP2: Googleスプレッドシートを新規作成する
次に、自動化システムの土台となるGoogleスプレッドシートを用意します。
Googleドライブから「新規」>「Googleスプレッドシート」を選択し、新しいスプレッドシートを作成します。ファイル名は何でも構いませんが、後で分かりやすいように「請求書自動発行システムの構築」などと名前を付けておきましょう。
この時点ではまだ何もしていない、まっさらなスプレッドシートです。「シート1」しかない状態で大丈夫です。

STEP3: Apps Scriptを開いてコードを貼り付ける
作成したスプレッドシートに、先ほどClaudeが生成したGASコードを組み込んでいきます。
1. Apps Scriptエディタを開く
スプレッドシートのメニューバーから「拡張機能」>「Apps Script」を選択します。ちなみに、Google Apps Scriptの頭文字3つを略称で呼ぶと「GAS(ガス)」です。

2. コードを貼り付ける
Apps Scriptエディタが新しいタブで開きます。元々書かれている function myFunction() {} というサンプルコードをすべて削除してください。
そして、STEP1でClaudeが生成した「請求書自動発行 gas」ファイルの中身をすべてコピーし、空になったエディタ画面にペタッと貼り付けます。

STEP4: コードを保存して実行(権限承認)
コードを貼り付けたら、スクリプトを有効にするための設定を行います。
1. プロジェクトを保存する
エディタ上部にあるフロッピーディスクのアイコン(プロジェクトを保存)をクリックして、貼り付けたコードを保存します。保存しないとスクリプトが動かないので、絶対に保存してください。

2. スクリプトを実行し、権限を承認する
保存ボタンの隣にある「実行」ボタンをクリックします。初回実行時には「承認が必要です」というダイアログが表示されます。安心してください、これは正常な動作です。

これは、作成したスクリプトがあなたのGoogleアカウント情報(スプレッドシートやGmailなど)にアクセスすることを許可するための手続きです。以下の手順で承認を進めてください。
| 手順 | 操作内容 |
|---|---|
| 1 | 「権限を確認」をクリック |
| 2 | ご自身のGoogleアカウントを選択 |
| 3 | 「詳細」をクリックし、「○○(安全ではないページ)に移動」を選択 |
| 4 | すべてのアクセス権限を確認し、「許可」をクリック |

これで、スプレッドシート上でスクリプトが動作する準備が整いました。
STEP5: 初期セットアップを実行する
スクリプトの権限承認が完了したら、スプレッドシートのタブに戻ります。ここで注目してほしいのが、メニューバーです。先ほどまで「拡張機能」の右隣は「ヘルプ」までしかなかったはずですが、今は「📄 請求書」という新しいメニューが追加されています。これは、先ほど実行したスクリプトによって自動的に追加されたものです。

このメニューから、システムの初期設定を行います。
1. 「請求書」メニューから「初期セットアップ」を選択
メニューをクリックすると、「初期セットアップ」「請求書プレビュー」「PDF生成」「PDF生成+メール送信」など、さまざまな機能が表示されます。まずは「初期セットアップ」を選択してください。

2. 完了ダイアログを確認
スクリプトが実行され、「初期セットアップ完了 — 設定シートに自社情報を入力してください。請求データ・請求書から操作できます」というダイアログが表示されます。
同時に、シートの下部に「設定」「請求データ」「請求書テンプレート」という3つの新しいシートが自動的に作成されていることを確認してください。

STEP6: 「設定」シートに自社情報を入力する
自動作成された「設定」シートを開きます。ここには、請求書に記載する自社の情報を入力します。Claudeが添付PDFから読み取った情報が既にサンプルとして入力されている場合がありますので、ご自身の正しい情報に書き換えてください。
入力が必要な項目は以下のとおりです。
| 項目 | 説明 |
|---|---|
| 会社名 | 正式な会社名(例:NEXT INNOVAITION株式会社) |
| 会社名表記(請求書) | 請求書に表示する会社名 |
| 代表者名 | 代表者のお名前 |
| 郵便番号・住所 | 会社の所在地 |
| 電話番号 | 連絡先電話番号 |
| メールアドレス | 送信元となるメールアドレス |
| 銀行名・支店名・口座種別・口座番号・口座名義 | 振込先の口座情報 |
| 消費税率 | 消費税率(例:10) |
| 請求書保存フォルダID | GoogleドライブのフォルダID(STEP9で設定) |
ここで入力した情報が、今後作成されるすべての請求書に反映されます。

STEP7: 「請求データ」シートに請求情報を入力する
次に、「請求データ」シートを開きます。ここには、請求先の顧客情報や請求内容を入力します。Claudeが添付PDFから読み取ったサンプルデータが既に入力されている場合がありますので、それを参考にご自身の請求情報に書き換えていきましょう。
主な入力項目は以下のとおりです。
| 項目 | 説明 |
|---|---|
| 請求No | 請求書番号(自動採番も可能) |
| 請求日 | 請求書の発行日 |
| 宛先会社名 | 請求先の会社名 |
| 宛先敬称 | 「御中」など |
| 件名 | 請求の件名(例:AI顧問(コンサルティング費用)) |
| 支払期限 | お支払い期限 |
| 摘要・数量・単位・単価 | 請求明細の内容(最大4行まで) |
| 送信先メールアドレス | 請求書を送付する宛先のメールアドレス |
送信先メールアドレスは必ず入力してください。 これがないと、メール送信時にエラーになります。

STEP8: プレビューで請求書を確認する
データ入力が完了したら、実際に請求書がどのように生成されるか確認してみましょう。
- 「請求データ」シートで、プレビューしたい行を選択(行のどこかのセルをクリック)します。
- 「請求書」メニューから「請求書プレビュー(テンプレート更新)」を選択します。
すると、「請求書テンプレート」シートに自動的に移動し、選択した行のデータが反映された請求書のプレビューが表示されます。会社名、金額、振込先などの内容に間違いがないか、ここでしっかりと確認しましょう。

STEP9: GoogleドライブのフォルダIDを設定する
作成した請求書PDFを保存するためのGoogleドライブフォルダを指定します。
1. Googleドライブで保存用フォルダを作成する
Googleドライブで、請求書を保存するための新しいフォルダを作成します(例:「請求書保存」)。
2. フォルダIDをコピーする
作成したフォルダを開き、ブラウザのアドレスバーに表示されているURLを確認します。URLの末尾部分(https://drive.google.com/drive/folders/ 以降の英数字の羅列)が「フォルダID」です。この部分をコピーしてください。
3. 設定シートに貼り付ける
スプレッドシートの「設定」シートに戻り、「請求書保存フォルダID」の欄に、コピーしたフォルダIDを貼り付けます。これで、生成されたPDFが自動的にこのフォルダに保存されるようになります。
STEP10: PDFを生成し、メールを送信する
いよいよ、請求書の自動生成とメール送信を実行します。
- 「請求データ」シートで、送信したい請求情報の行を選択します。(行のどこかのセルをクリックしてください。選択していないとエラーになります)
- 「請求書」メニューから「PDF生成+メール送信」を選択します。
- 送信内容の確認ダイアログが表示されます。「以下の内容で請求書を送信します」という確認メッセージを読み、問題がなければ「OK」をクリックします。

スクリプトが実行され、PDFの作成とメール送信が自動で行われます。数秒後には「請求書を送信しました」という完了メッセージが表示されます。

STEP11: 結果を確認する
最後に、すべてが正しく実行されたかを確認しましょう。
1. Googleドライブの確認
STEP9で指定した「請求書保存」フォルダを開きます。ファイル名に顧客名と日付が入った請求書PDFが自動で保存されているはずです。PDFを開いて、フォーマットや内容に問題がないか確認してみてください。

2. Gmailの確認
「請求データ」シートで指定した送信先メールアドレスの受信トレイを確認します。請求書のPDFが添付されたメールが届いていれば、自動化システムは完璧に動作しています!メール本文も、ビジネスにふさわしい丁寧な文面が自動生成されています。

お疲れ様でした!これで、基本的な請求書業務の自動化は完了です。
【応用編】もう毎月入力しない!定期請求も完全自動化しよう
基本機能だけでも十分に便利ですが、毎月同じ顧客に同じ金額を請求する場合、「請求データ」シートに毎月毎月、請求日や振り込みの期日をわざわざ記入して実行するのは、ちょっと面倒ですよね。
ご安心ください。Claudeに追加でお願いするだけで、一度マスタ設定すれば、あとは完全に放置できる「定期請求の自動化」機能も作ってくれます。ここからは、その応用編です。
STEP12: Claudeに改良を依頼する
先ほどのClaudeとの会話の続きで、以下のようにお願いしてみましょう。
📋 コピペ用プロンプト(応用編)
ちなみに、これって毎月毎月、請求データに入れておかないといけないんですか?基本的に毎月同じ会社に対して同じ金額を請求するので、請求月などを選んで、毎月末に自動的にPDFが作成されるような設定を入れたいです。
するとClaudeは、機能を改良したv2(バージョン2)のGASコードを新たに生成してくれます。v1からv2への主な変更点は以下のとおりです。
| 項目 | v1(基本機能版) | v2(定期請求対応版) |
|---|---|---|
| 請求データの管理 | 「請求データ」シートに毎回手入力 | 「定期請求マスタ」に一度登録すればOK |
| 請求履歴 | なし | 「請求履歴」シートに自動記録 |
| 自動実行 | 手動のみ | 毎月25日午前9時に自動実行(トリガー設定) |
| 重複チェック | なし | 同じ月に二重発行されない仕組み |
| 手動操作 | PDF生成+メール送信 | ドライラン(確認)、指定月分の生成+送信も可能 |

STEP13: 新しいGASコード(v2)に差し替える
- スプレッドシートの「拡張機能」>「Apps Script」から再度エディタを開きます。
- 既存のコードをすべて削除し、Claudeが生成したv2のコードをすべて貼り付けます。
- 忘れずに「プロジェクトを保存」(フロッピーディスクアイコン)をクリックします。
- 「実行」ボタンを押して、再度権限の承認を行ってください。新しい機能が増えたため、改めて許可が必要です。承認手順はSTEP4と同じです。

STEP14: 再度「初期セットアップ」を実行し、新シートを確認する
スプレッドシートに戻り、「請求書」メニューからもう一度「初期セットアップ」を実行します。すると、新たに「定期請求マスタ」と「請求履歴」という2つのシートが追加されます。
| シート名 | 役割 |
|---|---|
| 定期請求マスタ | 毎月請求する顧客の情報(会社名、金額、請求サイクルなど)を登録しておくマスタシート |
| 請求履歴 | 自動送信した請求の記録が自動的に溜まっていくシート(二重発行防止にも使用) |

STEP15: 「設定」シートと「定期請求マスタ」に情報を入力する
設定シートを開き、STEP6と同様に自社情報を入力します。加えて、v2では以下の項目も追加されています。
| 追加項目 | 説明 |
|---|---|
| 支払期限(翌月何日) | 請求書に記載する支払期限の日付 |
| 自動送信ON | チェックを入れるとメール送信も自動実行。OFFにするとPDF生成のみ |
| メール件名テンプレート | メールの件名フォーマット(例:【請求書】{件名} – {自社名}) |
次に、「定期請求マスタ」シートを開き、毎月請求が発生する顧客の情報を入力します。会社名、メールアドレス、請求金額、請求サイクル(毎月)、契約開始日・終了日などを一度入力すれば、今後の手動入力は不要になります。
STEP16: 自動実行トリガーを設定&手動で実行してみる
最後に、自動実行の設定とテストを行います。
1. 月次自動実行トリガーを設定する
「請求書」メニューの中から「月次自動実行トリガー設定」を選択します。これで、毎月25日の午前9時に、翌月分の請求書が自動で作成・送信されるように設定されます。設定完了のダイアログが表示されたらOKです。
2. 手動で実行してみる
本当に動くか、今すぐ試してみましょう。「請求書」メニューから以下の操作ができます。
| メニュー項目 | 機能 |
|---|---|
| 今月の対象を確認(ドライラン) | 送信前に、今月どの顧客が対象かを確認できる |
| 今月分を手動生成+送信 | 今月分の請求書を即座に生成・送信する |
| 指定月分を生成+送信 | 過去月や未来月を指定して手動実行する |
「今月分を手動生成+送信」を選択して実行してみましょう。確認ダイアログで「続行」を選択すると、スクリプトが自動で動き、処理結果が表示されます。
実行後、「請求履歴」シートに実行記録が自動的に追加されます。請求No、請求日、請求年月、宛先会社名、件名、支払期限、摘要、金額などが自動で記録されていることを確認してください。

もちろん、Googleドライブと送信先メールにも請求書が届いていることを確認しましょう。これで、あなたは請求書業務から完全に解放されました!
まとめ:AIと共に、ビジネスを自動化しよう
お疲れ様でした。いかがだったでしょうか?
プログラミングの知識がなくても、AIと対話するように指示するだけで、これほど強力な業務自動化システムが構築できる。これが、現代のAI活用のリアルです。今回の内容は、実際のビジネスでAIを活用して業務を自動化していくという文脈においては、基本中の基本です。
もちろん、もっとカスタマイズしたい場合も、Claudeに相談すれば全然対応してくれます。「請求書のフォーマットを変えたい」「自動返信の下書きも作りたい」など、どんどんお願いしてみてください。
大切なのは、コピペして終わりにするのではなく、自分の手を動かして、この事務作業を自動化していくという体験に慣れることです。答え合わせ用にプレゼントのコードを使っていただきつつ、ぜひ一度、この記事を見ながらご自身で実装してみてください。
【プレゼント】コピペするだけ!プロンプト&GASコード
この記事を最後まで読んでくださったあなたに、今回使用した「Claudeへの指示プロンプト」と「完成版GASコード(v1&v2)」をプレゼントします。ぜひご自身の業務自動化にお役立てください。
Claudeへの指示プロンプト
【基本編プロンプト】
請求書の作成を自動化するGAS(Google Apps Script)を作成したいです。
添付の請求書PDFを参考に、スプレッドシート上で宛名や宛先項目などをすべて変数化し、
内容を調整することで請求書を自動発行できる仕組みを構築してください。
また、以下の機能も追加してほしいです:
- 自動送信機能:作成した請求書をお客様のメールアドレス宛に自動で送信する。
【応用編プロンプト】
ちなみに、これって毎月毎月、請求データに入れておかないといけないんですか?
基本的に毎月同じ会社に対して同じ金額を請求するので、
請求月などを選んで、毎月末に自動的にPDFが作成されるような設定を入れたいです。
GASコード
【v1:基本機能版 — 請求書自動発行 gas】
📎 // ============================================================
// 請求書自動発行 Google Apps Script
// NEXT INNOVAITION株式会社
// ============================================================
//
// 【セットアップ手順】
// 1. Google スプレッドシートを新規作成
// 2. 下記の「シート構成」に従い、3つのシートを作成
// 3. 拡張機能 > Apps Script にこのコードを貼り付け
// 4. initializeSheets() を一度実行してヘッダーを自動セットアップ
// 5.「設定」シートに自社情報を入力
// 6.「請求データ」シートに請求情報を入力
// 7. メニューから「請求書発行」を実行
//
// 【シート構成】
// ① 設定シート:自社情報・振込先など固定情報
// ② 請求データシート:請求ごとの可変情報
// ③ 請求書テンプレートシート:PDF生成用テンプレート
// ============================================================
// ----- グローバル定数 -----
const SHEET_SETTINGS = '設定';
const SHEET_INVOICE_DATA = '請求データ';
const SHEET_TEMPLATE = '請求書テンプレート';
const PDF_FOLDER_NAME = '請求書PDF';
// ============================================================
// メニュー追加
// ============================================================
function onOpen() {
const ui = SpreadsheetApp.getUi();
ui.createMenu('📄 請求書')
.addItem('🔧 初期セットアップ', 'initializeSheets')
.addSeparator()
.addItem('📋 請求書プレビュー(テンプレート更新)', 'generateInvoicePreview')
.addItem('📄 PDF生成', 'generateInvoicePDF')
.addItem('📧 PDF生成+メール送信', 'generateAndSendInvoice')
.addSeparator()
.addItem('📧 選択行を一括送信', 'batchSendSelectedInvoices')
.addToUi();
}
// ============================================================
// 初期セットアップ:シートとヘッダーを自動作成
// ============================================================
function initializeSheets() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
// --- 設定シート ---
let settingsSheet = ss.getSheetByName(SHEET_SETTINGS);
if (!settingsSheet) {
settingsSheet = ss.insertSheet(SHEET_SETTINGS);
}
const settingsData = [
['項目', '値'],
['会社名', 'NEXT INNOVAITION株式会社'],
['会社名(カナ)', 'ネクストイノベーションカブシキガイシャ'],
['代表者名', '黒山 結音'],
['郵便番号', ''],
['住所', 'テスト'],
['電話番号', 'テスト'],
['メールアドレス', 'テスト'],
['銀行名', 'テスト'],
['支店名', 'テスト'],
['口座種別', 'テスト'],
['口座番号', 'テスト'],
['口座名義', 'テスト'],
['消費税率', '10'],
['請求書保存フォルダID', ''],
['ロゴ画像URL', ''],
];
settingsSheet.getRange(1, 1, settingsData.length, 2).setValues(settingsData);
settingsSheet.setColumnWidth(1, 200);
settingsSheet.setColumnWidth(2, 400);
settingsSheet.getRange(1, 1, 1, 2).setFontWeight('bold').setBackground('#4472C4').setFontColor('white');
// --- 請求データシート ---
let dataSheet = ss.getSheetByName(SHEET_INVOICE_DATA);
if (!dataSheet) {
dataSheet = ss.insertSheet(SHEET_INVOICE_DATA);
}
const dataHeaders = [
'請求No', '請求日', '宛先会社名', '宛先敬称', '宛先代表者名', '宛先代表者敬称',
'件名', '支払期限',
'摘要1', '数量1', '単位1', '単価1',
'摘要2', '数量2', '単位2', '単価2',
'摘要3', '数量3', '単位3', '単価3',
'摘要4', '数量4', '単位4', '単価4',
'摘要5', '数量5', '単位5', '単価5',
'備考',
'送信先メールアドレス', '送信済み', '送信日時'
];
dataSheet.getRange(1, 1, 1, dataHeaders.length).setValues([dataHeaders]);
dataSheet.getRange(1, 1, 1, dataHeaders.length).setFontWeight('bold').setBackground('#4472C4').setFontColor('white');
dataSheet.setFrozenRows(1);
// サンプルデータ挿入
const sampleData = [
'20260105-001',
'2026/01/05',
'テスト株式会社',
'御中',
'',
'様',
'AI顧問(コンサルティング)費用',
'1月30日',
'AI顧問(コンサルティング)費用:契約期間分', 1, 'ヶ月分', 300000,
'', '', '', '',
'', '', '', '',
'', '', '', '',
'', '', '', '',
'',
'example@example.com',
'',
''
];
dataSheet.getRange(2, 1, 1, sampleData.length).setValues([sampleData]);
// 列幅調整
dataSheet.setColumnWidth(1, 150); // 請求No
dataSheet.setColumnWidth(2, 100); // 請求日
dataSheet.setColumnWidth(3, 200); // 宛先会社名
// --- テンプレートシート ---
let templateSheet = ss.getSheetByName(SHEET_TEMPLATE);
if (!templateSheet) {
templateSheet = ss.insertSheet(SHEET_TEMPLATE);
}
// テンプレートの初期フォーマットを設定
setupTemplateSheet_(templateSheet);
SpreadsheetApp.getUi().alert(
'✅ 初期セットアップ完了\n\n' +
'1.「設定」シートに自社情報を入力してください\n' +
'2.「請求データ」シートに請求情報を入力してください\n' +
'3. メニューの「請求書」から操作できます'
);
}
// ============================================================
// テンプレートシートの書式設定
// ============================================================
function setupTemplateSheet_(sheet) {
sheet.clear();
// 列幅設定(A〜Hの8列を使用)
sheet.setColumnWidth(1, 30); // A: 余白
sheet.setColumnWidth(2, 40); // B: No
sheet.setColumnWidth(3, 300); // C: 摘要
sheet.setColumnWidth(4, 80); // D: 数量
sheet.setColumnWidth(5, 80); // E: 単位
sheet.setColumnWidth(6, 120); // F: 単価
sheet.setColumnWidth(7, 150); // G: 金額
sheet.setColumnWidth(8, 30); // H: 余白
// 全体のフォント
sheet.getRange('A1:H50').setFontFamily('Meiryo').setFontSize(10);
}
// ============================================================
// 設定シートから値を取得するヘルパー
// ============================================================
function getSettingsMap_() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(SHEET_SETTINGS);
const data = sheet.getDataRange().getValues();
const map = {};
for (let i = 1; i < data.length; i++) {
map[data[i][0]] = data[i][1];
}
return map;
}
// ============================================================
// 請求データシートから指定行のデータを取得
// ============================================================
function getInvoiceData_(rowIndex) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(SHEET_INVOICE_DATA);
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const rowData = sheet.getRange(rowIndex, 1, 1, sheet.getLastColumn()).getValues()[0];
const data = {};
headers.forEach((h, i) => {
data[h] = rowData[i];
});
// 明細行をパース
data.items = [];
for (let i = 1; i <= 5; i++) {
const desc = data[`摘要${i}`];
if (desc && desc !== '') {
data.items.push({
description: desc,
quantity: data[`数量${i}`] || 0,
unit: data[`単位${i}`] || '',
unitPrice: data[`単価${i}`] || 0,
});
}
}
return data;
}
// ============================================================
// 請求書テンプレートシートにデータを反映
// ============================================================
function populateTemplate_(invoiceData) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(SHEET_TEMPLATE);
const settings = getSettingsMap_();
const taxRate = parseFloat(settings['消費税率']) / 100;
sheet.clear();
setupTemplateSheet_(sheet);
let row = 1;
// --- ヘッダー部分 ---
// 行1: 請求No(右寄せ)
sheet.getRange(row, 6, 1, 2).merge()
.setValue(`請求No. ${invoiceData['請求No']}`)
.setHorizontalAlignment('right').setFontSize(9).setFontColor('#666666');
row++;
// 行2: タイトル「請 求 書」
sheet.getRange(row, 2, 1, 6).merge()
.setValue('請 求 書')
.setHorizontalAlignment('center')
.setFontSize(22).setFontWeight('bold');
row++;
row++; // 空行
// 行4: 宛先会社名
const clientName = `${invoiceData['宛先会社名']} ${invoiceData['宛先敬称'] || '御中'}`;
sheet.getRange(row, 2, 1, 3).merge()
.setValue(clientName)
.setFontSize(14).setFontWeight('bold');
// 右側に請求日
const invoiceDate = invoiceData['請求日'];
const formattedDate = invoiceDate instanceof Date
? Utilities.formatDate(invoiceDate, 'Asia/Tokyo', 'yyyy/MM/dd')
: String(invoiceDate);
sheet.getRange(row, 6, 1, 2).merge()
.setValue(`請求日 ${formattedDate}`)
.setHorizontalAlignment('right').setFontSize(9);
row++;
// 宛先に下線
sheet.getRange(row - 1, 2, 1, 3).setBorder(false, false, true, false, false, false,
'#333333', SpreadsheetApp.BorderStyle.SOLID_MEDIUM);
// 行5: 代表者名(あれば)
if (invoiceData['宛先代表者名']) {
sheet.getRange(row, 2, 1, 3).merge()
.setValue(`代表者:${invoiceData['宛先代表者名']} ${invoiceData['宛先代表者敬称'] || '様'}`)
.setFontSize(10);
row++;
}
row++; // 空行
// --- 自社情報(右側) ---
const companyInfoStart = row;
sheet.getRange(row, 5, 1, 3).merge()
.setValue(settings['会社名'])
.setHorizontalAlignment('right').setFontSize(11).setFontWeight('bold');
row++;
sheet.getRange(row, 5, 1, 3).merge()
.setValue(settings['住所'])
.setHorizontalAlignment('right').setFontSize(8);
row++;
sheet.getRange(row, 5, 1, 3).merge()
.setValue(`TEL: ${settings['電話番号']}`)
.setHorizontalAlignment('right').setFontSize(8);
row++;
row++; // 空行
// --- 件名 ---
sheet.getRange(row, 2, 1, 6).merge()
.setValue(`件名: ${invoiceData['件名']}`)
.setFontSize(10);
row++;
// --- 「下記の通り、ご請求申し上げます。」 ---
sheet.getRange(row, 2, 1, 6).merge()
.setValue('下記の通り、ご請求申し上げます。')
.setFontSize(10);
row++;
row++; // 空行
// --- 合計金額 ---
let subtotal = 0;
invoiceData.items.forEach(item => {
subtotal += item.quantity * item.unitPrice;
});
const tax = Math.floor(subtotal * taxRate);
const total = subtotal + tax;
sheet.getRange(row, 2, 1, 6).merge()
.setValue(`合計金額 ¥${total.toLocaleString()} (税込)`)
.setFontSize(14).setFontWeight('bold')
.setBackground('#F2F2F2')
.setBorder(true, true, true, true, false, false, '#333333', SpreadsheetApp.BorderStyle.SOLID);
row++;
// お支払期限
sheet.getRange(row, 2, 1, 6).merge()
.setValue(`お支払期限: ${invoiceData['支払期限']}`)
.setHorizontalAlignment('right').setFontSize(9);
row++;
row++; // 空行
// --- 明細テーブル ヘッダー ---
const tableHeaderRow = row;
const tableHeaders = ['', 'No.', '摘要', '数量', '', '単価', '金額'];
// B〜G列にヘッダー
sheet.getRange(row, 2).setValue('No.').setHorizontalAlignment('center');
sheet.getRange(row, 3).setValue('摘要').setHorizontalAlignment('center');
sheet.getRange(row, 4).setValue('数量').setHorizontalAlignment('center');
sheet.getRange(row, 5).setValue('').setHorizontalAlignment('center');
sheet.getRange(row, 6).setValue('単価').setHorizontalAlignment('center');
sheet.getRange(row, 7).setValue('金額').setHorizontalAlignment('center');
sheet.getRange(row, 2, 1, 6)
.setBackground('#4472C4').setFontColor('white').setFontWeight('bold').setFontSize(9);
row++;
// --- 明細行 ---
const maxItems = 12; // 最大12行表示
for (let i = 0; i < maxItems; i++) {
const item = invoiceData.items[i];
if (item) {
const amount = item.quantity * item.unitPrice;
sheet.getRange(row, 2).setValue(i + 1).setHorizontalAlignment('center');
sheet.getRange(row, 3).setValue(item.description);
sheet.getRange(row, 4).setValue(item.quantity).setHorizontalAlignment('right');
sheet.getRange(row, 5).setValue(item.unit).setHorizontalAlignment('center');
sheet.getRange(row, 6).setValue(`¥${item.unitPrice.toLocaleString()}`).setHorizontalAlignment('right');
sheet.getRange(row, 7).setValue(`¥${amount.toLocaleString()}`).setHorizontalAlignment('right');
}
// 交互色
if (i % 2 === 1) {
sheet.getRange(row, 2, 1, 6).setBackground('#F8F9FA');
}
// 下線
sheet.getRange(row, 2, 1, 6)
.setBorder(false, false, true, false, false, false, '#DDDDDD', SpreadsheetApp.BorderStyle.DOTTED);
row++;
}
// テーブル外枠
sheet.getRange(tableHeaderRow, 2, maxItems + 1, 6)
.setBorder(true, true, true, true, false, false, '#4472C4', SpreadsheetApp.BorderStyle.SOLID);
row++; // 空行
// --- 小計・消費税・合計 ---
sheet.getRange(row, 5, 1, 2).merge().setValue('小計').setHorizontalAlignment('right').setFontWeight('bold');
sheet.getRange(row, 7).setValue(`¥${subtotal.toLocaleString()}`).setHorizontalAlignment('right').setFontWeight('bold');
row++;
sheet.getRange(row, 5, 1, 2).merge().setValue(`消費税等(${settings['消費税率']}%)`).setHorizontalAlignment('right');
sheet.getRange(row, 7).setValue(`¥${tax.toLocaleString()}`).setHorizontalAlignment('right');
row++;
sheet.getRange(row, 5, 1, 2).merge().setValue('合計').setHorizontalAlignment('right').setFontSize(12).setFontWeight('bold');
sheet.getRange(row, 7).setValue(`¥${total.toLocaleString()}`)
.setHorizontalAlignment('right').setFontSize(12).setFontWeight('bold')
.setBorder(false, false, true, false, false, false, '#4472C4', SpreadsheetApp.BorderStyle.SOLID_MEDIUM);
row++;
row++; // 空行
// --- 備考 ---
if (invoiceData['備考']) {
sheet.getRange(row, 2).setValue('備考').setFontWeight('bold').setFontSize(9);
row++;
sheet.getRange(row, 2, 1, 6).merge()
.setValue(invoiceData['備考'])
.setFontSize(9).setFontColor('#555555');
row++;
}
row++; // 空行
// --- 振込先情報 ---
sheet.getRange(row, 2).setValue('振込先').setFontWeight('bold').setFontSize(10)
.setBackground('#F2F2F2');
sheet.getRange(row, 2, 1, 6).setBackground('#F2F2F2')
.setBorder(true, true, true, true, false, false, '#CCCCCC', SpreadsheetApp.BorderStyle.SOLID);
row++;
const bankInfo = [
`${settings['銀行名']}`,
`${settings['支店名']}`,
`${settings['口座種別']} ${settings['口座番号']}`,
`${settings['口座名義']}`,
];
bankInfo.forEach(info => {
sheet.getRange(row, 3, 1, 4).merge().setValue(info).setFontSize(9);
row++;
});
// 最終行を記録(PDF範囲に使用)
sheet.getRange(1, 9).setValue(row).setFontColor('white'); // 非表示で行数保持
SpreadsheetApp.flush();
}
// ============================================================
// 請求書プレビュー(テンプレートシートを更新)
// ============================================================
function generateInvoicePreview() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const dataSheet = ss.getSheetByName(SHEET_INVOICE_DATA);
// アクティブな行を取得(ヘッダー行の場合は2行目)
let activeRow = ss.getActiveSheet().getName() === SHEET_INVOICE_DATA
? ss.getActiveCell().getRow()
: 2;
if (activeRow < 2) activeRow = 2;
const invoiceData = getInvoiceData_(activeRow);
if (!invoiceData['請求No']) {
SpreadsheetApp.getUi().alert('❌ 選択行に請求データがありません');
return;
}
populateTemplate_(invoiceData);
// テンプレートシートをアクティブに
ss.getSheetByName(SHEET_TEMPLATE).activate();
SpreadsheetApp.getUi().alert(`✅ 請求書プレビューを更新しました\n請求No: ${invoiceData['請求No']}`);
}
// ============================================================
// PDF生成
// ============================================================
function generateInvoicePDF(rowIndex) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
if (!rowIndex) {
const activeSheet = ss.getActiveSheet();
rowIndex = activeSheet.getName() === SHEET_INVOICE_DATA
? ss.getActiveCell().getRow()
: 2;
if (rowIndex < 2) rowIndex = 2;
}
const invoiceData = getInvoiceData_(rowIndex);
if (!invoiceData['請求No']) {
SpreadsheetApp.getUi().alert('❌ 選択行に請求データがありません');
return null;
}
// テンプレートにデータ反映
populateTemplate_(invoiceData);
SpreadsheetApp.flush();
// テンプレートシートのIDを取得
const templateSheet = ss.getSheetByName(SHEET_TEMPLATE);
const sheetId = templateSheet.getSheetId();
// PDF生成URL
const url = ss.getUrl().replace(/\/edit.*$/, '')
+ '/export?exportFormat=pdf'
+ '&format=pdf'
+ '&size=A4'
+ '&portrait=true'
+ '&fitw=true'
+ '&gridlines=false'
+ '&printtitle=false'
+ '&sheetnames=false'
+ '&pagenum=UNDEFINED'
+ '&fzr=false'
+ '&gid=' + sheetId
+ '&top_margin=0.5'
+ '&bottom_margin=0.5'
+ '&left_margin=0.4'
+ '&right_margin=0.4';
const token = ScriptApp.getOAuthToken();
const response = UrlFetchApp.fetch(url, {
headers: { 'Authorization': 'Bearer ' + token }
});
const pdfBlob = response.getBlob();
// ファイル名生成
const dateStr = invoiceData['請求日'] instanceof Date
? Utilities.formatDate(invoiceData['請求日'], 'Asia/Tokyo', 'yyyyMMdd')
: String(invoiceData['請求日']).replace(/\//g, '');
const fileName = `請求書_${invoiceData['宛先会社名']}御中${dateStr}.pdf`;
pdfBlob.setName(fileName);
// Google Driveに保存
let folder;
const settings = getSettingsMap_();
const folderId = settings['請求書保存フォルダID'];
if (folderId) {
try {
folder = DriveApp.getFolderById(folderId);
} catch (e) {
folder = null;
}
}
if (!folder) {
// フォルダが未設定or見つからない場合、自動作成
const folders = DriveApp.getFoldersByName(PDF_FOLDER_NAME);
if (folders.hasNext()) {
folder = folders.next();
} else {
folder = DriveApp.createFolder(PDF_FOLDER_NAME);
}
// フォルダIDを設定シートに保存
const settingsSheet = ss.getSheetByName(SHEET_SETTINGS);
const settingsData = settingsSheet.getDataRange().getValues();
for (let i = 0; i < settingsData.length; i++) {
if (settingsData[i][0] === '請求書保存フォルダID') {
settingsSheet.getRange(i + 1, 2).setValue(folder.getId());
break;
}
}
}
// 同名ファイルがあれば上書き
const existingFiles = folder.getFilesByName(fileName);
while (existingFiles.hasNext()) {
existingFiles.next().setTrashed(true);
}
const file = folder.createFile(pdfBlob);
Logger.log(`PDF生成完了: ${fileName}`);
Logger.log(`保存先: ${file.getUrl()}`);
return {
file: file,
fileName: fileName,
blob: pdfBlob,
invoiceData: invoiceData
};
}
// ============================================================
// PDF生成+メール送信
// ============================================================
function generateAndSendInvoice() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const activeSheet = ss.getActiveSheet();
let rowIndex = activeSheet.getName() === SHEET_INVOICE_DATA
? ss.getActiveCell().getRow()
: 2;
if (rowIndex < 2) rowIndex = 2;
const invoiceData = getInvoiceData_(rowIndex);
if (!invoiceData['送信先メールアドレス']) {
SpreadsheetApp.getUi().alert('❌ 送信先メールアドレスが入力されていません');
return;
}
// 確認ダイアログ
const ui = SpreadsheetApp.getUi();
const response = ui.alert(
'📧 請求書送信確認',
`以下の内容で請求書を送信します:\n\n` +
`請求No: ${invoiceData['請求No']}\n` +
`宛先: ${invoiceData['宛先会社名']}\n` +
`送信先: ${invoiceData['送信先メールアドレス']}\n` +
`合計金額の確認は「プレビュー」でご確認ください\n\n` +
`送信しますか?`,
ui.ButtonSet.YES_NO
);
if (response !== ui.Button.YES) {
return;
}
// PDF生成
const result = generateInvoicePDF(rowIndex);
if (!result) return;
// メール送信
sendInvoiceEmail_(result, rowIndex);
ui.alert(`✅ 請求書を送信しました\n\n送信先: ${invoiceData['送信先メールアドレス']}`);
}
// ============================================================
// メール送信処理
// ============================================================
function sendInvoiceEmail_(result, rowIndex) {
const settings = getSettingsMap_();
const invoiceData = result.invoiceData;
const toEmail = invoiceData['送信先メールアドレス'];
// 件名
const subject = `【請求書】${invoiceData['件名']} - ${settings['会社名']}`;
// 本文
const body = createEmailBody_(invoiceData, settings);
// HTML本文
const htmlBody = createEmailHtmlBody_(invoiceData, settings);
// 送信
GmailApp.sendEmail(toEmail, subject, body, {
htmlBody: htmlBody,
attachments: [result.blob],
name: settings['会社名'],
replyTo: settings['メールアドレス'] || Session.getActiveUser().getEmail(),
});
// 送信済みフラグを更新
const ss = SpreadsheetApp.getActiveSpreadsheet();
const dataSheet = ss.getSheetByName(SHEET_INVOICE_DATA);
const headers = dataSheet.getRange(1, 1, 1, dataSheet.getLastColumn()).getValues()[0];
const sentColIndex = headers.indexOf('送信済み') + 1;
const sentDateColIndex = headers.indexOf('送信日時') + 1;
if (sentColIndex > 0) {
dataSheet.getRange(rowIndex, sentColIndex).setValue('✅');
}
if (sentDateColIndex > 0) {
dataSheet.getRange(rowIndex, sentDateColIndex).setValue(new Date());
}
Logger.log(`メール送信完了: ${toEmail}`);
}
// ============================================================
// メール本文(テキスト版)
// ============================================================
function createEmailBody_(invoiceData, settings) {
return `${invoiceData['宛先会社名']} 御中
いつもお世話になっております。
${settings['会社名']}の${settings['代表者名']}です。
${invoiceData['件名']}の請求書をお送りいたします。
添付のPDFファイルをご確認ください。
■ 請求No: ${invoiceData['請求No']}
■ お支払期限: ${invoiceData['支払期限']}
■ 振込先
${settings['銀行名']} ${settings['支店名']}
${settings['口座種別']} ${settings['口座番号']}
${settings['口座名義']}
ご不明な点がございましたら、お気軽にお問い合わせください。
何卒よろしくお願いいたします。
━━━━━━━━━━━━━━━━━━━━━━━━
${settings['会社名']}
${settings['代表者名']}
TEL: ${settings['電話番号']}
${settings['メールアドレス'] ? 'Email: ' + settings['メールアドレス'] : ''}
━━━━━━━━━━━━━━━━━━━━━━━━
`;
}
// ============================================================
// メール本文(HTML版)
// ============================================================
function createEmailHtmlBody_(invoiceData, settings) {
return `
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family: 'Meiryo', 'Hiragino Sans', sans-serif; font-size: 14px; color: #333; line-height: 1.8;">
<p>${invoiceData['宛先会社名']} 御中</p>
<p>いつもお世話になっております。<br>
${settings['会社名']}の${settings['代表者名']}です。</p>
<p><strong>${invoiceData['件名']}</strong>の請求書をお送りいたします。<br>
添付のPDFファイルをご確認ください。</p>
<table style="border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 6px 16px; background: #f5f5f5; border: 1px solid #ddd; font-weight: bold;">請求No</td>
<td style="padding: 6px 16px; border: 1px solid #ddd;">${invoiceData['請求No']}</td>
</tr>
<tr>
<td style="padding: 6px 16px; background: #f5f5f5; border: 1px solid #ddd; font-weight: bold;">お支払期限</td>
<td style="padding: 6px 16px; border: 1px solid #ddd;">${invoiceData['支払期限']}</td>
</tr>
</table>
<div style="background: #f8f9fa; border-left: 4px solid #4472C4; padding: 12px 16px; margin: 16px 0;">
<strong>振込先</strong><br>
${settings['銀行名']} ${settings['支店名']}<br>
${settings['口座種別']} ${settings['口座番号']}<br>
${settings['口座名義']}
</div>
<p>ご不明な点がございましたら、お気軽にお問い合わせください。<br>
何卒よろしくお願いいたします。</p>
<hr style="border: none; border-top: 1px solid #ddd; margin: 24px 0;">
<div style="font-size: 12px; color: #888;">
<strong>${settings['会社名']}</strong><br>
${settings['代表者名']}<br>
TEL: ${settings['電話番号']}<br>
${settings['メールアドレス'] ? 'Email: ' + settings['メールアドレス'] : ''}
</div>
</body>
</html>
`;
}
// ============================================================
// 一括送信(請求データシートの選択行)
// ============================================================
function batchSendSelectedInvoices() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const dataSheet = ss.getSheetByName(SHEET_INVOICE_DATA);
const ui = SpreadsheetApp.getUi();
// データ行数を取得
const lastRow = dataSheet.getLastRow();
if (lastRow < 2) {
ui.alert('❌ 請求データがありません');
return;
}
// 未送信の行を探す
const headers = dataSheet.getRange(1, 1, 1, dataSheet.getLastColumn()).getValues()[0];
const sentColIndex = headers.indexOf('送信済み') + 1;
const emailColIndex = headers.indexOf('送信先メールアドレス') + 1;
const unsent = [];
for (let r = 2; r <= lastRow; r++) {
const sent = dataSheet.getRange(r, sentColIndex).getValue();
const email = dataSheet.getRange(r, emailColIndex).getValue();
const invoiceNo = dataSheet.getRange(r, 1).getValue();
if (!sent && email && invoiceNo) {
unsent.push({ row: r, invoiceNo: invoiceNo, email: email });
}
}
if (unsent.length === 0) {
ui.alert('ℹ️ 未送信の請求書はありません');
return;
}
// 確認
const list = unsent.map(u => ` ${u.invoiceNo} → ${u.email}`).join('\n');
const response = ui.alert(
'📧 一括送信確認',
`以下の ${unsent.length} 件の請求書を送信します:\n\n${list}\n\n送信しますか?`,
ui.ButtonSet.YES_NO
);
if (response !== ui.Button.YES) return;
// 送信実行
let successCount = 0;
let errorCount = 0;
const errors = [];
unsent.forEach(item => {
try {
const result = generateInvoicePDF(item.row);
if (result) {
sendInvoiceEmail_(result, item.row);
successCount++;
}
} catch (e) {
errorCount++;
errors.push(`${item.invoiceNo}: ${e.message}`);
Logger.log(`Error sending ${item.invoiceNo}: ${e.message}`);
}
});
let message = `✅ 送信完了: ${successCount}件`;
if (errorCount > 0) {
message += `\n❌ エラー: ${errorCount}件\n\n${errors.join('\n')}`;
}
ui.alert(message);
}
// ============================================================
// ユーティリティ:請求番号の自動採番
// ============================================================
function getNextInvoiceNumber() {
const today = new Date();
const datePrefix = Utilities.formatDate(today, 'Asia/Tokyo', 'yyyyMMdd');
const ss = SpreadsheetApp.getActiveSpreadsheet();
const dataSheet = ss.getSheetByName(SHEET_INVOICE_DATA);
const lastRow = dataSheet.getLastRow();
let maxSeq = 0;
if (lastRow >= 2) {
const invoiceNos = dataSheet.getRange(2, 1, lastRow - 1, 1).getValues();
invoiceNos.forEach(row => {
const no = String(row[0]);
if (no.startsWith(datePrefix)) {
const seq = parseInt(no.split('-')[1]) || 0;
if (seq > maxSeq) maxSeq = seq;
}
});
}
return `${datePrefix}-${String(maxSeq + 1).padStart(3, '0')}`;
}
// ============================================================
// 新規請求データ行を追加
// ============================================================
function addNewInvoiceRow() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const dataSheet = ss.getSheetByName(SHEET_INVOICE_DATA);
const nextRow = dataSheet.getLastRow() + 1;
const invoiceNo = getNextInvoiceNumber();
const today = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd');
dataSheet.getRange(nextRow, 1).setValue(invoiceNo);
dataSheet.getRange(nextRow, 2).setValue(today);
dataSheet.activate();
dataSheet.getRange(nextRow, 3).activate();
SpreadsheetApp.getUi().alert(`📋 新規請求行を追加しました\n請求No: ${invoiceNo}`);
}
【v2:定期請求対応版 — 請求書自動発行 gas v2】
📎 // ============================================================
// 請求書自動発行 Google Apps Script v2.0
// NEXT INNOVAITION株式会社
// ============================================================
//
// 【v2.0 新機能】
// - 定期請求マスタ:毎月同じ請求を自動生成
// - 月次自動実行トリガー:毎月末に自動でPDF生成+メール送信
// - 請求サイクル対応:毎月 / 2ヶ月 / 3ヶ月 / 6ヶ月 / 12ヶ月
// - 契約期間管理:開始月・終了月を設定可能
//
// 【セットアップ手順】
// 1. Google スプレッドシートを新規作成
// 2. 拡張機能 > Apps Script にこのコードを貼り付け
// 3. initializeSheets() を一度実行してシートを自動セットアップ
// 4.「設定」シートに自社情報を入力
// 5.「定期請求マスタ」シートに顧客情報を登録
// 6. メニューの「請求書 > ⏰ 月次自動実行トリガー設定」を実行
// → 以降は毎月25日に自動でPDF生成+送信されます
//
// 【シート構成】
// ① 設定:自社情報・振込先など固定情報
// ② 定期請求マスタ:顧客ごとの定期請求設定(★メイン管理シート)
// ③ 請求履歴:自動生成された請求の履歴ログ
// ④ 請求書テンプレート:PDF生成用(自動描画)
// ============================================================
// ----- グローバル定数 -----
const SHEET_SETTINGS = '設定';
const SHEET_MASTER = '定期請求マスタ';
const SHEET_HISTORY = '請求履歴';
const SHEET_TEMPLATE = '請求書テンプレート';
const PDF_FOLDER_NAME = '請求書PDF';
// ============================================================
// メニュー追加
// ============================================================
function onOpen() {
const ui = SpreadsheetApp.getUi();
ui.createMenu('📄 請求書')
.addItem('🔧 初期セットアップ', 'initializeSheets')
.addSeparator()
.addSubMenu(ui.createMenu('📋 定期請求マスタ')
.addItem('今月の対象を確認(ドライラン)', 'dryRunMonthlyInvoices')
.addItem('今月分を手動生成+送信', 'manualRunMonthlyInvoices')
.addItem('指定月分を生成+送信...', 'runForSpecificMonth'))
.addSeparator()
.addSubMenu(ui.createMenu('📄 単発請求')
.addItem('選択行のプレビュー', 'previewFromHistory')
.addItem('選択行のPDF生成', 'generatePDFFromHistory')
.addItem('選択行を送信', 'sendFromHistory'))
.addSeparator()
.addItem('⏰ 月次自動実行トリガー設定', 'setupMonthlyTrigger')
.addItem('⏰ トリガー解除', 'removeMonthlyTrigger')
.addToUi();
}
// ============================================================
// 初期セットアップ
// ============================================================
function initializeSheets() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
// -------------------------------------------------------
// ① 設定シート
// -------------------------------------------------------
let settingsSheet = ss.getSheetByName(SHEET_SETTINGS) || ss.insertSheet(SHEET_SETTINGS);
settingsSheet.clear();
const settingsData = [
['項目', '値'],
['会社名', 'NEXT INNOVAITION株式会社'],
['会社名表記(請求書)', 'NEXT INNOVAITION株式会社'],
['会社名(カナ)', 'ネクストイノベーションカブシキガイシャ'],
['代表者名', '黒山 結音'],
['郵便番号', ''],
['住所', 'テスト'],
['電話番号', 'テスト'],
['メールアドレス', 'テスト'],
['銀行名', 'テスト'],
['支店名', 'テスト'],
['口座種別', 'テスト'],
['口座番号', 'テスト'],
['口座名義', 'ネクストイノベーションカブシキガイシャ'],
['消費税率', '10'],
['請求書保存フォルダID', ''],
['支払期限(翌月何日)', '30'],
['自動送信ON', 'TRUE'],
['メール件名テンプレート', '【請求書】{件名} - {自社名}'],
];
settingsSheet.getRange(1, 1, settingsData.length, 2).setValues(settingsData);
settingsSheet.setColumnWidth(1, 220);
settingsSheet.setColumnWidth(2, 400);
settingsSheet.getRange(1, 1, 1, 2).setFontWeight('bold').setBackground('#4472C4').setFontColor('white');
settingsSheet.getRange('B17').insertCheckboxes(); // 自動送信ON
// -------------------------------------------------------
// ② 定期請求マスタ
// -------------------------------------------------------
let masterSheet = ss.getSheetByName(SHEET_MASTER) || ss.insertSheet(SHEET_MASTER);
masterSheet.clear();
const masterHeaders = [
'ステータス', // A: 有効 / 停止
'顧客名', // B
'敬称', // C: 御中
'代表者名', // D
'代表者敬称', // E: 様
'件名', // F
'請求サイクル(月)', // G: 1=毎月, 3=四半期, etc.
'請求開始月', // H: 2026/01
'請求終了月', // I: 空欄=無期限
'摘要1', // J
'数量1', // K
'単位1', // L
'単価1', // M
'摘要2', // N
'数量2', // O
'単位2', // P
'単価2', // Q
'摘要3', // R
'数量3', // S
'単位3', // T
'単価3', // U
'備考テンプレート', // V: {開始月}〜{終了月} などプレースホルダ対応
'送信先メールアドレス', // W
'CC', // X
];
masterSheet.getRange(1, 1, 1, masterHeaders.length).setValues([masterHeaders]);
masterSheet.getRange(1, 1, 1, masterHeaders.length)
.setFontWeight('bold').setBackground('#4472C4').setFontColor('white').setFontSize(9);
masterSheet.setFrozenRows(1);
// サンプルデータ
const sampleMaster = [
'有効',
'テスト株式会社',
'御中',
'',
'様',
'AI顧問(コンサルティング)費用',
1, // 1ヶ月サイクル
'2026/01', // 開始月
'', // 無期限
'AI顧問(コンサルティング)費用:契約期間分',
1, 'ヶ月分', 300000,
'', '', '', '',
'', '', '', '',
'',
'example@example.com',
'',
];
masterSheet.getRange(2, 1, 1, sampleMaster.length).setValues([sampleMaster]);
// ステータス列にプルダウン
const statusRule = SpreadsheetApp.newDataValidation()
.requireValueInList(['有効', '停止'], true).build();
masterSheet.getRange('A2:A100').setDataValidation(statusRule);
// 列幅
masterSheet.setColumnWidth(1, 80);
masterSheet.setColumnWidth(2, 200);
masterSheet.setColumnWidth(6, 250);
masterSheet.setColumnWidth(7, 130);
masterSheet.setColumnWidth(22, 350);
masterSheet.setColumnWidth(23, 200);
// -------------------------------------------------------
// ③ 請求履歴シート
// -------------------------------------------------------
let historySheet = ss.getSheetByName(SHEET_HISTORY) || ss.insertSheet(SHEET_HISTORY);
historySheet.clear();
const historyHeaders = [
'請求No', '請求日', '請求年月', '宛先会社名', '宛先敬称',
'宛先代表者名', '宛先代表者敬称', '件名', '支払期限',
'摘要1', '数量1', '単位1', '単価1',
'摘要2', '数量2', '単位2', '単価2',
'摘要3', '数量3', '単位3', '単価3',
'小計', '消費税', '合計(税込)',
'備考',
'送信先メールアドレス', 'CC',
'PDF URL', '送信ステータス', '送信日時',
'マスタ行番号',
];
historySheet.getRange(1, 1, 1, historyHeaders.length).setValues([historyHeaders]);
historySheet.getRange(1, 1, 1, historyHeaders.length)
.setFontWeight('bold').setBackground('#4472C4').setFontColor('white').setFontSize(9);
historySheet.setFrozenRows(1);
historySheet.setColumnWidth(1, 150);
historySheet.setColumnWidth(4, 200);
// -------------------------------------------------------
// ④ テンプレートシート
// -------------------------------------------------------
let templateSheet = ss.getSheetByName(SHEET_TEMPLATE) || ss.insertSheet(SHEET_TEMPLATE);
setupTemplateSheet_(templateSheet);
// 完了メッセージ
SpreadsheetApp.getUi().alert(
'✅ 初期セットアップ完了!\n\n' +
'【手順】\n' +
'1.「設定」シートで自社情報を確認\n' +
'2.「定期請求マスタ」に顧客を登録\n' +
'3. メニュー「請求書 > ⏰ 月次自動実行トリガー設定」で自動化ON\n\n' +
'→ 毎月25日に翌月分の請求書が自動生成+送信されます'
);
}
// ============================================================
// テンプレートシート書式
// ============================================================
function setupTemplateSheet_(sheet) {
sheet.clear();
sheet.setColumnWidth(1, 30);
sheet.setColumnWidth(2, 40);
sheet.setColumnWidth(3, 300);
sheet.setColumnWidth(4, 80);
sheet.setColumnWidth(5, 80);
sheet.setColumnWidth(6, 120);
sheet.setColumnWidth(7, 150);
sheet.setColumnWidth(8, 30);
sheet.getRange('A1:H50').setFontFamily('Meiryo').setFontSize(10);
}
// ============================================================
// 設定シートからマップ取得
// ============================================================
function getSettingsMap_() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(SHEET_SETTINGS);
const data = sheet.getDataRange().getValues();
const map = {};
for (let i = 1; i < data.length; i++) {
map[data[i][0]] = data[i][1];
}
return map;
}
// ============================================================
// マスタシートの全行データを取得
// ============================================================
function getMasterData_() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(SHEET_MASTER);
const data = sheet.getDataRange().getValues();
const headers = data[0];
const rows = [];
for (let i = 1; i < data.length; i++) {
if (!data[i][0]) continue; // 空行スキップ
const row = {};
headers.forEach((h, idx) => { row[h] = data[i][idx]; });
row._rowIndex = i + 1; // スプレッドシート上の行番号
rows.push(row);
}
return rows;
}
// ============================================================
// 対象月に請求すべきマスタ行を判定
// ============================================================
function getTargetMasterRows_(targetYear, targetMonth) {
const masters = getMasterData_();
const targetYM = targetYear * 100 + targetMonth; // 202601 形式
return masters.filter(m => {
// ステータスチェック
if (m['ステータス'] !== '有効') return false;
// 開始月チェック
const startYM = parseYearMonth_(m['請求開始月']);
if (!startYM || targetYM < startYM) return false;
// 終了月チェック
if (m['請求終了月']) {
const endYM = parseYearMonth_(m['請求終了月']);
if (endYM && targetYM > endYM) return false;
}
// サイクルチェック
const cycle = parseInt(m['請求サイクル(月)']) || 1;
if (cycle === 1) return true; // 毎月は常にOK
// 開始月からの経過月数がサイクルの倍数かチェック
const startYear = Math.floor(startYM / 100);
const startMon = startYM % 100;
const monthsElapsed = (targetYear - startYear) * 12 + (targetMonth - startMon);
return monthsElapsed >= 0 && monthsElapsed % cycle === 0;
});
}
// ============================================================
// "2026/01" や "2026-01" を 202601 に変換
// ============================================================
function parseYearMonth_(val) {
if (!val) return null;
const s = String(val).replace(/[\/\-]/g, '');
// "202601" or Date object
if (val instanceof Date) {
return val.getFullYear() * 100 + (val.getMonth() + 1);
}
const num = parseInt(s);
return num > 100000 ? num : null; // 6桁以上なら有効
}
// ============================================================
// 請求年月の表示文字列 (202601 → "2026年1月")
// ============================================================
function formatYM_(ym) {
const y = Math.floor(ym / 100);
const m = ym % 100;
return `${y}年${m}月`;
}
// ============================================================
// 重複チェック:同じマスタ行×請求年月が既に履歴にあるか
// ============================================================
function isDuplicateInvoice_(masterRowIndex, yearMonth) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const histSheet = ss.getSheetByName(SHEET_HISTORY);
if (histSheet.getLastRow() < 2) return false;
const headers = histSheet.getRange(1, 1, 1, histSheet.getLastColumn()).getValues()[0];
const ymCol = headers.indexOf('請求年月');
const masterCol = headers.indexOf('マスタ行番号');
const data = histSheet.getRange(2, 1, histSheet.getLastRow() - 1, histSheet.getLastColumn()).getValues();
return data.some(row => {
const existYM = String(row[ymCol]).replace(/[\/\-年月]/g, '').trim();
const existMaster = row[masterCol];
return existMaster == masterRowIndex && existYM == String(yearMonth);
});
}
// ============================================================
// 請求番号の自動採番
// ============================================================
function generateInvoiceNo_(targetDate) {
const dateStr = Utilities.formatDate(targetDate, 'Asia/Tokyo', 'yyyyMMdd');
const ss = SpreadsheetApp.getActiveSpreadsheet();
const histSheet = ss.getSheetByName(SHEET_HISTORY);
let maxSeq = 0;
if (histSheet.getLastRow() >= 2) {
const invoiceNos = histSheet.getRange(2, 1, histSheet.getLastRow() - 1, 1).getValues();
invoiceNos.forEach(row => {
const no = String(row[0]);
if (no.startsWith(dateStr)) {
const seq = parseInt(no.split('-')[1]) || 0;
if (seq > maxSeq) maxSeq = seq;
}
});
}
return `${dateStr}-${String(maxSeq + 1).padStart(3, '0')}`;
}
// ============================================================
// マスタ1行から請求データオブジェクトを生成
// ============================================================
function buildInvoiceFromMaster_(master, targetYear, targetMonth) {
const settings = getSettingsMap_();
const cycle = parseInt(master['請求サイクル(月)']) || 1;
const taxRate = parseFloat(settings['消費税率']) / 100;
// 請求日 = 対象月の5日(例: 2026/01/05)
const invoiceDate = new Date(targetYear, targetMonth - 1, 5);
const invoiceDateStr = Utilities.formatDate(invoiceDate, 'Asia/Tokyo', 'yyyy/MM/dd');
// 支払期限 = 対象月の指定日(デフォルト30日)
const dueDateDay = parseInt(settings['支払期限(翌月何日)']) || 30;
const paymentDeadline = `${targetMonth}月${dueDateDay}日`;
// 請求番号
const invoiceNo = generateInvoiceNo_(invoiceDate);
// 契約期間表示(備考テンプレート用)
const periodStart = `${targetYear}年${targetMonth}月`;
const endMonth = targetMonth + cycle - 1;
const endYear = targetYear + Math.floor((endMonth - 1) / 12);
const endMon = ((endMonth - 1) % 12) + 1;
const periodEnd = `${endYear}年${endMon}月`;
// 備考のプレースホルダ置換
let remarks = String(master['備考テンプレート'] || '');
remarks = remarks.replace(/\{開始月\}/g, periodStart);
remarks = remarks.replace(/\{終了月\}/g, periodEnd);
remarks = remarks.replace(/\{請求年\}/g, String(targetYear));
remarks = remarks.replace(/\{請求月\}/g, String(targetMonth));
// 明細
const items = [];
for (let i = 1; i <= 3; i++) {
const desc = master[`摘要${i}`];
if (desc && desc !== '') {
items.push({
description: desc,
quantity: master[`数量${i}`] || 0,
unit: master[`単位${i}`] || '',
unitPrice: master[`単価${i}`] || 0,
});
}
}
// 金額計算
let subtotal = 0;
items.forEach(item => { subtotal += item.quantity * item.unitPrice; });
const tax = Math.floor(subtotal * taxRate);
const total = subtotal + tax;
return {
'請求No': invoiceNo,
'請求日': invoiceDateStr,
'請求年月': `${targetYear}/${String(targetMonth).padStart(2, '0')}`,
'宛先会社名': master['顧客名'],
'宛先敬称': master['敬称'] || '御中',
'宛先代表者名': master['代表者名'] || '',
'宛先代表者敬称': master['代表者敬称'] || '様',
'件名': master['件名'],
'支払期限': paymentDeadline,
items: items,
subtotal: subtotal,
tax: tax,
total: total,
'備考': remarks,
'送信先メールアドレス': master['送信先メールアドレス'],
'CC': master['CC'] || '',
'_masterRowIndex': master._rowIndex,
};
}
// ============================================================
// 請求履歴シートに1行追加
// ============================================================
function appendToHistory_(invoice, pdfUrl, sendStatus) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const histSheet = ss.getSheetByName(SHEET_HISTORY);
const row = [
invoice['請求No'],
invoice['請求日'],
invoice['請求年月'],
invoice['宛先会社名'],
invoice['宛先敬称'],
invoice['宛先代表者名'],
invoice['宛先代表者敬称'],
invoice['件名'],
invoice['支払期限'],
];
// 明細(3行分)
for (let i = 0; i < 3; i++) {
const item = invoice.items[i];
if (item) {
row.push(item.description, item.quantity, item.unit, item.unitPrice);
} else {
row.push('', '', '', '');
}
}
row.push(
invoice.subtotal,
invoice.tax,
invoice.total,
invoice['備考'],
invoice['送信先メールアドレス'],
invoice['CC'],
pdfUrl || '',
sendStatus || '',
sendStatus === '送信済み' ? new Date() : '',
invoice['_masterRowIndex'],
);
histSheet.appendRow(row);
return histSheet.getLastRow();
}
// ============================================================
// テンプレートにデータ反映(PDF用)
// ============================================================
function populateTemplate_(invoiceData) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(SHEET_TEMPLATE);
const settings = getSettingsMap_();
sheet.clear();
setupTemplateSheet_(sheet);
let row = 1;
// --- 請求No(右寄せ)---
sheet.getRange(row, 6, 1, 2).merge()
.setValue(`請求No. ${invoiceData['請求No']}`)
.setHorizontalAlignment('right').setFontSize(9).setFontColor('#666666');
row++;
// --- タイトル ---
sheet.getRange(row, 2, 1, 6).merge()
.setValue('請 求 書')
.setHorizontalAlignment('center')
.setFontSize(22).setFontWeight('bold');
row += 2;
// --- 宛先 ---
const clientName = `${invoiceData['宛先会社名']} ${invoiceData['宛先敬称'] || '御中'}`;
sheet.getRange(row, 2, 1, 3).merge()
.setValue(clientName)
.setFontSize(14).setFontWeight('bold');
// 請求日(右側)
sheet.getRange(row, 6, 1, 2).merge()
.setValue(`請求日 ${invoiceData['請求日']}`)
.setHorizontalAlignment('right').setFontSize(9);
row++;
// 宛先下線
sheet.getRange(row - 1, 2, 1, 3).setBorder(false, false, true, false, false, false,
'#333333', SpreadsheetApp.BorderStyle.SOLID_MEDIUM);
// 代表者名
if (invoiceData['宛先代表者名']) {
sheet.getRange(row, 2, 1, 3).merge()
.setValue(`代表者:${invoiceData['宛先代表者名']} ${invoiceData['宛先代表者敬称'] || '様'}`)
.setFontSize(10);
row++;
}
row++;
// --- 自社情報(右側)---
const displayName = settings['会社名表記(請求書)'] || settings['会社名'];
sheet.getRange(row, 5, 1, 3).merge()
.setValue(displayName)
.setHorizontalAlignment('right').setFontSize(11).setFontWeight('bold');
row++;
sheet.getRange(row, 5, 1, 3).merge()
.setValue(settings['住所'])
.setHorizontalAlignment('right').setFontSize(8);
row++;
sheet.getRange(row, 5, 1, 3).merge()
.setValue(`TEL: ${settings['電話番号']}`)
.setHorizontalAlignment('right').setFontSize(8);
row += 2;
// --- 件名 ---
sheet.getRange(row, 2, 1, 6).merge()
.setValue(`件名: ${invoiceData['件名']}`)
.setFontSize(10);
row++;
sheet.getRange(row, 2, 1, 6).merge()
.setValue('下記の通り、ご請求申し上げます。')
.setFontSize(10);
row += 2;
// --- 合計金額 ---
sheet.getRange(row, 2, 1, 6).merge()
.setValue(`合計金額 ¥${invoiceData.total.toLocaleString()} (税込)`)
.setFontSize(14).setFontWeight('bold')
.setBackground('#F2F2F2')
.setBorder(true, true, true, true, false, false, '#333333', SpreadsheetApp.BorderStyle.SOLID);
row++;
sheet.getRange(row, 2, 1, 6).merge()
.setValue(`お支払期限: ${invoiceData['支払期限']}`)
.setHorizontalAlignment('right').setFontSize(9);
row += 2;
// --- 明細テーブル ---
const tableHeaderRow = row;
sheet.getRange(row, 2).setValue('No.').setHorizontalAlignment('center');
sheet.getRange(row, 3).setValue('摘要').setHorizontalAlignment('center');
sheet.getRange(row, 4).setValue('数量').setHorizontalAlignment('center');
sheet.getRange(row, 5).setValue('').setHorizontalAlignment('center');
sheet.getRange(row, 6).setValue('単価').setHorizontalAlignment('center');
sheet.getRange(row, 7).setValue('金額').setHorizontalAlignment('center');
sheet.getRange(row, 2, 1, 6)
.setBackground('#4472C4').setFontColor('white').setFontWeight('bold').setFontSize(9);
row++;
const maxItems = 12;
for (let i = 0; i < maxItems; i++) {
const item = invoiceData.items[i];
if (item) {
const amount = item.quantity * item.unitPrice;
sheet.getRange(row, 2).setValue(i + 1).setHorizontalAlignment('center');
sheet.getRange(row, 3).setValue(item.description);
sheet.getRange(row, 4).setValue(item.quantity).setHorizontalAlignment('right');
sheet.getRange(row, 5).setValue(item.unit).setHorizontalAlignment('center');
sheet.getRange(row, 6).setValue(`¥${item.unitPrice.toLocaleString()}`).setHorizontalAlignment('right');
sheet.getRange(row, 7).setValue(`¥${amount.toLocaleString()}`).setHorizontalAlignment('right');
}
if (i % 2 === 1) {
sheet.getRange(row, 2, 1, 6).setBackground('#F8F9FA');
}
sheet.getRange(row, 2, 1, 6)
.setBorder(false, false, true, false, false, false, '#DDDDDD', SpreadsheetApp.BorderStyle.DOTTED);
row++;
}
sheet.getRange(tableHeaderRow, 2, maxItems + 1, 6)
.setBorder(true, true, true, true, false, false, '#4472C4', SpreadsheetApp.BorderStyle.SOLID);
row++;
// --- 小計・税・合計 ---
const settings2 = getSettingsMap_();
sheet.getRange(row, 5, 1, 2).merge().setValue('小計').setHorizontalAlignment('right').setFontWeight('bold');
sheet.getRange(row, 7).setValue(`¥${invoiceData.subtotal.toLocaleString()}`).setHorizontalAlignment('right').setFontWeight('bold');
row++;
sheet.getRange(row, 5, 1, 2).merge().setValue(`消費税等(${settings2['消費税率']}%)`).setHorizontalAlignment('right');
sheet.getRange(row, 7).setValue(`¥${invoiceData.tax.toLocaleString()}`).setHorizontalAlignment('right');
row++;
sheet.getRange(row, 5, 1, 2).merge().setValue('合計').setHorizontalAlignment('right').setFontSize(12).setFontWeight('bold');
sheet.getRange(row, 7).setValue(`¥${invoiceData.total.toLocaleString()}`)
.setHorizontalAlignment('right').setFontSize(12).setFontWeight('bold')
.setBorder(false, false, true, false, false, false, '#4472C4', SpreadsheetApp.BorderStyle.SOLID_MEDIUM);
row += 2;
// --- 備考 ---
if (invoiceData['備考']) {
sheet.getRange(row, 2).setValue('備考').setFontWeight('bold').setFontSize(9);
row++;
sheet.getRange(row, 2, 1, 6).merge()
.setValue(invoiceData['備考'])
.setFontSize(9).setFontColor('#555555');
row += 2;
}
// --- 振込先 ---
sheet.getRange(row, 2).setValue('振込先').setFontWeight('bold').setFontSize(10);
sheet.getRange(row, 2, 1, 6).setBackground('#F2F2F2')
.setBorder(true, true, true, true, false, false, '#CCCCCC', SpreadsheetApp.BorderStyle.SOLID);
row++;
[settings2['銀行名'], settings2['支店名'],
`${settings2['口座種別']} ${settings2['口座番号']}`,
settings2['口座名義']
].forEach(info => {
sheet.getRange(row, 3, 1, 4).merge().setValue(info).setFontSize(9);
row++;
});
SpreadsheetApp.flush();
}
// ============================================================
// PDF生成 → Google Drive保存
// ============================================================
function generatePDF_(invoiceData) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
populateTemplate_(invoiceData);
SpreadsheetApp.flush();
Utilities.sleep(2000); // テンプレート描画の安定待ち
const templateSheet = ss.getSheetByName(SHEET_TEMPLATE);
const sheetId = templateSheet.getSheetId();
const url = ss.getUrl().replace(/\/edit.*$/, '')
+ '/export?exportFormat=pdf&format=pdf&size=A4&portrait=true'
+ '&fitw=true&gridlines=false&printtitle=false&sheetnames=false'
+ '&pagenum=UNDEFINED&fzr=false&gid=' + sheetId
+ '&top_margin=0.5&bottom_margin=0.5&left_margin=0.4&right_margin=0.4';
const token = ScriptApp.getOAuthToken();
const response = UrlFetchApp.fetch(url, {
headers: { 'Authorization': 'Bearer ' + token }
});
const pdfBlob = response.getBlob();
const dateStr = String(invoiceData['請求日']).replace(/\//g, '');
const fileName = `請求書_${invoiceData['宛先会社名']}御中${dateStr}.pdf`;
pdfBlob.setName(fileName);
// フォルダ取得 or 作成
const folder = getOrCreatePDFFolder_();
const existingFiles = folder.getFilesByName(fileName);
while (existingFiles.hasNext()) existingFiles.next().setTrashed(true);
const file = folder.createFile(pdfBlob);
return { file, fileName, blob: pdfBlob, url: file.getUrl() };
}
// ============================================================
// PDFフォルダ取得 or 作成
// ============================================================
function getOrCreatePDFFolder_() {
const settings = getSettingsMap_();
const folderId = settings['請求書保存フォルダID'];
if (folderId) {
try { return DriveApp.getFolderById(folderId); } catch (e) {}
}
const folders = DriveApp.getFoldersByName(PDF_FOLDER_NAME);
let folder = folders.hasNext() ? folders.next() : DriveApp.createFolder(PDF_FOLDER_NAME);
// IDを設定シートに保存
const ss = SpreadsheetApp.getActiveSpreadsheet();
const settingsSheet = ss.getSheetByName(SHEET_SETTINGS);
const data = settingsSheet.getDataRange().getValues();
for (let i = 0; i < data.length; i++) {
if (data[i][0] === '請求書保存フォルダID') {
settingsSheet.getRange(i + 1, 2).setValue(folder.getId());
break;
}
}
return folder;
}
// ============================================================
// メール送信
// ============================================================
function sendInvoiceEmail_(invoiceData, pdfBlob) {
const settings = getSettingsMap_();
const toEmail = invoiceData['送信先メールアドレス'];
if (!toEmail) return false;
// 件名テンプレート
let subject = settings['メール件名テンプレート'] || '【請求書】{件名} - {自社名}';
subject = subject.replace(/\{件名\}/g, invoiceData['件名']);
subject = subject.replace(/\{自社名\}/g, settings['会社名']);
const body = createEmailBody_(invoiceData, settings);
const htmlBody = createEmailHtmlBody_(invoiceData, settings);
const options = {
htmlBody: htmlBody,
attachments: [pdfBlob],
name: settings['会社名'],
replyTo: settings['メールアドレス'] || Session.getActiveUser().getEmail(),
};
if (invoiceData['CC']) {
options.cc = invoiceData['CC'];
}
GmailApp.sendEmail(toEmail, subject, body, options);
return true;
}
// ============================================================
// メール本文(テキスト)
// ============================================================
function createEmailBody_(invoiceData, settings) {
return `${invoiceData['宛先会社名']} 御中
いつもお世話になっております。
${settings['会社名']}の${settings['代表者名']}です。
${invoiceData['件名']}の請求書をお送りいたします。
添付のPDFファイルをご確認ください。
■ 請求No: ${invoiceData['請求No']}
■ お支払期限: ${invoiceData['支払期限']}
■ 振込先
${settings['銀行名']} ${settings['支店名']}
${settings['口座種別']} ${settings['口座番号']}
${settings['口座名義']}
ご不明な点がございましたら、お気軽にお問い合わせください。
何卒よろしくお願いいたします。
━━━━━━━━━━━━━━━━━━━━━━━━
${settings['会社名']}
${settings['代表者名']}
TEL: ${settings['電話番号']}
${settings['メールアドレス'] ? 'Email: ' + settings['メールアドレス'] : ''}
━━━━━━━━━━━━━━━━━━━━━━━━`;
}
// ============================================================
// メール本文(HTML)
// ============================================================
function createEmailHtmlBody_(invoiceData, settings) {
return `<!DOCTYPE html>
<html><head><meta charset="UTF-8"></head>
<body style="font-family:'Meiryo','Hiragino Sans',sans-serif;font-size:14px;color:#333;line-height:1.8;">
<p>${invoiceData['宛先会社名']} 御中</p>
<p>いつもお世話になっております。<br>
${settings['会社名']}の${settings['代表者名']}です。</p>
<p><strong>${invoiceData['件名']}</strong>の請求書をお送りいたします。<br>
添付のPDFファイルをご確認ください。</p>
<table style="border-collapse:collapse;margin:16px 0;">
<tr><td style="padding:6px 16px;background:#f5f5f5;border:1px solid #ddd;font-weight:bold;">請求No</td>
<td style="padding:6px 16px;border:1px solid #ddd;">${invoiceData['請求No']}</td></tr>
<tr><td style="padding:6px 16px;background:#f5f5f5;border:1px solid #ddd;font-weight:bold;">お支払期限</td>
<td style="padding:6px 16px;border:1px solid #ddd;">${invoiceData['支払期限']}</td></tr>
</table>
<div style="background:#f8f9fa;border-left:4px solid #4472C4;padding:12px 16px;margin:16px 0;">
<strong>振込先</strong><br>
${settings['銀行名']} ${settings['支店名']}<br>
${settings['口座種別']} ${settings['口座番号']}<br>
${settings['口座名義']}
</div>
<p>ご不明な点がございましたら、お気軽にお問い合わせください。<br>
何卒よろしくお願いいたします。</p>
<hr style="border:none;border-top:1px solid #ddd;margin:24px 0;">
<div style="font-size:12px;color:#888;">
<strong>${settings['会社名']}</strong><br>
${settings['代表者名']}<br>
TEL: ${settings['電話番号']}<br>
${settings['メールアドレス'] ? 'Email: ' + settings['メールアドレス'] : ''}
</div>
</body></html>`;
}
// ============================================================
// ★ 月次自動実行のメイン処理
// ============================================================
function monthlyAutoRun() {
const now = new Date();
// 翌月分を生成(25日実行想定なので翌月)
let targetMonth = now.getMonth() + 2; // getMonth() is 0-indexed, +2 for next month
let targetYear = now.getFullYear();
if (targetMonth > 12) {
targetMonth -= 12;
targetYear++;
}
runInvoicesForMonth_(targetYear, targetMonth, true);
}
// ============================================================
// 指定年月の請求処理
// ============================================================
function runInvoicesForMonth_(targetYear, targetMonth, autoSend) {
const settings = getSettingsMap_();
const shouldSend = autoSend && (settings['自動送信ON'] === true || settings['自動送信ON'] === 'TRUE');
const targets = getTargetMasterRows_(targetYear, targetMonth);
const yearMonth = targetYear * 100 + targetMonth;
const results = [];
targets.forEach(master => {
// 重複チェック
if (isDuplicateInvoice_(master._rowIndex, `${targetYear}/${String(targetMonth).padStart(2, '0')}`)) {
results.push({ customer: master['顧客名'], status: 'スキップ(生成済み)' });
return;
}
try {
// 請求データ生成
const invoice = buildInvoiceFromMaster_(master, targetYear, targetMonth);
// PDF生成
const pdf = generatePDF_(invoice);
// メール送信
let sendStatus = 'PDF生成済み';
if (shouldSend && invoice['送信先メールアドレス']) {
sendInvoiceEmail_(invoice, pdf.blob);
sendStatus = '送信済み';
}
// 履歴に記録
appendToHistory_(invoice, pdf.url, sendStatus);
results.push({ customer: master['顧客名'], status: sendStatus });
} catch (e) {
results.push({ customer: master['顧客名'], status: `エラー: ${e.message}` });
Logger.log(`Error for ${master['顧客名']}: ${e.stack}`);
}
});
return results;
}
// ============================================================
// ドライラン(今月の対象確認)
// ============================================================
function dryRunMonthlyInvoices() {
const now = new Date();
let targetMonth = now.getMonth() + 2;
let targetYear = now.getFullYear();
if (targetMonth > 12) { targetMonth -= 12; targetYear++; }
const targets = getTargetMasterRows_(targetYear, targetMonth);
if (targets.length === 0) {
SpreadsheetApp.getUi().alert(
`📋 ${targetYear}年${targetMonth}月の対象\n\n対象となる請求はありません。`
);
return;
}
const settings = getSettingsMap_();
const taxRate = parseFloat(settings['消費税率']) / 100;
let msg = `📋 ${targetYear}年${targetMonth}月の請求対象(${targets.length}件)\n\n`;
targets.forEach((m, i) => {
let subtotal = 0;
for (let j = 1; j <= 3; j++) {
subtotal += (m[`数量${j}`] || 0) * (m[`単価${j}`] || 0);
}
const total = subtotal + Math.floor(subtotal * taxRate);
const dup = isDuplicateInvoice_(m._rowIndex, `${targetYear}/${String(targetMonth).padStart(2, '0')}`);
msg += `${i + 1}. ${m['顧客名']}\n`;
msg += ` 金額: ¥${total.toLocaleString()}(税込)\n`;
msg += ` 送信先: ${m['送信先メールアドレス']}\n`;
msg += dup ? ` ⚠️ 既に生成済み(スキップされます)\n` : '';
msg += '\n';
});
SpreadsheetApp.getUi().alert(msg);
}
// ============================================================
// 手動実行(今月分を生成+送信)
// ============================================================
function manualRunMonthlyInvoices() {
const now = new Date();
let targetMonth = now.getMonth() + 2;
let targetYear = now.getFullYear();
if (targetMonth > 12) { targetMonth -= 12; targetYear++; }
const ui = SpreadsheetApp.getUi();
const response = ui.alert(
`📧 ${targetYear}年${targetMonth}月分の請求書を生成・送信します`,
'続行しますか?(事前に「対象確認」の実行をおすすめします)',
ui.ButtonSet.YES_NO
);
if (response !== ui.Button.YES) return;
const results = runInvoicesForMonth_(targetYear, targetMonth, true);
showResults_(targetYear, targetMonth, results);
}
// ============================================================
// 指定月分を生成
// ============================================================
function runForSpecificMonth() {
const ui = SpreadsheetApp.getUi();
const response = ui.prompt(
'📅 請求対象月を指定',
'年月を入力してください(例: 2026/04)',
ui.ButtonSet.OK_CANCEL
);
if (response.getSelectedButton() !== ui.Button.OK) return;
const input = response.getResponseText().trim();
const match = input.match(/(\d{4})[\/\-]?(\d{1,2})/);
if (!match) {
ui.alert('❌ 形式が正しくありません。例: 2026/04');
return;
}
const targetYear = parseInt(match[1]);
const targetMonth = parseInt(match[2]);
const confirm = ui.alert(
`📧 ${targetYear}年${targetMonth}月分`,
'請求書を生成・送信しますか?',
ui.ButtonSet.YES_NO
);
if (confirm !== ui.Button.YES) return;
const results = runInvoicesForMonth_(targetYear, targetMonth, true);
showResults_(targetYear, targetMonth, results);
}
// ============================================================
// 結果表示
// ============================================================
function showResults_(year, month, results) {
if (results.length === 0) {
SpreadsheetApp.getUi().alert(`${year}年${month}月の対象はありませんでした。`);
return;
}
let msg = `📊 ${year}年${month}月 処理結果\n\n`;
results.forEach((r, i) => {
const icon = r.status.includes('エラー') ? '❌' : r.status.includes('スキップ') ? '⏭️' : '✅';
msg += `${icon} ${r.customer}: ${r.status}\n`;
});
SpreadsheetApp.getUi().alert(msg);
}
// ============================================================
// 履歴シートから操作(単発)
// ============================================================
function previewFromHistory() {
const invoice = getInvoiceFromHistoryRow_();
if (!invoice) return;
populateTemplate_(invoice);
SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_TEMPLATE).activate();
SpreadsheetApp.getUi().alert(`✅ プレビュー更新: ${invoice['請求No']}`);
}
function generatePDFFromHistory() {
const invoice = getInvoiceFromHistoryRow_();
if (!invoice) return;
const pdf = generatePDF_(invoice);
SpreadsheetApp.getUi().alert(`✅ PDF生成完了: ${pdf.fileName}\n${pdf.url}`);
}
function sendFromHistory() {
const invoice = getInvoiceFromHistoryRow_();
if (!invoice) return;
const ui = SpreadsheetApp.getUi();
const confirm = ui.alert(
'📧 送信確認',
`${invoice['宛先会社名']} (${invoice['送信先メールアドレス']}) に送信しますか?`,
ui.ButtonSet.YES_NO
);
if (confirm !== ui.Button.YES) return;
const pdf = generatePDF_(invoice);
sendInvoiceEmail_(invoice, pdf.blob);
// 履歴更新
const ss = SpreadsheetApp.getActiveSpreadsheet();
const histSheet = ss.getSheetByName(SHEET_HISTORY);
const row = ss.getActiveCell().getRow();
const headers = histSheet.getRange(1, 1, 1, histSheet.getLastColumn()).getValues()[0];
const statusCol = headers.indexOf('送信ステータス') + 1;
const dateCol = headers.indexOf('送信日時') + 1;
if (statusCol) histSheet.getRange(row, statusCol).setValue('送信済み');
if (dateCol) histSheet.getRange(row, dateCol).setValue(new Date());
ui.alert('✅ 送信完了!');
}
function getInvoiceFromHistoryRow_() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const histSheet = ss.getSheetByName(SHEET_HISTORY);
const row = ss.getActiveCell().getRow();
if (ss.getActiveSheet().getName() !== SHEET_HISTORY || row < 2) {
SpreadsheetApp.getUi().alert('❌ 請求履歴シートの行を選択してください');
return null;
}
const headers = histSheet.getRange(1, 1, 1, histSheet.getLastColumn()).getValues()[0];
const rowData = histSheet.getRange(row, 1, 1, histSheet.getLastColumn()).getValues()[0];
const data = {};
headers.forEach((h, i) => { data[h] = rowData[i]; });
// items再構成
data.items = [];
for (let i = 1; i <= 3; i++) {
if (data[`摘要${i}`]) {
data.items.push({
description: data[`摘要${i}`],
quantity: data[`数量${i}`] || 0,
unit: data[`単位${i}`] || '',
unitPrice: data[`単価${i}`] || 0,
});
}
}
data.subtotal = data['小計'] || 0;
data.tax = data['消費税'] || 0;
data.total = data['合計(税込)'] || 0;
return data;
}
// ============================================================
// ⏰ 月次トリガー設定(毎月25日 午前9時)
// ============================================================
function setupMonthlyTrigger() {
// 既存トリガー削除
removeMonthlyTrigger();
ScriptApp.newTrigger('monthlyAutoRun')
.timeBased()
.onMonthDay(25)
.atHour(9)
.create();
SpreadsheetApp.getUi().alert(
'✅ 月次自動実行トリガーを設定しました\n\n' +
'実行タイミング:毎月25日 午前9時〜10時\n' +
'処理内容:翌月分の請求書を自動生成+送信\n\n' +
'※「設定」シートの「自動送信ON」で送信の有無を切替可能です'
);
}
function removeMonthlyTrigger() {
const triggers = ScriptApp.getProjectTriggers();
triggers.forEach(trigger => {
if (trigger.getHandlerFunction() === 'monthlyAutoRun') {
ScriptApp.deleteTrigger(trigger);
}
});
SpreadsheetApp.getUi().alert('⏰ 月次トリガーを解除しました');
}
この記事が、あなたのビジネスを加速させる一助となれば幸いです。

コメント