跳转至

一个 LOD 模型回答五个电商问题

本例用一个语义模型、三张原表回答五个常见的用户、交易与退款问题, 涉及固定字段、按用户等权再聚合、include、exclude、固定指标、组合计算和独立参数窗口。 数据全部合成。概念见理解 LOD,规则见 LOD 编写参考。

运行样例

先安装 Dosi 和 DuckDB CLI,使用默认的 datus 模式。 核对脚本需要 Python 3.10 以上版本,无须安装第三方 Python 包。

样例文件随 Dosi 安装归档发布,目录为 examples/lod-commerce。 如果 dosi info 显示的样例目录里没有 lod-commerce,请升级 Dosi。 使用安装脚本时,样例位于 ~/.local/share/dosi/examples;自行解压安装包时, 将 DOSI_EXAMPLES 指向归档里的 examples/ 目录。

样例目录包含完整的 model.yaml、含建表语句和全部合成记录的 seed.sql、 queries.json、expected.json、五份独立原始 SQL、核对脚本和 README。 使用已安装的版本时,先复制样例:

export DOSI_EXAMPLES=~/.local/share/dosi/examples
cp -R "$DOSI_EXAMPLES/lod-commerce" ./lod-commerce

也可以在包含本教程的源码根目录复制:

cp -R fixtures/lod_commerce ./lod-commerce

然后进入工作副本,初始化并核对:

cd lod-commerce
dosi --version
duckdb commerce.duckdb < seed.sql
dosi validate --model model.yaml --strict
python3 verify_lod_commerce.py --case-dir . --variants baseline --report report.json

初始化时使用新数据库文件。最后一条命令执行下文五条语义查询,并用独立原始 SQL 核对完整结果。去掉 --variants baseline 可在五种数据状态下重复运行,共核对 25 次, 期间不修改模型。report.json 保存实际结果、生成 SQL 和校验信息。 下列表格使用业务输出列名,queries.json 明确记录 CLI 指标/字段名到这些名称的映射。 核对不要求行顺序一致。

原始数据与业务口径

基础数据有 7 个用户、12 笔已支付订单、9 份退款申请,完整覆盖至 2030 年 5 月 31 日。 金额单位为人民币元,类型为 DECIMAL(12,2)。日期区间左闭右开,4 月包括 4 月 1 日, 不包括 5 月 1 日。

原表 一行代表什么 原始字段
users 一个注册用户 user_id, signup_date, region, acquisition_channel
orders 一笔支付成功的订单 order_id, user_id, paid_date, paid_date_key, channel, paid_amount
refunds 一份退款申请的当前状态 refund_id, order_id, user_id, requested_date, resolved_date, resolved_date_key, status, requested_amount

三个主键分别为用户编号、订单编号和退款申请编号。退款表的 order_id 可以重复, 因为一笔订单可能有多次部分退款。订单都属于已注册用户,退款用户与订单买家一致。 paid_amount 保留原始实付金额,不因退款扣减。同一用户同一天的多笔订单仍是多次购买。

acquisition_channel 是用户注册时的获客来源,取值为 ads、organic、referral; 订单渠道 channel 则是 app 或 web,两者不能混用。地区是当前属性。 本例没有获客成本,也不涉及多触点归因。

退款状态为 SUCCEEDED、REJECTED 或 PENDING。只有成功申请的金额才是已退款金额。 成功申请的 resolved_date 是退款成功日期;处理中申请没有完成日期。 整数日期码采用 YYYYMMDD 编码,NULL 保持 NULL。成功退款累计不超过订单实付金额。 这些日期和金额都是原始字段,没有提前计算业务指标。

模型声明订单到用户、退款到用户两条普通多对一关系,不直接连接两张明细表。 U07 在基础数据中没有订单,不能进入付费用户均值的分母,也不能进入 4 月买家的召回名单。

模型如何区分计算粒度

