import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common' import { BODY_PORTRAIT_OFFLINE_PROTOCOL, CreateProfessionalAssessmentDto, CreateTrainingPlanDto, GrowthShareCardRecord, ProfessionalAssessmentKind, ProfessionalAssessmentSessionRecord, ProfessionalObservationScores, ReassessmentTodoRecord, TrainingPlanRecord, TrainingPlanStatus, } from '@mp-pilates/shared' import { Prisma } from '@prisma/client' import { PrismaService } from '../prisma/prisma.service' import { createShareCode } from './body-portrait.token' const DEFAULT_PHASES = [ { name: 'Phase 1 · 重新建立控制', lessonStart: 1, lessonEnd: 4, focus: ['呼吸', '骨盆控制', '核心激活'], summary: '先找回呼吸、骨盆和核心的基本控制。', sortOrder: 0 }, { name: 'Phase 2 · 改善活动能力', lessonStart: 5, lessonEnd: 8, focus: ['胸椎', '肩带', '髋'], summary: '在控制基础上改善上背、肩和髋的活动。', sortOrder: 1 }, { name: 'Phase 3 · 整合身体动作', lessonStart: 9, lessonEnd: 12, focus: ['全身控制', '稳定', '日常动作迁移'], summary: '把训练能力带回站、走和日常动作。', sortOrder: 2 }, ] @Injectable() export class BodyPortraitOfflineService { constructor(private readonly prisma: PrismaService) {} async createSession(userId: string, operatorId: string, dto: CreateProfessionalAssessmentDto): Promise { await this.ensureUser(userId) if (dto.bookingId) { const booking = await this.prisma.booking.findFirst({ where: { id: dto.bookingId, userId } }) if (!booking) throw new BadRequestException('只能关联该学员的预约') } if (dto.originAssessmentId) { const assessment = await this.prisma.bodyPortraitAssessment.findFirst({ where: { id: dto.originAssessmentId, userId } }) if (!assessment) throw new BadRequestException('线上画像不属于该学员') } const recordedAt = this.date(dto.recordedAt) const created = await this.prisma.professionalAssessmentSession.create({ data: { userId, kind: dto.kind, protocolVersion: BODY_PORTRAIT_OFFLINE_PROTOCOL, recordedAt, bookingId: dto.bookingId, originAssessmentId: dto.originAssessmentId, observations: dto.observations as unknown as Prisma.InputJsonValue, subjectiveTension: dto.subjectiveTension ?? null, coachSummary: dto.coachSummary.trim().slice(0, 1000), trainingFocus: dto.trainingFocus.trim().slice(0, 500), phaseGoal: dto.phaseGoal.trim().slice(0, 500), photoAngle: dto.photoAngle || null, operatorId, }, }) if (dto.kind === ProfessionalAssessmentKind.FOLLOW_UP) { await this.prisma.reassessmentTodo.updateMany({ where: { userId, completedAt: null }, data: { completedAt: new Date() }, }) } return this.mapSession(created) } async listSessions(userId: string): Promise { const rows = await this.prisma.professionalAssessmentSession.findMany({ where: { userId }, orderBy: { recordedAt: 'asc' }, }) return rows.map((row) => this.mapSession(row)) } async createPlan(userId: string, dto: CreateTrainingPlanDto): Promise { await this.ensureUser(userId) if (dto.sessionId) { const session = await this.prisma.professionalAssessmentSession.findFirst({ where: { id: dto.sessionId, userId } }) if (!session) throw new BadRequestException('评估记录不属于该学员') } const plan = await this.prisma.trainingPlan.create({ data: { userId, sessionId: dto.sessionId, title: dto.title?.trim() || '你的 12 周身体改善计划', status: TrainingPlanStatus.ACTIVE, weeks: dto.weeks || 12, phases: { create: DEFAULT_PHASES.map((phase) => ({ ...phase, focus: phase.focus as unknown as Prisma.InputJsonValue, })), }, }, include: { phases: { orderBy: { sortOrder: 'asc' } } }, }) const now = new Date() await this.prisma.reassessmentTodo.createMany({ data: [4, 8, 12].map((checkpoint) => ({ userId, planId: plan.id, lessonCheckpoint: checkpoint, dueAt: new Date(now.getTime() + checkpoint * 7 * 24 * 60 * 60 * 1000), })), }) return this.mapPlan(plan) } async listPlans(userId: string): Promise { const plans = await this.prisma.trainingPlan.findMany({ where: { userId }, include: { phases: { orderBy: { sortOrder: 'asc' } } }, orderBy: { createdAt: 'desc' }, }) return plans.map((plan) => this.mapPlan(plan)) } async todos(userId: string): Promise { const rows = await this.prisma.reassessmentTodo.findMany({ where: { userId }, orderBy: { lessonCheckpoint: 'asc' }, }) return rows.map((row) => ({ id: row.id, userId: row.userId, planId: row.planId, lessonCheckpoint: row.lessonCheckpoint, dueAt: row.dueAt.toISOString(), completedAt: row.completedAt?.toISOString() || null, })) } async createShareCard(userId: string, includePhotos: boolean): Promise { const sessions = await this.listSessions(userId) const first = sessions[0] const latest = sessions[sessions.length - 1] const completedCount = await this.prisma.booking.count({ where: { userId, status: 'COMPLETED' } }) const plan = await this.prisma.trainingPlan.findFirst({ where: { userId }, orderBy: { createdAt: 'desc' } }) const rows = first && latest ? this.compare(first, latest) : [] const created = await this.prisma.growthShareCard.create({ data: { userId, planId: plan?.id, shareCode: createShareCode(), title: `我的第 ${completedCount || 1} 节普拉提`, caption: first && latest && first.subjectiveTension != null && latest.subjectiveTension != null ? `${plan?.weeks || 12} 周前肩颈紧张 ${first.subjectiveTension}/10,现在 ${latest.subjectiveTension}/10。坚持有时候真的看得见。` : '坚持有时候真的看得见。', completedCount, weeks: plan?.weeks || 12, rows: rows as unknown as Prisma.InputJsonValue, includePhotos: includePhotos === true, }, }) return this.mapShare(created) } async publicShare(shareCode: string) { const card = await this.prisma.growthShareCard.findUnique({ where: { shareCode } }) if (!card) throw new NotFoundException('分享卡片不存在') return { ...this.mapShare(card), includePhotos: false, } } private compare(first: ProfessionalAssessmentSessionRecord, latest: ProfessionalAssessmentSessionRecord) { const labels: { key: keyof ProfessionalObservationScores; label: string }[] = [ { key: 'shoulderFlexion', label: '肩屈活动' }, { key: 'singleLeg', label: '单腿稳定' }, { key: 'coreControl', label: '核心控制' }, { key: 'thoracicExtension', label: '胸椎活动' }, ] const rows = labels.map((item) => ({ label: item.label, first: `${first.observations[item.key]}/5`, latest: `${latest.observations[item.key]}/5`, })) if (first.subjectiveTension != null || latest.subjectiveTension != null) { rows.push({ label: '肩颈主观紧张', first: first.subjectiveTension != null ? `${first.subjectiveTension}/10` : '—', latest: latest.subjectiveTension != null ? `${latest.subjectiveTension}/10` : '—', }) } return rows } private mapSession(row: { id: string userId: string kind: string protocolVersion: string recordedAt: Date bookingId: string | null originAssessmentId: string | null observations: Prisma.JsonValue subjectiveTension: number | null coachSummary: string trainingFocus: string phaseGoal: string photoAngle: string | null }): ProfessionalAssessmentSessionRecord { return { id: row.id, userId: row.userId, kind: row.kind as ProfessionalAssessmentKind, protocolVersion: row.protocolVersion, recordedAt: row.recordedAt.toISOString().slice(0, 10), bookingId: row.bookingId, originAssessmentId: row.originAssessmentId, observations: row.observations as unknown as ProfessionalObservationScores, subjectiveTension: row.subjectiveTension, coachSummary: row.coachSummary, trainingFocus: row.trainingFocus, phaseGoal: row.phaseGoal, photoAngle: row.photoAngle as ProfessionalAssessmentSessionRecord['photoAngle'], } } private mapPlan(plan: { id: string userId: string title: string status: string weeks: number sessionId: string | null phases: Array<{ id: string; name: string; lessonStart: number; lessonEnd: number; focus: Prisma.JsonValue; summary: string }> }): TrainingPlanRecord { return { id: plan.id, userId: plan.userId, title: plan.title, status: plan.status as TrainingPlanStatus, weeks: plan.weeks, sessionId: plan.sessionId, phases: plan.phases.map((phase) => ({ id: phase.id, name: phase.name, lessonStart: phase.lessonStart, lessonEnd: phase.lessonEnd, focus: phase.focus as string[], summary: phase.summary, })), } } private mapShare(card: { id: string shareCode: string title: string caption: string completedCount: number weeks: number rows: Prisma.JsonValue includePhotos: boolean }): GrowthShareCardRecord { return { id: card.id, shareCode: card.shareCode, title: card.title, caption: card.caption, completedCount: card.completedCount, weeks: card.weeks, rows: card.rows as unknown as GrowthShareCardRecord['rows'], includePhotos: card.includePhotos, } } private async ensureUser(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true } }) if (!user) throw new NotFoundException('学员不存在') } private date(value: string) { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new BadRequestException('日期无效') const date = new Date(`${value}T00:00:00.000Z`) if (Number.isNaN(date.getTime())) throw new BadRequestException('日期无效') if (date.getTime() > Date.now()) throw new BadRequestException('日期不能晚于今天') return date } }