飞书接单到月度结算:我用一个 DSH 插件跑通鞋类采购 WMS,附 22 项验收

目录

客户在飞书里发一句「a客户 型号b 黑色3双43码」,Agent 自动解析、入库;后台按天汇总、一键导出出库单;月底按客户单价自动结算,生成 PDF。

这不是 demo,而是一个已经跑通的 DSH 插件:11 个 Agent 工具、13 条 HTTP 路由、1 个独立管理面板、22 项需求验收全部 PASS、40 个自动化测试全部通过。

这篇文章拆解它的完整链路:消息怎么解析、缺字段怎么追问、价格和结算怎么算、PDF 中文字体怎么避坑,以及 8 个真实踩过的坑。如果你也在做「聊天接单 + 后台对账」这类系统,可以直接套。

一、问题:订单散在聊天里,月底靠 Excel 对账

鞋类批发场景的采购消息,通常长这样:

text 复制代码
a客户 型号b 黑色3双43码
再补两双白色55码的b款
b款 黑色3双43码 白色2双42码
客户:张三,型号 X1,黑色,3双,43码

同一条需求,可能有四种表达:

  • 中文数字:两双、三双
  • 字段乱序:型号在前、数量在后
  • 省略客户名:第二句只写「再补两双」
  • 一个消息里多行项目,颜色和尺码交叉

传统做法是内勤复制到 Excel,一天几十条,月底再按客户、型号、单价手工汇总。问题不在于「记不下来」,而在于:

  1. 同一客户同一天多条消息,容易漏记或重复;
  2. 客户资料不全时,订单先记了,联系方式后补,账对不上人;
  3. 单价按客户 + 型号维护,Excel 里一列一列改,改错一次就是钱;
  4. 出库单和结算单要重新排版,无法追溯原始消息。

所以这套系统的目标不是「做一个 WMS 大而全」,而是把接单 → 入库 → 汇总 → 出库 → 结算这条最短链路固化下来。

二、整体架构:Agent 在前,确定性代码在后

最终链路只有一条:

text 复制代码
飞书消息
  → wms_ingest_message(Agent 工具)
  → lib/parse.js 确定性预解析 + Agent 语义理解
  → lib/store.js 订单入库(只追加)
  → 当日汇总 / 月度结算
  → 出库单 PDF / 结算单 PDF
  → lib/panel.html 管理面板
层 文件 职责
插件装配 lib/index.js 注册 11 个工具、13 条路由、角色权限、系统提示词
消息解析 lib/parse.js 中文数字、颜色别名、尺码、型号、字段乱序
数据层 lib/store.js 客户 / 单价 / 订单 / 待补录,JSON 原子写
PDF lib/pdf.js 出库单、结算单,中文字体探测与降级
管理面板 lib/panel.html 无框架单页,跟随系统深浅色
设置页入口 lib/client.js DSH「设置 → 鞋类采购」

关键设计是双轨解析:

  • parse.js 提供可复现的确定性规则,覆盖常见写法,40 个测试可以逐条验证;
  • Agent(LLM)负责规则覆盖不到的口语化表达,必要时直接调用结构化的 wms_record_order。

规则不是用来替代模型的,而是用来兜底和保证可测。模型会变,规则不会。

三、消息解析:把「两双白色55码的b款」拆成结构化字段

核心函数是 parseMessage,输入原始文本,输出客户名、行项目、缺失字段:

js 复制代码
import { parseMessage } from './lib/parse.js'

const parsed = parseMessage('再补两双白色55码的b款', {
  knownCustomers: ['a', '张三'],
})

// parsed.customerName -> ''(本条没写客户,由 chatId 上下文补齐)
// parsed.items -> [{ model: 'b', color: '白色', size: '55', quantity: 2 }]
// parsed.missing -> ['customer']

几个归一化规则看起来不起眼,但决定了能不能用:

