目录
-
- [一、问题:订单散在聊天里,月底靠 Excel 对账](#一、问题:订单散在聊天里,月底靠 Excel 对账)
- [二、整体架构:Agent 在前,确定性代码在后](#二、整体架构:Agent 在前,确定性代码在后)
- 三、消息解析:把「两双白色55码的b款」拆成结构化字段
- 四、三种返回动作:不确定就不入库
- 五、数据模型:四张表,只追加,原子写
- 六、单价与结算:颜色专价、生效日期、未定价单独标出
- [七、PDF 中文字体:ttc 字体集合是一个必踩的坑](#七、PDF 中文字体:ttc 字体集合是一个必踩的坑)
- 八、面板与角色:服务端鉴权,不是前端藏按钮
- [九、怎么证明它能用:22 + 40 + 12 三层验证](#九、怎么证明它能用:22 + 40 + 12 三层验证)
- [十、8 个真实的坑,比功能列表更值钱](#十、8 个真实的坑,比功能列表更值钱)
- 十一、已知边界:明确不做什么
- 十二、三条可以复用的经验
客户在飞书里发一句「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,一天几十条,月底再按客户、型号、单价手工汇总。问题不在于「记不下来」,而在于:
- 同一客户同一天多条消息,容易漏记或重复;
- 客户资料不全时,订单先记了,联系方式后补,账对不上人;
- 单价按客户 + 型号维护,Excel 里一列一列改,改错一次就是钱;
- 出库单和结算单要重新排版,无法追溯原始消息。
所以这套系统的目标不是「做一个 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 原子写 |
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 双。
六、单价与结算:颜色专价、生效日期、未定价单独标出
单价按「客户 + 型号(+ 颜色)」维护,并带生效日期。查价顺序是:
- 先找该客户 + 型号 + 颜色的专价;
- 没有专价,再找该客户 + 型号的通用价;
- 只取生效日期不晚于订单日期的价格;
- 如果所有价格都还没生效(例如给历史订单补价),用最早的价格兜底。
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,比在需求评审时口头说「以后再说」有用得多。
十二、三条可以复用的经验
- 规则兜底,模型增强。LLM 负责理解人话,确定性代码负责可测和可追溯。两者不是替代关系。
- 不确定就不入库。缺字段时返回追问,比先存一条脏数据再想办法修,成本低一个数量级。
- 验证只看状态。需求矩阵、单测、真实 HTTP、PDF 字节数、URL 跳转,都比「我觉得没问题」可靠。
项目结构不复杂,lib/ 下 5 个文件、约 1200 行核心代码,却覆盖了从消息接入到 PDF 结算的完整闭环。如果你也在做类似的小系统,欢迎在评论区聊聊:你们的接单消息,现在还是靠人往 Excel 里抄吗?