Commit 1d2759dc by luoqi

fix(persona): 就诊次数/RFM频次 按诊所本地时区去重(修时区虚增)

问题(用户在王振中发现):画像'就诊2次',但病历只1条。查实=时区 bug。
王振中所有事实本地(Asia/Shanghai)都在 2025-09-26:encounter 本地03:09(=UTC 09-25 19:09)、
treatment 本地11:10(=UTC 09-26 03:10)。但 lifecycle/rfm 用 occurredAt.toISOString()(UTC日期)
去重就诊天 → encounter落09-25、treatment落09-26 → 切成两天 → 虚增'就诊2次'(实1次)。

- 新增 visit-day.util.ts:localDayKey(date, tz='Asia/Shanghai') 按诊所本地时区取 YYYY-MM-DD。
- lifecycle-stage + rfm 的就诊天去重改用 localDayKey(原 toISOString UTC)。
  (首/末诊用 getTime min/max,时区无关,不动)
- 影响:lifecycle 生命周期分期 + RFM 频次F + visitsPerYear,凡有本地凌晨(00-08点=UTC前一天)事件的患者。
- CLINIC_TZ 暂常量(试点全+8);多租户后续从 platform.pullConfig.timezone 接进 feature ctx。
验证(本地 --force 重算):王振中 lifecycle/rfm 均 '就诊2次'→'就诊1次'。
注:算法变更需 recompute-persona --force(数据没变,水位闸会 noop)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent aac46403
...@@ -6,6 +6,7 @@ import type { ...@@ -6,6 +6,7 @@ import type {
FeatureExtractorContext, FeatureExtractorContext,
PersonaFeatureDraft, PersonaFeatureDraft,
} from './feature.interface'; } from './feature.interface';
import { localDayKey } from './visit-day.util';
/** /**
* lifecycle_stage 生命周期(B.1.4)— 规则层,snapshot * lifecycle_stage 生命周期(B.1.4)— 规则层,snapshot
...@@ -68,7 +69,7 @@ export class LifecycleStageFeatureExtractor implements FeatureExtractor { ...@@ -68,7 +69,7 @@ export class LifecycleStageFeatureExtractor implements FeatureExtractor {
for (const f of visitFacts) { for (const f of visitFacts) {
if (!f.occurredAt) continue; if (!f.occurredAt) continue;
const t = f.occurredAt.getTime(); const t = f.occurredAt.getTime();
days.add(f.occurredAt.toISOString().slice(0, 10)); days.add(localDayKey(f.occurredAt)); // 按诊所本地时区去重(避免 UTC 跨午夜虚增就诊次数)
if (first === null || t < first) first = t; if (first === null || t < first) first = t;
if (last === null || t > last) last = t; if (last === null || t > last) last = t;
} }
......
...@@ -11,6 +11,7 @@ import type { ...@@ -11,6 +11,7 @@ import type {
FeatureExtractorContext, FeatureExtractorContext,
PersonaFeatureDraft, PersonaFeatureDraft,
} from './feature.interface'; } from './feature.interface';
import { localDayKey } from './visit-day.util';
/** /**
* rfm 价值分群(RFM 八象限 + 生命周期)— 统计层 * rfm 价值分群(RFM 八象限 + 生命周期)— 统计层
...@@ -126,7 +127,7 @@ export class RfmFeatureExtractor implements FeatureExtractor { ...@@ -126,7 +127,7 @@ export class RfmFeatureExtractor implements FeatureExtractor {
let firstVisit: Date | null = null; let firstVisit: Date | null = null;
for (const f of visitFacts) { for (const f of visitFacts) {
if (!f.occurredAt) continue; if (!f.occurredAt) continue;
visitDays.add(f.occurredAt.toISOString().slice(0, 10)); visitDays.add(localDayKey(f.occurredAt)); // 按诊所本地时区去重(避免 UTC 跨午夜虚增频次)
if (!lastVisit || f.occurredAt > lastVisit) lastVisit = f.occurredAt; if (!lastVisit || f.occurredAt > lastVisit) lastVisit = f.occurredAt;
if (!firstVisit || f.occurredAt < firstVisit) firstVisit = f.occurredAt; if (!firstVisit || f.occurredAt < firstVisit) firstVisit = f.occurredAt;
} }
......
/// 就诊"自然日"去重工具(lifecycle 就诊次数 / RFM 频次共用)。
///
/// ⚠️ 不能用 occurredAt.toISOString()(UTC 日期)做去重:本地同一天、但 UTC 跨午夜的两个事件
/// (如 encounter 本地 09-26 03:09 = UTC 09-25 19:09,treatment 本地 09-26 11:10 = UTC 09-26 03:10)
/// 会被切成 09-25 / 09-26 两天 → 虚增就诊次数(王振中:实 1 次被算 2 次)。
/// 按诊所本地时区取 YYYY-MM-DD 才是"自然就诊日"。
export const CLINIC_TZ = 'Asia/Shanghai'; // 试点全为 +8;多租户后续从 platform.pullConfig.timezone 接进 feature ctx
/// 诊所本地时区下的就诊日 key(en-CA locale 输出即 YYYY-MM-DD)。
export function localDayKey(date: Date, tz: string = CLINIC_TZ): string {
return new Intl.DateTimeFormat('en-CA', { timeZone: tz }).format(date);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment