Commit 765f70c1 by luoqi

merge: feat/plan-view-pv-uv → test(plan 详情页 PV/UV 埋点)

parents 90724514 1bccd47f
Pipeline #3463 failed in 0 seconds
......@@ -251,6 +251,35 @@ export class PlansAggregateController {
return { ok: true as const, invocationId: script.agentInvocationId, feedback };
}
@Post(':id/view')
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({
summary: '上报 plan 详情页浏览(PV/UV 埋点)—— 每进入一次记一条',
})
async reportPlanView(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string,
) {
// ⚠️ **不加认领闸**(区别于 recall-feedback / 执行结果):浏览发生在认领**之前**,
// 池子里任何一条都能点开看。加了闸这个埋点就只剩已认领的,PV/UV 直接废掉。
const plan = await this.prisma.followupPlan.findFirst({
where: { id: planId, hostId: scope.hostId, tenantId: scope.tenantId },
select: { id: true, hostId: true, tenantId: true, patientId: true },
});
// 静默返回 not_found:埋点是**尽力而为**的旁路,不能因为它失败而影响页面
if (!plan) return { ok: false as const, reason: 'not_found' };
await recordPlanEvent(this.prisma, {
hostId: plan.hostId,
tenantId: plan.tenantId,
planId: plan.id,
patientId: plan.patientId,
event: PlanEventType.VIEW,
actorUserId: user.sub,
});
return { ok: true as const };
}
@Post(':id/recall-feedback')
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({
......
......@@ -203,3 +203,36 @@ describe('RecycleSchedulerService — 自动回收默认关闭', () => {
.toHaveBeenCalled();
});
});
// =============================================================
// 2026-07 加 view 事件(plan 详情页 PV/UV)—— 锁死"浏览 ≠ 处理"
// =============================================================
describe('PlanEventType.VIEW — PV/UV 埋点', () => {
it('⭐ 红线:view 绝不能进 HUMAN_TOUCH_EVENTS', () => {
// byHuman 是**口径开关**不是"是不是人做的"。给 view 写 true 会让
// 「客服处理过的患者」静默膨胀成"看一眼也算处理",且**没有任何编译错误**。
expect(PLAN_EVENT_META.view.byHuman).toBe(false);
expect(HUMAN_TOUCH_EVENTS).not.toContain(PlanEventType.VIEW);
});
it('HUMAN_TOUCH_EVENTS 精确等于四个处理类事件(加了新事件要显式决策)', () => {
expect([...HUMAN_TOUCH_EVENTS].sort()).toEqual(
[
PlanEventType.ASSIGN,
PlanEventType.CLAIM,
PlanEventType.FEEDBACK,
PlanEventType.RELEASE,
].sort(),
);
});
it('view 不改变归属:holdsPatient=false,独立 group', () => {
expect(PLAN_EVENT_META.view.holdsPatient).toBe(false);
expect(PLAN_EVENT_META.view.group).toBe('view');
});
it('view 已在枚举登记(元数据完整)', () => {
expect(PlanEventType.VIEW).toBe('view');
expect(PLAN_EVENT_META.view.labelZh.length).toBeGreaterThan(0);
});
});
......@@ -38,6 +38,10 @@ export const plansApi = {
getAggregate: (planId: string) =>
api.get<PlanDetailData>(`/pac/v1/plans/${encodeURIComponent(planId)}/full`),
/** PV/UV 埋点:上报一次 plan 详情页浏览。**尽力而为**,失败不影响页面(调用方吞异常)*/
reportView: (planId: string) =>
api.post<{ ok: boolean }>(`/pac/v1/plans/${encodeURIComponent(planId)}/view`, {}),
/** 回访历史一句话摘要(有则取、无则当场生成;无回访记录 status='empty')*/
getRecallSummary: (planId: string) =>
api.get<{ summary: string | null; status: 'ready' | 'empty'; source?: string }>(
......
......@@ -35,6 +35,11 @@ export function usePlanAggregate(planId: string) {
if (loadedFor.current === planId) return;
loadedFor.current = planId;
void load();
// ⭐ PV/UV 埋点:借这道守卫定义「进入一次详情页」——
// 三种入口全收(直接进/刷新 · 患者列表切换 · /plans 落地自动跳转),
// 而**手动 refresh 不重复计**(refresh 直接调 load(),走不到这里)。
// 尽力而为:失败只吞掉,埋点不能影响页面。
void plansApi.reportView(planId).catch(() => {});
}, [planId, load]);
return { state, refresh: load };
......
......@@ -755,26 +755,41 @@ export const PlanEventType = {
AUTO_RELEASE: 'auto_release',
/// 反馈:召回准不准 👍/👎(reason 列存 up/down)
FEEDBACK: 'feedback',
/// 浏览:进入 plan 详情页一次(PV/UV 用)。**不是"处理"** —— 见下方 byHuman 说明。
/// 三种入口全收:直接进/刷新 · 患者列表切换 · /plans 落地自动跳转;
/// 同一 planId 每次进入记一次(前端 usePlanAggregate 去重守卫为界),手动刷新不重复计。
VIEW: 'view',
} as const;
export type PlanEventType = (typeof PlanEventType)[keyof typeof PlanEventType];
/**
* 事件元数据 —— 报表分组 + "算不算人工处理"的判定源。
*
* group ownership 归属变更 / feedback 质量反馈(后续可加 lifecycle 等)
* byHuman 是否人工动作。⭐ 统计「客服处理过哪些患者」只算 byHuman=true 的,
* auto_release 是系统行为,不能算成"客服处理过"。
* group ownership 归属变更 / feedback 质量反馈 / view 浏览埋点(后续可加 lifecycle 等)
* byHuman ⭐ **口径开关,不是"是不是人做的"** —— 语义是「该事件算不算『客服处理过这个患者』」,
* 即是否进 [[HUMAN_TOUCH_EVENTS]]。两类为 false,原因不同:
* · auto_release —— 系统超时回收,压根不是人的动作;
* · view —— 是人的动作,但**浏览 ≠ 处理**。若给它 true,「处理过的患者」
* 口径会静默膨胀成"看一眼也算",而 HUMAN_TOUCH_EVENTS 是 filter 派生的,
* 改错**不会有任何编译错误或类型报错**。下方 spec 有断言锁死。
* holdsPatient 该事件之后 plan 是否仍挂在人名下(用于算归属区间)
*/
export const PLAN_EVENT_META: Record<
PlanEventType,
{ labelZh: string; group: 'ownership' | 'feedback'; byHuman: boolean; holdsPatient: boolean }
{
labelZh: string;
group: 'ownership' | 'feedback' | 'view';
byHuman: boolean;
holdsPatient: boolean;
}
> = {
claim: { labelZh: '认领', group: 'ownership', byHuman: true, holdsPatient: true },
assign: { labelZh: '指派', group: 'ownership', byHuman: true, holdsPatient: true },
release: { labelZh: '返池', group: 'ownership', byHuman: true, holdsPatient: false },
auto_release: { labelZh: '超时自动回收', group: 'ownership', byHuman: false, holdsPatient: false },
feedback: { labelZh: '召回反馈', group: 'feedback', byHuman: true, holdsPatient: true },
// ⚠️ byHuman: false —— 浏览是人做的,但**不算"处理过"**(见上方字段说明)。别改成 true。
view: { labelZh: '查看详情', group: 'view', byHuman: false, holdsPatient: false },
};
/// 「该患者被客服处理过」的事件集合 —— 统计口径收口在这里,避免各处各写一套。
......
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