技术架构方案 · v1.6

司机订单系统(Field Order Platform)

将现有 Google 表单「FIELD TIO & SUGGESTED ORDERS」升级为面向企业内部使用的 Web 产品——供约 20 名司机在门店现场录单,实时同步给销售与仓库团队。

使用方 司机 / 销售 / 仓库 / 管理员 规模 ~20 司机 · 8 条线路 · 3 类订单 更新 2026-08-03 · PLMA 精简字段 / 门店照片支持多张与删除重拍
01

现状与问题

先还原了原表单的真实结构,方案基于表单实际字段设计,而不是泛泛而谈。

原表单字段结构

分组字段说明
订单头ORDER TYPETurn In Order / Suggested Order / New Customer First Order
订单头FIELD REPRESENTATIVE下拉选择,约 30 人(含离职/在职混杂)
订单头ROUTE NUMBERSydney 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司机手动统计总箱数,无法自动校验

这套结构在 Google 表单里带来的实际问题

02

目标与角色

产品要服务四类人,各自要的是完全不同的界面,而不是同一张表单的不同权限。

● 司机 · Driver

现场快速录单:选门店 → 勾商品类别 → 输数量 → 一键提交。要求大字号、少滑动、离线可用、能复制上次订单。

● 销售 · Sales

看今天/本周所有订单,按线路、司机、门店筛选,核对系统按客户等级自动算出的订单金额,通过司机上传的门店照片远程掌握店况、判断是否需要人工跟进,导出给 CRM 或财务。

● 仓库 · Warehouse

按 SKU 汇总当天/次日要拣的总量,生成拣货单,标记出库状态,反馈缺货给销售和司机。

● 管理员 · Admin

维护商品目录(上下架、停货标记)、线路、司机/员工账号,无需改代码即可调整表单结构。

03

技术栈选型

这个规模(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 人内部工具的迭代节奏

关于数据库选型:直接用 PG,而不是自己再挑一个

Supabase 底层就是标准的 PostgreSQL——不是一个自己发明的私有数据库。这意味着:

结论(已确认): 第一期直接用 Supabase Cloud——它就是你想要的"PG + 可被 AI 工具直接管理",同时顺带解决了下面第 04 节的登录/会话需求,不需要额外自己实现一套账号系统。之后如果确实需要把数据放到自己的服务器上,因为都是标准 PG,可以随时切换成自托管的开源版 Supabase(Docker),架构不需要重新设计,属于"部署位置"的调整而不是"推倒重来"。
04

登录与会话策略

企业内部工具,未登录不可见任何业务内容;登录一次后设备保持 30 天免登录,30 天后强制重新登录。

访问控制:先登录,才有首页

产品不存在"公开首页"——应用启动时先检查本地是否有有效登录态:

打开应用路由守卫先检查本地是否存有有效 Session(access token + refresh token)
无 Session / 已过期直接渲染登录页,不加载任何订单、门店、商品等业务数据或页面
登录成功Supabase Auth 签发 access token(短期,自动静默续期)+ refresh token(长期,决定"多久要重新登录")
进入对应角色首页司机→录单页,销售/仓库/管理员→各自看板,见前面原型演示的四个视图

会话保持 30 天,之后强制重新登录

配置项设置效果
Supabase Auth · Session Time-box30 天refresh token 从登录那一刻起 30 天后强制失效,不管中间是否天天使用——精确对应「1 个月后要重新登录」的要求
Access token 自动续期默认(约 1 小时刷新一次)司机使用期间完全无感,App 在后台用 refresh token 静默换取新的 access token
本地会话存储浏览器/PWA 本地存储(IndexedDB)关闭 App、重启手机、断网都不影响,30 天内重新打开无需再输入密码
登录方式邮箱 + 密码(司机可用工号邮箱),账号由管理员在后台预先创建不开放自助注册——20 人规模不需要,也避免陌生人注册进来看到订单数据
已确认:固定 30 天口径实现——不管期间有没有用,登录满 30 天当天就强制要求重新登录(Supabase Auth 的 Session Time-box),设备丢失或员工离职后凭证也会按时失效,更安全。
05

系统架构

三端共享同一个 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: 司机端可查看自己订单状态
06

数据模型

对照原表单字段整理出的核心表,商品目录和线路独立维护,避免像现在这样写死在表单选项里。这一版按最新沟通加入了客户分级定价、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_stocklow_stockon_promotion 都是独立的数据字段,不是商品名里的文字(现表单里的「❌OUT OF STOCK」)。管理员在后台切一个开关,所有端立刻同步;stock_source 字段留了口子——第一期先支持管理员手动维护,后续如果有独立的库存/ERP 系统,可以配一个定时同步任务自动覆盖这三个标记,管理员的手动修改仍然优先生效。

三个标记在各端的展示规则不同:缺货——司机端直接把这个 SKU 从列表里隐藏(既然订不了,给司机看反而是噪音),销售/仓库/管理端照常显示,仓库拣货单会单独标出来提醒不要往这上面排产能;库存紧张促销——所有端都显示,司机端也需要看到,因为这两个信息直接影响司机现场该怎么跟客户说。

定价分级与 PLMA 货架陈列

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)。私人独立经营的门店基本都落在"默认陈列"这一档。

