スプレッドシートの顧客マスタとGoogleフォームをつなげて、「1回目は手入力 → 2回目以降はプルダウンで選ぶだけ」の見積フローを作ります。
はじめに:これまでの「第1〜4回」
このシリーズでは、Excelだけでがんばっていたバックオフィス業務を、
「スマホ → Googleフォーム → 見積PDF」という流れに載せ替えるまでを、一歩ずつ進めてきました。
- 第1回:フォーム送信で見積テンプレをコピーして、見積書ドラフトを自動作成
- 第2回:見積No(2025xx-0001形式)の自動採番と、回答シートへの書き戻し
- 第3回:見積PDFを自動作成して、メールに添付して送信
- 第4回:明細行を「複数行」に対応(品名1〜5、数量・単価つき)
🔗 内部リンク
今回はその「第5回」です。
第5回のゴールと学べること
今回やること
この回で目指すのは、次の2つだけです。
- Googleフォームの回答から、新規顧客を顧客マスタに自動登録する
- 顧客マスタの内容をもとに、フォームの
「既存顧客(リストから選択)」プルダウンを自動更新する
営業担当から見える世界はこう変わります。
- 初回の見積依頼
→ 「新規顧客名」を入力して送信すると、顧客マスタに自動登録される - 2回目以降の見積依頼
→ 「既存顧客(リストから選択)」から会社名を選ぶだけ
学べるポイント
- フォーム回答のタイトルを
trim()しながら読むテクニック - 顧客マスタに「なければ追加」するシンプルな関数
- 顧客マスタ → フォームのプルダウンを スクリプトから自動更新 する方法
- 4回までで作った「見積PDF自動化」に、
無理なく顧客マスタ機能を足す設計の考え方✅
全体像:処理の流れを確認
処理の流れはシンプルです。
- フォーム送信トリガーで
onFormSubmit(e)が動く parseAnswers(e)で回答を読みやすい形(オブジェクト)に変換nextEstimateNo_()で見積Noを採番createEstimateFromTemplate(ans)で- 見積テンプレをコピー
- 値を差し込み
- PDFを作成
- ✅ 顧客マスタへの登録&顧客情報の読み込み
writeBackRow_()で回答シートに見積No・URLを書き戻しsendEstimateEmails_()でPDFをメール送信logSend_()で送信ログを残す- ✅ 顧客マスタ更新時に、
syncCustomerChoices_()でフォームの既存顧客プルダウンを更新
事前準備①:顧客マスタシートをつくる
見積テンプレを置いているスプレッドシートに、
「顧客マスタ」という名前のシートを1枚追加します。
顧客マスタのヘッダ行(1行目)
| A列 | B列 | C列 | D列 | E列 |
|---|---|---|---|---|
| 会社名 | 郵便番号 | 住所1 | 住所2 | TEL |
※ 今回の最小実装では 会社名だけ使えればOK ですが、
将来「請求書で住所やTELも出したい」となったときのために、
あらかじめ列を作っておくと育てやすいです。
サンプル行をいくつか入れておく
最初にざっくりこれくらい入れておくと、フォームのプルダウンがいい感じになります。
| 会社名 | 郵便番号 | 住所1 | 住所2 | TEL |
|---|---|---|---|---|
| よりみち工業株式会社 | 123-4567 | 東京都XXX区○○1-2-3 | 第1よりみちビル4F | 03-1234-5678 |
| くぼみ商事株式会社 | 980-0000 | 宮城県△△市〇〇区○○ | 022-000-0000 | |
| ミチコンサル株式会社 | 016-0000 | 秋田県□□市○○ | 0185-00-0000 | |
| ぼっちプラス株式会社 | 020-0000 | 岩手県○○郡** | 019-000-0000 |
住所・TELは今は使わなくてもOKです。
請求書や顧客別売上集計に広げたくなったときに効いてきます。
事前準備②:フォームの項目をそろえる
顧客まわりで使うフォームの質問は、最低これだけあればOKです。
- 既存顧客(リストから選択)(プルダウン)
- 新規顧客名(初回のみ入力)(テキスト)
- お客様メール(メールアドレス・必須)
- 担当者メール(メールアドレス・必須)
その他、第4回までで使っている項目:
- 件名
- 見積日(日時)
- 納期(日時)
- 品目数
- 品名1〜5、数量1〜5、単価1〜5
- 備考
- 担当者
※ フォームの質問タイトルと、スクリプト内で指定しているタイトルは、
スペースも含めて一致している必要があります。
今回のコードでは、タイトル側も trim() して探すようにしてあるので、
お尻のスペース問題もかなり吸収できます。
スクリプト全体
✅ このコードをそのままGASエディタに貼り付けて、
- テンプレID
- 出力フォルダID
- フォームID
を自分の環境に合わせて書き換えてください。
/***** 見積PDF自動作成スクリプト(第5回:顧客マスタ連携版) *********
*
* ■このスクリプトでできること
* 1) Googleフォームが送信されたら回答を読み込む
* 2) 見積Noを自動採番(例:202512-0001)
* 3) 見積テンプレート(スプレッドシート)をコピー
* 4) 宛名/件名/日付/納期/備考/担当者/明細/合計 を差し込む
* 5) PDFに変換してGoogleドライブに保存
* 6) 回答行に「見積No/PDF URL/見積ファイルURL」を書き戻す
* 7) PDFをメールで「お客様」「担当者」に自動送信
* 8) 送信結果を「送信履歴」シートに1行追記
* 9) 顧客マスタに「新規顧客」を自動登録し、フォームの既存顧客リストも更新
*
*****************************************************************************/
/** ===== 0. 環境設定(ここだけ自分の環境に合わせて変更) ===== */
// ▼見積テンプレートのスプレッドシートID
const TEMPLATE_FILE_ID = 'YOUR_TEMPLATE_ID_HERE';
// ▼作成した見積ファイル&PDFを保存するフォルダID
const OUTPUT_FOLDER_ID = 'YOUR_OUTPUT_FOLDER_ID_HERE';
// ▼GoogleフォームのIDと、既存顧客リスト項目のタイトル
const FORM_ID = 'YOUR_FORM_ID_HERE';
const EXISTING_CUSTOMER_ITEM_TITLE = '既存顧客(リストから選択)';
// ▼見積書の「明細」行の最大数
const MAX_ITEMS = 5;
// ▼明細の列の意味(テンプレ側の列順と合わせる)
const DETAIL_COLS = ['品名', '数量', '単価', '金額'];
// ▼メール送信ログを記録するシート名
const LOG_SHEET_NAME = '送信履歴';
// ▼顧客マスタの設定
const CUSTOMER_SHEET_NAME = '顧客マスタ'; // 顧客マスタのシート名
const CUSTOMER_KEY_HEADER = '会社名'; // キーとして使うヘッダ名(会社名)
/** テンプレ側で作っておく「名前付き範囲」 */
const NR = {
atena: 'EST_宛名',
kenmei: 'EST_件名',
date: 'EST_見積日',
exp: 'EST_納期',
notes: 'EST_備考',
tanto: 'EST_担当者',
estNo: 'EST_見積No',
detailRange: 'EST_明細範囲',
total: 'EST_合計',
// 顧客マスタから拾って見積書に表示したい場合(任意)
customerName: 'EST_会社名',
zip: 'EST_郵便番号',
address1: 'EST_住所1',
address2: 'EST_住所2',
tel: 'EST_TEL'
};
/** ===== 1. メイン関数:フォーム送信時に動く処理 ===== */
function onFormSubmit(e) {
// 1) フォーム回答を扱いやすい形に変換
const ans = parseAnswers(e);
// 2) 見積Noを採番
const estNo = nextEstimateNo_(); // 例:202512-0001
ans.見積No = estNo;
// 3) 見積テンプレをコピー → 差し込み → PDF化
const { ssUrl, pdfUrl, pdfFileId } = createEstimateFromTemplate(ans);
// 4) 回答行に「見積No/URL」を書き戻す
writeBackRow_(e, { estNo, ssUrl, pdfUrl });
// 5) PDFをメール送信
const results = sendEstimateEmails_(ans, { ssUrl, pdfUrl, pdfFileId });
// 6) 送信結果をログに記録
results.forEach(r => logSend_({
kind: r.kind,
status: r.ok ? 'SUCCESS' : 'ERROR',
to: r.to,
subject:r.subject,
estNo,
pdfUrl,
error: r.ok ? '' : (r.errorMessage || '')
}));
}
/** ===== 2. 回答パース関数:フォームの回答をオブジェクト化 ===== */
function parseAnswers(e) {
const nv = (e && e.namedValues) ? e.namedValues : {};
// フォームのタイトル側も trim して探す安全版 g()
const titles = Object.keys(nv);
const g = (title) => {
const key = titles.find(k => k.trim() === String(title).trim());
if (!key) return '';
return nv[key] ? String(nv[key][0]).trim() : '';
};
const toNum = (v) => Number(String(v).replace(/[, ]/g, '')) || 0;
const fmtDate = (s) =>
s ? Utilities.formatDate(new Date(s), 'Asia/Tokyo', 'yyyy年M月d日') : '';
// 顧客関連
const existingCustomer = g('既存顧客(リストから選択)');
const newCustomer = g('新規顧客名(初回のみ入力)');
// 顧客マスタで検索/登録に使うキー
const customerKey = existingCustomer || newCustomer;
// 見積書やメールの宛名
const atena = newCustomer || existingCustomer;
// 基本項目
const kenmei = g('件名');
const date = fmtDate(g('見積日'));
const exp = fmtDate(g('納期'));
const notes = g('備考');
const tanto = g('担当者');
// メール宛先
const customerEmail = g('お客様メール');
const staffEmail = g('担当者メール');
// 品目数(1〜MAX_ITEMS に丸め)
const rawCount = toNum(g('品目数'));
const itemCount = Math.max(1, Math.min(MAX_ITEMS, rawCount || 1));
// 明細行(品名1〜5/数量1〜5/単価1〜5)
const rows = Array.from({ length: MAX_ITEMS }, (_, i) => {
const n = i + 1;
const name = g(`品名${n}`);
const qty = toNum(g(`数量${n}`));
const price = toNum(g(`単価${n}`));
const amount = qty * price;
return { name, qty, price, amount };
}).filter(r => r.name && (r.qty || r.price));
// 全部空でも最低1行は確保
const normalized =
(rows && rows.length > 0)
? rows.slice(0, itemCount)
: [{ name:'', qty:0, price:0, amount:0 }];
// シート貼り付け用テーブル [[品名,数量,単価,金額], ...]
const table = normalized.map(r => [r.name, r.qty, r.price, r.amount]);
while (table.length < MAX_ITEMS) table.push(['','','','']);
// 合計金額
const total = normalized.reduce((sum, r) => sum + (r.amount || 0), 0);
return {
customerKey,
atena,
kenmei,
date,
exp,
notes,
tanto,
customerEmail,
staffEmail,
itemCount,
itemsTable: table,
total
};
}
/** ===== 3. 見積生成:テンプレコピー → 差し込み → PDF化 ===== */
function createEstimateFromTemplate(ans) {
const now = new Date();
const ymd = Utilities.formatDate(now, 'Asia/Tokyo', 'yyyyMMdd_HHmm');
const safeKenmei = safe_(ans.kenmei || '無題');
const name = `見積_${ans.見積No}_${safeKenmei}_${ymd}`;
// テンプレートスプレッドシートをコピー
const copyFile = DriveApp.getFileById(TEMPLATE_FILE_ID)
.makeCopy(name, DriveApp.getFolderById(OUTPUT_FOLDER_ID));
const ss = SpreadsheetApp.openById(copyFile.getId());
// 顧客マスタから既存顧客を探す(なければ null)
let customer = findCustomer_(ans.customerKey);
// 見つからなければ顧客マスタに追加(新規顧客を自動登録)
if (!customer && ans.customerKey) {
addCustomerIfNotExists_(ans.customerKey);
customer = findCustomer_(ans.customerKey); // 住所など使いたくなったとき用
}
// 名前付き範囲に値をセット
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.notes, ans.notes);
setNR_(ss, NR.tanto, ans.tanto);
if (NR.estNo) setNR_(ss, NR.estNo, ans.見積No);
// 顧客マスタの情報を見積に流し込みたい場合(任意)
if (customer) {
if (NR.customerName) setNR_(ss, NR.customerName, customer.name);
if (NR.zip) setNR_(ss, NR.zip, customer.zip);
if (NR.address1) setNR_(ss, NR.address1, customer.address1);
if (NR.address2) setNR_(ss, NR.address2, customer.address2);
if (NR.tel) setNR_(ss, NR.tel, customer.tel);
}
// 明細テーブル&合計
writeDetails_(ss, ans.itemsTable);
if (NR.total) setNR_(ss, NR.total, ans.total);
SpreadsheetApp.flush();
// 1枚目のシートをPDFとしてエクスポート
const sheet = ss.getSheets()[0];
const sheetId = sheet.getSheetId();
const url = ss.getUrl().replace(/edit$/, '') +
'export?format=pdf' +
'&gid=' + sheetId +
'&range=A1:J32' +
'&size=A4' +
'&portrait=true' +
'&fitw=true' +
'&scale=4' +
'&gridlines=false' +
'&sheetnames=false' +
'&printtitle=false' +
'&pagenum=UNDEFINED';
const token = ScriptApp.getOAuthToken();
const response = UrlFetchApp.fetch(url, {
headers: { Authorization: 'Bearer ' + token }
});
const pdfBlob = response.getBlob().setName(`${name}.pdf`);
const pdfFile = DriveApp.getFolderById(OUTPUT_FOLDER_ID).createFile(pdfBlob);
return {
ssUrl: ss.getUrl(),
pdfUrl: pdfFile.getUrl(),
pdfFileId: pdfFile.getId()
};
}
/** 明細範囲にテーブルを一括で書き込む */
function writeDetails_(ss, table) {
if (!NR.detailRange) return;
const r = ss.getRangeByName(NR.detailRange);
if (!r) return;
const rows = r.getNumRows();
const cols = r.getNumColumns();
if (cols < DETAIL_COLS.length) {
throw new Error(`EST_明細範囲の列数が不足:期待${DETAIL_COLS.length}列 / 実際${cols}列`);
}
const fixed = table.slice(0, rows).map(row => {
const a = row.slice(0, DETAIL_COLS.length);
while (a.length < DETAIL_COLS.length) a.push('');
return a;
});
while (fixed.length < rows) fixed.push(Array(DETAIL_COLS.length).fill(''));
r.clearContent();
r.offset(0, 0, rows, DETAIL_COLS.length).setValues(fixed);
}
/** 名前付き範囲に値をセット(存在しない場合は無視) */
function setNR_(ss, name, value) {
if (!name) return;
const r = ss.getRangeByName(name);
if (r) r.setValue(value);
}
/** ===== 4. 回答行への書き戻し ===== */
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) {
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;
}
/** ===== 5. 見積No採番(月ごとにリセット) ===== */
function nextEstimateNo_() {
const ym = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyyMM');
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')}`;
} finally {
lock.releaseLock();
}
}
/** ===== 6. メール送信部分 ===== */
function sendEstimateEmails_(ans, refs) {
const { pdfUrl, pdfFileId } = refs;
const pdfBlob = DriveApp.getFileById(pdfFileId).getBlob();
const toCustomer = (ans.customerEmail || '').trim();
const toStaff = String(ans.staffEmail || '').trim();
if (!toStaff) {
throw new Error('担当者メールアドレスが空です(フォームの必須設定を確認してください)');
}
const subjectBase = `【お見積り】${ans.kenmei || ''}(${ans.見積No})`;
const links = { pdfUrl };
const results = [];
if (toCustomer) {
results.push(sendOneMail_({
kind: 'CUSTOMER',
to: toCustomer,
subject: subjectBase,
bodyText: renderCustomerText_(ans, links),
bodyHtml: renderCustomerHtml_(ans, links),
attachments: [pdfBlob]
}));
}
results.push(sendOneMail_({
kind: 'STAFF',
to: toStaff,
subject: `【見積通知】${ans.見積No}/${ans.kenmei || ''}`,
bodyText: renderStaffText_(ans, links),
bodyHtml: renderStaffHtml_(ans, links),
attachments: [pdfBlob]
}));
return results;
}
function sendOneMail_({ kind, to, subject, bodyText, bodyHtml, attachments }) {
try {
GmailApp.sendEmail(to, subject, bodyText, {
htmlBody: bodyHtml,
attachments:attachments,
replyTo: '',
name: '見積自動送信(GAS)'
});
return { kind, to, subject, ok: true };
} catch (err) {
return { kind, to, subject, ok: false, errorMessage: String(err) };
}
}
/** お客様向けメール本文(TEXT版) */
function renderCustomerText_(ans, links) {
return [
`${ans.atena} 様`,
'',
'このたびはお見積りのご依頼ありがとうございます。',
`件名:${ans.kenmei}`,
`見積番号:${ans.見積No}`,
`見積日:${ans.date}/納期:${ans.exp}`,
'',
'PDFを添付しております。内容をご確認ください。',
`(PDFダウンロード)${links.pdfUrl}`,
'',
'ご不明点がありましたら本メールにご返信ください。',
`担当:${ans.tanto}`,
''
].join('\n');
}
/** お客様向けメール本文(HTML版) */
function renderCustomerHtml_(ans, links) {
return `
<p>${escapeHtml_(ans.atena)} 様</p>
<p>このたびはお見積りのご依頼ありがとうございます。</p>
<ul>
<li>件名:${escapeHtml_(ans.kenmei)}</li>
<li>見積番号:${escapeHtml_(ans.見積No)}</li>
<li>見積日:${escapeHtml_(ans.date)} / 納期:${escapeHtml_(ans.exp)}</li>
</ul>
<p>PDFを添付しております。内容をご確認ください。</p>
<p>
<a href="${links.pdfUrl}">PDFダウンロード</a>
</p>
<p>ご不明点がありましたら本メールにご返信ください。<br>
担当:${escapeHtml_(ans.tanto)}</p>
`;
}
/** 担当者向けメール本文(TEXT版) */
function renderStaffText_(ans, links) {
return [
'【見積通知(自動)】',
`見積番号:${ans.見積No}`,
`件名:${ans.kenmei}`,
`宛名:${ans.atena}`,
`担当:${ans.tanto}`,
`見積日:${ans.date}/納期:${ans.exp}`,
'',
`PDF:${links.pdfUrl}`,
''
].join('\n');
}
/** 担当者向けメール本文(HTML版) */
function renderStaffHtml_(ans, links) {
return `
<p><strong>【見積通知(自動)】</strong></p>
<ul>
<li>見積番号:${escapeHtml_(ans.見積No)}</li>
<li>件名:${escapeHtml_(ans.kenmei)}</li>
<li>宛名:${escapeHtml_(ans.atena)}</li>
<li>担当:${escapeHtml_(ans.tanto)}</li>
<li>見積日:${escapeHtml_(ans.date)} / 納期:${escapeHtml_(ans.exp)}</li>
</ul>
<p><a href="${links.pdfUrl}">PDF</a></p>
`;
}
/** ===== 7. ログ&ユーティリティ ===== */
function logSend_({ kind, status, to, subject, estNo, pdfUrl, error }) {
const ss = SpreadsheetApp.getActive();
let sh = ss.getSheetByName(LOG_SHEET_NAME);
if (!sh) {
sh = ss.insertSheet(LOG_SHEET_NAME);
sh.appendRow(['日時','種類','ステータス','宛先','件名','見積No','PDF URL','エラー']);
}
sh.appendRow([
Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd HH:mm:ss'),
kind,
status,
to,
subject,
estNo,
pdfUrl,
error
]);
}
/** HTML用の特殊文字をエスケープ */
function escapeHtml_(s) {
return String(s || '').replace(/[&<>"']/g, c => ({
'&':'&',
'<':'<',
'>':'>',
'"':'"',
'\'':'''
}[c]));
}
/** ファイル名に使えない文字を安全な文字に置き換える */
function safe_(s) {
return String(s || '').trim()
.replace(/[\/:\\?"<>|#\[\]\n\r]+/g, ' ')
.substring(0, 80);
}
/** 顧客マスタから1件取得(あれば会社情報を返す) */
function findCustomer_(key) {
key = String(key || '').trim();
if (!key) return null;
const ss = SpreadsheetApp.getActive();
const sh = ss.getSheetByName(CUSTOMER_SHEET_NAME);
if (!sh) return null;
const values = sh.getDataRange().getValues();
if (values.length < 2) return null;
const headers = values[0].map(h => String(h || '').trim());
const idxKey = headers.indexOf(CUSTOMER_KEY_HEADER);
const idxZip = headers.indexOf('郵便番号');
const idxAdr1 = headers.indexOf('住所1');
const idxAdr2 = headers.indexOf('住所2');
const idxTel = headers.indexOf('TEL');
if (idxKey === -1) return null;
const row = values.slice(1).find(r =>
String(r[idxKey] || '').trim() === key
);
if (!row) return null;
return {
name: row[idxKey] || '',
zip: idxZip >= 0 ? (row[idxZip] || '') : '',
address1: idxAdr1 >= 0 ? (row[idxAdr1] || '') : '',
address2: idxAdr2 >= 0 ? (row[idxAdr2] || '') : '',
tel: idxTel >= 0 ? (row[idxTel] || '') : ''
};
}
/** 顧客マスタに「なければ追加」するシンプル関数 */
function addCustomerIfNotExists_(name) {
name = String(name || '').trim();
if (!name) return;
const ss = SpreadsheetApp.getActive();
let sh = ss.getSheetByName(CUSTOMER_SHEET_NAME);
// シートがなければ新規作成+ヘッダ行
if (!sh) {
sh = ss.insertSheet(CUSTOMER_SHEET_NAME);
sh.appendRow(['会社名','郵便番号','住所1','住所2','TEL']);
}
const values = sh.getDataRange().getValues();
const headers = values[0].map(h => String(h || '').trim());
let idxKey = headers.indexOf(CUSTOMER_KEY_HEADER);
if (idxKey === -1) {
idxKey = 0;
sh.getRange(1, 1).setValue(CUSTOMER_KEY_HEADER);
}
const exists = values.slice(1).some(row =>
String(row[idxKey] || '').trim() === name
);
if (exists) return;
const colCount = Math.max(headers.length, 5);
const newRow = new Array(colCount).fill('');
newRow[idxKey] = name;
sh.appendRow(newRow);
// 顧客マスタが増えたのでフォームのプルダウンも更新
syncCustomerChoices_();
}
/** 顧客マスタ → フォーム「既存顧客」プルダウンを同期 */
function syncCustomerChoices_() {
const ss = SpreadsheetApp.getActive();
const sh = ss.getSheetByName(CUSTOMER_SHEET_NAME);
if (!sh) return;
const values = sh.getDataRange().getValues();
if (values.length < 2) return;
const headers = values[0].map(h => String(h || '').trim());
let idxKey = headers.indexOf(CUSTOMER_KEY_HEADER);
if (idxKey === -1) return;
const names = values
.slice(1)
.map(r => String(r[idxKey] || '').trim())
.filter(n => n);
const uniqueNames = [...new Set(names)];
if (uniqueNames.length === 0) return;
const form = FormApp.openById(FORM_ID);
const items = form.getItems(FormApp.ItemType.LIST); // プルダウン項目
const target = items.find(it =>
it.getTitle().trim() === EXISTING_CUSTOMER_ITEM_TITLE.trim()
);
if (!target) return;
const listItem = target.asListItem();
const choices = uniqueNames.map(name => listItem.createChoice(name));
listItem.setChoices(choices);
}
動作確認チェックリスト
- テンプレシートに必要な「名前付き範囲」があるか
EST_宛名,EST_件名,EST_見積日,EST_納期,EST_備考,EST_担当者EST_明細範囲,EST_合計
- 「顧客マスタ」シートが存在しているか(無ければ自動作成される)
- フォームに以下の質問があるか
- 既存顧客(リストから選択)
- 新規顧客名(初回のみ入力)
- お客様メール(必須)
- 担当者メール(必須)
- GAS側で
TEMPLATE_FILE_IDOUTPUT_FOLDER_IDFORM_ID
を自分の環境に書き換えたか
- 「トリガー」で
onFormSubmitをフォーム送信時に設定したか
ここまで整っていれば、
「新規顧客でフォーム送信」 → 「顧客マスタに1行追加」 → 「プルダウンに反映」
という流れまで一気に到達できます。
まとめ:最小実装のまま、じわっと“実務寄り”に
第5回では、これまでの見積自動化フローに、顧客マスタ連携を足しました。
- 新規顧客は自動で顧客マスタに登録
- 顧客マスタの内容はフォームのプルダウンに自動反映
- 「最小実装+実務でちゃんと使えるライン」に戻した
というのが今回の着地です。

コメント