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([ 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 { 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) 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 = {} 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 { 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) : 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 { 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() 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() 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) : 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: [], }