07

核心体验改进

这是司机每天要用几十次的工具,体验差一点,一天下来就是几十分钟的浪费和几个错单。

现在(Google 表单)

  • 几十个 SKU 平铺成长列表,逐页翻
  • 数量靠点 1–6 的方格
  • 门店信息每次手打
  • 总箱数人工心算,看不到金额
  • 提交后看不到任何后续状态
  • 弱网环境下容易提交失败/漏单

新产品

  • 按类别折叠 + 搜索框,常订商品置顶
  • 数量步进器(- 5 +),支持直接输入
  • 门店输入即搜索联想,自动带出上次订单
  • 总箱数、总金额按客户等级自动算出并实时校验
  • 司机可查看自己订单是否已被仓库确认
  • 断网自动存草稿,恢复网络自动补交

门店信息:司机端从简,销售/管理端从全

同一份客户数据,不同角色看到的详略程度不一样:

字段司机端销售 / 管理端
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:一个独立的两步流程,不是订单流程的延伸

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 次)」,一键清掉更早的历史照片,不需要逐条手动删。

其他值得做的小功能

08

权限与数据隔离

全部通过 Supabase Row Level Security 在数据库层实现,前端不需要额外写权限判断逻辑。

角色可见范围可写操作
Driver仅自己线路下的门店与自己提交的订单创建订单、编辑未确认的草稿
Sales所有订单、所有门店(可按线路筛选),含金额、门店照片标记跟进状态、导出、编辑门店联系信息
Warehouse所有订单的商品明细(不需要门店联系人、金额等销售信息)更新拣货/出库状态、标记缺货
Admin全部商品目录(含价格、促销/库存紧张/停售标记)、货架陈列图库、客户主数据(含 Excel 导入)、线路、账号管理
已确认:停售信息只有一份权威来源。 商品目录(含停售、库存紧张、促销标记、价格)只有 Admin 角色能编辑,Sales 和 Warehouse 都是只读——不存在"仓库标了缺货,销售那边不知道"的情况,因为压根就没有第二份清单可以维护。价格和价目表也在同一个 Admin → Products 页面里编辑(含每个 SKU 的基础价和 A/B/C 三档等级价),改完对所有端实时生效。
09

实施路线图

建议 6~8 周内分阶段上线,先让司机端可用并跑通与仓库/销售的通知闭环,管理后台和离线能力可以在第二阶段补齐。

第 1 周

需求梳理与数据整理

确认最终商品目录(含分类、是否停售、基础价)、线路清单、司机/销售/仓库人员名单、客户等级口径与折扣比例、冰柜规格清单、连锁品牌的陈列图素材;和现有下游流程(谁在用 Sheet、怎么用)对齐,确定过渡期是否需要双写。

产出:数据字典产出:账号清单产出:价目表
第 2 周

后端搭建

创建 Supabase(PG)项目,建表、写 RLS 策略,配置 Auth 与 30 天 Session Time-box,导入商品/线路/账号初始数据。

Supabase 项目数据库 Schema登录策略
第 3–4 周

司机端 H5

登录页 + 路由守卫、录单流程(选门店→[PLMA 选冰柜看陈列图]→分类勾选→自动算价→提交)、提交后可选拍照、离线草稿与自动同步、复制上次订单。这是第一个要给司机试用的版本。

移动优先离线支持PLMA
第 5 周

销售 / 仓库看板 + 通知

实时订单列表、按线路/日期筛选、导出 CSV,仓库 SKU 汇总拣货单,Edge Function 触发短信/邮件通知。

RealtimeTwilio/邮件
第 6 周

管理后台

商品上下架/停货标记、等级价目表维护、货架陈列图库(默认 + 连锁覆盖)、客户主数据管理(含 Excel 批量导入)、线路与账号维护,让业务方自己就能改,不需要每次找开发。

目录管理价目表陈列图库Excel 导入
第 7–8 周

试点与切换

挑 3–5 名司机灰度试用 1~2 周,收集问题;确认无误后全员切换,Google 表单保留只读作为历史存档。

灰度全员上线
10

成本量级

这个规模大概率在各服务的免费或最低付费档位内。

服务预计月成本说明
Supabase免费档 或 ~$25/月(Pro)20~30 用户、每天几百单的数据量,免费档大概率够用;Session Time-box 等更细的 Auth 策略配置需要 Pro 档
Vercel/Netlify免费档纯前端静态部署,个人/小型团队额度足够
Twilio SMS按条计费,约 $0.01~0.08/条只对需要人工跟进的场景(如带照片、金额异常)触发短信,可以把量控制得很低
域名(可选)约 $10~15/年用于替代默认的 vercel.app 域名,非必须
11

风险与建议

以下几项在这版沟通中已经有明确结论,不再是悬而未决的风险,写在这里是为了留一个记录:停售权威来源——已定为 Admin 唯一编辑权限(见第 08 节);价格生效时间点——已定为"下单留痕,历史订单不受后续改价影响"(见第 07 节);陈列图素材质量与多图门店照片压缩与保留策略——已定为统一规格 + 后台一键清理(见第 07 节)。