Customer-linked tasks stay on their record and no longer appear in today, calendar, score, or reminders. Co-authored-by: Cursor <cursoragent@cursor.com>
569 lines
19 KiB
TypeScript
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);
|
|
}
|
|
}
|