diff --git a/src/common/config/constants.ts b/src/common/config/constants.ts index 858de56..3a66b4b 100644 --- a/src/common/config/constants.ts +++ b/src/common/config/constants.ts @@ -108,6 +108,7 @@ export const RESIGNATION_STAGES = { ASM: 'ASM', RBM: 'RBM', ZBH: 'ZBH', + DD_LEAD: 'DD Lead', NBH: 'NBH', DD_ADMIN: 'DD Admin', LEGAL: 'Legal', diff --git a/src/common/middleware/stageRoleCheck.ts b/src/common/middleware/stageRoleCheck.ts new file mode 100644 index 0000000..3c24956 --- /dev/null +++ b/src/common/middleware/stageRoleCheck.ts @@ -0,0 +1,61 @@ +import { Response, NextFunction } from 'express'; +import db from '../../database/models/index.js'; +import { AuthRequest } from '../../types/express.types.js'; + +/** + * Middleware to check if the user has the correct role for the current approval level of an LOI/LOA request. + */ +export const checkApprovalPermission = (type: 'LOI' | 'LOA') => { + return async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { requestId } = req.params; + const model = type === 'LOI' ? db.LoiApproval : db.LoaApproval; + + // Find current pending approval level + const currentApproval = await model.findOne({ + where: { requestId, action: 'Pending' }, + order: [['level', 'ASC']] + }); + + if (!currentApproval) { + return res.status(404).json({ success: false, message: `No pending ${type} approval found` }); + } + + // Check if user's role matches the required role for this level + if (req.user?.roleCode !== currentApproval.approverRole && req.user?.roleCode !== 'Super Admin') { + return res.status(403).json({ + success: false, + message: `Permission denied. Only ${currentApproval.approverRole} can perform this action at Level ${currentApproval.level}.` + }); + } + + next(); + } catch (error) { + console.error('Approval permission check error:', error); + res.status(500).json({ success: false, message: 'Internal server error during permission check' }); + } + }; +}; + +/** + * Middleware to check if the user has the correct role for a specific application stage. + */ +export const checkStagePermission = (allowedRoles: string[]) => { + return async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' }); + + if (!allowedRoles.includes(req.user.roleCode) && req.user.roleCode !== 'Super Admin') { + return res.status(403).json({ + success: false, + message: 'Access denied for this stage action', + requiredRoles: allowedRoles + }); + } + + next(); + } catch (error) { + res.status(500).json({ success: false, message: 'Stage permission check failed' }); + } + }; +}; diff --git a/src/common/utils/externalMocks.service.ts b/src/common/utils/externalMocks.service.ts new file mode 100644 index 0000000..1029994 --- /dev/null +++ b/src/common/utils/externalMocks.service.ts @@ -0,0 +1,115 @@ +import { v4 as uuidv4 } from 'uuid'; + +/** + * External Mocks Service + * Centralized place for mocking external dependencies until real APIs are ready. + */ +export const ExternalMocksService = { + + /** + * Mock SAP Dealer Code Generation + * Returns random codes for Sales, Service, GMA, and Gear. + */ + mockGenerateSapCodes: async (applicationId: string) => { + console.log(`[MOCK SAP] Generating codes for application: ${applicationId}`); + // Simulate network delay + await new Promise(resolve => setTimeout(resolve, 1000)); + + return { + success: true, + data: { + salesCode: `SLS-${Math.floor(1000 + Math.random() * 9000)}`, + serviceCode: `SRV-${Math.floor(1000 + Math.random() * 9000)}`, + gmaCode: `GMA-${Math.floor(1000 + Math.random() * 9000)}`, + gearCode: `GER-${Math.floor(1000 + Math.random() * 9000)}`, + sapMasterId: uuidv4().substring(0, 8).toUpperCase() + } + }; + }, + + /** + * Mock Gemini AI Summary Generation + * Generates a consensus-based summary from panel feedback. + */ + mockGenerateAiSummary: async (applicationId: string, feedbackList: any[]) => { + console.log(`[MOCK GEMINI] Generating AI summary for: ${applicationId}`); + await new Promise(resolve => setTimeout(resolve, 1500)); + + const approveCount = feedbackList.filter(f => f.recommendation === 'Approve' || f.recommendation === 'Selected').length; + const total = feedbackList.length; + + let summary = ""; + if (approveCount === total) { + summary = "The panel has reached a strong consensus for approval. The candidate demonstrates exceptional business acumen and brand passion, with zero identified risks."; + } else if (approveCount > total / 2) { + summary = "The majority of the panel recommends approval, highlighting strong local market knowledge, though some concerns were raised regarding initial working capital which should be monitored."; + } else { + summary = "The panel is divided or leaning towards rejection. Concerns primarily focus on inadequate infrastructure readiness and limited previous experience in the automotive sector."; + } + + return { + success: true, + summary + }; + }, + + /** + * Mock Google Calendar Invite + * Returns a mock meeting link. + */ + mockScheduleMeeting: async (details: any) => { + console.log(`[MOCK GOOGLE CALENDAR] Scheduling meeting: ${details.type} at ${details.scheduledAt}`); + return { + success: true, + meetLink: `https://meet.google.com/mock-${uuidv4().substring(0, 8)}`, + calendarEventId: uuidv4() + }; + }, + + /** + * Mock WhatsApp Notification + * Logs the notification instead of sending. + */ + mockSendWhatsApp: async (phone: string, message: string) => { + console.log(`[MOCK WHATSAPP] To: ${phone} | Message: ${message}`); + // In a real implementation, sensitive docs would be excluded here per SRS. + return { + success: true, + messageId: `WA-${uuidv4().substring(0, 12)}` + }; + }, + + /** + * Mock SAP Status Synchronization + * Simulates updating dealer status in SAP. + */ + mockSyncDealerStatusToSap: async (dealerCode: string, status: string) => { + console.log(`[MOCK SAP] Syncing status for dealer ${dealerCode}: ${status}`); + await new Promise(resolve => setTimeout(resolve, 1200)); + return { + success: true, + sapTransactionId: `SAP-TX-${uuidv4().substring(0, 8).toUpperCase()}`, + timestamp: new Date().toISOString() + }; + }, + + /** + * Mock SAP Financial Data Retrieval + * Simulates fetching outstanding dues and credit limits from SAP. + */ + mockGetFinancialDuesFromSap: async (dealerCode: string) => { + console.log(`[MOCK SAP] Fetching financial dues for dealer: ${dealerCode}`); + await new Promise(resolve => setTimeout(resolve, 1500)); + return { + success: true, + data: { + outstandingInvoices: Math.floor(50000 + Math.random() * 200000), + securityDeposit: Math.floor(100000 + Math.random() * 500000), + creditLimit: Math.floor(1000000 + Math.random() * 5000000), + pendingClaims: Math.floor(10000 + Math.random() * 50000) + } + }; + } +}; + +export default ExternalMocksService; diff --git a/src/modules/assessment/assessment.controller.ts b/src/modules/assessment/assessment.controller.ts index d604534..3411125 100644 --- a/src/modules/assessment/assessment.controller.ts +++ b/src/modules/assessment/assessment.controller.ts @@ -33,11 +33,13 @@ export const submitQuestionnaireResponse = async (req: AuthRequest, res: Respons try { const { applicationId, questionnaireId, responses } = req.body; // responses: [{ questionId, responseValue, attachmentUrl }] - // Calculate score logic (Placeholder) - let calculatedScore = 0; - let totalWeight = 0; + const application = await db.Application.findByPk(applicationId); + if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); + + let totalWeightedScore = 0; for (const resp of responses) { + // Save response await QuestionnaireResponse.create({ applicationId, questionnaireId, @@ -45,19 +47,56 @@ export const submitQuestionnaireResponse = async (req: AuthRequest, res: Respons responseValue: resp.responseValue, attachmentUrl: resp.attachmentUrl }); - // Add scoring logic here based on question type/weight + + // Scoring Logic: + // 1. Fetch the question to get its weight + const question = await QuestionnaireQuestion.findByPk(resp.questionId, { + include: [{ model: db.QuestionnaireOption, as: 'questionOptions' }] + }); + + if (question) { + let questionScore = 0; + // If it's an option-based question, find the selected option's score + if (question.questionOptions && question.questionOptions.length > 0) { + const selectedOption = question.questionOptions.find((opt: any) => opt.optionText === resp.responseValue); + if (selectedOption) { + questionScore = selectedOption.score; + } + } else if (!isNaN(Number(resp.responseValue))) { + // If it's a numeric input, use it directly (if appropriate) + questionScore = Number(resp.responseValue); + } + + totalWeightedScore += (questionScore * (question.weight || 1)); + } } - // Create Score Record - await QuestionnaireScore.create({ + // Create/Update Score Record + await QuestionnaireScore.upsert({ applicationId, questionnaireId, - score: calculatedScore, - maxScore: 100, // Placeholder + score: totalWeightedScore, + maxScore: 100, // Based on SRS section weightages status: 'Completed' }); - res.status(201).json({ success: true, message: 'Responses submitted successfully' }); + // Update Application + await application.update({ + score: totalWeightedScore, + overallStatus: 'Questionnaire Completed', + progressPercentage: 20 + }); + + // Log Status History + await db.ApplicationStatusHistory.create({ + applicationId: application.id, + previousStatus: 'Questionnaire Pending', + newStatus: 'Questionnaire Completed', + changedBy: req.user?.id || null, + reason: 'Questionnaire submitted by applicant' + }); + + res.status(201).json({ success: true, message: 'Responses submitted and scored successfully', score: totalWeightedScore }); } catch (error) { console.error('Submit response error:', error); res.status(500).json({ success: false, message: 'Error submitting responses' }); @@ -99,6 +138,24 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => { await db.Application.update({ overallStatus: newStatus }, { where: { id: applicationId } }); + // MOCK INTEGRATIONS + // 1. Google Calendar Mock + const { meetLink } = await ExternalMocksService.mockScheduleMeeting({ + type, + scheduledAt, + applicationId + }); + await interview.update({ linkOrLocation: meetLink }); + + // 2. WhatsApp Mock + const appRecord = await db.Application.findByPk(applicationId); + if (appRecord) { + await ExternalMocksService.mockSendWhatsApp( + appRecord.phone, + `Dear ${appRecord.applicantName}, your ${type} is scheduled at ${scheduledAt}. Join here: ${meetLink}` + ); + } + if (participants && participants.length > 0) { console.log(`Processing ${participants.length} participants...`); for (const userId of participants) { @@ -316,6 +373,48 @@ export const submitLevel2Feedback = async (req: AuthRequest, res: Response) => { // --- AI Summary --- +import { ExternalMocksService } from '../../common/utils/externalMocks.service.js'; + +export const generateAiSummary = async (req: AuthRequest, res: Response) => { + try { + const { applicationId } = req.params; + + // 1. Fetch all interview evaluations for this application + const interviews = await Interview.findAll({ + where: { applicationId }, + include: [{ model: InterviewEvaluation, as: 'evaluations' }] + }); + + const allEvaluations = interviews.flatMap((i: any) => i.evaluations || []); + + if (allEvaluations.length === 0) { + return res.status(400).json({ success: false, message: 'No interview evaluations found to summarize' }); + } + + // 2. Map evaluations to a format Gemini (mock) understands + const feedbackList = allEvaluations.map((e: any) => ({ + recommendation: e.recommendation, + feedback: e.qualitativeFeedback + })); + + // 3. Trigger Mock Gemini call + const { summary } = await ExternalMocksService.mockGenerateAiSummary(applicationId as string, feedbackList); + + // 4. Save/Update AI Summary + const [aiSummary, created] = await AiSummary.upsert({ + applicationId, + summary, + status: 'Generated', + modelUsed: 'Gemini 1.5 Pro (Mock)' + }, { returning: true }); + + res.json({ success: true, data: aiSummary }); + } catch (error) { + console.error('Generate AI summary error:', error); + res.status(500).json({ success: false, message: 'Error generating AI summary' }); + } +}; + export const getAiSummary = async (req: Request, res: Response) => { try { const { applicationId } = req.params; @@ -325,6 +424,10 @@ export const getAiSummary = async (req: Request, res: Response) => { order: [['createdAt', 'DESC']] }); + if (!summary) { + return res.json({ success: false, message: 'No AI Summary generated yet' }); + } + res.json({ success: true, data: summary }); } catch (error) { console.error('Get AI summary error:', error); diff --git a/src/modules/assessment/assessment.routes.ts b/src/modules/assessment/assessment.routes.ts index b0eba4c..ce1b6fd 100644 --- a/src/modules/assessment/assessment.routes.ts +++ b/src/modules/assessment/assessment.routes.ts @@ -20,6 +20,7 @@ router.post('/recommendation', assessmentController.updateRecommendation); router.post('/decision', assessmentController.updateInterviewDecision); // AI Summary +router.post('/ai-summary/:applicationId', assessmentController.generateAiSummary); router.get('/ai-summary/:applicationId', assessmentController.getAiSummary); export default router; diff --git a/src/modules/eor/eor.controller.ts b/src/modules/eor/eor.controller.ts index 96b4f07..2ee4299 100644 --- a/src/modules/eor/eor.controller.ts +++ b/src/modules/eor/eor.controller.ts @@ -28,12 +28,44 @@ export const getChecklist = async (req: Request, res: Response) => { export const createChecklist = async (req: AuthRequest, res: Response) => { try { const { applicationId } = req.body; - const checklist = await EorChecklist.create({ applicationId }); - // Create Default Items? - // const defaultItems = [...]; - // for... - res.status(201).json({ success: true, message: 'EOR Checklist initiated', data: checklist }); + + const application = await db.Application.findByPk(applicationId); + if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); + + const [checklist, created] = await EorChecklist.findOrCreate({ + where: { applicationId }, + defaults: { status: 'In Progress' } + }); + + if (created) { + // Define Default Mandatory Items per SRS + const defaultItems = [ + { itemType: 'Architecture', description: 'Brand Signage & Facade as per guidelines' }, + { itemType: 'Architecture', description: 'Interior Fit-out & Furniture' }, + { itemType: 'Sales', description: 'Display Vehicles (All models) available' }, + { itemType: 'Sales', description: 'Test Ride Vehicles registered' }, + { itemType: 'Training', description: 'Sales Staff (DSE) Training Completed' }, + { itemType: 'Training', description: 'Service Technician (Pro-Meck) Training' }, + { itemType: 'IT', description: 'DMS (Dealer Management System) configured' }, + { itemType: 'IT', description: 'High-speed internet & IT Hardware ready' }, + { itemType: 'Service', description: 'Special Tools & Equipment installed' }, + { itemType: 'Finance', description: 'Bank Account mapped in SAP' } + ]; + + const itemsData = defaultItems.map(item => ({ + ...item, + checklistId: checklist.id, + isCompliant: false + })); + + await EorChecklistItem.bulkCreate(itemsData); + } + + await application.update({ overallStatus: 'EOR In Progress' }); + + res.status(201).json({ success: true, message: 'EOR Checklist initiated with default items', data: checklist }); } catch (error) { + console.error('Create EOR checklist error:', error); res.status(500).json({ success: false, message: 'Error creating checklist' }); } } diff --git a/src/modules/loa/loa.controller.ts b/src/modules/loa/loa.controller.ts index 4535832..8a7efda 100644 --- a/src/modules/loa/loa.controller.ts +++ b/src/modules/loa/loa.controller.ts @@ -27,27 +27,113 @@ export const createRequest = async (req: AuthRequest, res: Response) => { try { const { applicationId } = req.body; - const request = await LoaRequest.create({ - applicationId, - requestedBy: req.user?.id, - status: 'Pending' + const application = await db.Application.findByPk(applicationId); + if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); + + const [request, created] = await LoaRequest.findOrCreate({ + where: { applicationId }, + defaults: { + requestedBy: req.user?.id, + status: 'In Progress' + } }); - await LoaApproval.create({ - requestId: request.id, - level: 1, - approverRole: 'Zone Manager', // Example - action: 'Pending' + // Initialize first level approval (DD Head) for LOA + await LoaApproval.findOrCreate({ + where: { requestId: request.id, level: 1 }, + defaults: { + approverRole: 'DD Head', + action: 'Pending' + } }); - res.status(201).json({ success: true, message: 'LOA Request created', data: request }); + await application.update({ overallStatus: 'LOA Pending' }); + + res.status(201).json({ success: true, message: 'LOA Request initiated with DD Head approval', data: request }); } catch (error) { console.error('Create LOA request error:', error); res.status(500).json({ success: false, message: 'Error creating LOA request' }); } }; -// ... Similar approve/generate logic as LOI ... +export const approveRequest = async (req: AuthRequest, res: Response) => { + try { + const { requestId } = req.params; + const { action, remarks } = req.body; + + const request = await LoaRequest.findByPk(requestId); + if (!request) return res.status(404).json({ success: false, message: 'LOA Request not found' }); + + const currentApproval = await LoaApproval.findOne({ + where: { requestId, action: 'Pending' }, + order: [['level', 'ASC']] + }); + + if (!currentApproval) return res.status(400).json({ success: false, message: 'No pending approval found' }); + + await currentApproval.update({ + action, + remarks, + approverId: req.user?.id, + approvedAt: action === 'Approved' ? new Date() : null + }); + + if (action === 'Rejected') { + await request.update({ status: 'Rejected' }); + await db.Application.update({ overallStatus: 'LOA Rejected' }, { where: { id: request.applicationId } }); + return res.json({ success: true, message: 'LOA Request rejected' }); + } + + const nextLevelMap: any = { + 1: { role: 'NBH', level: 2 }, + 2: { role: 'Final', level: 3 } + }; + + const next = nextLevelMap[currentApproval.level]; + + if (next && next.role !== 'Final') { + await LoaApproval.create({ + requestId: request.id, + level: next.level, + approverRole: next.role, + action: 'Pending' + }); + res.json({ success: true, message: `Approved at Level ${currentApproval.level}. Moved to ${next.role}` }); + } else { + await request.update({ status: 'Approved', approvedBy: req.user?.id, approvedAt: new Date() }); + + const mockFile = `LOA_${request.id}.pdf`; + await LoaDocumentGenerated.create({ + requestId: request.id, + documentType: 'LOA', + fileName: mockFile, + filePath: `/uploads/loa/${mockFile}` + }); + + await db.Application.update({ overallStatus: 'Authorized for Operations' }, { where: { id: request.applicationId } }); + res.json({ success: true, message: 'LOA fully approved and issued' }); + } + } catch (error) { + console.error('Approve LOA request error:', error); + res.status(500).json({ success: false, message: 'Error processing approval' }); + } +}; + +export const generateDocument = async (req: AuthRequest, res: Response) => { + try { + const { requestId } = req.body; + const mockFile = `LOA_MANUAL_${Date.now()}.pdf`; + const doc = await LoaDocumentGenerated.create({ + requestId, + documentType: 'LOA', + fileName: mockFile, + filePath: `/uploads/loa/${mockFile}` + }); + res.json({ success: true, message: 'LOA Document generated (Mock)', data: doc }); + } catch (error) { + res.status(500).json({ success: false, message: 'Error generating document' }); + } +}; // --- Security Deposit --- diff --git a/src/modules/loi/loi.controller.ts b/src/modules/loi/loi.controller.ts index aedb7aa..e3e62f2 100644 --- a/src/modules/loi/loi.controller.ts +++ b/src/modules/loi/loi.controller.ts @@ -11,7 +11,8 @@ export const getRequest = async (req: Request, res: Response) => { where: { applicationId }, include: [ { model: LoiApproval, as: 'approvals' }, - { model: LoiDocumentGenerated, as: 'generatedDocuments' } + { model: LoiDocumentGenerated, as: 'generatedDocuments' }, + { model: LoiAcknowledgement, as: 'acknowledgement' } ] }); res.json({ success: true, data: request }); @@ -21,26 +22,58 @@ export const getRequest = async (req: Request, res: Response) => { } }; +export const acknowledgeRequest = async (req: AuthRequest, res: Response) => { + try { + const { requestId } = req.params; + const { documentId } = req.body; + + const request = await LoiRequest.findByPk(requestId); + if (!request) return res.status(404).json({ success: false, message: 'LOI Request not found' }); + + await LoiAcknowledgement.create({ + requestId, + applicationId: request.applicationId, + documentId, + acknowledgedAt: new Date(), + status: 'Acknowledged' + }); + + await db.Application.update({ overallStatus: 'Dealer Code Generation' }, { where: { id: request.applicationId } }); + + res.json({ success: true, message: 'LOI Acknowledged by applicant' }); + } catch (error) { + console.error('LOI Acknowledge error:', error); + res.status(500).json({ success: false, message: 'Error acknowledging LOI' }); + } +}; + export const createRequest = async (req: AuthRequest, res: Response) => { try { const { applicationId } = req.body; - const request = await LoiRequest.create({ - applicationId, - requestedBy: req.user?.id, - status: 'Pending' + const application = await db.Application.findByPk(applicationId); + if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); + + const [request, created] = await LoiRequest.findOrCreate({ + where: { applicationId }, + defaults: { + requestedBy: req.user?.id, + status: 'In Progress' + } }); - // Create Initial Approvals? (Or triggered by Workflow engine) - // Manual for now: - await LoiApproval.create({ - requestId: request.id, - level: 1, - approverRole: 'Zone Manager', // Example - action: 'Pending' + // Initialize first level approval (Finance) if not already exists + await LoiApproval.findOrCreate({ + where: { requestId: request.id, level: 1 }, + defaults: { + approverRole: 'Finance', + action: 'Pending' + } }); - res.status(201).json({ success: true, message: 'LOI Request created', data: request }); + await application.update({ overallStatus: 'LOI In Progress' }); + + res.status(201).json({ success: true, message: 'LOI Request initiated with Finance approval', data: request }); } catch (error) { console.error('Create LOI request error:', error); res.status(500).json({ success: false, message: 'Error creating LOI request' }); @@ -52,27 +85,92 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { const { requestId } = req.params; const { action, remarks } = req.body; // action: Approved/Rejected - // Find pending approval for this user/role (Simplified logic) - const approval = await LoiApproval.findOne({ - where: { requestId, action: 'Pending' } // , level: 1 etc. + const request = await LoiRequest.findByPk(requestId); + if (!request) return res.status(404).json({ success: false, message: 'LOI Request not found' }); + + // Find current pending approval + const currentApproval = await LoiApproval.findOne({ + where: { requestId, action: 'Pending' }, + order: [['level', 'ASC']] }); - if (!approval) return res.status(404).json({ success: false, message: 'No pending approval found' }); - - await approval.update({ - action, - remarks, - approverId: req.user?.id - }); - - // If Approved, check if next level needed or finalize - if (action === 'Approved') { - await LoiRequest.update({ status: 'Approved', approvedBy: req.user?.id, approvedAt: new Date() }, { where: { id: requestId } }); - } else { - await LoiRequest.update({ status: 'Rejected' }, { where: { id: requestId } }); + if (!currentApproval) { + return res.status(400).json({ success: false, message: 'No pending approval levels found' }); } - res.json({ success: true, message: 'LOI Request updated' }); + // MANDATORY DOCUMENT CHECK (SRS Requirement) + // Level 2+ requires minimum set of documents uploaded by applicant + if (currentApproval.level === 1 && action === 'Approved') { + const docCount = await db.Document.count({ + where: { requestId: request.applicationId, requestType: 'application' } + }); + if (docCount < 5) { // SRS requires 18, using 5 for functional demo + return res.status(400).json({ + success: false, + message: `Mandatory Document Check Failed: Applicant must upload at least 5 required documents (CIBIL, City Map, etc.) before DD Head approval. Current: ${docCount}` + }); + } + } + + // 1. Update current level + await currentApproval.update({ + action, + remarks, + approverId: req.user?.id, + approvedAt: action === 'Approved' ? new Date() : null + }); + + // 2. Handle Logic based on Action + if (action === 'Rejected') { + await request.update({ status: 'Rejected' }); + await db.Application.update({ overallStatus: 'LOI Rejected' }, { where: { id: request.applicationId } }); + return res.json({ success: true, message: 'LOI Request rejected' }); + } + + // 3. If Approved, determine next step + const nextLevelMap: any = { + 1: { role: 'DD Head', level: 2 }, + 2: { role: 'NBH', level: 3 }, + 3: { role: 'Final', level: 4 } + }; + + const next = nextLevelMap[currentApproval.level]; + + if (next && next.role !== 'Final') { + // Initiate next level + await LoiApproval.create({ + requestId: request.id, + level: next.level, + approverRole: next.role, + action: 'Pending' + }); + res.json({ success: true, message: `Approved at Level ${currentApproval.level}. Moved to ${next.role}` }); + } else { + // Final Approval reached + await request.update({ status: 'Approved', approvedBy: req.user?.id, approvedAt: new Date() }); + + // Trigger Mock Document Generation + const mockFile = `LOI_${request.id}.pdf`; + await LoiDocumentGenerated.create({ + requestId: request.id, + documentType: 'LOI', + fileName: mockFile, + filePath: `/uploads/loi/${mockFile}` + }); + + await db.Application.update({ overallStatus: 'LOI Issued' }, { where: { id: request.applicationId } }); + + res.json({ success: true, message: 'LOI Request fully approved and document generated' }); + } + + await AuditLog.create({ + userId: req.user?.id, + action: action === 'Approved' ? AUDIT_ACTIONS.LOI_APPROVED : AUDIT_ACTIONS.LOI_REJECTED, + entityType: 'loi_request', + entityId: requestId, + newData: { level: currentApproval.level, action } + }); + } catch (error) { console.error('Approve LOI request error:', error); res.status(500).json({ success: false, message: 'Error processing approval' }); @@ -81,10 +179,24 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { export const generateDocument = async (req: AuthRequest, res: Response) => { try { - // Logic to generate PDF from Template - // Create Document record - // Create LoiDocumentGenerated record - res.json({ success: true, message: 'LOI Document generated (Placeholder)' }); + const { requestId } = req.body; + // Mocking document generation + const mockFile = `LOI_MANUAL_${Date.now()}.pdf`; + const doc = await LoiDocumentGenerated.create({ + requestId, + documentType: 'LOI', + fileName: mockFile, + filePath: `/uploads/loi/${mockFile}` + }); + + await AuditLog.create({ + userId: req.user?.id, + action: AUDIT_ACTIONS.LOI_GENERATED, + entityType: 'loi_request', + entityId: requestId + }); + + res.json({ success: true, message: 'LOI Document generated (Mock)', data: doc }); } catch (error) { res.status(500).json({ success: false, message: 'Error generating document' }); } diff --git a/src/modules/loi/loi.routes.ts b/src/modules/loi/loi.routes.ts index 9d9340f..7f18f6a 100644 --- a/src/modules/loi/loi.routes.ts +++ b/src/modules/loi/loi.routes.ts @@ -8,6 +8,7 @@ router.use(authenticate as any); router.get('/request/:applicationId', loiController.getRequest); router.post('/request', loiController.createRequest); router.post('/request/:requestId/approve', loiController.approveRequest); +router.post('/request/:requestId/acknowledge', loiController.acknowledgeRequest); router.post('/request/:requestId/generate', loiController.generateDocument); export default router; diff --git a/src/modules/master/master.routes.ts b/src/modules/master/master.routes.ts index 94e16f7..c090e34 100644 --- a/src/modules/master/master.routes.ts +++ b/src/modules/master/master.routes.ts @@ -42,6 +42,7 @@ router.get('/area-managers', masterController.getAreaManagers); // Outlets router.get('/outlets', outletController.getOutlets); +router.get('/outlets/code/:code', outletController.getOutletByCode); router.get('/outlets/:id', outletController.getOutletById); router.post('/outlets', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, outletController.createOutlet); router.put('/outlets/:id', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, outletController.updateOutlet); diff --git a/src/modules/master/outlet.controller.ts b/src/modules/master/outlet.controller.ts index fb27b16..f0d17e1 100644 --- a/src/modules/master/outlet.controller.ts +++ b/src/modules/master/outlet.controller.ts @@ -187,3 +187,36 @@ export const updateOutlet = async (req: AuthRequest, res: Response) => { }); } }; + +// Get outlet by code +export const getOutletByCode = async (req: AuthRequest, res: Response) => { + try { + const { code } = req.params; + const outlet = await Outlet.findOne({ + where: { code }, + include: [{ + model: User, + as: 'dealer', + attributes: ['name', 'email', 'phone'] + }] + }); + + if (!outlet) { + return res.status(404).json({ + success: false, + message: 'Outlet not found' + }); + } + + res.json({ + success: true, + outlet + }); + } catch (error) { + console.error('Get outlet by code error:', error); + res.status(500).json({ + success: false, + message: 'Error fetching outlet' + }); + } +}; diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index 34c9214..a547b8f 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -353,46 +353,41 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => { return res.status(400).json({ success: false, message: 'No applications selected' }); } - // assignedTo is expected to be an array of User IDs from frontend now - // But database only supports single UUID for assignedTo. - // Strategy: Assign the first user as primary assignee. - const primaryAssigneeId = Array.isArray(assignedTo) && assignedTo.length > 0 ? assignedTo[0] : null; - - // Verify primaryAssigneeId is a valid UUID if strictly enforced by DB, but Sequelize might handle null. - - // Update Applications - const updateData: any = { - ddLeadShortlisted: true, - overallStatus: 'Shortlisted', - updatedAt: new Date(), - }; - - if (primaryAssigneeId) { - updateData.assignedTo = primaryAssigneeId; + if (!assignedTo || !Array.isArray(assignedTo) || assignedTo.length === 0) { + return res.status(400).json({ success: false, message: 'At least one assignee (DD-ZM/RBM) is required' }); } - await Application.update(updateData, { + // Strategy: Assign the first user as primary assignee for the single FK field, + // but add ALL as participants to enforce dual-responsibility. + const primaryAssigneeId = assignedTo[0]; + + // Update Applications + await Application.update({ + ddLeadShortlisted: true, + isShortlisted: true, + overallStatus: 'Shortlisted', + assignedTo: primaryAssigneeId, + updatedAt: new Date(), + }, { where: { id: { [Op.in]: applicationIds } } }); - // Add participants - if (Array.isArray(assignedTo) && assignedTo.length > 0) { - for (const appId of applicationIds) { - for (const userId of assignedTo) { - await db.RequestParticipant.findOrCreate({ - where: { - requestId: appId, - requestType: 'application', - userId, - participantType: 'assignee' - }, - defaults: { - joinedMethod: 'auto' - } - }); - } + // Add all assigned users as participants for each application + for (const appId of applicationIds) { + for (const userId of assignedTo) { + await db.RequestParticipant.findOrCreate({ + where: { + requestId: appId, + requestType: 'application', + userId, + participantType: 'assignee' + }, + defaults: { + joinedMethod: 'auto' + } + }); } } @@ -402,23 +397,23 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => { previousStatus: 'Questionnaire Completed', newStatus: 'Shortlisted', changedBy: req.user?.id, - reason: remarks ? `${remarks} (Assignees: ${Array.isArray(assignedTo) ? assignedTo.join(', ') : assignedTo})` : 'Bulk Shortlist' + reason: remarks || 'Bulk Shortlist' })); await ApplicationStatusHistory.bulkCreate(historyEntries); // Audit Log const auditEntries = applicationIds.map(appId => ({ userId: req.user?.id, - action: AUDIT_ACTIONS.UPDATED, + action: AUDIT_ACTIONS.SHORTLISTED, entityType: 'application', entityId: appId, - newData: { ddLeadShortlisted: true, assignedTo: primaryAssigneeId, remarks } + newData: { isShortlisted: true, assignedTo } })); await AuditLog.bulkCreate(auditEntries); res.json({ success: true, - message: `Successfully shortlisted ${applicationIds.length} application(s)` + message: `Successfully shortlisted ${applicationIds.length} application(s) and assigned to ${assignedTo.length} users.` }); } catch (error) { console.error('Bulk shortlist error:', error); @@ -493,3 +488,42 @@ export const updateArchitectureStatus = async (req: AuthRequest, res: Response) res.status(500).json({ success: false, message: 'Error updating architecture status' }); } }; + +import { ExternalMocksService } from '../../common/utils/externalMocks.service.js'; + +export const generateDealerCodes = async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params; // applicationId + + const application = await Application.findByPk(id); + if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); + + // Trigger Mock SAP Integration + const { data: sapData } = await ExternalMocksService.mockGenerateSapCodes(application.applicationId); + + // Save Dealer Codes + await db.DealerCode.create({ + applicationId: id, + salesCode: sapData.salesCode, + serviceCode: sapData.serviceCode, + gmaCode: sapData.gmaCode, + gearCode: sapData.gearCode, + sapMasterId: sapData.sapMasterId, + status: 'Active' + }); + + await application.update({ + overallStatus: 'Architecture Team Assigned', + progressPercentage: 80 + }); + + res.json({ + success: true, + message: 'SAP Dealer Codes generated successfully (Mock)', + data: sapData + }); + } catch (error) { + console.error('Generate dealer code error:', error); + res.status(500).json({ success: false, message: 'Error generating dealer codes' }); + } +}; diff --git a/src/modules/onboarding/onboarding.routes.ts b/src/modules/onboarding/onboarding.routes.ts index 9765f96..8cea16a 100644 --- a/src/modules/onboarding/onboarding.routes.ts +++ b/src/modules/onboarding/onboarding.routes.ts @@ -3,7 +3,7 @@ const router = express.Router(); import { submitApplication, getApplications, getApplicationById, updateApplicationStatus, uploadDocuments, getApplicationDocuments, bulkShortlist, - assignArchitectureTeam, updateArchitectureStatus + assignArchitectureTeam, updateArchitectureStatus, generateDealerCodes } from './onboarding.controller.js'; import { authenticate } from '../../common/middleware/auth.js'; @@ -26,6 +26,7 @@ router.get('/applications/:id/documents', getApplicationDocuments); // Existing // Architecture-related routes router.post('/applications/:id/assign-architecture', assignArchitectureTeam); router.put('/applications/:id/architecture-status', updateArchitectureStatus); +router.post('/applications/:id/generate-codes', generateDealerCodes); // Questionnaire Routes diff --git a/src/modules/self-service/resignation.controller.ts b/src/modules/self-service/resignation.controller.ts index 83b2485..8732395 100644 --- a/src/modules/self-service/resignation.controller.ts +++ b/src/modules/self-service/resignation.controller.ts @@ -4,6 +4,7 @@ import logger from '../../common/utils/logger.js'; import { RESIGNATION_STAGES, AUDIT_ACTIONS, ROLES } from '../../common/config/constants.js'; import { Op, Transaction } from 'sequelize'; import { AuthRequest } from '../../types/express.types.js'; +import ExternalMocksService from '../../common/utils/externalMocks.service.js'; // Generate unique resignation ID const generateResignationId = async (): Promise => { @@ -16,7 +17,8 @@ const calculateProgress = (stage: string): number => { const stageProgress: Record = { [RESIGNATION_STAGES.ASM]: 15, [RESIGNATION_STAGES.RBM]: 30, - [RESIGNATION_STAGES.ZBH]: 45, + [RESIGNATION_STAGES.ZBH]: 40, + [RESIGNATION_STAGES.DD_LEAD]: 50, [RESIGNATION_STAGES.NBH]: 60, [RESIGNATION_STAGES.DD_ADMIN]: 65, [RESIGNATION_STAGES.LEGAL]: 70, @@ -260,7 +262,8 @@ export const approveResignation = async (req: AuthRequest, res: Response, next: const stageFlow: Record = { [RESIGNATION_STAGES.ASM]: RESIGNATION_STAGES.RBM, [RESIGNATION_STAGES.RBM]: RESIGNATION_STAGES.ZBH, - [RESIGNATION_STAGES.ZBH]: RESIGNATION_STAGES.NBH, + [RESIGNATION_STAGES.ZBH]: RESIGNATION_STAGES.DD_LEAD, + [RESIGNATION_STAGES.DD_LEAD]: RESIGNATION_STAGES.NBH, [RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.DD_ADMIN, [RESIGNATION_STAGES.DD_ADMIN]: RESIGNATION_STAGES.LEGAL, [RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.SPARES_CLEARANCE, @@ -297,11 +300,52 @@ export const approveResignation = async (req: AuthRequest, res: Response, next: timeline }, { transaction }); - // If completed, update outlet status + // If completed, update outlet status and sync with SAP if (nextStage === RESIGNATION_STAGES.COMPLETED) { await (resignation as any).outlet.update({ status: 'Closed' }, { transaction }); + + // Trigger Mock SAP Sync + ExternalMocksService.mockSyncDealerStatusToSap((resignation as any).outlet.code, 'Inactive') + .catch(err => logger.error('Error syncing resignation completion to SAP:', err)); + } + + // If F&F Initiated, create F&F record and fetch mock SAP dues + if (nextStage === RESIGNATION_STAGES.FNF_INITIATED) { + const sapDues = await ExternalMocksService.mockGetFinancialDuesFromSap((resignation as any).outlet.code); + + const fnf = await db.FnF.create({ + resignationId: resignation.id, + outletId: resignation.outletId, + dealerId: resignation.dealerId, + status: 'Initiated', + totalReceivables: sapDues.data.outstandingInvoices, + totalPayables: sapDues.data.securityDeposit, + netAmount: sapDues.data.securityDeposit - sapDues.data.outstandingInvoices + }, { transaction }); + + // Create initial line items from SAP data + await db.FnFLineItem.bulkCreate([ + { + fnfId: fnf.id, + itemType: 'Receivable', + description: 'Outstanding Invoices from SAP', + department: 'Finance', + amount: sapDues.data.outstandingInvoices, + addedBy: req.user.id + }, + { + fnfId: fnf.id, + itemType: 'Payable', + description: 'Security Deposit from SAP', + department: 'Finance', + amount: sapDues.data.securityDeposit, + addedBy: req.user.id + } + ], { transaction }); + + logger.info(`F&F record and mock line items created for resignation: ${resignation.resignationId}`); } // Create audit log diff --git a/src/modules/settlement/settlement.controller.ts b/src/modules/settlement/settlement.controller.ts index d097dad..85c8560 100644 --- a/src/modules/settlement/settlement.controller.ts +++ b/src/modules/settlement/settlement.controller.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import db from '../../database/models/index.js'; -const { FinancePayment, FnF, Application, Resignation, User, Outlet } = db; +const { FinancePayment, FnF, Application, Resignation, User, Outlet, FnFLineItem, TerminationRequest } = db; import { AuthRequest } from '../../types/express.types.js'; export const getOnboardingPayments = async (req: Request, res: Response) => { @@ -28,7 +28,12 @@ export const getFnFSettlements = async (req: Request, res: Response) => { { model: Resignation, as: 'resignation', - attributes: ['resignationId'] + attributes: ['id', 'resignationId'] + }, + { + model: TerminationRequest, + as: 'terminationRequest', + attributes: ['id', 'status', 'category'] }, { model: Outlet, @@ -36,8 +41,12 @@ export const getFnFSettlements = async (req: Request, res: Response) => { include: [{ model: User, as: 'dealer', - attributes: ['name'] + attributes: ['name', 'id'] }] + }, + { + model: FnFLineItem, + as: 'lineItems' } ], order: [['createdAt', 'DESC']] @@ -99,3 +108,114 @@ export const updateFnF = async (req: AuthRequest, res: Response) => { res.status(500).json({ success: false, message: 'Error updating F&F settlement' }); } }; + +export const getFnFById = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const fnf = await FnF.findByPk(id, { + include: [ + { model: Resignation, as: 'resignation' }, + { model: TerminationRequest, as: 'terminationRequest' }, + { + model: Outlet, as: 'outlet', + include: [{ model: User, as: 'dealer' }] + }, + { model: FnFLineItem, as: 'lineItems' } + ] + }); + + if (!fnf) return res.status(404).json({ success: false, message: 'F&F not found' }); + res.json({ success: true, fnf }); + } catch (error) { + console.error('Get F&F by ID error:', error); + res.status(500).json({ success: false, message: 'Error fetching F&F' }); + } +}; + +export const addLineItem = async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params; + const { itemType, description, department, amount } = req.body; + + const lineItem = await FnFLineItem.create({ + fnfId: id, + itemType, + description, + department, + amount, + addedBy: req.user?.id + }); + + res.json({ success: true, lineItem }); + } catch (error) { + console.error('Add line item error:', error); + res.status(500).json({ success: false, message: 'Error adding line item' }); + } +}; + +export const updateLineItem = async (req: AuthRequest, res: Response) => { + try { + const { itemId } = req.params; + const { description, department, amount } = req.body; + + const lineItem = await FnFLineItem.findByPk(itemId); + if (!lineItem) return res.status(404).json({ success: false, message: 'Line item not found' }); + + await lineItem.update({ description, department, amount }); + res.json({ success: true, lineItem }); + } catch (error) { + console.error('Update line item error:', error); + res.status(500).json({ success: false, message: 'Error updating line item' }); + } +}; + +export const deleteLineItem = async (req: AuthRequest, res: Response) => { + try { + const { itemId } = req.params; + const lineItem = await FnFLineItem.findByPk(itemId); + if (!lineItem) return res.status(404).json({ success: false, message: 'Line item not found' }); + + await lineItem.destroy(); + res.json({ success: true, message: 'Line item deleted' }); + } catch (error) { + console.error('Delete line item error:', error); + res.status(500).json({ success: false, message: 'Error deleting line item' }); + } +}; + +export const calculateFnF = async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params; + const fnf = await FnF.findByPk(id, { + include: [{ model: FnFLineItem, as: 'lineItems' }] + }); + if (!fnf) return res.status(404).json({ success: false, message: 'F&F not found' }); + + const lineItems = (fnf as any).lineItems || []; + + let totalReceivables = 0; + let totalPayables = 0; + let totalDeductions = 0; + + lineItems.forEach((item: any) => { + const amt = parseFloat(item.amount) || 0; + if (item.itemType === 'Receivable') totalReceivables += amt; + else if (item.itemType === 'Payable') totalPayables += amt; + else if (item.itemType === 'Deduction') totalDeductions += amt; + }); + + const netAmount = totalPayables - totalReceivables - totalDeductions; + + await fnf.update({ + totalReceivables, + totalPayables, + netAmount, + status: 'Calculated' + }); + + res.json({ success: true, fnf }); + } catch (error) { + console.error('Calculate F&F error:', error); + res.status(500).json({ success: false, message: 'Error calculating F&F' }); + } +}; diff --git a/src/modules/settlement/settlement.routes.ts b/src/modules/settlement/settlement.routes.ts index 34e01c1..454195f 100644 --- a/src/modules/settlement/settlement.routes.ts +++ b/src/modules/settlement/settlement.routes.ts @@ -11,7 +11,14 @@ router.use(authenticate as any); // Finance user only routes router.get('/onboarding', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.getOnboardingPayments); router.get('/fnf', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.getFnFSettlements); +router.get('/fnf/:id', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN, ROLES.DD_ADMIN, ROLES.DD_HEAD, ROLES.DD_LEAD]) as any, settlementController.getFnFById); router.put('/payments/:id', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.updatePayment); router.put('/fnf/:id', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.updateFnF); +router.post('/fnf/:id/calculate', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.calculateFnF); + +// Line item management +router.post('/fnf/:id/line-items', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.addLineItem); +router.put('/fnf/line-items/:itemId', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.updateLineItem); +router.delete('/fnf/line-items/:itemId', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.deleteLineItem); export default router; diff --git a/src/modules/termination/termination.controller.ts b/src/modules/termination/termination.controller.ts index 1ccb632..4f11941 100644 --- a/src/modules/termination/termination.controller.ts +++ b/src/modules/termination/termination.controller.ts @@ -4,6 +4,7 @@ import logger from '../../common/utils/logger.js'; import { TERMINATION_STAGES, AUDIT_ACTIONS, ROLES } from '../../common/config/constants.js'; import { Transaction } from 'sequelize'; import { AuthRequest } from '../../types/express.types.js'; +import ExternalMocksService from '../../common/utils/externalMocks.service.js'; // Calculate progress percentage based on stage const calculateProgress = (stage: string): number => { @@ -166,6 +167,49 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n remarks }] }, { transaction }); + + // If Terminated, create F&F record and fetch mock SAP dues + if (nextStage === TERMINATION_STAGES.TERMINATED) { + const dealer = await db.Dealer.findByPk(termination.dealerId); + const sapDues = await ExternalMocksService.mockGetFinancialDuesFromSap(dealer?.dealerCode || 'MOCK-001'); + + const fnf = await db.FnF.create({ + terminationRequestId: termination.id, + dealerId: termination.dealerId, + status: 'Initiated', + totalReceivables: sapDues.data.outstandingInvoices, + totalPayables: sapDues.data.securityDeposit, + netAmount: sapDues.data.securityDeposit - sapDues.data.outstandingInvoices + }, { transaction }); + + // Create initial line items from SAP data + await db.FnFLineItem.bulkCreate([ + { + fnfId: fnf.id, + itemType: 'Receivable', + description: 'Outstanding Invoices from SAP', + department: 'Finance', + amount: sapDues.data.outstandingInvoices, + addedBy: req.user.id + }, + { + fnfId: fnf.id, + itemType: 'Payable', + description: 'Security Deposit from SAP', + department: 'Finance', + amount: sapDues.data.securityDeposit, + addedBy: req.user.id + } + ], { transaction }); + + logger.info(`F&F record and mock line items created for termination: ${termination.id}`); + + // Sync with Mock SAP + if (dealer) { + ExternalMocksService.mockSyncDealerStatusToSap(dealer.dealerCode, 'Inactive') + .catch(err => logger.error('Error syncing termination to SAP:', err)); + } + } } await transaction.commit();