用途 模型定义 为什么采用这个粒度
买家分类 orders.buyer_order_count = fixed(order_count, orders.user_id) 每个买家一个购买次数
用户累计消费等权平均 orders.buyer_lifetime_paid = fixed(paid_total, orders.user_id);mean_lifetime_paid 对该字段求平均 每个买家只贡献一次
渠道内购买频次 AVG(include(order_count, orders.user_id)) 与 MAX(include(order_count, orders.user_id)) 在展示渠道上增加买家粒度
当天全站基准 site_amount = fixed(paid_total, orders.paid_date) 保留日期,不受 app 展示过滤影响
所选渠道月总额 app_month_amount = exclude(paid_total, orders.paid_date) 去掉日期,保留渠道及其筛选
召回评估 用户表承载固定字段,base 分别筛选订单和退款 窗口独立,每个用户输出一行

表中是 DATUS 声明含义。完整模型还包含对应的核心 SQL:固定字段保留基础聚合, include 用 AVG 或 MAX 包住基础聚合,fixed/exclude 指标使用 SUM 包装。 两个 compose 指标分别除以当天全站基准和渠道月总额,使用 NULLIF 在分母为零时返回 NULL。 模型不需要嵌套 LOD 或在 source 中提前汇总数据。

公共数据集、关系与基础指标

完整的 model.yaml 和 seed.sql 在运行样例里复制出来的样例目录中。 下面是公共模型,包括原始字段、真实主键、 两条多对一关系和普通基础指标。查询时间范围通过声明为时间维度的 orders.paid_date 路由。

展开公共模型
version: 0.2.0.dev0
semantic_model:
- name: commerce
  datasets:
  - name: users
    source: users
    primary_key:
    - user_id
    fields:
    - name: user_id
      expression: {dialects: [{dialect: ANSI_SQL, expression: user_id}]}
      datatype: String
      dimension: {}
    - name: region
      expression: {dialects: [{dialect: ANSI_SQL, expression: region}]}
      datatype: String
      dimension: {}
    - name: acquisition_channel
      expression: {dialects: [{dialect: ANSI_SQL, expression: acquisition_channel}]}
      datatype: String
      dimension: {}
  - name: orders
    source: orders
    primary_key:
    - order_id
    fields:
    - name: order_id
      expression: {dialects: [{dialect: ANSI_SQL, expression: order_id}]}
      datatype: String
      custom_extensions:
      - vendor_name: DATUS
        data: '{"v":"1.11","is_dimension":false}'
    - name: user_id
      expression: {dialects: [{dialect: ANSI_SQL, expression: user_id}]}
      datatype: String
      dimension: {}
    - name: paid_date
      expression: {dialects: [{dialect: ANSI_SQL, expression: paid_date}]}
      datatype: Date
      dimension:
        is_time: true
    - name: paid_date_key
      expression: {dialects: [{dialect: ANSI_SQL, expression: paid_date_key}]}
      datatype: Integer
      custom_extensions:
      - vendor_name: DATUS
        data: '{"v":"1.11","is_dimension":false}'
    - name: channel
      expression: {dialects: [{dialect: ANSI_SQL, expression: channel}]}
      datatype: String
      dimension: {}
    - name: paid_amount
      expression: {dialects: [{dialect: ANSI_SQL, expression: paid_amount}]}
      datatype: Decimal
      custom_extensions:
      - vendor_name: DATUS
        data: '{"v":"1.11","is_dimension":false}'
  - name: refunds
    source: refunds
    primary_key:
    - refund_id
    fields:
    - name: refund_id
      expression: {dialects: [{dialect: ANSI_SQL, expression: refund_id}]}
      datatype: String
      custom_extensions:
      - vendor_name: DATUS
        data: '{"v":"1.11","is_dimension":false}'
    - name: order_id
      expression: {dialects: [{dialect: ANSI_SQL, expression: order_id}]}
      datatype: String
      custom_extensions:
      - vendor_name: DATUS
        data: '{"v":"1.11","is_dimension":false}'
    - name: user_id
      expression: {dialects: [{dialect: ANSI_SQL, expression: user_id}]}
      datatype: String
      dimension: {}
    - name: resolved_date_key
      expression: {dialects: [{dialect: ANSI_SQL, expression: resolved_date_key}]}
      datatype: Integer
      custom_extensions:
      - vendor_name: DATUS
        data: '{"v":"1.11","is_dimension":false}'
    - name: status
      expression: {dialects: [{dialect: ANSI_SQL, expression: status}]}
      datatype: String
      dimension: {}
    - name: requested_amount
      expression: {dialects: [{dialect: ANSI_SQL, expression: requested_amount}]}
      datatype: Decimal
      custom_extensions:
      - vendor_name: DATUS
        data: '{"v":"1.11","is_dimension":false}'
  relationships:
  - name: orders_to_users
    from: orders
    to: users
    from_columns:
    - user_id
    to_columns:
    - user_id
  - name: refunds_to_users
    from: refunds
    to: users
    from_columns:
    - user_id
    to_columns:
    - user_id
  metrics:
  - name: paid_total
    expression:
      dialects:
      - dialect: ANSI_SQL
        expression: SUM(orders.paid_amount)
  - name: order_count
    expression:
      dialects:
      - dialect: ANSI_SQL
        expression: COUNT(DISTINCT orders.order_id)
  - name: buyers
    expression:
      dialects:
      - dialect: ANSI_SQL
        expression: COUNT(DISTINCT orders.user_id)
  - name: refund_requested_total
    expression:
      dialects:
      - dialect: ANSI_SQL
        expression: SUM(refunds.requested_amount)

