import { WorkflowRequest } from '../models/WorkflowRequest'; import { DealerClaimDetails } from '../models/DealerClaimDetails'; import { DealerProposalDetails } from '../models/DealerProposalDetails'; import { DealerCompletionDetails } from '../models/DealerCompletionDetails'; import { DealerProposalCostItem } from '../models/DealerProposalCostItem'; import { InternalOrder, IOStatus } from '../models/InternalOrder'; import { ClaimBudgetTracking, BudgetStatus } from '../models/ClaimBudgetTracking'; import { ClaimInvoice } from '../models/ClaimInvoice'; import { ClaimCreditNote } from '../models/ClaimCreditNote'; import { DealerCompletionExpense } from '../models/DealerCompletionExpense'; import { ApprovalLevel } from '../models/ApprovalLevel'; import { Participant } from '../models/Participant'; import { User } from '../models/User'; import { WorkflowService } from './workflow.service'; import { DealerClaimApprovalService } from './dealerClaimApproval.service'; import { generateRequestNumber } from '../utils/helpers'; import { Priority, WorkflowStatus, ApprovalStatus, ParticipantType } from '../types/common.types'; import { sapIntegrationService } from './sapIntegration.service'; import { dmsIntegrationService } from './dmsIntegration.service'; import { notificationService } from './notification.service'; import { activityService } from './activity.service'; import { UserService } from './user.service'; import logger from '../utils/logger'; /** * Dealer Claim Service * Handles business logic specific to dealer claim management workflow */ export class DealerClaimService { private workflowService = new WorkflowService(); private approvalService = new DealerClaimApprovalService(); private userService = new UserService(); /** * Create a new dealer claim request */ async createClaimRequest( userId: string, claimData: { activityName: string; activityType: string; dealerCode: string; dealerName: string; dealerEmail?: string; dealerPhone?: string; dealerAddress?: string; activityDate?: Date; location: string; requestDescription: string; periodStartDate?: Date; periodEndDate?: Date; estimatedBudget?: number; approvers?: Array<{ email: string; name?: string; userId?: string; level: number; tat?: number | string; tatType?: 'hours' | 'days'; }>; } ): Promise { try { // Generate request number const requestNumber = await generateRequestNumber(); // Validate initiator - check if userId is a valid UUID first const isValidUUID = (str: string): boolean => { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; return uuidRegex.test(str); }; if (!isValidUUID(userId)) { // If userId is not a UUID (might be Okta ID), try to find by email or other means // This shouldn't happen in normal flow, but handle gracefully throw new Error(`Invalid initiator ID format. Expected UUID, got: ${userId}`); } const initiator = await User.findByPk(userId); if (!initiator) { throw new Error('Initiator not found'); } // Validate approvers array is provided if (!claimData.approvers || !Array.isArray(claimData.approvers) || claimData.approvers.length === 0) { throw new Error('Approvers array is required. Please assign approvers for all workflow steps.'); } // Now create workflow request (manager is validated) // For claim management, requests are submitted immediately (not drafts) // Step 1 will be active for dealer to submit proposal const now = new Date(); const workflowRequest = await WorkflowRequest.create({ initiatorId: userId, requestNumber, templateType: 'DEALER CLAIM', // Set template type for dealer claim management workflowType: 'CLAIM_MANAGEMENT', title: `${claimData.activityName} - Claim Request`, description: claimData.requestDescription, priority: Priority.STANDARD, status: WorkflowStatus.PENDING, // Submitted, not draft totalLevels: 5, // Fixed 5-step workflow for claim management (Activity Creation, E-Invoice Generation, and Credit Note Confirmation are now activity logs only) currentLevel: 1, // Step 1: Dealer Proposal Submission totalTatHours: 0, // Will be calculated from approval levels isDraft: false, // Not a draft - submitted and ready for workflow isDeleted: false, submissionDate: now, // Set submission date for SLA tracking (required for overall SLA calculation) }); // Create claim details await DealerClaimDetails.create({ requestId: workflowRequest.requestId, activityName: claimData.activityName, activityType: claimData.activityType, dealerCode: claimData.dealerCode, dealerName: claimData.dealerName, dealerEmail: claimData.dealerEmail, dealerPhone: claimData.dealerPhone, dealerAddress: claimData.dealerAddress, activityDate: claimData.activityDate, location: claimData.location, periodStartDate: claimData.periodStartDate, periodEndDate: claimData.periodEndDate, }); // Initialize budget tracking with initial estimated budget (if provided) await ClaimBudgetTracking.upsert({ requestId: workflowRequest.requestId, initialEstimatedBudget: claimData.estimatedBudget, budgetStatus: BudgetStatus.DRAFT, currency: 'INR', }); // Create 8 approval levels for claim management workflow from approvers array await this.createClaimApprovalLevelsFromApprovers(workflowRequest.requestId, userId, claimData.dealerEmail, claimData.approvers || []); // Schedule TAT jobs for Step 1 (Dealer Proposal Submission) - first active step // This ensures SLA tracking starts immediately from request creation const { tatSchedulerService } = await import('./tatScheduler.service'); const dealerLevel = await ApprovalLevel.findOne({ where: { requestId: workflowRequest.requestId, levelNumber: 1 // Step 1: Dealer Proposal Submission } }); if (dealerLevel && dealerLevel.approverId && dealerLevel.levelStartTime) { try { const workflowPriority = (workflowRequest as any)?.priority || 'STANDARD'; await tatSchedulerService.scheduleTatJobs( workflowRequest.requestId, (dealerLevel as any).levelId, dealerLevel.approverId, Number(dealerLevel.tatHours || 0), dealerLevel.levelStartTime, workflowPriority ); logger.info(`[DealerClaimService] TAT jobs scheduled for Step 1 (Dealer Proposal Submission) - Priority: ${workflowPriority}`); } catch (tatError) { logger.error(`[DealerClaimService] Failed to schedule TAT jobs for Step 1:`, tatError); // Don't fail request creation if TAT scheduling fails } } // Create participants (initiator, dealer, department lead, finance - exclude system) await this.createClaimParticipants(workflowRequest.requestId, userId, claimData.dealerEmail); // Get initiator details for activity logging and notifications const initiatorName = initiator.displayName || initiator.email || 'User'; // Log creation activity await activityService.log({ requestId: workflowRequest.requestId, type: 'created', user: { userId: userId, name: initiatorName }, timestamp: new Date().toISOString(), action: 'Claim request created', details: `Claim request "${workflowRequest.title}" created by ${initiatorName} for dealer ${claimData.dealerName}` }); // Send notification to INITIATOR confirming submission await notificationService.sendToUsers([userId], { title: 'Claim Request Submitted Successfully', body: `Your claim request "${workflowRequest.title}" has been submitted successfully.`, requestNumber: requestNumber, requestId: workflowRequest.requestId, url: `/request/${requestNumber}`, type: 'request_submitted', priority: 'MEDIUM' }); // Get approval levels for notifications // Step 1: Dealer Proposal Submission (first active step - log assignment at creation) // Subsequent steps will have assignment logged when they become active (via approval service) // Notify Step 1 (Dealer) - dealerLevel was already fetched above for TAT scheduling if (dealerLevel && dealerLevel.approverId) { // Skip notifications for system processes const approverEmail = dealerLevel.approverEmail || ''; const isSystemProcess = approverEmail.toLowerCase() === 'system@royalenfield.com' || approverEmail.toLowerCase().includes('system') || dealerLevel.approverId === 'system' || dealerLevel.approverName === 'System Auto-Process'; if (!isSystemProcess) { // Send notification to Dealer (Step 1) for proposal submission await notificationService.sendToUsers([dealerLevel.approverId], { title: 'New Claim Request - Proposal Required', body: `Claim request "${workflowRequest.title}" requires your proposal submission.`, requestNumber: requestNumber, requestId: workflowRequest.requestId, url: `/request/${requestNumber}`, type: 'assignment', priority: 'HIGH', actionRequired: true }); // Log assignment activity for dealer (Step 1 - first active step) await activityService.log({ requestId: workflowRequest.requestId, type: 'assignment', user: { userId: userId, name: initiatorName }, timestamp: new Date().toISOString(), action: 'Assigned to dealer', details: `Claim request assigned to dealer ${dealerLevel.approverName || dealerLevel.approverEmail || claimData.dealerName} for proposal submission` }); } else { logger.info(`[DealerClaimService] Skipping notification for system process: ${approverEmail} at Step 1`); } } // Note: Step 2, 3, and subsequent steps will have assignment activities logged // when they become active (when previous step is approved) via the approval service logger.info(`[DealerClaimService] Created claim request: ${workflowRequest.requestNumber}`); return workflowRequest; } catch (error: any) { // Log detailed error information for debugging const errorDetails: any = { message: error.message, name: error.name, }; // Sequelize validation errors if (error.errors && Array.isArray(error.errors)) { errorDetails.validationErrors = error.errors.map((e: any) => ({ field: e.path, message: e.message, value: e.value, })); } // Sequelize database errors if (error.parent) { errorDetails.databaseError = { message: error.parent.message, code: error.parent.code, detail: error.parent.detail, }; } logger.error('[DealerClaimService] Error creating claim request:', errorDetails); throw error; } } /** * Create 5-step approval levels for claim management from approvers array * Validates and creates approval levels based on user-provided approvers * Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are handled as activity logs only, not approval steps */ private async createClaimApprovalLevelsFromApprovers( requestId: string, initiatorId: string, dealerEmail?: string, approvers: Array<{ email: string; name?: string; userId?: string; level: number; tat?: number | string; tatType?: 'hours' | 'days'; stepName?: string; // For additional approvers isAdditional?: boolean; // Flag for additional approvers originalStepLevel?: number; // Original step level for fixed steps }> = [] ): Promise { const initiator = await User.findByPk(initiatorId); if (!initiator) { throw new Error('Initiator not found'); } // Step definitions with default TAT (only manual approval steps) // Note: Activity Creation (was level 4), E-Invoice Generation (was level 7), and Credit Note Confirmation (was level 8) // are now handled as activity logs only, not approval steps const stepDefinitions = [ { level: 1, name: 'Dealer Proposal Submission', defaultTat: 72, isAuto: false }, { level: 2, name: 'Requestor Evaluation', defaultTat: 48, isAuto: false }, { level: 3, name: 'Department Lead Approval', defaultTat: 72, isAuto: false }, { level: 4, name: 'Dealer Completion Documents', defaultTat: 120, isAuto: false }, { level: 5, name: 'Requestor Claim Approval', defaultTat: 48, isAuto: false }, ]; // Sort approvers by level to process in order const sortedApprovers = [...approvers].sort((a, b) => a.level - b.level); // Track which original steps have been processed const processedOriginalSteps = new Set(); // Process approvers in order by their level for (const approver of sortedApprovers) { let approverId: string | null = null; let approverEmail = ''; let approverName = 'System'; let tatHours = 48; // Default TAT let levelName = ''; let isSystemStep = false; let isFinalApprover = false; // Find the step definition this approver belongs to let stepDef = null; // Check if this is a system step by email (for backwards compatibility) const isSystemEmail = approver.email === 'system@royalenfield.com' || approver.email === 'finance@royalenfield.com'; if (approver.isAdditional) { // Additional approver - use stepName from frontend levelName = approver.stepName || 'Additional Approver'; isSystemStep = false; isFinalApprover = false; } else { // Fixed step - find by originalStepLevel first, then by matching level const originalLevel = approver.originalStepLevel || approver.level; stepDef = stepDefinitions.find(s => s.level === originalLevel); if (!stepDef) { // Try to find by current level if originalStepLevel not provided stepDef = stepDefinitions.find(s => s.level === approver.level); } // System steps (Activity Creation, E-Invoice Generation, Credit Note Confirmation) are no longer approval steps // They are handled as activity logs only // If approver has system email but no step definition found, skip creating approval level if (!stepDef && isSystemEmail) { logger.info(`[DealerClaimService] Skipping system step approver at level ${approver.level} - system steps are now activity logs only`); continue; // Skip creating approval level for system steps } if (stepDef) { levelName = stepDef.name; isSystemStep = false; // No system steps in approval levels anymore isFinalApprover = stepDef.level === 5; // Last step is now Requestor Claim Approval (level 5) processedOriginalSteps.add(stepDef.level); } else { // Fallback - shouldn't happen but handle gracefully levelName = `Step ${approver.level}`; isSystemStep = false; logger.warn(`[DealerClaimService] Could not find step definition for approver at level ${approver.level}, using fallback name`); } // Ensure levelName is never empty and truncate if too long (max 100 chars) if (!levelName || levelName.trim() === '') { levelName = approver.isAdditional ? `Additional Approver - Level ${approver.level}` : `Step ${approver.level}`; logger.warn(`[DealerClaimService] levelName was empty for approver at level ${approver.level}, using fallback: ${levelName}`); } // Truncate levelName to max 100 characters (database constraint) if (levelName.length > 100) { logger.warn(`[DealerClaimService] levelName too long (${levelName.length} chars) for level ${approver.level}, truncating to 100 chars`); levelName = levelName.substring(0, 97) + '...'; } } // System steps are no longer created as approval levels - they are activity logs only // This code path should not be reached anymore, but kept for safety if (isSystemStep) { logger.warn(`[DealerClaimService] System step detected but should not create approval level. Skipping.`); continue; // Skip creating approval level for system steps } { // User-provided approver (fixed or additional) if (!approver.email) { throw new Error(`Approver email is required for level ${approver.level}: ${levelName}`); } // Calculate TAT in hours if (approver.tat) { const tat = Number(approver.tat); if (isNaN(tat) || tat <= 0) { throw new Error(`Invalid TAT for level ${approver.level}. TAT must be a positive number.`); } tatHours = approver.tatType === 'days' ? tat * 24 : tat; } else if (stepDef) { tatHours = stepDef.defaultTat; } // Ensure user exists in database (create from Okta if needed) let user: User | null = null; // Helper function to check if a string is a valid UUID const isValidUUID = (str: string): boolean => { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; return uuidRegex.test(str); }; // Try to find user by userId if it's a valid UUID if (approver.userId && isValidUUID(approver.userId)) { try { user = await User.findByPk(approver.userId); } catch (error: any) { // If findByPk fails (e.g., invalid UUID format), log and continue to email lookup logger.debug(`[DealerClaimService] Could not find user by userId ${approver.userId}, will try email lookup`); } } // If user not found by ID (or userId was not a valid UUID), try email if (!user && approver.email) { user = await User.findOne({ where: { email: approver.email.toLowerCase() } }); if (!user) { // User doesn't exist - create from Okta logger.info(`[DealerClaimService] User ${approver.email} not found in DB, syncing from Okta`); try { user = await this.userService.ensureUserExists({ email: approver.email.toLowerCase(), userId: approver.userId, // Pass Okta ID if provided (ensureUserExists will handle it) }) as any; logger.info(`[DealerClaimService] Successfully synced user ${approver.email} from Okta`); } catch (oktaError: any) { logger.error(`[DealerClaimService] Failed to sync user from Okta: ${approver.email}`, oktaError); throw new Error(`User email '${approver.email}' not found in organization directory. Please verify the email address.`); } } } if (!user) { throw new Error(`Could not resolve user for level ${approver.level}: ${approver.email}`); } approverId = user.userId; approverEmail = user.email; approverName = approver.name || user.displayName || user.email || 'Approver'; } // Ensure we have a valid approverId if (!approverId) { logger.error(`[DealerClaimService] No approverId resolved for level ${approver.level}, using initiator as fallback`); approverId = initiatorId; approverEmail = approverEmail || initiator.email; approverName = approverName || 'Unknown Approver'; } // Ensure approverId is a valid UUID before creating const isValidUUID = (str: string): boolean => { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; return uuidRegex.test(str); }; if (!approverId || !isValidUUID(approverId)) { logger.error(`[DealerClaimService] Invalid approverId for level ${approver.level}: ${approverId}`); throw new Error(`Invalid approver ID format for level ${approver.level}. Expected UUID.`); } // Create approval level using the approver's level (which may be shifted) const now = new Date(); const isStep1 = approver.level === 1; try { // Check for duplicate level_number for this request_id (unique constraint) const existingLevel = await ApprovalLevel.findOne({ where: { requestId, levelNumber: approver.level } }); if (existingLevel) { logger.error(`[DealerClaimService] Duplicate level number ${approver.level} already exists for request ${requestId}`); throw new Error(`Level ${approver.level} already exists for this request. This may indicate a duplicate approver.`); } await ApprovalLevel.create({ requestId, levelNumber: approver.level, // Use the approver's level (may be shifted) levelName: levelName, // Already validated and truncated above approverId: approverId, approverEmail: approverEmail || '', approverName: approverName || 'Unknown', tatHours: tatHours || 0, status: isStep1 ? ApprovalStatus.PENDING : ApprovalStatus.PENDING, isFinalApprover: isFinalApprover || false, elapsedHours: 0, remainingHours: tatHours || 0, tatPercentageUsed: 0, levelStartTime: isStep1 ? now : undefined, tatStartTime: isStep1 ? now : undefined, // Note: tatDays is NOT included - it's auto-calculated by the database } as any); } catch (createError: any) { // Log detailed validation errors const errorDetails: any = { message: createError.message, name: createError.name, level: approver.level, levelName: levelName?.substring(0, 50), // Truncate for logging approverId, approverEmail, approverName: approverName?.substring(0, 50), tatHours, }; // Sequelize validation errors if (createError.errors && Array.isArray(createError.errors)) { errorDetails.validationErrors = createError.errors.map((e: any) => ({ field: e.path, message: e.message, value: e.value, type: e.type, })); } // Database constraint errors if (createError.parent) { errorDetails.databaseError = { message: createError.parent.message, code: createError.parent.code, detail: createError.parent.detail, constraint: createError.parent.constraint, }; } logger.error(`[DealerClaimService] Failed to create approval level for level ${approver.level}:`, errorDetails); throw new Error(`Failed to create approval level ${approver.level} (${levelName}): ${createError.message}`); } } // Validate that required fixed steps were processed const requiredSteps = stepDefinitions.filter(s => !s.isAuto); for (const requiredStep of requiredSteps) { if (!processedOriginalSteps.has(requiredStep.level)) { logger.warn(`[DealerClaimService] Required step ${requiredStep.level} (${requiredStep.name}) was not found in approvers array`); } } } /** * Create participants for claim management workflow * Includes: Initiator, Dealer, Department Lead, Finance Approver * Excludes: System users */ private async createClaimParticipants( requestId: string, initiatorId: string, dealerEmail?: string ): Promise { try { const initiator = await User.findByPk(initiatorId); if (!initiator) { throw new Error('Initiator not found'); } // Get all approval levels to extract approvers const approvalLevels = await ApprovalLevel.findAll({ where: { requestId }, order: [['levelNumber', 'ASC']], }); const participantsToAdd: Array<{ userId: string; userEmail: string; userName: string; participantType: ParticipantType; }> = []; // 1. Add Initiator participantsToAdd.push({ userId: initiatorId, userEmail: initiator.email, userName: initiator.displayName || initiator.email || 'Initiator', participantType: ParticipantType.INITIATOR, }); // 2. Add Dealer (treated as Okta/internal user - sync from Okta if needed) if (dealerEmail && dealerEmail.toLowerCase() !== 'system@royalenfield.com') { let dealerUser = await User.findOne({ where: { email: dealerEmail.toLowerCase() }, }); if (!dealerUser) { logger.info(`[DealerClaimService] Dealer ${dealerEmail} not found in DB for participants, syncing from Okta`); try { dealerUser = await this.userService.ensureUserExists({ email: dealerEmail.toLowerCase(), }) as any; logger.info(`[DealerClaimService] Successfully synced dealer ${dealerEmail} from Okta for participants`); } catch (oktaError: any) { logger.error(`[DealerClaimService] Failed to sync dealer from Okta for participants: ${dealerEmail}`, oktaError); // Don't throw - dealer might be added later, but log the error logger.warn(`[DealerClaimService] Skipping dealer participant creation for ${dealerEmail}`); } } if (dealerUser) { participantsToAdd.push({ userId: dealerUser.userId, userEmail: dealerUser.email, userName: dealerUser.displayName || dealerUser.email || 'Dealer', participantType: ParticipantType.APPROVER, }); } } // 3. Add all approvers from approval levels (excluding system and duplicates) const addedUserIds = new Set([initiatorId]); const systemEmails = ['system@royalenfield.com']; for (const level of approvalLevels) { const approverEmail = (level as any).approverEmail?.toLowerCase(); const approverId = (level as any).approverId; // Skip if system user or already added if ( !approverId || systemEmails.includes(approverEmail || '') || addedUserIds.has(approverId) ) { continue; } // Skip if email is system email if (approverEmail && systemEmails.includes(approverEmail)) { continue; } // Helper function to check if a string is a valid UUID const isValidUUID = (str: string): boolean => { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; return uuidRegex.test(str); }; // Only try to find user if approverId is a valid UUID if (!isValidUUID(approverId)) { logger.warn(`[DealerClaimService] Invalid UUID format for approverId: ${approverId}, skipping participant creation`); continue; } const approverUser = await User.findByPk(approverId); if (approverUser) { participantsToAdd.push({ userId: approverId, userEmail: approverUser.email, userName: approverUser.displayName || approverUser.email || 'Approver', participantType: ParticipantType.APPROVER, }); addedUserIds.add(approverId); } } // Create participants (deduplicate by userId) const participantMap = new Map(); const rolePriority: Record = { 'INITIATOR': 3, 'APPROVER': 2, 'SPECTATOR': 1, }; for (const participantData of participantsToAdd) { const existing = participantMap.get(participantData.userId); if (existing) { // Keep higher priority role const existingPriority = rolePriority[existing.participantType] || 0; const newPriority = rolePriority[participantData.participantType] || 0; if (newPriority > existingPriority) { participantMap.set(participantData.userId, participantData); } } else { participantMap.set(participantData.userId, participantData); } } // Create participant records for (const participantData of participantMap.values()) { await Participant.create({ requestId, userId: participantData.userId, userEmail: participantData.userEmail, userName: participantData.userName, participantType: participantData.participantType, canComment: true, canViewDocuments: true, canDownloadDocuments: true, notificationEnabled: true, addedBy: initiatorId, isActive: true, } as any); } logger.info(`[DealerClaimService] Created ${participantMap.size} participants for claim request ${requestId}`); } catch (error) { logger.error('[DealerClaimService] Error creating participants:', error); // Don't throw - participants are not critical for request creation } } /** * Resolve Department Lead based on initiator's department/manager * If multiple users found with same department, uses the first one */ /** * Resolve Department Lead/Manager by searching Okta using manager's displayName * Flow: * 1. Get manager displayName from initiator's user record * 2. Search Okta directory by displayName * 3. If empty: Return null (no manager found, fallback to old method) * 4. If single: Use that user, create in DB if doesn't exist, return user * 5. If multiple: Throw error with list of users (frontend will show confirmation) * * @param initiator - The user creating the claim request * @returns User object for department lead/manager, or null if not found * @throws Error if multiple managers found (frontend should handle confirmation) */ private async resolveDepartmentLeadFromManager(initiator: User): Promise { try { // Get manager displayName from initiator's user record const managerDisplayName = initiator.manager; // This is the displayName of the manager if (!managerDisplayName) { logger.warn(`[DealerClaimService] Initiator ${initiator.email} has no manager displayName set`); // Return null - caller will handle the error return null; } logger.info(`[DealerClaimService] Searching Okta for manager with displayName: "${managerDisplayName}"`); // Search Okta by displayName const oktaUsers = await this.userService.searchOktaByDisplayName(managerDisplayName); if (oktaUsers.length === 0) { logger.warn(`[DealerClaimService] No reporting manager found in Okta for displayName: "${managerDisplayName}"`); // Return null - caller will handle the error return null; } if (oktaUsers.length === 1) { // Single match - use this user const oktaUser = oktaUsers[0]; const managerEmail = oktaUser.profile.email || oktaUser.profile.login; logger.info(`[DealerClaimService] Found single manager match: ${managerEmail} for displayName: "${managerDisplayName}"`); // Check if user exists in DB, create if doesn't exist const managerUser = await this.userService.ensureUserExists({ userId: oktaUser.id, email: managerEmail, displayName: oktaUser.profile.displayName || `${oktaUser.profile.firstName || ''} ${oktaUser.profile.lastName || ''}`.trim(), firstName: oktaUser.profile.firstName, lastName: oktaUser.profile.lastName, department: oktaUser.profile.department, phone: oktaUser.profile.mobilePhone, }); return managerUser; } // Multiple matches - throw error with list for frontend confirmation const managerOptions = oktaUsers.map(u => ({ userId: 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, })); logger.warn(`[DealerClaimService] Multiple managers found (${oktaUsers.length}) for displayName: "${managerDisplayName}"`); // Create a custom error with the manager options const error: any = new Error(`Multiple reporting managers found. Please select one.`); error.code = 'MULTIPLE_MANAGERS_FOUND'; error.managers = managerOptions; throw error; } catch (error: any) { // If it's our custom multiple managers error, re-throw it if (error.code === 'MULTIPLE_MANAGERS_FOUND') { throw error; } // For other errors, log and fallback to old method logger.error(`[DealerClaimService] Error resolving manager from Okta:`, error); return await this.resolveDepartmentLead(initiator); } } /** * Legacy method: Resolve Department Lead using old logic * Kept as fallback when Okta search fails or manager displayName not set */ private async resolveDepartmentLead(initiator: User): Promise { try { const { Op } = await import('sequelize'); logger.info(`[DealerClaimService] Resolving department lead for initiator: ${initiator.email}, department: ${initiator.department}, manager: ${initiator.manager}`); // Priority 1: Find user with MANAGEMENT role in same department if (initiator.department) { const deptLeads = await User.findAll({ where: { department: initiator.department, role: 'MANAGEMENT' as any, isActive: true, }, order: [['createdAt', 'ASC']], // Get first one if multiple limit: 1, }); if (deptLeads.length > 0) { logger.info(`[DealerClaimService] Found department lead by MANAGEMENT role: ${deptLeads[0].email} for department: ${initiator.department}`); return deptLeads[0]; } else { logger.debug(`[DealerClaimService] No MANAGEMENT role user found in department: ${initiator.department}`); } } else { logger.debug(`[DealerClaimService] Initiator has no department set`); } // Priority 2: Find users with "Department Lead", "Team Lead", "Team Manager", "Group Manager", "Assistant Manager", "Deputy Manager" in designation, same department if (initiator.department) { const leads = await User.findAll({ where: { department: initiator.department, designation: { [Op.or]: [ { [Op.iLike]: '%department lead%' }, { [Op.iLike]: '%departmentlead%' }, { [Op.iLike]: '%dept lead%' }, { [Op.iLike]: '%deptlead%' }, { [Op.iLike]: '%team lead%' }, { [Op.iLike]: '%team manager%' }, { [Op.iLike]: '%group manager%' }, { [Op.iLike]: '%assistant manager%' }, { [Op.iLike]: '%deputy manager%' }, { [Op.iLike]: '%lead%' }, { [Op.iLike]: '%head%' }, { [Op.iLike]: '%manager%' }, ], } as any, isActive: true, }, order: [['createdAt', 'ASC']], // Get first one if multiple limit: 1, }); if (leads.length > 0) { logger.info(`[DealerClaimService] Found lead by designation: ${leads[0].email} (designation: ${leads[0].designation})`); return leads[0]; } } // Priority 3: Use initiator's manager field if (initiator.manager) { const manager = await User.findOne({ where: { email: initiator.manager, isActive: true, }, }); if (manager) { logger.info(`[DealerClaimService] Using initiator's manager as department lead: ${manager.email}`); return manager; } } // Priority 4: Find any user in same department (fallback - use first one) if (initiator.department) { const anyDeptUser = await User.findOne({ where: { department: initiator.department, isActive: true, userId: { [Op.ne]: initiator.userId }, // Exclude initiator }, order: [['createdAt', 'ASC']], }); if (anyDeptUser) { logger.warn(`[DealerClaimService] Using first available user in department as fallback: ${anyDeptUser.email} (designation: ${anyDeptUser.designation}, role: ${anyDeptUser.role})`); return anyDeptUser; } else { logger.debug(`[DealerClaimService] No other users found in department: ${initiator.department}`); } } // Priority 5: Search across all departments for users with "Department Lead" designation logger.debug(`[DealerClaimService] Trying to find any user with "Department Lead" designation...`); const anyDeptLead = await User.findOne({ where: { designation: { [Op.iLike]: '%department lead%', } as any, isActive: true, userId: { [Op.ne]: initiator.userId }, // Exclude initiator }, order: [['createdAt', 'ASC']], }); if (anyDeptLead) { logger.warn(`[DealerClaimService] Found user with "Department Lead" designation across all departments: ${anyDeptLead.email} (department: ${anyDeptLead.department})`); return anyDeptLead; } // Priority 6: Find any user with MANAGEMENT role (across all departments) logger.debug(`[DealerClaimService] Trying to find any user with MANAGEMENT role...`); const anyManagementUser = await User.findOne({ where: { role: 'MANAGEMENT' as any, isActive: true, userId: { [Op.ne]: initiator.userId }, // Exclude initiator }, order: [['createdAt', 'ASC']], }); if (anyManagementUser) { logger.warn(`[DealerClaimService] Found user with MANAGEMENT role across all departments: ${anyManagementUser.email} (department: ${anyManagementUser.department})`); return anyManagementUser; } // Priority 7: Find any user with ADMIN role (across all departments) logger.debug(`[DealerClaimService] Trying to find any user with ADMIN role...`); const anyAdminUser = await User.findOne({ where: { role: 'ADMIN' as any, isActive: true, userId: { [Op.ne]: initiator.userId }, // Exclude initiator }, order: [['createdAt', 'ASC']], }); if (anyAdminUser) { logger.warn(`[DealerClaimService] Found user with ADMIN role as fallback: ${anyAdminUser.email} (department: ${anyAdminUser.department})`); return anyAdminUser; } logger.warn(`[DealerClaimService] Could not resolve department lead for initiator: ${initiator.email} (department: ${initiator.department || 'NOT SET'}, manager: ${initiator.manager || 'NOT SET'})`); logger.warn(`[DealerClaimService] No suitable department lead found. Please ensure:`); logger.warn(`[DealerClaimService] 1. Initiator has a department set: ${initiator.department || 'MISSING'}`); logger.warn(`[DealerClaimService] 2. There is at least one user with MANAGEMENT role in the system`); logger.warn(`[DealerClaimService] 3. Initiator's manager field is set: ${initiator.manager || 'MISSING'}`); return null; } catch (error) { logger.error('[DealerClaimService] Error resolving department lead:', error); return null; } } /** * Resolve Finance Team approver for Step 8 */ private async resolveFinanceApprover(): Promise { try { const { Op } = await import('sequelize'); // Priority 1: Find user with department containing "Finance" and MANAGEMENT role const financeManager = await User.findOne({ where: { department: { [Op.iLike]: '%finance%', } as any, role: 'MANAGEMENT' as any, }, order: [['createdAt', 'DESC']], }); if (financeManager) { logger.info(`[DealerClaimService] Found finance manager: ${financeManager.email}`); return financeManager; } // Priority 2: Find user with designation containing "Finance" or "Accountant" const financeUser = await User.findOne({ where: { [Op.or]: [ { designation: { [Op.iLike]: '%finance%' } as any }, { designation: { [Op.iLike]: '%accountant%' } as any }, ], }, order: [['createdAt', 'DESC']], }); if (financeUser) { logger.info(`[DealerClaimService] Found finance user by designation: ${financeUser.email}`); return financeUser; } // Priority 3: Check admin configurations for finance team email const { getConfigValue } = await import('./configReader.service'); const financeEmail = await getConfigValue('FINANCE_TEAM_EMAIL'); if (financeEmail) { const financeUserByEmail = await User.findOne({ where: { email: financeEmail }, }); if (financeUserByEmail) { logger.info(`[DealerClaimService] Found finance user from config: ${financeEmail}`); return financeUserByEmail; } } logger.warn('[DealerClaimService] Could not resolve finance approver, will use default email'); return null; } catch (error) { logger.error('[DealerClaimService] Error resolving finance approver:', error); return null; } } /** * Get claim details with all related data */ async getClaimDetails(requestId: string): Promise { try { const request = await WorkflowRequest.findByPk(requestId, { include: [ { model: User, as: 'initiator' }, { model: ApprovalLevel, as: 'approvalLevels' }, ] }); if (!request) { throw new Error('Request not found'); } // Handle backward compatibility: workflowType may be undefined in old environments const workflowType = request.workflowType || 'NON_TEMPLATIZED'; if (workflowType !== 'CLAIM_MANAGEMENT') { throw new Error('Request is not a claim management request'); } // Fetch related claim data separately const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId }, include: [ { model: DealerProposalCostItem, as: 'costItems', required: false, separate: true, // Use separate query for ordering order: [['itemOrder', 'ASC']] } ] }); const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId } }); // Fetch Internal Order details const internalOrder = await InternalOrder.findOne({ where: { requestId }, include: [ { model: User, as: 'organizer', required: false } ] }); // Serialize claim details to ensure proper field names let serializedClaimDetails = null; if (claimDetails) { serializedClaimDetails = (claimDetails as any).toJSON ? (claimDetails as any).toJSON() : claimDetails; } // Transform proposal details to include cost items as array let transformedProposalDetails = null; if (proposalDetails) { const proposalData = (proposalDetails as any).toJSON ? (proposalDetails as any).toJSON() : proposalDetails; // Get cost items from separate table (dealer_proposal_cost_items) let costBreakup: any[] = []; if (proposalData.costItems && Array.isArray(proposalData.costItems) && proposalData.costItems.length > 0) { // Use cost items from separate table costBreakup = proposalData.costItems.map((item: any) => ({ description: item.itemDescription || item.description, amount: Number(item.amount) || 0 })); } // Note: costBreakup JSONB field has been removed - only using separate table now transformedProposalDetails = { ...proposalData, costBreakup, // Always return as array for frontend compatibility costItems: proposalData.costItems || [] // Also include raw cost items }; } // Serialize completion details let serializedCompletionDetails = null; if (completionDetails) { serializedCompletionDetails = (completionDetails as any).toJSON ? (completionDetails as any).toJSON() : completionDetails; } // Serialize internal order details let serializedInternalOrder = null; if (internalOrder) { serializedInternalOrder = (internalOrder as any).toJSON ? (internalOrder as any).toJSON() : internalOrder; } // Fetch Budget Tracking details const budgetTracking = await ClaimBudgetTracking.findOne({ where: { requestId } }); // Fetch Invoice details const claimInvoice = await ClaimInvoice.findOne({ where: { requestId } }); // Fetch Credit Note details const claimCreditNote = await ClaimCreditNote.findOne({ where: { requestId } }); // Fetch Completion Expenses (individual expense items) const completionExpenses = await DealerCompletionExpense.findAll({ where: { requestId }, order: [['createdAt', 'ASC']] }); // Serialize new tables let serializedBudgetTracking = null; if (budgetTracking) { serializedBudgetTracking = (budgetTracking as any).toJSON ? (budgetTracking as any).toJSON() : budgetTracking; } let serializedInvoice = null; if (claimInvoice) { serializedInvoice = (claimInvoice as any).toJSON ? (claimInvoice as any).toJSON() : claimInvoice; } let serializedCreditNote = null; if (claimCreditNote) { serializedCreditNote = (claimCreditNote as any).toJSON ? (claimCreditNote as any).toJSON() : claimCreditNote; } // Transform completion expenses to array format for frontend const expensesBreakdown = completionExpenses.map((expense: any) => { const expenseData = expense.toJSON ? expense.toJSON() : expense; return { description: expenseData.description || '', amount: Number(expenseData.amount) || 0 }; }); return { request: (request as any).toJSON ? (request as any).toJSON() : request, claimDetails: serializedClaimDetails, proposalDetails: transformedProposalDetails, completionDetails: serializedCompletionDetails, internalOrder: serializedInternalOrder, // New normalized tables budgetTracking: serializedBudgetTracking, invoice: serializedInvoice, creditNote: serializedCreditNote, completionExpenses: expensesBreakdown, // Array of expense items }; } catch (error) { logger.error('[DealerClaimService] Error getting claim details:', error); throw error; } } /** * Submit dealer proposal (Step 1) */ async submitDealerProposal( requestId: string, proposalData: { proposalDocumentPath?: string; proposalDocumentUrl?: string; costBreakup: any[]; totalEstimatedBudget: number; timelineMode: 'date' | 'days'; expectedCompletionDate?: Date; expectedCompletionDays?: number; dealerComments: string; } ): Promise { try { const request = await WorkflowRequest.findByPk(requestId); if (!request || request.workflowType !== 'CLAIM_MANAGEMENT') { throw new Error('Invalid claim request'); } if (request.currentLevel !== 1) { throw new Error('Proposal can only be submitted at step 1'); } // Save proposal details (costBreakup removed - now using separate table) const [proposal] = await DealerProposalDetails.upsert({ requestId, proposalDocumentPath: proposalData.proposalDocumentPath, proposalDocumentUrl: proposalData.proposalDocumentUrl, // costBreakup field removed - now using dealer_proposal_cost_items table totalEstimatedBudget: proposalData.totalEstimatedBudget, timelineMode: proposalData.timelineMode, expectedCompletionDate: proposalData.expectedCompletionDate, expectedCompletionDays: proposalData.expectedCompletionDays, dealerComments: proposalData.dealerComments, submittedAt: new Date(), }, { returning: true }); // Get proposalId - handle both Sequelize instance and plain object let proposalId = (proposal as any).proposalId || (proposal as any).proposal_id; // If not found, try getDataValue method if (!proposalId && (proposal as any).getDataValue) { proposalId = (proposal as any).getDataValue('proposalId'); } // If still not found, fetch the proposal by requestId if (!proposalId) { const existingProposal = await DealerProposalDetails.findOne({ where: { requestId } }); if (existingProposal) { proposalId = (existingProposal as any).proposalId || (existingProposal as any).proposal_id || ((existingProposal as any).getDataValue ? (existingProposal as any).getDataValue('proposalId') : null); } } if (!proposalId) { throw new Error('Failed to get proposal ID after saving proposal details'); } // Save cost items to separate table (preferred approach) if (proposalData.costBreakup && proposalData.costBreakup.length > 0) { // Delete existing cost items for this proposal (in case of update) await DealerProposalCostItem.destroy({ where: { proposalId } }); // Insert new cost items const costItems = proposalData.costBreakup.map((item: any, index: number) => ({ proposalId, requestId, itemDescription: item.description || item.itemDescription || '', amount: Number(item.amount) || 0, itemOrder: index })); await DealerProposalCostItem.bulkCreate(costItems); logger.info(`[DealerClaimService] Saved ${costItems.length} cost items for proposal ${proposalId}`); } // Update budget tracking with proposal estimate await ClaimBudgetTracking.upsert({ requestId, proposalEstimatedBudget: proposalData.totalEstimatedBudget, proposalSubmittedAt: new Date(), budgetStatus: BudgetStatus.PROPOSED, currency: 'INR', }); // Approve Dealer Proposal Submission step dynamically (by levelName, not hardcoded step number) let dealerProposalLevel = await ApprovalLevel.findOne({ where: { requestId, levelName: 'Dealer Proposal Submission' } }); // Fallback: try to find by levelNumber 1 (for backwards compatibility) if (!dealerProposalLevel) { dealerProposalLevel = await ApprovalLevel.findOne({ where: { requestId, levelNumber: 1 } }); } if (dealerProposalLevel) { await this.approvalService.approveLevel( dealerProposalLevel.levelId, { action: 'APPROVE', comments: 'Dealer proposal submitted' }, 'system', // System approval { ipAddress: null, userAgent: null } ); } logger.info(`[DealerClaimService] Dealer proposal submitted for request: ${requestId}`); } catch (error) { logger.error('[DealerClaimService] Error submitting dealer proposal:', error); throw error; } } /** * Submit dealer completion documents (Step 5) */ async submitCompletionDocuments( requestId: string, completionData: { activityCompletionDate: Date; numberOfParticipants?: number; closedExpenses: any[]; totalClosedExpenses: number; invoicesReceipts?: any[]; attendanceSheet?: any; } ): Promise { try { const request = await WorkflowRequest.findByPk(requestId); // Handle backward compatibility: workflowType may be undefined in old environments const workflowType = request?.workflowType || 'NON_TEMPLATIZED'; if (!request || workflowType !== 'CLAIM_MANAGEMENT') { throw new Error('Invalid claim request'); } // Find the "Dealer Completion Documents" step by levelName (handles step shifts due to additional approvers) const approvalLevels = await ApprovalLevel.findAll({ where: { requestId }, order: [['levelNumber', 'ASC']] }); const dealerCompletionStep = approvalLevels.find((level: any) => { const levelName = (level.levelName || '').toLowerCase(); return levelName.includes('dealer completion') || levelName.includes('completion documents'); }); if (!dealerCompletionStep) { throw new Error('Dealer Completion Documents step not found'); } // Check if current level matches the Dealer Completion Documents step (handles step shifts) if (request.currentLevel !== dealerCompletionStep.levelNumber) { throw new Error(`Completion documents can only be submitted at the Dealer Completion Documents step (currently at step ${request.currentLevel})`); } // Save completion details const [completionDetails] = await DealerCompletionDetails.upsert({ requestId, activityCompletionDate: completionData.activityCompletionDate, numberOfParticipants: completionData.numberOfParticipants, totalClosedExpenses: completionData.totalClosedExpenses, submittedAt: new Date(), }); // Persist individual closed expenses to dealer_completion_expenses const completionId = (completionDetails as any)?.completionId; if (completionData.closedExpenses && completionData.closedExpenses.length > 0) { // Clear existing expenses for this request to avoid duplicates await DealerCompletionExpense.destroy({ where: { requestId } }); const expenseRows = completionData.closedExpenses.map((item: any) => ({ requestId, completionId, description: item.description, amount: item.amount, })); await DealerCompletionExpense.bulkCreate(expenseRows); } // Update budget tracking with closed expenses await ClaimBudgetTracking.upsert({ requestId, closedExpenses: completionData.totalClosedExpenses, closedExpensesSubmittedAt: new Date(), budgetStatus: BudgetStatus.CLOSED, currency: 'INR', }); // Approve Dealer Completion Documents step dynamically (by levelName, not hardcoded step number) let dealerCompletionLevel = await ApprovalLevel.findOne({ where: { requestId, levelName: 'Dealer Completion Documents' } }); // Fallback: try to find by levelNumber 4 (new position after removing system steps) if (!dealerCompletionLevel) { dealerCompletionLevel = await ApprovalLevel.findOne({ where: { requestId, levelNumber: 4 } }); } if (dealerCompletionLevel) { await this.approvalService.approveLevel( dealerCompletionLevel.levelId, { action: 'APPROVE', comments: 'Completion documents submitted' }, 'system', { ipAddress: null, userAgent: null } ); } logger.info(`[DealerClaimService] Completion documents submitted for request: ${requestId}`); } catch (error) { logger.error('[DealerClaimService] Error submitting completion documents:', error); throw error; } } /** * Update IO details (Step 3 - Department Lead) * Validates IO number with SAP and blocks budget */ /** * Update IO details and block amount in SAP * Only stores data when blocking amount > 0 * This method is called when user actually blocks the amount */ async updateIODetails( requestId: string, ioData: { ioNumber: string; ioRemark?: string; availableBalance?: number; blockedAmount?: number; remainingBalance?: number; }, organizedByUserId?: string ): Promise { try { const blockedAmount = ioData.blockedAmount || 0; // If blocking amount > 0, proceed with SAP integration and blocking // If blocking amount is 0 but ioNumber and ioRemark are provided, just save the IO details without blocking if (blockedAmount <= 0) { // Allow saving IO details (ioNumber, ioRemark) even without blocking amount // This is useful when Step 3 is approved but amount hasn't been blocked yet if (ioData.ioNumber && (ioData.ioRemark !== undefined)) { const organizedBy = organizedByUserId || null; // Create or update Internal Order record with just IO details (no blocking) const [internalOrder, created] = await InternalOrder.findOrCreate({ where: { requestId }, defaults: { requestId, ioNumber: ioData.ioNumber, ioRemark: ioData.ioRemark || '', ioAvailableBalance: ioData.availableBalance || 0, ioBlockedAmount: 0, ioRemainingBalance: ioData.remainingBalance || 0, organizedBy: organizedBy || undefined, organizedAt: new Date(), status: IOStatus.PENDING, } }); if (!created) { // Update existing IO record with new IO details // IMPORTANT: When updating existing record, preserve balance fields from previous blocking // Only update ioNumber and ioRemark - don't overwrite balance values await internalOrder.update({ ioNumber: ioData.ioNumber, ioRemark: ioData.ioRemark || '', // Don't update balance fields for existing records - preserve values from previous blocking // Only update organizedBy and organizedAt organizedBy: organizedBy || internalOrder.organizedBy, organizedAt: new Date(), }); logger.info(`[DealerClaimService] IO details updated (preserved existing balance values) for request: ${requestId}`, { ioNumber: ioData.ioNumber, ioRemark: ioData.ioRemark, preservedAvailableBalance: internalOrder.ioAvailableBalance, preservedBlockedAmount: internalOrder.ioBlockedAmount, preservedRemainingBalance: internalOrder.ioRemainingBalance, }); } logger.info(`[DealerClaimService] IO details saved (without blocking) for request: ${requestId}`, { ioNumber: ioData.ioNumber, ioRemark: ioData.ioRemark }); return; // Exit early - no SAP blocking needed } else { throw new Error('Blocked amount must be greater than 0, or ioNumber and ioRemark must be provided'); } } // Validate IO number with SAP const ioValidation = await sapIntegrationService.validateIONumber(ioData.ioNumber); if (!ioValidation.isValid) { throw new Error(`Invalid IO number: ${ioValidation.error || 'IO number not found in SAP'}`); } // Block budget in SAP const request = await WorkflowRequest.findByPk(requestId); const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN'; logger.info(`[DealerClaimService] Blocking budget in SAP:`, { requestId, requestNumber, ioNumber: ioData.ioNumber, amountToBlock: blockedAmount, availableBalance: ioData.availableBalance || ioValidation.availableBalance, }); const blockResult = await sapIntegrationService.blockBudget( ioData.ioNumber, blockedAmount, requestNumber, `Budget block for claim request ${requestNumber}` ); if (!blockResult.success) { throw new Error(`Failed to block budget in SAP: ${blockResult.error}`); } const sapReturnedBlockedAmount = blockResult.blockedAmount; // Extract SAP reference number from blockId (this is the Sap_Reference_no from SAP response) // Only use the actual SAP reference number - don't use any generated fallback const sapDocumentNumber = blockResult.blockId || undefined; const availableBalance = ioData.availableBalance || ioValidation.availableBalance; // Log if SAP reference number was received if (sapDocumentNumber) { logger.info(`[DealerClaimService] ✅ SAP Reference Number received: ${sapDocumentNumber}`); } else { logger.warn(`[DealerClaimService] ⚠️ No SAP Reference Number received from SAP response`); } // Use the amount we REQUESTED for calculation, not what SAP returned // SAP might return a slightly different amount due to rounding, but we calculate based on what we requested // Only use SAP's returned amount if it's significantly different (more than 1 rupee), which would indicate an actual issue const amountDifference = Math.abs(sapReturnedBlockedAmount - blockedAmount); const useSapAmount = amountDifference > 1.0; // Only use SAP's amount if difference is more than 1 rupee const finalBlockedAmount = useSapAmount ? sapReturnedBlockedAmount : blockedAmount; // Log SAP response vs what we sent logger.info(`[DealerClaimService] SAP block result:`, { requestedAmount: blockedAmount, sapReturnedBlockedAmount: sapReturnedBlockedAmount, sapReturnedRemainingBalance: blockResult.remainingBalance, sapDocumentNumber: sapDocumentNumber, // SAP reference number from response availableBalance, amountDifference, usingSapAmount: useSapAmount, finalBlockedAmountUsed: finalBlockedAmount, }); // Warn if SAP blocked a significantly different amount than requested if (amountDifference > 0.01) { if (amountDifference > 1.0) { logger.warn(`[DealerClaimService] ⚠️ Significant amount mismatch! Requested: ${blockedAmount}, SAP blocked: ${sapReturnedBlockedAmount}, Difference: ${amountDifference}`); } else { logger.info(`[DealerClaimService] Minor amount difference (likely rounding): Requested: ${blockedAmount}, SAP returned: ${sapReturnedBlockedAmount}, Using requested amount for calculation`); } } // Calculate remaining balance: availableBalance - requestedAmount // IMPORTANT: Use the amount we REQUESTED, not SAP's returned amount (unless SAP blocked significantly different amount) // This ensures accuracy: remaining = available - requested const calculatedRemainingBalance = availableBalance - finalBlockedAmount; // Only use SAP's value if it's valid AND matches our calculation (within 1 rupee tolerance) // This is a safety check - if SAP's value is way off, use our calculation const sapRemainingBalance = blockResult.remainingBalance; const sapValueIsValid = sapRemainingBalance > 0 && sapRemainingBalance <= availableBalance && Math.abs(sapRemainingBalance - calculatedRemainingBalance) < 1; const remainingBalance = sapValueIsValid ? sapRemainingBalance : calculatedRemainingBalance; // Ensure remaining balance is not negative const finalRemainingBalance = Math.max(0, remainingBalance); // Warn if SAP's value doesn't match our calculation if (!sapValueIsValid && sapRemainingBalance !== calculatedRemainingBalance) { logger.warn(`[DealerClaimService] ⚠️ SAP returned invalid remaining balance (${sapRemainingBalance}), using calculated value (${calculatedRemainingBalance})`); } logger.info(`[DealerClaimService] Budget blocking calculation:`, { availableBalance, blockedAmount: finalBlockedAmount, sapRemainingBalance, calculatedRemainingBalance, finalRemainingBalance }); // Get the user who is blocking the IO (current user) const organizedBy = organizedByUserId || null; // Round amounts to 2 decimal places for database storage (avoid floating point precision issues) const roundedAvailableBalance = Math.round(availableBalance * 100) / 100; const roundedBlockedAmount = Math.round(finalBlockedAmount * 100) / 100; const roundedRemainingBalance = Math.round(finalRemainingBalance * 100) / 100; // Create or update Internal Order record (only when blocking) const ioRecordData = { requestId, ioNumber: ioData.ioNumber, ioRemark: ioData.ioRemark || '', ioAvailableBalance: roundedAvailableBalance, ioBlockedAmount: roundedBlockedAmount, ioRemainingBalance: roundedRemainingBalance, sapDocumentNumber: sapDocumentNumber, // Store SAP reference number organizedBy: organizedBy || undefined, organizedAt: new Date(), status: IOStatus.BLOCKED, }; logger.info(`[DealerClaimService] Storing IO details in database:`, { ioNumber: ioData.ioNumber, ioAvailableBalance: availableBalance, ioBlockedAmount: finalBlockedAmount, ioRemainingBalance: finalRemainingBalance, sapDocumentNumber: sapDocumentNumber, requestId }); const [internalOrder, created] = await InternalOrder.findOrCreate({ where: { requestId }, defaults: ioRecordData }); if (!created) { // Update existing IO record - explicitly update all fields including remainingBalance logger.info(`[DealerClaimService] Updating existing IO record for request: ${requestId}`); logger.info(`[DealerClaimService] Update data:`, { ioRemainingBalance: ioRecordData.ioRemainingBalance, ioBlockedAmount: ioRecordData.ioBlockedAmount, ioAvailableBalance: ioRecordData.ioAvailableBalance, sapDocumentNumber: ioRecordData.sapDocumentNumber }); // Explicitly update all fields to ensure remainingBalance is saved const updateResult = await internalOrder.update({ ioNumber: ioRecordData.ioNumber, ioRemark: ioRecordData.ioRemark, ioAvailableBalance: ioRecordData.ioAvailableBalance, ioBlockedAmount: ioRecordData.ioBlockedAmount, ioRemainingBalance: ioRecordData.ioRemainingBalance, // Explicitly ensure this is updated sapDocumentNumber: ioRecordData.sapDocumentNumber, // Update SAP document number organizedBy: ioRecordData.organizedBy, organizedAt: ioRecordData.organizedAt, status: ioRecordData.status }); logger.info(`[DealerClaimService] Update result:`, updateResult ? 'Success' : 'Failed'); } else { logger.info(`[DealerClaimService] Created new IO record for request: ${requestId}`); } // Verify what was actually saved - reload from database await internalOrder.reload(); const savedRemainingBalance = internalOrder.ioRemainingBalance; logger.info(`[DealerClaimService] ✅ IO record after save (verified from database):`, { ioId: internalOrder.ioId, ioNumber: internalOrder.ioNumber, ioAvailableBalance: internalOrder.ioAvailableBalance, ioBlockedAmount: internalOrder.ioBlockedAmount, ioRemainingBalance: savedRemainingBalance, expectedRemainingBalance: finalRemainingBalance, match: savedRemainingBalance === finalRemainingBalance || Math.abs((savedRemainingBalance || 0) - finalRemainingBalance) < 0.01, status: internalOrder.status }); // Warn if remaining balance doesn't match if (Math.abs((savedRemainingBalance || 0) - finalRemainingBalance) >= 0.01) { logger.error(`[DealerClaimService] ⚠️ WARNING: Remaining balance mismatch! Expected: ${finalRemainingBalance}, Saved: ${savedRemainingBalance}`); } // Update budget tracking with blocked amount await ClaimBudgetTracking.upsert({ requestId, ioBlockedAmount: finalBlockedAmount, ioBlockedAt: new Date(), budgetStatus: BudgetStatus.BLOCKED, currency: 'INR', }); logger.info(`[DealerClaimService] IO blocked for request: ${requestId}`, { ioNumber: ioData.ioNumber, blockedAmount: finalBlockedAmount, availableBalance, remainingBalance: finalRemainingBalance }); } catch (error) { logger.error('[DealerClaimService] Error blocking IO:', error); throw error; } } /** * Update e-invoice details (Step 7) * Generates e-invoice via DMS integration */ async updateEInvoiceDetails( requestId: string, invoiceData?: { eInvoiceNumber?: string; eInvoiceDate?: Date; dmsNumber?: string; amount?: number; description?: string; } ): Promise { try { const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); if (!claimDetails) { throw new Error('Claim details not found'); } const budgetTracking = await ClaimBudgetTracking.findOne({ where: { requestId } }); const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId } }); const internalOrder = await InternalOrder.findOne({ where: { requestId } }); const claimInvoice = await ClaimInvoice.findOne({ where: { requestId } }); const request = await WorkflowRequest.findByPk(requestId); if (!request) { throw new Error('Workflow request not found'); } const workflowType = (request as any).workflowType; if (workflowType !== 'CLAIM_MANAGEMENT') { throw new Error('This endpoint is only for claim management workflows'); } const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN'; // If invoice data not provided, generate via DMS if (!invoiceData?.eInvoiceNumber) { const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } }); const invoiceAmount = invoiceData?.amount || proposalDetails?.totalEstimatedBudget || budgetTracking?.proposalEstimatedBudget || budgetTracking?.initialEstimatedBudget || 0; const invoiceResult = await dmsIntegrationService.generateEInvoice({ requestNumber, dealerCode: claimDetails.dealerCode, dealerName: claimDetails.dealerName, amount: invoiceAmount, description: invoiceData?.description || `E-Invoice for claim request ${requestNumber}`, ioNumber: internalOrder?.ioNumber || undefined, }); if (!invoiceResult.success) { throw new Error(`Failed to generate e-invoice: ${invoiceResult.error}`); } await ClaimInvoice.upsert({ requestId, invoiceNumber: invoiceResult.eInvoiceNumber, invoiceDate: invoiceResult.invoiceDate || new Date(), dmsNumber: invoiceResult.dmsNumber, amount: invoiceAmount, status: 'GENERATED', generatedAt: new Date(), description: invoiceData?.description || `E-Invoice for claim request ${requestNumber}`, }); logger.info(`[DealerClaimService] E-Invoice generated via DMS for request: ${requestId}`, { eInvoiceNumber: invoiceResult.eInvoiceNumber, dmsNumber: invoiceResult.dmsNumber }); } else { // Manual entry - just update the fields await ClaimInvoice.upsert({ requestId, invoiceNumber: invoiceData.eInvoiceNumber, invoiceDate: invoiceData.eInvoiceDate || new Date(), dmsNumber: invoiceData.dmsNumber, amount: invoiceData.amount, status: 'UPDATED', generatedAt: new Date(), description: invoiceData.description, }); logger.info(`[DealerClaimService] E-Invoice details manually updated for request: ${requestId}`); } // Check if Requestor Claim Approval is approved - if not, approve it first // Find dynamically by levelName (handles step shifts due to additional approvers) const approvalLevels = await ApprovalLevel.findAll({ where: { requestId }, order: [['levelNumber', 'ASC']] }); let requestorClaimLevel = approvalLevels.find((level: any) => { const levelName = (level.levelName || '').toLowerCase(); return levelName.includes('requestor') && (levelName.includes('claim') || levelName.includes('approval')); }); // Fallback: try to find by levelNumber 5 (new position after removing system steps) // But only if no match found by name (handles edge cases) if (!requestorClaimLevel) { requestorClaimLevel = approvalLevels.find((level: any) => level.levelNumber === 5); } // Validate that we're at the Requestor Claim Approval step before allowing DMS push if (requestorClaimLevel && request.currentLevel !== requestorClaimLevel.levelNumber) { throw new Error(`Cannot push to DMS. Request is currently at step ${request.currentLevel}, but Requestor Claim Approval is at step ${requestorClaimLevel.levelNumber}. Please complete all previous steps first.`); } if (requestorClaimLevel && requestorClaimLevel.status !== ApprovalStatus.APPROVED) { logger.info(`[DealerClaimService] Requestor Claim Approval not approved yet. Auto-approving for request ${requestId}`); // Auto-approve Requestor Claim Approval await this.approvalService.approveLevel( requestorClaimLevel.levelId, { action: 'APPROVE', comments: 'Auto-approved when pushing to DMS. E-Invoice generation will be logged as activity.' }, 'system', { ipAddress: null, userAgent: 'System Auto-Process' } ); logger.info(`[DealerClaimService] Requestor Claim Approval approved. E-Invoice generation will be logged as activity when DMS webhook is received.`); } else { // Requestor Claim Approval already approved logger.info(`[DealerClaimService] Requestor Claim Approval already approved. E-Invoice generation will be logged as activity when DMS webhook is received.`); } // Log E-Invoice generation as activity (no approval level needed) await activityService.log({ requestId, type: 'status_change', user: { userId: 'system', name: 'System Auto-Process' }, timestamp: new Date().toISOString(), action: 'E-Invoice Generation Initiated', details: `E-Invoice generation initiated via DMS integration for request ${requestNumber}. Waiting for DMS webhook confirmation.`, }); } catch (error) { logger.error('[DealerClaimService] Error updating e-invoice details:', error); throw error; } } /** * Log E-Invoice Generation as activity (no longer an approval step) * This method logs the e-invoice generation activity when invoice is generated via DMS webhook */ async logEInvoiceGenerationActivity(requestId: string, invoiceNumber?: string): Promise { try { logger.info(`[DealerClaimService] Logging E-Invoice Generation activity for request ${requestId}`); const request = await WorkflowRequest.findByPk(requestId); if (!request) { throw new Error(`Workflow request ${requestId} not found`); } const workflowType = (request as any).workflowType; if (workflowType !== 'CLAIM_MANAGEMENT') { logger.warn(`[DealerClaimService] Skipping E-Invoice activity logging - not a claim management workflow (type: ${workflowType})`); return; } const requestNumber = (request as any).requestNumber || (request as any).request_number || 'UNKNOWN'; const claimInvoice = await ClaimInvoice.findOne({ where: { requestId } }); const finalInvoiceNumber = invoiceNumber || claimInvoice?.invoiceNumber || 'N/A'; // Log E-Invoice Generation as activity await activityService.log({ requestId, type: 'status_change', user: { userId: 'system', name: 'System Auto-Process' }, timestamp: new Date().toISOString(), action: 'E-Invoice Generated', details: `E-Invoice generated via DMS. Invoice Number: ${finalInvoiceNumber}. Request: ${requestNumber}`, }); logger.info(`[DealerClaimService] E-Invoice Generation activity logged for request ${requestId} (Invoice: ${finalInvoiceNumber})`); } catch (error) { logger.error(`[DealerClaimService] Error logging E-Invoice Generation activity for request ${requestId}:`, error); // Don't throw - activity logging is not critical } } /** * Update credit note details (Step 8) * Generates credit note via DMS integration */ async updateCreditNoteDetails( requestId: string, creditNoteData?: { creditNoteNumber?: string; creditNoteDate?: Date; creditNoteAmount?: number; reason?: string; description?: string; } ): Promise { try { const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); if (!claimDetails) { throw new Error('Claim details not found'); } const budgetTracking = await ClaimBudgetTracking.findOne({ where: { requestId } }); const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId } }); const claimInvoice = await ClaimInvoice.findOne({ where: { requestId } }); const request = await WorkflowRequest.findByPk(requestId); const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN'; // If credit note data not provided, generate via DMS if (!creditNoteData?.creditNoteNumber) { const creditNoteAmount = creditNoteData?.creditNoteAmount || budgetTracking?.closedExpenses || completionDetails?.totalClosedExpenses || 0; // Only generate via DMS if invoice exists, otherwise allow manual entry if (claimInvoice?.invoiceNumber) { const creditNoteResult = await dmsIntegrationService.generateCreditNote({ requestNumber, eInvoiceNumber: claimInvoice.invoiceNumber, dealerCode: claimDetails.dealerCode, dealerName: claimDetails.dealerName, amount: creditNoteAmount, reason: creditNoteData?.reason || 'Claim settlement', description: creditNoteData?.description || `Credit note for claim request ${requestNumber}`, }); if (!creditNoteResult.success) { throw new Error(`Failed to generate credit note: ${creditNoteResult.error}`); } await ClaimCreditNote.upsert({ requestId, invoiceId: claimInvoice.invoiceId, creditNoteNumber: creditNoteResult.creditNoteNumber, creditNoteDate: creditNoteResult.creditNoteDate || new Date(), creditNoteAmount: creditNoteResult.creditNoteAmount, status: 'GENERATED', confirmedAt: new Date(), reason: creditNoteData?.reason || 'Claim settlement', description: creditNoteData?.description || `Credit note for claim request ${requestNumber}`, }); logger.info(`[DealerClaimService] Credit note generated via DMS for request: ${requestId}`, { creditNoteNumber: creditNoteResult.creditNoteNumber, creditNoteAmount: creditNoteResult.creditNoteAmount }); } else { // No invoice exists - create credit note manually without invoice link await ClaimCreditNote.upsert({ requestId, invoiceId: undefined, // No invoice linked creditNoteNumber: undefined, // Will be set manually later creditNoteDate: creditNoteData?.creditNoteDate || new Date(), creditNoteAmount: creditNoteAmount, status: 'PENDING', reason: creditNoteData?.reason || 'Claim settlement', description: creditNoteData?.description || `Credit note for claim request ${requestNumber} (no invoice)`, }); logger.info(`[DealerClaimService] Credit note created without invoice for request: ${requestId}`); } } else { // Manual entry - just update the fields await ClaimCreditNote.upsert({ requestId, invoiceId: claimInvoice?.invoiceId || undefined, // Allow undefined if no invoice creditNoteNumber: creditNoteData.creditNoteNumber, creditNoteDate: creditNoteData.creditNoteDate || new Date(), creditNoteAmount: creditNoteData.creditNoteAmount, status: 'UPDATED', confirmedAt: new Date(), reason: creditNoteData?.reason, description: creditNoteData?.description, }); logger.info(`[DealerClaimService] Credit note details manually updated for request: ${requestId}`); } } catch (error) { logger.error('[DealerClaimService] Error updating credit note details:', error); throw error; } } /** * Send credit note to dealer and auto-approve Step 8 * This method sends the credit note to the dealer via email/notification and auto-approves Step 8 */ async sendCreditNoteToDealer(requestId: string, userId: string): Promise { try { logger.info(`[DealerClaimService] Sending credit note to dealer for request ${requestId}`); // Get credit note details const creditNote = await ClaimCreditNote.findOne({ where: { requestId } }); if (!creditNote) { throw new Error('Credit note not found. Please ensure credit note is generated before sending to dealer.'); } // Get claim details for dealer information const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); if (!claimDetails) { throw new Error('Claim details not found'); } // Get workflow request const request = await WorkflowRequest.findByPk(requestId); if (!request) { throw new Error('Workflow request not found'); } const workflowType = (request as any).workflowType; if (workflowType !== 'CLAIM_MANAGEMENT') { throw new Error('This operation is only available for claim management workflows'); } // Credit Note Confirmation is now an activity log only, not an approval step const requestNumber = (request as any).requestNumber || (request as any).request_number || 'UNKNOWN'; // Update credit note status to CONFIRMED await creditNote.update({ status: 'CONFIRMED', confirmedAt: new Date(), confirmedBy: userId, }); // Log Credit Note Confirmation as activity (no approval step needed) await activityService.log({ requestId, type: 'status_change', user: { userId: userId, name: 'Finance Team' }, timestamp: new Date().toISOString(), action: 'Credit Note Confirmed and Sent', details: `Credit note sent to dealer. Credit Note Number: ${creditNote.creditNoteNumber || 'N/A'}. Credit Note Amount: ₹${creditNote.creditNoteAmount || 0}. Request: ${requestNumber}`, }); // Send notification to dealer (you can implement email service here) logger.info(`[DealerClaimService] Credit note sent to dealer`, { requestId, creditNoteNumber: creditNote.creditNoteNumber, dealerEmail: claimDetails.dealerEmail, dealerName: claimDetails.dealerName, }); // TODO: Implement email service to send credit note to dealer // await emailService.sendCreditNoteToDealer({ // dealerEmail: claimDetails.dealerEmail, // dealerName: claimDetails.dealerName, // creditNoteNumber: creditNote.creditNoteNumber, // creditNoteAmount: creditNote.creditNoteAmount, // requestNumber: requestNumber, // }); } catch (error) { logger.error('[DealerClaimService] Error sending credit note to dealer:', error); throw error; } } /** * Process Activity Creation (now activity log only, not an approval step) * Creates activity confirmation and sends emails to dealer, requestor, and department lead * Logs activity instead of creating/approving approval level */ async processActivityCreation(requestId: string): Promise { try { logger.info(`[DealerClaimService] Processing Activity Creation for request ${requestId}`); // Get workflow request const request = await WorkflowRequest.findByPk(requestId); if (!request) { throw new Error(`Workflow request ${requestId} not found`); } // Verify this is a claim management workflow const workflowType = (request as any).workflowType; if (workflowType !== 'CLAIM_MANAGEMENT') { logger.warn(`[DealerClaimService] Skipping Activity Creation - not a claim management workflow (type: ${workflowType})`); return; } // Get claim details const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); if (!claimDetails) { throw new Error(`Claim details not found for request ${requestId}`); } // Get participants for email notifications const initiator = await User.findByPk((request as any).initiatorId); const dealerUser = claimDetails.dealerEmail ? await User.findOne({ where: { email: claimDetails.dealerEmail } }) : null; // Get department lead dynamically (by levelName, not hardcoded step number) let deptLeadLevel = await ApprovalLevel.findOne({ where: { requestId, levelName: 'Department Lead Approval' } }); // Fallback: try to find by levelNumber 3 (for backwards compatibility) if (!deptLeadLevel) { deptLeadLevel = await ApprovalLevel.findOne({ where: { requestId, levelNumber: 3 } }); } const departmentLead = deptLeadLevel?.approverId ? await User.findByPk(deptLeadLevel.approverId) : null; const requestNumber = (request as any).requestNumber || (request as any).request_number || 'UNKNOWN'; const activityName = claimDetails.activityName || 'Activity'; const activityType = claimDetails.activityType || 'N/A'; // Prepare email recipients const emailRecipients: string[] = []; const userIdsForNotification: string[] = []; // Add initiator if (initiator) { emailRecipients.push(initiator.email); userIdsForNotification.push(initiator.userId); } // Add dealer if (dealerUser) { emailRecipients.push(dealerUser.email); userIdsForNotification.push(dealerUser.userId); } else if (claimDetails.dealerEmail) { emailRecipients.push(claimDetails.dealerEmail); } // Add department lead if (departmentLead) { emailRecipients.push(departmentLead.email); userIdsForNotification.push(departmentLead.userId); } // Send activity confirmation emails const emailSubject = `Activity Created: ${activityName} - ${requestNumber}`; const emailBody = `Activity "${activityName}" (${activityType}) has been created successfully for request ${requestNumber}. IO confirmation to be made.`; // Send notifications to users in the system if (userIdsForNotification.length > 0) { await notificationService.sendToUsers(userIdsForNotification, { title: emailSubject, body: emailBody, requestId, requestNumber, url: `/request/${requestNumber}`, type: 'activity_created', priority: 'MEDIUM', actionRequired: false }); } // Log Activity Creation as activity (no approval level needed) await activityService.log({ requestId, type: 'status_change', user: { userId: 'system', name: 'System Auto-Process' }, timestamp: new Date().toISOString(), action: 'Activity Created', details: `Activity "${activityName}" created. Activity confirmation email auto-triggered to dealer, requestor, and department lead. IO confirmation to be made.`, }); logger.info(`[DealerClaimService] Activity Creation logged as activity for request ${requestId}. Activity creation completed.`); } catch (error) { logger.error(`[DealerClaimService] Error processing Step 4 activity creation for request ${requestId}:`, error); throw error; } } }