import { ForbiddenException, Injectable } from '@nestjs/common'; import type { RoutineCalendarDayDto, RoutineCategoryStatDto, RoutineDayState, RoutineExecutionRowDto, RoutineHistoryDayDto, RoutineMemberStatDto, RoutineMonthlyReportDto, RoutinePerformanceDto, RoutinePeriodTotalsDto, RoutineScoreDto, RoutineStatusBreakdownDto, RoutineTaskStatus, RoutineTeamPerformanceDto, RoutineTrendPointDto, RoutineWeeklyReportDto, } from '@mento/shared'; import type { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { deskRoutineTaskWhere, internalRoutineTaskWhere } from './internal-routine'; import type { RoutineRangeQueryDto } from './dto'; import { describeRecurrence } from './recurrence'; import { routineRule } from './routine-mapper'; import { type RoutineScope, assertManager, canView } from './routine-scope'; import { addDays, dateToDayKey, dayDiff, dayKeyToDate, eachDay, jalaliDayOfMonth, tehranDayKey, weekStartKey, } from './routine-dates'; type StatTask = { assigneeId: string; scheduledFor: Date; status: RoutineTaskStatus; isMandatory: boolean; categoryId: string | null; routineId: string | null; source: string; dueAt: Date | null; completedAt: Date | null; createdAt: Date; }; type Counts = { total: number; completed: number; overdue: number; skipped: number; cancelled: number; pending: number; }; const statSelect = { assigneeId: true, scheduledFor: true, status: true, isMandatory: true, categoryId: true, routineId: true, source: true, dueAt: true, completedAt: true, createdAt: true, } satisfies Prisma.RoutineTaskSelect; function emptyCounts(): Counts { return { total: 0, completed: 0, overdue: 0, skipped: 0, cancelled: 0, pending: 0 }; } function addTo(c: Counts, status: RoutineTaskStatus, n = 1) { c.total += n; if (status === 'completed') c.completed += n; else if (status === 'overdue') c.overdue += n; else if (status === 'skipped') c.skipped += n; else if (status === 'cancelled') c.cancelled += n; else c.pending += n; } /** Taskهای لغو و رد شده در درصد حساب نمی‌شوند */ export function effectiveTotal(c: Counts): number { return c.total - c.cancelled - c.skipped; } export function percentOf(c: Counts): number | null { const eff = effectiveTotal(c); return eff > 0 ? Math.round((c.completed / eff) * 100) : null; } export function dayState(c: Counts, day: string, today: string): RoutineDayState { const eff = effectiveTotal(c); if (eff <= 0 || day > today) return 'empty'; if (c.completed >= eff) return 'complete'; if (c.completed === 0 && day < today) return 'missed'; return 'partial'; } export function jalaliMonthStart(day: string): string { return addDays(day, -(jalaliDayOfMonth(day) - 1)); } @Injectable() export class RoutineStatsService { constructor(private readonly prisma: PrismaService) {} /* ───────── امتیاز روزانه و Streak ───────── */ async score(organizationId: string, userId: string, day = tehranDayKey()): Promise { const monthStart = jalaliMonthStart(day); const weekStart = weekStartKey(day); const from = [monthStart, weekStart, addDays(day, -1)].sort()[0]!; const tasks = await this.prisma.routineTask.findMany({ where: { organizationId, assigneeId: userId, scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(day) }, ...internalRoutineTaskWhere, }, select: { scheduledFor: true, status: true }, }); const range = (a: string, b: string) => { const c = emptyCounts(); for (const t of tasks) { const k = dateToDayKey(t.scheduledFor); if (k >= a && k <= b) addTo(c, t.status); } return percentOf(c); }; const yesterday = addDays(day, -1); return { today: range(day, day), yesterday: range(yesterday, yesterday), week: range(weekStart, day), month: range(monthStart, day), streak: (await this.streaks(organizationId, [userId], day)).get(userId) ?? 0, }; } /** روزهای متوالی که همه Taskهای اجباری انجام (یا مجاز رد) شده‌اند؛ روز بدون Task اجباری خنثی است */ async streaks( organizationId: string, userIds: string[], today = tehranDayKey(), ): Promise> { const out = new Map(); if (!userIds.length) return out; const groups = await this.prisma.routineTask.groupBy({ by: ['assigneeId', 'scheduledFor', 'status'], where: { organizationId, assigneeId: { in: userIds }, isMandatory: true, status: { not: 'cancelled' }, scheduledFor: { gte: dayKeyToDate(addDays(today, -180)), lte: dayKeyToDate(today) }, ...internalRoutineTaskWhere, }, _count: { _all: true }, }); const byUser = new Map>(); for (const g of groups) { const days = byUser.get(g.assigneeId) ?? new Map(); const key = dateToDayKey(g.scheduledFor); const cur = days.get(key) ?? { total: 0, ok: 0 }; cur.total += g._count._all; if (g.status === 'completed' || g.status === 'skipped') cur.ok += g._count._all; days.set(key, cur); byUser.set(g.assigneeId, days); } for (const userId of userIds) { const days = byUser.get(userId); let streak = 0; if (days) { const keys = [...days.keys()].sort().reverse(); for (const key of keys) { const d = days.get(key)!; const complete = d.ok >= d.total; if (key === today) { if (complete) streak += 1; continue; } if (!complete) break; streak += 1; } } out.set(userId, streak); } return out; } /* ───────── خلاصه روزها (تقویم/تاریخچه) ───────── */ summarizeDays(tasks: Array<{ scheduledFor: Date; status: RoutineTaskStatus }>, from: string, to: string) { const today = tehranDayKey(); const map = new Map(); for (const t of tasks) { const k = dateToDayKey(t.scheduledFor); const c = map.get(k) ?? emptyCounts(); addTo(c, t.status); map.set(k, c); } return eachDay(from, to).map((date) => { const c = map.get(date) ?? emptyCounts(); return { date, counts: c, state: dayState(c, date, today), percent: percentOf(c) }; }); } toCalendarDays( tasks: Array<{ scheduledFor: Date; status: RoutineTaskStatus }>, from: string, to: string, ): RoutineCalendarDayDto[] { return this.summarizeDays(tasks, from, to).map(({ date, counts, state, percent }) => ({ date, total: effectiveTotal(counts), completed: counts.completed, overdue: counts.overdue, percent, state, })); } /* ───────── گزارش‌ها ───────── */ async performance(scope: RoutineScope, q: RoutineRangeQueryDto): Promise { const userId = q.userId || scope.userId; if (!canView(scope, userId)) throw new ForbiddenException('دسترسی ندارید'); const { from, to } = this.range(q, 29); const tasks = await this.fetch(scope.organizationId, from, to, { ...q, userId }, [userId]); const agg = await this.aggregate(scope.organizationId, tasks, from, to); const streak = (await this.streaks(scope.organizationId, [userId])).get(userId) ?? 0; return { from, to, total: agg.effTotal, completed: agg.counts.completed, notDone: agg.status.notDone, overdue: agg.counts.overdue, percent: percentOf(agg.counts), avgCompletionMinutes: this.avgCompletion(tasks), completeDays: agg.days.complete, partialDays: agg.days.partial, missedDays: agg.days.missed, streak, status: agg.status, trend: agg.trend, byCategory: agg.byCategory, }; } async team(scope: RoutineScope, q: RoutineRangeQueryDto): Promise { assertManager(scope); const { from, to } = this.range(q, 29); const users = await this.teamUsers(scope, q); const tasks = await this.fetch(scope.organizationId, from, to, q, users.map((u) => u.id)); const agg = await this.aggregate(scope.organizationId, tasks, from, to); return { from, to, total: agg.effTotal, completed: agg.counts.completed, overdue: agg.counts.overdue, percent: percentOf(agg.counts), status: agg.status, trend: agg.trend, byCategory: agg.byCategory, members: await this.memberStats(scope.organizationId, users, tasks), }; } async weekly(scope: RoutineScope, q: RoutineRangeQueryDto): Promise { const from = weekStartKey(q.from || tehranDayKey()); const to = addDays(from, 6); const prevFrom = addDays(from, -7); const userIds = await this.viewUsers(scope, q); const tasks = await this.fetch(scope.organizationId, prevFrom, to, q, userIds); const current = tasks.filter((t) => dateToDayKey(t.scheduledFor) >= from); const previous = tasks.filter((t) => dateToDayKey(t.scheduledFor) < from); return { from, to, days: this.toCalendarDays(current, from, to), totals: this.periodTotals(current, from, to), previous: this.periodTotals(previous, prevFrom, addDays(from, -1)), }; } async monthly(scope: RoutineScope, q: RoutineRangeQueryDto): Promise { const from = q.from || jalaliMonthStart(tehranDayKey()); const to = q.to || addDays(jalaliMonthStart(addDays(from, 32)), -1); const length = dayDiff(from, to) + 1; const prevTo = addDays(from, -1); const prevFrom = addDays(from, -length); const userIds = await this.viewUsers(scope, q); const tasks = await this.fetch(scope.organizationId, prevFrom, to, q, userIds); const current = tasks.filter((t) => dateToDayKey(t.scheduledFor) >= from); const previous = tasks.filter((t) => dateToDayKey(t.scheduledFor) < from); const status = this.statusBreakdown(current); let byUser: RoutineMemberStatDto[] = []; if (q.view === 'team') { const users = await this.teamUsers(scope, q); byUser = await this.memberStats(scope.organizationId, users, current); } return { from, to, totals: this.periodTotals(current, from, to), previous: this.periodTotals(previous, prevFrom, prevTo), status, days: this.toCalendarDays(current, from, to), byUser, }; } async routinesExecution(scope: RoutineScope, q: RoutineRangeQueryDto): Promise { assertManager(scope); const { from, to } = this.range(q, 29); const users = await this.teamUsers(scope, q); const where: Prisma.RoutineTaskWhereInput = { organizationId: scope.organizationId, routineId: { not: null }, scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) }, assigneeId: { in: users.map((u) => u.id) }, status: { notIn: ['cancelled'] }, ...(q.categoryId ? { categoryId: q.categoryId } : {}), }; const groups = await this.prisma.routineTask.groupBy({ by: ['routineId', 'status'], where, _count: { _all: true }, }); const map = new Map(); for (const g of groups) { if (!g.routineId) continue; const c = map.get(g.routineId) ?? emptyCounts(); addTo(c, g.status, g._count._all); map.set(g.routineId, c); } if (!map.size) return []; const routines = await this.prisma.routine.findMany({ where: { id: { in: [...map.keys()] } }, include: { assignee: { select: { fullName: true } }, team: { select: { name: true } } }, }); return routines .map((r) => { const c = map.get(r.id)!; const resolved = c.completed + c.overdue + c.skipped; return { routineId: r.id, title: r.deletedAt ? `${r.title} (حذف‌شده)` : r.title, assigneeLabel: r.assignee?.fullName ?? r.team?.name ?? '—', recurrenceLabel: describeRecurrence(routineRule(r)), occurrences: c.total, completed: c.completed, notDone: c.overdue + c.pending, skipped: c.skipped, percent: resolved ? Math.round((c.completed / resolved) * 100) : null, }; }) .sort((a, b) => (a.percent ?? 101) - (b.percent ?? 101)); } async history(scope: RoutineScope, q: RoutineRangeQueryDto): Promise { const userId = q.userId || scope.userId; if (!canView(scope, userId)) throw new ForbiddenException('دسترسی ندارید'); const { from, to } = this.range(q, 59); const tasks = await this.fetch(scope.organizationId, from, to, { ...q, userId }, [userId]); return this.summarizeDays(tasks, from, to) .filter((d) => d.counts.total > 0) .reverse() .map(({ date, counts, state, percent }) => ({ date, total: counts.total, completed: counts.completed, overdue: counts.overdue, skipped: counts.skipped, cancelled: counts.cancelled, percent, state, })); } /* ───────── کمکی ───────── */ private range(q: { from?: string; to?: string }, defaultDays: number) { const to = q.to || tehranDayKey(); let from = q.from || addDays(to, -defaultDays); if (from > to) from = to; if (dayDiff(from, to) > 400) from = addDays(to, -400); return { from, to }; } private async viewUsers(scope: RoutineScope, q: RoutineRangeQueryDto) { if (q.view === 'team') return (await this.teamUsers(scope, q)).map((u) => u.id); const userId = q.userId || scope.userId; if (!canView(scope, userId)) throw new ForbiddenException('دسترسی ندارید'); return [userId]; } async teamUsers(scope: RoutineScope, q: { userId?: string; teamId?: string }) { assertManager(scope); let ids = scope.manageableUserIds; if (q.teamId) { const members = await this.prisma.teamMember.findMany({ where: { teamId: q.teamId, team: { organizationId: scope.organizationId } }, select: { userId: true }, }); const teamIds = members.map((m) => m.userId); ids = ids ? ids.filter((id) => teamIds.includes(id)) : teamIds; } if (q.userId) { ids = ids ? ids.filter((id) => id === q.userId) : [q.userId]; } const rows = await this.prisma.membership.findMany({ where: { organizationId: scope.organizationId, isActive: true, user: { isActive: true }, ...(ids ? { userId: { in: ids } } : {}), }, select: { user: { select: { id: true, fullName: true } } }, orderBy: { user: { fullName: 'asc' } }, }); return rows.map((r) => r.user); } private fetch( organizationId: string, from: string, to: string, q: RoutineRangeQueryDto, userIds: string[], ): Promise { return this.prisma.routineTask.findMany({ where: { organizationId, assigneeId: { in: userIds }, scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) }, ...deskRoutineTaskWhere(q.source), ...(q.categoryId ? { categoryId: q.categoryId } : {}), ...(q.status ? { status: q.status } : {}), }, select: statSelect, }); } private statusBreakdown(tasks: StatTask[]): RoutineStatusBreakdownDto & { notDone: number } { const c = emptyCounts(); for (const t of tasks) addTo(c, t.status); return { completed: c.completed, notDone: c.pending + c.overdue, overdue: c.overdue, skipped: c.skipped, cancelled: c.cancelled, }; } private periodTotals(tasks: StatTask[], from: string, to: string): RoutinePeriodTotalsDto { const c = emptyCounts(); for (const t of tasks) addTo(c, t.status); const days = this.summarizeDays(tasks, from, to); const percents = days.map((d) => d.percent).filter((p): p is number => p != null); return { total: effectiveTotal(c), completed: c.completed, notDone: c.pending + c.overdue, overdue: c.overdue, percent: percentOf(c), completeDays: days.filter((d) => d.state === 'complete').length, partialDays: days.filter((d) => d.state === 'partial').length, missedDays: days.filter((d) => d.state === 'missed').length, avgPercent: percents.length ? Math.round(percents.reduce((s, p) => s + p, 0) / percents.length) : null, }; } private async aggregate(organizationId: string, tasks: StatTask[], from: string, to: string) { const counts = emptyCounts(); for (const t of tasks) addTo(counts, t.status); const days = this.summarizeDays(tasks, from, to); const trend: RoutineTrendPointDto[] = days.map((d) => ({ date: d.date, total: effectiveTotal(d.counts), completed: d.counts.completed, percent: d.percent, })); const catCounts = new Map(); for (const t of tasks) { const c = catCounts.get(t.categoryId) ?? emptyCounts(); addTo(c, t.status); catCounts.set(t.categoryId, c); } const catIds = [...catCounts.keys()].filter((id): id is string => !!id); const cats = catIds.length ? await this.prisma.routineCategory.findMany({ where: { organizationId, id: { in: catIds } }, }) : []; const byCategory: RoutineCategoryStatDto[] = [...catCounts.entries()] .map(([id, c]) => { const cat = cats.find((x) => x.id === id); return { categoryId: id, name: cat?.name ?? 'بدون دسته', color: cat?.color ?? '#94a3b8', total: effectiveTotal(c), completed: c.completed, percent: percentOf(c), }; }) .sort((a, b) => b.total - a.total); return { counts, effTotal: effectiveTotal(counts), status: this.statusBreakdown(tasks), trend, byCategory, days: { complete: days.filter((d) => d.state === 'complete').length, partial: days.filter((d) => d.state === 'partial').length, missed: days.filter((d) => d.state === 'missed').length, }, }; } private async memberStats( organizationId: string, users: Array<{ id: string; fullName: string }>, tasks: StatTask[], ): Promise { const streaks = await this.streaks(organizationId, users.map((u) => u.id)); return users.map((user) => { const own = tasks.filter((t) => t.assigneeId === user.id); const c = emptyCounts(); for (const t of own) addTo(c, t.status); return { user, total: effectiveTotal(c), completed: c.completed, overdue: c.overdue, skipped: c.skipped, percent: percentOf(c), streak: streaks.get(user.id) ?? 0, avgCompletionMinutes: this.avgCompletion(own), }; }); } /** میانگین فاصله انجام از زمان برنامه (روتین: ساعت برنامه، بقیه: زمان ثبت) */ private avgCompletion(tasks: StatTask[]): number | null { const values: number[] = []; for (const t of tasks) { if (t.status !== 'completed' || !t.completedAt) continue; const ref = t.source === 'routine' && t.dueAt ? t.dueAt : t.createdAt; values.push(Math.max(0, (t.completedAt.getTime() - ref.getTime()) / 60_000)); } if (!values.length) return null; return Math.round(values.reduce((s, v) => s + v, 0) / values.length); } }