后面五题分别展示需要增加的定义。将 fields 合并到同名数据集的字段列表, 将 metrics 合并到模型已有的指标列表,不要替换整个数据集,也不要重复定义同名数据集。 补齐五题的定义后,就是完整电商模型。使用安装目录里的 model.yaml 时,这些定义已经全部存在。

1. 复购用户贡献了多少销售额?

截至 5 月 31 日,把有购买行为的用户分为一次购买和多次购买两组,每个用户计数一次, 金额则汇总该组全部订单。截止日期放在上下文中,同时限定固定分类和金额汇总。 查询使用普通原始行指标,按固定值派生的分类字段分组。

模型定义。 将下面两个字段加入 orders.fields。公共模型的 order_count 按订单去重计数;固定字段把它变成每位买家一个购买次数,buyer_group 再把次数转换成分类维度。 订单表只有支付成功的订单,因此其中每位买家至少购买过一次。这段 CASE 不用于划分未购买用户。

datasets:
- name: orders
  fields:
  - name: buyer_order_count
    expression:
      dialects:
      - dialect: ANSI_SQL
        expression: COUNT(DISTINCT orders.order_id)
    datatype: Integer
    custom_extensions:
    - vendor_name: DATUS
      data: '{"v":"1.11","is_dimension":false,"lod":{"expr":"fixed(order_count, orders.user_id)"}}'
  - name: buyer_group
    expression:
      dialects:
      - dialect: ANSI_SQL
        expression: CASE WHEN buyer_order_count = 1 THEN 'once' ELSE 'repeat' END
    datatype: String
    dimension: {}

查询。

dosi query --model model.yaml --format json \
  --metrics buyers,paid_total \
  --group-by orders.buyer_group \
  --context-filter 'orders.paid_date < DATE '"'"'2030-06-01'"'"'' \
  --execute --db commerce.duckdb
buyer_group buyers paid_amount
once 3 500
repeat 3 900

清单将 paid_total 映射为 paid_amount。U01、U02、U03 属于多次购买组。 退款不减少这里的原始实付金额。

2. 哪个获客渠道带来的付费用户累计消费更高?

