上一篇给出了从高价值问题到最小可用本体的五步建设法。本篇已经完成“选定问题”这一步,只展开零售订单案例中的对象、粒度、时间、指标口径、数据映射和复算,不再另建一套五步方法。零售订单本体建模,可以从一句真实问题开始:先确定统计对象和数据粒度,再把支付、发货、退款等变化建成事件,为每个对象和事件声明身份、关系、时间与指标口径,最后映射到源字段、查询逻辑和测试。这样,Data Agent 才能知道“买了”该看支付还是下单,销售额怎样汇总,退款又该扣到哪里。
本案例只解决一个问题:
上一个完整自然月,华东线下门店的会员客户买了哪些 SKU?按净销售额列出前 10 个商品,同时展示销售额、退款金额、净销售额和支付订单数。
在查表之前,先把每个自然语言表达变成可确认的业务定义:
| 自然语言 | 本案例采用的定义 |
|---|---|
| 上一个完整自然月 | 企业业务时区下,付款完成时间落在上一个自然月 |
| 华东线下门店 | 付款发生时,门店类型为线下且区域归属为华东 |
| 会员客户 | 付款发生时,客户处于有效会员状态 |
| 买了 | 订单已经完成支付,以付款完成时间判断 |
| 商品 | SKU 粒度,不合并同一 SPU 下的不同规格 |
| 销售额 | 分摊到订单行的成功支付商品金额,不含运费 |
| 退款金额 | 该批订单截至指定截止时刻已成功退款的商品金额 |
| 净销售额 | 销售额减退款金额,属于本文的经营分析口径 |
这里最容易遗漏的是“退款截止时刻”。一笔销售发生在月末,退款发生在次月,历史结果是否更新,取决于企业采用的退款政策。结果页应同时展示统计月份和退款截止时刻,否则同一问题在不同日期出现不同数字时难以解释。
这个问题需要五个具有稳定身份的对象:客户、订单、订单行、商品和门店。下单、支付、发货和退款则属于围绕对象发生的事件。
零售订单中的对象、关系与事件 客户 → 提交 → 订单 门店 → 受理 → 订单 订单 → 包含 → 订单行 订单行 → 对应 → 商品 SKU 订单 → 发生 → 支付事件 订单行 → 发生 → 发货事件 订单行 → 发生 → 退款事件
对象和事件分开建模,能够保留业务变化过程。一张订单可以有多笔支付,一条订单行可以被分批发货,也可以部分退款、多次退款。只保留订单最终状态,会丢失这些变化,进而影响销售、履约和退款分析。
关系还要声明基数。例如:一个客户可以提交多张订单;一张订单包含多条订单行;一条订单行对应一个 SKU;一条订单行可以发生多次退款。基数不清,支付明细和退款明细一次性连接时就容易把金额成倍放大。
粒度说明一条记录究竟代表什么。不同粒度的事实应分别建模,不能为了查询方便全部塞进一张宽表。
| 对象或事件 | 一条记录代表什么 | 建议身份 |
|---|---|---|
| 客户 | 一个经过主数据识别的客户 | customer_id |
| 订单 | 一个来源系统中的业务订单 | 统一 order_id,保留来源系统和来源订单号 |
| 订单行 | 一张订单中的一个商品明细行 | (order_id, line_no) |
| 商品 | 一个可交易的规格 | sku_id |
| 门店 | 一个销售网点或渠道节点 | store_id |
| 支付事件 | 一笔成功支付交易 | payment_txn_id |
| 退款事件 | 一次针对订单行的退款 | refund_line_id |
手机号、姓名、商品名和门店名都可能变化、重复或缺失,不能直接承担稳定身份。统一客户 ID 需要保留来源客户号、映射依据、生效时间和失效时间。订单号也应携带来源系统,避免不同渠道出现相同编号后被错误合并。
订单数据通常同时包含创建、支付、发货、退款和更新时间。它们回答的是不同问题:
| 时间 | 对应事件 | 适合回答的问题 | 本案例是否使用 |
|---|---|---|---|
| 订单创建时间 | 下单 | 需求趋势、未支付转化 | 否 |
| 付款完成时间 | 订单完成支付 | 已购买商品、支付销售额 | 是,筛选月份 |
| 发货时间 | 发货 | 履约量、发货时效 | 否 |
| 退款成功时间 | 退款 | 退款发生额、售后分析 | 是,判断截止时刻 |
| 技术更新时间 | 数据同步 | 增量处理与变更捕获 | 不进入业务口径 |
本案例把“买了”绑定到订单完成支付。1 月 31 日下单、2 月 1 日付款的订单,按这个定义属于 2 月。如果改用创建时间,订单数和销售额都会跨月。
会员状态和门店区域也带有时间。本案例使用“付款时会员状态”和“付款时门店区域”。客户后来退会、门店后来调整区域,历史结果仍按付款时属性解释,除非企业明确规定追溯重算。
月份范围建议采用半开区间:
payment_completed_at >= period_start AND payment_completed_at < period_end
这样可以稳定处理月末边界,也不会让相邻月份重复包含同一条记录。
商品分析的最低计算粒度是订单行。订单头上的实付金额、优惠和运费属于订单粒度,直接复制到每条订单行后汇总,会重复计算。
本案例采用:
销售额 = 所有目标订单行的实付商品金额之和 退款金额 = 这些订单行截至退款截止时刻的成功退款金额之和 净销售额 = 销售额 - 退款金额 金额退款率 = 退款金额 ÷ 销售额
如果数据源已经提供经过结算的行级实付金额,可以直接使用。如果只有订单级实付,就需要由业务和财务确认分摊规则,并确保所有订单行分摊后的金额合计等于订单商品实付金额。
退款还要先选择时间政策:
| 退款政策 | 计算方式 | 更适合回答 |
|---|---|---|
| 支付批次口径 | 取本期支付订单,扣除这些订单截至截止时刻的成功退款 | 上个月卖出的商品后来实际留下多少 |
| 当期发生口径 | 本期支付额减去本期发生的退款额 | 本月支付与退款的净流入怎样 |
本案例采用支付批次口径。需要注意,本文的“净销售额”是经营分析指标,不等同于财务会计中的收入确认。正式项目应由财务确认名称、适用范围和计算政策。
每个业务定义都要能够追溯到物理来源。映射表至少包含:
| 业务属性 | 物理来源示例 | 关键规则 | 必要检查 |
|---|---|---|---|
| 客户身份 | 客户主数据与跨系统映射表 | 来源客户号映射统一客户 ID | 同一有效来源记录只能命中一个 ID |
| 付款时会员状态 | 客户历史表 | 按付款完成时间做有效期关联 | 历史区间不能重叠 |
| 订单身份 | 订单系统 | 来源系统加来源订单号映射统一 ID | 来源自然键唯一 |
| 订单行商品 | 订单明细与商品主数据 | 订单行映射 SKU | 有效订单行必须命中 SKU |
| 行级实付 | 行级结算或经批准的分摊结果 | 按订单行保存 | 行级合计与订单商品实付守恒 |
| 退款 | 退款明细 | 只取成功退款并关联订单行 | 累计退款不能无依据超过可退金额 |
| 门店区域 | 门店历史表 | 按付款时间取有效区域 | 历史区间完整且不重叠 |
在查询层,先把自然语言转换成结构化意图,明确统计对象、筛选条件、指标、分组、退款政策和排序,再映射到底层查询。这样,业务人员可以检查“系统理解了什么”,数据人员可以继续检查“这些理解落到了哪些字段和逻辑”。
在亿问 Data Agent 的公开技术架构中(https://yiwendata.com/product#technology),Alisa 将经标准化的问题意图转为 LogicForm;SemanticDB 根据企业语义与物理映射,把 LogicForm 映射为 SQL、API 或 URL 等执行目标;执行层在权限与审计边界内完成调用并返回结果。SQL 是这条链路可能采用的执行结果之一,不是业务含义的起点。
| 常见错误 | 会造成什么结果 | 正确处理 |
|---|---|---|
| 支付和退款明细直接同时 Join | 一对多关系相乘,金额被放大 | 先分别聚合到订单行,再合并 |
用 DISTINCT 掩盖重复 | 真实事件可能被误删,重复仍未解决 | 先明确粒度和关系基数 |
| 用订单创建时间解释“买了” | 跨月订单被统计到错误月份 | 将“买了”绑定已确认的业务事件 |
| 只看订单最终状态 | 部分退款订单的有效销售被丢弃 | 保留行级支付和退款事件 |
| 按商品名分组 | 改名或同名规格被错误合并 | 用 sku_id 承担身份,名称只展示 |
假设有两条符合条件的会员订单行:SKU-A 的行实付分别为 80 元和 50 元,成功退款分别为 20 元和 50 元;SKU-B 的行实付为 120 元,没有退款。
| SKU | 销售额 | 退款金额 | 净销售额 | 支付订单数 |
|---|---|---|---|---|
| SKU-B | 120 | 0 | 120 | 1 |
| SKU-A | 130 | 70 | 60 | 2 |
这组数据的价值不是模拟真实经营,而是提供一个任何环境都能复算的验收样本。调整退款截止时刻后,结果应按已声明政策变化;更换“买了”对应的事件后,月份归属也应随之变化。
上线前还应覆盖主键唯一、引用完整、月初月末边界、历史快照、金额分摊守恒、一对多去重、退款上限、同义问法和权限范围等测试。
以下内容供技术读者选读,用于展示建模结果怎样进入结构化意图和查询骨架;跳过本节不影响对文章主线的理解。下面只展示教学化结构,用于说明问题怎样进入执行层。字段名称不代表亿问产品的实际接口格式。
{"subject": "OrderLine", "filters": [ "Customer.member_flag_at_payment = true", "Store.region_at_payment = '华东'", "Store.type_at_payment = '线下'", "Order.payment_completed_at in previous_complete_month" ], "metrics": [ "sales_amount", "refund_amount_as_of_cutoff", "net_sales_amount", "paid_order_count" ], "group_by": ["Product.sku_id"], "refund_policy": "paid_cohort_as_of_cutoff", "order_by": "net_sales_amount desc", "limit": 10}对应的查询应先圈定支付订单行,再将退款聚合到相同订单行粒度,最后按 SKU 汇总:
WITH paid_lines AS (...按付款月份、会员和门店筛选订单行...), refunds_by_line AS (...按退款截止时刻聚合到订单行...), line_results AS (...合并每条订单行的实付与退款...) SELECT sku_id, SUM(sales_amount) AS sales_amount, SUM(refund_amount) AS refund_amount, SUM(sales_amount) - SUM(refund_amount) AS net_sales_amount FROM line_results GROUP BY sku_id ORDER BY net_sales_amount DESC;
技术实现可以变化,但“业务问题—结构化语义—字段映射—查询—测试结果”这条对应关系应始终可以检查。
宽表可以提升某些查询效率,但不一定保留支付、退款、发货等事件的独立粒度。只要业务问题涉及多次支付、部分退款或历史状态,就需要先确认宽表是否仍能还原这些事实。
商品是订单行上的对象。订单头金额如果直接复制到每个商品行,按 SKU 汇总时会被重复计算。只有先把金额可靠分配到订单行,商品分析才有稳定基础。
不能直接推导。毛利还需要采购成本、履约成本、营销分摊、币种和成本版本等定义,应由新的业务问题触发独立扩展和验收。
“上个月买了什么”不是一句自然语言到一段 SQL 的直接翻译。系统要先确认谁买、何时算买、商品按什么粒度、销售和退款怎样计算,再把这些定义映射到数据与查询。对象提供稳定身份,事件保存业务变化,关系说明事实怎样连接,指标口径决定数字怎样产生。只有这几层能够逐项复核,Data Agent 才能把看似简单的问数变成企业可以共同确认的答案。