import dayjs, { Dayjs } from 'dayjs'; import { TAT_CONFIG, isTestMode } from '../config/tat.config'; // Cache for holidays to avoid repeated DB queries let holidaysCache: Set = new Set(); let holidaysCacheExpiry: Date | null = null; // Cache for working hours configuration interface WorkingHoursConfig { startHour: number; endHour: number; startDay: number; endDay: number; } let workingHoursCache: WorkingHoursConfig | null = null; let workingHoursCacheExpiry: Date | null = null; /** * Load working hours configuration from database and cache them */ async function loadWorkingHoursCache(): Promise { try { // Reload cache every 5 minutes (shorter than holidays since it's more critical) if (workingHoursCacheExpiry && new Date() < workingHoursCacheExpiry) { return; } const { getWorkingHours, getConfigNumber } = await import('../services/configReader.service'); const hours = await getWorkingHours(); const startDay = await getConfigNumber('WORK_START_DAY', 1); // Monday const endDay = await getConfigNumber('WORK_END_DAY', 5); // Friday workingHoursCache = { startHour: hours.startHour, endHour: hours.endHour, startDay: startDay, endDay: endDay }; workingHoursCacheExpiry = dayjs().add(5, 'minute').toDate(); } catch (error) { workingHoursCache = { startHour: TAT_CONFIG.WORK_START_HOUR, endHour: TAT_CONFIG.WORK_END_HOUR, startDay: TAT_CONFIG.WORK_START_DAY, endDay: TAT_CONFIG.WORK_END_DAY }; } } /** * Load holidays from database and cache them */ async function loadHolidaysCache(): Promise { try { // Reload cache every 6 hours if (holidaysCacheExpiry && new Date() < holidaysCacheExpiry) { return; } const { holidayService } = await import('../services/holiday.service'); const currentYear = new Date().getFullYear(); const startDate = `${currentYear}-01-01`; const endDate = `${currentYear + 1}-12-31`; // Include next year for year-end calculations const holidays = await holidayService.getHolidaysInRange(startDate, endDate); holidaysCache = new Set(holidays); holidaysCacheExpiry = dayjs().add(6, 'hour').toDate(); } catch (error) { console.error('[TAT] Error loading holidays:', error); // Continue without holidays if loading fails } } /** * Check if a date is a holiday (uses cache) */ function isHoliday(date: Dayjs): boolean { const dateStr = date.format('YYYY-MM-DD'); return holidaysCache.has(dateStr); } /** * Check if a given date is within working time * Working hours: Configured in admin settings (default: Monday-Friday, 9 AM - 6 PM) * Excludes: Weekends (Sat/Sun) and holidays * In TEST MODE: All times are considered working time */ function isWorkingTime(date: Dayjs): boolean { // In test mode, treat all times as working time for faster testing if (isTestMode()) { return true; } // Use cached working hours (with fallback to TAT_CONFIG) const config = workingHoursCache || { startHour: TAT_CONFIG.WORK_START_HOUR, endHour: TAT_CONFIG.WORK_END_HOUR, startDay: TAT_CONFIG.WORK_START_DAY, endDay: TAT_CONFIG.WORK_END_DAY }; const day = date.day(); // 0 = Sun, 6 = Sat const hour = date.hour(); // Check if weekend (based on configured working days) if (day < config.startDay || day > config.endDay) { return false; } // Check if outside working hours (based on configured hours) if (hour < config.startHour || hour >= config.endHour) { return false; } // Check if holiday if (isHoliday(date)) { return false; } return true; } /** * Add working hours to a start date (STANDARD mode) * Skips weekends, non-working hours, and holidays (unless in test mode) * Uses dynamic working hours from admin configuration * In TEST MODE: 1 hour = 1 minute for faster testing */ export async function addWorkingHours(start: Date | string, hoursToAdd: number): Promise { let current = dayjs(start); // In test mode, convert hours to minutes for faster testing if (isTestMode()) { return current.add(hoursToAdd, 'minute'); } // Load working hours and holidays cache if not loaded await loadWorkingHoursCache(); await loadHolidaysCache(); const config = workingHoursCache || { startHour: TAT_CONFIG.WORK_START_HOUR, endHour: TAT_CONFIG.WORK_END_HOUR, startDay: TAT_CONFIG.WORK_START_DAY, endDay: TAT_CONFIG.WORK_END_DAY }; // If start time is before working hours or outside working days/holidays, // advance to the next working hour start (reset to clean hour) const originalStart = current.format('YYYY-MM-DD HH:mm:ss'); const wasOutsideWorkingHours = !isWorkingTime(current); while (!isWorkingTime(current)) { const hour = current.hour(); const day = current.day(); // If before work start hour on a working day, jump to work start hour if (day >= config.startDay && day <= config.endDay && !isHoliday(current) && hour < config.startHour) { current = current.hour(config.startHour); } else { // After working hours or non-working day - advance to next working period current = current.add(1, 'hour'); } } // If start time was outside working hours, reset to clean work start time (no minutes) if (wasOutsideWorkingHours) { current = current.minute(0).second(0).millisecond(0); } // Split into whole hours and fractional part const wholeHours = Math.floor(hoursToAdd); const fractionalHours = hoursToAdd - wholeHours; let remaining = wholeHours; // Add whole hours while (remaining > 0) { current = current.add(1, 'hour'); if (isWorkingTime(current)) { remaining -= 1; } } // Add fractional part (convert to minutes) if (fractionalHours > 0) { const minutesToAdd = Math.round(fractionalHours * 60); current = current.add(minutesToAdd, 'minute'); // Check if fractional addition pushed us outside working time if (!isWorkingTime(current)) { // Advance to next working period while (!isWorkingTime(current)) { current = current.add(1, 'hour'); const hour = current.hour(); const day = current.day(); // If before work start hour on a working day, jump to work start hour if (day >= config.startDay && day <= config.endDay && !isHoliday(current) && hour < config.startHour) { current = current.hour(config.startHour).minute(0).second(0).millisecond(0); } } } } return current; } /** * Add working hours for EXPRESS priority * Includes ALL days (weekends, holidays) but only counts working hours (9 AM - 6 PM) * @param start - Start date/time * @param hoursToAdd - Hours to add * @returns Deadline date */ export async function addWorkingHoursExpress(start: Date | string, hoursToAdd: number): Promise { let current = dayjs(start); // In test mode, convert hours to minutes for faster testing if (isTestMode()) { return current.add(hoursToAdd, 'minute'); } // Load configuration await loadWorkingHoursCache(); const config = workingHoursCache || { startHour: TAT_CONFIG.WORK_START_HOUR, endHour: TAT_CONFIG.WORK_END_HOUR, startDay: TAT_CONFIG.WORK_START_DAY, endDay: TAT_CONFIG.WORK_END_DAY }; // If start time is outside working hours, advance to work start hour (reset to clean hour) const originalStart = current.format('YYYY-MM-DD HH:mm:ss'); const currentHour = current.hour(); if (currentHour < config.startHour) { current = current.hour(config.startHour).minute(0).second(0).millisecond(0); } else if (currentHour >= config.endHour) { current = current.add(1, 'day').hour(config.startHour).minute(0).second(0).millisecond(0); } // Split into whole hours and fractional part const wholeHours = Math.floor(hoursToAdd); const fractionalHours = hoursToAdd - wholeHours; let remaining = wholeHours; // Add whole hours while (remaining > 0) { current = current.add(1, 'hour'); const hour = current.hour(); // For express: count ALL days (including weekends/holidays) // But only during working hours (configured start - end hour) if (hour >= config.startHour && hour < config.endHour) { remaining -= 1; } } // Add fractional part (convert to minutes) if (fractionalHours > 0) { const minutesToAdd = Math.round(fractionalHours * 60); current = current.add(minutesToAdd, 'minute'); // Check if fractional addition pushed us past working hours if (current.hour() >= config.endHour) { // Overflow to next day's working hours const excessMinutes = (current.hour() - config.endHour) * 60 + current.minute(); current = current.add(1, 'day').hour(config.startHour).minute(excessMinutes).second(0).millisecond(0); } } return current; } /** * Add calendar hours (24/7, no exclusions) - DEPRECATED * @deprecated Use addWorkingHoursExpress() for express priority */ export function addCalendarHours(start: Date | string, hoursToAdd: number): Dayjs { let current = dayjs(start); // In test mode, convert hours to minutes for faster testing if (isTestMode()) { return current.add(hoursToAdd, 'minute'); } // Simply add hours without any exclusions (24/7) return current.add(hoursToAdd, 'hour'); } /** * Synchronous version for backward compatibility (doesn't check holidays) * Use addWorkingHours() for holiday-aware calculations * @deprecated Use async addWorkingHours() instead for accurate calculations */ export function addWorkingHoursSync(start: Date | string, hoursToAdd: number): Dayjs { let current = dayjs(start); // In test mode, convert hours to minutes for faster testing if (isTestMode()) { return current.add(hoursToAdd, 'minute'); } // Use cached working hours with fallback const config = workingHoursCache || { startHour: TAT_CONFIG.WORK_START_HOUR, endHour: TAT_CONFIG.WORK_END_HOUR, startDay: TAT_CONFIG.WORK_START_DAY, endDay: TAT_CONFIG.WORK_END_DAY }; // If start time is before working hours or outside working days, // advance to the next working hour start (reset to clean hour) const originalStart = current.format('YYYY-MM-DD HH:mm:ss'); let hour = current.hour(); let day = current.day(); // Check if originally outside working hours const wasOutsideWorkingHours = !(day >= config.startDay && day <= config.endDay && hour >= config.startHour && hour < config.endHour); // If before work start hour on a working day, jump to work start hour if (day >= config.startDay && day <= config.endDay && hour < config.startHour) { current = current.hour(config.startHour); } else { // Advance to next working hour while (!(day >= config.startDay && day <= config.endDay && hour >= config.startHour && hour < config.endHour)) { current = current.add(1, 'hour'); day = current.day(); hour = current.hour(); } } // If start time was outside working hours, reset to clean work start time if (wasOutsideWorkingHours) { current = current.minute(0).second(0).millisecond(0); } let remaining = hoursToAdd; while (remaining > 0) { current = current.add(1, 'hour'); const day = current.day(); const hour = current.hour(); // Simple check without holidays (but respects configured working hours) if (day >= config.startDay && day <= config.endDay && hour >= config.startHour && hour < config.endHour) { remaining -= 1; } } return current; } /** * Initialize holidays and working hours cache (call on server startup) */ export async function initializeHolidaysCache(): Promise { await loadWorkingHoursCache(); await loadHolidaysCache(); } /** * Clear working hours cache (call when admin updates configuration) * Also immediately reloads the cache with new values */ export async function clearWorkingHoursCache(): Promise { workingHoursCache = null; workingHoursCacheExpiry = null; // Immediately reload the cache with new values await loadWorkingHoursCache(); } /** * Calculate TAT milestones (50%, 75%, 100%) * Returns Date objects for each milestone * Async version - honors holidays */ export async function calculateTatMilestones(start: Date | string, tatDurationHours: number) { const halfTime = await addWorkingHours(start, tatDurationHours * 0.5); const seventyFive = await addWorkingHours(start, tatDurationHours * 0.75); const full = await addWorkingHours(start, tatDurationHours); return { halfTime: halfTime.toDate(), seventyFive: seventyFive.toDate(), full: full.toDate() }; } /** * Synchronous version for backward compatibility (doesn't check holidays) */ export function calculateTatMilestonesSync(start: Date | string, tatDurationHours: number) { const halfTime = addWorkingHoursSync(start, tatDurationHours * 0.5); const seventyFive = addWorkingHoursSync(start, tatDurationHours * 0.75); const full = addWorkingHoursSync(start, tatDurationHours); return { halfTime: halfTime.toDate(), seventyFive: seventyFive.toDate(), full: full.toDate() }; } /** * Calculate delay in milliseconds from now to target date */ export function calculateDelay(targetDate: Date): number { const now = dayjs(); const target = dayjs(targetDate); const delay = target.diff(now, 'millisecond'); return delay > 0 ? delay : 0; // Return 0 if target is in the past } /** * Check if current time is within working hours * @returns true if currently in working hours, false if paused */ export async function isCurrentlyWorkingTime(priority: string = 'standard'): Promise { await loadWorkingHoursCache(); await loadHolidaysCache(); const now = dayjs(); // In test mode, always working time if (isTestMode()) { return true; } const config = workingHoursCache || { startHour: TAT_CONFIG.WORK_START_HOUR, endHour: TAT_CONFIG.WORK_END_HOUR, startDay: TAT_CONFIG.WORK_START_DAY, endDay: TAT_CONFIG.WORK_END_DAY }; const day = now.day(); const hour = now.hour(); const dateStr = now.format('YYYY-MM-DD'); // Check working hours const isWorkingHour = hour >= config.startHour && hour < config.endHour; // For express: include weekends, for standard: exclude weekends const isWorkingDay = priority === 'express' ? true : (day >= config.startDay && day <= config.endDay); // Check if not a holiday const isNotHoliday = !holidaysCache.has(dateStr); return isWorkingDay && isWorkingHour && isNotHoliday; } /** * Calculate comprehensive SLA status for an approval level * Returns all data needed for frontend display */ export async function calculateSLAStatus( levelStartTime: Date | string, tatHours: number, priority: string = 'standard', endDate?: Date | string | null ) { await loadWorkingHoursCache(); await loadHolidaysCache(); const startDate = dayjs(levelStartTime); // Use provided endDate if available (for completed requests), otherwise use current time const endTime = endDate ? dayjs(endDate) : dayjs(); // Calculate elapsed working hours const elapsedHours = await calculateElapsedWorkingHours(levelStartTime, endTime.toDate(), priority); const remainingHours = Math.max(0, tatHours - elapsedHours); const percentageUsed = tatHours > 0 ? Math.min(100, Math.round((elapsedHours / tatHours) * 100)) : 0; // Calculate deadline based on priority // EXPRESS: All days (Mon-Sun) but working hours only (9 AM - 6 PM) // STANDARD: Weekdays only (Mon-Fri) and working hours (9 AM - 6 PM) const deadline = priority === 'express' ? (await addWorkingHoursExpress(levelStartTime, tatHours)).toDate() : (await addWorkingHours(levelStartTime, tatHours)).toDate(); // Check if currently paused (outside working hours) // For completed requests (with endDate), it's not paused const isPaused = endDate ? false : !(await isCurrentlyWorkingTime(priority)); // Determine status let status: 'on_track' | 'approaching' | 'critical' | 'breached' = 'on_track'; if (percentageUsed >= 100) { status = 'breached'; } else if (percentageUsed >= 80) { status = 'critical'; } else if (percentageUsed >= 60) { status = 'approaching'; } // Format remaining time const formatTime = (hours: number) => { if (hours <= 0) return '0h'; const days = Math.floor(hours / 8); // 8 working hours per day const remainingHrs = Math.floor(hours % 8); const minutes = Math.round((hours % 1) * 60); if (days > 0) { return minutes > 0 ? `${days}d ${remainingHrs}h ${minutes}m` : `${days}d ${remainingHrs}h`; } return minutes > 0 ? `${remainingHrs}h ${minutes}m` : `${remainingHrs}h`; }; return { elapsedHours: Math.round(elapsedHours * 100) / 100, remainingHours: Math.round(remainingHours * 100) / 100, percentageUsed, deadline: deadline.toISOString(), isPaused, status, remainingText: formatTime(remainingHours), elapsedText: formatTime(elapsedHours) }; } /** * Calculate elapsed working hours between two dates * Uses minute-by-minute precision to accurately count only working time * @param startDate - Start time (when level was assigned) * @param endDate - End time (defaults to now) * @param priority - 'express' or 'standard' (express includes weekends, standard excludes) * @returns Elapsed working hours (with decimal precision) */ export async function calculateElapsedWorkingHours( startDate: Date | string, endDateParam: Date | string | null = null, priority: string = 'standard' ): Promise { await loadWorkingHoursCache(); await loadHolidaysCache(); let start = dayjs(startDate); const end = dayjs(endDateParam || new Date()); // In test mode, use raw minutes for 1:1 conversion if (isTestMode()) { return end.diff(start, 'minute') / 60; } const config = workingHoursCache || { startHour: TAT_CONFIG.WORK_START_HOUR, endHour: TAT_CONFIG.WORK_END_HOUR, startDay: TAT_CONFIG.WORK_START_DAY, endDay: TAT_CONFIG.WORK_END_DAY }; // CRITICAL FIX: If start time is outside working hours, advance to next working period // This ensures we only count elapsed time when TAT is actually running const originalStart = start.format('YYYY-MM-DD HH:mm:ss'); // For standard priority, check working days and hours if (priority !== 'express') { const wasOutsideWorkingHours = !isWorkingTime(start); while (!isWorkingTime(start)) { const hour = start.hour(); const day = start.day(); // If before work start hour on a working day, jump to work start hour if (day >= config.startDay && day <= config.endDay && !isHoliday(start) && hour < config.startHour) { start = start.hour(config.startHour); } else { // Otherwise, advance by 1 hour and check again start = start.add(1, 'hour'); } } // If start time was outside working hours, reset to clean work start time if (wasOutsideWorkingHours) { start = start.minute(0).second(0).millisecond(0); } } else { // For express priority, only check working hours (not days) const hour = start.hour(); if (hour < config.startHour) { // Before hours - reset to clean start start = start.hour(config.startHour).minute(0).second(0).millisecond(0); } else if (hour >= config.endHour) { // After hours - reset to clean start of next day start = start.add(1, 'day').hour(config.startHour).minute(0).second(0).millisecond(0); } } if (end.isBefore(start)) { return 0; } let totalWorkingMinutes = 0; let currentDate = start.startOf('day'); const endDay = end.startOf('day'); // Process each day while (currentDate.isBefore(endDay) || currentDate.isSame(endDay, 'day')) { const dateStr = currentDate.format('YYYY-MM-DD'); const dayOfWeek = currentDate.day(); // Check if this day is a working day const isWorkingDay = priority === 'express' ? true : (dayOfWeek >= config.startDay && dayOfWeek <= config.endDay); const isNotHoliday = !holidaysCache.has(dateStr); if (isWorkingDay && isNotHoliday) { // Determine the working period for this day let dayStart = currentDate.hour(config.startHour).minute(0).second(0); let dayEnd = currentDate.hour(config.endHour).minute(0).second(0); // Adjust for first day (might start mid-day) if (currentDate.isSame(start, 'day')) { if (start.hour() >= config.endHour) { // Started after work hours - skip this day currentDate = currentDate.add(1, 'day'); continue; } else if (start.hour() >= config.startHour) { // Started during work hours - use actual start time dayStart = start; } // If before work hours, dayStart is already correct (work start time) } // Adjust for last day (might end mid-day) if (currentDate.isSame(end, 'day')) { if (end.hour() < config.startHour) { // Ended before work hours - skip this day currentDate = currentDate.add(1, 'day'); continue; } else if (end.hour() < config.endHour) { // Ended during work hours - use actual end time dayEnd = end; } // If after work hours, dayEnd is already correct (work end time) } // Calculate minutes worked this day if (dayStart.isBefore(dayEnd)) { const minutesThisDay = dayEnd.diff(dayStart, 'minute'); totalWorkingMinutes += minutesThisDay; } } currentDate = currentDate.add(1, 'day'); // Safety check if (currentDate.diff(start, 'day') > 730) { // 2 years console.error('[TAT] Safety break - exceeded 2 years'); break; } } const hours = totalWorkingMinutes / 60; return hours; } /** * Calculate business days between two dates * Excludes weekends and holidays * @param startDate - Start date * @param endDate - End date (defaults to now) * @param priority - 'express' or 'standard' (express includes weekends, standard excludes) * @returns Number of business days */ export async function calculateBusinessDays( startDate: Date | string, endDate: Date | string | null = null, priority: string = 'standard' ): Promise { await loadWorkingHoursCache(); await loadHolidaysCache(); let start = dayjs(startDate).startOf('day'); const end = dayjs(endDate || new Date()).startOf('day'); // In test mode, use calendar days if (isTestMode()) { return end.diff(start, 'day') + 1; } const config = workingHoursCache || { startHour: TAT_CONFIG.WORK_START_HOUR, endHour: TAT_CONFIG.WORK_END_HOUR, startDay: TAT_CONFIG.WORK_START_DAY, endDay: TAT_CONFIG.WORK_END_DAY }; let businessDays = 0; let current = start; // Count each day from start to end (inclusive) while (current.isBefore(end) || current.isSame(end, 'day')) { const dayOfWeek = current.day(); // 0 = Sunday, 6 = Saturday const dateStr = current.format('YYYY-MM-DD'); // For express priority: count all days (including weekends) but exclude holidays // For standard priority: count only working days (Mon-Fri) and exclude holidays const isWorkingDay = priority === 'express' ? true // Express includes weekends : (dayOfWeek >= config.startDay && dayOfWeek <= config.endDay); const isNotHoliday = !holidaysCache.has(dateStr); if (isWorkingDay && isNotHoliday) { businessDays++; } current = current.add(1, 'day'); // Safety check to prevent infinite loops if (current.diff(start, 'day') > 730) { // 2 years console.error('[TAT] Safety break - exceeded 2 years in business days calculation'); break; } } return businessDays; }