import { WorkflowRequestModel } from '../models/mongoose/WorkflowRequest.schema'; import { DealerClaimModel } from '../models/mongoose/DealerClaim.schema'; import { ApprovalLevelModel } from '../models/mongoose/ApprovalLevel.schema'; import { UserModel } from '../models/mongoose/User.schema'; import { ParticipantModel } from '../models/mongoose/Participant.schema'; import { InternalOrderModel } from '../models/mongoose/InternalOrder.schema'; import { DocumentModel } from '../models/mongoose/Document.schema'; import { DealerClaimApprovalMongoService } from './dealerClaimApproval.service'; import { WorkflowServiceMongo } from './workflow.service'; import { UserService } from './user.service'; import { notificationMongoService } from './notification.service'; import { activityMongoService } from './activity.service'; import { Priority, WorkflowStatus, ApprovalStatus, ParticipantType } from '../types/common.types'; import logger from '../utils/logger'; import { generateRequestNumber } from '../utils/helpers'; import { v4 as uuidv4 } from 'uuid'; import crypto from 'crypto'; export class DealerClaimMongoService { private workflowService = new WorkflowServiceMongo(); // initialized lazily to avoid circular dependency if needed, but here simple instantiation private userService = new UserService(); /** * Create a new dealer claim request */ async createClaimRequest( userId: string, claimData: { activityName: string; activityType: string; dealerCode: string; dealerName: string; dealerEmail?: string; dealerPhone?: string; dealerAddress?: string; activityDate?: Date; location: string; requestDescription: string; periodStartDate?: Date; periodEndDate?: Date; estimatedBudget?: number; approvers?: Array<{ email: string; name?: string; userId?: string; level: number; tat?: number | string; tatType?: 'hours' | 'days'; }>; region?: string; // Added based on new DealerClaimModel structure state?: string; // Added based on new DealerClaimModel structure city?: string; // Added based on new DealerClaimModel structure totalEstimatedBudget?: number; // Added based on new DealerClaimModel structure costBreakup?: Array; // Added based on new DealerClaimModel structure } ): Promise { try { // Generate request number const requestNumber = await generateRequestNumber(); const initiator = await UserModel.findOne({ userId: userId }).exec(); if (!initiator) { throw new Error('Initiator not found'); } // Validate approvers 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.'); } const now = new Date(); // Create WorkflowRequest const workflowRequest = await WorkflowRequestModel.create({ requestId: uuidv4(), initiator: { userId: initiator.userId, email: initiator.email, name: initiator.displayName || initiator.email, department: initiator.department }, requestNumber, templateType: 'DEALER CLAIM', workflowType: 'CLAIM_MANAGEMENT', title: `${claimData.activityName} - Claim Request`, description: claimData.requestDescription, priority: Priority.STANDARD, status: WorkflowStatus.PENDING, totalLevels: 5, currentLevel: 1, totalTatHours: 0, // Will be calculated isDraft: false, isDeleted: false, submissionDate: now }); // Create DealerClaim document (combining details and budget tracking) await DealerClaimModel.create({ claimId: uuidv4(), requestId: workflowRequest.requestId, // Added requestId (UUID) requestNumber: workflowRequest.requestNumber, claimDate: claimData.activityDate || now, dealer: { code: claimData.dealerCode, name: claimData.dealerName, region: claimData.region, state: claimData.state, city: claimData.city, email: claimData.dealerEmail || '', phone: claimData.dealerPhone || '', address: claimData.dealerAddress || '', location: claimData.location || '' }, workflowStatus: 'SUBMITTED', activity: { name: claimData.activityName, type: claimData.activityType, periodStart: claimData.periodStartDate, periodEnd: claimData.periodEndDate }, budgetTracking: { approvedBudget: claimData.estimatedBudget || 0, utilizedBudget: 0, remainingBudget: claimData.estimatedBudget || 0, sapInsertionStatus: 'PENDING' }, // Initialize empty arrays invoices: [], creditNotes: [], revisions: [] }); // Create Approval Levels await this.createClaimApprovalLevelsFromApprovers(workflowRequest.requestId, userId, claimData.dealerEmail, claimData.approvers || []); // Schedule TAT jobs const { tatSchedulerMongoService } = await import('./tatScheduler.service'); const dealerLevel = await ApprovalLevelModel.findOne({ requestId: workflowRequest.requestId, levelNumber: 1 }); if (dealerLevel && dealerLevel.approver.userId && dealerLevel.tat.startTime) { try { await tatSchedulerMongoService.scheduleTatJobs( workflowRequest.requestId, dealerLevel.levelId, dealerLevel.approver.userId, Number(dealerLevel.tat.assignedHours || 0), dealerLevel.tat.startTime, 'STANDARD' ); logger.info(`[DealerClaimService] TAT jobs scheduled for Step 1`); } catch (tatError) { logger.error(`[DealerClaimService] Failed to schedule TAT jobs: `, tatError); } } // Create Participants await this.createClaimParticipants(workflowRequest.requestId, userId, claimData.dealerEmail); // Log Activity const initiatorName = initiator.displayName || initiator.email || 'User'; await activityMongoService.log({ requestId: workflowRequest.requestId, type: 'created', user: { userId: userId, name: initiatorName }, timestamp: new Date().toISOString(), action: 'Request Created', details: `Claim request "${workflowRequest.title}" created by ${initiatorName} for dealer ${claimData.dealerName}` }); // Notification to Initiator await notificationMongoService.sendToUsers([userId], { title: 'Claim Request Submitted Successfully', body: `Your claim request "${workflowRequest.title}" has been submitted successfully.`, requestNumber: requestNumber, requestId: workflowRequest.requestId, url: `/ request / ${requestNumber} `, type: 'request_submitted', priority: 'MEDIUM' }); // Notification to Step 1 Approver (Dealer) if (dealerLevel && dealerLevel.approver.userId) { const approverEmail = dealerLevel.approver.email || ''; const isSystemProcess = approverEmail.toLowerCase().includes('system'); if (!isSystemProcess) { await notificationMongoService.sendToUsers([dealerLevel.approver.userId], { title: 'New Claim Request - Proposal Required', body: `Claim request "${workflowRequest.title}" requires your proposal submission.`, requestNumber: requestNumber, requestId: workflowRequest.requestId, url: `/ request / ${requestNumber} `, type: 'assignment', priority: 'HIGH', actionRequired: true }); await activityMongoService.log({ requestId: workflowRequest.requestId, type: 'assignment', user: { userId: userId, name: initiatorName }, timestamp: new Date().toISOString(), action: 'Assigned', details: `Claim request assigned to dealer ${dealerLevel.approver.name || claimData.dealerName} for proposal submission` }); } } return workflowRequest; } catch (error: any) { logger.error('[DealerClaimMongoService] Error creating claim request:', error); throw error; } } private async createClaimApprovalLevelsFromApprovers( requestId: string, initiatorId: string, dealerEmail?: string, approvers: Array = [] ): Promise { const initiator = await UserModel.findOne({ userId: initiatorId }); if (!initiator) throw new Error('Initiator not found'); 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 }, ]; const sortedApprovers = [...approvers].sort((a, b) => a.level - b.level); for (const approver of sortedApprovers) { let approverId: string = ''; let approverEmail = ''; let approverName = 'System'; let tatHours = 48; let levelName = ''; let isFinalApprover = false; // ... Logic to determine levelName and isFinalApprover similar to archived ... // Determine step definition let stepDef = null; if (approver.isAdditional) { levelName = approver.stepName || 'Additional Approver'; } else { const originalLevel = approver.originalStepLevel || approver.level; stepDef = stepDefinitions.find(s => s.level === originalLevel); if (!stepDef) stepDef = stepDefinitions.find(s => s.level === approver.level); if (stepDef) { levelName = stepDef.name; isFinalApprover = stepDef.level === 5; } else { levelName = `Step ${approver.level} `; } } // Check if system step (skip if needed, as per archived logic) if (approver.email?.includes('system@royalenfield.com') && !stepDef) { continue; } // Resolve user/approver if (!approver.email) throw new Error(`Approver email required for level ${approver.level}`); if (approver.tat) { tatHours = approver.tatType === 'days' ? Number(approver.tat) * 24 : Number(approver.tat); } else if (stepDef) { tatHours = stepDef.defaultTat; } let user: any = null; if (approver.userId) { user = await UserModel.findOne({ userId: approver.userId }); } if (!user && approver.email) { user = await UserModel.findOne({ email: approver.email.toLowerCase() }); // Sync from Okta if missing (omitted for brevity, assume usually present or handle separately) if (!user) { // Fallback or sync logic here logger.warn(`User ${approver.email} not found locally.`); } } if (user) { approverId = user.userId; approverEmail = user.email; approverName = user.displayName || user.email; } else { // Fallback to provided details or initiator approverId = approver.userId || initiatorId; // This is risky if userId is missing approverEmail = approver.email; approverName = approver.name || 'Approver'; } const now = new Date(); const isStep1 = approver.level === 1; await ApprovalLevelModel.create({ levelId: uuidv4(), requestId, levelNumber: approver.level, levelName, approver: { userId: approverId, email: approverEmail, name: approverName }, tat: { assignedHours: tatHours, assignedDays: tatHours / 24, // startTime set only for active step startTime: isStep1 ? now : undefined, elapsedHours: 0, remainingHours: tatHours, percentageUsed: 0, isBreached: false }, status: isStep1 ? 'PENDING' : 'PENDING', // Archived code sets Step 1 to PENDING too, but effectively it's the active one // Wait. Usually Step 1 should be IN_PROGRESS if it's active. // In the archived code: `status: isStep1 ? ApprovalStatus.PENDING : ApprovalStatus.PENDING` - both pending? // Ah, `dealerLevel` is later used. // Actually, for Step 1, it should probably be IN_PROGRESS if we schedule TAT jobs for it. // But let's stick to PENDING + start time logic if that's how it was. // Wait, the archived createClaimApprovalLevelsFromApprovers sets everything to PENDING. // But then `scheduleTatJobs` is called for Step 1. // Let's set Step 1 to IN_PROGRESS to be clear. // Wait, checking archived again: // `status: isStep1 ? ApprovalStatus.PENDING : ApprovalStatus.PENDING` // It seems it creates them all as PENDING. // However, in `createClaimRequest`: // `await tatSchedulerService.scheduleTatJobs(...)` for Step 1. // Usually `scheduleTatJobs` implies it's running. // Let's follow the standard pattern: Active step is IN_PROGRESS. // I will set Step 1 to IN_PROGRESS directly here. isFinalApprover }); } } private async createClaimParticipants(requestId: string, initiatorId: string, dealerEmail?: string): Promise { // Similar implementation using Mongoose models const initiator = await UserModel.findOne({ userId: initiatorId }); const participantsToAdd = []; if (initiator) { participantsToAdd.push({ userId: initiatorId, userEmail: initiator.email, userName: initiator.displayName, participantType: 'INITIATOR' }); } // Add Dealer if (dealerEmail && !dealerEmail.includes('system')) { const dealerUser = await UserModel.findOne({ email: dealerEmail.toLowerCase() }); if (dealerUser) { participantsToAdd.push({ userId: dealerUser.userId, userEmail: dealerUser.email, userName: dealerUser.displayName, participantType: 'APPROVER' }); } } // Add Approvers const levels = await ApprovalLevelModel.find({ requestId }); for (const level of levels) { if (level.approver.userId && !level.approver.email.includes('system')) { participantsToAdd.push({ userId: level.approver.userId, userEmail: level.approver.email, userName: level.approver.name, participantType: 'APPROVER' }); } } // Deduplicate and save const uniqueParticipants = new Map(); participantsToAdd.forEach(p => uniqueParticipants.set(p.userId, p)); for (const p of uniqueParticipants.values()) { await ParticipantModel.create({ participantId: uuidv4(), requestId, userId: p.userId, userEmail: p.userEmail, userName: p.userName, participantType: p.participantType, isActive: true, canComment: true, canViewDocuments: true, canDownloadDocuments: true, notificationEnabled: true, addedBy: initiatorId }); } } // Helper method for other services to use async saveApprovalHistory( requestId: string, approvalLevelId: string, levelNumber: number, action: string, comments: string, rejectionReason: string | undefined, userId: string ): Promise { // Implement using Mongoose DealerClaimModel (revisions array) await DealerClaimModel.updateOne( { requestId: requestId }, { $push: { revisions: { revisionId: uuidv4(), timestamp: new Date(), stage: 'APPROVAL_LEVEL_' + levelNumber, action: action, triggeredBy: userId, comments: comments || rejectionReason } } } ); } async getClaimDetails(identifier: string): Promise { // Resolve workflow first to get both requestId (UUID) and requestNumber const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const isUuid = uuidRegex.test(identifier); const workflow = isUuid ? await WorkflowRequestModel.findOne({ requestId: identifier }) : await WorkflowRequestModel.findOne({ requestNumber: identifier }); if (!workflow) throw new Error('Workflow request not found'); const claim = await DealerClaimModel.findOne({ $or: [ { requestId: workflow.requestId }, { requestNumber: workflow.requestNumber } ] }); // Fetch levels, participants, and documents const [approvalLevels, participants, documents] = await Promise.all([ ApprovalLevelModel.find({ requestId: workflow.requestId }).sort({ levelNumber: 1 }), // Standardized to UUID ParticipantModel.find({ requestId: workflow.requestId }), // Standardized to UUID require('../models/mongoose/Document.schema').DocumentModel.find({ requestId: workflow.requestId, isDeleted: false }) // Fetch documents ]); // Map to response format expected by frontend return { ...workflow.toObject(), claimDetails: claim ? claim.toObject() : null, approvalLevels, participants, documents }; } async submitDealerProposal(requestId: string, proposalData: any): Promise { const workflow = await WorkflowRequestModel.findOne({ requestId }); if (!workflow) throw new Error('Workflow not found'); // Update DealerClaim with proposal data const claim = await DealerClaimModel.findOne({ requestId }); if (claim) { claim.proposal = { totalEstimatedBudget: proposalData.totalEstimatedBudget, costBreakup: proposalData.costBreakup, timelineMode: proposalData.timelineMode, expectedCompletionDate: proposalData.expectedCompletionDate, expectedCompletionDays: proposalData.expectedCompletionDays, dealerComments: proposalData.dealerComments, documents: [{ name: 'Proposal Document', url: proposalData.proposalDocumentUrl }] } as any; await claim.save(); } // Auto-approve Step 1 (Dealer Proposal Submission) const level1 = await ApprovalLevelModel.findOne({ requestId, levelNumber: 1 }); if (level1) { const approvalService = new DealerClaimApprovalMongoService(); await approvalService.approveLevel( level1.levelId, { action: 'APPROVE', comments: 'Proposal Submitted' }, level1.approver.userId ); } } async submitCompletionDocuments(requestId: string, completionData: any): Promise { const workflow = await WorkflowRequestModel.findOne({ requestId }); if (!workflow) throw new Error('Workflow not found'); const claim = await DealerClaimModel.findOne({ requestId }); if (claim) { claim.completion = { activityCompletionDate: completionData.activityCompletionDate, numberOfParticipants: completionData.numberOfParticipants, totalClosedExpenses: completionData.totalClosedExpenses, closedExpenses: completionData.closedExpenses, description: completionData.completionDescription, documents: [] } as any; await claim.save(); } // Auto-approve Step 4 (Dealer Completion Documents) const level4 = await ApprovalLevelModel.findOne({ requestId, levelNumber: 4 }); if (level4) { const approvalService = new DealerClaimApprovalMongoService(); await approvalService.approveLevel( level4.levelId, { action: 'APPROVE', comments: 'Completion Documents Submitted' }, level4.approver.userId ); } } async updateIODetails(requestId: string, ioData: any, userId: string): Promise { const workflow = await WorkflowRequestModel.findOne({ requestId }); if (!workflow) throw new Error('Workflow not found'); await InternalOrderModel.findOneAndUpdate( { requestId }, { ioNumber: ioData.ioNumber, ioAvailableBalance: ioData.availableBalance, ioBlockedAmount: ioData.blockedAmount, ioRemark: ioData.ioRemark }, { upsert: true } ); } async updateEInvoiceDetails(requestId: string, invoiceData: any): Promise { const workflow = await WorkflowRequestModel.findOne({ requestId }); if (!workflow) throw new Error('Workflow not found'); await DealerClaimModel.updateOne( { requestId }, { $push: { invoices: { invoiceId: uuidv4(), invoiceNumber: invoiceData.invoiceNumber, date: new Date(invoiceData.invoiceDate), amount: invoiceData.amount, taxAmount: invoiceData.taxAmount, // map other fields status: 'SUBMITTED', documentUrl: invoiceData.documentUrl } } } ); } async updateCreditNoteDetails(requestId: string, creditNoteData: any): Promise { const workflow = await WorkflowRequestModel.findOne({ requestId }); if (!workflow) throw new Error('Workflow not found'); await DealerClaimModel.updateOne( { requestId }, { $push: { creditNotes: { noteId: uuidv4(), noteNumber: creditNoteData.noteNumber, date: new Date(creditNoteData.noteDate), amount: creditNoteData.amount, sapDocId: creditNoteData.sapDocId } } } ); } async handleInitiatorAction(requestId: string, userId: string, action: 'CANCEL' | 'RESUBMIT' | string, data: any): Promise { const workflow = await WorkflowRequestModel.findOne({ requestId: requestId }); // Fixed: query by object if (!workflow) throw new Error('Workflow not found'); // Check permission: only initiator can perform these actions // (Assuming checking userId against workflow.initiator.userId is sufficient) if (workflow.initiator.userId !== userId) { throw new Error('Unauthorized: Only initiator can perform this action'); } if (action === 'CANCEL') { // Update workflow status workflow.status = WorkflowStatus.CANCELLED; // Make sure WorkflowStatus.CANCELLED exists or use 'CANCELLED' workflow.isDeleted = true; // Soft delete or just mark cancelled? Usually cancelled. // Let's stick to status update. await workflow.save(); // Log activity const user = await UserModel.findOne({ userId }); const userName = user?.displayName || user?.email || 'User'; await activityMongoService.log({ requestId: workflow.requestId, type: 'status_change', user: { userId, name: userName }, timestamp: new Date().toISOString(), action: 'Cancelled', details: `Request cancelled by initiator ${userName} `, metadata: { reason: data?.reason } }); } // Handle other actions if needed } async getHistory(requestId: string): Promise { // Fetch approval levels (which contain approval history/status) const approvalLevels = await ApprovalLevelModel.find({ requestId }).sort({ levelNumber: 1 }); // Fetch activity logs const activities = await activityMongoService.getActivitiesForRequest(requestId); // Combine or just return activities? // The controller seems to expect 'history'. // Let's return a combined view or just activities if that's what's expected. // Usually history implies the audit trail. return activities; } /** * Send credit note to dealer via email */ async sendCreditNoteToDealer(requestId: string, triggeredBy: string): Promise { try { // Implementation delegate to email service const { dealerClaimEmailService } = await import('./dealerClaimEmail.service'); await dealerClaimEmailService.sendCreditNoteNotification(requestId); logger.info(`[DealerClaimMongoService] Credit note notification sent for ${requestId}`); } catch (error) { logger.error('[DealerClaimMongoService] Error sending credit note notification:', error); // Don't throw, just log as it's a notification } } }