字段 支持的写法 归一化结果
数量 3双 / 两双 / 三对 / 3 pairs 3 / 2 / 3 / 3
颜色 黑 / 黑色 / black / blk 黑色
尺码 43码 / 43号 / EU43 / 43.5 43 / 43 / 43 / 43.5
型号 型号b / b款 / 款号 X1 / style X1 b / b / X1 / X1
客户 a客户 / 客户:张三 / 我是李四 a / 张三 / 李四

解析器按 token 扫描,遇到数量就生成一行,并记住「最近出现的型号和颜色」,供后续省略写法的行复用:

js 复制代码
// 简化后的主循环:数量触发建行,型号/颜色可继承
const qtyMatch = QTY_RE.exec(token)
if (qtyMatch) {
  pending = {
    model: extractModel(descriptor) || carriedModel,
    color: extractColor(descriptor) || carriedColor,
    quantity: normalizeQuantity(qtyMatch[1]),
    size: '',
  }
  if (pending.model) carriedModel = pending.model
  if (pending.color) carriedColor = pending.color
}

还有一个容易被忽略的上下文:群聊里客户第二次报单通常不写名字。系统按 chat_id 记住该会话最近一次报单客户,没写客户名时自动沿用:

js 复制代码
if (!parsed.customerName && chatId) {
  const remembered = store.chatCustomer(chatId)
  if (remembered) {
    parsed.customerName = remembered.name
    parsed.missing = parsed.missing.filter((key) => key !== 'customer')
  }
}

这一个改动,直接消灭了「再补两双」被反复追问「客户是谁」的尴尬。

四、三种返回动作:不确定就不入库

整个入站逻辑只有三个出口,Agent 拿到 action 后决定怎么回复客户:

action 触发条件 行为 是否入库
recorded 五个字段齐全 返回订单明细、合计、待定价提示 是
need_info 缺客户 / 型号 / 颜色 / 尺码 / 数量 返回已识别内容 + 缺字段清单 + 标准格式 否,只进待补录
retry 完全无法解析 返回重述话术 + 标准示例 否,只进待补录
js 复制代码
if (!parsed.items.length) {
  await store.addPending({ chatId, rawText: text, missing: ['all'] })
  return { action: 'retry', reply: buildRetryReply(parsed) }
}

if (parsed.missing.length) {
  await store.addPending({ chatId, rawText: text, missing: parsed.missing })
  return { action: 'need_info', reply: buildFollowUp(parsed) }
}

const orders = await store.recordOrders(lines, { orderDate, chatId, source: 'feishu' })
return { action: 'recorded', orders, reply: '✅ 已登记......' }

这里有一条硬约定:字段不全时绝不入库。

脏数据的代价远高于多问一句。缺尺码的订单进了库,出库时才发现,就得改单;而需求里明确「订单只追加、不修改不作废」,改单能力根本不存在。于是系统选择在入口处拦住。

客户信息不全时同理:新客户建档后如果没留联系方式,回复里会带一句「请补充 XX 的联系方式」,客户在聊天里回一条,wms_upsert_customer 完成补录,不需要进后台。

五、数据模型:四张表,只追加,原子写

lib/store.js 只用 JSON 落盘,没有数据库依赖:

js 复制代码
const emptyDb = () => ({
  version: 1,
  customers: [],   // 客户档案:名称 / 别名 / 联系方式 / 地址
  prices: [],      // 单价:客户 × 型号 [+ 颜色] + 生效日期
  orders: [],      // 订单行:客户 × 型号 × 颜色 × 尺码 × 数量
  pending: [],     // 待补录:解析不完整或待补联系方式
  events: [],
  chats: {},
  seq: 0,
})

写入用「临时文件 + rename」保证原子性:

js 复制代码
async save() {
  this._saving = this._saving.then(async () => {
    const body = JSON.stringify(this.db, null, 2)
    const tmp = `${this.dbPath}.${process.pid}.tmp`
    await fsp.writeFile(tmp, body, 'utf8')
    await fsp.rename(tmp, this.dbPath)
  })
  return this._saving
}

订单只追加,汇总时才按「客户 × 型号 × 颜色 × 尺码」合并求和。这样每一条汇总都能追溯到原始消息,rawText、chatId、messageId 都留在订单行里。

