fix(plan): unchanged 分支支持 reason 就地刷新 —— 修存量 plan 的 signals 不生效

## 问题

引擎变化判定只比 `(scenario, subKey)` 集合;集合没变就走 unchanged 分支,
**plan_reasons 一个字都不改**。于是"只改 signals 内容"的算法升级对存量 plan
永远不生效 —— 生产 3,170 条高龄缺牙即使上线了按年龄排治疗(signals 新增
focusCategory / patientAge),仍显示「目标·种植」。

原先只能删库重算(丢认领、丢版本号、丢触达计数)或写一次性 SQL 回填(逻辑重复、
易与引擎口径漂移)。

## 做法

unchanged 分支新增就地刷新:**不升版本、不动认领/状态/抑制窗/触达计数**。
同时补 plan.goal 的比较(goal 是静态文案,算法改措辞要能落到存量)。

刻意不动的字段:closedReason / closedAt(关闭状态)、source / sourceActorId /
campaignId(来源溯源)、lifecycle;已关闭的 reason 行直接跳过。

##  关键:只在语义变化时才写

reason 文案与 signals 都内嵌天数(`${days} 天前` / `signals.daysSince`),天天变。
无条件刷新 = 每日重算把全部 plan_reasons 重写一遍(生产 23 万+ plan),纯废写 + WAL 膨胀。

判据(reason-refresh.ts):比 **signals(去掉 daysSince)+ evidence.factIds(排序后)**,
**不解析文案** —— reason 文案完全由 signals 派生(诊断名←triggers、牙位←toothPosition、
治疗类目←expectedCategories+patientAge),所以比 signals 既充分又不依赖文案格式。

踩到并修掉的坑:**Postgres jsonb 不保留键序**(按键长+字节序重排),直接 JSON.stringify
比较会让库里读出的和新算出的永远不等 → 每次都判"变了"。本地实测连跑三次每次都刷新。
改用递归排序键的规范化序列化后,第二三次不再写。

## 验证
- 382 单测通过(新增 16 例 reason-refresh 单元 + 2 例批量路径集成)
- 本地端到端(90 岁吕学文,人为退回存量旧状态 + 认领 + 触达 2 次):
  第 1 次重算 → 「reason 就地刷新 3 条」,signals 补上 focusCategory/patientAge、
  文案变「未启动活动义齿 / 种植」、goal 换高龄版;
  version 仍为 1、status=assigned、assignee=u-tester、contactAttempts=2 全部未动;
  第 2、3 次重算 → 0 条刷新(幂等,不产生废写)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Found errors in your .gitlab-ci.yml:

  • jobs:openapi-drift config contains unknown keys: rules
You can also test your .gitlab-ci.yml in the Lint
Status Job ID Name Coverage