centralized TAT logic kept in backend with admin config inlined
This commit is contained in:
parent
cfbb1c8b04
commit
1aa7fb9056
@ -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
|
||||
|
||||
@ -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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -9,6 +9,9 @@ const userController = new UserController();
|
||||
// GET /api/v1/users/search?q=<email or name>
|
||||
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;
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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<UserModel> {
|
||||
// Validate required fields
|
||||
@ -78,7 +93,59 @@ export class UserService {
|
||||
});
|
||||
}
|
||||
|
||||
async searchUsers(query: string, limit: number = 10, excludeUserId?: string): Promise<UserModel[]> {
|
||||
async searchUsers(query: string, limit: number = 10, excludeUserId?: string): Promise<any[]> {
|
||||
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<UserModel[]> {
|
||||
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<UserModel> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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<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
|
||||
};
|
||||
|
||||
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<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'
|
||||
) {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user