Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
765f70c1
Commit
765f70c1
authored
Jul 28, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: feat/plan-view-pv-uv → test(plan 详情页 PV/UV 埋点)
parents
90724514
1bccd47f
Pipeline
#3463
failed in 0 seconds
Changes
5
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
90 additions
and
4 deletions
+90
-4
apps/pac-service/src/modules/plan-aggregate/plans-aggregate.controller.ts
+29
-0
apps/pac-service/tests/plan-event-log.spec.ts
+33
-0
apps/pac-web/src/components/plans/plans-api.ts
+4
-0
apps/pac-web/src/components/plans/use-plan-aggregate.ts
+5
-0
packages/types/src/enums/index.ts
+19
-4
No files found.
apps/pac-service/src/modules/plan-aggregate/plans-aggregate.controller.ts
View file @
765f70c1
...
...
@@ -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
({
...
...
apps/pac-service/tests/plan-event-log.spec.ts
View file @
765f70c1
...
...
@@ -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
);
});
});
apps/pac-web/src/components/plans/plans-api.ts
View file @
765f70c1
...
...
@@ -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
}
>
(
...
...
apps/pac-web/src/components/plans/use-plan-aggregate.ts
View file @
765f70c1
...
...
@@ -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
};
...
...
packages/types/src/enums/index.ts
View file @
765f70c1
...
...
@@ -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
},
};
/// 「该患者被客服处理过」的事件集合 —— 统计口径收口在这里,避免各处各写一套。
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment