Re_Backend/src/utils/tatTimeUtils.ts

732 lines
24 KiB
TypeScript

import dayjs, { Dayjs } from 'dayjs';
import { TAT_CONFIG, isTestMode } from '../config/tat.config';
// Cache for holidays to avoid repeated DB queries
let holidaysCache: Set<string> = 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<void> {
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<void> {
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<Dayjs> {
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<Dayjs> {
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<void> {
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<void> {
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<boolean> {
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<number> {
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<number> {
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;
}