上一篇《26 条题库全跑通,为什么我还不敢把 AI 问数交给用户?》里,我重新划清了“查询能跑”和“能力可以交付”之间的边界。
这次不再继续讲验收方法。我换回 WideWorldImporters 的业务库,实际交付一块销售能力。
先看结果:我只接了发票和发票明细,没有先建数仓,也没有为表格、接口和 AI 分别维护三套查询。
同一个 wwi_oltp_invoice_lines QueryModel 已经可以:
Sales.Invoices 与 Sales.InvoiceLines 的手写 SQL 核对。2016 年 1 月 1 日到 5 月 31 日的核对结果是:
| 指标 | OLTP 手写 SQL | Runtime API | MCP |
|---|---|---|---|
| 发票明细行数 | 29,458 | 29,458 | 29,458 |
| 含税金额 | 25,971,029.11 | 25,971,029.11 | 25,971,029.11 |
| 未税金额 | 22,633,175.55 | 22,633,175.55 | 22,633,175.55 |
| 税额 | 3,337,853.56 | 3,337,853.56 | 3,337,853.56 |
| 利润 | 11,174,765.55 | 11,174,765.55 | 11,174,765.55 |
| 数量 | 1,241,304 | 1,241,304 | 1,241,304 |
这才是这篇想验证的事情:对已经有业务系统、但还没有成熟 BI 平台的团队,第一步能不能不是“先建设完整数据平台”,而是先从一块可解释、可核对的数据开始,把表格、API 和 AI 一起交付出去。
前两篇使用的是 WideWorldImportersDW。
它很适合验证销售、订单、库存和多阶段分析,但对我们当前面对的企业并不够有代表性。很多中小企业有 ERP、TMS、CRM、OMS 或自研业务系统,却没有同步维护良好的数仓。
如果产品必须等客户先完成数仓建设,它就没有回答这些企业眼前的问题。
所以这次我使用的是 OLTP 数据库 WideWorldImporters,而不是 WideWorldImportersDW。第一版也没有扫描全库,只选择:
Sales.Invoices:发票头、客户、开票日期。Sales.InvoiceLines:商品、数量、单价、税额、含税金额和利润。Sales.Customers:客户名称与客户分类。Warehouse.StockItems:商品名称与商品属性。源数据共有 70,510 张发票、228,265 条发票明细,时间范围是 2013-01-01 到 2016-05-31。
“不接全库”在这里不是口号。销售交付不需要读取联系人、账号、采购、库存流水、温控记录和其他无关数据,也不需要先解释每一张表。
直接接业务库,最容易犯的错是看到一个像金额的字段就开始聚合。
这次的口径来自发票明细:
未税金额 = InvoiceLines.UnitPrice × InvoiceLines.Quantity
税额 = InvoiceLines.TaxAmount
含税金额 = InvoiceLines.ExtendedPrice
利润 = InvoiceLines.LineProfit
默认日期 = Invoices.InvoiceDate
我对全部 228,265 条明细检查了公式:
ExtendedPrice - (UnitPrice × Quantity + TaxAmount)
最大绝对差为 0.00。
这批样例里 IsCreditNote = 1 的发票数为 0,所以当前结果不包含贷项冲减。它不代表真实企业以后不需要处理退货和红字,只代表这批数据没有提供相应样本,文章不能假装已经验证。
同样,数据在 2016-05-31 结束,因此这里写的是“2016 年样例事实期”,不是“2016 全年”。
这次新增了一个 TM 和一个 QM。
TM 的事实查询直接从两个 OLTP 表出发:
const invoiceLineViewSql = `
SELECT
invoice_line_id,
line_invoice_id AS invoice_id,
customer_id,
stock_item_id,
invoice_date,
quantity,
unit_price,
tax_amount,
total_including_tax,
line_profit
FROM (...) invoice_lines
INNER JOIN (...) invoice_headers
ON line_invoice_id = header_invoice_id`;
QM 则只开放本次交付需要的字段:发票日期、发票与明细 ID、客户、商品、地理属性,以及数量、金额、税额和利润。
const invoiceLines = loadTableModel('WwiOltpInvoiceLineModel');
export const queryModel = {
name: 'wwi_oltp_invoice_lines',
model: invoiceLines,
columnGroups: [
{ caption: 'Invoice Time', items: [/* 日期字段 */] },
{ caption: 'Customer And Geography', items: [/* 客户与地区 */] },
{ caption: 'Stock Item', items: [/* 商品 */] },
{ caption: 'Invoice Line Detail', items: [/* 明细列 */] },
{ caption: 'Invoice Measures', items: [/* 聚合指标 */] }
]
};
这里有一个看似重复、实际很重要的设计:金额和数量同时保留“明细属性”和“聚合度量”。
业务表格需要显示一条发票明细的数量与金额;汇总查询则要明确执行 SUM。把两者分开后,表格不会因为选中了度量就意外聚合,AI 也不需要临时猜“这次是看单行还是求和”。
我把下面这段 DSL 交给 Data Viewer:
{
"model": "wwi_oltp_invoice_lines",
"payload": {
"columns": [
"invoiceDate$id",
"invoiceId",
"invoiceLineId",
"customer$caption",
"stockItem$caption",
"quantityDetail",
"unitPrice",
"taxAmountDetail",
"totalIncludingTaxDetail",
"lineProfitDetail"
],
"slice": [
{
"field": "invoiceDate$id",
"op": "[)",
"value": ["2016-05-01", "2016-06-01"]
}
]
}
}
页面返回 6,351 条明细。按日期、发票 ID 和明细 ID 倒序后的第一行是:
| 字段 | 值 |
|---|---|
| 开票日期 | 2016-05-31 |
| 发票 ID | 70510 |
| 发票明细 ID | 228265 |
| 客户 | Wingtip Toys (Penns Creek, PA) |
| 商品 | Furry gorilla with big eyes slippers (Black) S |
| 数量 | 8 |
| 单价 | 32.00 |
| 税额 | 38.40 |
| 含税金额 | 294.40 |
| 利润 | 192.00 |
Runtime API 执行同一份 DSL,第一行完全相同。5 月手写 SQL 汇总得到的含税金额为 5,704,232.71,页面日期过滤下的记录数也是 6,351。
这张表没有新增专用 Vue 页面,也没有新增一个“发票查询 Controller”。它直接消费 QM 返回的 schema 和 rows。
这不代表真正嵌入业务系统时永远不写前端代码。菜单、客户详情跳转、按钮权限、导出、品牌样式和业务操作仍需要包装。但新增一个查询字段,不再必然意味着后端 DTO、SQL、接口和前端列定义一起改。
Runtime API 接收的不是自然语言,而是受控 Query DSL。
例如汇总 2016 年样例事实期:
{
"columns": [
"count(invoiceLineId) as invoiceLineCount",
"sum(totalIncludingTax) as totalIncludingTax",
"sum(totalExcludingTax) as totalExcludingTax",
"sum(taxAmount) as taxAmount",
"sum(lineProfit) as lineProfit",
"sum(quantity) as quantity"
],
"slice": [
{
"field": "invoiceDate$id",
"op": "[)",
"value": ["2016-01-01", "2016-06-01"]
}
]
}
返回:
{
"invoiceLineCount": 29458,
"totalIncludingTax": 25971029.11,
"totalExcludingTax": 22633175.55,
"taxAmount": 3337853.56,
"lineProfit": 11174765.55,
"quantity": 1241304
}
同一个 QM 还执行了月度趋势和 Top 客户。这里没有增加 /sales/summary、/sales/monthly、/sales/top-customers 三个专用接口;业务系统可以根据自己的权限和交互,把受控 DSL 当作查询服务使用。
当然,这个入口不应该替代创建发票、审核、退款等领域接口。通用语义查询适合读场景,不应该绕过业务系统的写入规则。
MCP 暴露的 dataset.query_model 最终仍然调用这个 QM。
我通过 /mcp/analyst/rpc 执行相同汇总 payload,返回的仍然是:
29,458 条明细
含税金额 25,971,029.11
未税金额 22,633,175.55
税额 3,337,853.56
利润 11,174,765.55
数量 1,241,304
这比“AI 也能查数据库”更重要。
如果页面写一套 SQL、后端接口再写一套、AI 临时生成第三套,那么三个入口迟早会在日期、税额、客户范围和状态上打架。现在它们的交互形式不同,但字段和指标来自同一个 QM,查询还能回放到生成 SQL 和源表。
AI 可以帮助用户选择字段、组合过滤和解释结果,却不能在页面之外另造一套业务事实。
这次最值得保留的失败,不是 SQL 写错,而是客户名称一开始全部为空。
原因是 WWI 在 Sales.Customers 上配置了行级安全:只有属于对应 Sales Territory 角色的用户才能看到该区域客户。
文章使用的 wwi_readonly 最初只有 db_datareader。它可以读取发票和金额,但客户表返回 0 行,于是出现了一个很危险的状态:
销售额有结果;
客户 ID 有结果;
客户名称与地区全部为空。
这说明“数据库账号能连通”不等于“维度数据可见”。
本地验证中,我给文章专用账号加入了八个销售区域角色,才拿到完整样例客户。账号仍然不是 db_datawriter 或 db_ddladmin,也没有 CREATE TABLE 权限。
生产环境不能照抄“加入全部区域”的做法。正确方向是把用户身份映射到应有区域,让页面、API 和 AI 使用同一权限上下文。
本次新增资产如下:
| 资产 | 文件数 | 行数 |
|---|---|---|
| TM | 1 | 210 |
| QM | 1 | 84 |
| 可复现 DSL payload | 4 | 84 |
| 本地 Runtime 启动脚本 | 1 | 61 |
| 合计 | 7 | 439 |
没有新增:
但不能把 439 行理解成生产交付的全部成本。还需要数据库只读账号、Runtime 配置、模型验证、业务口径确认,以及 Data Viewer 的查询链接缓存。
当前 Data Viewer 使用 MongoDB 保存带过期时间的查询上下文。这是一个真实依赖,不应该被“零代码表格”几个字藏起来。
Runtime 0.1.15 下,Query API 和 MCP 都能正确执行带日期过滤的 DSL;Data Viewer 无过滤明细也能正常显示。
但 0.1.15 的 Viewer 适配器在处理带 slice 的缓存查询时,会把空 $expr 当成表达式执行,导致页面加载失败。
我使用当前工作区的 9.2.0-SNAPSHOT 构建做了同一条浏览器验证:日期过滤、6,351 条记录、分页和表格渲染都已通过,没有 console error 或 failed request。
这只能证明缺陷在当前代码中已修复,不能把快照版本写成稳定发布版。正式复现应使用包含该修复的后续发布版本。
这类记录看起来不如成功截图漂亮,但它决定了文章是在展示真实产品状态,还是只展示一条绕开问题的 happy path。
这次直连 OLTP 是合理的,因为:
但这些数字不是性能承诺。我没有在本次验证中运行压测、并发矩阵或生产负载测试。
当分析查询开始影响交易库,或者出现下面这些情况时,就应该评估只读副本、CDC、缓存、预聚合或数仓:
所以这里的结论不是“数仓没用”,而是:企业不必等完整数仓建成,才交付第一块 BI / AI;但第一块能力跑起来后,也要用真实负载决定何时升级数据架构。
如果只看一张表格,直接写 SQL 和接口并不难。
难的是之后用户继续增加日期、客户类别、地区、商品、税率、金额范围、排序、汇总和 AI 问法时,页面、接口和 AI 是否开始各自演化。
这次验证的产品价值不是“少写一条 SQL”,而是把三条交付路径收回到同一个查询模型:
Sales.Invoices + Sales.InvoiceLines
↓
WwiOltpInvoiceLineModel
↓
wwi_oltp_invoice_lines
↙ ↓ ↘
Data Viewer API MCP / AI
企业仍然需要决定什么是销售额、谁能看哪些客户、哪些数据可信;Foggy 没有替企业做这些决定。
但一旦决定被写进 QM,表格、业务查询服务和 AI 不需要再各自猜一次。
下一篇我准备继续沿着这张发票明细表往前走:实际增加一组业务查询条件,看看新增字段和筛选时,到底还要改多少后端与前端代码。那会比抽象讨论“低代码”更容易判断这套产品有没有真实优势。
验证资产:
dataagent/wide-world-importers-oltp-sales/models/dataagent/wide-world-importers-oltp-sales/payloads/docs/wide-world-importers-dataagent-evidence/2026-08-02-wide-world-importers-oltp-sales-closure.md