这一篇只讲一件事:客户要「自动报价」,我没有直接开写引擎,而是拿 33,176 张真实历史订单 反查了一遍引擎的能力边界。查完的结果很硬——两个维度能打,两个维度是空的。真正的卡点不在算法,在三个前置数据。
站点:dlmware.cn,德莉玛门窗 / 淋浴房生产管理系统,业务流是订单 → BOM → 玻璃图纸 → 报价。
本篇对应的对象:P0~P3 四层切片里的 P1 定价引擎,以及把它卡住的三件 P0 地基。
相关基础篇:复盘 24 · 德莉玛 monorepo、复盘 25 · 订购单流水线、20 · 跨模块踩坑总账;29 · 订购单系统(单体)与 30 · 玻璃图纸导出 同批产出,讲的是这套系统的另外两面。
客户提出的需求是「自动报价」。一个工程师的本能反应是:把型号表读进来、把厚度映射做出来、把面积算上去、出价。但在动手之前,我先把客户库里的历史订单拉出来跑了一遍覆盖率校验。
| 输入 | 规模 | 在这台引擎里扮演什么 |
|---|---|---|
| 历史订单 | 33,176 单 | 校验样本本体。不是用来训练的,是用来量「引擎能认出多少」的 |
| 经销商 | 387 家 | 客户归属维度,决定报价走哪一套价格档 |
| 型号 | 2,799 个 | 型号识别的地基,号对不上,后面全是空算 |
拿历史单号逐个回查接口,看返回里字段到底是 null、是空串、还是真的有值。猜出来的覆盖率一定是错的,跑出来的才是数。
从 33,176 单里抽 400 单做人工复核,把「有尺寸 / 没尺寸」逐单点出来。这一步的目的是发现「字段存在 ≠ 有数据」。
型号、厚度、尺寸、客户归属四个维度分开算。合成一个数(比如「综合可用率 55%」)等于把坏消息洗掉了。
这是整篇最核心的一张表。四个数字,两个能打、两个空心,中间没有模糊地带。
| 维度 | 真实命中率 | 缺口在哪 | 读出什么结论 |
|---|---|---|---|
| 型号识别 | 96.6% | 2,799 个型号里能对上号的(出价的地基) | 可用:地基是实的,这块不用重做 |
| 玻璃厚度 | 95.2% | 厚度档位基本齐全,余下 4.8% 是边缘值 | 可用:边缘值走「标注 + 人工确认」即可 |
| 尺寸维度 | 29% | 历史订单压根没填规格 | 瓶颈:算面积、算玻璃、算型材全靠它 |
| 客户归属 | 0% | 缺订单 ↔ 经销商 ID 对照表 | 完全缺失:「这单属于哪个经销商档案」答不出(但「哪个客户买了多少单」答得出,见 28 篇第 5 张图) |
customer_id 空值 100% 上得出「完全缺失」。但订单侧的 customer_name 本身就是可用的客户标识:302 个去重客户名(292 个形如「地区-姓名」,无空值、无过短异常),270 个客户买过 2 次以上 = 89.4% 的客户,贡献 99.9% 的订单,TOP10 客户占 24.6%。customer.name,因为档案用 dlm001 编号、订单用「地区-姓名」自由文本,是两套编码」——方向对,但结论错:我假设了 name 列就是客户名,实际那列是编号。改成「把所有候选列逐个试、报出匹配率最高的那一列」之后,用客户表的 contact 列(也是「地区-姓名」格式)比对,302 个客户名里 264 个能对上 = 87.4%,两套编码基本能对上,不必做名称归一。products/retail-ai-audit/scripts/audit.py 的 linkage_check(),作为体检的固定检查项。spec = 尺寸」,这是错的——把全库 spec 取值分布打出来看,装的是规格与颜色(否 / 无 / 黑色 / 黑金沙 / 预埋),没有一条是尺寸。真正装尺寸的是 room 列,但它是混字段(形状 + 尺寸 + 单值 + 163 条「以图片为准」),必须用正则认:含「数字×数字」的 8,956 单 = 27.0%。详见 28 篇第 09 节的口径更正。前两个数字让人乐观,抽样的那个数字把乐观直接打回原形。
| 抽样项 | 真实结果 | 这条说明了什么 |
|---|---|---|
| 抽样规模 | 400 单 | 不是全量,但足以推翻「字段应该都有值」的假设 |
| spec 缺失 | 97% 的 spec 是 null 或「否」(即无尺寸) | 字段在 schema 里写着,但生产数据里基本是空的 |
| 有尺寸的单 | 只有 3 单 | 1% 都不到,样本本身就不具代表性 |
| 这 3 单属于哪个系列 | K / Y 系列,不是 H / S / M 主流系列 | 唯一有尺寸数据的样本,落在非主流系列上 |
把上面的缺口收拢,只剩三件事。这三件事做不完,P1 引擎写出来也只能演示、不能出真价。
| # | 前置依赖 | 缺了会怎样(具体后果) | 数据从哪来 | 优先级 |
|---|---|---|---|---|
| ① | 出厂价红线 (PV / KW / K1 这套价表) | 认得出型号,但没有价可乘:面积算得再准,也乘不出钱。 部分系列只有 H / S / M 有价,K 系列要靠 BOM 模板兜底,H4 没有独立价,只能引用 H 或按模板折算 | 客户内部价表,必须书面给一版现行有效版本,不接受「你去系统里查」 | P0 · 🔴 阻塞 |
| ② | 尺寸数据源 | 面积、玻璃、型材、工费全都算不了;29% 的覆盖意味着八成订单出不了面积 | 要么历史补录规格,要么从图纸 / 下单明细反查,二选一,不能两个都要 | P0 · 🔴 阻塞 |
| ③ | 订单 ↔ 经销商 ID 对照表 | 客户归属 0%:不知道这单属于 387 家里的哪一家,就不知道该走哪个价格档、给谁返点、算谁的毛利 | 客户方出对照表;缺失时按订单号反查经销商系统补,但一定要落一张正式表 | P0 · 🔴 阻塞 |
| 排法 | 做什么 | 结果 | 取舍 |
|---|---|---|---|
| A 先引擎 | 先把型号/厚度匹配做完,价表和数据边用边要 | 三个月后交出一个能跑但出不了真价的引擎 | 否:把数据问题伪装成交付问题 |
| B 全做完再引擎 | 等三个前置依赖全部齐了再动引擎 | 交付时间完全押在客户给数据的时间上 | 否:空窗期太长,谁都没底 |
| C 分层(我选的) | P0 数据地基 → P1 引擎 → P2 拆单与 BOM → P3 派单;P1 先用已验证的匹配能力出价,缺维度就显式标「算不出」 | 依赖顺序清晰,P1 的匹配成果不会作废;P0 补齐即可直接出真价 | 是:代价是 P0 阶段没有「报价」可看 |
出厂价红线(PV / KW / K1)不是一张表,是分档的。分档一拆开,缺口就露出来了。
| 系列 | 价格覆盖 | 没价时怎么办 | 出价可信度 |
|---|---|---|---|
| H | 已配价格 | — | 可直接出价 |
| H4 | 未配独立价 | 引用 H 或按模板折算 | 中低,必须标注「参考价」 |
| K | 靠 BOM 模板兜底 | 走 BOM 逐件折算,要先知道 BOM 里每个子件的件价才算得动 | 中:依赖 BOM 模板本身准确 |
| M | 已配价格 | — | 可直接出价 |
| S | 已配价格 | — | 可直接出价 |
这一层必须讲清楚,否则容易被误读成「两个系统不一致、是我没做好」。不是,是刻意物理分离的两套东西,跑在不同的层面上。
| 层次 | 技术栈(真实) | 干什么用 | 能不能跑真价 |
|---|---|---|---|
| P0~P3 原型 (CPQ / BOM / 供应商引擎) | tRPC v11 + Node | 验证引擎逻辑:型号怎么认、厚度怎么映射、BOM 怎么拆、供应商怎么选 | 不能:不接生产数据库、不带真价表 |
| 生产侧 | 单体 server.js(8787)+ better-sqlite3 + pm2 + rsync | 真订单、真库存、真图纸、真价 | 能,但前提是 P0 三件齐了 |
server.js、单文件库、pm2 守护 + rsync 推文件。这套组合简单到没有理由换——备份就是拷文件,迁移服务器就是拷文件,单机单进程跑得稳。不为「以后可能不够」提前引入分布式,是我在这个项目里守住的一条线。详见 复盘 24 · 德莉玛 monorepo;生产侧的工程细节(29 · 订购单系统(单体))与图纸侧(30 · 玻璃图纸导出与归档)是同一批的另外两篇。三个前置依赖没齐,引擎出不了真价,那还怎么推进?我的解法是:给干系人一张 shower-cpq-demo.html——无服务器、无价格数据、单文件。
| 约束 | 为什么不能用真实系统演示 |
|---|---|
| 干系人打不开内网 | 真实系统在内网地址上,对方根本进不去;一旦打不开,讨论直接卡死 |
| 系统里有真价 | 对外演示不能露出真实出厂价红线,哪怕只是截图 |
| 真实系统一进去就是列表 + 筛选 + 分页 | 不开服务器的人三分钟后还在找按钮,他看不到「引擎在干什么」 |
| 数据本身还没到位 | 拿残缺数据演示,只会演示出一个错误结论 |
| 特性 | 做法 | 为什么这么做 |
|---|---|---|
| 单文件、无服务器 | 一个 shower-cpq-demo.html,双击打开 | 「打开就能看懂」是给不写代码的人看的东西的第一原则,不是「功能全」 |
| 无价格数据 | 按 P0 / P1 分档演示,不填任何金额 | 把「引擎逻辑」和「价格数据」这两件事在演示里切开,谁缺什么一眼可见 |
| 分档:P0 / P1 | P0 三件依赖做成待办态,P1 匹配逻辑做成可交互 | 干系人能直接看见:要出真价,卡在这三件上 |
| 它是一份共识工具,不是产品 | 用途是对齐认知,不是替代正式系统 | 演示页用完就该退休,不能变成长期维护的第二套真相 |
校验期间客户说过一句原话:「先看,不要改任何东西。」这句话后来成了我在这条产线上的工作方式。
| 环节 | 做了什么 | 没做什么 | 为什么 |
|---|---|---|---|
| 拉数据 | 读订单、跑覆盖率、抽 400 单复核 | 不写回任何字段 | 补规格是客户业务动作,我替他补等于给脏数据背书 |
| 翻价表 | 逐系列核价(PV / KW / K1) | 不新建、不覆盖任何价档 | 价表是合同级数据,动一个档位要重新对账 |
| 跑原型 | 在原型侧用假数据跑引擎逻辑 | 不连生产库写数据 | 见第 06 节的两套栈分离 |
| 出结论 | 写成校验报告 + 三件 P0 依赖 | 不含「我顺手改了点什么」 | 诊断结论如果不附上「我没改任何东西」,就不可信 |
| # | 坑 | 症状 | 对策(已落地) |
|---|---|---|---|
| 1 | 字段存在 ≠ 有数据 | 400 单里 97% 的 spec 是 null 或「否」,schema 和文档都写着「支持按尺寸报价」 | 开工前必跑覆盖率校验,校验报告先于需求文档 |
| 2 | 样本落在非主流系列上 | 唯一有尺寸的 3 单是 K / Y 系列,不是 H / S / M 主流系列,导致 29% 这个数连趋势都不能代表 | 抽样必须按系列分层抽,不能整池随机 |
| 3 | 把坏消息合并成一个总数 | 四个维度一平均,「综合可用率」看着还行 | 四个维度分开报,分母写清楚(2,799 个型号) |
| 4 | 演示页变成第二套真相 | 单文件演示被当成正经产品长期维护,规则与生产分叉 | 演示页标注用途 = 对齐认知,规则代码与生产同源 |
| 5 | 把数据问题当成工期问题 | 引擎先交付一个「能跑但出不了真价」的版本,风险到最后才暴露 | 三件依赖全划 P0,缺一件就不进 P1 出价 |
shower-cpq-demo.html 为什么是单文件 + 无服务器 + 无价格数据 + 按 P0/P1 分档做过,而且我第一件事不是写代码,是拿真实数据跑校验。德莉玛这条线的需求是「订单 → 拆单 → 报价 → 派单」全自动。我用他们库里的 33,176 张真实订单、387 家经销商、2,799 个型号 反查了一遍引擎边界。
结果是四个数:型号识别 96.6%、玻璃厚度 95.2%、尺寸维度 29%、客户归属 0%。前两个能打,后两个是空的。我特意把四个维度分开报,不合成一个「综合可用率」——合成总数就是把坏消息洗掉了,分母也必须写清楚是 2,799 个型号而不是 33,176 单。
真正把结论翻过来的,是 400 单抽样:97% 的 spec 是 null 或「否」,只有 3 单有尺寸,而且是 K / Y 系列,不是 H / S / M 主流系列。也就是说 29% 这个数连趋势都不能代表。
所以核心结论是:引擎出不出真价,是数据问题,不是代码问题。接口是通的、字段类型是长的、schema 里也写了、文档也写「支持按尺寸报价」,纸面上一切就绪,跑真实数据才发现压根没填。而且这种错不报错——它会安静地吐出一张格式标准、数字是整数、打印出来谁都挑不出毛病的报价单,一路走到客户现场才爆。这比系统崩溃危险得多。
我的方案是切成四层:P0 数据地基(订单 ↔ 经销商 ID 对照表、出厂价红线 PV/KW/K1、尺寸数据源)→ P1 引擎(型号 / 厚度匹配)→ P2 拆单与 BOM → P3 派单。这里最关键的判断是「P0 不做完,P1 做出来也是废的」。
排法上我否掉了两个:A 先引擎边用边要数据,三个月后交出一个能跑但出不了真价的东西,等于把数据问题伪装成交付问题;B 等数据齐了再做,交付时间完全押在客户给数据的时间上,空窗期太长谁都没底。我选分层:依赖顺序清晰,P1 的匹配成果不会作废,P0 一补齐就能直接出真价。
代价我也认:P0 阶段交付物很素——一张对照表、一张价表、一份补录清单,没有一行让人眼前一亮的界面。但用三个月换一张对照表,和用三个月换一个跑不出真价的引擎,是两种完全不同的风险。
价格这块有个具体的细节值得说:五个系列 H / H4 / K / M / S,只有 H / S / M 已配价格,K 系列靠 BOM 模板兜底,H4 没有独立价,只能引用 H 或按模板折算。所以 H4 一律带「参考价」标记出价,并且把折算口径写在报价单上。一个敢把假设写在脸上的报价单,比一个藏起假设的报价单有用得多——后者客户会信,然后照着下单,然后亏钱。
技术架构上有两点必须讲清楚,否则容易被当成不一致。第一,两套栈是刻意物理分离的:P0~P3 原型是 tRPC v11 + Node,专门跑引擎逻辑,不接生产库、不带真价;生产侧是单体 server.js(8787)+ better-sqlite3 + pm2 + rsync。为什么分?因为引擎逻辑要反复改,生产数据不能反复动——一个进程里同时改算法和动真实订单表,是最贵的调试方式。但要盯住一个债:两边的规则代码必须同源,只换数据源,否则前面所有校验数字都不作数。
第二,数据没到位时我给干系人的是一张 shower-cpq-demo.html:单文件、无服务器、无价格数据、按 P0/P1 分档。理由很硬:对方打不开内网、系统里有真价不能露、真实系统一进去就是列表筛选分页他三分钟还在找按钮、拿残缺数据演示只会演示出一个错误结论。这个手法本身比那张 HTML 值钱:不要拿假数据糊一个能跑的 demo 去证明能力,那会把三个数据卡点推迟到上线那天一起爆。
最后一条工作方法。客户说过一句原话:「先看,不要改任何东西。」我把这条立成了诊断期的红线:拉数据只读不写回、翻价表不新建不覆盖、原型不连生产库。原因是一半的价值在结论,一半在「结论可复现」——你在排查里顺手改过字段,那 33,176 单跑出来的就不是数了,下次谁跑一遍结果不一样,报告就失去权威。补数据、建对照表这些写操作,全部走客户确认后的独立批次,单独留对账记录。
顺带一条排期经验:数据地基的工作量大部分是等客户给数据、反复对字段,这些时间不算在写代码的时间里。我现在把「等客户的时间」单独列一行,不算开发工期,但写进交付承诺的日历。没有缓冲的排期,本质是一份一定会失约的承诺。
如果要挑一句当收尾,我挑这个:把「算不了」做成一个正式状态。报价必须有三态——算得出(绿,显示金额区间并标注所用价)、算不出要补数据(黄,逐条列出缺哪几项并能一键跳到采集表单)、数据本身错(红,指出矛盾点)。只要「算不了」不是正式状态,一线就只剩三条路:自己估一个、不用系统、回来找你,三条都是最坏结果。把它做成一等公民,等于让系统自己说「我不知道」,而不是让人替它瞎说。