1. 适用场景

当出现以下现象时使用本手册:

  • 报价规则列表中的动态自定义价变成 0;
  • 指导价大于 0,但“指导价 + 固定金额”的自定义价为 0;
  • 编辑页、列表页和实际询价显示不一致;
  • 价格基础数据同步后,同一批次出现部分规则正确、部分规则被刷成 0。

本手册只处理已确认属于批量刷新联合键错误的数据,不处理所有 custom_price=0 数据。

2. 结论先看

旧版 QuoteManagerSer::updateCustomPrice() 的问题是:

CASE 按 (inv_id, quote_rule_id) 成对计算
WHERE 却按 inv_id IN (...) AND quote_rule_id IN (...) 独立过滤

当物料集合和规则集合发生交叉时,某条行同时命中两个 IN,但没有对应的 CASE WHEN。旧 SQL 没有 ELSE,目标列最终表现为 NULL,在非空默认 0 的字段上落成 custom_price=0。

修复分两层:

  1. 代码止血:WHERE 改为成对 (inv_id, quote_rule_id),增加 sid 和 ELSE custom_price。
  2. 历史补偿:只根据审核后的 manifest,按原规则重新计算,并使用 compare-and-set 逐条补回。

3. 业务与技术链路

flowchart TD
  A[结算价或指导价基础数据同步] --> B[QuoteGoodsBase 更新基础价格]
  B --> C[t_price_change_log]
  C --> D[MqSer 发布价格变更消息]
  D --> E[GuidPriceNotify 消费]
  E --> F[QuoteManagerSer::handlePriceChange]
  F --> G[updateCustomPrice]
  G --> H[t_quote_rule_goods_shard]
  H --> I[报价规则列表与实际询价]

主要代码和数据源:

  • 基础价格任务:application/controllers/tasks/QuoteGoodsBase.php
  • 消费者:application/controllers/tasks/GuidPriceNotify.php
  • 消息生产:application/Services/Mq/MqSer.php
  • 批量重算:application/Services/BaseData/QuoteManagerSer.php::updateCustomPrice()
  • 规则商品:t_quote_rule_goods_<sid % 64>
  • 基础价格:t_quote_goods_base_<sid % 64>
  • 变更批次:t_price_change_log
  • 价格变化主题:DGJ_GUID_PRICE_CHANGE、DGJ_GUIDE_PRICE_CHANGE

4. 字段语义,先分清再修

字段含义排查注意
custom_price_type=1固定自定义价金额权威字段是 fix_price;custom_price=0 可能正常
custom_price_type=2动态自定义价金额权威字段是 custom_price
price_base=1基于指导价期望值按指导价和规则参数计算
price_base=2基于最近结算价只在结算价规则行上按结算价计算
price_method=1固定金额加价base_price + add_amount,金额单位为分
price_method=2百分比加价按现有 calculateCustomPrice() 复算,不能套用固定金额公式
fix_price固定价字段不得用动态补偿脚本覆盖
custom_price动态计算结果本问题的历史补偿目标字段

固定价出现 fix_price=1200、custom_price=0 不等于丢价;动态指导价出现 guide_price>0、custom_price=0 才需要继续核对批次和联合键。

5. 排查流程

5.1 先确认代码版本

确认运行中的 QuoteManagerSer.php 已包含:

  • 成对 (inv_id, quote_rule_id) 条件;
  • sid 条件;
  • CASE ... ELSE custom_price END。

如果代码仍是旧版,先止血发布,不能先大范围修历史数据。

5.2 确认同一价格批次

优先使用明确的价格日志批次键:

bill_date + change_type + version

不要只按 modify_time 或当前 custom_price=0 扫全表。历史案例使用了:

bill_date=2026-09-07
change_type=2
version=27

5.3 重建交叉命中集合

对每个 sid:

  1. 从价格日志得到本批次 inv_id 集合;
  2. 从旧版 SQL 日志或同批动态结算价规则得到真实 (inv_id, quote_rule_id) CASE 对;
  3. 计算独立 IN 产生的交叉积;
  4. 排除真实 CASE 对,只保留“命中 WHERE 但没有 CASE 分支”的行;
  5. 连接当前规则商品和基础价格,生成 manifest。

5.4 异常候选条件

候选必须同时满足:

custom_price_type=2
price_base=1
guide_price>0
custom_price=0
存在旧 SQL 的交叉命中证据
没有后续规则修改或价格漂移

以下情况必须排除:

  • 固定价行,即 custom_price_type=1;
  • 指导价或基础价本身为 0;
  • 无动态自定义价配置;
  • 当前值已经非 0;
  • 当前规则参数、指导价或批次时间已发生变化;
  • 只有独立 IN 命中、但没有旧批次证据。

6. Manifest 必须包含的字段

至少保存以下字段,不能只保存 id:

sid
quote_rule_id
t_quote_rule_goods.id
inv_id
sku_id
guide_price 快照
last_settle_price 快照
custom_price 原值
custom_price_type
price_base
price_method
round_type
add_amount / add_percent
异常批次时间或批次键
expected_custom_price