js 复制代码
const key = [o.customerId, normalizeKey(o.model), o.color, o.size].join('|')
row.quantity += o.quantity
row.lines += 1

同一客户同一天发三条消息,最终就是一组累加后的汇总行,而不是三条互相覆盖的记录。验收里对应的断言是:黑色 43 码 3 + 2 = 5 双,当日合计 7 双。

六、单价与结算:颜色专价、生效日期、未定价单独标出

单价按「客户 + 型号(+ 颜色)」维护,并带生效日期。查价顺序是:

  1. 先找该客户 + 型号 + 颜色的专价;
  2. 没有专价,再找该客户 + 型号的通用价;
  3. 只取生效日期不晚于订单日期的价格;
  4. 如果所有价格都还没生效(例如给历史订单补价),用最早的价格兜底。
js 复制代码
priceFor(customerId, model, color = '', date = localDate()) {
  const all = this.db.prices.filter(
    (p) => p.customerId === customerId && normalizeKey(p.model) === normalizeKey(model),
  )

  const colorPool = all.filter((p) => normalizeKey(p.color ?? '') === normalizeKey(color))
  const genericPool = all.filter((p) => !p.color)

  const colorApplicable = colorPool.filter((p) => p.effectiveFrom <= date)
  if (colorApplicable.length) return latest(colorApplicable)

  const genericApplicable = genericPool.filter((p) => p.effectiveFrom <= date)
  if (genericApplicable.length) return latest(genericApplicable)

  // 历史订单补价:取最早上架价兜底
  const fallback = colorPool.length ? colorPool : genericPool
  return fallback.length ? earliest(fallback).unitPrice : null
}

月度结算按「数量 × 该客户对应单价」计算,未定价的部分不会静默变成 0,而是单独统计:

js 复制代码
const unitPrice = o.unitPrice ?? this.priceFor(o.customerId, o.model, o.color, o.orderDate)
line.quantity += o.quantity
entry.quantity += o.quantity
if (unitPrice === null || unitPrice === undefined) continue  // 计入未定价数量

const amount = round2(unitPrice * o.quantity)
line.amount = round2(line.amount + amount)
entry.amount = round2(entry.amount + amount)

验收里的实际数字:

客户 型号 颜色 尺码 数量 单价 金额
a b 黑色 43 5 125 625
b X1 白色 42 3 待定价 未定价
合计 8 875

结算单会把「未定价」单独标红,财务一眼能看到哪几行缺价格,而不是对着一个看似正确的总额猜。

七、PDF 中文字体:ttc 字体集合是一个必踩的坑

出库单和结算单用 pdfkit 渲染。中文变方框的根因通常不是没装字体,而是装了字体集合:

  • msyh.ttc、simsun.ttc 这类 .ttc 是字体集合,pdfkit 不支持;
  • 直接 registerFont 会抛 this.font.createSubset is not a function;
  • 正确做法是用 fontkit 打开一次,确认有 createSubset 能力再注册。
js 复制代码
export function findCjkFont() {
  for (const candidate of FONT_CANDIDATES) {
    try {
      if (!fs.statSync(candidate).isFile()) continue
      const font = fontkit.openSync(candidate)
      if (typeof font?.createSubset !== 'function') continue
      return candidate
    } catch {
      /* 继续找下一个 */
    }
  }
  return null // 降级 Helvetica,中文缺字但不崩
}

候选列表覆盖 Windows / macOS / Linux 常见路径,WMS_PDF_FONT 可以强制指定一个 .ttf。本机实际命中的是 C:/Windows/Fonts/simhei.ttf。

这一段在验收报告里对应一个真实故障:最初直接传 .ttc,PDF 生成直接崩;加了 fontkit 预校验后,才有了「要么正确嵌入、要么干净降级」的行为。

八、面板与角色:服务端鉴权,不是前端藏按钮

管理面板是单文件 lib/panel.html,无框架、无构建,包含 7 个页签:

