- fix(booking): 修复未认领画像阻断约课问题,支持约课事务内自动认领绑定 - fix(app): 修复 Pinia 状态解构导致响应式失效,补齐 PortraitRadar 组件实例传参 - fix(admin): 修复经营助手复测待办点击 404,优化体验课后线索回访文案 - fix(server): 线下评估日期间隔按中国时区校验,复测按期次单项核销,优化单次评估分享卡与限流清理 - test: 补齐 body-portrait-offline 与 booking 来源画像单元测试 Co-authored-by: Cursor <cursoragent@cursor.com>
272 lines
10 KiB
TypeScript
272 lines
10 KiB
TypeScript
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: this.lifecycle.followUpAfterTrialDraft({
|
|
nickname: lead.user.nickname,
|
|
report,
|
|
}),
|
|
})
|
|
}
|
|
}
|
|
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: [],
|
|
}
|