脱Excel!GASで顧客マスタ連携!フォームから見積先を自動登録&プルダウン更新(第5回)

GASで業務自動化

スプレッドシートの顧客マスタとGoogleフォームをつなげて、「1回目は手入力 → 2回目以降はプルダウンで選ぶだけ」の見積フローを作ります。


はじめに:これまでの「第1〜4回」

このシリーズでは、Excelだけでがんばっていたバックオフィス業務を、
「スマホ → Googleフォーム → 見積PDF」という流れに載せ替えるまでを、一歩ずつ進めてきました。

  • 第1回:フォーム送信で見積テンプレをコピーして、見積書ドラフトを自動作成
  • 第2回:見積No(2025xx-0001形式)の自動採番と、回答シートへの書き戻し
  • 第3回:見積PDFを自動作成して、メールに添付して送信
  • 第4回:明細行を「複数行」に対応(品名1〜5、数量・単価つき)

🔗 内部リンク

今回はその「第5回」です。


第5回のゴールと学べること

今回やること

この回で目指すのは、次の2つだけです。

  1. Googleフォームの回答から、新規顧客を顧客マスタに自動登録する
  2. 顧客マスタの内容をもとに、フォームの
    「既存顧客(リストから選択)」プルダウンを自動更新する

営業担当から見える世界はこう変わります。

  • 初回の見積依頼
    → 「新規顧客名」を入力して送信すると、顧客マスタに自動登録される
  • 2回目以降の見積依頼
    → 「既存顧客(リストから選択)」から会社名を選ぶだけ

学べるポイント

  • フォーム回答のタイトルを trim() しながら読むテクニック
  • 顧客マスタに「なければ追加」するシンプルな関数
  • 顧客マスタ → フォームのプルダウンを スクリプトから自動更新 する方法
  • 4回までで作った「見積PDF自動化」に、
    無理なく顧客マスタ機能を足す設計の考え方

全体像:処理の流れを確認

処理の流れはシンプルです。

  1. フォーム送信トリガーで onFormSubmit(e) が動く
  2. parseAnswers(e) で回答を読みやすい形(オブジェクト)に変換
  3. nextEstimateNo_() で見積Noを採番
  4. createEstimateFromTemplate(ans)
    • 見積テンプレをコピー
    • 値を差し込み
    • PDFを作成
    • ✅ 顧客マスタへの登録&顧客情報の読み込み
  5. writeBackRow_() で回答シートに見積No・URLを書き戻し
  6. sendEstimateEmails_() でPDFをメール送信
  7. logSend_() で送信ログを残す
  8. ✅ 顧客マスタ更新時に、syncCustomerChoices_() でフォームの既存顧客プルダウンを更新

事前準備①:顧客マスタシートをつくる

見積テンプレを置いているスプレッドシートに、
「顧客マスタ」という名前のシートを1枚追加します。

顧客マスタのヘッダ行(1行目)

A列B列C列D列E列
会社名郵便番号住所1住所2TEL

※ 今回の最小実装では 会社名だけ使えればOK ですが、
将来「請求書で住所やTELも出したい」となったときのために、
あらかじめ列を作っておくと育てやすいです。

サンプル行をいくつか入れておく

最初にざっくりこれくらい入れておくと、フォームのプルダウンがいい感じになります。

会社名郵便番号住所1住所2TEL
よりみち工業株式会社123-4567東京都XXX区○○1-2-3第1よりみちビル4F03-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 => ({
    '&':'&amp;',
    '<':'&lt;',
    '>':'&gt;',
    '"':'&quot;',
    '\'':'&#39;'
  }[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);
}

動作確認チェックリスト

  1. テンプレシートに必要な「名前付き範囲」があるか
    • EST_宛名, EST_件名, EST_見積日, EST_納期, EST_備考, EST_担当者
    • EST_明細範囲, EST_合計
  2. 「顧客マスタ」シートが存在しているか(無ければ自動作成される)
  3. フォームに以下の質問があるか
    • 既存顧客(リストから選択)
    • 新規顧客名(初回のみ入力)
    • お客様メール(必須)
    • 担当者メール(必須)
  4. GAS側で
    • TEMPLATE_FILE_ID
    • OUTPUT_FOLDER_ID
    • FORM_ID
      を自分の環境に書き換えたか
  5. 「トリガー」で onFormSubmit をフォーム送信時に設定したか

ここまで整っていれば、
「新規顧客でフォーム送信」 → 「顧客マスタに1行追加」 → 「プルダウンに反映」
という流れまで一気に到達できます。


まとめ:最小実装のまま、じわっと“実務寄り”に

第5回では、これまでの見積自動化フローに、顧客マスタ連携を足しました。

  • 新規顧客は自動で顧客マスタに登録
  • 顧客マスタの内容はフォームのプルダウンに自動反映
  • 「最小実装+実務でちゃんと使えるライン」に戻した

というのが今回の着地です。

コメント

タイトルとURLをコピーしました