本次案例中,78 条全部属于同一服务站和同一规则,规则为动态“指导价 + 1000 分”;但下次不能直接假设所有候选规则参数一致,必须逐行生成期望值。

7. 安全补偿流程

7.1 连接边界

生产补偿必须使用生产应用 default 数据库连接,并先执行:

SELECT DATABASE(), @@hostname, @@read_only, @@super_read_only;

只有确认连接的是可写主库后,才允许进入 apply。报表连接或 PolarDB 只读节点只能用于查询;写入被拒绝不代表业务 SQL 有问题。

7.2 逐条 compare-and-set

每条更新必须带主键和原快照条件,示意如下:

UPDATE t_quote_rule_goods_<shard> g
JOIN t_quote_goods_base_<shard> b
  ON b.sid = g.sid AND b.inv_id = g.inv_id
SET g.custom_price = :expected_custom_price
WHERE g.id = :id
  AND g.sid = :sid
  AND g.quote_rule_id = :quote_rule_id
  AND g.custom_price_type = 2
  AND g.price_base = 1
  AND g.custom_price = 0
  AND b.guide_price = :guide_price_snapshot;

推荐执行顺序:

  1. dry-run 生成 manifest,确认数量和字段分布;
  2. 锁定每行并再次读取当前值;
  3. 校验规则、价格、原值和批次快照;
  4. 只更新 custom_price;
  5. 立即回读并要求 affected_rows=1;
  6. 全部行通过后提交事务;
  7. 提交后再次逐条回读;
  8. 抽查列表和实际询价。

如果任意一条前置条件不符,整批回滚或跳过该行并记录原因,不能强行覆盖。

7.3 本次真实结果

本次生产历史补偿已完成:

  • 批次候选:78 条;
  • 每条原值:custom_price=0;
  • 每条计算:当前指导价 + 1000 分;
  • 事务内逐条校验和回读:78/78 通过;
  • 提交后最终回读:78/78 通过;
  • 未修改 t_price_change_log、指导价、结算价、fix_price 或 MQ。

逐条结果保存在项目变更包的 production-compensation-20260915.md,该文件不应发布到公共知识库作为生产数据清单。

8. 验证清单

数据库层

  • [ ] manifest 数量与审批数量一致;
  • [ ] 所有目标行原值仍为 0;
  • [ ] 每行 affected_rows=1;
  • [ ] 每行回读值等于 expected_custom_price;
  • [ ] 原始异常筛选结果为 0;
  • [ ] 非目标服务站、规则和物料没有被更新;
  • [ ] t_price_change_log.status 未被人工改动。

业务层

  • [ ] 报价规则列表显示正确;
  • [ ] 实际询价结果正确;
  • [ ] 固定价行仍以 fix_price 为准;
  • [ ] 后续新价格批次没有再次出现交叉覆盖;
  • [ ] 消费者日志没有新的 SQL 异常或数据库错误。

9. 禁止操作与回滚

禁止:

  • UPDATE ... WHERE custom_price=0 全表补值;
  • 把所有指导价乘以加价规则,不核对规则商品行;
  • 修改 t_price_change_log.status 伪造消费成功;
  • 重放历史 MQ 代替历史数据补偿;
  • 修改 guide_price、last_settle_price 或 fix_price;
  • 连接只读报表库后反复重试写入;
  • 将生产账号、密码、完整连接串写进脚本、文档或聊天记录。

如果补偿结果需要回滚,必须使用原 manifest,且只允许在当前值仍等于“补偿后值”时执行反向 compare-and-set;不能凭感觉把数据改回 0。

10. 关键源代码与证据

  • application/Services/BaseData/QuoteManagerSer.php::updateCustomPrice():批量更新和成对条件修复。
  • application/Services/BaseData/QuoteManagerBaseSer.php::calculateCustomPrice():动态价格计算公式。
  • application/controllers/tasks/QuoteGoodsBase.php:基础价格变化检测和价格日志写入。
  • application/controllers/tasks/GuidPriceNotify.php:价格变化消息消费。
  • application/Services/Mq/MqSer.php:价格变化消息发布。
  • doc/ai/changes/active/BUG-20260904-QUOTE-CUSTOM-PRICE-DISPLAY-报价规则固定自定义价列表显示为零/evidence.md:生产根因和补偿边界。
  • doc/ai/changes/active/BUG-20260904-QUOTE-CUSTOM-PRICE-DISPLAY-报价规则固定自定义价列表显示为零/production-compensation-20260915.md:本次逐条补偿清单,仅限内部变更包使用。

11. 下一次快速判断

看到“指导价有值、动态自定义价为 0”时,先回答四个问题:

  1. 这是动态价还是固定价?
  2. 这条规则是否真的属于某个价格变更批次?
  3. 旧 SQL 是否存在独立 IN 交叉命中且没有对应 CASE?
  4. 当前规则参数和基础价格是否仍与 manifest 一致?

只有四个问题都能用数据回答,才进入历史补偿;否则保持只读,先扩大证据范围。