import { Op } from 'sequelize'; import { sequelize } from '../config/database'; import logger from '../utils/logger'; 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 { ClaimInvoiceItem } from '../models/ClaimInvoiceItem'; import { DealerCompletionExpense } from '../models/DealerCompletionExpense'; import { ApprovalLevel } from '../models/ApprovalLevel'; import { Participant } from '../models/Participant'; import { User } from '../models/User'; import { DealerClaimHistory, SnapshotType } from '../models/DealerClaimHistory'; import { ActivityType } from '../models/ActivityType'; import { Document } from '../models/Document'; import { Dealer } from '../models/Dealer'; 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 { pwcIntegrationService } from './pwcIntegration.service'; import { wfmFileService } from './wfmFile.service'; import { findDealerLocally } from './dealer.service'; import { notificationService } from './notification.service'; import { activityService } from './activity.service'; import { UserService } from './user.service'; import { dmsIntegrationService } from './dmsIntegration.service'; import { validateDealerUser } from './userEnrichment.service'; // findDealerLocally removed (duplicate) const appDomain = process.env.APP_DOMAIN || 'royalenfield.com'; let workflowServiceInstance: any; let approvalServiceInstance: any; let userServiceInstance: any; /** * Dealer Claim Service * Handles business logic specific to dealer claim management workflow */ export class DealerClaimService { private getWorkflowService(): WorkflowService { if (!workflowServiceInstance) { const { WorkflowService } = require('./workflow.service'); workflowServiceInstance = new WorkflowService(); } return workflowServiceInstance; } private getApprovalService(): DealerClaimApprovalService { if (!approvalServiceInstance) { const { DealerClaimApprovalService } = require('./dealerClaimApproval.service'); approvalServiceInstance = new DealerClaimApprovalService(); } return approvalServiceInstance; } private getUserService(): UserService { if (!userServiceInstance) { const { UserService } = require('./user.service'); userServiceInstance = new UserService(); } return userServiceInstance; } /** * 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 { // 0. Validate Dealer User (jobTitle='Dealer' and employeeId=dealerCode) logger.info(`[DealerClaimService] Validating dealer for code: ${claimData.dealerCode}`); const dealerUser = await validateDealerUser(claimData.dealerCode); // Update claim data with validated dealer user details if not provided claimData.dealerName = dealerUser.displayName || claimData.dealerName; claimData.dealerEmail = dealerUser.email || claimData.dealerEmail; claimData.dealerPhone = (dealerUser as any).mobilePhone || (dealerUser as any).phone || claimData.dealerPhone; // 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'); } // Fallback: Enrichment from local dealer table if data is missing or incomplete // We still keep this as a secondary fallback, but validation above is primary const localDealer = await findDealerLocally(claimData.dealerCode, claimData.dealerEmail); if (localDealer) { logger.info(`[DealerClaimService] Enriched claim request with local dealer data: ${localDealer.dealerCode}`); claimData.dealerName = claimData.dealerName || localDealer.dealerName; claimData.dealerEmail = claimData.dealerEmail || localDealer.dealerPrincipalEmailId || localDealer.email; claimData.dealerPhone = claimData.dealerPhone || localDealer.phone; } // 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.'); } // 1. Transform approvers and ensure users exist in database const userService = this.getUserService(); const transformedLevels = []; // Define step names mapping const stepNames: Record = { 1: 'Dealer Proposal Submission', 2: 'Requestor Evaluation', 3: 'Department Lead Approval', 4: 'Dealer Completion Documents', 5: 'Requestor Claim Approval' }; for (const a of claimData.approvers) { let approverUserId = a.userId; // Determine level name - use mapped name or fallback to "Step X" let levelName = stepNames[a.level] || `Step ${a.level}`; // If this is a Dealer-specific step (Step 1 or Step 4), ensure we use the validated dealerUser if (a.level === 1 || a.level === 4) { logger.info(`[DealerClaimService] Assigning validated dealer user to ${levelName} (Step ${a.level})`); approverUserId = dealerUser.userId; a.email = dealerUser.email; a.name = dealerUser.displayName || dealerUser.email; } // If userId missing, ensure user exists by email if (!approverUserId && a.email) { try { const user = await userService.ensureUserExists({ email: a.email }); approverUserId = user.userId; } catch (e) { logger.warn(`[DealerClaimService] Could not resolve user for email ${a.email}:`, e); // If it fails, keep it empty and let the workflow service handle it (or fail early) } } let tatHours = 24; // Default if (a.tat) { const val = typeof a.tat === 'number' ? a.tat : parseInt(a.tat as string); tatHours = a.tatType === 'days' ? val * 24 : val; } // Already determined levelName above // If it's an additional approver (not one of the standard steps), label it clearly // Note: The frontend might send extra steps if approvers are added dynamically // But for initial creation, we usually stick to the standard flow transformedLevels.push({ levelNumber: a.level, levelName: levelName, approverId: approverUserId || '', // Fallback to empty string if still not resolved approverEmail: a.email, approverName: a.name || a.email, tatHours: tatHours, // New 5-step flow: Level 5 is the final approver (Requestor Claim Approval) isFinalApprover: a.level === 5 }); } // 2. Transform participants const transformedParticipants = [ { userId: userId, userName: initiator.displayName || initiator.email, userEmail: initiator.email, participantType: 'INITIATOR' as any, } ]; // Add approvers as participants for (const level of transformedLevels) { if (level.approverId) { transformedParticipants.push({ userId: level.approverId, userName: level.approverName, userEmail: level.approverEmail, participantType: 'APPROVER' as any }); } } const workflowService = this.getWorkflowService(); const workflowRequest = await workflowService.createWorkflow(userId, { templateType: 'DEALER CLAIM' as any, workflowType: 'CLAIM_MANAGEMENT', title: `${claimData.activityName} - Claim Request`, description: claimData.requestDescription, priority: Priority.STANDARD, approvalLevels: transformedLevels, participants: transformedParticipants, isDraft: false } as any); // 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', }); // Redundant level creation removed - handled by workflowService.createWorkflow // Redundant TAT scheduling removed - handled by workflowService.createWorkflow 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 systemEmails = [`system@${appDomain}`]; const financeEmails = [`finance@${appDomain}`]; const isSystemEmail = systemEmails.includes(approver.email) || financeEmails.includes(approver.email); 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 { const userService = this.getUserService(); user = await 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@${appDomain}`) { 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 { const userService = this.getUserService(); dealerUser = await 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@${appDomain}`]; 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 userService = this.getUserService(); const oktaUsers = await 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 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 internalOrders = await InternalOrder.findAll({ where: { requestId }, include: [ { model: User, as: 'organizer', required: false } ], order: [['createdAt', 'ASC']] }); // Serialize claim details to ensure proper field names let serializedClaimDetails = null; if (claimDetails) { serializedClaimDetails = (claimDetails as any).toJSON ? (claimDetails as any).toJSON() : claimDetails; // Fetch default GST rate and taxation type from ActivityType table try { const activityTypeTitle = (claimDetails.activityType || '').trim(); logger.info(`[DealerClaimService] Resolving taxationType for activity: "${activityTypeTitle}"`); let activity = await ActivityType.findOne({ where: { title: activityTypeTitle } }); // Fallback 1: Try normalized title (handling en-dash vs hyphen) if (!activity && activityTypeTitle) { const normalizedTitle = activityTypeTitle.replace(/–/g, '-'); if (normalizedTitle !== activityTypeTitle) { activity = await ActivityType.findOne({ where: { title: normalizedTitle } }); } } // Fallback 2: Handle cases where activity is found but taxationType is missing, or activity not found if (activity && activity.taxationType) { serializedClaimDetails.defaultGstRate = Number(activity.gstRate) || 18; serializedClaimDetails.taxationType = activity.taxationType; logger.info(`[DealerClaimService] Resolved from ActivityType record: ${activity.taxationType}`); } else { // Infer from title if record is missing or incomplete const isNonGst = activityTypeTitle.toLowerCase().includes('non'); serializedClaimDetails.taxationType = isNonGst ? 'Non GST' : 'GST'; serializedClaimDetails.defaultGstRate = isNonGst ? 0 : (activity ? (Number(activity.gstRate) || 18) : 18); logger.info(`[DealerClaimService] Inferred taxationType from title: ${serializedClaimDetails.taxationType} (Activity record ${activity ? 'found but missing taxationType' : 'not found'})`); } } catch (error) { logger.warn(`[DealerClaimService] Error fetching activity type for ${claimDetails.activityType}:`, error); serializedClaimDetails.defaultGstRate = 18; serializedClaimDetails.taxationType = 'GST'; // Safe default } serializedClaimDetails.internalOrders = internalOrders.map(io => (io as any).toJSON ? (io as any).toJSON() : io); // Maintain backward compatibility for single internalOrder field (using first one) serializedClaimDetails.internalOrder = internalOrders.length > 0 ? (internalOrders[0] as any).toJSON ? (internalOrders[0] as any).toJSON() : internalOrders[0] : null; // Fetch dealer details (GSTIN, State, City) from dealers table / external API enrichment try { const dealer = await findDealerLocally(claimDetails.dealerCode); if (dealer) { serializedClaimDetails.dealerGstin = dealer.gstin || null; serializedClaimDetails.dealerGSTIN = dealer.gstin || null; // Backward compatibility serializedClaimDetails.dealerState = dealer.state || null; serializedClaimDetails.dealerCity = dealer.city || null; logger.info(`[DealerClaimService] Enriched claim details with dealer info for ${claimDetails.dealerCode}: GSTIN=${dealer.gstin}, State=${dealer.state}`); } } catch (dealerError) { logger.warn(`[DealerClaimService] Error fetching dealer details for ${claimDetails.dealerCode}:`, dealerError); } } // 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, quantity: Number(item.quantity) || 1, hsnCode: item.hsnCode || '', gstRate: Number(item.gstRate) || 0, gstAmt: Number(item.gstAmt) || 0, cgstRate: Number(item.cgstRate) || 0, cgstAmt: Number(item.cgstAmt) || 0, sgstRate: Number(item.sgstRate) || 0, sgstAmt: Number(item.sgstAmt) || 0, igstRate: Number(item.igstRate) || 0, igstAmt: Number(item.igstAmt) || 0, utgstRate: Number(item.utgstRate) || 0, utgstAmt: Number(item.utgstAmt) || 0, cessRate: Number(item.cessRate) || 0, cessAmt: Number(item.cessAmt) || 0, totalAmt: Number(item.totalAmt) || 0, isService: !!item.isService })); } // 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 const serializedInternalOrders = internalOrders.map(io => (io as any).toJSON ? (io as any).toJSON() : io); // For backward compatibility, also provide serializedInternalOrder (first one) const serializedInternalOrder = serializedInternalOrders.length > 0 ? serializedInternalOrders[0] : null; // 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, gstRate: Number(expenseData.gstRate) || 0, gstAmt: Number(expenseData.gstAmt) || 0, cgstAmt: Number(expenseData.cgstAmt) || 0, sgstAmt: Number(expenseData.sgstAmt) || 0, igstAmt: Number(expenseData.igstAmt) || 0, totalAmt: Number(expenseData.totalAmt) || 0, expenseDate: expenseData.expenseDate }; }); return { request: (request as any).toJSON ? (request as any).toJSON() : request, claimDetails: serializedClaimDetails, proposalDetails: transformedProposalDetails, completionDetails: serializedCompletionDetails, internalOrder: serializedInternalOrder, internalOrders: serializedInternalOrders, // Return full list for UI // 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; }, dealerUserId?: string // Optional dealer user ID for history tracking ): Promise { try { const request = await WorkflowRequest.findByPk(requestId); if (!request || request.workflowType !== 'CLAIM_MANAGEMENT') { throw new Error('Invalid claim request'); } // Get dealer user ID if not provided - try to find by dealer email from claim details let actualDealerUserId: string | null = dealerUserId || null; if (!actualDealerUserId) { const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); if (claimDetails?.dealerEmail) { const dealerUser = await User.findOne({ where: { email: claimDetails.dealerEmail } }); actualDealerUserId = dealerUser?.userId || null; } } 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'); } // Clear 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, quantity: Number(item.quantity) || 1, hsnCode: item.hsnCode || '', gstRate: Number(item.gstRate) || 0, gstAmt: Number(item.gstAmt) || 0, cgstRate: Number(item.cgstRate) || 0, cgstAmt: Number(item.cgstAmt) || 0, sgstRate: Number(item.sgstRate) || 0, sgstAmt: Number(item.sgstAmt) || 0, igstRate: Number(item.igstRate) || 0, igstAmt: Number(item.igstAmt) || 0, utgstRate: Number(item.utgstRate) || 0, utgstAmt: Number(item.utgstAmt) || 0, cessRate: Number(item.cessRate) || 0, cessAmt: Number(item.cessAmt) || 0, totalAmt: Number(item.totalAmt) || Number(item.amount) || 0, isService: !!item.isService, itemOrder: index })); await DealerProposalCostItem.bulkCreate(costItems); logger.info(`[DealerClaimService] Saved ${costItems.length} cost items for proposal ${proposalId}`); // Calculate total proposed taxable amount for IO blocking const totalProposedTaxableAmount = proposalData.costBreakup.reduce((sum: number, item: any) => { const amount = Number(item.amount) || 0; const quantity = Number(item.quantity) || 1; return sum + (amount * quantity); }, 0); // Update taxable amount in DealerClaimDetails for IO blocking reference await DealerClaimDetails.update( { totalProposedTaxableAmount }, { where: { requestId } } ); // Update budget tracking with proposed expenses 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) { // Use dealer's comment if provided, otherwise use default message const approvalComment = proposalData.dealerComments?.trim() ? proposalData.dealerComments.trim() : 'Dealer proposal submitted'; // Perform the approval action FIRST - only save snapshot if action succeeds const approvalService = this.getApprovalService(); await approvalService.approveLevel( dealerProposalLevel.levelId, { action: 'APPROVE', comments: approvalComment }, actualDealerUserId || (request as any).initiatorId || 'system', // Use dealer or initiator ID { ipAddress: null, userAgent: null } ); // Save proposal history AFTER approval succeeds (this is the only snapshot needed for dealer submission) // Use dealer user ID if available, otherwise use initiator ID as fallback const historyUserId = actualDealerUserId || (request as any).initiatorId || null; if (!historyUserId) { logger.warn(`[DealerClaimService] No user ID available for proposal history, skipping history save`); } else { try { await this.saveProposalHistory( requestId, dealerProposalLevel.levelId, dealerProposalLevel.levelNumber, `Proposal Submitted: ${approvalComment}`, historyUserId ); // Note: We don't save workflow history here - proposal history is sufficient // Workflow history will be saved when the level is approved and moves to next level } catch (snapshotError) { // Log error but don't fail the submission - snapshot is for audit, not critical logger.error(`[DealerClaimService] Failed to save proposal history snapshot (non-critical):`, snapshotError); } } } 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; completionDescription?: string; }, dealerUserId?: string // Optional dealer user ID for history tracking ): 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 claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); const dealer = await findDealerLocally(claimDetails?.dealerCode, claimDetails?.dealerEmail); const buyerStateCode = "33"; let dealerStateCode = "33"; if (dealer?.gstin && dealer.gstin.length >= 2 && !isNaN(Number(dealer.gstin.substring(0, 2)))) { dealerStateCode = dealer.gstin.substring(0, 2); } else if (dealer?.state) { if (dealer.state.toLowerCase().includes('tamil nadu')) dealerStateCode = "33"; else dealerStateCode = "00"; } const isIGST = dealerStateCode !== buyerStateCode; const completionId = (completionDetails as any)?.completionId; const expenseRows: any[] = []; if (completionData.closedExpenses && completionData.closedExpenses.length > 0) { // Determine taxation type for fallback logic const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); let isNonGst = false; if (claimDetails?.activityType) { const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } }); const taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST'); isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST'; } // Clear existing expenses for this request to avoid duplicates await DealerCompletionExpense.destroy({ where: { requestId } }); completionData.closedExpenses.forEach((item: any) => { const amount = Number(item.amount) || 0; const quantity = Number(item.quantity) || 1; const baseTotal = amount * quantity; // Tax calculations (simplified for brevity, matching previous logic) const gstRate = isNonGst ? 0 : (Number(item.gstRate) || 18); const totalTaxAmt = baseTotal * (gstRate / 100); let finalCgstRate = 0, finalCgstAmt = 0, finalSgstRate = 0, finalSgstAmt = 0, finalIgstRate = 0, finalIgstAmt = 0, finalUtgstRate = 0, finalUtgstAmt = 0; if (!isNonGst) { if (isIGST) { finalIgstRate = gstRate; finalIgstAmt = totalTaxAmt; } else { finalCgstRate = gstRate / 2; finalCgstAmt = totalTaxAmt / 2; finalSgstRate = gstRate / 2; finalSgstAmt = totalTaxAmt / 2; } } expenseRows.push({ requestId, completionId, description: item.description, amount, quantity, taxableAmt: baseTotal, // Added for Scenario 1 comparison hsnCode: item.hsnCode || '', gstRate, gstAmt: totalTaxAmt, cgstRate: finalCgstRate, cgstAmt: finalCgstAmt, sgstRate: finalSgstRate, sgstAmt: finalSgstAmt, igstRate: finalIgstRate, igstAmt: finalIgstAmt, utgstRate: finalUtgstRate, utgstAmt: finalUtgstAmt, cessRate: Number(item.cessRate) || 0, cessAmt: Number(item.cessAmt) || 0, totalAmt: Number(item.totalAmt) || (baseTotal + totalTaxAmt), isService: !!item.isService, expenseDate: item.date instanceof Date ? item.date : (item.date ? new Date(item.date) : (completionData.activityCompletionDate || new Date())), }); }); await DealerCompletionExpense.bulkCreate(expenseRows); } // Update budget tracking with closed expenses (Gross and Taxable) const totalTaxableClosedExpenses = expenseRows.reduce((sum, item) => sum + Number(item.taxableAmt || 0), 0); await ClaimBudgetTracking.upsert({ requestId, closedExpenses: completionData.totalClosedExpenses, // Gross amount taxableClosedExpenses: totalTaxableClosedExpenses, // Taxable amount (for Scenario 1 comparison) closedExpensesSubmittedAt: new Date(), budgetStatus: BudgetStatus.CLOSED, currency: 'INR', }); // Scenario 1: Unblocking excess budget if actual taxable expenses < total blocked amount const allInternalOrders = await InternalOrder.findAll({ where: { requestId, status: IOStatus.BLOCKED } }); const totalBlockedAmount = allInternalOrders.reduce((sum, io) => sum + Number(io.ioBlockedAmount || 0), 0); if (totalTaxableClosedExpenses < totalBlockedAmount) { const amountToRelease = parseFloat((totalBlockedAmount - totalTaxableClosedExpenses).toFixed(2)); logger.info(`[DealerClaimService] Scenario 1: Actual taxable expenses (₹${totalTaxableClosedExpenses}) < Total blocked (₹${totalBlockedAmount}). Releasing ₹${amountToRelease}.`); // Release budget from the most recent IO record (or first available) // In a more complex setup, we might release proportionally, but here we pick the one with enough balance const ioToRelease = allInternalOrders.sort((a, b) => (b.createdAt as any) - (a.createdAt as any))[0]; if (ioToRelease && amountToRelease > 0) { try { const releaseResult = await sapIntegrationService.releaseBudget( ioToRelease.ioNumber, amountToRelease, String((request as any).requestNumber || (request as any).request_number || requestId), ioToRelease.sapDocumentNumber || undefined ); if (releaseResult.success) { logger.info(`[DealerClaimService] Successfully released ₹${amountToRelease} from IO ${ioToRelease.ioNumber}`); } else { logger.warn(`[DealerClaimService] SAP release failed: ${releaseResult.error}`); } } catch (releaseError) { logger.error(`[DealerClaimService] Error during budget release:`, releaseError); } } } else if (totalTaxableClosedExpenses > totalBlockedAmount) { // Scenario 2: Actual taxable expenses > Total blocked amount const additionalAmountNeeded = parseFloat((totalTaxableClosedExpenses - totalBlockedAmount).toFixed(2)); logger.info(`[DealerClaimService] Scenario 2: Actual taxable expenses (₹${totalTaxableClosedExpenses}) > Total blocked (₹${totalBlockedAmount}). Additional ₹${additionalAmountNeeded} needs to be blocked.`); // Update DealerClaimDetails with the new total required amount for IO blocking reference await DealerClaimDetails.update( { totalProposedTaxableAmount: totalTaxableClosedExpenses }, { where: { requestId } } ); // Signal that re-blocking is needed by updating status back to PROPOSED await ClaimBudgetTracking.update( { budgetStatus: BudgetStatus.PROPOSED }, { where: { requestId } } ); } // 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) { // Use dealer's completion description if provided, otherwise use default message const approvalComment = completionData.completionDescription?.trim() ? completionData.completionDescription.trim() : 'Completion documents submitted'; // Get dealer user ID if not provided - try to find by dealer email from claim details let actualDealerUserId: string | null = dealerUserId || null; if (!actualDealerUserId) { const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); if (claimDetails?.dealerEmail) { const dealerUser = await User.findOne({ where: { email: claimDetails.dealerEmail } }); actualDealerUserId = dealerUser?.userId || null; } } // Perform the approval action FIRST - only save snapshot if action succeeds const approvalService = this.getApprovalService(); await approvalService.approveLevel( dealerCompletionLevel.levelId, { action: 'APPROVE', comments: approvalComment }, actualDealerUserId || (request as any).initiatorId || 'system', { ipAddress: null, userAgent: null } ); // Save completion history AFTER approval succeeds (this is the only snapshot needed for dealer completion) // Use dealer user ID if available, otherwise use initiator ID as fallback const historyUserId = actualDealerUserId || (request as any).initiatorId || null; if (!historyUserId) { logger.warn(`[DealerClaimService] No user ID available for completion history, skipping history save`); } else { try { await this.saveCompletionHistory( requestId, dealerCompletionLevel.levelId, dealerCompletionLevel.levelNumber, `Completion Submitted: ${approvalComment}`, historyUserId ); // Note: We don't save workflow history here - completion history is sufficient // Workflow history will be saved when the level is approved and moves to next level } catch (snapshotError) { // Log error but don't fail the submission - snapshot is for audit, not critical logger.error(`[DealerClaimService] Failed to save completion history snapshot (non-critical):`, snapshotError); } } } 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 { // Ensure blockedAmount is rounded to exactly 2 decimal places from the start const blockedAmount = ioData.blockedAmount ? parseFloat(ioData.blockedAmount.toFixed(2)) : 0; // If blocking amount > 0, proceed with SAP integration and blocking // If blocking amount is 0 but ioNumber is provided, just save the IO details without blocking if (blockedAmount <= 0) { // Allow saving IO details (ioNumber only) even without blocking amount // This is useful when Step 3/Requestor Evaluation is in progress but amount hasn't been blocked yet or for linking IO if (ioData.ioNumber) { const organizedBy = organizedByUserId || null; // Check if an IO record already exists for this request and IO number // This prevents duplicate 0-amount "provisioned" records when re-saving IO details const existingIO = await InternalOrder.findOne({ where: { requestId, ioNumber: ioData.ioNumber } }); if (existingIO) { // Update existing record with latest remark and organizer info if provided await existingIO.update({ ioRemark: ioData.ioRemark || existingIO.ioRemark || '', organizedBy: organizedBy || existingIO.organizedBy || undefined, organizedAt: new Date(), }); logger.info(`[DealerClaimService] Existing IO record updated for request: ${requestId}`, { ioNumber: ioData.ioNumber, status: existingIO.status }); return; } // Create a new Internal Order record if none exists for this IO and request await InternalOrder.create({ 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, }); logger.info(`[DealerClaimService] IO provision record created for request: ${requestId}`, { ioNumber: ioData.ioNumber }); return; // Exit early - no SAP blocking needed } else { throw new Error('Blocked amount must be greater than 0, or ioNumber 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; const sapDocumentNumber = blockResult.blockId || undefined; const availableBalance = parseFloat((ioData.availableBalance || ioValidation.availableBalance).toFixed(2)); // Use the amount we REQUESTED for calculation, unless SAP blocked significantly different amount const amountDifference = Math.abs(sapReturnedBlockedAmount - blockedAmount); const useSapAmount = amountDifference > 1.0; const finalBlockedAmount = useSapAmount ? sapReturnedBlockedAmount : blockedAmount; // Calculate remaining balance const calculatedRemainingBalance = parseFloat((availableBalance - finalBlockedAmount).toFixed(2)); const sapRemainingBalance = blockResult.remainingBalance ? parseFloat(blockResult.remainingBalance.toFixed(2)) : 0; const sapValueIsValid = sapRemainingBalance > 0 && sapRemainingBalance <= availableBalance && Math.abs(sapRemainingBalance - calculatedRemainingBalance) < 1; const remainingBalance = sapValueIsValid ? sapRemainingBalance : calculatedRemainingBalance; const finalRemainingBalance = parseFloat(Math.max(0, remainingBalance).toFixed(2)); // Create new Internal Order record for this block operation (supporting multiple blocks) const organizedBy = organizedByUserId || null; await InternalOrder.create({ requestId, ioNumber: ioData.ioNumber, ioRemark: ioData.ioRemark || '', ioAvailableBalance: availableBalance, ioBlockedAmount: finalBlockedAmount, ioRemainingBalance: finalRemainingBalance, organizedBy: organizedBy || undefined, organizedAt: new Date(), sapDocumentNumber: sapDocumentNumber || undefined, status: IOStatus.BLOCKED, }); // Update budget tracking with TOTAL blocked amount from all records const allInternalOrders = await InternalOrder.findAll({ where: { requestId, status: IOStatus.BLOCKED } }); const totalBlockedAmount = allInternalOrders.reduce((sum, io) => sum + Number(io.ioBlockedAmount || 0), 0); await ClaimBudgetTracking.upsert({ requestId, ioBlockedAmount: totalBlockedAmount, ioBlockedAt: new Date(), budgetStatus: BudgetStatus.BLOCKED, currency: 'INR', }); logger.info(`[DealerClaimService] Budget block recorded for request: ${requestId}`, { ioNumber: ioData.ioNumber, blockedAmount: finalBlockedAmount, totalBlockedAmount, sapDocumentNumber, finalRemainingBalance }); // Save IO history after successful blocking // Find the Department Lead IO Approval level (Step 3) const ioApprovalLevel = await ApprovalLevel.findOne({ where: { requestId, levelName: 'Department Lead IO Approval' } }); // Fallback: try to find by levelNumber 3 const ioLevel = ioApprovalLevel || await ApprovalLevel.findOne({ where: { requestId, levelNumber: 3 } }); // Get user ID for history - use organizedBy if it's a UUID, otherwise try to find user let ioHistoryUserId: string | null = null; if (ioLevel) { if (organizedBy) { // Check if organizedBy is a valid UUID const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; if (uuidRegex.test(organizedBy)) { ioHistoryUserId = organizedBy; } else { // Try to find user by email or name const user = await User.findOne({ where: { email: organizedBy } }); ioHistoryUserId = user?.userId || null; } } // Fallback to initiator if no user found if (!ioHistoryUserId) { const request = await WorkflowRequest.findByPk(requestId); ioHistoryUserId = (request as any)?.initiatorId || null; } } // Save IO history AFTER budget tracking update succeeds (only if ioLevel exists) if (ioLevel && ioHistoryUserId) { try { await this.saveIOHistory( requestId, ioLevel.levelId, ioLevel.levelNumber, `IO Blocked: ₹${finalBlockedAmount.toFixed(2)} blocked in SAP`, ioHistoryUserId ); } catch (snapshotError) { // Log error but don't fail the IO blocking - snapshot is for audit, not critical logger.error(`[DealerClaimService] Failed to save IO history snapshot (non-critical):`, snapshotError); } } else if (ioLevel && !ioHistoryUserId) { logger.warn(`[DealerClaimService] No user ID available for IO history, skipping history save`); } 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 PWC E-Invoice service if (!invoiceData?.eInvoiceNumber) { const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } }); const invoiceAmount = invoiceData?.amount || proposalDetails?.totalEstimatedBudget || budgetTracking?.proposalEstimatedBudget || budgetTracking?.initialEstimatedBudget || 0; // Generate custom invoice number based on specific format: INDC + DealerCode + AB + Sequence // Format: INDC[DealerCode]AB[Sequence] (e.g., INDC004597AB0001) logger.info(`[DealerClaimService] Generating custom invoice number for dealer: ${claimDetails.dealerCode}`); const customInvoiceNumber = await this.generateCustomInvoiceNumber(claimDetails.dealerCode); logger.info(`[DealerClaimService] Generated custom invoice number: ${customInvoiceNumber} for request: ${requestId}`); const invoiceResult = await pwcIntegrationService.generateSignedInvoice(requestId, invoiceAmount, customInvoiceNumber); logger.info(`[DealerClaimService] PWC Generation Result: Success=${invoiceResult.success}, AckNo=${invoiceResult.ackNo}, SignedInv present=${!!invoiceResult.signedInvoice}`); if (!invoiceResult.success) { throw new Error(`Failed to generate signed e-invoice via PWC: ${invoiceResult.error}`); } await ClaimInvoice.upsert({ requestId, invoiceNumber: customInvoiceNumber, // Use custom invoice number as primary identifier invoiceDate: invoiceResult.ackDate instanceof Date ? invoiceResult.ackDate : (invoiceResult.ackDate ? new Date(invoiceResult.ackDate) : new Date()), irn: invoiceResult.irn, ackNo: invoiceResult.ackNo, ackDate: invoiceResult.ackDate instanceof Date ? invoiceResult.ackDate : (invoiceResult.ackDate ? new Date(invoiceResult.ackDate) : null), signedInvoice: invoiceResult.signedInvoice, qrCode: invoiceResult.qrCode, qrImage: invoiceResult.qrImage, pwcResponse: invoiceResult.rawResponse, irpResponse: invoiceResult.irpResponse, amount: invoiceAmount, taxableValue: invoiceResult.totalAssAmt, igstTotal: invoiceResult.totalIgstAmt, cgstTotal: invoiceResult.totalCgstAmt, sgstTotal: invoiceResult.totalSgstAmt, status: 'GENERATED', generatedAt: new Date(), description: invoiceData?.description || `PWC Signed Invoice for claim request ${requestNumber}`, }); logger.info(`[DealerClaimService] Signed Invoice generated via PWC for request: ${requestId}`, { ackNo: invoiceResult.ackNo, irn: invoiceResult.irn }); } else { // Manual entry - just update the fields await ClaimInvoice.upsert({ requestId, invoiceNumber: invoiceData.eInvoiceNumber, invoiceDate: invoiceData.eInvoiceDate || new Date(), dmsNumber: invoiceData.dmsNumber, amount: Number(invoiceData.amount) || 0, status: 'UPDATED', generatedAt: new Date(), description: invoiceData.description, }); logger.info(`[DealerClaimService] E-Invoice details manually updated for request: ${requestId}`); } // Generate CSV for WFM system (INCOMING\WFM_MAIN\DLR_INC_CLAIMS) await this.pushWFMCSV(requestId).catch((err: Error) => { logger.error('[DealerClaimService] Initial WFM push failed:', err); }); // Generate PDF Invoice try { const { pdfService } = require('./pdf.service'); await pdfService.generateInvoicePdf(requestId); } catch (error) { logger.error(`[DealerClaimService] Failed to generate PDF for request ${requestId}:`, error); // Don't throw, we still want to proceed with auto-approval } // 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 E-Invoice generation if (requestorClaimLevel && request.currentLevel !== requestorClaimLevel.levelNumber) { throw new Error(`Cannot generate E-Invoice. Request is currently at step ${request.currentLevel}, but Requestor Claim Approval is at step ${requestorClaimLevel.levelNumber}. Please complete all previous steps first.`); } // E-Invoice Generation is successful - auto-approve the Requestor Claim Approval step if (requestorClaimLevel && requestorClaimLevel.status !== 'APPROVED') { const approvalService = this.getApprovalService(); await approvalService.approveLevel( requestorClaimLevel.levelId, { action: 'APPROVE', comments: 'Auto-approved after successful E-Invoice generation' }, 'system' ); logger.info(`[DealerClaimService] Step "${requestorClaimLevel.levelName}" auto-approved after E-Invoice generation for request ${requestId}`); } // 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 PWC integration for request ${requestNumber}. Step "${requestorClaimLevel?.levelName || 'Requestor Claim Approval'}" auto-approved.`, }); } 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 PWC service */ 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 PWC. 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: Number(creditNoteResult.creditNoteAmount) || 0, 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: Number(creditNoteData.creditNoteAmount) || 0, 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; } } /** * Genetate Custom Invoice Number * Format: INDC - DEALER CODE - AB (For FY) - sequence nos. * Sample: INDC004597AB0001 */ private async generateCustomInvoiceNumber(dealerCode: string): Promise { const fyCode = 'AB'; // Hardcoded FY code as per requirement // Ensure dealer code is padded/truncated if needed to fit length constraints, but requirement says "004597" which is 6 digits. // Assuming dealerCode is already correct length or we use it as is. const cleanDealerCode = (dealerCode || '000000').trim(); const prefix = `INDC${cleanDealerCode}${fyCode}`; // Find last invoice with this prefix const lastInvoice = await ClaimInvoice.findOne({ where: { invoiceNumber: { [Op.like]: `${prefix}%` } }, order: [ [sequelize.fn('LENGTH', sequelize.col('invoice_number')), 'DESC'], ['invoice_number', 'DESC'] ] }); let sequence = 1; if (lastInvoice && lastInvoice.invoiceNumber) { // Extract the sequence part (last 4 digits) const lastSeqStr = lastInvoice.invoiceNumber.replace(prefix, ''); const lastSeq = parseInt(lastSeqStr, 10); if (!isNaN(lastSeq)) { sequence = lastSeq + 1; } } // Pad sequence to 4 digits const sequenceStr = sequence.toString().padStart(4, '0'); return `${prefix}${sequenceStr}`; } /** * 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, }); } 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 with proper metadata if (userIdsForNotification.length > 0) { // Prepare metadata for activity created email template const activityData = { activityName: activityName, activityType: activityType, activityDate: claimDetails.activityDate, location: claimDetails.location || 'Not specified', dealerName: claimDetails.dealerName || 'Dealer', dealerCode: claimDetails.dealerCode, initiatorName: initiator ? (initiator.displayName || initiator.email) : 'Initiator', departmentLeadName: departmentLead ? (departmentLead.displayName || departmentLead.email) : undefined, ioNumber: undefined, // IO number will be added later when IO is created nextSteps: 'IO confirmation to be made. Dealer will proceed with activity execution and submit completion documents.' }; await notificationService.sendToUsers(userIdsForNotification, { title: emailSubject, body: emailBody, requestId, requestNumber, url: `/request/${requestNumber}`, type: 'activity_created', priority: 'MEDIUM', actionRequired: false, metadata: { activityData: activityData } }); } // 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; } } /** * Snapshot current claim state for version history before revisions */ /** * Save proposal version history (Step 1) */ async saveProposalHistory( requestId: string, approvalLevelId: string, levelNumber: number, changeReason: string, userId: string ): Promise { try { const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } }); if (!proposalDetails) { logger.warn(`[DealerClaimService] No proposal found for request ${requestId}, skipping history`); return; } const costItems = await DealerProposalCostItem.findAll({ where: { proposalId: (proposalDetails as any).proposalId || (proposalDetails as any).proposal_id } }); // Get level name from approval level const level = await ApprovalLevel.findByPk(approvalLevelId); const levelName = level?.levelName || undefined; // Get next version for this level (match by levelName for consistency) const lastVersion = await DealerClaimHistory.findOne({ where: levelName ? { requestId, levelName, snapshotType: SnapshotType.PROPOSAL } : { requestId, levelNumber, snapshotType: SnapshotType.PROPOSAL }, order: [['version', 'DESC']] }); const nextVersion = lastVersion ? lastVersion.version + 1 : 1; // Store all proposal data in JSONB // Handle expectedCompletionDate - it might be a Date object, string, or null let expectedCompletionDateStr = null; if (proposalDetails.expectedCompletionDate) { if (proposalDetails.expectedCompletionDate instanceof Date) { expectedCompletionDateStr = proposalDetails.expectedCompletionDate.toISOString(); } else if (typeof proposalDetails.expectedCompletionDate === 'string') { expectedCompletionDateStr = proposalDetails.expectedCompletionDate; } } // Fetch supporting documents const supportingDocs = await Document.findAll({ where: { requestId, category: 'SUPPORTING', isDeleted: false }, order: [['uploadedAt', 'DESC']] }); const snapshotData = { documentUrl: proposalDetails.proposalDocumentUrl, totalBudget: Number(proposalDetails.totalEstimatedBudget || 0), comments: proposalDetails.dealerComments, expectedCompletionDate: expectedCompletionDateStr, costItems: costItems.map(i => ({ description: i.itemDescription, amount: Number(i.amount || 0), quantity: Number(i.quantity || 1), hsnCode: i.hsnCode || '', gstRate: Number(i.gstRate || 0), gstAmt: Number(i.gstAmt || 0), cgstRate: Number(i.cgstRate || 0), cgstAmt: Number(i.cgstAmt || 0), sgstRate: Number(i.sgstRate || 0), sgstAmt: Number(i.sgstAmt || 0), igstRate: Number(i.igstRate || 0), igstAmt: Number(i.igstAmt || 0), utgstRate: Number(i.utgstRate || 0), utgstAmt: Number(i.utgstAmt || 0), cessRate: Number(i.cessRate || 0), cessAmt: Number(i.cessAmt || 0), totalAmt: Number(i.totalAmt || 0), isService: !!i.isService, order: i.itemOrder })), otherDocuments: supportingDocs.map(doc => ({ documentId: doc.documentId, fileName: doc.fileName, originalFileName: doc.originalFileName, storageUrl: doc.storageUrl, uploadedAt: doc.uploadedAt })) }; await DealerClaimHistory.create({ requestId, approvalLevelId, levelNumber, levelName, version: nextVersion, snapshotType: SnapshotType.PROPOSAL, snapshotData, changeReason, changedBy: userId }); logger.info(`[DealerClaimService] Saved proposal history (v${nextVersion}) for level ${levelNumber}, request ${requestId}`); } catch (error) { logger.error(`[DealerClaimService] Error saving proposal history for request ${requestId}:`, error); } } /** * Save completion version history (Step 4/5) */ async saveCompletionHistory( requestId: string, approvalLevelId: string, levelNumber: number, changeReason: string, userId: string ): Promise { try { const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId } }); if (!completionDetails) { logger.warn(`[DealerClaimService] No completion found for request ${requestId}, skipping history`); return; } const expenses = await DealerCompletionExpense.findAll({ where: { requestId } }); // Get level name from approval level const level = await ApprovalLevel.findByPk(approvalLevelId); const levelName = level?.levelName || undefined; // Get next version for this level (match by levelName for consistency) const lastVersion = await DealerClaimHistory.findOne({ where: levelName ? { requestId, levelName, snapshotType: SnapshotType.COMPLETION } : { requestId, levelNumber, snapshotType: SnapshotType.COMPLETION }, order: [['version', 'DESC']] }); const nextVersion = lastVersion ? lastVersion.version + 1 : 1; // Fetch supporting documents for completion const supportingDocs = await Document.findAll({ where: { requestId, category: 'SUPPORTING', isDeleted: false }, order: [['uploadedAt', 'DESC']] }); // Store all completion data in JSONB const snapshotData = { documentUrl: (completionDetails as any).completionDocumentUrl || null, totalExpenses: Number(completionDetails.totalClosedExpenses || 0), comments: (completionDetails as any).completionDescription || null, expenses: expenses.map(e => ({ description: e.description, amount: Number(e.amount || 0) })), otherDocuments: supportingDocs.map(doc => ({ documentId: doc.documentId, fileName: doc.fileName, originalFileName: doc.originalFileName, storageUrl: doc.storageUrl, uploadedAt: doc.uploadedAt })) }; await DealerClaimHistory.create({ requestId, approvalLevelId, levelNumber, levelName, version: nextVersion, snapshotType: SnapshotType.COMPLETION, snapshotData, changeReason, changedBy: userId }); logger.info(`[DealerClaimService] Saved completion history (v${nextVersion}) for level ${levelNumber}, request ${requestId}`); } catch (error) { logger.error(`[DealerClaimService] Error saving completion history for request ${requestId}:`, error); } } /** * Save internal order version history */ async saveIOHistory( requestId: string, approvalLevelId: string, levelNumber: number, changeReason: string, userId: string ): Promise { try { const internalOrder = await InternalOrder.findOne({ where: { requestId } }); if (!internalOrder || !internalOrder.ioBlockedAmount || internalOrder.ioBlockedAmount <= 0) { logger.warn(`[DealerClaimService] No IO block found for request ${requestId}, skipping history`); return; } // Get level name from approval level const level = await ApprovalLevel.findByPk(approvalLevelId); const levelName = level?.levelName || undefined; // Get next version for this level (match by levelName for consistency) const lastVersion = await DealerClaimHistory.findOne({ where: levelName ? { requestId, levelName, snapshotType: SnapshotType.INTERNAL_ORDER } : { requestId, levelNumber, snapshotType: SnapshotType.INTERNAL_ORDER }, order: [['version', 'DESC']] }); const nextVersion = lastVersion ? lastVersion.version + 1 : 1; // Store all IO data in JSONB const snapshotData = { ioNumber: internalOrder.ioNumber, blockedAmount: Number(internalOrder.ioBlockedAmount || 0), availableBalance: Number(internalOrder.ioAvailableBalance || 0), remainingBalance: Number(internalOrder.ioRemainingBalance || 0), sapDocumentNumber: internalOrder.sapDocumentNumber }; await DealerClaimHistory.create({ requestId, approvalLevelId, levelNumber, levelName, version: nextVersion, snapshotType: SnapshotType.INTERNAL_ORDER, snapshotData, changeReason, changedBy: userId }); logger.info(`[DealerClaimService] Saved IO history (v${nextVersion}) for level ${levelNumber}, request ${requestId}`); } catch (error) { logger.error(`[DealerClaimService] Error saving IO history for request ${requestId}:`, error); } } /** * Save approval version history (for approver actions) */ async saveApprovalHistory( requestId: string, approvalLevelId: string, levelNumber: number, action: 'APPROVE' | 'REJECT', comments: string, rejectionReason: string | undefined, userId: string ): Promise { try { const level = await ApprovalLevel.findByPk(approvalLevelId); if (!level) { logger.warn(`[DealerClaimService] No approval level found for ${approvalLevelId}, skipping history`); return; } // Get next version for this level (match by levelName for consistency) const lastVersion = await DealerClaimHistory.findOne({ where: level.levelName ? { requestId, levelName: level.levelName, snapshotType: SnapshotType.APPROVE } : { requestId, levelNumber, snapshotType: SnapshotType.APPROVE }, order: [['version', 'DESC']] }); const nextVersion = lastVersion ? lastVersion.version + 1 : 1; // Store approval data in JSONB const snapshotData = { action, comments: comments || undefined, rejectionReason: rejectionReason || undefined, approverName: level.approverName, approverEmail: level.approverEmail, levelName: level.levelName }; // Build changeReason - will be updated later if moving to next level // For now, just include the basic approval/rejection info let changeReason = action === 'APPROVE' ? `Approved by ${level.approverName || level.approverEmail}` : `Rejected by ${level.approverName || level.approverEmail}`; if (action === 'REJECT' && (rejectionReason || comments)) { changeReason += `. Reason: ${rejectionReason || comments}`; } await DealerClaimHistory.create({ requestId, approvalLevelId, levelNumber, levelName: level.levelName || undefined, version: nextVersion, snapshotType: SnapshotType.APPROVE, snapshotData, changeReason, changedBy: userId }); logger.info(`[DealerClaimService] Saved approval history (v${nextVersion}) for level ${levelNumber}, request ${requestId}`); } catch (error) { logger.error(`[DealerClaimService] Error saving approval history for request ${requestId}:`, error); } } /** * Save workflow-level version history (for actions that move workflow forward/backward) */ async saveWorkflowHistory( requestId: string, changeReason: string, userId: string, approvalLevelId?: string, levelNumber?: number, levelName?: string, approvalComment?: string ): Promise { try { const wf = await WorkflowRequest.findByPk(requestId); if (!wf) return; // Get next version for workflow-level snapshots PER LEVEL // Each level should have its own version numbering starting from 1 // Filter by levelName or levelNumber to get versions for this specific level const lastVersion = await DealerClaimHistory.findOne({ where: levelName ? { requestId, levelName, snapshotType: SnapshotType.WORKFLOW } : levelNumber !== undefined ? { requestId, levelNumber, snapshotType: SnapshotType.WORKFLOW } : { requestId, snapshotType: SnapshotType.WORKFLOW }, order: [['version', 'DESC']] }); const nextVersion = lastVersion ? lastVersion.version + 1 : 1; // Store workflow data in JSONB // Include level information for version tracking and comparison // Include approval comment if provided (for approval actions) const snapshotData: any = { status: wf.status, currentLevel: wf.currentLevel, // Include level info in snapshotData for completeness and version tracking approvalLevelId: approvalLevelId || undefined, levelNumber: levelNumber || undefined, levelName: levelName || undefined }; // Add approval comment to snapshotData if provided if (approvalComment) { snapshotData.comments = approvalComment; } await DealerClaimHistory.create({ requestId, approvalLevelId: approvalLevelId || undefined, levelNumber: levelNumber || undefined, levelName: levelName || undefined, version: nextVersion, snapshotType: SnapshotType.WORKFLOW, snapshotData, changeReason, changedBy: userId }); logger.info(`[DealerClaimService] Saved workflow history (v${nextVersion}) for request ${requestId}, level ${levelNumber || 'N/A'}`); } catch (error) { logger.error(`[DealerClaimService] Error saving workflow history for request ${requestId}:`, error); } } /** * Create or activate initiator action level when request is rejected * This allows initiator to take action (REVISE, CANCEL, REOPEN) directly from the step card */ async createOrActivateInitiatorLevel( requestId: string, userId: string ): Promise { try { const wf = await WorkflowRequest.findByPk(requestId); if (!wf) return null; // Check if initiator level already exists let initiatorLevel = await ApprovalLevel.findOne({ where: { requestId, levelName: 'Initiator Action' } }); if (initiatorLevel) { // Activate existing level await initiatorLevel.update({ status: ApprovalStatus.IN_PROGRESS, levelStartTime: new Date(), tatStartTime: new Date(), approverId: wf.initiatorId }); return initiatorLevel; } // Create new initiator level // Find the highest level number to place it after const maxLevel = await ApprovalLevel.findOne({ where: { requestId }, order: [['levelNumber', 'DESC']] }); const nextLevelNumber = maxLevel ? maxLevel.levelNumber + 1 : 0; // Get initiator user details const initiatorUser = await User.findByPk(wf.initiatorId); if (!initiatorUser) { throw new Error('Initiator user not found'); } initiatorLevel = await ApprovalLevel.create({ requestId, levelNumber: nextLevelNumber, levelName: 'Initiator Action', approverId: wf.initiatorId, approverEmail: initiatorUser.email || '', approverName: initiatorUser.displayName || initiatorUser.email || 'Initiator', status: ApprovalStatus.IN_PROGRESS, levelStartTime: new Date(), tatStartTime: new Date(), tatHours: 0, // No TAT for initiator action elapsedHours: 0, remainingHours: 0, tatPercentageUsed: 0, isFinalApprover: false } as any); logger.info(`[DealerClaimService] Created/activated initiator level for request ${requestId}`); return initiatorLevel; } catch (error) { logger.error(`[DealerClaimService] Error creating/activating initiator level:`, error); return null; } } /** * @deprecated - Removed complex snapshot method. Snapshots are now taken at step execution. */ async saveCompleteRevisionSnapshot_DEPRECATED( requestId: string, changeReason: string, userId: string ): Promise { try { logger.info(`[DealerClaimService] Capturing complete revision snapshot for request ${requestId}`); // 1. Capture current proposal snapshot (if exists) const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } }); if (proposalDetails) { const costItems = await DealerProposalCostItem.findAll({ where: { proposalId: (proposalDetails as any).proposalId || (proposalDetails as any).proposal_id } }); // Find dealer proposal level const dealerLevel = await ApprovalLevel.findOne({ where: { requestId, levelName: 'Dealer Proposal Submission' } }) || await ApprovalLevel.findOne({ where: { requestId, levelNumber: 1 } }); if (dealerLevel) { const proposalSnapshotData = { documentUrl: proposalDetails.proposalDocumentUrl, totalBudget: Number(proposalDetails.totalEstimatedBudget || 0), comments: proposalDetails.dealerComments, expectedCompletionDate: proposalDetails.expectedCompletionDate ? proposalDetails.expectedCompletionDate.toISOString() : null, costItems: costItems.map(i => ({ description: i.itemDescription, amount: Number(i.amount || 0), order: i.itemOrder })) }; // Get next version for this level const lastProposalVersion = await DealerClaimHistory.findOne({ where: { requestId, levelName: dealerLevel.levelName || undefined, snapshotType: SnapshotType.PROPOSAL }, order: [['version', 'DESC']] }); const nextProposalVersion = lastProposalVersion ? lastProposalVersion.version + 1 : 1; await DealerClaimHistory.create({ requestId, approvalLevelId: dealerLevel.levelId, levelNumber: dealerLevel.levelNumber, levelName: dealerLevel.levelName || undefined, version: nextProposalVersion, snapshotType: SnapshotType.PROPOSAL, snapshotData: proposalSnapshotData, changeReason: `${changeReason} - Pre-revision snapshot`, changedBy: userId }); logger.info(`[DealerClaimService] Captured proposal snapshot (v${nextProposalVersion}) for revision`); } } // 2. Capture current completion snapshot (if exists) const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId } }); if (completionDetails) { const expenses = await DealerCompletionExpense.findAll({ where: { completionId: (completionDetails as any).completionId || (completionDetails as any).completion_id } }); // Find completion level const completionLevel = await ApprovalLevel.findOne({ where: { requestId, levelName: 'Dealer Completion Documents' } }) || await ApprovalLevel.findOne({ where: { requestId, levelNumber: 4 } }); if (completionLevel) { const completionSnapshotData = { documentUrl: (completionDetails as any).completionDocumentUrl || null, totalExpenses: Number(completionDetails.totalClosedExpenses || 0), comments: (completionDetails as any).completionDescription || null, expenses: expenses.map(e => ({ description: e.description, amount: Number(e.amount || 0) })) }; // Get next version for this level const lastCompletionVersion = await DealerClaimHistory.findOne({ where: { requestId, levelName: completionLevel.levelName || undefined, snapshotType: SnapshotType.COMPLETION }, order: [['version', 'DESC']] }); const nextCompletionVersion = lastCompletionVersion ? lastCompletionVersion.version + 1 : 1; await DealerClaimHistory.create({ requestId, approvalLevelId: completionLevel.levelId, levelNumber: completionLevel.levelNumber, levelName: completionLevel.levelName || undefined, version: nextCompletionVersion, snapshotType: SnapshotType.COMPLETION, snapshotData: completionSnapshotData, changeReason: `${changeReason} - Pre-revision snapshot`, changedBy: userId }); logger.info(`[DealerClaimService] Captured completion snapshot (v${nextCompletionVersion}) for revision`); } } // 3. Capture current IO snapshot (if exists) const internalOrder = await InternalOrder.findOne({ where: { requestId } }); if (internalOrder && internalOrder.ioBlockedAmount && internalOrder.ioBlockedAmount > 0) { const ioLevel = await ApprovalLevel.findOne({ where: { requestId, levelName: 'Department Lead IO Approval' } }) || await ApprovalLevel.findOne({ where: { requestId, levelNumber: 3 } }); if (ioLevel) { const ioSnapshotData = { ioNumber: internalOrder.ioNumber, blockedAmount: Number(internalOrder.ioBlockedAmount || 0), availableBalance: Number(internalOrder.ioAvailableBalance || 0), remainingBalance: Number(internalOrder.ioRemainingBalance || 0), sapDocumentNumber: internalOrder.sapDocumentNumber }; // Get next version for this level const lastIOVersion = await DealerClaimHistory.findOne({ where: { requestId, levelName: ioLevel.levelName || undefined, snapshotType: SnapshotType.INTERNAL_ORDER }, order: [['version', 'DESC']] }); const nextIOVersion = lastIOVersion ? lastIOVersion.version + 1 : 1; await DealerClaimHistory.create({ requestId, approvalLevelId: ioLevel.levelId, levelNumber: ioLevel.levelNumber, levelName: ioLevel.levelName || undefined, version: nextIOVersion, snapshotType: SnapshotType.INTERNAL_ORDER, snapshotData: ioSnapshotData, changeReason: `${changeReason} - Pre-revision snapshot`, changedBy: userId }); logger.info(`[DealerClaimService] Captured IO snapshot (v${nextIOVersion}) for revision`); } } // 4. Capture ALL approval comments from all levels (so approvers can see their previous comments) const allLevels = await ApprovalLevel.findAll({ where: { requestId }, order: [['levelNumber', 'ASC']] }); for (const level of allLevels) { // Only capture if level has been acted upon (has comments or action date) if (level.comments || level.actionDate || level.status === ApprovalStatus.APPROVED || level.status === ApprovalStatus.REJECTED) { const approver = level.approverId ? await User.findByPk(level.approverId) : null; const approvalSnapshotData = { action: level.status === ApprovalStatus.APPROVED ? 'APPROVE' : level.status === ApprovalStatus.REJECTED ? 'REJECT' : 'PENDING', comments: level.comments || undefined, rejectionReason: level.status === ApprovalStatus.REJECTED ? (level.comments || undefined) : undefined, approverName: approver?.displayName || approver?.email || undefined, approverEmail: approver?.email || undefined, levelName: level.levelName || undefined }; // Get next version for this level's approval snapshot const lastApprovalVersion = await DealerClaimHistory.findOne({ where: { requestId, levelName: level.levelName || undefined, snapshotType: SnapshotType.APPROVE }, order: [['version', 'DESC']] }); const nextApprovalVersion = lastApprovalVersion ? lastApprovalVersion.version + 1 : 1; await DealerClaimHistory.create({ requestId, approvalLevelId: level.levelId, levelNumber: level.levelNumber, levelName: level.levelName || undefined, version: nextApprovalVersion, snapshotType: SnapshotType.APPROVE, snapshotData: approvalSnapshotData, changeReason: `${changeReason} - Pre-revision approval snapshot`, changedBy: userId }); logger.info(`[DealerClaimService] Captured approval snapshot (v${nextApprovalVersion}) for level ${level.levelNumber} (${level.levelName})`); } } // 5. Save workflow-level snapshot const wf = await WorkflowRequest.findByPk(requestId); if (wf) { const lastWorkflowVersion = await DealerClaimHistory.findOne({ where: { requestId, snapshotType: SnapshotType.WORKFLOW }, order: [['version', 'DESC']] }); const nextWorkflowVersion = lastWorkflowVersion ? lastWorkflowVersion.version + 1 : 1; await DealerClaimHistory.create({ requestId, version: nextWorkflowVersion, snapshotType: SnapshotType.WORKFLOW, snapshotData: { status: wf.status, currentLevel: wf.currentLevel }, changeReason: `${changeReason} - Pre-revision workflow snapshot`, changedBy: userId }); logger.info(`[DealerClaimService] Captured workflow snapshot (v${nextWorkflowVersion}) for revision`); } logger.info(`[DealerClaimService] Complete revision snapshot captured for request ${requestId}`); } catch (error) { logger.error(`[DealerClaimService] Error saving complete revision snapshot for request ${requestId}:`, error); // Don't throw - we want to continue even if snapshot fails } } /** * Handle initiator actions when a request is in RETURNED status */ async handleInitiatorAction( requestId: string, userId: string, action: 'REOPEN' | 'DISCUSS' | 'REVISE' | 'CANCEL', data?: { reason: string } ): Promise { const wf = await WorkflowRequest.findByPk(requestId); if (!wf) throw new Error('Request not found'); // Check if the current user is the initiator if (wf.initiatorId !== userId) { throw new Error('Only the initiator can perform actions on a rejected/returned request'); } // A returned request is REJECTED but has NO closureDate if (wf.status !== WorkflowStatus.REJECTED || wf.closureDate) { throw new Error(`Request is in ${wf.status} status (Closed: ${!!wf.closureDate}), expected an open REJECTED state to perform this action`); } const initiator = await User.findByPk(userId); const initiatorName = initiator?.displayName || initiator?.email || 'Initiator'; const now = new Date(); switch (action) { case 'CANCEL': { // Format change reason to include the comment if provided const changeReason = data?.reason && data.reason.trim() ? `Request Cancelled: ${data.reason.trim()}` : 'Request Cancelled'; // Find current level for workflow history const currentLevel = await ApprovalLevel.findOne({ where: { requestId, levelNumber: wf.currentLevel || 1 } }); await wf.update({ status: WorkflowStatus.CLOSED, closureDate: now }); await activityService.log({ requestId, type: 'status_change', user: { userId, name: initiatorName }, timestamp: now.toISOString(), action: 'Request Cancelled', details: data?.reason && data.reason.trim() ? `Request was cancelled by initiator. Reason: ${data.reason.trim()}` : 'Request was cancelled by initiator.' }); break; } case 'REOPEN': { // Format change reason to include the comment if provided const changeReason = data?.reason && data.reason.trim() ? `Request Reopened: ${data.reason.trim()}` : 'Request Reopened'; // Find Department Lead level dynamically (handles step shifts) const approvalsReopen = await ApprovalLevel.findAll({ where: { requestId } }); const deptLeadLevel = approvalsReopen.find(l => { const name = (l.levelName || '').toLowerCase(); return name.includes('department lead') || name.includes('dept lead') || l.levelNumber === 3; }); if (!deptLeadLevel) { throw new Error('Department Lead approval level not found for this request'); } const deptLeadLevelNumber = deptLeadLevel.levelNumber; // Move back to Department Lead Approval level FIRST await wf.update({ status: WorkflowStatus.PENDING, currentLevel: deptLeadLevelNumber }); // Capture workflow snapshot AFTER workflow update succeeds try { await this.saveWorkflowHistory( requestId, `Reopened and moved to Department Lead level (${deptLeadLevelNumber}) - ${changeReason}`, userId, deptLeadLevel.levelId, deptLeadLevelNumber, deptLeadLevel.levelName || undefined ); } catch (snapshotError) { // Log error but don't fail the reopen - snapshot is for audit, not essential logger.error(`[DealerClaimService] Failed to save workflow history snapshot (non-critical):`, snapshotError); } // Reset the found level status to IN_PROGRESS so Dept Lead can approve again await deptLeadLevel.update({ status: ApprovalStatus.IN_PROGRESS, levelStartTime: now, tatStartTime: now, actionDate: undefined, comments: undefined }); await activityService.log({ requestId, type: 'approval', user: { userId, name: initiatorName }, timestamp: now.toISOString(), action: 'Request Reopened', details: data?.reason && data.reason.trim() ? `Initiator reopened the request for Department Lead approval. Reason: ${data.reason.trim()}` : 'Initiator reopened the request for Department Lead approval.' }); if (deptLeadLevel.approverId) { await notificationService.sendToUsers([deptLeadLevel.approverId], { title: `Request Reopened: ${wf.requestNumber}`, body: `Initiator has reopened the request "${wf.title}" after revision/discussion.`, requestNumber: wf.requestNumber, requestId: wf.requestId, url: `/request/${wf.requestNumber}`, type: 'assignment', priority: 'HIGH', actionRequired: true }); } break; } case 'DISCUSS': { // Format change reason to include the comment if provided const changeReason = data?.reason && data.reason.trim() ? `Discussion Requested: ${data.reason.trim()}` : 'Discussion Requested'; // Find Dealer level dynamically const approvalsDiscuss = await ApprovalLevel.findAll({ where: { requestId } }); const dealerLevelDiscuss = approvalsDiscuss.find(l => { const name = (l.levelName || '').toLowerCase(); return name.includes('dealer proposal') || l.levelNumber === 1; }); // Note: DISCUSS action doesn't change workflow state, so no snapshot needed // The action is logged in activity log only await activityService.log({ requestId, type: 'status_change', user: { userId, name: initiatorName }, timestamp: now.toISOString(), action: 'Discuss with Dealer', details: data?.reason && data.reason.trim() ? `Initiator indicated they will discuss with the dealer. Reason: ${data.reason.trim()}` : 'Initiator indicated they will discuss with the dealer.' }); if (dealerLevelDiscuss?.approverId) { await notificationService.sendToUsers([dealerLevelDiscuss.approverId], { title: `Discussion Requested: ${wf.requestNumber}`, body: `The initiator of request "${wf.title}" wants to discuss the proposal with you.`, requestNumber: wf.requestNumber, requestId: wf.requestId, url: `/request/${wf.requestNumber}`, type: 'info', priority: 'MEDIUM' }); } break; } case 'REVISE': { // Format change reason const changeReason = data?.reason && data.reason.trim() ? `Revision Requested: ${data.reason.trim()}` : 'Revision Requested'; // Find current level and previous level const allLevels = await ApprovalLevel.findAll({ where: { requestId }, order: [['levelNumber', 'ASC']] }); const currentLevelNumber = wf.currentLevel || 1; const currentLevel = allLevels.find(l => l.levelNumber === currentLevelNumber); if (!currentLevel) { throw new Error('Current approval level not found'); } // Find previous level (the one before current) const previousLevel = allLevels.find(l => l.levelNumber < currentLevelNumber); if (!previousLevel) { throw new Error('No previous level found to revise to'); } // Move back to previous level FIRST await wf.update({ status: WorkflowStatus.PENDING, currentLevel: previousLevel.levelNumber }); // Capture workflow snapshot AFTER workflow update succeeds try { await this.saveWorkflowHistory( requestId, `Moved back to previous level (${previousLevel.levelNumber}) - ${changeReason}`, userId, previousLevel.levelId, previousLevel.levelNumber, previousLevel.levelName || undefined ); } catch (snapshotError) { // Log error but don't fail the revise - snapshot is for audit, not essential logger.error(`[DealerClaimService] Failed to save workflow history snapshot (non-critical):`, snapshotError); } // Reset current level to PENDING await currentLevel.update({ status: ApprovalStatus.PENDING, actionDate: undefined, levelStartTime: undefined, levelEndTime: undefined, tatStartTime: undefined, elapsedHours: 0, tatPercentageUsed: 0, comments: undefined }); // Activate previous level await previousLevel.update({ status: ApprovalStatus.IN_PROGRESS, levelStartTime: now, tatStartTime: now, comments: changeReason, // Save revision reason as comment actionDate: undefined, levelEndTime: undefined, elapsedHours: 0, tatPercentageUsed: 0 }); await activityService.log({ requestId, type: 'assignment', user: { userId, name: initiatorName }, timestamp: now.toISOString(), action: 'Revision Requested', details: data?.reason && data.reason.trim() ? `Initiator requested revision. Moving back to previous step. Reason: ${data.reason.trim()}` : 'Initiator requested revision. Moving back to previous step.' }); // Notify the approver of the previous level if (previousLevel.approverId) { await notificationService.sendToUsers([previousLevel.approverId], { title: `Revision Required: ${wf.requestNumber}`, body: `Initiator has requested a revision for request "${wf.title}". The request has been moved back to your level.`, requestNumber: wf.requestNumber, requestId: wf.requestId, url: `/request/${wf.requestNumber}`, type: 'assignment', priority: 'HIGH', actionRequired: true }); } break; } } const { emitToRequestRoom } = await import('../realtime/socket'); emitToRequestRoom(requestId, 'request:updated', { requestId, requestNumber: wf.requestNumber, action: `INITIATOR_${action}`, timestamp: now.toISOString() }); } async getHistory(requestId: string): Promise { const history = await DealerClaimHistory.findAll({ where: { requestId }, order: [['version', 'DESC']], include: [ { model: User, as: 'changer', attributes: ['userId', 'displayName', 'email'] } ] }); // Map to plain objects and sort otherDocuments in snapshots return history.map(item => { const plain = item.get({ plain: true }); if (plain.snapshotData && plain.snapshotData.otherDocuments && Array.isArray(plain.snapshotData.otherDocuments)) { plain.snapshotData.otherDocuments.sort((a: any, b: any) => { const dateA = a.uploadedAt ? new Date(a.uploadedAt).getTime() : 0; const dateB = b.uploadedAt ? new Date(b.uploadedAt).getTime() : 0; return dateB - dateA; }); } return plain; }); } /** * Push CSV to WFM folder and track status * This is used by both auto-trigger and manual re-trigger */ async pushWFMCSV(requestId: string): Promise { const invoice = await ClaimInvoice.findOne({ where: { requestId } }); if (!invoice) { throw new Error('Invoice not found'); } try { const [invoiceItems, claimDetails, internalOrder] = await Promise.all([ ClaimInvoiceItem.findAll({ where: { requestId } }), DealerClaimDetails.findOne({ where: { requestId } }), InternalOrder.findOne({ where: { requestId } }) ]); if (!claimDetails) { throw new Error('Dealer claim details not found'); } const requestNumber = (await WorkflowRequest.findByPk(requestId))?.requestNumber || 'UNKNOWN'; if (invoiceItems.length > 0) { let sapRefNo = ''; let isNonGst = false; if (claimDetails.activityType) { const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } }); sapRefNo = activity?.sapRefNo || ''; const taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST'); isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST'; } const formatDate = (date: any) => { const d = new Date(date); const year = d.getFullYear(); const month = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${year}${month}${day}`; }; const csvData = invoiceItems.map((item: any) => { const row: any = { TRNS_UNIQ_NO: item.transactionCode || '', CLAIM_NUMBER: requestNumber, INV_NUMBER: invoice.invoiceNumber || '', DEALER_CODE: claimDetails.dealerCode, IO_NUMBER: internalOrder?.ioNumber || '', CLAIM_DOC_TYP: sapRefNo, CLAIM_TYPE: claimDetails.activityType, CLAIM_DATE: formatDate(invoice.invoiceDate || new Date()), CLAIM_AMT: item.assAmt }; if (!isNonGst) { const totalTax = Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0); row.GST_AMT = totalTax.toFixed(2); row.GST_PERCENTAGE = item.gstRt; } return row; }); await wfmFileService.generateIncomingClaimCSV(csvData, `CN_${claimDetails.dealerCode}_${requestNumber}.csv`, isNonGst); await invoice.update({ wfmPushStatus: 'SUCCESS', wfmPushError: null }); logger.info(`[DealerClaimService] WFM CSV successfully pushed for request ${requestNumber}`); } else { logger.warn(`[DealerClaimService] No invoice items found for WFM push: ${requestNumber}`); } } catch (error: any) { const errorMessage = error instanceof Error ? error.message : 'Unknown error pushing to WFM'; await invoice.update({ wfmPushStatus: 'FAILED', wfmPushError: errorMessage }); throw error; } } }