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。
修复分两层:
- 代码止血:
WHERE改为成对(inv_id, quote_rule_id),增加sid和ELSE custom_price。 - 历史补偿:只根据审核后的 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:
- 从价格日志得到本批次
inv_id集合; - 从旧版 SQL 日志或同批动态结算价规则得到真实
(inv_id, quote_rule_id)CASE 对; - 计算独立
IN产生的交叉积; - 排除真实 CASE 对,只保留“命中 WHERE 但没有 CASE 分支”的行;
- 连接当前规则商品和基础价格,生成 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;
推荐执行顺序:
- dry-run 生成 manifest,确认数量和字段分布;
- 锁定每行并再次读取当前值;
- 校验规则、价格、原值和批次快照;
- 只更新
custom_price; - 立即回读并要求
affected_rows=1; - 全部行通过后提交事务;
- 提交后再次逐条回读;
- 抽查列表和实际询价。
如果任意一条前置条件不符,整批回滚或跳过该行并记录原因,不能强行覆盖。
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”时,先回答四个问题:
- 这是动态价还是固定价?
- 这条规则是否真的属于某个价格变更批次?
- 旧 SQL 是否存在独立
IN交叉命中且没有对应 CASE? - 当前规则参数和基础价格是否仍与 manifest 一致?
只有四个问题都能用数据回答,才进入历史补偿;否则保持只读,先扩大证据范围。