页签 内容
概览 今日 / 本月数量与金额、客户数、资料待补、待补录、最近动态
客户档案 新增 / 补录客户(同名更新),资料是否完整
单价管理 按客户 + 型号(+ 颜色)维护单价与生效日期
当日汇总 / 出库 按日期汇总 + 导出 PDF 出库单
月度结算 按月份结算 + 导出 PDF 结算单,未定价标红
订单流水 当日订单行,只读,来源可追溯
待补录 解析失败 / 待补联系方式的队列

角色权限在服务端的能力表里定义,不依赖前端隐藏:

角色 能力
管理员 admin 客户读写、单价读写、订单读写、汇总、出库导出、结算、补录
库管 warehouse 只读客户 / 单价 / 订单、汇总、出库导出、查看补录
财务 finance 只读客户 / 单价 / 订单、结算、结算导出

请求通过 x-wms-role 头传递角色,越权直接 403:

js 复制代码
const guard = (req, res, capability, handler) => {
  const role = roleOf(req)
  if (!can(role, capability)) {
    return json(res, 403, {
      ok: false,
      error: `角色「${ROLES[role].label}」没有权限:${capability}`,
    })
  }
  return handler(req, res, { role, store })
}

13 条路由覆盖完整链路:

方法 路径 能力
GET /api/wms/health 健康检查、数据目录、字体、角色、概览
GET /api/wms/panel 后台面板 HTML
GET /api/wms/overview 概览数字
GET/POST /api/wms/customers 客户列表 / 建档或补录
GET/POST /api/wms/prices 单价列表 / 新增或修改
GET/POST /api/wms/orders 订单流水 / 手工补单
GET /api/wms/daily?date= 当日汇总
GET /api/wms/outbound.pdf?date= 出库单 PDF
GET /api/wms/settlement?month= 月度结算数据
GET /api/wms/settlement.pdf?month= 结算单 PDF
GET /api/wms/pending 待补录列表
POST /api/wms/pending/resolve 标记待补录已处理
POST /api/wms/ingest 单条采购消息入库(与飞书链路同源)

面板还支持深链,#/daily、#/settlement 可以直接打开对应页签。

九、怎么证明它能用:22 + 40 + 12 三层验证

这类业务系统最怕「看起来能跑」。项目用三层验证把结论钉死:

层次 手段 结果
需求层 npm run verify,逐条对照需求文档 22 PASS / 0 FAIL
单元 + 集成 npm test,解析 / 数据层 / PDF / 插件装配与路由 40 PASS / 0 FAIL
线上实例 npm run e2e,对运行中的 DSH Web 发真实 HTTP 12 PASS / 0 FAIL

需求矩阵不是一句「全部完成」,而是每条都有可复现的验证方式:

text 复制代码
R1.1  PASS  飞书采购消息接入并生成结构化订单    客户 a / 型号 b / 黑色 / 3 双 / 43 码 → 已入库
R1.2  PASS  格式不固定的消息也能解析            中文数字 / 字段乱序 / 省略客户名均识别
R1.4  PASS  同一客户同一天多条消息,数量自动累加  黑色 43 码:3 + 2 = 5 双;当日合计 7 双
R2.1  PASS  无法解析时给出重新描述提示 + 标准示例 返回 action=retry,话术含标准示例
R3.2  PASS  客户信息不全时 Agent 通过飞书追问     缺尺码 → 追问「尺码」,未产生订单
R5.2  PASS  订单金额 = 数量 × 该客户对应单价      a · b · 黑色 · 43:5 双 × 125 = 625
R6.2  PASS  月度结算金额可计算并可导出           应收 ¥875;PDF 15883 字节
ROLE.3 PASS 财务可结算,不能导出出库单或改单价    结算单 200 / 出库单 403 / 改价 403
EXCL  PASS  暂不包含项确实没有实现              路由 / 工具 / 数据层均无这些能力

最后一行 EXCL 值得单独说:需求文档明确「暂不包含库存扣减、订单改废、收款折扣运费、定时汇总」,项目不但没做,还写了反向断言防止以后「顺手加上」。范围控制也是需求的一部分。

十、8 个真实的坑,比功能列表更值钱

开发过程中修掉的问题,按杀伤力排序:

