返回正文目录

不建数仓:直接用业务库的发票明细交付表格、查询 API 和 AI 问数

上一篇《26 条题库全跑通,为什么我还不敢把 AI 问数交给用户?》里,我重新划清了“查询能跑”和“能力可以交付”之间的边界。

这次不再继续讲验收方法。我换回 WideWorldImporters 的业务库,实际交付一块销售能力。

先看结果:我只接了发票和发票明细,没有先建数仓,也没有为表格、接口和 AI 分别维护三套查询。

同一个 wwi_oltp_invoice_lines QueryModel 已经可以:

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 一起交付出去。

这次为什么不用上一套 DW 模型

前两篇使用的是 WideWorldImportersDW。

它很适合验证销售、订单、库存和多阶段分析,但对我们当前面对的企业并不够有代表性。很多中小企业有 ERP、TMS、CRM、OMS 或自研业务系统,却没有同步维护良好的数仓。

如果产品必须等客户先完成数仓建设,它就没有回答这些企业眼前的问题。

所以这次我使用的是 OLTP 数据库 WideWorldImporters,而不是 WideWorldImportersDW。第一版也没有扫描全库,只选择:

源数据共有 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 全年”。

一个 QM 同时提供明细和汇总

这次新增了一个 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、接口和前端列定义一起改。

同一份查询也能作为业务 API

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 当作查询服务使用。

当然,这个入口不应该替代创建发票、审核、退款等领域接口。通用语义查询适合读场景,不应该绕过业务系统的写入规则。

AI 入口没有另一套“销售额”

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 可以帮助用户选择字段、组合过滤和解释结果,却不能在页面之外另造一套业务事实。

直连 OLTP 时,权限会真实地挡在面前

这次最值得保留的失败,不是 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 不需要再各自猜一次。

下一篇我准备继续沿着这张发票明细表往前走:实际增加一组业务查询条件,看看新增字段和筛选时,到底还要改多少后端与前端代码。那会比抽象讨论“低代码”更容易判断这套产品有没有真实优势。

验证资产: