GASで見積書をテンプレートから自動作成する方法

GASで見積書をテンプレートから自動作成する方法

見積依頼のたびに、過去の見積書をコピーして品目と数量を書き換え、消費税を電卓で計算し直していないでしょうか。

品目数が毎回変わる見積書は、契約書や案内状のような定型書類と違い、明細行の数だけレイアウトが崩れやすく、単価の転記ミスや消費税の計算違いも起きやすい書類です。

Google Apps Script(Googleスプレッドシートに付属するスクリプト環境)を使えば、この転記と計算をまとめて自動化できます。

単価マスタと見積明細を分けて管理し、明細行が何行あっても崩れない見積書PDFを自動作成する方法を整理しています。

見積書作成の自動化を無料で相談する

Google Apps Scriptでの見積書自動作成が向くケース(明細行が可変という前提での設計)

見積書は請求書と違い、依頼ごとに品目数も品目の組み合わせも変わります。

そのため今回のテンプレートは、明細を1件ずつ差し込む方式ではなく、見積書シートに明細欄をあらかじめ最大行数ぶん確保しておき、依頼件数に応じて使う行数だけ埋めて残りを空にする方式にしています。

単価マスタで一元管理

見積書ごとに単価を直接入力

単価改定時の作業

マスタの1行を直すだけ

過去の見積書は直せず放置される

入力ミスの起きやすさ

品目コードの選び間違いのみ

単価の桁間違いが起きやすい

新しい品目の追加

マスタに1行追加すれば全体で使える

都度スクリプトの改修が必要になりやすい

書類のレイアウトに値を差し込むだけの一般的な仕組みは、以下で扱っています。

事前準備:単価マスタ・見積明細・見積書シートを分ける

準備するシートは3つです。

1つ目は品目コード、品名、単価を並べた『単価マスタ』シートで、単価を変更したいときはここだけを直します。

2つ目は見積番号ごとに品目コードと数量を1行ずつ並べた『見積明細』シートで、同じ見積番号の行が複数あってもかまいません。

3つ目は顧客名や見積番号を書き込む欄と、明細を最大15行ぶん確保した表を持つ『見積書』シートで、これが実際に印刷・PDF化される見た目になる場所です。

明細をシートごとに分けておくことで、見積依頼が同時に何件来ても、見積書シートを使い回しながら1件ずつ順番にPDF化していけます。

Google Apps Scriptで見積書をテンプレートから自動作成する手順

見積明細シートを見積番号ごとに集計し、単価マスタで単価を引き当てたうえで見積書シートへ書き込み、その範囲をPDFとして書き出す流れをスクリプトにします。

Step1. 見積書シートの明細欄を固定行数で設計する

『見積書』シートに、顧客名・見積番号を入れるセルと、品名・数量・単価・金額の4列からなる明細表を作ります。

明細表は最大で扱いたい行数(ここでは15行)を先に罫線とセル書式だけ引いておき、値が入っていない行は空欄のまま印刷されるようにしておきます。

行数を後から増やす場合は、罫線を伸ばしたうえでコード側のTEMPLATE_ITEM_MAX_ROWSも同じ数に直します。

Step2. コードを貼り付ける(コード全文)

拡張機能からApps Scriptエディタを開き、以下をそのまま貼り付けます。

const HEADER_SHEET_NAME = '見積依頼';
const PRICE_SHEET_NAME = '単価マスタ';
const DETAIL_SHEET_NAME = '見積明細';
const TEMPLATE_SHEET_NAME = '見積書';
const OUTPUT_FOLDER_ID = 'ここにドライブの保存先フォルダIDを入れる';

const TEMPLATE_ITEM_START_ROW = 10; // 見積書シートの明細1行目
const TEMPLATE_ITEM_MAX_ROWS = 15; // 明細欄に確保している最大行数
const TAX_RATE = 0.1;

// 見積依頼シートの列(A列から順に0,1,2...)
const HEADER_COL = {
  quoteNumber: 0,
  customerName: 1,
  status: 2,
};

// 見積明細シートの列
const DETAIL_COL = {
  quoteNumber: 0,
  itemCode: 1,
  quantity: 2,
};

// 単価マスタシートの列
const PRICE_COL = {
  itemCode: 0,
  itemName: 1,
  unitPrice: 2,
};