先计算每个付费用户的累计消费,再按注册来源让每个用户等权参与平均。 这衡量用户消费水平,不是平均订单金额,也不是获客投入回报。 有四笔订单的用户不能获得四倍权重。

模型定义。 将字段加入 orders.fields,指标加入模型的 metrics。 固定键是买家,查询则通过 orders_to_users 按获客渠道分组。 普通指标对固定字段求平均,每个展示渠道内每位买家只贡献一次。 如果写成 AVG(orders.paid_amount),得到的就是订单均值。 字段放在订单表上,统计对象自然是付费买家;改放用户表时,需要另外明确未购买用户是否参与。

datasets:
- name: orders
  fields:
  - name: buyer_lifetime_paid
    expression:
      dialects:
      - dialect: ANSI_SQL
        expression: SUM(orders.paid_amount)
    datatype: Decimal
    custom_extensions:
    - vendor_name: DATUS
      data: '{"v":"1.11","is_dimension":false,"lod":{"expr":"fixed(paid_total, orders.user_id)"}}'
metrics:
- name: mean_lifetime_paid
  expression:
    dialects:
    - dialect: ANSI_SQL
      expression: AVG(orders.buyer_lifetime_paid)

查询。

dosi query --model model.yaml --format json \
  --metrics mean_lifetime_paid \
  --group-by users.acquisition_channel \
  --context-filter 'orders.paid_date < DATE '"'"'2030-06-01'"'"'' \
  --execute --db commerce.duckdb
acquisition_channel mean_lifetime_paid
ads 400
organic 120
referral 180

广告渠道的两个买家分别消费 500 和 300,均值为 400。 指标从订单总体出发,不把未购买的注册用户计入分母。

3. App 和网页用户的购买频次有什么不同?

在 4 月、每个订单渠道内,先计算各买家的订单数,再求平均值和最大值。 跨渠道购买的用户分别进入两边总体,同一天两笔订单仍计为两笔。

模型定义。 将下面两个指标加入 metrics。 本题需要各买家在当前渠道和 4 月范围内的订单数,include 在渠道分组上增加买家键, 然后用 AVG 或 MAX 汇总这些次数。固定买家次数会忽略普通渠道筛选,不能表达这里的渠道内购买频次。

metrics:
- name: mean_orders
  expression:
    dialects:
    - dialect: ANSI_SQL
      expression: AVG(COUNT(DISTINCT orders.order_id))
  custom_extensions:
  - vendor_name: DATUS
    data: '{"v":"1.11","derive":{"type":"lod","expr":"AVG(include(order_count, orders.user_id))"}}'
- name: max_orders
  expression:
    dialects:
    - dialect: ANSI_SQL
      expression: MAX(COUNT(DISTINCT orders.order_id))
  custom_extensions:
  - vendor_name: DATUS
    data: '{"v":"1.11","derive":{"type":"lod","expr":"MAX(include(order_count, orders.user_id))"}}'

查询。

dosi query --model model.yaml --format json \
  --metrics mean_orders,max_orders \
  --group-by orders.channel \
  --start-time 2030-04-01 \
  --end-time 2030-05-01 \
  --time-dimension orders.paid_date \
  --execute --db commerce.duckdb
channel mean_orders max_orders
app 1.5 2
web 1 1

两个 include 指标使用相同的新增维度,可以共查;普通销售额指标需要另查。

4. App 每天卖了多少,占当天全站多少?

按 4 月的 app 下单日期,输出当天 app 销售额、全站销售额、app 占比、app 月总额, 以及当天对 app 月总额的贡献。普通 app 条件限定分子和 exclude 小计, 固定的全站日基准仍保留网页销售额。4 月时间范围同时限定这三种计算。 日期分组使用原样的 orders.paid_date,即使存储类型已是日期,也不要添加 :day, 因为带键 fixed 指标要求该键原样出现在展示分组中。

