# BD_Manual:dws_member_spending_power_index(SPI 消费力指数) > DWS 表:`dws.dws_member_spending_power_index` > DWD 数据源:`dwd.dwd_settlement_head`(消费订单)、`dwd.dwd_recharge_order`(充值订单) > 配置表:`dws.cfg_index_parameters`(`index_type='SPI'`) > 任务代码:`DWS_SPENDING_POWER_INDEX` > 代码位置:`apps/etl/connectors/feiqiu/tasks/dws/index/spending_power_index_task.py` > DDL 位置:`docs/database/ddl/etl_feiqiu__dws.sql` > 迁移脚本:`db/etl_feiqiu/migrations/2026-02-23_create_dws_member_spending_power_index.sql` > 种子数据:`db/etl_feiqiu/seeds/seed_index_parameters.sql`(`index_type='SPI'` 部分) --- ## 1. 表结构 | 列名 | 类型 | 默认值 | 业务含义 | 取值范围/示例 | |------|------|--------|---------|-------------| | `spi_id` | BIGINT (SERIAL) | nextval 序列 | 自增主键(PK) | 自增 | | `site_id` | INTEGER NOT NULL | — | 门店 ID | 飞球门店 ID | | `member_id` | BIGINT NOT NULL | — | 会员 ID | 飞球会员 ID | | `spend_30` | NUMERIC(14,2) | 0 | 近 30 天消费总额(元) | `0.00` ~ 金额值 | | `spend_90` | NUMERIC(14,2) | 0 | 近 90 天消费总额(元) | `0.00` ~ 金额值 | | `recharge_90` | NUMERIC(14,2) | 0 | 近 90 天充值总额(元) | `0.00` ~ 金额值 | | `orders_30` | INTEGER | 0 | 近 30 天消费笔数 | `0` ~ 正整数 | | `orders_90` | INTEGER | 0 | 近 90 天消费笔数 | `0` ~ 正整数 | | `visit_days_30` | INTEGER | 0 | 近 30 天消费日数(按天去重) | `0` ~ `30` | | `visit_days_90` | INTEGER | 0 | 近 90 天消费日数(按天去重) | `0` ~ `90` | | `avg_ticket_90` | NUMERIC(14,2) | 0 | 90 天客单价(= spend_90 / max(orders_90, 1)) | `0.00` ~ 金额值 | | `active_weeks_90` | INTEGER | 0 | 近 90 天有消费的自然周数 | `0` ~ `13` | | `daily_spend_ewma_90` | NUMERIC(14,2) | 0 | 日消费 EWMA(指数加权移动平均) | `0.00` ~ 金额值 | | `score_level_raw` | NUMERIC(10,4) | 0 | Level 子分原始分(消费水平) | ≥ 0 | | `score_speed_raw` | NUMERIC(10,4) | 0 | Speed 子分原始分(消费速度) | ≥ 0 | | `score_stability_raw` | NUMERIC(10,4) | 0 | Stability 子分原始分(消费稳定性) | `0.0000` ~ `1.0000` | | `score_level_display` | NUMERIC(5,2) | 0 | Level 子分展示分 | `0.00` ~ `10.00` | | `score_speed_display` | NUMERIC(5,2) | 0 | Speed 子分展示分 | `0.00` ~ `10.00` | | `score_stability_display` | NUMERIC(5,2) | 0 | Stability 子分展示分 | `0.00` ~ `10.00` | | `raw_score` | NUMERIC(10,4) | 0 | SPI 总分原始分(加权合成) | ≥ 0 | | `display_score` | NUMERIC(5,2) | 0 | SPI 总分展示分 | `0.00` ~ `10.00` | | `calc_time` | TIMESTAMPTZ | NOW() | 本次计算时间 | ISO 时间戳 | | `created_at` | TIMESTAMPTZ | NOW() | 记录创建时间 | ISO 时间戳 | | `updated_at` | TIMESTAMPTZ | NOW() | 记录最后更新时间 | ISO 时间戳 | --- ## 2. 主键与索引 | 名称 | 类型 | 列 | 说明 | |------|------|----|------| | `dws_member_spending_power_index_pkey` | PRIMARY KEY | `spi_id` | 物理主键(自增序列) | | `idx_spi_site_member` | UNIQUE INDEX | `(site_id, member_id)` | 业务主键:每个门店每个会员唯一一条记录 | | `idx_spi_display_score` | INDEX | `(site_id, display_score DESC)` | 按门店查询展示分排名,支持 TOP-N 查询 | --- ## 3. 数据写入策略 - **delete-before-insert**:每次执行按 `site_id` 全量刷新 1. `DELETE FROM dws.dws_member_spending_power_index WHERE site_id = %s` 2. 批量 `INSERT` 新计算结果 - 无数据时跳过(不删除、不插入),返回 `{'status': 'skipped', 'reason': 'no_data'}` --- ## 4. 算法概要 ### 4.1 数据来源 | 来源表 | 筛选条件 | 提取内容 | |--------|---------|---------| | `dwd.dwd_settlement_head` | 近 90 天,`settle_type IN (1, 3)` | 消费金额、笔数、消费日数、周覆盖、日消费序列 | | `dwd.dwd_recharge_order` | 近 90 天,`settle_type = 5` | 充值总额 | ### 4.2 子分公式 - **Level**(消费水平,权重 0.60): `L = w_s30 × ln(1 + spend_30/M30) + w_s90 × ln(1 + spend_90/M90) + w_ticket × ln(1 + avg_ticket_90/T0) + w_r90 × ln(1 + recharge_90/R90)` - **Speed**(消费速度,权重 0.30): `S = w_abs × V_abs + w_rel × max(0, V_rel) + w_ewma × V_ewma` - `V_abs = ln(1 + spend_30 / (max(visit_days_30, 1) × V0))` - `V_rel = ln((v_30 + ε) / (v_90 + ε))`,仅加速加分 - `V_ewma = ln(1 + daily_spend_ewma_90 / E0)` - **Stability**(消费稳定性,权重 0.10): `P = active_weeks_90 / 13`,取值 [0, 1] ### 4.3 总分合成 `SPI_raw = w_L × L + w_S × S + w_P × P`(默认 0.60 / 0.30 / 0.10) ### 4.4 展示分映射 Raw → Winsorize(P5, P95) → 可选压缩(log1p/asinh) → MinMax [0, 10] → 可选 EWMA 平滑 子分(Level/Speed/Stability)各自独立映射,使用 `SPI_LEVEL` / `SPI_SPEED` / `SPI_STABILITY` 隔离分位历史。 --- ## 5. 配置参数 所有参数存储在 `dws.cfg_index_parameters`(`index_type='SPI'`),缺失时回退到代码中 `DEFAULT_PARAMS`。 | 参数名 | 默认值 | 说明 | |--------|--------|------| | `spend_window_short_days` | 30 | 短窗口天数 | | `spend_window_long_days` | 90 | 长窗口天数 | | `ewma_alpha_daily_spend` | 0.3 | 日消费 EWMA 平滑系数 | | `amount_base_spend_30` | 500.0 | 30 天消费金额压缩基数 | | `amount_base_spend_90` | 1500.0 | 90 天消费金额压缩基数 | | `amount_base_ticket_90` | 200.0 | 客单价压缩基数 | | `amount_base_recharge_90` | 1000.0 | 充值金额压缩基数 | | `amount_base_speed_abs` | 100.0 | 绝对速度压缩基数 | | `amount_base_ewma_90` | 50.0 | EWMA 速度压缩基数 | | `w_level_spend_30` | 0.30 | Level 子分中 spend_30 权重 | | `w_level_spend_90` | 0.35 | Level 子分中 spend_90 权重 | | `w_level_ticket_90` | 0.20 | Level 子分中 avg_ticket_90 权重 | | `w_level_recharge_90` | 0.15 | Level 子分中 recharge_90 权重 | | `w_speed_abs` | 0.50 | Speed 子分中绝对速度权重 | | `w_speed_rel` | 0.30 | Speed 子分中相对速度权重 | | `w_speed_ewma` | 0.20 | Speed 子分中 EWMA 速度权重 | | `weight_level` | 0.60 | 总分中 Level 权重 | | `weight_speed` | 0.30 | 总分中 Speed 权重 | | `weight_stability` | 0.10 | 总分中 Stability 权重 | | `stability_window_days` | 90 | 稳定性计算窗口 | | `use_stability` | 1 | 是否启用稳定性子分(0=禁用) | | `percentile_lower` | 5 | Winsorize 下分位 | | `percentile_upper` | 95 | Winsorize 上分位 | | `compression_mode` | 1 | 压缩模式:0=无,1=log1p,2=asinh | | `use_smoothing` | 1 | 是否启用 EWMA 分位平滑 | | `ewma_alpha` | 0.2 | 分位平滑 EWMA 系数 | | `speed_epsilon` | 1e-6 | 速度计算防除零小量 | --- ## 6. 前置依赖 - 任务依赖:`DWS_MEMBER_CONSUMPTION`(需先完成会员消费汇总) - 数据源表:`dwd.dwd_settlement_head`、`dwd.dwd_recharge_order` 必须已有数据 - 配置表:`dws.cfg_index_parameters` 中 `index_type='SPI'` 种子数据已插入(缺失时使用默认值) - 分位历史表:`dws.dws_index_percentile_history`(首次执行时无历史,不平滑) --- ## 7. 验证 SQL ### 7.1 检查表是否存在且有数据 ```sql SELECT COUNT(*) AS total_rows, COUNT(DISTINCT site_id) AS site_count, MIN(calc_time) AS earliest_calc, MAX(calc_time) AS latest_calc FROM dws.dws_member_spending_power_index; ``` ### 7.2 检查展示分范围是否合规(应全部在 [0, 10]) ```sql SELECT COUNT(*) FILTER (WHERE display_score < 0 OR display_score > 10) AS spi_out_of_range, COUNT(*) FILTER (WHERE score_level_display < 0 OR score_level_display > 10) AS level_out_of_range, COUNT(*) FILTER (WHERE score_speed_display < 0 OR score_speed_display > 10) AS speed_out_of_range, COUNT(*) FILTER (WHERE score_stability_display < 0 OR score_stability_display > 10) AS stability_out_of_range FROM dws.dws_member_spending_power_index; -- 预期:所有列均为 0 ``` ### 7.3 检查业务主键唯一性(不应有重复) ```sql SELECT site_id, member_id, COUNT(*) AS cnt FROM dws.dws_member_spending_power_index GROUP BY site_id, member_id HAVING COUNT(*) > 1; -- 预期:无结果返回 ``` ### 7.4 按门店查看 SPI 分布概况 ```sql SELECT site_id, COUNT(*) AS member_count, ROUND(AVG(display_score), 2) AS avg_spi, ROUND(MIN(display_score), 2) AS min_spi, ROUND(MAX(display_score), 2) AS max_spi, ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY display_score), 2) AS median_spi FROM dws.dws_member_spending_power_index GROUP BY site_id ORDER BY site_id; ``` ### 7.5 检查 Stability 子分原始分范围(应在 [0, 1]) ```sql SELECT COUNT(*) AS out_of_range FROM dws.dws_member_spending_power_index WHERE score_stability_raw < 0 OR score_stability_raw > 1; -- 预期:0 ``` --- ## 8. 兼容性说明 | 影响范围 | 说明 | |---------|------| | ETL 任务 | 新增任务 `DWS_SPENDING_POWER_INDEX`,依赖 `DWS_MEMBER_CONSUMPTION`。不影响现有 WBI/NCI/RS/OS/MS/ML 指数任务 | | 后端 API | 当前无 API 直接读取此表。后续如需暴露 SPI 数据,需新增接口 | | 管理后台 | 当前无前端页面展示 SPI。后续可在会员详情页新增 SPI 展示 | | 小程序 | 无影响 | | 其他指数 | SPI 独立于现有指数体系,不修改任何已有表或任务逻辑 | | 分位历史 | SPI 会向 `dws.dws_index_percentile_history` 写入 `index_type='SPI'`/`SPI_LEVEL`/`SPI_SPEED`/`SPI_STABILITY` 的分位记录 | --- ## 9. 回滚策略 ### 9.1 删除数据(保留表结构) ```sql DELETE FROM dws.dws_member_spending_power_index; DELETE FROM dws.dws_index_percentile_history WHERE index_type LIKE 'SPI%'; DELETE FROM dws.cfg_index_parameters WHERE index_type = 'SPI'; ``` ### 9.2 完整回滚(删除表) ```sql DROP INDEX IF EXISTS dws.idx_spi_display_score; DROP INDEX IF EXISTS dws.idx_spi_site_member; DROP TABLE IF EXISTS dws.dws_member_spending_power_index; DROP SEQUENCE IF EXISTS dws.dws_member_spending_power_index_spi_id_seq; ``` ### 9.3 回滚任务注册 从 `orchestration/task_registry.py` 中移除 `DWS_SPENDING_POWER_INDEX` 注册行,并从 `tasks/dws/index/__init__.py` 和 `tasks/dws/__init__.py` 中移除 `SpendingPowerIndexTask` 导出。 --- ## 10. 代码引用 - 任务类:`tasks/dws/index/spending_power_index_task.py` → `SpendingPowerIndexTask` - 继承:`BaseIndexTask`(`tasks/dws/index/base_index_task.py`) - 任务注册:`orchestration/task_registry.py` → `DWS_SPENDING_POWER_INDEX` - 属性测试:`tests/test_spi_properties.py` - 单元测试:`apps/etl/connectors/feiqiu/tests/unit/test_spi_task.py` - 迁移脚本:`db/etl_feiqiu/migrations/2026-02-23_create_dws_member_spending_power_index.sql` - 种子数据:`db/etl_feiqiu/seeds/seed_index_parameters.sql`