将现有 Google 表单「FIELD TIO & SUGGESTED ORDERS」升级为面向企业内部使用的 Web 产品——供约 20 名司机在门店现场录单,实时同步给销售与仓库团队。
先还原了原表单的真实结构,方案基于表单实际字段设计,而不是泛泛而谈。
| 分组 | 字段 | 说明 |
|---|---|---|
| 订单头 | ORDER TYPE | Turn In Order / Suggested Order / New Customer First Order |
| 订单头 | FIELD REPRESENTATIVE | 下拉选择,约 30 人(含离职/在职混杂) |
| 订单头 | ROUTE NUMBER | Sydney North/South/East/West、Newcastle、New England、ACT、Monaro 共 8 条线路 |
| 客户信息 | CUSTOMER SITE ID / STORE NAME / AUTHORIZED BY | 手工文本输入,无校验,易打错、无法去重 |
| 订单头 | REQUIRED DELIVERY DAY / COMMENTS | 下拉 + 自由文本 |
| 商品明细 | Impulse / Ben & Jerry's / Multi Pack & Takehome … | 每类几十个 SKU(Magnum、Cornetto、Golden Gaytime、Paddle Pop、Solero、Weis 等),每行 1–6 的数量方格,多页翻页 |
| 订单尾 | TOTAL BOXES ON ORDER | 司机手动统计总箱数,无法自动校验 |
产品要服务四类人,各自要的是完全不同的界面,而不是同一张表单的不同权限。
现场快速录单:选门店 → 勾商品类别 → 输数量 → 一键提交。要求大字号、少滑动、离线可用、能复制上次订单。
看今天/本周所有订单,按线路、司机、门店筛选,核对系统按客户等级自动算出的订单金额,通过司机上传的门店照片远程掌握店况、判断是否需要人工跟进,导出给 CRM 或财务。
按 SKU 汇总当天/次日要拣的总量,生成拣货单,标记出库状态,反馈缺货给销售和司机。
维护商品目录(上下架、停货标记)、线路、司机/员工账号,无需改代码即可调整表单结构。
这个规模(20+ 内部用户、每天大概率 <200 单)不需要重型自建后端,选型原则是「团队能自己维护、上线快、按需付费」。
| 层次 | 选型 | 为什么 |
|---|---|---|
| 前端 | React + TypeScript + Vite,Tailwind CSS,打包为 PWA / H5 | 可安装到司机手机主屏、支持离线缓存;企业内部工具不需要 SSR |
| 数据库 | 标准 PostgreSQL(由 Supabase 托管) | 见下方「关于数据库选型」——就是原生 PG,你可以直接用 AI/SQL 工具管理表结构和数据 |
| 后端服务 | Supabase:Auth + Row Level Security + Realtime + Edge Functions | 在 PG 之上直接拿到账号体系、权限隔离、实时推送、服务端函数,不用另起一套后端服务 |
| 鉴权 / 会话 | Supabase Auth(邮箱+密码),Session 有效期设为 30 天 | 满足「登录后设备端保持 1 个月凭证」的要求,详见第 04 节 |
| 离线支持 | Service Worker + IndexedDB(如 Dexie.js)+ 后台同步 | 门店现场弱网时先本地保存草稿,恢复网络后自动提交,避免漏单 |
| 通知 | Supabase Edge Function → Twilio(SMS)/ Resend 或 SendGrid(邮件) | 新单提交后即时通知仓库/销售;带门店照片的订单可在通知里带缩略图,方便销售快速判断是否需要人工跟进 |
| 图片存储 | Supabase Storage | 门店照片、货架陈列图统一存这里,直接和订单/客户记录关联,不需要另建文件服务 |
| 托管部署 | Vercel 或 Netlify(前端静态站 + PWA) | 免运维、按 Git 分支自动预览,适合 20 人内部工具的迭代节奏 |
Supabase 底层就是标准的 PostgreSQL——不是一个自己发明的私有数据库。这意味着:
psql、DBeaver、TablePlus,或者像现在这样让 AI 通过 SQL/Migration 工具直接读写表结构和数据(这次会话里已经连了 Supabase 的管理工具,能直接建表、跑迁移、查日志)。pg_dump 就能把全部数据导出,换到任何一台 PG 主机上都能跑起来。企业内部工具,未登录不可见任何业务内容;登录一次后设备保持 30 天免登录,30 天后强制重新登录。
产品不存在"公开首页"——应用启动时先检查本地是否有有效登录态:
| 配置项 | 设置 | 效果 |
|---|---|---|
| Supabase Auth · Session Time-box | 30 天 | refresh token 从登录那一刻起 30 天后强制失效,不管中间是否天天使用——精确对应「1 个月后要重新登录」的要求 |
| Access token 自动续期 | 默认(约 1 小时刷新一次) | 司机使用期间完全无感,App 在后台用 refresh token 静默换取新的 access token |
| 本地会话存储 | 浏览器/PWA 本地存储(IndexedDB) | 关闭 App、重启手机、断网都不影响,30 天内重新打开无需再输入密码 |
| 登录方式 | 邮箱 + 密码(司机可用工号邮箱),账号由管理员在后台预先创建 | 不开放自助注册——20 人规模不需要,也避免陌生人注册进来看到订单数据 |
三端共享同一个 Supabase(PG)后端,靠角色和 Realtime 订阅区分视图,而不是三套独立系统。
flowchart TB
subgraph Client["客户端"]
D["司机端 H5/PWA
录单 · 离线草稿 · 复制上次订单"]
S["销售看板
今日订单 · 按线路/门店筛选 · 导出"]
W["仓库看板
SKU 汇总拣货单 · 出库状态"]
A["管理后台
商品目录 · 线路 · 账号"]
end
subgraph Supabase["Supabase(标准 PostgreSQL)"]
Auth["Auth
角色: driver / sales / warehouse / admin
Session Time-box: 30 天"]
DB[("Postgres
orders · order_items · products · customers")]
RLS["Row Level Security
按角色隔离数据访问"]
RT["Realtime
订单变更实时推送"]
EF["Edge Functions
提交校验 · 通知触发 · 汇总计算"]
end
subgraph Notify["通知"]
TW["Twilio SMS"]
Mail["邮件(Resend/SendGrid)"]
end
D -- 登录(30天会话) --> Auth
S -- 登录(30天会话) --> Auth
W -- 登录(30天会话) --> Auth
A -- 登录(30天会话) --> Auth
Auth --> RLS --> DB
S -- 订阅 --> RT
W -- 订阅 --> RT
DB --> RT
DB --> EF
EF --> TW
EF --> Mail
A --> DB
D -.本地草稿.-> LocalDB[("IndexedDB
离线队列")]
LocalDB -. 恢复网络后同步 .-> DB
sequenceDiagram participant Dr as 司机 participant App as PWA(离线队列) participant DB as Supabase DB participant EF as Edge Function participant Sa as 销售看板 participant Wh as 仓库看板 Dr->>App: 选门店/线路,勾选商品数量 App->>App: 本地校验(总箱数自动核对) App->>DB: 提交订单(在线) / 存入本地队列(离线) DB->>EF: 触发 on-insert EF->>Sa: Realtime 推送(带门店照片缩略图,如有) EF->>Wh: Realtime 推送,加入拣货汇总 Wh->>DB: 更新状态: 已拣货/已出库 DB->>Sa: 状态变更实时同步 DB->>Dr: 司机端可查看自己订单状态
对照原表单字段整理出的核心表,商品目录和线路独立维护,避免像现在这样写死在表单选项里。这一版按最新沟通加入了客户分级定价、PLMA 货架陈列、门店照片三块新能力。
erDiagram
USERS ||--o{ ORDERS : creates
ROUTES ||--o{ CUSTOMERS : covers
ROUTES ||--o{ USERS : assigned_to
CUSTOMERS ||--o{ ORDERS : places
ORDERS ||--|{ ORDER_ITEMS : contains
PRODUCTS ||--o{ ORDER_ITEMS : referenced_by
CATEGORIES ||--o{ PRODUCTS : groups
USERS {
uuid id
text full_name
text role "driver/sales/warehouse/admin"
uuid route_id
timestamp last_login_at
}
ROUTES {
uuid id
text name "Sydney North 等 8 条"
}
CUSTOMERS {
uuid id
text site_id "8 位数字,如 1500 8888"
text store_name
text suburb "如 Burwood,司机端只看这个,不看大区域"
text authorized_by
text tier "定价等级 A/B/C"
text chain_name "7-Eleven/BP/EG…,独立门店为空"
uuid route_id
}
ORDERS {
uuid id
text order_type "TIO/Suggested/PLMA"
uuid customer_id
uuid driver_id
date required_delivery_day
text comments
text status "submitted/picking/fulfilled"
int total_boxes "PLMA 记录固定为 0,不含商品"
numeric total_price "按客户等级自动算出;PLMA 记录固定为 0"
uuid freezer_size_id "仅 PLMA 订单"
jsonb photo_urls "门店照片,可拍多张,选填"
timestamp created_at
}
ORDER_ITEMS {
uuid id
uuid order_id
uuid product_id
int quantity
numeric unit_price "下单那一刻按等级算出并留痕"
}
CATEGORIES {
uuid id
text name "Impulse/B&J/Multi Pack…"
}
PRODUCTS {
uuid id
uuid category_id
text sku_name
bool is_active
bool out_of_stock "司机端不展示这个 SKU"
bool low_stock "司机端正常展示 + 库存紧张标记"
bool on_promotion "所有端都展示的促销标记"
numeric base_price "当前生效价"
numeric scheduled_price "排期改价:新价格,未到日期前不生效"
date scheduled_effective_date "到这天自动把 scheduled_price 转正为 base_price"
text stock_source "manual / feed,标记是手动维护还是来自库存系统自动同步"
}
关键改进点: out_of_stock、low_stock、on_promotion 都是独立的数据字段,不是商品名里的文字(现表单里的「❌OUT OF STOCK」)。管理员在后台切一个开关,所有端立刻同步;stock_source 字段留了口子——第一期先支持管理员手动维护,后续如果有独立的库存/ERP 系统,可以配一个定时同步任务自动覆盖这三个标记,管理员的手动修改仍然优先生效。
三个标记在各端的展示规则不同:缺货——司机端直接把这个 SKU 从列表里隐藏(既然订不了,给司机看反而是噪音),销售/仓库/管理端照常显示,仓库拣货单会单独标出来提醒不要往这上面排产能;库存紧张和促销——所有端都显示,司机端也需要看到,因为这两个信息直接影响司机现场该怎么跟客户说。
erDiagram
CUSTOMERS ||--o{ ORDERS : places
PRODUCTS ||--o{ TIER_PRICES : "has price per tier"
FREEZER_SIZES ||--o{ PLANOGRAMS : "default for"
PLANOGRAMS }o--o{ CHAINS : "override for"
TIER_PRICES {
uuid id
uuid product_id
text tier "A/B/C"
numeric unit_price "= base_price × 等级折扣"
}
FREEZER_SIZES {
uuid id
text label "12 Basket / 18 Basket / 24 Basket"
int basket_count
}
PLANOGRAMS {
uuid id
uuid freezer_size_id
text scope "default / chain / customer"
text chain_name "命中连锁总部标准时使用"
uuid customer_id "极少数门店级专属覆盖"
jsonb image_urls "一组参考图,宽冰柜可以传多张"
}
取用逻辑(从精确到粗略):先查这家门店本身是否有专属陈列(scope=customer,极少数)→ 再查所属连锁总部是否有统一标准(scope=chain,比如 7-Eleven/BP/EG)→ 都没有就用这个冰柜规格的默认陈列(scope=default)。私人独立经营的门店基本都落在"默认陈列"这一档。
这是司机每天要用几十次的工具,体验差一点,一天下来就是几十分钟的浪费和几个错单。
同一份客户数据,不同角色看到的详略程度不一样:
| 字段 | 司机端 | 销售 / 管理端 |
|---|---|---|
| Site ID(8 位数字) | 直接显示数字本身(如 1500 8888),不加"Site ID"字样——司机一看数字格式就知道这是店号 | 同样显示,且可搜索、可在导入时校验唯一性 |
| Suburb(如 Burwood) | 显示,用于和司机自己确认"是这家店没错" | 显示 |
| 大区域(如 Sydney West) | 不显示——司机不需要,显示了反而让界面更挤 | 显示,用于按线路筛选、排班 |
| 定价等级 / 所属连锁 | 不需要单独显示,价格已经算好了 | 显示,销售需要知道这家店按什么价格结算,仓库/管理员需要知道是否受连锁总部陈列标准约束 |
每个 SKU 维护一个基础价(Base Price),客户按等级分为 A/B/C(比如 A = 连锁总部直营价、B = 加盟商价、C = 独立门店价,具体等级口径和折扣比例由你们定)。司机选好商品数量后,系统按这家店当前的等级自动算出单价、小计、总金额,提交前就能看到"这一单大概多少钱"——不需要司机记价目表,也不会有人手动改错价。
价格改在哪、什么时候生效:Admin 后台的 Products 页面里,改基础价和 A/B/C 三档等级价都在同一处(不需要跳到另一个页面)。改价支持排期生效:不填生效日期就立刻生效;填一个未来日期,系统会先把新价格记成"待生效",当前价格照常使用,直到那天到了才自动切换成新价——不需要销售/仓库记着"哪天该改价",也不会有人提前手动改早了。不管是立即生效还是排期生效,都不会改动已经下单的历史订单:每笔订单在提交那一刻就把当时算出的单价留痕存进订单明细里,之后价目表再怎么调,已提交(哪怕还没出库)的订单金额都不会跟着变,避免和财务对不上账。
| 标记 | 司机端 | 销售 / 仓库 / 管理端 | 谁来维护 |
|---|---|---|---|
| 🏷 特价促销 | 显示(促销标签,帮司机现场推销) | 显示 | 管理员手动维护 |
| ⚠ 库存紧张 | 显示(提醒司机"这个可能不是每次都有") | 显示,仓库拣货单会提示"确认库存后再排产能" | 管理员手动维护,或后续接入库存系统自动同步 |
| ✕ 缺货 | 不显示——直接把这个 SKU 从司机的商品列表里拿掉,订不了的东西没必要让司机看到,减少干扰 | 显示,仓库拣货单单独标出,避免误排产能 | 管理员手动维护,或后续接入库存系统自动同步 |
「谁来维护」这一列刻意留了两条路:第一期管理员在后台手动打标,如果后续你们有独立的库存管理系统或 ERP,可以配一个定时任务自动把库存状态同步过来覆盖这三个标记——架构上两者共存,自动同步的结果始终可以被管理员手动改一次覆盖掉。
司机端不是"缩小版网页",是要按手机原生使用习惯专门打磨的几个细节:
PLMA 替换了原来的「New Customer First Order」,是和普通订单完全分开的一件事——司机不需要在选完 PLMA 后再走一遍选商品、算金额那一套。具体是两步:
| 步骤 | 内容 |
|---|---|
| 第 1 步 · 订单信息 | 选订单类型为 PLMA、搜索并选定门店——就这两项。选了 PLMA 之后,Authorized By 和 Required Delivery Day 这两个字段会隐藏,因为陈列巡检不涉及授权下单人、也不涉及送货日,硬留着只会让司机多划一屏没用的输入框 |
| 第 2 步 · 货架陈列 | 只显示刚才选的门店(只读展示,不是再搜一次)→ 选冰柜规格(12/18/24 Basket)→ 自动显示这家店对应的陈列图(连锁标准或默认)→ 备注(选填,这一项从"订单信息"那一步挪到这里,PLMA 不会同时出现两处备注框)→ 底部按钮直接是 「拍照」,不是"下一步:选择商品" |
点「拍照」之后:系统先记录这次陈列巡检(门店、冰柜规格、用的是哪张陈列图、备注),同时立刻唤起相机。拍完的照片会显示成一个小图集,司机可以继续追加拍多张、也可以删掉拍糊的重拍——不限制只能拍一张,宽冰柜经常需要分几张才能拍全。确认没问题后点 「完成」,结束这次巡检并跳回最开始的订单信息页,可以直接开始录下一单。这条记录不含商品明细和金额,因为它本来就不是一张订货单。
素材规范:司机是在手机小屏幕上看陈列图,要求统一比例和清晰度——建议 4:3、长边至少 1600px,避免有的图糊、有的图上文字小到看不清。一个规格的冰柜允许挂多张参考图:24 Basket 这类比较宽的冰柜,一张照片经常拍不全,管理后台支持给同一个"规格 + 连锁"组合传多张图(拍不同角度/分段),不强求塞进一张里。
普通订单(Turn In Order / Suggested Order)提交后,司机可以选择性拍一张或多张店内照片,同样支持追加拍摄和删除重拍,上传后销售在订单详情里就能看到,用来远程判断这家店是否需要人工跟进——纯辅助决策,不是必填项,不会拖慢司机的录单速度。PLMA 的拍照则是流程本身的最后一步(见上),不是"提交后可选"。
存储成本控制:照片在司机手机端上传前先压缩到合理分辨率(例如长边压到 1600px 左右、JPEG 有损压缩),避免原图直接上传占用过多 Supabase Storage 空间;管理后台提供"清理旧照片"功能,可设置「每家店只保留最近 N 次(默认 3 次)」,一键清掉更早的历史照片,不需要逐条手动删。
全部通过 Supabase Row Level Security 在数据库层实现,前端不需要额外写权限判断逻辑。
| 角色 | 可见范围 | 可写操作 |
|---|---|---|
| Driver | 仅自己线路下的门店与自己提交的订单 | 创建订单、编辑未确认的草稿 |
| Sales | 所有订单、所有门店(可按线路筛选),含金额、门店照片 | 标记跟进状态、导出、编辑门店联系信息 |
| Warehouse | 所有订单的商品明细(不需要门店联系人、金额等销售信息) | 更新拣货/出库状态、标记缺货 |
| Admin | 全部 | 商品目录(含价格、促销/库存紧张/停售标记)、货架陈列图库、客户主数据(含 Excel 导入)、线路、账号管理 |
建议 6~8 周内分阶段上线,先让司机端可用并跑通与仓库/销售的通知闭环,管理后台和离线能力可以在第二阶段补齐。
确认最终商品目录(含分类、是否停售、基础价)、线路清单、司机/销售/仓库人员名单、客户等级口径与折扣比例、冰柜规格清单、连锁品牌的陈列图素材;和现有下游流程(谁在用 Sheet、怎么用)对齐,确定过渡期是否需要双写。
创建 Supabase(PG)项目,建表、写 RLS 策略,配置 Auth 与 30 天 Session Time-box,导入商品/线路/账号初始数据。
登录页 + 路由守卫、录单流程(选门店→[PLMA 选冰柜看陈列图]→分类勾选→自动算价→提交)、提交后可选拍照、离线草稿与自动同步、复制上次订单。这是第一个要给司机试用的版本。
实时订单列表、按线路/日期筛选、导出 CSV,仓库 SKU 汇总拣货单,Edge Function 触发短信/邮件通知。
商品上下架/停货标记、等级价目表维护、货架陈列图库(默认 + 连锁覆盖)、客户主数据管理(含 Excel 批量导入)、线路与账号维护,让业务方自己就能改,不需要每次找开发。
挑 3–5 名司机灰度试用 1~2 周,收集问题;确认无误后全员切换,Google 表单保留只读作为历史存档。
这个规模大概率在各服务的免费或最低付费档位内。
| 服务 | 预计月成本 | 说明 |
|---|---|---|
| Supabase | 免费档 或 ~$25/月(Pro) | 20~30 用户、每天几百单的数据量,免费档大概率够用;Session Time-box 等更细的 Auth 策略配置需要 Pro 档 |
| Vercel/Netlify | 免费档 | 纯前端静态部署,个人/小型团队额度足够 |
| Twilio SMS | 按条计费,约 $0.01~0.08/条 | 只对需要人工跟进的场景(如带照片、金额异常)触发短信,可以把量控制得很低 |
| 域名(可选) | 约 $10~15/年 | 用于替代默认的 vercel.app 域名,非必须 |
customers 表,否则新系统会继承旧数据的脏乱。以下几项在这版沟通中已经有明确结论,不再是悬而未决的风险,写在这里是为了留一个记录:停售权威来源——已定为 Admin 唯一编辑权限(见第 08 节);价格生效时间点——已定为"下单留痕,历史订单不受后续改价影响"(见第 07 节);陈列图素材质量与多图、门店照片压缩与保留策略——已定为统一规格 + 后台一键清理(见第 07 节)。