feat: 新增个人身体画像评估与馆主经营助手
把 3 分钟身体状态评估做成独立获客链路,匿名测评后登录认领完整报告,并接入体验预约与今日待办。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
268
packages/server/src/body-portrait/body-portrait-admin.service.ts
Normal file
268
packages/server/src/body-portrait/body-portrait-admin.service.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common'
|
||||
import {
|
||||
BODY_DIMENSION_LABELS,
|
||||
BodyDimension,
|
||||
BodyPortraitAnswers,
|
||||
BodyPortraitReport,
|
||||
BodyPortraitSource,
|
||||
GrowthEventName,
|
||||
GrowthFunnelRow,
|
||||
GrowthLeadDetail,
|
||||
GrowthLeadStage,
|
||||
GrowthLeadSummary,
|
||||
GrowthTodayDashboard,
|
||||
GrowthTodayTask,
|
||||
} from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { BodyPortraitLifecycleService } from './body-portrait-lifecycle.service'
|
||||
import { normalizeAnswers } from './scoring/score-v1'
|
||||
|
||||
const TERMINAL = new Set<string>([
|
||||
GrowthLeadStage.TRIAL_BOOKED,
|
||||
GrowthLeadStage.TRIAL_ATTENDED,
|
||||
GrowthLeadStage.PLAN_PURCHASED,
|
||||
GrowthLeadStage.TRAINING,
|
||||
GrowthLeadStage.RENEWAL_DUE,
|
||||
GrowthLeadStage.RENEWED,
|
||||
])
|
||||
|
||||
@Injectable()
|
||||
export class BodyPortraitAdminService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly lifecycle: BodyPortraitLifecycleService,
|
||||
) {}
|
||||
|
||||
async dashboard(): Promise<GrowthTodayDashboard> {
|
||||
const [leads, todos, funnel] = await Promise.all([
|
||||
this.prisma.growthLead.findMany({
|
||||
include: {
|
||||
user: { select: { nickname: true, phone: true } },
|
||||
latestAssessment: { include: { visit: true } },
|
||||
},
|
||||
orderBy: { lastActiveAt: 'desc' },
|
||||
take: 80,
|
||||
}),
|
||||
this.prisma.reassessmentTodo.findMany({
|
||||
where: { completedAt: null, dueAt: { lte: new Date() } },
|
||||
include: { user: { select: { nickname: true } } },
|
||||
take: 20,
|
||||
}),
|
||||
this.funnel(),
|
||||
])
|
||||
const tasks: GrowthTodayTask[] = []
|
||||
const twoDaysAgo = Date.now() - 2 * 24 * 60 * 60 * 1000
|
||||
for (const lead of leads) {
|
||||
const report = (lead.latestAssessment?.report || null) as BodyPortraitReport | null
|
||||
const answers = normalizeAnswers(lead.latestAssessment?.answers as Partial<BodyPortraitAnswers>)
|
||||
const draft = this.lifecycle.followUpDraft({
|
||||
nickname: lead.user.nickname,
|
||||
report,
|
||||
sittingHours: answers.sittingHours,
|
||||
workPosture: answers.workPosture,
|
||||
})
|
||||
if (!TERMINAL.has(lead.stage) && lead.latestAssessment?.completedAt && ['CLAIMED', 'PHONE_BOUND', 'COMPLETED'].includes(lead.stage)) {
|
||||
const top = report ? this.topDimension(report) : null
|
||||
tasks.push({
|
||||
kind: 'completed_unbooked',
|
||||
leadId: lead.id,
|
||||
userId: lead.userId,
|
||||
nickname: lead.user.nickname || '未命名',
|
||||
title: `${lead.user.nickname || '学员'}完成身体测试但未预约`,
|
||||
detail: top ? `${BODY_DIMENSION_LABELS[top[0]]}关注度 ${top[1]}` : '已完成线上画像',
|
||||
happenedAt: lead.lastActiveAt.toISOString(),
|
||||
followUpDraft: draft,
|
||||
})
|
||||
}
|
||||
if (lead.stage === GrowthLeadStage.TRIAL_ATTENDED && lead.lastActiveAt.getTime() <= twoDaysAgo) {
|
||||
tasks.push({
|
||||
kind: 'trial_unpurchased',
|
||||
leadId: lead.id,
|
||||
userId: lead.userId,
|
||||
nickname: lead.user.nickname || '未命名',
|
||||
title: `${lead.user.nickname || '学员'}完成体验课后未购买`,
|
||||
detail: '建议回访,确认是否需要 12 周改善计划',
|
||||
happenedAt: lead.lastActiveAt.toISOString(),
|
||||
followUpDraft: draft,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const todo of todos) {
|
||||
tasks.push({
|
||||
kind: 'reassessment_due',
|
||||
leadId: todo.planId,
|
||||
userId: todo.userId,
|
||||
nickname: todo.user.nickname || '未命名',
|
||||
title: `${todo.user.nickname || '学员'}到达第 ${todo.lessonCheckpoint} 节复测节点`,
|
||||
detail: '建议安排阶段复测',
|
||||
happenedAt: todo.dueAt.toISOString(),
|
||||
followUpDraft: `训练已经走到第 ${todo.lessonCheckpoint} 节附近了,可以安排一次阶段复测,看看身体状态有没有变化。`,
|
||||
})
|
||||
}
|
||||
return { tasks: tasks.slice(0, 12), funnel }
|
||||
}
|
||||
|
||||
async listLeads(query: { page?: number; limit?: number; search?: string; stage?: string; source?: string }) {
|
||||
const page = Math.max(1, Number(query.page) || 1)
|
||||
const limit = Math.min(50, Math.max(1, Number(query.limit) || 20))
|
||||
const where: Record<string, unknown> = {}
|
||||
if (query.stage) where.stage = query.stage
|
||||
if (query.source) where.source = query.source
|
||||
if (query.search) {
|
||||
where.user = {
|
||||
OR: [
|
||||
{ nickname: { contains: query.search } },
|
||||
{ phone: { contains: query.search } },
|
||||
],
|
||||
}
|
||||
}
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.growthLead.findMany({
|
||||
where,
|
||||
include: { user: { select: { nickname: true, phone: true } }, latestAssessment: true },
|
||||
orderBy: { lastActiveAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.growthLead.count({ where }),
|
||||
])
|
||||
return {
|
||||
items: items.map((lead) => this.toSummary(lead)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
}
|
||||
}
|
||||
|
||||
async leadDetail(id: string): Promise<GrowthLeadDetail> {
|
||||
const lead = await this.prisma.growthLead.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
user: { select: { nickname: true, phone: true } },
|
||||
latestAssessment: { include: { visit: true } },
|
||||
},
|
||||
})
|
||||
if (!lead) throw new NotFoundException('线索不存在')
|
||||
const events = await this.prisma.growthEvent.findMany({
|
||||
where: { userId: lead.userId },
|
||||
orderBy: { occurredAt: 'asc' },
|
||||
take: 50,
|
||||
})
|
||||
const report = (lead.latestAssessment?.report || null) as BodyPortraitReport | null
|
||||
const answers = lead.latestAssessment
|
||||
? normalizeAnswers(lead.latestAssessment.answers as Partial<BodyPortraitAnswers>)
|
||||
: null
|
||||
const summary = this.toSummary(lead)
|
||||
return {
|
||||
...summary,
|
||||
goalLabel: report?.goalLabel || null,
|
||||
scores: report?.scores || null,
|
||||
levels: report?.levels || null,
|
||||
evidence: report?.evidence || [],
|
||||
answers,
|
||||
report,
|
||||
events: events.map((event) => ({
|
||||
id: event.id,
|
||||
name: event.name as GrowthEventName,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
})),
|
||||
coachNote: lead.coachNote,
|
||||
firstAssessmentHints: report?.firstAssessmentHints || [],
|
||||
}
|
||||
}
|
||||
|
||||
async updateNote(id: string, coachNote: string) {
|
||||
const lead = await this.prisma.growthLead.findUnique({ where: { id } })
|
||||
if (!lead) throw new NotFoundException('线索不存在')
|
||||
await this.prisma.growthLead.update({ where: { id }, data: { coachNote: coachNote.slice(0, 500) } })
|
||||
return this.leadDetail(id)
|
||||
}
|
||||
|
||||
private async funnel(): Promise<GrowthFunnelRow[]> {
|
||||
const visits = await this.prisma.bodyPortraitVisit.findMany({
|
||||
select: { id: true, source: true },
|
||||
})
|
||||
const byVisit = new Map(visits.map((visit) => [visit.id, visit.source as BodyPortraitSource]))
|
||||
const events = await this.prisma.growthEvent.findMany({
|
||||
select: { name: true, visitId: true, userId: true },
|
||||
})
|
||||
const rows = new Map<BodyPortraitSource, { source: BodyPortraitSource; started: number; completed: number; claimed: number; booked: number; attended: number; purchased: number }>()
|
||||
const ensure = (source: BodyPortraitSource) => {
|
||||
const current = rows.get(source) || {
|
||||
source, started: 0, completed: 0, claimed: 0, booked: 0, attended: 0, purchased: 0,
|
||||
}
|
||||
rows.set(source, current)
|
||||
return current
|
||||
}
|
||||
for (const source of Object.values(BodyPortraitSource)) ensure(source)
|
||||
for (const visit of visits) ensure(visit.source as BodyPortraitSource).started += 0
|
||||
const counted = new Set<string>()
|
||||
for (const event of events) {
|
||||
const source = (event.visitId && byVisit.get(event.visitId)) || BodyPortraitSource.ORGANIC
|
||||
const row = ensure(source)
|
||||
const key = `${source}:${event.name}:${event.userId || event.visitId}`
|
||||
if (counted.has(key)) continue
|
||||
counted.add(key)
|
||||
if (event.name === GrowthEventName.ASSESSMENT_STARTED) row.started += 1
|
||||
if (event.name === GrowthEventName.ASSESSMENT_COMPLETED) row.completed += 1
|
||||
if (event.name === GrowthEventName.ASSESSMENT_CLAIMED) row.claimed += 1
|
||||
if (event.name === GrowthEventName.TRIAL_BOOKED) row.booked += 1
|
||||
if (event.name === GrowthEventName.TRIAL_ATTENDED) row.attended += 1
|
||||
if (event.name === GrowthEventName.PLAN_PURCHASED) row.purchased += 1
|
||||
}
|
||||
return [...rows.values()]
|
||||
}
|
||||
|
||||
private toSummary(lead: {
|
||||
id: string
|
||||
userId: string
|
||||
stage: string
|
||||
source: string | null
|
||||
campaignId: string | null
|
||||
lastActiveAt: Date
|
||||
user: { nickname: string; phone: string | null }
|
||||
latestAssessment: { completedAt: Date | null; safetyFlagged: boolean; report: unknown } | null
|
||||
}): GrowthLeadSummary {
|
||||
const report = (lead.latestAssessment?.report || null) as BodyPortraitReport | null
|
||||
const top = report ? this.topDimension(report) : null
|
||||
const answers = lead.latestAssessment
|
||||
? normalizeAnswers((lead.latestAssessment as { answers?: unknown }).answers as Partial<BodyPortraitAnswers>)
|
||||
: EMPTY_SAFE
|
||||
return {
|
||||
id: lead.id,
|
||||
userId: lead.userId,
|
||||
nickname: lead.user.nickname || '未命名',
|
||||
phone: lead.user.phone,
|
||||
stage: lead.stage as GrowthLeadStage,
|
||||
source: (lead.source as BodyPortraitSource | null) || null,
|
||||
campaignId: lead.campaignId,
|
||||
primaryType: report?.primaryType || null,
|
||||
topDimension: top?.[0] || null,
|
||||
safetyFlagged: lead.latestAssessment?.safetyFlagged || false,
|
||||
lastActiveAt: lead.lastActiveAt.toISOString(),
|
||||
completedAt: lead.latestAssessment?.completedAt?.toISOString() || null,
|
||||
followUpDraft: this.lifecycle.followUpDraft({
|
||||
nickname: lead.user.nickname,
|
||||
report,
|
||||
sittingHours: answers.sittingHours,
|
||||
workPosture: answers.workPosture,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
private topDimension(report: BodyPortraitReport): [BodyDimension, number] {
|
||||
return (Object.entries(report.scores) as [BodyDimension, number][]).sort((a, b) => b[1] - a[1])[0]
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_SAFE: BodyPortraitAnswers = {
|
||||
concerns: [],
|
||||
goal: null,
|
||||
sittingHours: null,
|
||||
exerciseFreq: null,
|
||||
workPosture: null,
|
||||
endOfDayFatigue: [],
|
||||
afterSitting: [],
|
||||
standingNotice: [],
|
||||
safety: [],
|
||||
}
|
||||
Reference in New Issue
Block a user