Files
gyxx-flow/docs/style-identity-refactor-plan.md
T
wangyunlong 6f1c193f4b feat(product-commerce): 款式主键统一 style_id 并下线评价采集
采集端与 data-hub 统一用 style_id (= dim_style.id) 关联款式,style_name 降级为显示冗余。

款式主键:
- 14 张采集表加 style_id BIGINT + style_match_method + *_style_id_idx 索引
- 一律逻辑外键:不建 FOREIGN KEY,早期 10 个 *_style_id_fk 在 schema.sql 里幂等 DROP
- loader 下发 dim_style.id;db 层 attach_style_ids / resolve_style_ids_by_product 兜底
- 新增 backfill_style_id.py 三段回填(款名 / 日报按商品 ID / 广告表按 spu_id)
- 新增视图 v_style_id_coverage、v_style_unmatched_product、v_style_unmatched_source
- 实库补录 199 个商品 ID 到 67 款 spus;日报 style_id 覆盖 49.1% -> 79.0%,
  tm 广告 99.4% / jd 92.4% / dy 83.2%,其余 11 张 100%

评价采集下线:
- product_reviews 归档为 product_reviews_archived_20261101
- 差评改由 shop.{tmall,jd,dy}_negative_reviews.daily 采集,消费方读 v_negative_reviews
- 删除 import_product_reviews.py / orchestrate_review_collection.py 及相关命令与绑定
- dy_product_scraping 默认流程摘除评论采集,保留 --reviews-only

天猫广告:
- 迁移到独立表 tm_product_ad_daily_metrics,不再写 product_daily_metrics.raw_data

同批附带:
- 光合上传支持天猫新店 new_bag(仅黑曜石系列)
- 京东商智双月日历排除 -diff-month 补位格
- 万相主体搜索词覆盖 STYLE_SEARCH_KEYWORDS
- ERP 部分编码失败时用已成功编码销量兜底,避免整款归零
2026-11-01 16:43:27 +08:00

