/** * 見本:一覧シートから請求書PDFを一括作成し、Googleドライブに保存する(Kaname / AI制作) * * 前提 * - シート「一覧」:A=請求先, B=メール, C=品目, D=数量, E=単価, F=請求日(このシートは読むだけで、書き換えません) * - シート「テンプレ」:B3=請求先, B4=請求日, B7=品目, C7=数量, D7=単価, E7=金額, E9=小計, E10=消費税, E11=合計 * - SETTINGS.folderId に保存先フォルダのIDを入れる * * 動作 * - 結果は別シート「処理記録」に1行ずつ残します(行番号・請求先・結果・ファイル名・日時)。 * 「作成済」と記録された行は次回から飛ばすので、二重作成されません。 * - 数量・単価が数値でない行は「エラー」と記録してスキップ(他の行は処理を続けます)。 * 一覧を直して再実行すれば、エラーだった行だけ作り直します。 * - メニュー「試し実行」は、PDFも記録も作らず、処理する件数と最初の3件の計算結果だけを表示します。 * - 請求日が空欄なら実行日を使います。 */ const SETTINGS = { folderId: 'ここに保存先フォルダID', taxRate: 0.10, logSheetName: '処理記録', tz: 'Asia/Tokyo', }; function onOpen() { SpreadsheetApp.getUi().createMenu('請求書') .addItem('試し実行(作成しない)', 'previewInvoices') .addItem('未作成の請求書をPDFにする', 'createInvoices') .addToUi(); } /** 試し実行:何も作らず、対象件数と最初の3件の計算結果を表示する */ function previewInvoices() { const ss = SpreadsheetApp.getActive(); const done = loadDoneRows_(ss.getSheetByName(SETTINGS.logSheetName)); const targets = collectTargets_(ss.getSheetByName('一覧'), done); const ok = targets.filter(t => !t.error); const lines = ok.slice(0, 3).map(t => `${t.row}行目 ${t.client}:小計 ${t.amount} / 税 ${t.tax} / 合計 ${t.amount + t.tax}(${t.fileName})`); const bad = targets.filter(t => t.error).map(t => `${t.row}行目 ${t.client}:${t.error}`); const msg = `【試し実行】作成予定 ${ok.length}件` + (lines.length ? '\n' + lines.join('\n') : '') + (bad.length ? `\nエラーになる行 ${bad.length}件:\n` + bad.join('\n') : ''); SpreadsheetApp.getUi().alert(msg); return { planned: ok.length, errors: bad.length }; } function createInvoices() { const ss = SpreadsheetApp.getActive(); const tpl = ss.getSheetByName('テンプレ'); const folder = DriveApp.getFolderById(SETTINGS.folderId); const log = ss.getSheetByName(SETTINGS.logSheetName) || createLogSheet_(ss); const targets = collectTargets_(ss.getSheetByName('一覧'), loadDoneRows_(log)); const now = () => Utilities.formatDate(new Date(), SETTINGS.tz, 'yyyy/MM/dd HH:mm'); let made = 0, failed = 0; targets.forEach(t => { try { // 1行の失敗で全体を止めず、処理記録にエラーを残す(一覧を直して再実行すれば再処理) if (t.error) throw new Error(t.error); tpl.getRange('B3').setValue(t.client); tpl.getRange('B4').setValue(t.billDate); tpl.getRange('B7:E7').setValues([[t.item, t.qty, t.price, t.amount]]); tpl.getRange('E9:E11').setValues([[t.amount], [t.tax], [t.amount + t.tax]]); SpreadsheetApp.flush(); folder.createFile(exportSheetAsPdf_(ss.getId(), tpl.getSheetId()).setName(t.fileName)); log.appendRow([t.row, t.client, '作成済', t.fileName, now()]); made++; } catch (e) { log.appendRow([t.row, t.client, 'エラー: ' + e.message, '', now()]); failed++; } }); SpreadsheetApp.getUi().alert(`${made}件の請求書を作成しました。` + (failed ? `(エラー ${failed}件:「${SETTINGS.logSheetName}」シートを確認してください)` : '')); return { made, failed }; } /** 一覧から、まだ作成済みでない行を読み、金額とファイル名を計算する(シートには書き込まない) */ function collectTargets_(list, done) { const rows = list.getDataRange().getValues(); const out = []; for (let i = 1; i < rows.length; i++) { const [client, , item, qty, price, date] = rows[i]; const row = i + 1; if (!client || done[row + '\u0001' + client]) continue; const t = { row, client, item }; const q = Number(qty), p = Number(price); const billDate = date ? new Date(date) : new Date(); if (qty === '' || price === '' || !isFinite(q) || !isFinite(p)) t.error = '数量・単価が数値ではありません'; else if (isNaN(billDate.getTime())) t.error = '請求日を日付として読めません'; else { t.qty = q; t.price = p; t.billDate = billDate; t.amount = Math.round(q * p); t.tax = Math.floor(t.amount * SETTINGS.taxRate); t.fileName = `請求書_${client}_${Utilities.formatDate(billDate, SETTINGS.tz, 'yyyyMMdd')}.pdf`; } out.push(t); } return out; } /** 処理記録から「作成済」の行(行番号+請求先)を集める */ function loadDoneRows_(log) { const done = {}; if (!log || log.getLastRow() < 2) return done; log.getRange(2, 1, log.getLastRow() - 1, 3).getValues().forEach(([row, client, result]) => { if (String(result).indexOf('作成済') === 0) done[row + '\u0001' + client] = true; }); return done; } function createLogSheet_(ss) { const log = ss.insertSheet(SETTINGS.logSheetName); log.appendRow(['一覧の行', '請求先', '結果', 'ファイル名', '日時']); return log; } function exportSheetAsPdf_(ssId, sheetId) { const url = `https://docs.google.com/spreadsheets/d/${ssId}/export?format=pdf&gid=${sheetId}` + '&size=A4&portrait=true&fitw=true&gridlines=false&printtitle=false&sheetnames=false'; const res = UrlFetchApp.fetch(url, { headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() } }); return res.getBlob(); }