副标题:从两期汇总到可核对的客户明细
"今年销售额比去年同期少了,具体是哪些客户带来的?"
这是业务人员很自然的问题。技术人员可能会把它写成:按客户分别计算两个期间的 SUM,再做一次差值排序;但客户不会这样提问。他们更关心的是:总额到底少了多少,哪些客户变化最大,接下来能不能继续点进去看。
这类问题已经不是简单地取前几名。只看今年销售额最高的客户,回答不了"谁比去年少了";只看总额,也会把客户之间的增长和下降抵消掉。
这篇用 Wide World Importers 的业务库发票事实做一个完整的比较:2016 年 1~5 月相比 2015 年同期,哪些客户的开票含税销售额下降最多?
先说明证据边界:本文表中的数值来自 OLTP 手写 SQL 基线;wwi_oltp_invoice_lines 的字段定义和单期客户聚合已经通过 QM 描述与 Runtime 查询核对。下面的跨期 composeScript 展示的是受控查询结构,尚未将其宣称为当前 Runtime 已通过 focused execute 验证的稳定能力。
先给结果:总额只小幅下降,客户之间已经发生了很大变化
本例使用的是发票日期,比较范围为:
- 2015-01-01(含)至 2015-06-01(不含);
- 2016-01-01(含)至 2016-06-01(不含)。
这里的"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% |
这里的"变化金额"是:
text
2016 年同期销售额 - 2015 年同期销售额
因此,越负表示下降越多。变化比例是变化金额除以 2015 年同期销售额。
这张表回答的是"哪些客户的销售额下降最多",还没有回答"为什么下降"。把一个数值变化直接说成客户流失、价格问题或销售人员原因,是另一个未经验证的结论。
先把"销售额下降"定义清楚
如果没有这一步,后面的差值即使算得很快,也可能比较的是两个不同的东西。
本例固定了以下口径:
| 业务表达 | 本例采用的定义 |
|---|---|
| 销售额 | 开票含税销售额,不是订单金额 |
| 事实来源 | Sales.Invoices 和 Sales.InvoiceLines |
| 金额字段 | 发票明细的 ExtendedPrice,按明细汇总 |
| 日期 | Sales.Invoices.InvoiceDate |
| 统计粒度 | 客户 |
| 比较区间 | 两个相同长度的 1~5 月区间 |
| 客户识别 | 使用 CustomerID,名称只用于展示 |
| 排序 | 按 2016 年销售额减 2015 年销售额升序 |
这也是为什么结果表里要同时保留客户 ID 和客户名称。名称可能修改、重名或包含地区信息,真正用于两期关联的是稳定的客户 ID。
本次样例数据中,两个期间没有出现无法关联到客户的明细行,因此没有额外的 Unknown 客户行。生产系统仍然需要提前决定:未知客户是单独展示、排除,还是归入一个明确的业务类别,不能让查询执行时临时决定。
同一个 QM,先分别算两期,再比较
前面的文章已经把"开票销售额"和"订单含税金额"分开了。这里继续使用已经确认过的发票 QueryModel:
text
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 |
先看一个期间的查询结构:
json
{
"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 年,只改变日期范围;客户粒度、事实来源和金额度量都不变。
但"两期对比"不能把两个日期条件简单地放进同一个查询里。那样得到的通常是一张合并后的总表,而不是两列可以相减的期间结果。正确的逻辑至少分成三步:
text
第一步:按客户汇总 2015 年同期销售额
customer$id, customer$caption, revenue_2015
第二步:按客户汇总 2016 年同期销售额
customer$id, customer$caption, revenue_2016
第三步:按 customer$id 对齐两张中间结果
delta = revenue_2016 - revenue_2015
deltaRate = delta / revenue_2015(对比期为 0 时留空)
只保留 delta < 0,再按 delta 升序排序
这里的关键不是把两段 SQL 拼在一起,而是让每一步仍然有清楚的语义边界:两期都从同一个 QM 出发,期间条件分别落在 invoiceDate$id,最后用客户 ID 对齐。
如果使用 Foggy 的受控多阶段查询,结构可以表达为两个 dsl 计划和一个语义关联。下面是查询结构示意,不是完整的可直接复制执行脚本:
javascript
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" }
]);
// 下面还需要投影 delta、deltaRate,过滤 delta < 0,按 delta 和 customer_id 稳定排序
这段代码表达的是受控查询计划,不是让模型自由编写数据库 SQL。完整实现还需要明确处理四件事:
full join后,客户 ID、名称和两期金额都要从左右两侧合并展示,不能只取左侧字段;- 只有对比期金额大于 0 时才计算变化比例,对比期为 0 的客户显示"无对比基期"或留空;
- 在派生结果上过滤
delta < 0,否则没有下降客户时,查询可能把增长最少的客户误称为"下降最多"; - 增加
customer_id作为第二排序键,保证差值相同时结果顺序稳定。
真正执行前,还需要由 Runtime 对字段、关联键、派生表达式、权限条件和目标数据库方言进行 validate;执行结果还要和手写 SQL 基线核对。
full 关联的意义是保留只在某一期出现的客户。对于没有 2015 年同期销售额的客户,不能把变化比例硬算成一个有误导性的百分比,应显示为"无对比基期"或留空;对于只有 2015 年、2016 年没有销售额的客户,当前值可以按业务规则视为 0,但也要在结果说明中标记这种边界。
本例的结果主要关注下降客户。当前样例里 2015 年的 647 个有发票明细的客户都能在合并结果中找到,2016 年共有 663 个,因此没有因为只使用两期交集而影响前 5 名;这不能替代生产系统对新增、暂停和消失客户的完整处理。
为什么必须保留中间结果
如果 AI 只返回下面这张 Top 5 表,业务人员很难判断数字是否可信:
text
Svetlana Todorovic -74,889.85
Wingtip Toys ... -68,376.08
...
至少应该能继续看到:
- 两个期间各自的销售额;
- 当前使用的日期范围;
- 使用的是订单金额还是开票销售额;
- 客户是按 ID 还是按名称关联;
- 变化金额与变化比例的计算公式;
- 是否存在没有对比基期的客户;
- 当前结果是否带有权限范围。
权限范围不能只在最终结果表上补充。两个期间的聚合都必须使用同一个可信的用户、组织和授权上下文;否则比较的可能不是同一批业务数据。例如,2015 年和 2016 年分别使用了不同区域权限,即使计算公式完全相同,差值也没有可比性。
所以结果表可以设计成下面这样:
| 客户 | 客户 ID | 对比期销售额 | 当前期销售额 | 下降金额 | 下降比例 | 操作 |
|---|---|---|---|---|---|---|
| Svetlana Todorovic | 947 | 93,921.20 | 19,031.35 | -74,889.85 | -79.74% | 筛选 / 查看 |
聊天窗口只需要先展示前几行和结论,完整结果则可以进入业务系统已有的查询表格:
- 按区域或客户类别继续筛选;
- 修改排序,查看增长最多的客户;
- 展开客户 ID 对应的明细查询;
- 如果系统已经提供客户详情页,再由业务主键跳转过去。
这里的"如果"很重要。Wide World Importers 样例本身没有一个已经接好的客户详情页面。本篇的手写 SQL 基线验证了结果数字,QM 描述和单期查询验证了字段及聚合入口;跨期多阶段执行和详情跳转都不写成已完成能力。
总额下降,不代表每个客户都下降
这次结果还有一个很适合用来检查 AI 分析质量的地方。
2016 年同期总额比 2015 年少 109,990.93,但下降客户的减少额合计为 6,276,207.44,增长客户的增加额合计为 6,166,216.51。两边相互抵消以后,才得到最终的净下降。
这说明"销售额下降了多少"和"哪些客户拖累了结果"是两个层次的问题:
text
总额变化 = 所有客户变化金额的合计
下降客户 = 变化金额 < 0 的客户集合
下降排行 = 按变化金额从小到大排序后的客户集合
如果 AI 只做第一行,它只能报告总额;如果它只返回下降客户,却不提供两个期间的原始聚合值,业务人员又无法核对差值。真正可用的回答需要把汇总、分组和比较过程都留下来。
"下降"是事实,"原因"需要另一组证据
从上面的结果可以确认:这些客户在两个相同长度的开票期间内,含税发票销售额减少了。
但仅凭这张表,不能确认以下结论:
- 客户已经流失;
- 客户减少下单是因为库存不足;
- 价格、折扣或产品结构发生变化;
- 负责销售的人员没有跟进;
- 下一步一定应该降价或重新分配客户。
要分析这些原因,还需要继续组合订单、商品、库存、价格、拜访或客户状态等事实,并为每一个原因定义可验证的指标。比如"库存不足"至少需要检查同一期间、同一客户或商品粒度下的库存事实;不能因为销售额下降和库存都出现在数据库里,就让 AI 自动把两者建立因果关系。
这也是多阶段查询和跨模型查询的边界:查询引擎可以帮助我们把事实按明确的键和条件组合起来,但它不会替业务团队决定因果关系。
这类问题应该如何进入产品
对业务人员来说,入口可以一直保持简单:
text
2016 年 1~5 月相比 2015 年同期,哪些客户销售额下降最多?
系统内部则需要保存一份可解释的上下文:
text
事实:发票明细
指标:开票含税销售额
日期:发票日期
对比期:2015-01-01 至 2015-06-01
当前期:2016-01-01 至 2016-06-01
粒度:客户
排序:变化金额升序
结果:可继续筛选的客户汇总表
用户不需要知道 customer$id、invoiceDate$id 或 totalIncludingTax 这些技术名称,但 AI 和查询引擎必须知道。用户的自然表达负责提出问题,语义模型负责固定口径,多阶段查询负责完成比较,业务表格负责让用户继续检查。
这四层缺一层,结果都会变得不完整:
text
自然问题
↓
语义口径与权限上下文
↓
两期聚合与差值计算
↓
可核对、可筛选、可回到业务系统的结果
最后
"销售额下降了"只是一个汇总事实。真正有用的回答应该继续告诉业务人员:
- 比较的是哪两个期间;
- 使用的是哪一种销售额;
- 哪些客户的差值最负;
- 每个差值是如何计算的;
- 哪些结论仍然需要订单、库存或客户状态来验证。
从一个总数到一张客户明细表,中间不是多写几个字段,而是增加了一次有明确粒度、关联键和边界条件的分析。这样 AI 才不是只报一个"下降了"的数字,而是把业务人员带到下一步可以核对和追查的位置。
下一篇可以继续追问:销售额下降以后,如何沿着同一个客户结果进一步查看订单和商品结构,同时仍然把"相关变化"和"原因结论"分开。