副标题:从两期汇总到可核对的客户明细
“今年销售额比去年同期少了,具体是哪些客户带来的?”
这是业务人员很自然的问题。技术人员可能会把它写成:按客户分别计算两个期间的 SUM,再做一次差值排序;但客户不会这样提问。他们更关心的是:总额到底少了多少,哪些客户变化最大,接下来能不能继续点进去看。
这类问题已经不是简单地取前几名。只看今年销售额最高的客户,回答不了“谁比去年少了”;只看总额,也会把客户之间的增长和下降抵消掉。
这篇用 Wide World Importers 的业务库发票事实做一个完整的比较:2016 年 1~5 月相比 2015 年同期,哪些客户的开票含税销售额下降最多?
先说明证据边界:本文表中的数值来自 OLTP 手写 SQL 基线;wwi_oltp_invoice_lines 的字段定义和单期客户聚合已经通过 QM 描述与 Runtime 查询核对。本文的跨期 composeScript 也已经在本地 dev/test Runtime 0.1.15 上通过 validate、preview 和只读 execute,结果与手写 SQL 基线的前 5 名一致。这个 focused 验证证明的是 QM → 多阶段计划 → SQL → 结果这条链路;使用的是隔离的本地测试连接,不把它当作生产权限验证或客户详情页跳转证据。
本例使用的是发票日期,比较范围为:
这里的“2016 年”只指样例数据中的 1~5 月,不是 2016 全年。
两个期间的总量如下:
| 指标 | 2015 年 1~5 月 | 2016 年 1~5 月 | 变化 |
|---|---|---|---|
| 发票数 | 9,108 | 9,190 | +82 |
| 发票明细行数 | 29,483 | 29,458 | -25 |
| 开票含税销售额 | 26,081,020.04 | 25,971,029.11 | -109,990.93 |
| 有发票明细的客户数 | 647 | 663 | +16 |
销售额净减少 109,990.93,约下降 0.42%。如果只看这个数字,可能会觉得变化并不大。
但把结果按客户对齐后,663 个有发票明细的客户中有 342 个客户下降,321 个客户增长。增长客户抵消了下降客户的很大一部分影响,所以总额看起来只是小幅变化,并不代表客户层面的变化也小。
按销售额差值从小到大排序,下降最多的前 5 个客户是:
| 客户 | 2015 年同期 | 2016 年 1~5 月 | 变化金额 | 变化比例 |
|---|---|---|---|---|
| Svetlana Todorovic | 93,921.20 | 19,031.35 | -74,889.85 | -79.74% |
| Wingtip Toys (Schererville, IN) | 96,368.11 | 27,992.03 | -68,376.08 | -70.95% |
| Wingtip Toys (Montoya, NM) | 99,056.12 | 31,356.48 | -67,699.64 | -68.34% |
| Wingtip Toys (Knights Landing, CA) | 83,313.72 | 19,259.73 | -64,053.99 | -76.88% |
| Wingtip Toys (Truscott, TX) | 79,041.35 | 21,890.51 | -57,150.84 | -72.30% |
这里的“变化金额”是:
2016 年同期销售额 - 2015 年同期销售额
因此,越负表示下降越多。变化比例是变化金额除以 2015 年同期销售额。
这张表回答的是“哪些客户的销售额下降最多”,还没有回答“为什么下降”。把一个数值变化直接说成客户流失、价格问题或销售人员原因,是另一个未经验证的结论。
如果没有这一步,后面的差值即使算得很快,也可能比较的是两个不同的东西。
本例固定了以下口径:
| 业务表达 | 本例采用的定义 |
|---|---|
| 销售额 | 开票含税销售额,不是订单金额 |
| 事实来源 | Sales.Invoices 和 Sales.InvoiceLines |
| 金额字段 | 发票明细的 ExtendedPrice,按明细汇总 |
| 日期 | Sales.Invoices.InvoiceDate |
| 统计粒度 | 客户 |
| 比较区间 | 两个相同长度的 1~5 月区间 |
| 客户识别 | 使用 CustomerID,名称只用于展示 |
| 排序 | 按 2016 年销售额减 2015 年销售额升序 |
这也是为什么结果表里要同时保留客户 ID 和客户名称。名称可能修改、重名或包含地区信息,真正用于两期关联的是稳定的客户 ID。
本次样例数据中,两个期间没有出现无法关联到客户的明细行,因此没有额外的 Unknown 客户行。生产系统仍然需要提前决定:未知客户是单独展示、排除,还是归入一个明确的业务类别,不能让查询执行时临时决定。
前面的文章已经把“开票销售额”和“订单含税金额”分开了。这里继续使用已经确认过的发票 QueryModel:
wwi_oltp_invoice_lines
这个模型对上层暴露的是业务字段,而不是让 AI 直接猜数据库表:
先把几个技术词翻译成人话:QM(QueryModel)是业务可查询模型,DSL 是结构化查询描述;后面出现的 full join,表示两期中只出现在其中一期的客户也要保留下来,而不是只保留两期都出现的客户。
| 语义字段 | 作用 | 物理来源 |
|---|---|---|
customer$id |
客户稳定 ID,用于分组和关联 | Sales.Customers.CustomerID |
customer$caption |
客户名称,用于展示 | 客户维度名称 |
customer$category |
客户类别,后续可筛选 | 客户维度属性 |
customer$region |
客户区域,后续可筛选 | 客户维度属性 |
invoiceDate$id |
发票日期过滤 | Sales.Invoices.InvoiceDate |
totalIncludingTax |
含税金额度量,默认 SUM |
Sales.InvoiceLines.ExtendedPrice |
先看一个期间的查询结构:
{
"model": "wwi_oltp_invoice_lines",
"columns": [
"customer$id",
"customer$caption",
"sum(totalIncludingTax) as revenue"
],
"groupBy": [
"customer$id",
"customer$caption"
],
"slice": [
{
"field": "invoiceDate$id",
"op": "[)",
"value": ["2015-01-01", "2015-06-01"]
}
]
}
换成 2016 年,只改变日期范围;客户粒度、事实来源和金额度量都不变。
但“两期对比”不能把两个日期条件简单地放进同一个查询里。那样得到的通常是一张合并后的总表,而不是两列可以相减的期间结果。正确的逻辑至少分成三步:
第一步:按客户汇总 2015 年同期销售额
customer$id, customer$caption, revenue_2015
第二步:按客户汇总 2016 年同期销售额
customer$id, customer$caption, revenue_2016
第三步:按 customer$id 对齐两张中间结果
delta = revenue_2016 - revenue_2015
delta_rate = delta / revenue_2015(对比期为 0 时留空;脚本中用 NULLIF 保护)
只保留 delta < 0,再按 delta 升序排序
这里的关键不是把两段 SQL 拼在一起,而是让每一步仍然有清楚的语义边界:两期都从同一个 QM 出发,期间条件分别落在 invoiceDate$id,最后用客户 ID 对齐。
如果使用 Foggy 的受控多阶段查询,结构可以表达为两个 dsl 计划和一个语义关联。下面是本篇实际验证过的完整脚本,文件也保存在 dataagent/wide-world-importers-oltp-sales/compose/wwi-oltp-declining-customers.fsscript:
const prior = dsl({
model: "wwi_oltp_invoice_lines",
columns: [
"customer$id as customer_id",
"customer$caption as customer_name",
"sum(totalIncludingTax) as revenue_2015"
],
groupBy: ["customer$id", "customer$caption"],
slice: [
{ field: "invoiceDate$id", op: "[)", value: ["2015-01-01", "2015-06-01"] }
]
});
const current = dsl({
model: "wwi_oltp_invoice_lines",
columns: [
"customer$id as customer_id_2016",
"customer$caption as customer_name_2016",
"sum(totalIncludingTax) as revenue_2016"
],
groupBy: ["customer$id", "customer$caption"],
slice: [
{ field: "invoiceDate$id", op: "[)", value: ["2016-01-01", "2016-06-01"] }
]
});
const joined = prior.join(current, "full", [
{ left: "customer_id", op: "=", right: "customer_id_2016" }
]);
// full join 后合并两侧字段,缺失期间按 0 参与金额差值
const aligned = joined.query({
columns: [
"COALESCE(customer_id, customer_id_2016) as customer_id",
"COALESCE(customer_name, customer_name_2016) as customer_name",
"COALESCE(revenue_2015, 0) as revenue_2015",
"COALESCE(revenue_2016, 0) as revenue_2016"
]
});
// 先生成派生指标,再在下一层过滤新生成的 delta
const metrics = aligned.query({
columns: [
"customer_id",
"customer_name",
"revenue_2015",
"revenue_2016",
"revenue_2016 - revenue_2015 as delta",
"(revenue_2016 - revenue_2015) / NULLIF(revenue_2015, 0) as delta_rate"
]
});
const result = metrics.query({
slice: [
{ field: "delta", op: "<", value: 0 }
],
columns: [
"customer_id",
"customer_name",
"revenue_2015",
"revenue_2016",
"delta",
"delta_rate"
],
orderBy: [
{ field: "delta", dir: "ASC" },
{ field: "customer_id", dir: "ASC" }
],
limit: 5
});
return { plans: result };
这段代码表达的是受控查询计划,不是让模型自由编写数据库 SQL。补齐后,几个关键点分别落在代码中:
full join 后用 COALESCE 合并左右两侧的客户键、名称和金额;只在一侧出现的客户不会因为连接方式直接消失;NULLIF(revenue_2015, 0) 保护变化比例,对比期为 0 时结果为空,而不是制造一个没有业务意义的百分比;delta < 0,避免同层引用刚生成的别名,也避免把“增长最少”误称为“下降”;customer_id 作为第二排序键,保证差值相同时结果顺序稳定;limit: 5 只控制首屏返回的前 5 名,完整结果可以去掉限制后交给业务表格分页。执行前由 Runtime 对字段、关联键、派生表达式和目标数据库方言进行 validate,再用 preview 检查生成的 SQL;本次在 SQL Server 方言下执行成功,首行是 Svetlana Todorovic,delta=-74889.85,与基线一致。生产环境还必须在同一个用户、组织和权限上下文中执行两期聚合,不能因为本地 focused execute 通过,就跳过权限验证。
full 关联的意义是保留只在某一期出现的客户。对于没有 2015 年同期销售额的客户,不能把变化比例硬算成一个有误导性的百分比,应显示为“无对比基期”或留空;对于只有 2015 年、2016 年没有销售额的客户,当前值可以按业务规则视为 0,但也要在结果说明中标记这种边界。
本例的结果主要关注下降客户。当前样例里 2015 年的 647 个有发票明细的客户都能在合并结果中找到,2016 年共有 663 个,因此没有因为只使用两期交集而影响前 5 名;这不能替代生产系统对新增、暂停和消失客户的完整处理。
如果 AI 只返回下面这张 Top 5 表,业务人员很难判断数字是否可信:
Svetlana Todorovic -74,889.85
Wingtip Toys ... -68,376.08
...
至少应该能继续看到:
权限范围不能只在最终结果表上补充。两个期间的聚合都必须使用同一个可信的用户、组织和授权上下文;否则比较的可能不是同一批业务数据。例如,2015 年和 2016 年分别使用了不同区域权限,即使计算公式完全相同,差值也没有可比性。
所以结果表可以设计成下面这样:
| 客户 | 客户 ID | 对比期销售额 | 当前期销售额 | 下降金额 | 下降比例 | 操作 |
|---|---|---|---|---|---|---|
| Svetlana Todorovic | 947 | 93,921.20 | 19,031.35 | -74,889.85 | -79.74% | 筛选 / 查看 |
聊天窗口只需要先展示前几行和结论,完整结果则可以进入业务系统已有的查询表格:
这里的“如果”很重要。Wide World Importers 样例本身没有一个已经接好的客户详情页面。本篇的手写 SQL 基线和 Runtime focused execute 都验证了跨期结果;但客户详情跳转仍然没有在这个样例中接通,不能把“结果可继续查询”写成“已经完成页面导航”。
这次结果还有一个很适合用来检查 AI 分析质量的地方。
2016 年同期总额比 2015 年少 109,990.93,但下降客户的减少额合计为 6,276,207.44,增长客户的增加额合计为 6,166,216.51。两边相互抵消以后,才得到最终的净下降。
这说明“销售额下降了多少”和“哪些客户拖累了结果”是两个层次的问题:
总额变化 = 所有客户变化金额的合计
下降客户 = 变化金额 < 0 的客户集合
下降排行 = 按变化金额从小到大排序后的客户集合
如果 AI 只做第一行,它只能报告总额;如果它只返回下降客户,却不提供两个期间的原始聚合值,业务人员又无法核对差值。真正可用的回答需要把汇总、分组和比较过程都留下来。
从上面的结果可以确认:这些客户在两个相同长度的开票期间内,含税发票销售额减少了。
但仅凭这张表,不能确认以下结论:
要分析这些原因,还需要继续组合订单、商品、库存、价格、拜访或客户状态等事实,并为每一个原因定义可验证的指标。比如“库存不足”至少需要检查同一期间、同一客户或商品粒度下的库存事实;不能因为销售额下降和库存都出现在数据库里,就让 AI 自动把两者建立因果关系。
这也是多阶段查询和跨模型查询的边界:查询引擎可以帮助我们把事实按明确的键和条件组合起来,但它不会替业务团队决定因果关系。
对业务人员来说,入口可以一直保持简单:
2016 年 1~5 月相比 2015 年同期,哪些客户销售额下降最多?
系统内部则需要保存一份可解释的上下文:
事实:发票明细
指标:开票含税销售额
日期:发票日期
对比期:2015-01-01 至 2015-06-01
当前期:2016-01-01 至 2016-06-01
粒度:客户
排序:变化金额升序
结果:可继续筛选的客户汇总表
用户不需要知道 customer$id、invoiceDate$id 或 totalIncludingTax 这些技术名称,但 AI 和查询引擎必须知道。用户的自然表达负责提出问题,语义模型负责固定口径,多阶段查询负责完成比较,业务表格负责让用户继续检查。
这四层缺一层,结果都会变得不完整:
自然问题
↓
语义口径与权限上下文
↓
两期聚合与差值计算
↓
可核对、可筛选、可回到业务系统的结果
“销售额下降了”只是一个汇总事实。真正有用的回答应该继续告诉业务人员:
从一个总数到一张客户明细表,中间不是多写几个字段,而是增加了一次有明确粒度、关联键和边界条件的分析。这样 AI 才不是只报一个“下降了”的数字,而是把业务人员带到下一步可以核对和追查的位置。
下一篇可以继续追问:销售额下降以后,如何沿着同一个客户结果进一步查看订单和商品结构,同时仍然把“相关变化”和“原因结论”分开。