Commit 669f4fa6 by luoqi

feat(persona): 折扣锚点特征(D.1.3)+ 修 spec naive 点

- DW 无 original_amount;discount_*_rate 实为折扣金额(应收3-dept0.45=实收2.55)→
  折扣率=1-Σ折扣/应收。摄入 payment_record.content.discount_cents + settlement_project(重摄)。
- discount_anchor:取真实治疗最深【部分】折扣 + 日期/项目。
- ️ 修 spec 两个 naive 点:① 免费洁牙/检查促销(100%off=0折)会霸占锚点→只看原价≥¥500+ratio>0;
  ② 保险方付款非诊所折扣→排除 discount_insurance_rate(只算科室/公司/卡)。
- 本地 338 患者:avg 6.4折,样例合理(种植9.4折/修复/促销2.9-5.1折)。
- ️ 留口径:近免费(>90%off)comp/全保 是否算锚点;¥500 阈值可调。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 5210f3ea
......@@ -33,6 +33,12 @@ field_mapping:
# 卡券名称(B.1.3 权益身份关键词匹配源:储值/儿牙/银行私行/商保/医保)
cardTypeName: card_type_name
cardName: card_name
# 折扣锚点(D.1.3):4 渠道折扣额(元)+ 项目名;折扣率=1-Σ折扣/应收(parser 算)
discountDept: discount_dept_rate
discountCompany: discount_company_rate
discountInsurance: discount_insurance_rate
discountCard: discount_card_rate
settlementProject: settlement_project_name
# 商业保险公司名(settlement_mode_out.insurance_name)— 喂 persona「权益身份」特征。
# 保司名脏(57 个别名),归一在 entitlement-status.feature 的 canonicalInsurer 里做;
# 此处原样带入 fact.content.insurance_name(单一收口),非空即"商保结算"强信号。
......
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType } from '@pac/types';
import type {
ActiveFact,
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
/**
* discount_anchor 折扣锚点(D.1.3)— 规则层,snapshot
*
* 标签:历史最大折扣力度(最低折扣率)+ 对应结算日期/项目。销售推优惠的价格底线参考。
*
* 数据源:payment_record.content(摄入自结算):amount_cents=应收(原价)、discount_cents=折扣额(分,4渠道和)。
* ⚠️ DW 无 original_amount 字段;折扣率 = (应收−折扣)/应收 = 1 − discount_cents/amount_cents
* (discount_*_rate 实为折扣金额,非比率;储值卡 settlement_money=0 但 discount=0,口径稳)。
*
* 算法:遍历"真实治疗的部分折扣"结算,取最小折扣率(力度最大)+ 保留日期/项目。
* 无折扣记录 → 不打标签(业务:无锚点则换推增值权益而非直接降价)。
* ⚠️ 修正 spec naive 点:原"遍历全部结算"会被免费洁牙/检查促销(100%off=0折)霸占,
* 锚点失去"价格底线"意义。故只看 原价≥¥500(真实治疗,排基础/促销小项)且 ratio>0
* (排全免 comp)——锚点 = 真实治疗上谈到的最深【部分】折扣。¥500 阈值可调(口径)。
*/
@Injectable()
export class DiscountAnchorFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.DISCOUNT_ANCHOR;
private static readonly MIN_ORIGINAL_CENTS = 50000; // ¥500:真实治疗,排基础/促销小项
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
const pays = (ctx.factsByType.get(FactType.PAYMENT_RECORD) ?? []) as ActiveFact[];
let minRatio = Infinity;
let anchorAt: Date | null = null;
let project: string | null = null;
let anchorAmount = 0;
let anchorDiscount = 0;
for (const f of pays) {
const c = (f.content ?? {}) as Record<string, unknown>;
const amount = Number(c.amount_cents ?? 0); // 应收(原价)
const disc = Number(c.discount_cents ?? 0);
if (amount < DiscountAnchorFeatureExtractor.MIN_ORIGINAL_CENTS || disc <= 0) continue; // ≥¥500 真实治疗 + 有折扣
const ratio = Math.max(0, Math.min(1, (amount - disc) / amount));
if (ratio <= 0) continue; // 排全免 comp(0折不是可参考的销售折扣)
if (ratio < minRatio) {
minRatio = ratio;
anchorAt = f.occurredAt ?? null;
project = String(c.settlement_project ?? '').trim() || null;
anchorAmount = amount;
anchorDiscount = disc;
}
}
if (!Number.isFinite(minRatio)) return null; // 从无折扣 → 无锚点
const zhe = (minRatio * 10).toFixed(1); // 0.85 → 8.5折
const dateStr = anchorAt ? anchorAt.toISOString().slice(0, 10) : null;
const projStr = project ? ` · ${project}` : '';
return {
key: this.key,
description: `最大折扣力度 ${zhe}${dateStr ? ` · ${dateStr}` : ''}${projStr}`,
score: null,
data: {
minDiscount: Math.round(minRatio * 100) / 100, // 折扣率 0-1
anchorDate: dateStr,
project,
anchorAmountCents: anchorAmount,
anchorDiscountCents: anchorDiscount,
},
evidence: { factIds: [] },
};
}
}
......@@ -11,6 +11,7 @@ import { ReferralChampionFeatureExtractor } from './referral-champion.feature';
import { LifecycleStageFeatureExtractor } from './lifecycle-stage.feature';
import { TreatmentHistoryFeatureExtractor } from './treatment-history.feature';
import { TimePreferenceFeatureExtractor } from './time-preference.feature';
import { DiscountAnchorFeatureExtractor } from './discount-anchor.feature';
/**
* FeatureRegistry — 收集所有 PersonaFeature 提取器。
......@@ -34,9 +35,10 @@ export class FeatureRegistry {
lifecycle: LifecycleStageFeatureExtractor,
treatmentHistory: TreatmentHistoryFeatureExtractor,
timePref: TimePreferenceFeatureExtractor,
discountAnchor: DiscountAnchorFeatureExtractor,
dnc: DoNotContactStatusFeatureExtractor,
entitlement: EntitlementStatusFeatureExtractor,
) {
this.extractors = [rfm, age, gender, acquisition, family, referral, lifecycle, treatmentHistory, timePref, dnc, entitlement];
this.extractors = [rfm, age, gender, acquisition, family, referral, lifecycle, treatmentHistory, timePref, discountAnchor, dnc, entitlement];
}
}
......@@ -13,6 +13,7 @@ import { ReferralChampionFeatureExtractor } from './features/referral-champion.f
import { LifecycleStageFeatureExtractor } from './features/lifecycle-stage.feature';
import { TreatmentHistoryFeatureExtractor } from './features/treatment-history.feature';
import { TimePreferenceFeatureExtractor } from './features/time-preference.feature';
import { DiscountAnchorFeatureExtractor } from './features/discount-anchor.feature';
@Module({
controllers: [PersonaController],
......@@ -29,6 +30,7 @@ import { TimePreferenceFeatureExtractor } from './features/time-preference.featu
LifecycleStageFeatureExtractor,
TreatmentHistoryFeatureExtractor,
TimePreferenceFeatureExtractor,
DiscountAnchorFeatureExtractor,
DoNotContactStatusFeatureExtractor,
EntitlementStatusFeatureExtractor,
],
......
......@@ -342,6 +342,9 @@ const PaymentRecordContent = z
/// 卡券类型名 / 卡券名(可空)— B.1.3 权益身份关键词匹配源(储值/儿牙/银行私行等)
card_type_name: nullableString(),
card_name: nullableString(),
/// 折扣额(分,4 渠道求和)+ 结算项目名 — D.1.3 折扣锚点(折扣率=1-discount/应收)
discount_cents: z.number().int().optional().nullable(),
settlement_project: nullableString(),
/// 收费医生 id(host 侧)— 医患关系信号(患者主要花钱给哪个医生)
doctor_id: nullableString(),
/// 关联接诊 id(反查"这次收款关联哪次接诊")
......
......@@ -51,6 +51,14 @@ export class PaymentParser implements Parser {
// String() 强转 — host 偶有数字型卡券值(.trim 直接调会炸)。
const cardTypeName = String(c.cardTypeName ?? '').trim() || null;
const cardName = String(c.cardName ?? '').trim() || null;
// 折扣锚点(D.1.3):诊所可控折扣额(元)→ 分;折扣率 = 1 - 折扣/应收(feature 算)。
// ⚠️ 排除 discount_insurance_rate(保险方付款,非诊所价格折扣;不进"价格底线"锚点)。
const discYuan =
Number(c.discountDept ?? 0) +
Number(c.discountCompany ?? 0) +
Number(c.discountCard ?? 0);
const discountCents = Number.isFinite(discYuan) ? Math.round(discYuan * 100) : 0;
const settlementProject = String(c.settlementProject ?? '').trim() || null;
return [
{
......@@ -69,6 +77,8 @@ export class PaymentParser implements Parser {
insurance_name: insuranceName,
card_type_name: cardTypeName,
card_name: cardName,
discount_cents: discountCents,
settlement_project: settlementProject,
doctor_id: doctorId,
encounter_external_id: encounterExternalId,
related_order_external_id: orderExternalId,
......
......@@ -390,6 +390,7 @@ export const PersonaFeatureKey = {
LIFECYCLE_STAGE: 'lifecycle_stage', // 生命周期(潜客..流失客;时间+消费规则)
TREATMENT_HISTORY: 'treatment_history', // 治疗史(种植/正畸/修复/牙周;读 treatment category)
TIME_PREFERENCE: 'time_preference', // 时间偏好(工作日/周末/上午/下午/晚间;预约时刻统计)
DISCOUNT_ANCHOR: 'discount_anchor', // 折扣锚点(历史最低折扣率+日期;价格底线参考)
// v1 候选(规则路径,业务方反馈后逐步上)
ENTITLEMENT_STATUS: 'entitlement_status', // 权益身份(商保直付 / 医保 / 储值 / 私行;事实投影型,史+最近日期)
......
......@@ -47,6 +47,7 @@ export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }>
[PersonaFeatureKey.LIFECYCLE_STAGE]: { label: '生命周期', tone: 'teal' },
[PersonaFeatureKey.TREATMENT_HISTORY]: { label: '治疗史', tone: 'amber' },
[PersonaFeatureKey.TIME_PREFERENCE]: { label: '时间偏好', tone: 'sky' },
[PersonaFeatureKey.DISCOUNT_ANCHOR]: { label: '折扣锚点', tone: 'rose' },
[PersonaFeatureKey.VALUE]: { label: '患者价值', tone: 'indigo' },
[PersonaFeatureKey.TREATMENT_CHAIN_STATUS]: { label: '治疗链状态', tone: 'amber' },
[PersonaFeatureKey.RECALL_RISK]: { label: '流失风险', tone: 'emerald' },
......
......@@ -232,4 +232,22 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
owner: 'pac-algo',
version: 1,
},
// ── D.1.3 折扣锚点(业务 CDP 口径)──
discount_anchor: {
key: 'discount_anchor',
nameZh: '折扣锚点',
tier: 'rule',
timeSemantics: 'lifetime',
labelValues: ['最低折扣率 + 对应结算时间/项目'],
dataSource: 'PAC payment_record.content(amount_cents=应收/原价、discount_cents=4渠道折扣额和);DW 无 original_amount,折扣率自算',
dataFields: ['actual_amount', 'original_amount', 'settlement_date'],
meaning: '客户历史结算中享受过的最大折扣力度(最低折扣率),作为销售推优惠时的价格底线参考',
algorithm: [
'遍历有折扣的结算:折扣率 = 1 − discount_cents/amount_cents(DW 无原价字段;discount_*_rate 实为折扣金额)。',
'取最小折扣率(力度最大)+ 保留日期/项目。无折扣→不打标签(业务:无锚点换推增值权益,不直接降价)。',
].join('\n'),
owner: 'pac-algo',
version: 1,
},
};
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