AI 说销售额下降了,哪些客户拖累了结果?

副标题:从两期汇总到可核对的客户明细

"今年销售额比去年同期少了,具体是哪些客户带来的?"

这是业务人员很自然的问题。技术人员可能会把它写成:按客户分别计算两个期间的 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.InvoicesSales.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% 筛选 / 查看

聊天窗口只需要先展示前几行和结论,完整结果则可以进入业务系统已有的查询表格:

  1. 按区域或客户类别继续筛选;
  2. 修改排序,查看增长最多的客户;
  3. 展开客户 ID 对应的明细查询;
  4. 如果系统已经提供客户详情页,再由业务主键跳转过去。

这里的"如果"很重要。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$idinvoiceDate$idtotalIncludingTax 这些技术名称,但 AI 和查询引擎必须知道。用户的自然表达负责提出问题,语义模型负责固定口径,多阶段查询负责完成比较,业务表格负责让用户继续检查。

这四层缺一层,结果都会变得不完整:

text 复制代码
自然问题
   ↓
语义口径与权限上下文
   ↓
两期聚合与差值计算
   ↓
可核对、可筛选、可回到业务系统的结果

最后

"销售额下降了"只是一个汇总事实。真正有用的回答应该继续告诉业务人员:

  • 比较的是哪两个期间;
  • 使用的是哪一种销售额;
  • 哪些客户的差值最负;
  • 每个差值是如何计算的;
  • 哪些结论仍然需要订单、库存或客户状态来验证。

从一个总数到一张客户明细表,中间不是多写几个字段,而是增加了一次有明确粒度、关联键和边界条件的分析。这样 AI 才不是只报一个"下降了"的数字,而是把业务人员带到下一步可以核对和追查的位置。

下一篇可以继续追问:销售额下降以后,如何沿着同一个客户结果进一步查看订单和商品结构,同时仍然把"相关变化"和"原因结论"分开。

相关推荐
用户852495071841 小时前
NestJS 架构实战:给后端代码请来一位“项目经理
后端
唐青枫1 小时前
一个点号省掉一堆类型:Zig .{} 语法、类型推导与实战
后端
月才1 小时前
告别System.out.println,打造专业日志系统
后端
无糖可可果1 小时前
从零理解 Docker:为什么我的代码在你的电脑上跑不起来?
javascript·后端
泡海椒1 小时前
轻量级 Java JSON 库推荐:jquick-json 序列化/反序列化 + 变量替换 + 字节码优化全解析
后端
触底反弹1 小时前
🔥 NestJS 从零到实战:一个 Todos CRUD 搞懂企业级后端框架的核心设计
后端·typescript·nestjs
吃饱了得干活1 小时前
从经典的三层架构到DDD:一次对“业务逻辑层”的解剖与重构
java·后端·架构
XuCoder1 小时前
面试官:什么是覆盖索引?
后端
林太白1 小时前
What did Trea work help me with in this optimization process
前端·后端