我如何在 48 小时内,把婚礼请柬业务做到 85% 自动化(0 SaaS、全靠 Google 生态)
这不是一篇“心路历程”,而是一份可以照着搭的实战笔记:我如何用
Google Sheets + Forms + Docs + Drive + Gmail + Apps Script,
在不到 48 小时里,搭出一套适用于婚礼请柬业务的报价 & 下单 & 发票系统,
让业务流程 85% 脱离手动操作——不付一分钱给 SaaS,所有数据都掌握在自己手里。
这篇文章适合:小型婚礼品牌、工作室、freelancer 和已经有一定订单量的个体老板。
我会从整体思路开始,拆开每一个环节,用实际案例和代码展示,
我是怎样把一个“高度定制、变量很多”的实体请柬业务,变成一台可以稳定运转的自动机。
一、这篇文章在解决什么问题?
先说结论:我想解决的不是「怎么发一张发票」,而是:
- 当订单越来越多时,老板不被琐碎信息淹没;
- 助理可以在 不掌握敏感成本 的前提下,独立完成报价和开单;
- 每一个客户,从第一句报价到最后出货,数据都在一个系统里闭环。
以我的婚礼品牌 Xamiya Wedding 为例,现实世界长这样:
- 客制化程度高:信封、纸张、烫金、wax、数量、运送地区… 组合很多;
- 一开始全靠我自己算:翻 Excel、查汇率、对照成本表、写 WhatsApp 报价;
- 忙起来时,容易:报错价、忘记改数量、没记 log、不知道利润有没有被吃掉。
所以我给自己下了一个很狠的任务:
在 48 小时内,用 0 预算,把「报价 → 定金发票 → 尾款发票」做成一套可以交给助理使用的自动化系统。
二、整体架构:一台「婚礼请柬自动机」长什么样?
先给你整张图的感觉,再进细节。
- 前台(助理视角):
- 一个「报价界面」:输入数量、纸张类型、运输方式 → 一键生成 WhatsApp 报价文案(中英)。
- 一个「Final Orders」表:填好最终数量、材质、选项后,勾选「Send Final Invoice PDF」即可发出尾款发票。
- 后台(老板视角):
- 隐藏的
engine_cost_core:所有成本、汇率、目标利润、重量参数都在这里。 - 两个发票模板(Google Docs):订金 & 尾款。
- 三个 Apps Script 入口:
- 订金发票:
onFormSubmit(e) - 订单编号:
onEdit(e)生成Order ID - 尾款发票:
onFinalInvoiceCheckboxEdit(e)+sendFinalInvoicesBatch()
- 订金发票:
- 隐藏的
所有东西都只在 Google 生态里流转:
- 数据:Google Sheets
- 入口:Google Forms
- 文档:Google Docs
- 存档:Google Drive
- 通知:Gmail
- 胶水:Google Apps Script
没有额外 SaaS,没有月费,没有「想导出数据发现被锁在平台里」的焦虑。
三、报价引擎:前台 3 个变量,后台一整套利润模型
先从最前面的环节说起:报价。
大多数人想象中的报价,是这样的:
- Excel 里有一张成本表;
- 助理或老板自己算:印刷 + 运费 + 设计费 + 包装 + 目标利润;
- 算完之后,再手写一段 WhatsApp 报价给客户。
这很耗时间,也很容易出错。我的做法是把它拆成两层:
1. 后台:engine_cost_core(只给自己看的「算法心脏」)
这一张表长得很丑,但很重要。它大致分成两块:
- 固定参数区:单卡重量、包装重量、汇率、印刷单价、每单设计费、本地快递费、不同纸张的加价系数、目标利润率等。
- 计算区:给定数量、纸张类型、运输方式和目标利润率,计算:
- 总成本(印刷 + 国际运费 + 本地快递 + 设计费);
- 加上利润后的订单基础售价;
- 每张单价、每 100 张单价、每 +10 / +20 张的追加金额;
- 对应的中英文 WhatsApp 报价文案。
具体公式可以因人而异,这里给一个极简的伪公式思路(为了脱敏,数值都是示意):
=LET(
qty, $B$3, /* 数量,例如 100 */
fx, Config!B3, /* 汇率 */
printCNY, Config!B4 * qty, /* 印刷成本(人民币) */
intlShipRM, Config!B10, /* 国际运费(RM) */
designRM, Config!B6, /* 设计费 */
localRM, Config!B7, /* 本地快递 + 包装 */
paperExtra, XLOOKUP($B$4,PaperType,ExtraPerCard), /* 纸张加价 */
baseCost, printCNY * fx + intlShipRM + designRM + localRM + paperExtra*qty,
margin, Config!B8, /* 目标利润率,例如 0.8(示意) */
finalPrice, baseCost * (1 + margin),
pricePer100, finalPrice / qty * 100,
{ finalPrice, pricePer100 }
)
真正的表里会更复杂一些,但核心就是:所有复杂计算,集中在这张表做完。
2. 前台:给助理用的「简化版报价界面」
我另外做了一张「报价输入」表,助理只需要:
- 在下拉选项里选:数量、纸张类型、运输方式;
- 就能自动看到:预估总价、每 100 张的价格、每 +10 张 / +20 张的大致加价。
同时,这张表会从后台引用自动生成好的中英文 WhatsApp 文案,例如:
给你一个目前的单卡预估报价:100 张,纸张:Art,运输方式:Standard,
每 100 张大概是 RM 241.20 左右。
这个价格已经包含设计、印刷、国际运费、本地快递和包装成本。
如果数量多于 100 张,大致是每多 10 张加 RM 24.xx(具体我会在你确认下单前再帮你精算一次)。
助理只需要复制粘贴,完全不用知道背后到底怎么计算。
四、订金发票:Google 表单一提交,PDF 自动生成发出
当顾客同意报价、准备下订金时,我会发给对方一个 Google 表单链接,收集:
- 姓名、电话、Email;
- 收货地址;
- 数量、选择的方案、运输方式;
- 我们刚才确认好的价格。
这张表单对应的是一个 Sheets 工作表 Form responses 1,然后由
onFormSubmit(e) 触发脚本:
const SHEET_NAME = 'Form responses 1';
const INVOICE_TEMPLATE_ID = '你的 Google Doc 模板 ID';
const FOLDER_ID = '你的发票 PDF 存放 Folder ID';
function onFormSubmit(e) {
const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
const row = e.range.getRow();
// 1)读当前这一行的所有字段
const dataRange = sheet.getRange(row, 1, 1, sheet.getLastColumn());
const data = dataRange.getValues()[0];
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const record = {};
headers.forEach((h, i) => record[h] = data[i]);
// 2)生成发票 + 发邮件
createInvoiceAndEmail(record);
}
function createInvoiceAndEmail(data) {
const invoiceNo = 'XM-' + new Date().getTime(); // 简单稳妥的流水号
// 1)从模板复制一份新发票 Doc
const docCopy = DriveApp.getFileById(INVOICE_TEMPLATE_ID).makeCopy();
const docId = docCopy.getId();
const body = DocumentApp.openById(docId).getBody();
// 2)替换占位符(按你自己的字段名调整)
body.replaceText('{{DATE}}', new Date().toLocaleDateString());
body.replaceText('{{INVOICE_NO}}', invoiceNo);
body.replaceText('{{NAME}}', data['Name'] || '');
body.replaceText('{{EMAIL}}', data['Email'] || '');
body.replaceText('{{PHONE}}', data['Phone'] || '');
body.replaceText('{{QTY}}', data['Quantity'] || '');
body.replaceText('{{PRICE}}', data['Price'] || '');
body.replaceText('{{ADDRESS}}',data['Full Delivery Address'] || '');
DocumentApp.openById(docId).saveAndClose();
// 3)导出 PDF 存进指定 Folder
const pdfBlob = DriveApp.getFileById(docId).getAs('application/pdf');
const folder = DriveApp.getFolderById(FOLDER_ID);
const pdfFile = folder.createFile(pdfBlob).setName('Invoice - ' + invoiceNo + '.pdf');
// 4)发邮件给客户(可多语言)
const subject = 'Your Xamiya Invoice #' + invoiceNo;
const bodyText =
'Hi ' + (data['Name'] || '') + ',\n\n' +
'Here is your invoice. Please check the attached PDF.\n\n' +
'Thank you!\nXamiya Team';
GmailApp.sendEmail(data['Email'], subject, bodyText, {
attachments: [pdfFile]
});
// 5)清理临时 Doc(只保留 PDF)
DriveApp.getFileById(docId).setTrashed(true);
}
从客户的视角,体验是:
- 填完表单;
- 几秒内收到一封带 PDF 发票的 Email;
- 发票抬头、数量和价格都和刚刚沟通的一致。
从我的视角,整个过程几乎不需要再碰键盘。
五、尾款发票:Final Orders + 勾选一个 checkbox 就完成
订金收完,设计走完、数量定案后,就进入「尾款发票」阶段。
这里我用的是另一张表:Final Orders,字段包含:
- Order ID(自动生成);
- Name / Email / Final Quantity;
- Paper Type / Envelope Type / Sticker / Foil 等选项;
- Price per set / Balance Due;
- Send Final Invoice PDF(勾选用的 checkbox)。
1. 自动生成订单号:onEdit(e)
当我们开始填写某一行订单信息时,脚本会自动在第一列生成订单号:
function onEdit(e) {
const sheet = e.source.getActiveSheet();
if (sheet.getName() !== 'Final Orders') return;
const row = e.range.getRow();
if (row === 1) return; // 表头不处理
const orderIdCell = sheet.getRange(row, 1); // A 列
if (orderIdCell.getValue()) return; // 已有订单号就不重复生成
const name = sheet.getRange(row, 2).getValue(); // Name
const email = sheet.getRange(row, 3).getValue(); // Email
const qty = sheet.getRange(row, 4).getValue(); // Final Quantity
if (!name && !email && !qty) return; // 空行不生成 ID
const ts = Utilities.formatDate(
new Date(),
Session.getScriptTimeZone(),
'yyyyMMdd-HHmmss'
);
const newId = 'XM-' + ts + '-' + row;
orderIdCell.setValue(newId);
}
这样每一单都有唯一订单号,方便对账和存档。
2. 勾选 checkbox,自动生成尾款发票 PDF 并发送
核心逻辑拆成两个函数:
createFinalInvoicePDF(order):给定一行订单数据,生成 PDF 并发邮件。onFinalInvoiceCheckboxEdit(e):当某一行的「Send Final Invoice PDF」从 FALSE → TRUE 时,调用上面的函数。
const FINAL_SHEET_NAME = 'Final Orders';
const FINAL_INVOICE_TEMPLATE_ID = '你的尾款发票 Doc 模板 ID';
const FINAL_FOLDER_ID = '你的尾款 PDF Folder ID';
function createFinalInvoicePDF(order) {
if (!order || !order.email) return;
const templateFile = DriveApp.getFileById(FINAL_INVOICE_TEMPLATE_ID);
const copy = templateFile.makeCopy('Final Invoice - ' + (order.orderId || 'NO-ID'));
const copyId = copy.getId();
const doc = DocumentApp.openById(copyId);
const body = doc.getBody();
const today = Utilities.formatDate(
new Date(),
Session.getScriptTimeZone(),
'yyyy-MM-dd'
);
const invoiceNo = String(order.orderId || '');
// 替换占位符(按你的 Doc 模板来)
body.replaceText('{{DATE}}', today);
body.replaceText('{{ORDER_ID}}', invoiceNo);
body.replaceText('{{INVOICE_NO}}', invoiceNo);
body.replaceText('{{NAME}}', String(order.name || ''));
body.replaceText('{{EMAIL}}',String(order.email || ''));
body.replaceText('{{QTY}}', String(order.qty || ''));
body.replaceText('{{PAPER_TYPE}}', String(order.paper || ''));
body.replaceText('{{ENVELOPE_TYPE}}', String(order.envelope || ''));
body.replaceText('{{STICKER_WAX}}', String(order.sticker || ''));
body.replaceText('{{FOIL_TYPE}}', String(order.foilType || ''));
body.replaceText('{{PRICE_PER_SET}}', String(order.pricePerSet || ''));
body.replaceText('{{BALANCE_DUE}}', String(order.balance || ''));
doc.saveAndClose();
// 导出 PDF
const pdfBlob = DriveApp.getFileById(copyId).getAs('application/pdf');
const folder = DriveApp.getFolderById(FINAL_FOLDER_ID);
const pdfFile = folder.createFile(pdfBlob);
pdfFile.setName('Final Invoice - ' + invoiceNo + '.pdf');
// 发邮件
const subject = 'Your Xamiya Final Invoice #' + invoiceNo;
const bodyText =
'Hi ' + (order.name || '') + ',\n\n' +
'This is your final invoice for your Xamiya order.\n' +
'Balance due: ' + (order.balance || '') + '\n\n' +
'Once payment is received, we will proceed with production / shipment as discussed.\n\n' +
'Thank you!\nXamiya';
GmailApp.sendEmail(order.email, subject, bodyText, {
attachments: [pdfFile]
});
// 清理临时 Doc
DriveApp.getFileById(copyId).setTrashed(true);
}
function onFinalInvoiceCheckboxEdit(e) {
if (!e || !e.source) return;
const sheet = e.source.getActiveSheet();
if (!sheet || sheet.getName() !== FINAL_SHEET_NAME) return;
const range = e.range;
const row = range.getRow();
const col = range.getColumn();
if (row === 1) return;
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const idxSendFlag = headers.indexOf('Send Final Invoice PDF');
if (idxSendFlag === -1) return;
const sendCol = idxSendFlag + 1;
if (col !== sendCol) return;
const newValue = range.getValue();
const shouldSend =
newValue === true ||
String(newValue).toLowerCase() === 'true';
if (!shouldSend) return;
const rowValues = sheet.getRange(row, 1, 1, sheet.getLastColumn()).getValues()[0];
const idxOrderId = headers.indexOf('Order ID');
const idxName = headers.indexOf('Name');
const idxEmail = headers.indexOf('Email');
const idxQty = headers.indexOf('Final Quantity');
const idxPaper = headers.indexOf('Paper Type');
const idxEnvelope = headers.indexOf('Envelope Type');
const idxSticker = headers.indexOf('Sticker / Wax');
const idxPriceSet = headers.indexOf('Price per set');
const idxBalance = headers.indexOf('Balance Due');
const idxFoilType = headers.indexOf('Foil Type');
const email = idxEmail === -1 ? '' : rowValues[idxEmail];
if (!email) return;
const order = {
orderId: idxOrderId === -1 ? '' : rowValues[idxOrderId],
name: idxName === -1 ? '' : rowValues[idxName],
email: email,
qty: idxQty === -1 ? '' : rowValues[idxQty],
paper: idxPaper === -1 ? '' : rowValues[idxPaper],
envelope: idxEnvelope === -1 ? '' : rowValues[idxEnvelope],
sticker: idxSticker === -1 ? '' : rowValues[idxSticker],
pricePerSet: idxPriceSet === -1 ? '' : rowValues[idxPriceSet],
balance: idxBalance === -1 ? '' : rowValues[idxBalance],
foilType: idxFoilType === -1 ? '' : (rowValues[idxFoilType] || '')
};
createFinalInvoicePDF(order);
// 标记为 SENT,避免重复触发
sheet.getRange(row, sendCol).setValue('SENT');
}
这样一来:
- 助理只需要在 Final Orders 里填完信息 → 勾选 checkbox;
- 系统会自动生成带所有细节的尾款发票 PDF,发到客户邮箱;
- 所有发票 PDF 都会累积在一个 Folder 中,方便长期对账与归档。
六、如何保护敏感数据,又让助理很好用?
对于很多小团队,最现实的问题是:我能不能把报价交给助理做,但又不想暴露全部成本和利润?
我的做法是:
- 敏感参数(印刷成本、国际运费、目标利润率等)全部集中在
engine_cost_core; - 这张表单独设置为:只有我有编辑和查看权限;
- 助理使用的是:一个「纯前台」的报价界面表,里面只看到「数量→价格」的结果;
- 所有 WhatsApp 文案和单价,都是从后台引用过来的结果,而不是公式本身。
在系统思维里,这叫做:
前台只暴露「需要被操作的变量」,后台集中存放「算法和利润模型」,通过中间层把两者连接起来。
这样就可以实现一个很舒服的状态:
- 你可以随时根据汇率、成本变动调整利润率,而不需要重新教育助理;
- 助理只要对着界面,给客户报一个专业、稳定的价格;
- 客户体验不会因为你在后台调了一点数字而混乱。
七、85% 自动化是怎么来的?用一个订单生命周期算给你看
以前一个订单的人工步骤大致是:
- 客户来问 → 根据需求手动算价 → 写一段报价发给他;
- 客户同意 → 手动在 Word/PDF 里写发票 → 存成 PDF → 附件发邮件;
- 设计过程中多次确认 → 最后数量 / 规格锁定;
- 再重新算一遍尾款金额 → 再做一张尾款发票 → 再发邮件;
- 手动记录:这单的应收、已收、纸张类型、物流状态。
现在同一个订单,人工只需要:
- 在报价引擎输入变量 → 复制自动生成的 WhatsApp 文案发给客户;
- 客户准备下订金 → 发表单链接给他,剩下交给系统;
- 设计阶段:依然是人工沟通(这一部分我刻意不自动化);
- Final Orders 填完 → 助理勾选 checkbox → 尾款发票自动发送。
粗略估算:
- 以前每单纯手动作业时间 ≈ 40–60 分钟;
- 现在每单 ≈ 5–10 分钟(主要是人工确认和少量特殊情况处理)。
这就是我说的「85% 自动化」:不是没有人,而是把人从重复的机械动作里抽出来,把注意力放在设计、客户体验和高杠杆决策上。
八、常见坑与风控:触发器、测试环境和错单风险
最后讲一些实话:任何自动化系统都有坑,尤其是当你开始用 onFormSubmit、onEdit 这种触发器时。
1. 一定要有「测试环境」
- 在正式使用前,建议复制一份「测试表」和「测试表单」,连到同一套脚本;
- 先用虚拟订单跑 10–20 单,确认发票内容、邮件标题都正常;
- 确认没有错发给真实客户之后,再切到正式表。
2. 避免触发器被误触发
onEdit(e)的条件判断要写严谨,比如确认是指定 sheet、指定列;- 勾选 checkbox 触发发送时,一定要有状态标记(例如改成
SENT),避免重复发送; - 在脚本里多写几层「缺少 Email 就直接 return」这样的防护。
3. 跟会计 / 对账系统的衔接
- 发票 PDF 尽量用统一命名,如
Invoice - XM-订单号.pdf; - 必要时可以再做一张「财务对账表」,让收入与订单号一一对应;
- 如果未来接支付网关,这套订单编号体系也可以直接沿用。
九、常见问答(FAQ)
① 先画出自己的「订单人生」流程(客户从第一句询问到出货的一整条路);
② 用一张表,把所有需要记录的字段列出来;
③ 先做「订金发票」这一个环节:表单 → 表格 → 发票模板 → 脚本;
④ 跑通之后,再加上「尾款发票」和「报价引擎」;
⑤ 最后再考虑:哪些数据需要隐藏、哪些可以交给助理操作。
不必一口气做完全部,先跑通一个闭环,你就会对整个自动化的可能性有完全不一样的感觉。