Commit 28783dde by luoqi

feat(persona): 获客渠道特征(A.2.1)+ 副表立柱摄入

- DW fact_client_out.primary_category/sub_category(L2 初诊来源)→ 摄入 PAC 副表
  patient_profiles.acquisition_channel(PAC立柱标准枚举)/acquisition_sub(host原值)。
- 链路:canonical-codes.PACAcquisitionChannels(单一收口)+ patient.yaml enum_mapping(走入→walk_in 等)
  + PatientCanonicalSchema + 两条 upsert 路径(cold-import 全量 / dispatcher 增量)+ migration(2列+索引)。
- acquisition_channel persona 特征(snapshot,读副表立柱出标签)+ 注册表 spec/enum/label。
- 本地重摄 928:口碑331/走入162/集团营销158/地区营销157/电商65/集团销售53/自媒体2,特征100%覆盖。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 48c60a48
......@@ -20,4 +20,21 @@ field_mapping:
# 时效:"当前"会换 → 存当前值(upsert 覆盖);用于详情页展示 + 推送人匹配。
dedicatedCsName: current_task_director
dedicatedCsId: current_task_director_id
# 获客渠道(初诊来源,A.2.1)— 一级经 enum_mapping 归一 PAC 标准;二级原值透传
acquisitionChannel: primary_category
acquisitionSub: sub_category
# doNotContact / deceased 不映射 — 走 PatientCanonicalSchema default false
# host(瑞尔/瑞泰)初诊一级渠道 → PAC 立柱标准(canonical-codes.PACAcquisitionChannels)
enum_mapping:
acquisitionChannel:
走入: walk_in
口碑客户: word_of_mouth
集团销售渠道: group_sales
集团营销渠道: group_marketing
地区营销渠道: regional_marketing
电商平台: ecommerce
自媒体网络: social_media
瑞尔员工: employee
其他: other
_default: other
-- AlterTable
ALTER TABLE "patient_profiles" ADD COLUMN "acquisition_channel" TEXT,
ADD COLUMN "acquisition_sub" TEXT;
-- CreateIndex
CREATE INDEX "patient_profiles_acquisition_channel_idx" ON "patient_profiles"("acquisition_channel");
......@@ -249,6 +249,13 @@ model PatientProfile {
/// notes 单段长文本(客服备注 / 诊所自由描述)
notes String? @db.Text
/// 获客渠道(初诊来源,A.2.1) PAC 立柱标准枚举(host 值经 assembler enum_mapping 归一)
/// 一经判定不改(不可变源属性,非易变 persona,故落副表立柱而非 fact);多宿主共用此标准列。
/// 值见 canonical-codes.PACAcquisitionChannels(walk_in/word_of_mouth/.../other)
acquisitionChannel String? @map("acquisition_channel")
/// 二级渠道(host 原值透传,"详见数仓定义";不强约束,展示/下钻用)
acquisitionSub String? @map("acquisition_sub")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
......@@ -256,6 +263,7 @@ model PatientProfile {
@@index([doNotContact])
@@index([deceased])
@@index([acquisitionChannel])
@@map("patient_profiles")
}
......
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, acquisitionChannelLabelZh } from '@pac/types';
import type {
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
/**
* acquisition_channel 获客渠道(初诊来源,A.2.1)— 规则层,snapshot
*
* 口径(图):客户首次到诊的获客来源,数仓已按初诊来源规则算好(L2),一经判定不改。
* 数据来源:fact_client_out.primary_category(一级)/ sub_category(二级)→ 摄入 PAC 副表
* patient_profiles.acquisition_channel(PAC 立柱标准枚举)/ acquisition_sub(host 原值)。
* 本特征只把副表立柱值投影成标签(host→PAC 归一已在 assembler enum_mapping 完成)。
* 无渠道值(老数据/未摄入)→ 不打标签(返回 null)。
*/
@Injectable()
export class AcquisitionChannelFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.ACQUISITION_CHANNEL;
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
const channel = ctx.profile?.acquisitionChannel ?? null;
if (!channel) return null;
const sub = ctx.profile?.acquisitionSub ?? null;
const label = acquisitionChannelLabelZh(channel);
return {
key: this.key,
description: sub ? `${label} · ${sub}` : label,
score: null,
data: { channel, label, sub },
// 来自副表立柱(源自 fact_client_out,非 fact 证据链)
evidence: { factIds: [] },
};
}
}
......@@ -29,6 +29,9 @@ export interface FeatureExtractorContext {
deceased: boolean;
tags: string[];
notes: string | null;
/// 获客渠道(A.2.1):一级 PAC 标准枚举 / 二级 host 原值
acquisitionChannel: string | null;
acquisitionSub: string | null;
} | null;
/// 该 patient 的所有 active facts(按 type 索引方便查)
factsByType: Map<string, ActiveFact[]>;
......
......@@ -5,6 +5,7 @@ import { EntitlementStatusFeatureExtractor } from './entitlement-status.feature'
import { RfmFeatureExtractor } from './rfm.feature';
import { AgeBracketFeatureExtractor } from './age-bracket.feature';
import { GenderFeatureExtractor } from './gender.feature';
import { AcquisitionChannelFeatureExtractor } from './acquisition-channel.feature';
/**
* FeatureRegistry — 收集所有 PersonaFeature 提取器。
......@@ -22,9 +23,10 @@ export class FeatureRegistry {
rfm: RfmFeatureExtractor,
age: AgeBracketFeatureExtractor,
gender: GenderFeatureExtractor,
acquisition: AcquisitionChannelFeatureExtractor,
dnc: DoNotContactStatusFeatureExtractor,
entitlement: EntitlementStatusFeatureExtractor,
) {
this.extractors = [rfm, age, gender, dnc, entitlement];
this.extractors = [rfm, age, gender, acquisition, dnc, entitlement];
}
}
......@@ -7,6 +7,7 @@ import { EntitlementStatusFeatureExtractor } from './features/entitlement-status
import { RfmFeatureExtractor } from './features/rfm.feature';
import { AgeBracketFeatureExtractor } from './features/age-bracket.feature';
import { GenderFeatureExtractor } from './features/gender.feature';
import { AcquisitionChannelFeatureExtractor } from './features/acquisition-channel.feature';
@Module({
controllers: [PersonaController],
......@@ -17,6 +18,7 @@ import { GenderFeatureExtractor } from './features/gender.feature';
RfmFeatureExtractor,
AgeBracketFeatureExtractor,
GenderFeatureExtractor,
AcquisitionChannelFeatureExtractor,
DoNotContactStatusFeatureExtractor,
EntitlementStatusFeatureExtractor,
],
......
......@@ -203,6 +203,8 @@ export class PersonaService {
deceased: patient.profile.deceased,
tags: patient.profile.tags,
notes: patient.profile.notes,
acquisitionChannel: patient.profile.acquisitionChannel,
acquisitionSub: patient.profile.acquisitionSub,
}
: null,
factsByType,
......
......@@ -816,6 +816,9 @@ export class ColdImportService {
// (primaryContactType 已废弃 → 联系人改走 PatientRelation 边表,processPatientRelations)
tags: Array.isArray(c.tags) ? (c.tags as string[]) : [],
notes: (c.notes as string | undefined) ?? null,
// 获客渠道(A.2.1):一级 PAC 标准枚举(assembler enum_mapping 已归一)/ 二级 host 原值
acquisitionChannel: (c.acquisitionChannel as string | undefined) ?? null,
acquisitionSub: (c.acquisitionSub as string | undefined) ?? null,
};
try {
......
......@@ -232,6 +232,9 @@ export class PipelineDispatcher {
deceased: (row.deceased as boolean | undefined) ?? false,
tags: Array.isArray(row.tags) ? (row.tags as string[]) : [],
notes: (row.notes as string | undefined) ?? null,
// 获客渠道(A.2.1):一级 PAC 标准枚举 / 二级 host 原值(assembler 已映射)
acquisitionChannel: (row.acquisitionChannel as string | undefined) ?? null,
acquisitionSub: (row.acquisitionSub as string | undefined) ?? null,
};
const patient = await this.prisma.patient.upsert({
......
......@@ -713,3 +713,24 @@ export const PACTriggerTypeLabels: Record<string, string> = {
export function triggerTypeLabelZh(type: string): string {
return PACTriggerTypeLabels[type] ?? type;
}
/**
* PAC 立柱标准:获客渠道(初诊来源,A.2.1)— 单一收口。
* host 各自的渠道叫法经 assembler enum_mapping 归一到这套标准 code(多宿主共用)。
* 二级渠道(sub_category)host 特异,不在此枚举(原值透传 + 展示)。
*/
export const PACAcquisitionChannels: Record<string, string> = {
walk_in: '走入',
word_of_mouth: '口碑客户',
group_sales: '集团销售渠道',
group_marketing: '集团营销渠道',
regional_marketing: '地区营销渠道',
ecommerce: '电商平台',
social_media: '自媒体网络',
employee: '内部员工',
other: '其他',
};
export function acquisitionChannelLabelZh(code: string | null | undefined): string {
return (code && PACAcquisitionChannels[code]) || '未知';
}
......@@ -384,6 +384,7 @@ export const PersonaFeatureKey = {
// 规则层 · snapshot(从 birthDate 当下算)
AGE_BRACKET: 'age_bracket', // 年龄段(婴幼儿..老年,匹配适龄项目/家庭决策)
GENDER: 'gender', // 性别(男性/女性/未知,影响话术与项目推荐)
ACQUISITION_CHANNEL: 'acquisition_channel', // 获客渠道(初诊来源,数仓 L2;副表立柱)
// v1 候选(规则路径,业务方反馈后逐步上)
ENTITLEMENT_STATUS: 'entitlement_status', // 权益身份(商保直付 / 医保 / 储值 / 私行;事实投影型,史+最近日期)
......
......@@ -41,6 +41,7 @@ export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }>
[PersonaFeatureKey.RFM]: { label: '价值分群', tone: 'indigo' },
[PersonaFeatureKey.AGE_BRACKET]: { label: '年龄段', tone: 'sky' },
[PersonaFeatureKey.GENDER]: { label: '性别', tone: 'slate' },
[PersonaFeatureKey.ACQUISITION_CHANNEL]: { label: '获客渠道', tone: 'teal' },
[PersonaFeatureKey.VALUE]: { label: '患者价值', tone: 'indigo' },
[PersonaFeatureKey.TREATMENT_CHAIN_STATUS]: { label: '治疗链状态', tone: 'amber' },
[PersonaFeatureKey.RECALL_RISK]: { label: '流失风险', tone: 'emerald' },
......
......@@ -103,4 +103,19 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
owner: 'pac-algo',
version: 1,
},
// ── A.2.1 获客渠道(业务 CDP 口径,L2 数仓已算)──
acquisition_channel: {
key: 'acquisition_channel',
nameZh: '获客渠道',
tier: 'rule',
timeSemantics: 'snapshot', // 初诊来源一经判定不改
labelValues: ['走入', '口碑客户', '集团销售渠道', '集团营销渠道', '地区营销渠道', '电商平台', '自媒体网络', '内部员工', '其他'],
dataSource: 'DW fact_client_out.primary_category/sub_category(L2 数仓初诊来源)→ 摄入 PAC 副表 patient_profiles.acquisition_channel/sub(立柱标准枚举)',
dataFields: ['primary_category', 'sub_category'],
meaning: '客户首次到诊的获客来源,用于渠道价值分析。初诊来源一经判定不做二次改归类',
algorithm: 'host primary_category 经 assembler enum_mapping 归一到 PAC 立柱标准(走入→walk_in 等);二级 sub_category 原值透传。冲突解决(数仓侧):推荐人 > 活动码/渠道码 > 客户类型',
owner: 'pac-algo',
version: 1,
},
};
......@@ -65,6 +65,10 @@ export const PatientCanonicalSchema = z
tags: z.array(z.string()).optional().default([]),
/// 产品收集:host 备注 / 客户描述自由文本
notes: z.string().optional().nullable(),
/// 获客渠道(初诊来源,A.2.1)— PAC 立柱标准枚举(host 值经 assembler enum_mapping 归一)
acquisitionChannel: z.string().optional().nullable(),
/// 二级渠道(host 原值透传)
acquisitionSub: z.string().optional().nullable(),
})
.passthrough();
export type PatientCanonical = z.infer<typeof PatientCanonicalSchema>;
......
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