function createQuotesFromRequests() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const headerSheet = ss.getSheetByName(HEADER_SHEET_NAME);
  const priceMap = buildPriceMap(ss.getSheetByName(PRICE_SHEET_NAME));
  const detailRows = ss.getSheetByName(DETAIL_SHEET_NAME).getDataRange().getValues();
  const headerRows = headerSheet.getDataRange().getValues();

  for (let i = 1; i < headerRows.length; i++) {
    const row = headerRows[i];
    if (row[HEADER_COL.status] === '発行済') {
      continue;
    }

    const items = pickItemsForQuote(detailRows, row[HEADER_COL.quoteNumber], priceMap);
    generateQuotePdf(ss, row[HEADER_COL.quoteNumber], row[HEADER_COL.customerName], items);
    headerSheet.getRange(i + 1, HEADER_COL.status + 1).setValue('発行済');
  }
}

function buildPriceMap(priceSheet) {
  const rows = priceSheet.getDataRange().getValues();
  const map = {};
  for (let i = 1; i < rows.length; i++) {
    const row = rows[i];
    map[row[PRICE_COL.itemCode]] = {
      itemName: row[PRICE_COL.itemName],
      unitPrice: row[PRICE_COL.unitPrice],
    };
  }
  return map;
}

function pickItemsForQuote(detailRows, quoteNumber, priceMap) {
  const items = [];
  for (let i = 1; i < detailRows.length; i++) {
    const row = detailRows[i];
    if (row[DETAIL_COL.quoteNumber] !== quoteNumber) {
      continue;
    }

    const price = priceMap[row[DETAIL_COL.itemCode]];
    items.push({
      itemName: price.itemName,
      quantity: row[DETAIL_COL.quantity],
      unitPrice: price.unitPrice,
    });
  }
  return items;
}

function generateQuotePdf(ss, quoteNumber, customerName, items) {
  if (items.length > TEMPLATE_ITEM_MAX_ROWS) {
    throw new Error(`見積番号 ${quoteNumber} の明細行数がテンプレートの上限 ${TEMPLATE_ITEM_MAX_ROWS} 行を超えています`);
  }

  const templateSheet = ss.getSheetByName(TEMPLATE_SHEET_NAME);
  templateSheet.getRange(3, 2).setValue(customerName);
  templateSheet.getRange(4, 2).setValue(quoteNumber);
  writeItemRows(templateSheet, items);
  writeTaxSummary(templateSheet, items);
  SpreadsheetApp.flush();

  const pdfBlob = exportSheetAsPdf(ss, templateSheet);
  const folder = DriveApp.getFolderById(OUTPUT_FOLDER_ID);
  folder.createFile(pdfBlob).setName(`見積書_${quoteNumber}.pdf`);
}

function writeItemRows(templateSheet, items) {
  for (let i = 0; i < TEMPLATE_ITEM_MAX_ROWS; i++) {
    const row = TEMPLATE_ITEM_START_ROW + i;
    if (i < items.length) {
      const item = items[i];
      const amount = item.quantity * item.unitPrice;
      templateSheet.getRange(row, 2, 1, 4).setValues([[item.itemName, item.quantity, item.unitPrice, amount]]);
    } else {
      templateSheet.getRange(row, 2, 1, 4).clearContent();
    }
  }
}

function writeTaxSummary(templateSheet, items) {
  const subtotal = items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0);
  // 端数処理は切り捨てで実装。切り上げ・四捨五入にする場合は社内ルールに合わせてここを変更する
  const tax = Math.floor(subtotal * TAX_RATE);
  const summaryRow = TEMPLATE_ITEM_START_ROW + TEMPLATE_ITEM_MAX_ROWS + 1;
  templateSheet.getRange(summaryRow, 5).setValue(subtotal);
  templateSheet.getRange(summaryRow + 1, 5).setValue(tax);
  templateSheet.getRange(summaryRow + 2, 5).setValue(subtotal + tax);
}