模型定义。 将下面四个指标加入 metrics,共用公共模型的 paid_total。 site_amount 只固定日期,忽略普通 app 筛选;app_month_amount 从展示分组中去掉日期, 保留渠道及其筛选。两者仍受 4 月上下文限制。 两个 compose 指标分别用普通日实付除以这两个基准,零分母返回 NULL。 核心表达式展开指标引用,并保留声明要求的嵌套聚合写法。

metrics:
- name: site_amount
  expression:
    dialects:
    - dialect: ANSI_SQL
      expression: SUM(SUM(orders.paid_amount))
  custom_extensions:
  - vendor_name: DATUS
    data: '{"v":"1.11","derive":{"type":"lod","expr":"fixed(paid_total, orders.paid_date)"}}'
- name: app_month_amount
  expression:
    dialects:
    - dialect: ANSI_SQL
      expression: SUM(SUM(orders.paid_amount))
  custom_extensions:
  - vendor_name: DATUS
    data: '{"v":"1.11","derive":{"type":"lod","expr":"exclude(paid_total, orders.paid_date)"}}'
- name: daily_site_share
  expression:
    dialects:
    - dialect: ANSI_SQL
      expression: SUM(orders.paid_amount) / NULLIF(SUM(SUM(orders.paid_amount)), 0)
  custom_extensions:
  - vendor_name: DATUS
    data: '{"v":"1.11","derive":{"type":"compose","expr":"paid_total / NULLIF(site_amount, 0)"}}'
- name: app_month_share
  expression:
    dialects:
    - dialect: ANSI_SQL
      expression: SUM(orders.paid_amount) / NULLIF(SUM(SUM(orders.paid_amount)), 0)
  custom_extensions:
  - vendor_name: DATUS
    data: '{"v":"1.11","derive":{"type":"compose","expr":"paid_total / NULLIF(app_month_amount, 0)"}}'

查询。

dosi query --model model.yaml --format json \
  --metrics paid_total,site_amount,daily_site_share,app_month_amount,app_month_share \
  --group-by orders.channel,orders.paid_date \
  --where 'orders.channel = '"'"'app'"'"'' \
  --start-time 2030-04-01 \
  --end-time 2030-05-01 \
  --time-dimension orders.paid_date \
  --execute --db commerce.duckdb
channel paid_date app_amount site_amount daily_site_share app_month_amount app_month_share
app 2030-04-01 260 260 1 570 260 / 570
app 2030-04-02 160 500 0.32 570 160 / 570
app 2030-04-03 150 240 0.625 570 150 / 570

paid_total 映射为 app_amount。最后一列为便于核对写成精确分数,CLI 实际返回数值比例。 没有 app 订单的日期不补行。小计指标名称对应本报表,其定义实际跟随查询保留的渠道和筛选。

5. 4 月买过、5 月没再买的用户有哪些?

每个符合条件的用户输出一行,包括地区、4 月实付、5 月订单数和按完成日期统计的 4 月成功退款。 退款用于评估召回名单,不是排除用户的条件。

明细根数据集为用户表。跨表固定字段通过已声明的用户键关系分别读取订单和退款。 入选条件是 4 月订单数大于零、5 月订单数为零,因此零金额的已支付订单仍算购买。 缺失退款 SUM 显式转成零,缺失 COUNT 本来就是零。用户主键唯一,无须 DISTINCT。

模型定义。 将下面字段加入 users.fields,四个筛选指标加入 metrics。 公共模型的两条关系按用户键分别对应订单和退款,不把两张事实明细表直接相连。 固定键使用用户编号,确保结果按用户计算,不能换成订单或退款编号。

每个期间都写在筛选基础指标里:参考期订单数和金额共用 reference_start/reference_end, 观察期订单、退款则分别使用独立参数名。核心 SQL 代入参数默认值,derive.where 保留查询传值用的参数标记,固定字段再重复基础指标的 SQL。 缺失 COUNT 为零,缺失退款 SUM 为 NULL,因此只在展示字段中显式使用 COALESCE。