# 问题 现象 修复
1 尺码正则带 /g 所有消息都丢尺码,永远追问「尺码」 去掉全局标志,改单次匹配
2 中文数量归一化 两双 返回 NaN 先剥离「双 / 对 / pairs」单位再转数字
3 pdfkit 嵌入 .ttc PDF 生成崩溃 fontkit 预校验,只挑可嵌入的 .ttf
4 单价生效日期晚于订单 给历史订单补价后金额仍为 0 加「最早价兜底」,颜色专价仍优先
5 群聊省略客户名 「再补两双」被判缺客户 按 chat_id 记住最近报单客户
6 客户不存在时无法报价 Agent 先报价后建客户会报错 setPrice 对未知客户自动建档
7 插件安装卡死 timed out waiting for the writer lock 清理 0 字节的 package.json.lock
8 编辑脚本写坏编码 读取源码报 invalid UTF-8 改用文件工具重写,禁用 shell 重写源码

其中第 1 条最隐蔽:正则加 /g 后,String.match() 返回的是所有匹配的数组,而不是捕获组,m[1] 自然是 undefined。单元测试里加一条「尺码必须存在」,比盯着日志猜快得多。

第 5 条则是产品问题:规则解析器不知道「这个群上一次说的是谁」,得由数据层提供 chat_id → customer 的映射。技术方案往往就差这一层上下文。

十一、已知边界:明确不做什么

这套系统不是通用 WMS,边界写得很清楚:

  • 一条消息里出现多个客户,只支持「最近客户 + 显式客户名」,不自动拆分;
  • 尺码只识别两位数字(35--46,支持 43.5),S/M/L 不识别;
  • 订单只追加,不修改、不作废,录错只能新增相反数量;
  • 不做库存扣减、收款、折扣、运费、定时汇总;
  • 真实飞书 App 的收发链路由 dsh-feishu-bridge 负责,本项目用同源的 /api/wms/ingest 验证了解析与入库;
  • 修改 Host 代码后需要重启 dsh web,面板 HTML 刷新即可。

把这些写进 README 和 AGENTS.md,比在需求评审时口头说「以后再说」有用得多。

十二、三条可以复用的经验

  1. 规则兜底,模型增强。LLM 负责理解人话,确定性代码负责可测和可追溯。两者不是替代关系。
  2. 不确定就不入库。缺字段时返回追问,比先存一条脏数据再想办法修,成本低一个数量级。
  3. 验证只看状态。需求矩阵、单测、真实 HTTP、PDF 字节数、URL 跳转,都比「我觉得没问题」可靠。

项目结构不复杂,lib/ 下 5 个文件、约 1200 行核心代码,却覆盖了从消息接入到 PDF 结算的完整闭环。如果你也在做类似的小系统,欢迎在评论区聊聊:你们的接单消息,现在还是靠人往 Excel 里抄吗?

相关推荐
濮水大叔12 小时前
Cabloy/Vona 为什么不需要 NestJS 的 Dynamic Module
typescript·node.js·nestjs
java_logo14 小时前
Docker 部署 DeepSeek Harness(moelin):轻松搭建带登录的本地 AI Agent 平台
运维·docker·容器·ai agent·deepseek·harness·轩辕镜像
匠云创新14 小时前
读完一万段要两分钟:我们把标书读取从 WPS 对象模型搬到了 docx 解析
性能优化·node.js
某哈压力大1 天前
SSH 突然 Connection reset?不是密钥坏了,是 OpenSSL 把 sshd 干趴了
前端·node.js·命令行
回眸&啤酒鸭1 天前
【回眸】Node.js 新手从零到一实战指南
node.js
浪遏1 天前
recruitment-cli:让 AI 接手求职招聘,从架构到一次发送超时
架构·typescript·node.js
吴名氏.1 天前
Java转AI Agent教程(第十三周)混合检索与重排
java·数据库·人工智能·ai·ai agent
沉默小管3 天前
我用 NestJS + Nuxt 3 做了个导航站,聊几个踩过的坑
node.js·seo
小熊熊知识库3 天前
双层PDF转换软件
pdf·双层pdf转换