function exportSheetAsPdf(ss, sheet) {
  const url = `https://docs.google.com/spreadsheets/d/${ss.getId()}/export`
    + `?format=pdf&gid=${sheet.getSheetId()}`
    + '&size=A4&portrait=true&fitw=true&gridlines=false&printtitle=false';
  const response = UrlFetchApp.fetch(url, {
    headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}` },
  });
  return response.getBlob();
}

このコードが何をしているか

createQuotesFromRequestsが『見積依頼』シートを1行ずつ読み、発行済フラグが立っていない見積番号だけを処理します。

pickItemsForQuoteが『見積明細』シートから同じ見積番号の行をすべて集め、buildPriceMapで作った単価マスタの対応表から単価と品名を引き当てます。

集まった明細はwriteItemRowsで明細欄の上から書き込み、使わなかった行はclearContentで空にするため、明細が3行の依頼でも12行の依頼でも同じテンプレートで対応できます。

金額の合計と消費税はwriteTaxSummaryで計算し、最後にexportSheetAsPdfが見積書シートのある範囲をPDFとして書き出し、ドライブの指定フォルダへ保存します。

自分のデータに合わせて変える場所

  • OUTPUT_FOLDER_IDにはPDFの保存先フォルダのIDを入れます。
    フォルダを開いたときのURLの末尾の文字列がフォルダIDです。
  • TEMPLATE_ITEM_START_ROWTEMPLATE_ITEM_MAX_ROWSは、見積書シートの明細欄の位置と行数に合わせます。
  • TAX_RATEは消費税率です。
    軽減税率など複数税率を扱う場合は、品目ごとの税率を単価マスタに列として追加し、writeTaxSummaryの計算式を分ける必要があります。

Step3. 1件でテスト実行する

『見積依頼』と『見積明細』にテスト用の見積番号を1件だけ入力し、Apps ScriptエディタからcreateQuotesFromRequestsを選んで実行します。

初回実行時はUrlFetchAppとDriveへのアクセス許可を求める画面が出るため、内容を確認して許可します。

実行後、指定フォルダにPDFが1件作成され、明細・小計・消費税・合計が正しく入っているかを目視で確認してください。

Step4. 明細行数を変えて崩れないか確認する

明細が1行だけの依頼と、上限に近い件数の依頼の両方でテストし、空の行が正しく消えているか、行数が多いときに印刷範囲からはみ出していないかを確認します。

上限を超える件数の依頼が来た場合はgenerateQuotePdf内のチェックでエラーが発生する仕組みにしてあります。

上限を増やす際は、明細欄の罫線とTEMPLATE_ITEM_MAX_ROWSをセットで直してください。

見積書をテンプレートから自動作成する構築手順の4ステップ。明細欄を固定行数で設計→見積コードを貼り付ける→1件でテスト実行する→明細行数を変えて確認

単価マスタと消費税計算で気をつける点

単価マスタを分けておくと、価格改定のたびに見積書のスクリプトを直す必要がなくなり、マスタの1行を更新するだけで以降の見積書すべてに反映されます。

一方で品目コードの命名がばらつくと、pickItemsForQuoteが単価を引き当てられずエラーになるため、コードの採番ルールは別途決めておく必要があります。

消費税の端数処理は、切り捨て・切り上げ・四捨五入のどれを採用するかで金額が数円変わることがあります。

どの方式を使うかは会計処理上の社内ルールに従うべき部分です。

このスクリプトではMath.floorによる切り捨てをそのまま採用していますが、自社のルールが異なる場合はwriteTaxSummary内の計算式だけを差し替えれば対応できます。

見積が確定した後に請求書を発行する流れについては、以下で扱っています。


見積書作成の自動化について相談してみませんか?

「単価マスタの設計から相談したい」「明細行の上限をどう決めればいいかわからない」「見積から請求までまとめて自動化したい」

少しでもお心当たりがあれば、お気軽にご相談ください。

現在、Google Apps Scriptを使った業務自動化のお悩みをお伺いする 無料の個別相談 を実施しています。


よくある質問

Q. 明細行が15行を超える見積書にはどう対応すればいいですか?

テンプレートの明細欄の罫線を必要な行数まで伸ばし、コード内のTEMPLATE_ITEM_MAX_ROWSを同じ行数に変更してください。

行数を大きく増やす場合は、A4用紙1枚に収まるかを印刷プレビューで確認しておくと安心です。

Q. 消費税率が複数ある品目を扱えますか?

今回のコードは単一税率を前提にしています。

軽減税率など複数の税率を扱う場合は、単価マスタに税率の列を追加し、明細ごとに税額を計算してから合算する形にwriteTaxSummaryを書き換える必要があります。

Q. 単価マスタにない品目コードを指定するとどうなりますか?

priceMap[row[DETAIL_COL.itemCode]]が見つからず、その次の行でエラーになって処理が止まります。

見積明細を入力する担当者が品目コードを直接打たず、単価マスタのプルダウンから選ぶ形にしておくと、入力ミスを未然に防げます。