datasets:
- name: users
  fields:
  - name: reference_orders
    expression:
      dialects:
      - dialect: ANSI_SQL
        expression: COUNT(DISTINCT CASE WHEN orders.paid_date_key >= 0 AND orders.paid_date_key < 99991231 THEN
          orders.order_id END)
    datatype: Integer
    custom_extensions:
    - vendor_name: DATUS
      data: '{"v":"1.11","is_dimension":false,"lod":{"expr":"fixed(reference_order_count, users.user_id)"}}'
  - name: reference_paid
    expression:
      dialects:
      - dialect: ANSI_SQL
        expression: SUM(CASE WHEN orders.paid_date_key >= 0 AND orders.paid_date_key < 99991231 THEN orders.paid_amount
          END)
    datatype: Decimal
    custom_extensions:
    - vendor_name: DATUS
      data: '{"v":"1.11","is_dimension":false,"lod":{"expr":"fixed(reference_paid_amount, users.user_id)"}}'
  - name: observation_orders
    expression:
      dialects:
      - dialect: ANSI_SQL
        expression: COUNT(DISTINCT CASE WHEN orders.paid_date_key >= 0 AND orders.paid_date_key < 99991231 THEN
          orders.order_id END)
    datatype: Integer
    custom_extensions:
    - vendor_name: DATUS
      data: '{"v":"1.11","is_dimension":false,"lod":{"expr":"fixed(observation_order_count, users.user_id)"}}'
  - name: window_refunded_raw
    expression:
      dialects:
      - dialect: ANSI_SQL
        expression: SUM(CASE WHEN refunds.resolved_date_key >= 0 AND refunds.resolved_date_key < 99991231 AND refunds.status
          = 'SUCCEEDED' THEN refunds.requested_amount END)
    datatype: Decimal
    custom_extensions:
    - vendor_name: DATUS
      data: '{"v":"1.11","is_dimension":false,"lod":{"expr":"fixed(window_refund_amount, users.user_id)"}}'
  - name: window_refunded
    expression:
      dialects:
      - dialect: ANSI_SQL
        expression: COALESCE(window_refunded_raw, 0)
    datatype: Decimal
    custom_extensions:
    - vendor_name: DATUS
      data: '{"v":"1.11","is_dimension":false}'
metrics:
- name: reference_order_count
  expression:
    dialects:
    - dialect: ANSI_SQL
      expression: COUNT(DISTINCT CASE WHEN orders.paid_date_key >= 0 AND orders.paid_date_key < 99991231 THEN orders.order_id
        END)
  custom_extensions:
  - vendor_name: DATUS
    data: |-
      {
        "v": "1.11",
        "derive": {
          "type": "filter",
          "base": "order_count",
          "where": "orders.paid_date_key >= :reference_start AND orders.paid_date_key < :reference_end"
        },
        "params": [
          {
            "name": "reference_start",
            "type": "int",
            "default": 0,
            "min": 0,
            "max": 99991231
          },
          {
            "name": "reference_end",
            "type": "int",
            "default": 99991231,
            "min": 0,
            "max": 99991231
          }
        ]
      }
- name: reference_paid_amount
  expression:
    dialects:
    - dialect: ANSI_SQL
      expression: SUM(CASE WHEN orders.paid_date_key >= 0 AND orders.paid_date_key < 99991231 THEN orders.paid_amount
        END)
  custom_extensions:
  - vendor_name: DATUS
    data: |-
      {
        "v": "1.11",
        "derive": {
          "type": "filter",
          "base": "paid_total",
          "where": "orders.paid_date_key >= :reference_start AND orders.paid_date_key < :reference_end"
        },
        "params": [
          {
            "name": "reference_start",
            "type": "int",
            "default": 0,
            "min": 0,
            "max": 99991231
          },
          {
            "name": "reference_end",
            "type": "int",
            "default": 99991231,
            "min": 0,
            "max": 99991231
          }
        ]
      }
