前回は「フォーム送信→テンプレ複製→PDF化→URL書き戻し」まで最小実装しました。
でも後から探すとき、ファイル名や並びがバラバラ…これは積もると地味につらい。
そこで今回は、見積No(例:202510-0003)を自動採番して
- テンプレ(コピー側)へ差し込み
- PDFファイル名にも刻印
- 回答行にも書き戻し
まで、差分パッチだけで仕上げます。
(番号は月ごとリセット、LockServiceで同時実行の重複防止もOK)
完成イメージ
- 見積No:
202510-0003(Asia/Tokyo、月別カウント) - PDF名:
見積_202510-0003_案件名_20251025_1530.pdf - テンプレの名前付き範囲
EST_見積Noにも反映 - 回答シートの「見積No」列に書き戻し
事前準備(1分)
- 見積テンプレ(スプレッドシート)の名前付き範囲に
EST_見積Noを作成(タイトル近辺に配置がおすすめ) - 回答シートの1行目ヘッダーに「見積No」(任意。無ければコードで自動作成)
- 既存コードはそのまま。差分だけ入れます
差分パッチ(コピペOK)
/** ===== 設定(置き換え必須) ===== */
const TEMPLATE_FILE_ID = 'YOUR_TEMPLATE_SPREADSHEET_ID'; // 見積テンプレ
const OUTPUT_FOLDER_ID = 'YOUR_OUTPUT_FOLDER_ID'; // 生成物保存フォルダ
const FORM_SHEET_NAME = 'フォームの回答 1'; // 回答シート名を合わせる
const COL_ESTIMATE_URL = '見積ファイルURL';
const COL_PDF_URL = 'PDFダウンロードURL';
/** フォーム質問タイトル → キー(タイトルはフォームと一致させる) */
const FORM_KEYS = {
'宛名': 'atena',
'件名': 'kenmei',
'見積日(yyyy/MM/dd)': 'date',
'納期(yyyy/MM/dd)': 'exp',
'品名': 'item1',
'数量': 'qty1',
'単価': 'price1',
'備考': 'notes',
'担当者': 'tanto', //←追加
};
/** 名前付き範囲(テンプレ側) */
const NR = {
atena: 'EST_宛名',
kenmei: 'EST_件名',
date: 'EST_見積日',
exp: 'EST_納期',
item1: 'EST_品名1',
qty1: 'EST_数量1',
price1: 'EST_単価1',
notes: 'EST_備考',
tanto: 'EST_担当者', //←追加 名前付き範囲名で
};
/** ===== 1) フォーム送信トリガー =====
* スクリプトエディタ → トリガー → 「onFormSubmit」を"フォーム送信時"で登録
*/
function onFormSubmit(e) {
const ans = parseAnswers(e);
const { ssUrl, pdfUrl } = createEstimateFromTemplate(ans);
writeBackUrls_(e, ssUrl, pdfUrl);
}
/** ===== 2) 回答パース ===== */
function parseAnswers(e) {
const nv = e.namedValues || {};
const g = (title) => (nv[title] ? String(nv[title][0]).trim() : '');
const toNum = (v) => Number(String(v).replace(/[, ]/g, '')) || 0;
return {
atena: g('宛名'),
kenmei: g('件名'),
date: Utilities.formatDate(new Date(g('見積日')), 'Asia/Tokyo', 'yyyy年M月d日'), // 日付をきれいに
exp: Utilities.formatDate(new Date(g('納期')),'Asia/Tokyo','yyyy年M月d日'), // 日付をきれいに
item1: g('品名'),
qty1: toNum(g('数量')),
price1: toNum(g('単価')),
notes: g('備考'),
tanto: g('担当者'), //←追加
};
}
/** ===== 3) 見積生成 ===== */
function createEstimateFromTemplate(ans) {
// テンプレをコピー(ファイル名に日付+件名)
const ymd = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyyMMdd_HHmm');
//削除)const name = `見積_${ans.kenmei || '無題'}_${ymd}`;
//差分) ← 見積No を先頭に刻む(onFormSubmit2 で ans.見積No を入れてから呼ぶこと)
const name = `見積_${ans.見積No}_${ans.kenmei || '無題'}_${ymd}`;
const copyFile = DriveApp.getFileById(TEMPLATE_FILE_ID).makeCopy(name, DriveApp.getFolderById(OUTPUT_FOLDER_ID));
const ss = SpreadsheetApp.openById(copyFile.getId());
// 名前付き範囲へ値を注入
setNR_(ss, NR.atena, ans.atena);
setNR_(ss, NR.kenmei, ans.kenmei);
setNR_(ss, NR.date, ans.date);
setNR_(ss, NR.exp, ans.exp);
setNR_(ss, NR.item1, ans.item1);
setNR_(ss, NR.qty1, ans.qty1);
setNR_(ss, NR.price1, ans.price1);
setNR_(ss, NR.notes, ans.notes);
setNR_(ss, NR.tanto, ans.tanto); //←追加
//差分) ← 見積No をテンプレ(コピー側)の名前付き範囲へ反映
// どちらか好きな方で:NR に登録している場合 or 直接 getRangeByName
if (typeof NR !== 'undefined' && NR.estNo) {
// 例:NR.estNo = 'EST_見積No'
setNR_(ss, NR.estNo, ans.見積No);
} else {
const rNo = ss.getRangeByName('EST_見積No');
if (rNo) rNo.setValue(ans.見積No);
}
SpreadsheetApp.flush();
// === ここで書式をお好み調整:URLエクスポート方式 ===
const sheet = ss.getSheets()[0]; // 1枚目のシート(見積書)を対象に
const sheetId = sheet.getSheetId();
const url = ss.getUrl().replace(/edit$/, '') +
'export?format=pdf' +
'&gid=' + sheetId +
'&range=A1:J32' + // 範囲指定
'&size=A4' + // A4
'&portrait=true' + // タテ向き
'&fitw=true' + // ページ幅に収める
'&scale=4' + // 1ページにフィット
'&gridlines=false' + // 枠線なし
'&sheetnames=false' + // シート名非表示
'&printtitle=false' + // タイトル行/列なし
'&pagenum=UNDEFINED'; // ページ番号非表示
const token = ScriptApp.getOAuthToken();
const response = UrlFetchApp.fetch(url, {
headers: { Authorization: 'Bearer ' + token }
});
//削除)const pdfFile = DriveApp.getFolderById(OUTPUT_FOLDER_ID)
//削除) .createFile(response.getBlob().setName(name + '.pdf'));
//差分) ← PDF Blob にも見積No入りの name を刻印してから保存
const pdfBlob = response.getBlob().setName(name + '.pdf');
const pdfFile = DriveApp.getFolderById(OUTPUT_FOLDER_ID).createFile(pdfBlob);
return { ssUrl: ss.getUrl(), pdfUrl: pdfFile.getUrl() };
}
/** 名前付き範囲にセット(存在しなければ無視:安全側) */
function setNR_(ss, name, value) {
const r = ss.getRangeByName(name);
if (r) r.setValue(value);
}
/** ===== 4) 回答行へURLを書き戻し ===== */
function writeBackUrls_(e, ssUrl, pdfUrl) {
const sheet = e.range.getSheet();
const row = e.range.getRow();
ensureHeader_(sheet, [COL_ESTIMATE_URL, COL_PDF_URL]);
const map = headerMap_(sheet);
sheet.getRange(row, map[COL_ESTIMATE_URL]).setFormula(`=HYPERLINK("${ssUrl}","見積ファイル")`);
sheet.getRange(row, map[COL_PDF_URL]).setFormula(`=HYPERLINK("${pdfUrl}","PDF")`);
}
/** ヘッダ確保&列位置マップ */
function headerMap_(sheet) {
const lastCol = sheet.getLastColumn();
const headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0];
const map = {};
headers.forEach((h, i) => (map[h] = i + 1));
return map;
}
function ensureHeader_(sheet, need) {
const map = headerMap_(sheet);
const adds = need.filter((h) => !map[h]);
if (adds.length === 0) return;
const lastCol = sheet.getLastColumn();
sheet.getRange(1, lastCol + 1, 1, adds.length).setValues([adds]);
}
/**(第2回の差分メインはここから)新オーケストレーター:旧onFormSubmitは残し、トリガーはこっちに付け替える */
function onFormSubmit2(e) {
// 1) 回答パース(既存の関数をそのまま活用)
const ans = parseAnswers(e);
// 2) 見積Noを採番して ans に格納(以降の処理で使えるようにする)
const estNo = nextEstimateNo_();
ans.見積No = estNo;
// 3) 見積生成(テンプレに見積Noを差し込み、PDF名にも反映させる)
// ※ createEstimateFromTemplateの中で、EST_見積Noセット&pdf名刻印を行います(次章の微修正参照)
const { ssUrl, pdfUrl } = createEstimateFromTemplate(ans);
// 4) 回答行に「見積No / PDF URL / 見積ファイルURL」を書き戻す
writeBackRow_(e, { estNo, ssUrl, pdfUrl });
}
/** 回答行への書き戻し(見積No列が無ければ作成) */
function writeBackRow_(e, payload){
const { estNo, ssUrl, pdfUrl } = payload;
const sh = e.range.getSheet();
const row = e.range.getRow();
const colNo = getOrCreateColumnByHeader_(sh, '見積No', true);
const colPdf = getOrCreateColumnByHeader_(sh, 'PDF URL', true);
const colSs = getOrCreateColumnByHeader_(sh, '見積ファイルURL', true);
sh.getRange(row, colNo ).setValue(estNo);
sh.getRange(row, colPdf).setValue(pdfUrl);
sh.getRange(row, colSs ).setValue(ssUrl);
SpreadsheetApp.flush();
}
/** ヘッダ名から列番号を取得(なければ作る) */
function getOrCreateColumnByHeader_(sh, headerName, createIfMissing = false) {
const lastCol = sh.getLastColumn() || 1;
const headers = sh.getRange(1,1,1,lastCol).getValues()[0].map(v => String(v||'').trim());
let idx = headers.findIndex(h => h === headerName);
if (idx === -1 && createIfMissing) {
sh.getRange(1, lastCol + 1).setValue(headerName);
return lastCol + 1;
}
return idx + 1;
}
/** 見積No採番(月次リセット+重複防止) */
function nextEstimateNo_() {
const ym = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyyMM'); // 例: 202510
const key = `ESTNO_${ym}`;
const lock = LockService.getScriptLock();
lock.waitLock(10 * 1000);
try {
const props = PropertiesService.getScriptProperties();
const cur = parseInt(props.getProperty(key) || '0', 10) || 0;
const next = cur + 1;
props.setProperty(key, String(next));
return `${ym}-${String(next).padStart(4,'0')}`; // 202510-0001
} finally {
lock.releaseLock();
}
}
/** ファイル名に使えない文字を無害化(必要なら既存のsafe_を使ってOK) */
function safe_(s){
return String(s||'').trim()
.replace(/[\\/:*?"<>|#\[\]\n\r]+/g,' ')
.substring(0,80);
}
つまずき対策(1分で解決)
- トリガーの付け替え
スクリプトエディタ → トリガー →
実行関数:onFormSubmit2 / イベント:フォーム送信時 - 差分メインは関数の外に(トップレベル)に追加
- 旧
onFormSubmitは残してOK - 既存
createEstimateFromTemplate(ans)に「ちょい足し」
コピー側のテンプレに見積Noを差し込み → flush → PDF名にも刻印 - PDFに見積Noが載らない
→ テンプレ本体ではなくコピー側に書いているか
そして「SpreadsheetApp.flush()→ PDF生成」の順になっているか
動作チェック
- フォームから1件送信
- 回答行に 見積No / PDF URL / 見積ファイルURL が入る
- 出力フォルダに
見積_YYYYMM-XXXX_件名_yyyyMMdd_HHmm.pdfができる - PDF内の指定位置に見積Noが表示されていれば合格
まとめ(次回予告)
- 差分だけで「見積Noの自動採番」が完成
- 月次リセット+重複防止で実務運用レベルに
- PDF・テンプレ・回答行の三位一体で検索性・整合性アップ
第3回「見積PDFを自動メール送信」では、
- お客様宛にPDF添付で送付
- 担当者に内部通知
- 送信ログを回答シートに記録
まで一気に仕上げます。ここまで来たら“DXの背中”が見えてきますよ‼


コメント