Files
CRM_Montzar/apps/api/src/routines/routine-stats.service.ts
T
MahdiandCursor a17c7dbabc fix(routines): keep the daily desk for internal team work
Customer-linked tasks stay on their record and no longer appear in today, calendar, score, or reminders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 20:53:26 +03:30

569 lines
19 KiB
TypeScript

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<RoutineScoreDto> {
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<Map<string, number>> {
const out = new Map<string, number>();
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<string, Map<string, { total: number; ok: number }>>();
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<string, Counts>();
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<RoutinePerformanceDto> {
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<RoutineTeamPerformanceDto> {
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<RoutineWeeklyReportDto> {
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<RoutineMonthlyReportDto> {
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<RoutineExecutionRowDto[]> {
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<string, Counts>();
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<RoutineHistoryDayDto[]> {
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<StatTask[]> {
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<string | null, Counts>();
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<RoutineMemberStatDto[]> {
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);
}
}