脱Excel!GASで見積Noを自動採番&月次リセット+重複防止(第2回)

仕事

前回は「フォーム送信→テンプレ複製→PDF化→URL書き戻し」まで最小実装しました。
でも後から探すとき、ファイル名や並びがバラバラ…これは積もると地味につらい。

そこで今回は、見積No(例:202510-0003)を自動採番して

  • テンプレ(コピー側)へ差し込み
  • PDFファイル名にも刻印
  • 回答行にも書き戻し

まで、差分パッチだけで仕上げます。
(番号は月ごとリセットLockService同時実行の重複防止もOK)

完成イメージ

  • 見積No:202510-0003(Asia/Tokyo、月別カウント
  • PDF名:見積_202510-0003_案件名_20251025_1530.pdf
  • テンプレの名前付き範囲 EST_見積No にも反映
  • 回答シートの「見積No」列に書き戻し

事前準備(1分)

  1. 見積テンプレ(スプレッドシート)の名前付き範囲
    EST_見積No を作成(タイトル近辺に配置がおすすめ)
  2. 回答シートの1行目ヘッダーに「見積No」(任意。無ければコードで自動作成)
  3. 既存コードはそのまま。差分だけ入れます

差分パッチ(コピペ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を差し込み → flushPDF名にも刻印
  • PDFに見積Noが載らない
    テンプレ本体ではなくコピー側に書いているか
    そして「SpreadsheetApp.flush() → PDF生成」の順になっているか

動作チェック

  1. フォームから1件送信
  2. 回答行に 見積No / PDF URL / 見積ファイルURL が入る
  3. 出力フォルダに 見積_YYYYMM-XXXX_件名_yyyyMMdd_HHmm.pdf ができる
  4. PDF内の指定位置に見積Noが表示されていれば合格

まとめ(次回予告)

  • 差分だけで「見積Noの自動採番」が完成
  • 月次リセット+重複防止で実務運用レベル
  • PDF・テンプレ・回答行の三位一体で検索性・整合性アップ

第3回「見積PDFを自動メール送信」では、

  • お客様宛にPDF添付で送付
  • 担当者に内部通知
  • 送信ログを回答シートに記録
    まで一気に仕上げます。ここまで来たら“DXの背中”が見えてきますよ‼

コメント

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