- name: observation_order_count
  expression:
    dialects:
    - dialect: ANSI_SQL
      expression: COUNT(DISTINCT CASE WHEN orders.paid_date_key >= 0 AND orders.paid_date_key < 99991231 THEN orders.order_id
        END)
  custom_extensions:
  - vendor_name: DATUS
    data: |-
      {
        "v": "1.11",
        "derive": {
          "type": "filter",
          "base": "order_count",
          "where": "orders.paid_date_key >= :observation_start AND orders.paid_date_key < :observation_end"
        },
        "params": [
          {
            "name": "observation_start",
            "type": "int",
            "default": 0,
            "min": 0,
            "max": 99991231
          },
          {
            "name": "observation_end",
            "type": "int",
            "default": 99991231,
            "min": 0,
            "max": 99991231
          }
        ]
      }
- name: window_refund_amount
  expression:
    dialects:
    - dialect: ANSI_SQL
      expression: SUM(CASE WHEN refunds.resolved_date_key >= 0 AND refunds.resolved_date_key < 99991231 AND refunds.status
        = 'SUCCEEDED' THEN refunds.requested_amount END)
  custom_extensions:
  - vendor_name: DATUS
    data: |-
      {
        "v": "1.11",
        "derive": {
          "type": "filter",
          "base": "refund_requested_total",
          "where": "refunds.resolved_date_key >= :refund_start AND refunds.resolved_date_key < :refund_end AND refunds.status = :refund_status"
        },
        "params": [
          {
            "name": "refund_start",
            "type": "int",
            "default": 0,
            "min": 0,
            "max": 99991231
          },
          {
            "name": "refund_end",
            "type": "int",
            "default": 99991231,
            "min": 0,
            "max": 99991231
          },
          {
            "name": "refund_status",
            "type": "string",
            "default": "SUCCEEDED",
            "allowed": [
              "SUCCEEDED",
              "REJECTED",
              "PENDING"
            ]
          }
        ]
      }

查询。

dosi select --model model.yaml --format json \
  --from users \
  --fields users.user_id,users.region,users.reference_paid,users.observation_orders,users.window_refunded \
  --where 'users.reference_orders > 0 AND users.observation_orders = 0' \
  --param reference_start=20300401 \
  --param reference_end=20300501 \
  --param observation_start=20300501 \
  --param observation_end=20300601 \
  --param refund_start=20300401 \
  --param refund_end=20300501 \
  --param refund_status=SUCCEEDED \
  --limit 10000 \
  --execute --db commerce.duckdb
user_id region april_paid may_orders april_refunded
U02 south 200 0 30
U03 north 200 0 0
U05 south 40 0 0
U06 east 160 0 0

清单将 reference_paid、observation_orders、window_refunded 映射为带月份的展示列名, 模型字段本身保持可复用。三组参数分别控制参考期、观察期和退款期,refund_status 控制退款状态。参考期订单数与实付金额使用相同参数名。只改退款窗口,会改变展示退款金额, 不会改变入选用户。不要再加全局查询时间范围,让原本独立的窗口意外交叉截断。

明细查询最多返回 10000 行,本例显式使用这个上限。这是小规模分析样例,不演示批量导出或分页。

样例验证了什么

五条查询在一个模型中使用 LOD 的主要表达形式,受组合限制的计算分别查询。 它们还揭示常见口径错误:把退款申请数当成订单数、按订单数给用户加权、 把全站分母也筛成 app,以及把独立观察窗口相互截断。

五条查询不穷举所有键形态、空值、跨事实对齐、时间编码、错误或数据库方言, 这些边界由规则文档说明。核对脚本用金额变化、重复购买、新日期和日期边界记录 重复验证相同五个问题,不增加对外业务场景。