387 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 款式主键统一 + 评论采集下线 方案
> 状态:**已实施(2026-11-01)**,见文末「八、实施记录」
> 涉及项目:`gyxx-flow`(采集端)、`data-hub`(消费端,共用同一个 PostgreSQL)
> 编写日期:2026-11-01
## 一、三条结论
1. **`product_reviews` 可以下线**,但不能直接删——它有两个消费方(风格分析、营销日报)要改成读三张差评表。
2. **`style_id` 就是 `dim_style.id`**,与 data-hub 现有 `style_id` 同一体系,不会产生第二套 ID。已实测:`erp_product.style_id` 的 135 个值 **135/135** 命中 `dim_style.id`,`own_brand_product.style_id` 31/34 命中(3 条为历史脏数据)。
3. **改造入口只有一处**:`DIM_STYLE_RUNTIME_SQL` 早已 `SELECT s.id`,`merge_lifecycle_style_records()` 也已经拿到这个 id——只是被编码进 `source_record_id = "lifecycle_style:<id>"` 字符串里,没有作为独立字段下发给写入脚本。把它提成正式字段,全链路就都有了。
## 二、现状盘点
### 2.1 两套引用方式并存
| 侧 | 关联方式 | 表 |
|---|---|---|
| data-hub(产品生命、营销中台、客服、内容) | `style_id BIGINT` → `dim_style.id` | `erp_product`、`own_brand_product`、`erp_marketing_*`、`erp_cs_*`、`cmt_*`、`market_product_classification` 等 22 张 |
| gyxx-flow(采集端) | `style_name TEXT` | 15 张,见 2.3 |
### 2.2 实测匹配率(2026-11-01)
| 表 | 总行数 | 按 style_name 命中 dim_style |
|---|---|---|
| `daily_style_metrics` | 17,352 | 17,352(100%) |
| `erp_all_shop_style_daily_metrics` | 9,399 | 9,399(100%) |
| `product_reviews` | 1,936 | 1,434(74%,全部来自抖音;京东 214 行 style_name 全 NULL) |
| `product_daily_metrics` | 75,568 | **无款式列**,只能靠 `tm_spus/jd_spus/dy_spus @> to_jsonb(product_id)` 现算 |
`product_daily_metrics` 按商品 ID 反查的现状:
| 平台 | 行数 | 可匹配 | 去重商品 ID | 可匹配 ID |
|---|---|---|---|---|
| dy | 28,972 | 13,615(47%) | 253 | 83 |
| jd | 17,044 | 9,394(55%) | 160 | 61 |
| tm | 29,552 | 14,089(48%) | 281 | 92 |
一对多歧义 **0 条**。约一半行匹配不上,原因是 **458 个商品 ID 从未写进 dim_style 的 spus 数组**(dy 170 / jd 99 / tm 189),不是字段设计问题。
### 2.3 采集端 15 张只存 style_name 的表
`daily_style_metrics`、`weekly_style_metrics`、`erp_all_shop_style_daily_metrics`、`daily_persona_metrics`、`main_image_creatives`、`fact_style_analysis_reports`、`decline_events`、`fact_alert_history`、`jd_self_weekly_sales`、`persona_screenshots`、`product_reviews`、`jd_product_ad_daily_metrics`、`dy_product_ad_daily_metrics`、`workflow_dynamic_styles`、`product_daily_metrics`(无列,靠 ID 反查)。
## 三、目标模型
- **唯一关联键 = `style_id`(`dim_style.id`)**,所有采集表落库时必须带上。
- `style_name` 保留为**显示冗余**,不再作为 join 键;等下游切完 join 后可以只在视图里出现。
- 匹配不上的行(未建档商品、广告未匹配 SKU、差评)**允许 `style_id IS NULL`**,但必须同时写 `style_match_method`(`product_id` / `product_name` / `anchor` / `unmatched`),便于统计漏维护。
- **不动现有主键**。`daily_style_metrics` 的 PK 仍是 `(metric_date, style_name, platform)`——data-hub 有 6 处 `metric.style_name = style.name` 的 join,改 PK 收益小、回归面大。加列、加索引、切 join 即可。
## 四、实施分期
### P0 观测层(只读,不动数据)
新增两个视图,先把问题量化出来,方案落地后靠它们验收:
```sql
-- 未建档的平台商品 ID(推动在 dim_style 里补 spus)
CREATE OR REPLACE VIEW v_style_unmatched_product AS
SELECT m.platform, m.product_id,
MAX(m.product_name) AS product_name,
MIN(m.stat_date) AS first_date, MAX(m.stat_date) AS last_date,
COUNT(*) AS rows
FROM product_daily_metrics m
WHERE NOT EXISTS (
SELECT 1 FROM dim_style d
WHERE (m.platform = 'tm' AND d.tm_spus @> to_jsonb(m.product_id))
OR (m.platform = 'jd' AND d.jd_spus @> to_jsonb(m.product_id))
OR (m.platform = 'dy' AND d.dy_spus @> to_jsonb(m.product_id)))
GROUP BY m.platform, m.product_id;
-- 各表 style_id 回填进度
CREATE OR REPLACE VIEW v_style_id_coverage AS
SELECT 'daily_style_metrics' AS tbl, COUNT(*) AS rows,
COUNT(style_id) AS with_style_id FROM daily_style_metrics
UNION ALL SELECT 'erp_all_shop_style_daily_metrics', COUNT(*), COUNT(style_id)
FROM erp_all_shop_style_daily_metrics
UNION ALL SELECT 'product_daily_metrics', COUNT(*), COUNT(style_id)
FROM product_daily_metrics;
```
验收锚点:`v_style_id_coverage` 里前三张表 `with_style_id` 开始上涨到预期比例;`v_style_unmatched_product` 的条数随建档推进下降。
### P1 评论采集链路下线
**要删的东西**
| 位置 | 动作 |
|---|---|
| `orchestrate_daily_collection.py:437-452` | 移除 `import_product_reviews` 步骤 |
| `import_product_reviews.py` | 删除 |
| `orchestrate_review_collection.py` | 删除 |
| `config/commands.json:516` `product.review.orchestrate` | 移除命令 |
| `db/__init__.py` `upsert_product_reviews` | 删除 |
| `db/schema.sql:813-870` `product_reviews` 建表段 | 移除(库里的表归档不删) |
**要补的东西**
1. 建统一差评视图,字段对齐两个消费方现在的用法(`platform` / `rating` / `content` / `style_id` / `comment_date`):
```sql
CREATE OR REPLACE VIEW v_negative_reviews AS
SELECT 'tm' AS platform, n.business_date AS comment_date, n.review_level AS rating,
n.content, NULL::text AS order_id, NULL::text AS product_id,
n.product_name, n.review_id, n.review_at, n.raw_review
FROM tmall_negative_reviews n
UNION ALL
SELECT 'jd', n.business_date, n.review_level, n.content, n.order_id, NULL,
n.product_name, n.review_id, n.review_at, n.raw_review
FROM jd_negative_reviews n
UNION ALL
SELECT 'dy', n.business_date, n.rating_level, n.content, n.order_id, n.product_id,
n.product_name, n.review_id, n.review_at, n.raw_review
FROM dy_negative_reviews n;
```
2. 改两个消费方:
- `analyze_style.py:112 pull_reviews()` → 改查 `v_negative_reviews`(按 `style_id` 或 `product_id` 关联)
- `content_marketing/daily_marketing_report.py:1020 _review_context()` → 同上
3. `product_reviews` 归档而不是删除:
```sql
ALTER TABLE product_reviews RENAME TO product_reviews_archived_20261101;
```
**必须明确的功能退化**:`product_reviews` 存的是好/中/差全量,差评三表只有差评。下线后:
- 风格分析里的「评价等级分布」从三档变成只有差评一档(差评样本反而更完整:天猫 97 + 京东 32 + 抖音 14,而 `product_reviews` 里天猫是 0、京东 8 月就断了)。
- 营销日报的「近 7 天差评归因」不受影响,反而多了天猫来源。
- 若产品侧确实需要好评样本沉淀,需要把三个差评采集器扩成「按等级采集」,不要恢复 `product_reviews`。
另外 `dy_product_scraping.py` 主流程里的评论采集动作只产出本地 JSON,`product_reviews` 停写后就没有消费方,建议同步关掉(或保留但不再进 `product.daily`)。
### P2 loader 下发 style_id(1 处改动,收益最大)
`src/gyxx_flow/adapters/workflow_config.py:322 merge_lifecycle_style_records()`:
```python
output["style_id"] = lifecycle.get("id") # dim_style.id
output["style_match_method"] = "dim_style"
```
同时 `source_record_id` 保留(控制台/账本还在用),只是不再承担传 ID 的职责。
影响面:所有通过 `runtime_workflow_configs()` 取款的脚本自动获得 `style_id`,包括日报聚合、周报、ERP 全店铺、人像、主图、声明式分析。这一步不改表、不改写入逻辑,可以先合入验证 loader 输出。
### P3 采集表加列 + 回填 + 写入端填充
**统一 DDL 模板**(以 `daily_style_metrics` 为例,其余同构):
```sql
ALTER TABLE daily_style_metrics ADD COLUMN IF NOT EXISTS style_id BIGINT;
UPDATE daily_style_metrics d SET style_id = s.id
FROM dim_style s WHERE s.style_name = d.style_name AND d.style_id IS NULL;
CREATE INDEX IF NOT EXISTS daily_style_metrics_style_date_idx
ON daily_style_metrics (style_id, metric_date DESC);
```
按表处理(**必须匹配**的加 `NOT NULL`,**允许未匹配**的保持可空):
| 表 | 款式来源 | style_id | 写入端改动 |
|---|---|---|---|
| `daily_style_metrics` | loader 款式清单 | NOT NULL | `aggregate_daily_final.py` + `db.upsert_daily_metrics` |
| `weekly_style_metrics` | 从 daily 聚合 | NOT NULL | `weekly_aggregate.py` |
| `erp_all_shop_style_daily_metrics` | loader ERP 款 | NOT NULL | `backfill_erp_all_shop_daily.py` |
| `daily_persona_metrics` | loader | NOT NULL | `collect_*_persona_to_bitable.py` |
| `main_image_creatives` | loader | NOT NULL | `insert_main_image_records.py` / `insert_jd_main_image_records.py` |
| `fact_style_analysis_reports` | loader | NOT NULL | `analyze_style.py` |
| `decline_events` / `fact_alert_history` | daily 派生 | NOT NULL | `check_nine_day_decline.py` |
| `jd_self_weekly_sales` | loader | NOT NULL | `jd_self_inventory_sales_collector.py` |
| `persona_screenshots` | loader | NOT NULL | `taobao_dmp_item_crowd_insight_screenshots.py` |
| `product_daily_metrics` | 平台商品 ID 反查 | **可空** | `import_product_daily.py` |
| `jd_product_ad_daily_metrics` / `dy_product_ad_daily_metrics` | 名称/ID 反查 | **可空**(已有 `match_status`) | 两个 `import_*_ad_*.py` |
| 差评三表 | 反查 | **可空** | `collectors/*_negative_reviews.py` 入库侧 |
**`product_daily_metrics` 的回填要处理系列聚合带来的歧义**(当前 0 条歧义,但要按锚点优先写死优先级):
```sql
UPDATE product_daily_metrics m
SET style_id = pick.style_id
FROM LATERAL (
SELECT d.id AS style_id
FROM dim_style d
WHERE (m.platform = 'tm' AND d.tm_spus @> to_jsonb(m.product_id))
OR (m.platform = 'jd' AND d.jd_spus @> to_jsonb(m.product_id))
OR (m.platform = 'dy' AND d.dy_spus @> to_jsonb(m.product_id))
ORDER BY d.is_series_anchor DESC, LENGTH(d.style_name), d.id
LIMIT 1
) pick
WHERE m.style_id IS NULL;
```
(真实脚本里用 Python 侧 `_resolve_style_match()` 复用同一套优先级,SQL 只用于一次性回填。)
回填后预期的非空比例 = 2.2 节的实测值:**dy 47% / jd 55% / tm 48%**。这不是 bug——剩下的是 458 个未建档商品 ID,要靠 P5 的人补 spus 才能涨。
### P4 约束 + 下游切 join
1. ~~对「必须匹配」的表加外键~~ → **改为不建物理外键(2026-11-01 定案)**。
原始设想是给 10 张「必须匹配」的表加 `FOREIGN KEY (style_id) REFERENCES dim_style(id) ON UPDATE CASCADE`,
实施后又按业主意见拆掉了。理由是采集链路的语义与硬约束冲突:
- 采集要求 **fail-soft**:一个商品 ID 没建档,正确行为是把该行 `style_id` 留 NULL 并标
`unmatched`,继续写其它行。物理外键会让整批 INSERT/UPDATE 直接报错,等于「一个商品
未建档 → 当天数据全丢」,比留 NULL 更糟。
- 款式会动:拆系列、合并同款、重建 dim_style 行都要先卸约束再动数据,运维成本不小。
- data-hub 侧 22 张表本来就用 `style_id` 关联、**不带物理外键**,两边保持一致才好维护。
改为**逻辑外键**,完整性由三处保证:
| 环节 | 手段 |
|---|---|
| 写入 | loader 下发 `style_id`(源自 dim_style 本身);缺失时 `attach_style_ids` / `resolve_style_ids_by_product` 批量兜底 |
| 校验 | `backfill_style_id.py --dry-run` 预演;`v_style_id_coverage` 报每张表覆盖率 |
| 观测 | `v_style_unmatched_product`(日报口径)/ `v_style_unmatched_source`(全源口径)列未建档清单 |
老库里早期建过的 10 个 `<表名>_style_id_fk` 由 `schema.sql` 里的 `DROP CONSTRAINT IF EXISTS`
幂等清理;只保留 `*_style_id_idx` 索引(join 性能靠索引,不靠约束)。
2. data-hub 侧把 `metric.style_name = style.name` 换成 `metric.style_id = style.id`。涉及 `ProductLifecycleMapper.xml` 第 120、155 行(`daily_style_metrics`),以及 `JOIN dim_style dim ON dim.style_name = style.name`(第 15 行)。改完后 text join 变 bigint join,是纯性能收益;`style_name` 列仍在,改错了可以立即回切。
3. 加测试:
- `tests/.../test_daily_style_metrics_style_id.py`:写入必须带 `style_id`,且等于 loader 输出的 id
- `tests/.../test_style_id_coverage.py`:`v_style_id_coverage` 里「必须匹配」的表 `with_style_id == rows`
- 现有 `test_import_tmall_product_ads.py` 的风格(断言 SQL 形状)继续用在新增字段上
### P5 数据治理(不是代码活,但决定 P3 的收益)
`v_style_unmatched_product` 出来的 458 个商品 ID 需要在 dim_style 建档页补 `tm_spus/jd_spus/dy_spus`。分批推进:先补最近 90 天有销量的,历史行允许永久 NULL。
## 五、风险与取舍
| 风险 | 影响 | 应对 |
|---|---|---|
| 款名在 dim_style 被改,历史表 `style_name` 变孤儿 | 现在 join 直接丢数据,这正是要 style_id 的原因 | P3 之后 join 走 id,款名改动不再影响 |
| 加 `NOT NULL` 时某天有未建档款写入 | 写入失败,工作流中断 | 先在写入端做「未匹配记 NULL + 打 WARN」,P5 补齐后再收紧为 NOT NULL |
| `product_reviews` 下线后没有好评样本 | 风格分析评价分布退化 | 明确接受;需要时扩差评采集器等级,不恢复旧表 |
| 改 PK(有人会想顺手做) | data-hub 6 处 join + 飞书脚本全回归 | **本次明确不做**,PK 保持 style_name |
| 差评表天猫侧没有 product_id | 只能靠商品名反查款式 | 采集器补 product_id(页面有商品链接可解析),或退化为名称匹配并标记 `unmatched` |
## 六、验收标准
1. `v_style_id_coverage`:`daily_style_metrics`、`erp_all_shop_style_daily_metrics`、`weekly_style_metrics` 的 `with_style_id = rows`(100%)。
2. `product_daily_metrics` 的 `style_id` 非空比例不低于 2.2 节的实测基线(47%/55%/48%),且逐月上升。
3. `product_reviews` 无新增行;两个消费方在 `v_negative_reviews` 上跑通,风格分析与营销日报输出结构不变。
4. data-hub 产品生命页、营销中台预算页的款式指标与改造前同月数值一致(对账 2026-10 全月)。
## 七、建议执行顺序
P0 → P1(下线评论链路,独立可交付)→ P2(loader 一行改动,验证输出)→ P3(分表灰度,先做 `daily_style_metrics`)→ P4(约束 + 下游切换)→ P5(补建档)。
P1 与 P2/P3 没有依赖关系,可以先做 P1 拿到收益(少一条采集链路、少一张表),再从容做款式主键统一。
## 八、实施记录(2026-11-01)
### 已落地
| 阶段 | 成果 |
|---|---|
| P0 | `v_style_unmatched_product`、`v_style_id_coverage` 两个观测视图已建并验证 |
| P1 | 删 `import_product_reviews.py` / `orchestrate_review_collection.py`;移除 `product.review.orchestrate` 命令与 runtime binding;`run_collect` 不再调评论入库;建 `v_negative_reviews`;两个消费方改读视图;`product_reviews` 归档为 `product_reviews_archived_20261101`(1936 行已备份到 `var/tmp/product_reviews_archive_20261101.json`) |
| P2 | `merge_lifecycle_style_records` 输出 `style_id` + `style_match_method`;loader 透传;实库验证 **204/204 款全部拿到 style_id** |
| P3 | 14 张采集表加 `style_id`(+ `style_match_method`)+ 索引;db 层 `attach_style_ids` / `resolve_style_ids_by_product` 统一兜底;`backfill_style_id.py` 回填历史 |
| P4 | ~~10 张「必须匹配」表加 `style_id → dim_style(id)` 物理外键~~ → 已按要求**全部拆除**,改逻辑外键(库里 0 个 FK,只留 14 个 `style_id` 索引);新增 `tests/modules/product_commerce/test_style_id_identity.py`(28 条契约测试) |
| P5 | 提供 `v_style_unmatched_product` 清单(dy 170 / jd 99 / tm 189),需业务侧补 spus |
### 回填结果(实库)
| 表 | 行数 | 有 style_id | 比例 |
|---|---|---|---|
| daily_style_metrics | 17,352 | 17,352 | 100% |
| weekly_style_metrics | 532 | 532 | 100% |
| erp_all_shop_style_daily_metrics | 9,399 | 9,399 | 100% |
| daily_persona_metrics | 2,357 | 2,357 | 100% |
| main_image_creatives | 9,094 | 9,094 | 100% |
| fact_style_analysis_reports | 465 | 465 | 100% |
| decline_events / fact_alert_history | 14 / 14 | 14 / 14 | 100% |
| persona_screenshots | 25 | 25 | 100% |
| jd_self_weekly_sales | 117 | 111 | 94.9%(6 款已不在 dim_style) |
| tm_product_ad_daily_metrics | 11,727 | 11,108 | 94.7% |
| jd_product_ad_daily_metrics | 33,808 | 24,707 | 73.1% |
| dy_product_ad_daily_metrics | 18,202 | 10,875 | 59.7% |
| product_daily_metrics | 75,568 | 37,098 | 49.1%(未建档商品 ID 所致) |
### 与方案不同之处
1. **天猫广告表已先行迁到独立表**:方案编写时天猫指标还写在
`product_daily_metrics.raw_data.tm_ad_metrics`,实施期间该迁移已完成,因此改为给
`tm_product_ad_daily_metrics` 加 `style_id`(并纳入 `v_style_id_coverage`),
并未改动 `raw_data` 节点。
2. **写入端没有逐个脚本改**:方案原计划在 13 个写入脚本里各自填充,实际收敛到 db 层
的 `attach_style_ids` / `resolve_style_ids_by_product`(一次批量查询),再加 loader
下发。少改 13 处、不会漏,代价是 upsert 多一次按款名/商品 ID 的查询。
3. **`dy_product_scraping.py` 默认流程的评论采集直接摘除**(仍保留 `--reviews-only`
显式入口),比方案建议的「保留但不再进 product.daily」更彻底。
4. **物理外键最终没有保留**:P4 建过的 10 个 `<表名>_style_id_fk` 已全部 DROP,
`schema.sql` 改为「幂等清理 + 只建索引」。`style_id` 与 `dim_style.id` 是纯逻辑
外键关系,理由见 P4 段(fail-soft 采集语义、款式可重建、与 data-hub 对齐)。
### 仍待处理
- **data-hub 侧 join 未切**(`ProductLifecycleMapper.xml` 6 处仍是 `metric.style_name = style.name`)。
现在两边都有 id,可以随时切;切换后是 bigint join 替代 text join。
- P5 补建档:458 个商品 ID(dy 170 / jd 99 / tm 189)写进 `dim_style.spus`。
- 既有红灯(与本次无关):`test_tm_brand_profiles` 的 backfill 用例、`shop_intelligence`
的 12 个 jd 竞店用例,改动前即失败。
## 九、P5 补 spus 实施记录(2026-11-01)
### 依据的权威字段
补 spus 的匹配依据不是商品标题,而是 **`product_daily_metrics.product_no` —— 它存的就是款名**:
| 平台 | `product_no` 填充率 | 可用性 |
|---|---:|---|
| 天猫 | 100%(29552/29552) | 权威 |
| 京东 | 97.8%(16664/17044) | 权威 |
| 抖音 | 0%(0/28972) | 只能靠标题 |
这一条此前被忽略,导致「458 个未建档」被误判为只能猜标题。实际天猫/京东 136 个商品
靠 `product_no` 就精确命中,另有 31 个靠前后缀近似命中(「蓝鹊pro」→「蓝鹊」、
「知更大号」→「知更」、「星迹」→「星迹2」)。
注意 `product_no` **不是** ERP 编码:`dim_style.erp_style_codes` 存的是 `["10510"]`
这种数字码,两者用 `product_no = erp_style_codes` 只能对上 2 个,别混淆。
### 匹配算法(四路,按优先级)
1. `product_no` 精确 = `style_name` → 直接写;
2. `product_no` 与款名互为前后缀且长度差 ≤ 3,且候选唯一 → 直接写;
3. 标题包含完整款名(抖音只能走这条)→ 用「命中位置后的版本后缀」防串款:
标题「盖亚斜挎包**3**」、`dim_style` 有「盖亚斜挎3」时必须指后者而非「盖亚斜挎」;
若该版本款没建档,判「版本款未建档」而不是硬归到基础款;
4. 编码与标题互相矛盾 → 进人工清单,**不自动写**。
实测第 4 类里最贵的一条:天猫 `649545919334` 商品编码写「理岩」、标题写「阿波罗air」,
支付额 **50.2 万**。猜错就是 50 万挂错款,只能人工定。
### 写入结果
| 项 | 数值 |
|---|---:|
| 写入商品 ID | 199 个 |
| 涉及款式 | 67 个 |
| 覆盖 `product_daily_metrics` 行 | 22,636 |
| 未写(物料 / 冲突待定) | 87 个 |
合并策略:`tm_spus / jd_spus / dy_spus` 取**并集追加**,不覆盖已有值;写前备份到
`var/tmp/dim_style_spus_backup_20261101.json`(204 行)。
### 覆盖率变化
| 表 | 补前 | 补后 |
|---|---:|---:|
| product_daily_metrics | 49.1% | **79.0%** |
| tm_product_ad_daily_metrics | 94.7% | **99.4%** |
| jd_product_ad_daily_metrics | 73.1% | **92.4%** |
| dy_product_ad_daily_metrics | 59.7% | **83.2%** |
广告表的提升来自新增的第三段回填(按商品 ID + 补空 `style_name`):京东广告里
32 个商品 ID 就占了 9,101 行空值,补完 `style_name` 空白从 9,095 降到 2,554,
抖音从 7,327 降到 3,062。这意味着 **data-hub 侧不改任何 SQL,按款名的 join 已经多认回
11,359 行**。
### 剩余 259 个未建档 ID 的构成
| 分类 | 商品数 | 支付额 | 处置 |
|---|---:|---:|---|
| 待建档-真款 | 128 | 6.9 万 | 需在 dim_style 新建款(星云系列链接、拾影单肩包、幻月、纪元、极地冰冠、阿瑞斯、盖亚S1、漫游者、光/暗系列等) |
| 待建档-ozko 线 | 20 | 0.2 万 | ozko 子品牌款(逐云、微笑系列、甜酷系列、银鸥、独角兽、HB、cdbb、761X…) |
| 白名单-物料配件 | 30 | 5.6 万 | 不该进 spus(内胆包、数据线、风扇、防丢器…) |
| 白名单-服务/券 | 4 | 0 | 补邮费、兑换券、补贴 |
| 冲突待定 | 10 | 62.9 万 | 编码与标题矛盾,需人工定 |
清单:`var/tmp/spus_todo.csv`(按分档 + 金额排序)。
### 新增的长期机制
- `backfill_style_id.py` 增加 `AD_PRODUCT_TABLES` 与 `_backfill_ad_tables_by_product`,
广告表按 `spu_id / product_id` 二次回填,并只在 `style_name` 为空时补权威款名
(`COALESCE(NULLIF(BTRIM(style_name), ''), %s)`,不覆盖平台给出的值)。
- 新增视图 `v_style_unmatched_source`:`product_daily_metrics` + 三张广告表统一口径,
补建档只看这一个视图就够(实测与原 `v_style_unmatched_product` 数值一致,
说明没有纯投流商品只出现在广告表)。