diff --git a/env.example b/env.example index 45c06c0..e09cda0 100644 --- a/env.example +++ b/env.example @@ -25,6 +25,7 @@ REFRESH_TOKEN_EXPIRY=7d OKTA_DOMAIN=https://dev-830839.oktapreview.com OKTA_CLIENT_ID=0oa2j8slwj5S4bG5k0h8 OKTA_CLIENT_SECRET=your_okta_client_secret_here +OKTA_API_TOKEN=your_okta_api_token_here # For Okta User Management API (user search) # Session SESSION_SECRET=your_session_secret_here_min_32_chars diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index bb01d52..51aceba 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -16,8 +16,6 @@ export class UserController { const limit = Number(req.query.limit || 10); const currentUserId = (req as any).user?.userId || (req as any).user?.id; - logger.info('User search requested', { q, limit }); - const users = await this.userService.searchUsers(q, limit, currentUserId); const result = users.map(u => ({ @@ -37,6 +35,44 @@ export class UserController { ResponseHandler.error(res, 'User search failed', 500); } } + + /** + * Ensure user exists in database (create if not exists) + * Called when user is selected/tagged in the frontend + */ + async ensureUserExists(req: Request, res: Response): Promise { + try { + const { userId, email, displayName, firstName, lastName, department, phone } = req.body; + + if (!userId || !email) { + ResponseHandler.error(res, 'userId and email are required', 400); + return; + } + + const user = await this.userService.ensureUserExists({ + userId, + email, + displayName, + firstName, + lastName, + department, + phone + }); + + ResponseHandler.success(res, { + userId: user.userId, + email: user.email, + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + department: user.department, + isActive: user.isActive + }, 'User ensured in database'); + } catch (error: any) { + logger.error('Ensure user failed', { error }); + ResponseHandler.error(res, error.message || 'Failed to ensure user', 500); + } + } } diff --git a/src/routes/user.routes.ts b/src/routes/user.routes.ts index b634f1e..30df21f 100644 --- a/src/routes/user.routes.ts +++ b/src/routes/user.routes.ts @@ -9,6 +9,9 @@ const userController = new UserController(); // GET /api/v1/users/search?q= router.get('/search', authenticateToken, asyncHandler(userController.searchUsers.bind(userController))); +// POST /api/v1/users/ensure - Ensure user exists in DB (create if not exists) +router.post('/ensure', authenticateToken, asyncHandler(userController.ensureUserExists.bind(userController))); + export default router; diff --git a/src/services/approval.service.ts b/src/services/approval.service.ts index 14cd076..e7dfe4e 100644 --- a/src/services/approval.service.ts +++ b/src/services/approval.service.ts @@ -4,7 +4,8 @@ import { Participant } from '@models/Participant'; import { TatAlert } from '@models/TatAlert'; import { ApprovalAction } from '../types/approval.types'; import { ApprovalStatus, WorkflowStatus } from '../types/common.types'; -import { calculateElapsedHours, calculateTATPercentage } from '@utils/helpers'; +import { calculateTATPercentage } from '@utils/helpers'; +import { calculateElapsedWorkingHours } from '@utils/tatTimeUtils'; import logger from '@utils/logger'; import { Op } from 'sequelize'; import { notificationService } from './notification.service'; @@ -17,8 +18,13 @@ export class ApprovalService { const level = await ApprovalLevel.findByPk(levelId); if (!level) return null; + // Get workflow to determine priority for working hours calculation + const wf = await WorkflowRequest.findByPk(level.requestId); + const priority = ((wf as any)?.priority || 'standard').toString().toLowerCase(); + const now = new Date(); - const elapsedHours = calculateElapsedHours(level.levelStartTime || level.createdAt, now); + // Calculate elapsed hours using working hours logic (matches frontend) + const elapsedHours = await calculateElapsedWorkingHours(level.levelStartTime || level.createdAt, now, priority); const tatPercentage = calculateTATPercentage(elapsedHours, level.tatHours); const updateData = { @@ -60,10 +66,7 @@ export class ApprovalService { // Don't fail the approval if TAT alert update fails } - // Load workflow for titles and initiator - const wf = await WorkflowRequest.findByPk(level.requestId); - - // Handle approval - move to next level or close workflow + // Handle approval - move to next level or close workflow (wf already loaded above) if (action.action === 'APPROVE') { if (level.isFinalApprover) { // Final approver - close workflow as APPROVED diff --git a/src/services/tatScheduler.service.ts b/src/services/tatScheduler.service.ts index 85b4daf..5b1809a 100644 --- a/src/services/tatScheduler.service.ts +++ b/src/services/tatScheduler.service.ts @@ -1,5 +1,5 @@ import { tatQueue } from '../queues/tatQueue'; -import { calculateDelay, addWorkingHours, addCalendarHours } from '@utils/tatTimeUtils'; +import { calculateDelay, addWorkingHours, addWorkingHoursExpress } from '@utils/tatTimeUtils'; import { getTatThresholds } from './configReader.service'; import dayjs from 'dayjs'; import logger from '@utils/logger'; @@ -44,20 +44,23 @@ export class TatSchedulerService { let breachTime: Date; if (isExpress) { - // EXPRESS: 24/7 calculation - no exclusions - threshold1Time = addCalendarHours(now, tatDurationHours * (thresholds.first / 100)).toDate(); - threshold2Time = addCalendarHours(now, tatDurationHours * (thresholds.second / 100)).toDate(); - breachTime = addCalendarHours(now, tatDurationHours).toDate(); - logger.info(`[TAT Scheduler] Using EXPRESS mode (24/7) - no holiday/weekend exclusions`); + // EXPRESS: All calendar days (Mon-Sun, including weekends/holidays) but working hours only (9 AM - 6 PM) + const t1 = await addWorkingHoursExpress(now, tatDurationHours * (thresholds.first / 100)); + const t2 = await addWorkingHoursExpress(now, tatDurationHours * (thresholds.second / 100)); + const tBreach = await addWorkingHoursExpress(now, tatDurationHours); + threshold1Time = t1.toDate(); + threshold2Time = t2.toDate(); + breachTime = tBreach.toDate(); + logger.info(`[TAT Scheduler] Using EXPRESS mode - all days, working hours only (9 AM - 6 PM)`); } else { - // STANDARD: Working hours only, excludes holidays + // STANDARD: Working days only (Mon-Fri), working hours (9 AM - 6 PM), excludes holidays const t1 = await addWorkingHours(now, tatDurationHours * (thresholds.first / 100)); const t2 = await addWorkingHours(now, tatDurationHours * (thresholds.second / 100)); const tBreach = await addWorkingHours(now, tatDurationHours); threshold1Time = t1.toDate(); threshold2Time = t2.toDate(); breachTime = tBreach.toDate(); - logger.info(`[TAT Scheduler] Using STANDARD mode - excludes holidays, weekends, non-working hours`); + logger.info(`[TAT Scheduler] Using STANDARD mode - weekdays only, working hours (9 AM - 6 PM), excludes holidays`); } logger.info(`[TAT Scheduler] Calculating TAT milestones for request ${requestId}, level ${levelId}`); diff --git a/src/services/user.service.ts b/src/services/user.service.ts index c7fd7a5..3d9d79c 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -1,9 +1,24 @@ import { User as UserModel } from '../models/User'; import { Op } from 'sequelize'; import { SSOUserData } from '../types/auth.types'; // Use shared type +import axios from 'axios'; // Using UserModel type directly - interface removed to avoid duplication +interface OktaUser { + id: string; + status: string; + profile: { + firstName?: string; + lastName?: string; + displayName?: string; + email: string; + login: string; + department?: string; + mobilePhone?: string; + }; +} + export class UserService { async createOrUpdateUser(ssoData: SSOUserData): Promise { // Validate required fields @@ -78,7 +93,59 @@ export class UserService { }); } - async searchUsers(query: string, limit: number = 10, excludeUserId?: string): Promise { + async searchUsers(query: string, limit: number = 10, excludeUserId?: string): Promise { + const q = (query || '').trim(); + if (!q) { + return []; + } + + // Search Okta users + try { + const oktaDomain = process.env.OKTA_DOMAIN; + const oktaApiToken = process.env.OKTA_API_TOKEN; + + if (!oktaDomain || !oktaApiToken) { + console.error('❌ Okta credentials not configured'); + // Fallback to local DB search + return await this.searchUsersLocal(q, limit, excludeUserId); + } + + const response = await axios.get(`${oktaDomain}/api/v1/users`, { + params: { q, limit: Math.min(limit, 50) }, + headers: { + 'Authorization': `SSWS ${oktaApiToken}`, + 'Accept': 'application/json' + }, + timeout: 5000 + }); + + const oktaUsers: OktaUser[] = response.data || []; + + // Transform Okta users to our format + return oktaUsers + .filter(u => u.status === 'ACTIVE' && u.id !== excludeUserId) + .map(u => ({ + userId: u.id, + oktaSub: u.id, + email: u.profile.email || u.profile.login, + displayName: u.profile.displayName || `${u.profile.firstName || ''} ${u.profile.lastName || ''}`.trim(), + firstName: u.profile.firstName, + lastName: u.profile.lastName, + department: u.profile.department, + phone: u.profile.mobilePhone, + isActive: true + })); + } catch (error: any) { + console.error('❌ Okta user search failed:', error.message); + // Fallback to local DB search + return await this.searchUsersLocal(q, limit, excludeUserId); + } + } + + /** + * Fallback: Search users in local database + */ + private async searchUsersLocal(query: string, limit: number = 10, excludeUserId?: string): Promise { const q = (query || '').trim(); if (!q) { return []; @@ -100,4 +167,66 @@ export class UserService { limit: Math.min(Math.max(limit || 10, 1), 50), }); } + + /** + * Ensure user exists in database (create if not exists) + * Used when tagging users from Okta search results + */ + async ensureUserExists(oktaUserData: { + userId: string; + email: string; + displayName?: string; + firstName?: string; + lastName?: string; + department?: string; + phone?: string; + }): Promise { + const email = oktaUserData.email.toLowerCase(); + + // Check if user already exists + let user = await UserModel.findOne({ + where: { + [Op.or]: [ + { email }, + { oktaSub: oktaUserData.userId } + ] + } + }); + + if (user) { + // Update existing user with latest info from Okta + await user.update({ + oktaSub: oktaUserData.userId, + email, + firstName: oktaUserData.firstName || user.firstName, + lastName: oktaUserData.lastName || user.lastName, + displayName: oktaUserData.displayName || user.displayName, + department: oktaUserData.department || user.department, + phone: oktaUserData.phone || user.phone, + isActive: true, + updatedAt: new Date() + }); + return user; + } + + // Create new user + user = await UserModel.create({ + oktaSub: oktaUserData.userId, + email, + employeeId: null, // Will be updated on first login + firstName: oktaUserData.firstName || null, + lastName: oktaUserData.lastName || null, + displayName: oktaUserData.displayName || email.split('@')[0], + department: oktaUserData.department || null, + designation: null, + phone: oktaUserData.phone || null, + isActive: true, + isAdmin: false, + lastLogin: undefined, // Not logged in yet, just created for tagging + createdAt: new Date(), + updatedAt: new Date() + }); + + return user; + } } diff --git a/src/services/workflow.service.ts b/src/services/workflow.service.ts index f4efafb..21ac258 100644 --- a/src/services/workflow.service.ts +++ b/src/services/workflow.service.ts @@ -491,28 +491,44 @@ export class WorkflowService { const approvals = await ApprovalLevel.findAll({ where: { requestId: (wf as any).requestId }, order: [['levelNumber', 'ASC']], - attributes: ['levelId', 'levelNumber', 'levelName', 'approverId', 'approverEmail', 'approverName', 'tatHours', 'tatDays', 'status'] + attributes: ['levelId', 'levelNumber', 'levelName', 'approverId', 'approverEmail', 'approverName', 'tatHours', 'tatDays', 'status', 'levelStartTime', 'tatStartTime'] }); - const totalTat = Number((wf as any).totalTatHours || 0); - let percent = 0; - let remainingText = ''; - if ((wf as any).submissionDate && totalTat > 0) { - const startedAt = new Date((wf as any).submissionDate); - const now = new Date(); - const elapsedHrs = Math.max(0, (now.getTime() - startedAt.getTime()) / (1000 * 60 * 60)); - percent = Math.min(100, Math.round((elapsedHrs / totalTat) * 100)); - const remaining = Math.max(0, totalTat - elapsedHrs); - const days = Math.floor(remaining / 24); - const hours = Math.floor(remaining % 24); - remainingText = days > 0 ? `${days} days ${hours} hours remaining` : `${hours} hours remaining`; - } - // Calculate total TAT hours from all approvals const totalTatHours = approvals.reduce((sum: number, a: any) => { return sum + Number(a.tatHours || 0); }, 0); + const priority = ((wf as any).priority || 'standard').toString().toLowerCase(); + + // Calculate OVERALL request SLA (from submission to total deadline) + const { calculateSLAStatus } = require('@utils/tatTimeUtils'); + const submissionDate = (wf as any).submissionDate; + let overallSLA = null; + + if (submissionDate && totalTatHours > 0) { + try { + overallSLA = await calculateSLAStatus(submissionDate, totalTatHours, priority); + } catch (error) { + logger.error('[Workflow] Error calculating overall SLA:', error); + } + } + + // Calculate current level SLA (if there's an active level) + let currentLevelSLA = null; + if (currentLevel) { + const levelStartTime = (currentLevel as any).levelStartTime || (currentLevel as any).tatStartTime; + const levelTatHours = Number((currentLevel as any).tatHours || 0); + + if (levelStartTime && levelTatHours > 0) { + try { + currentLevelSLA = await calculateSLAStatus(levelStartTime, levelTatHours, priority); + } catch (error) { + logger.error('[Workflow] Error calculating current level SLA:', error); + } + } + } + return { requestId: (wf as any).requestId, requestNumber: (wf as any).requestNumber, @@ -529,6 +545,9 @@ export class WorkflowService { userId: (currentLevel as any).approverId, email: (currentLevel as any).approverEmail, name: (currentLevel as any).approverName, + levelStartTime: (currentLevel as any).levelStartTime, + tatHours: (currentLevel as any).tatHours, + sla: currentLevelSLA, // ← Backend-calculated SLA for current level } : null, approvals: approvals.map((a: any) => ({ levelId: a.levelId, @@ -539,9 +558,18 @@ export class WorkflowService { approverName: a.approverName, tatHours: a.tatHours, tatDays: a.tatDays, - status: a.status + status: a.status, + levelStartTime: a.levelStartTime || a.tatStartTime })), - sla: { percent, remainingText }, + sla: overallSLA || { + elapsedHours: 0, + remainingHours: totalTatHours, + percentageUsed: 0, + remainingText: `${totalTatHours}h remaining`, + isPaused: false, + status: 'on_track' + }, // ← Overall request SLA (all levels combined) + currentLevelSLA: currentLevelSLA, // ← Also provide at root level for easy access }; })); return data; @@ -1004,7 +1032,71 @@ export class WorkflowService { tatAlerts = []; } - return { workflow, approvals, participants, documents, activities, summary, tatAlerts }; + // Recalculate SLA for all approval levels with comprehensive data + const priority = ((workflow as any)?.priority || 'standard').toString().toLowerCase(); + const { calculateSLAStatus } = require('@utils/tatTimeUtils'); + + const updatedApprovals = await Promise.all(approvals.map(async (approval: any) => { + const status = (approval.status || '').toString().toUpperCase(); + const approvalData = approval.toJSON(); + + // Calculate SLA for active approvals (pending/in-progress) + if (status === 'PENDING' || status === 'IN_PROGRESS') { + const levelStartTime = approval.levelStartTime || approval.tatStartTime || approval.createdAt; + const tatHours = Number(approval.tatHours || 0); + + if (levelStartTime && tatHours > 0) { + try { + // Get comprehensive SLA status from backend utility + const slaData = await calculateSLAStatus(levelStartTime, tatHours, priority); + + // Return updated approval with comprehensive SLA data + return { + ...approvalData, + elapsedHours: slaData.elapsedHours, + remainingHours: slaData.remainingHours, + tatPercentageUsed: slaData.percentageUsed, + sla: slaData // ← Full SLA object with deadline, isPaused, status, etc. + }; + } catch (error) { + logger.error(`[Workflow] Error calculating SLA for level ${approval.levelNumber}:`, error); + // Return with fallback values if SLA calculation fails + return { + ...approvalData, + sla: { + elapsedHours: 0, + remainingHours: tatHours, + percentageUsed: 0, + isPaused: false, + status: 'on_track', + remainingText: `${tatHours}h`, + elapsedText: '0h' + } + }; + } + } + } + + // For completed/rejected levels, return as-is (already has final values from database) + return approvalData; + })); + + // Calculate overall request SLA + const submissionDate = (workflow as any).submissionDate; + const totalTatHours = updatedApprovals.reduce((sum, a) => sum + Number(a.tatHours || 0), 0); + let overallSLA = null; + + if (submissionDate && totalTatHours > 0) { + overallSLA = await calculateSLAStatus(submissionDate, totalTatHours, priority); + } + + // Update summary to include comprehensive SLA + const updatedSummary = { + ...summary, + sla: overallSLA || summary.sla + }; + + return { workflow, approvals: updatedApprovals, participants, documents, activities, summary: updatedSummary, tatAlerts }; } catch (error) { logger.error(`Failed to get workflow details ${requestId}:`, error); throw new Error('Failed to get workflow details'); diff --git a/src/utils/tatTimeUtils.ts b/src/utils/tatTimeUtils.ts index 8ad5e52..61686db 100644 --- a/src/utils/tatTimeUtils.ts +++ b/src/utils/tatTimeUtils.ts @@ -157,9 +157,49 @@ export async function addWorkingHours(start: Date | string, hoursToAdd: number): } /** - * Add calendar hours (EXPRESS mode - 24/7, no exclusions) - * For EXPRESS priority requests - counts all hours including holidays, weekends, non-working hours - * In TEST MODE: 1 hour = 1 minute for faster testing + * 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 + }; + + let remaining = hoursToAdd; + + 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 (9 AM - 6 PM) + if (hour >= config.startHour && hour < config.endHour) { + remaining -= 1; + } + } + + 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); @@ -169,7 +209,7 @@ export function addCalendarHours(start: Date | string, hoursToAdd: number): Dayj return current.add(hoursToAdd, 'minute'); } - // Express mode: Simply add hours without any exclusions (24/7) + // Simply add hours without any exclusions (24/7) return current.add(hoursToAdd, 'hour'); } @@ -269,3 +309,220 @@ export function calculateDelay(targetDate: Date): number { 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' +) { + await loadWorkingHoursCache(); + await loadHolidaysCache(); + + const startDate = dayjs(levelStartTime); + const now = dayjs(); + + // Calculate elapsed working hours + const elapsedHours = await calculateElapsedWorkingHours(levelStartTime, now.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) + const isPaused = !(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(); + + const 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 + }; + + 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; + + // Warning log for unusually high values + if (hours > 16) { // More than 2 working days + console.warn('[TAT] High elapsed hours detected:', { + startDate: start.format('YYYY-MM-DD HH:mm'), + endDate: end.format('YYYY-MM-DD HH:mm'), + priority, + elapsedHours: hours, + workingHoursConfig: config, + calendarHours: end.diff(start, 'hour') + }); + } + + return hours; +} +