732 lines
24 KiB
TypeScript
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;
|
|
}
|
|
|