diff --git a/check_loa.js b/check_loa.js new file mode 100644 index 0000000..9fe35d3 --- /dev/null +++ b/check_loa.js @@ -0,0 +1,19 @@ + +import db from './src/database/models/index.js'; + +async function check() { + try { + const progress = await db.ApplicationProgress.findAll({ + where: { applicationId: 'a8d0ffb3-be90-4aa8-9344-16729ed07056' }, + order: [['stageOrder', 'ASC']] + }); + for (const p of progress) { + console.log(`${p.stageOrder}: ${p.stageName} - Status: ${p.status}`); + } + process.exit(0); + } catch (e) { + console.error(e); + process.exit(1); + } +} +check(); diff --git a/check_stuck.js b/check_stuck.js new file mode 100644 index 0000000..d909c44 --- /dev/null +++ b/check_stuck.js @@ -0,0 +1,27 @@ + +import db from './src/database/models/index.js'; + +async function check() { + try { + const applicationId = 'APP-2026-2709'; + const application = await db.Application.findOne({ where: { applicationId } }); + + if (!application) { + console.log('Application not found'); + process.exit(0); + } + + const request = await db.LoaRequest.findOne({ where: { applicationId: application.id } }); + if (request) { + console.log(`Request ID: ${request.id}, Status: ${request.status}`); + } else { + console.log('No LoaRequest found'); + } + + process.exit(0); + } catch (e) { + console.error(e); + process.exit(1); + } +} +check(); diff --git a/heal_one.js b/heal_one.js new file mode 100644 index 0000000..5971bfc --- /dev/null +++ b/heal_one.js @@ -0,0 +1,30 @@ + +import db from './src/database/models/index.js'; +import { WorkflowService } from './src/services/WorkflowService.js'; +import { APPLICATION_STATUS } from './src/common/config/constants.js'; + +async function heal() { + try { + const applicationId = 'a8d0ffb3-be90-4aa8-9344-16729ed07056'; + const application = await db.Application.findByPk(applicationId); + + console.log(`Healing application ${application.applicationId}...`); + + const request = await db.LoaRequest.findOne({ where: { applicationId: application.id } }); + if (request) { + await request.update({ status: 'Approved', approvedAt: new Date() }); + } + + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.EOR_IN_PROGRESS, '18122cf4-d905-484c-a9ff-7b6229f101ef', { + reason: 'Manually recovered: Policy met with 3 approvals.', + progressPercentage: 97 + }); + + console.log('Done.'); + process.exit(0); + } catch (e) { + console.error(e); + process.exit(1); + } +} +heal(); diff --git a/heal_two.js b/heal_two.js new file mode 100644 index 0000000..c6a9e02 --- /dev/null +++ b/heal_two.js @@ -0,0 +1,30 @@ + +import db from './src/database/models/index.js'; +import { WorkflowService } from './src/services/WorkflowService.js'; +import { APPLICATION_STATUS } from './src/common/config/constants.js'; + +async function heal() { + try { + const applicationId = 'f2f55d38-befc-4d9a-b216-4e39fb2fb30e'; + const application = await db.Application.findByPk(applicationId); + + console.log(`Healing application ${application.applicationId}...`); + + const request = await db.LoaRequest.findOne({ where: { applicationId: application.id } }); + if (request) { + await request.update({ status: 'Approved', approvedAt: new Date() }); + } + + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.EOR_IN_PROGRESS, '18122cf4-d905-484c-a9ff-7b6229f101ef', { + reason: 'Manually recovered: LOA Policy met with required DD Head & NBH approvals.', + progressPercentage: 97 + }); + + console.log('Done.'); + process.exit(0); + } catch (e) { + console.error(e); + process.exit(1); + } +} +heal(); diff --git a/scripts/verify-offboarding-status.ts b/scripts/verify-offboarding-status.ts new file mode 100644 index 0000000..2fcb3d4 --- /dev/null +++ b/scripts/verify-offboarding-status.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { getResignationStatusForStage, getTerminationStatusForStage, normalizeClearanceStatus, normalizeFnFStatus } from '../src/common/utils/offboardingStatus.js'; + +assert.equal(normalizeFnFStatus('settled'), 'Completed'); +assert.equal(normalizeFnFStatus('finance approval'), 'Finance Approval'); + +assert.equal(getResignationStatusForStage('ASM'), 'ASM Review'); +assert.equal(getResignationStatusForStage('F&F Initiated'), 'F&F Initiated'); + +assert.equal(getTerminationStatusForStage('Submitted'), 'Submitted'); +assert.equal(getTerminationStatusForStage('Terminated'), 'Terminated'); + +assert.equal(normalizeClearanceStatus('Cleared', 0), 'NOC Submitted'); +assert.equal(normalizeClearanceStatus('Cleared', 100), 'Dues Pending'); +assert.equal(normalizeClearanceStatus('Pending', 0), 'Pending'); + +console.log('Offboarding status normalization checks passed.'); diff --git a/src/common/config/constants.ts b/src/common/config/constants.ts index 2ab038e..9b848a8 100644 --- a/src/common/config/constants.ts +++ b/src/common/config/constants.ts @@ -319,6 +319,7 @@ export const AUDIT_ACTIONS = { // FDD FDD_ASSIGNED: 'FDD_ASSIGNED', FDD_REPORT_UPLOADED: 'FDD_REPORT_UPLOADED', + FDD_FLAGGED_NON_RESPONSIVE: 'FDD_FLAGGED_NON_RESPONSIVE', // LOI & LOA LOI_REQUESTED: 'LOI_REQUESTED', @@ -413,6 +414,53 @@ export const DOCUMENT_TYPES = { OTHER: 'Other' } as const; +export const RESIGNATION_DOCUMENT_TYPES = [ + 'Resignation Letter', + 'Dealer Undertaking', + 'Approval Note', + 'Legal Communication', + 'Handover Document', + 'Settlement Supporting Document', + 'Other' +] as const; + +export const RESIGNATION_DOCUMENT_STAGES = [ + 'ASM', + 'RBM', + 'ZBH', + 'DD Lead', + 'NBH', + 'DD Admin', + 'Legal', + 'F&F Initiated' +] as const; + +export const TERMINATION_DOCUMENT_TYPES = [ + 'Termination Recommendation', + 'Show Cause Notice', + 'SCN Response', + 'Hearing Record', + 'Approval Note', + 'Termination Letter', + 'Settlement Supporting Document', + 'Other' +] as const; + +export const TERMINATION_DOCUMENT_STAGES = [ + 'Submitted', + 'RBM Review', + 'ZBH Review', + 'DD Lead Review', + 'Legal Verification', + 'NBH Evaluation', + 'Show Cause Notice', + 'Personal Hearing', + 'NBH Final Approval', + 'CCO Approval', + 'CEO Final Approval', + 'Legal - Termination Letter' +] as const; + // Request Types export const REQUEST_TYPES = { APPLICATION: 'application', diff --git a/src/common/middleware/auth.ts b/src/common/middleware/auth.ts index 2b2d55a..32d152a 100644 --- a/src/common/middleware/auth.ts +++ b/src/common/middleware/auth.ts @@ -42,6 +42,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu req.user = { id: application.id, email: application.email, + phone: application.phone, // Adding phone here firstName: application.applicantName ? application.applicantName.split(' ')[0] : 'Prospective', lastName: application.applicantName ? application.applicantName.split(' ').slice(1).join(' ') : 'User', fullName: application.applicantName, diff --git a/src/common/utils/offboardingStatus.ts b/src/common/utils/offboardingStatus.ts new file mode 100644 index 0000000..39eb299 --- /dev/null +++ b/src/common/utils/offboardingStatus.ts @@ -0,0 +1,70 @@ +import { FNF_STATUS, RESIGNATION_STAGES, TERMINATION_STAGES } from '../config/constants.js'; + +const FNF_STATUS_ALIASES: Record = { + initiated: FNF_STATUS.INITIATED, + new: FNF_STATUS.INITIATED, + draft: FNF_STATUS.INITIATED, + dd_clearance: FNF_STATUS.DD_CLEARANCE, + 'dd clearance': FNF_STATUS.DD_CLEARANCE, + legal_clearance: FNF_STATUS.LEGAL_CLEARANCE, + 'legal clearance': FNF_STATUS.LEGAL_CLEARANCE, + finance_approval: FNF_STATUS.FINANCE_APPROVAL, + 'finance approval': FNF_STATUS.FINANCE_APPROVAL, + completed: FNF_STATUS.COMPLETED, + settled: FNF_STATUS.COMPLETED +}; + +export const normalizeFnFStatus = (status: string | null | undefined): string => { + if (!status) return FNF_STATUS.INITIATED; + const key = status.trim().toLowerCase(); + return FNF_STATUS_ALIASES[key] || status; +}; + +export const getResignationStatusForStage = (stage: string): string => { + switch (stage) { + case RESIGNATION_STAGES.ASM: + case RESIGNATION_STAGES.RBM: + case RESIGNATION_STAGES.ZBH: + case RESIGNATION_STAGES.DD_LEAD: + case RESIGNATION_STAGES.NBH: + case RESIGNATION_STAGES.DD_ADMIN: + return `${stage} Review`; + case RESIGNATION_STAGES.LEGAL: + return 'Legal - Resignation Letter'; + case RESIGNATION_STAGES.FNF_INITIATED: + return RESIGNATION_STAGES.FNF_INITIATED; + case RESIGNATION_STAGES.COMPLETED: + return 'Completed'; + case RESIGNATION_STAGES.REJECTED: + return 'Rejected'; + default: + return stage; + } +}; + +export const getTerminationStatusForStage = (stage: string): string => { + switch (stage) { + case TERMINATION_STAGES.SUBMITTED: + return 'Submitted'; + case TERMINATION_STAGES.TERMINATED: + return 'Terminated'; + case TERMINATION_STAGES.REJECTED: + return 'Rejected'; + default: + return stage; + } +}; + +export const normalizeClearanceStatus = (status: string | null | undefined, amount: number): string => { + const normalizedAmount = Math.abs(Number(amount) || 0); + const value = (status || '').toLowerCase(); + + if (value === 'cleared' || value === 'noc submitted') { + return normalizedAmount > 0 ? 'Dues Pending' : 'NOC Submitted'; + } + if (value === 'dues' || value === 'dues pending') return 'Dues Pending'; + if (value === 'n/a') return 'N/A'; + if (value === 'pending') return 'Pending'; + + return normalizedAmount > 0 ? 'Dues Pending' : 'NOC Submitted'; +}; diff --git a/src/common/utils/progress.ts b/src/common/utils/progress.ts index e625b3f..0b6c862 100644 --- a/src/common/utils/progress.ts +++ b/src/common/utils/progress.ts @@ -143,8 +143,8 @@ export const syncApplicationProgress = async (applicationId: string, overallStat // Statuses that imply the CURRENT stage (single or both parallel) is finished const completionStatuses = [ 'Submitted', 'Questionnaire Completed', 'Shortlisted', 'Level 1 Approved', - 'Level 2 Approved', 'Level 3 Approved', 'LOI Issued', - 'LOA Issued', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded' + 'Level 2 Approved', 'Level 3 Approved', + 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded' ]; const isCurrentStageFinished = completionStatuses.includes(overallStatus); diff --git a/src/database/models/index.ts b/src/database/models/index.ts index 9fbcda6..19cebf0 100644 --- a/src/database/models/index.ts +++ b/src/database/models/index.ts @@ -38,7 +38,6 @@ import createTerminationAudit from './TerminationAudit.js'; import createFnFAudit from './FnFAudit.js'; import createConstitutionalAudit from './ConstitutionalAudit.js'; import createRelocationAudit from './RelocationAudit.js'; -import createDealerBankDetail from './DealerBankDetail.js'; // Batch 1: Organizational Hierarchy & User Management import createRole from './Role.js'; @@ -67,6 +66,7 @@ import createAiSummary from './AiSummary.js'; // Batch 4: Dealer Entity, Documents & Work Notes import createDealer from './Dealer.js'; import createDealerCode from './DealerCode.js'; +import createDealerBankDetail from './DealerBankDetail.js'; import createDocumentVersion from './DocumentVersion.js'; import createWorkNoteTag from './WorkNoteTag.js'; import createWorkNoteAttachment from './WorkNoteAttachment.js'; @@ -157,7 +157,6 @@ db.TerminationAudit = createTerminationAudit(sequelize); db.FnFAudit = createFnFAudit(sequelize); db.ConstitutionalAudit = createConstitutionalAudit(sequelize); db.RelocationAudit = createRelocationAudit(sequelize); -db.DealerBankDetail = createDealerBankDetail(sequelize); // Batch 1: Organizational Hierarchy & User Management db.Role = createRole(sequelize); @@ -186,6 +185,7 @@ db.AiSummary = createAiSummary(sequelize); // Batch 4: Dealer Entity, Documents & Work Notes db.Dealer = createDealer(sequelize); db.DealerCode = createDealerCode(sequelize); +db.DealerBankDetail = createDealerBankDetail(sequelize); db.DocumentVersion = createDocumentVersion(sequelize); db.WorkNoteTag = createWorkNoteTag(sequelize); db.WorkNoteAttachment = createWorkNoteAttachment(sequelize); diff --git a/src/modules/admin/admin.controller.ts b/src/modules/admin/admin.controller.ts index 80ed518..4e99199 100644 --- a/src/modules/admin/admin.controller.ts +++ b/src/modules/admin/admin.controller.ts @@ -173,9 +173,19 @@ export const getPermissions = async (req: Request, res: Response) => { export const getAllUsers = async (req: Request, res: Response) => { try { - const { roleCode, locationId } = req.query; + const { roleCode, locationId, search, page = 1, limit = 100 } = req.query as any; const whereClause: any = {}; + // 1. Search filter + if (search) { + whereClause[Op.or] = [ + { fullName: { [Op.iLike]: `%${search}%` } }, + { email: { [Op.iLike]: `%${search}%` } }, + { employeeId: { [Op.iLike]: `%${search}%` } } + ]; + } + + // 2. Role filter let rawRoleCode: any = roleCode || req.query['roleCode[]']; let finalRoleCodes: string[] = []; @@ -213,9 +223,11 @@ export const getAllUsers = async (req: Request, res: Response) => { } } - const users = await User.findAll({ + const { count, rows: users } = await User.findAndCountAll({ where: whereClause, attributes: { exclude: ['password'] }, + limit: Number(limit), + offset: (Number(page) - 1) * Number(limit), include: [ { model: Role, @@ -248,7 +260,8 @@ export const getAllUsers = async (req: Request, res: Response) => { ] } ], - order: [['createdAt', 'DESC']] + order: [['createdAt', 'DESC']], + distinct: true }); const result = users.map((u: any) => { @@ -288,7 +301,7 @@ export const getAllUsers = async (req: Request, res: Response) => { return userJson; }); - res.json({ success: true, data: result }); + res.json({ success: true, data: result, total: count }); } catch (error) { console.error('Get users error:', error); res.status(500).json({ success: false, message: 'Error fetching users' }); diff --git a/src/modules/assessment/assessment.controller.ts b/src/modules/assessment/assessment.controller.ts index ee25570..678a4ad 100644 --- a/src/modules/assessment/assessment.controller.ts +++ b/src/modules/assessment/assessment.controller.ts @@ -226,21 +226,6 @@ const processStageDecision = async (params: { targetStage = 'Statutory Work'; targetProgress = 85; } else if (stageCode === 'LOA_APPROVAL') { - // Hard-stop validation: Check for statutory and bank details - const missingFields = []; - if (!application.panNumber) missingFields.push('PAN Number'); - if (!application.gstNumber) missingFields.push('GST Number'); - if (!application.bankName) missingFields.push('Bank Name'); - if (!application.accountNumber) missingFields.push('Account Number'); - if (!application.ifscCode) missingFields.push('IFSC Code'); - - if (decision === 'Approved' && missingFields.length > 0) { - return { - forbidden: true, - message: `Cannot approve LOA: Missing mandatory fields: ${missingFields.join(', ')}. Please ensure they are filled in the application details.` - }; - } - targetStatus = APPLICATION_STATUS.LOA_ISSUED; targetStage = 'LOA'; targetProgress = 95; diff --git a/src/modules/audit/audit.controller.ts b/src/modules/audit/audit.controller.ts index b824256..f2cbb32 100644 --- a/src/modules/audit/audit.controller.ts +++ b/src/modules/audit/audit.controller.ts @@ -63,7 +63,39 @@ const ACTION_DESCRIPTIONS: Record = { RESIGNATION_APPROVED: 'Resignation approved', RESIGNATION_REJECTED: 'Resignation rejected', EMAIL_SENT: 'Email notification sent', - REMINDER_SENT: 'Reminder sent' + REMINDER_SENT: 'Reminder sent', + FDD_FLAGGED_NON_RESPONSIVE: 'APPLICANT FLAGGED: Non-responsive to audit queries' +}; + +const getNormalizedAuditPayload = (logData: any, entityType: string, entityId: string) => { + const payload = logData.details || logData.newData || {}; + const actorName = logData.user?.fullName || logData.userName || 'System'; + const action = logData.action || 'UPDATED'; + + let description = ACTION_DESCRIPTIONS[action] || + String(action).split('_').map((w: any) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(' '); + + if (payload?.stage) description += ` - Stage: ${payload.stage}`; + else if (payload?.department) description += ` - ${payload.department}`; + else if (payload?.status && action === 'UPDATED') description += ` to ${payload.status}`; + + return { + id: logData.id, + action, + description, + entityType, + entityId, + actor: { + name: actorName, + email: logData.user?.email || logData.userEmail || null + }, + userName: actorName, + userEmail: logData.user?.email || logData.userEmail || null, + remarks: logData.remarks || payload?.remarks || '', + newData: payload, + details: payload, + timestamp: logData.createdAt || logData.timestamp + }; }; /** @@ -94,8 +126,13 @@ export const getAuditLogs = async (req: AuthRequest, res: Response) => { const type = (entityType as string).toLowerCase(); if (type === 'resignation') { + const resignation = await db.Resignation.findOne({ + where: { [Op.or]: [{ id: entityId as string }, { resignationId: entityId as string }] }, + attributes: ['id'] + }); + const resolvedResignationId = resignation?.id || (entityId as string); const result = await db.ResignationAudit.findAndCountAll({ - where: { resignationId: entityId as string }, + where: { resignationId: resolvedResignationId }, include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email'] }], order: [['createdAt', 'DESC']], limit: limitNum, offset @@ -103,8 +140,13 @@ export const getAuditLogs = async (req: AuthRequest, res: Response) => { count = result.count; logs = result.rows; } else if (type === 'termination') { + const termination = await db.TerminationRequest.findOne({ + where: { [Op.or]: [{ id: entityId as string }, { requestId: entityId as string }] }, + attributes: ['id'] + }); + const resolvedTerminationId = termination?.id || (entityId as string); const result = await db.TerminationAudit.findAndCountAll({ - where: { terminationRequestId: entityId as string }, + where: { terminationRequestId: resolvedTerminationId }, include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email'] }], order: [['createdAt', 'DESC']], limit: limitNum, offset @@ -112,8 +154,13 @@ export const getAuditLogs = async (req: AuthRequest, res: Response) => { count = result.count; logs = result.rows; } else if (type === 'fnf') { + const fnf = await db.FnF.findOne({ + where: { [Op.or]: [{ id: entityId as string }, { settlementId: entityId as string }] }, + attributes: ['id'] + }); + const resolvedFnfId = fnf?.id || (entityId as string); const result = await db.FnFAudit.findAndCountAll({ - where: { fnfId: entityId as string }, + where: { fnfId: resolvedFnfId }, include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email'] }], order: [['createdAt', 'DESC']], limit: limitNum, offset @@ -121,8 +168,13 @@ export const getAuditLogs = async (req: AuthRequest, res: Response) => { count = result.count; logs = result.rows; } else if (type === 'constitutional_change') { + const constitutional = await db.ConstitutionalChange.findOne({ + where: { [Op.or]: [{ id: entityId as string }, { requestId: entityId as string }] }, + attributes: ['id'] + }); + const resolvedConstitutionalId = constitutional?.id || (entityId as string); const result = await db.ConstitutionalAudit.findAndCountAll({ - where: { constitutionalChangeId: entityId as string }, + where: { constitutionalChangeId: resolvedConstitutionalId }, include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email'] }], order: [['createdAt', 'DESC']], limit: limitNum, offset @@ -155,30 +207,10 @@ export const getAuditLogs = async (req: AuthRequest, res: Response) => { // Format the response with human-readable descriptions and consistent mapping const formattedLogs = logs.map((log: any) => { const logData = log.get ? log.get({ plain: true }) : log; - const details = logData.details || logData.newData; - - let baseDescription = ACTION_DESCRIPTIONS[logData.action] || - logData.action.split('_').map((w: any) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(' '); - - // EXCLUSIVE ADDITION: Enhance description with context if available (Stage/Status/Dept) - if (details) { - if (details.stage) baseDescription += ` - Stage: ${details.stage}`; - else if (details.department) baseDescription += ` - ${details.department}`; - else if (details.status && logData.action === 'UPDATED') baseDescription += ` to ${details.status}`; + if (logData.details?.statutoryStatus === 'Flagged') { + logData.action = 'FDD_FLAGGED_NON_RESPONSIVE'; } - - return { - id: logData.id, - action: logData.action, - description: baseDescription, - entityType: entityType, - entityId: entityId, - userName: logData.user?.fullName || 'System', - userEmail: logData.user?.email || null, - remarks: logData.remarks || logData.newData?.remarks, - newData: details, // Normalize module-specific 'details' to 'newData' for UI - timestamp: logData.createdAt - }; + return getNormalizedAuditPayload(logData, entityType as string, entityId as string); }); res.json({ @@ -217,30 +249,50 @@ export const getAuditSummary = async (req: AuthRequest, res: Response) => { // Dynamic Table Switching if (type === 'resignation') { - totalLogs = await db.ResignationAudit.count({ where: { resignationId: entityId as string } }); + const resignation = await db.Resignation.findOne({ + where: { [Op.or]: [{ id: entityId as string }, { resignationId: entityId as string }] }, + attributes: ['id'] + }); + const resolvedResignationId = resignation?.id || (entityId as string); + totalLogs = await db.ResignationAudit.count({ where: { resignationId: resolvedResignationId } }); latestLog = await db.ResignationAudit.findOne({ - where: { resignationId: entityId as string }, + where: { resignationId: resolvedResignationId }, include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }], order: [['createdAt', 'DESC']] }); } else if (type === 'termination') { - totalLogs = await db.TerminationAudit.count({ where: { terminationRequestId: entityId as string } }); + const termination = await db.TerminationRequest.findOne({ + where: { [Op.or]: [{ id: entityId as string }, { requestId: entityId as string }] }, + attributes: ['id'] + }); + const resolvedTerminationId = termination?.id || (entityId as string); + totalLogs = await db.TerminationAudit.count({ where: { terminationRequestId: resolvedTerminationId } }); latestLog = await db.TerminationAudit.findOne({ - where: { terminationRequestId: entityId as string }, + where: { terminationRequestId: resolvedTerminationId }, include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }], order: [['createdAt', 'DESC']] }); } else if (type === 'fnf') { - totalLogs = await db.FnFAudit.count({ where: { fnfId: entityId as string } }); + const fnf = await db.FnF.findOne({ + where: { [Op.or]: [{ id: entityId as string }, { settlementId: entityId as string }] }, + attributes: ['id'] + }); + const resolvedFnfId = fnf?.id || (entityId as string); + totalLogs = await db.FnFAudit.count({ where: { fnfId: resolvedFnfId } }); latestLog = await db.FnFAudit.findOne({ - where: { fnfId: entityId as string }, + where: { fnfId: resolvedFnfId }, include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }], order: [['createdAt', 'DESC']] }); } else if (type === 'constitutional' || type === 'constitutional_change') { - totalLogs = await db.ConstitutionalAudit.count({ where: { constitutionalChangeId: entityId as string } }); + const constitutional = await db.ConstitutionalChange.findOne({ + where: { [Op.or]: [{ id: entityId as string }, { requestId: entityId as string }] }, + attributes: ['id'] + }); + const resolvedConstitutionalId = constitutional?.id || (entityId as string); + totalLogs = await db.ConstitutionalAudit.count({ where: { constitutionalChangeId: resolvedConstitutionalId } }); latestLog = await db.ConstitutionalAudit.findOne({ - where: { constitutionalChangeId: entityId as string }, + where: { constitutionalChangeId: resolvedConstitutionalId }, include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }], order: [['createdAt', 'DESC']] }); diff --git a/src/modules/eor/eor.controller.ts b/src/modules/eor/eor.controller.ts index f3e9ed5..b8689d1 100644 --- a/src/modules/eor/eor.controller.ts +++ b/src/modules/eor/eor.controller.ts @@ -214,7 +214,7 @@ export const submitAudit = async (req: AuthRequest, res: Response) => { if (checklist) { if (checklist.applicationId) { await db.Application.update({ - overallStatus: 'Approved', + overallStatus: 'Inauguration', progressPercentage: 100 }, { where: { id: checklist.applicationId } }); diff --git a/src/modules/fdd/fdd.controller.ts b/src/modules/fdd/fdd.controller.ts index 9b43042..2592aa7 100644 --- a/src/modules/fdd/fdd.controller.ts +++ b/src/modules/fdd/fdd.controller.ts @@ -153,3 +153,38 @@ export const uploadReport = async (req: AuthRequest, res: Response) => { res.status(500).json({ success: false, message: 'Error uploading report' }); } }; + +export const flagNonResponsive = async (req: AuthRequest, res: Response) => { + try { + const { applicationId, remarks } = req.body; + const targetId = applicationId as string; + + // Resolve application first to get UUID + const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(targetId); + const application = await Application.findOne({ + where: isUUID ? { [Op.or]: [{ id: targetId }, { applicationId: targetId }] } : { applicationId: targetId } + }); + + if (!application) { + return res.status(404).json({ success: false, message: 'Application not found' }); + } + + // 1. Update Application status at model level + await application.update({ statutoryStatus: 'Flagged' }); + + // 2. Add high-level Audit Log entry (Using literal 'UPDATED' to absolutely avoid ENUM errors) + console.log(`[FDDController] Flagging application ${application.id} with action: UPDATED`); + await AuditLog.create({ + userId: req.user?.id, + action: 'UPDATED', + entityType: 'application', + entityId: application.id, + newData: { statutoryStatus: 'Flagged', remarks: remarks || 'Applicant is non-responsive to FDD queries.' } + }); + + res.json({ success: true, message: 'Application flagged successfully' }); + } catch (error) { + console.error('Flag non-responsive error:', error); + res.status(500).json({ success: false, message: 'Error highlighting non-responsiveness' }); + } +}; diff --git a/src/modules/fdd/fdd.routes.ts b/src/modules/fdd/fdd.routes.ts index 4b5e47d..090ed73 100644 --- a/src/modules/fdd/fdd.routes.ts +++ b/src/modules/fdd/fdd.routes.ts @@ -8,5 +8,6 @@ router.use(authenticate as any); router.get('/:applicationId', fddController.getAssignment); router.post('/assign', fddController.assignAgency); router.post('/report', fddController.uploadReport); +router.post('/flag', fddController.flagNonResponsive); export default router; diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index fd88a44..4086455 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -9,6 +9,7 @@ import { AuthRequest } from '../../types/express.types.js'; import { sendOpportunityEmail, sendNonOpportunityEmail, sendShortlistedEmail } from '../../common/utils/email.service.js'; import { syncLocationManagers } from '../master/syncHierarchy.service.js'; import { WorkflowService } from '../../services/WorkflowService.js'; +import { WorkflowIntegrityService } from '../../services/WorkflowIntegrityService.js'; import { NomenclatureService } from '../../common/utils/nomenclature.js'; const { DocumentStageConfig } = db; @@ -154,7 +155,8 @@ export const getApplications = async (req: AuthRequest, res: Response) => { // Security Check: If prospective dealer, only show their own application if (req.user?.roleCode === 'Prospective Dealer') { - whereClause.email = req.user.email; + // Filter by phone instead of email to show all applications from same user + whereClause.phone = (req.user as any).phone || req.user.email; } // Security Check: If FDD user, only show applications where they are a participant @@ -197,6 +199,9 @@ export const getApplicationById = async (req: AuthRequest, res: Response) => { where.applicationId = targetId; } + // PROACTIVE INTEGRITY CHECK: Ensure application isn't stalled before returning + await WorkflowIntegrityService.synchronizeApplicationState(targetId); + const application = await Application.findOne({ where, include: [ @@ -297,7 +302,19 @@ export const getApplicationById = async (req: AuthRequest, res: Response) => { // Security Check: Ensure prospective dealer controls data ownership and document privacy if (req.user?.roleCode === 'Prospective Dealer') { - if (application.email !== req.user.email) { + const userEmail = req.user.email; + const userPhone = (req.user as any).phone; + + // Helper to normalize phone for comparison (last 10 digits) + const normalize = (p: string) => p ? String(p).replace(/[^0-9]/g, '').slice(-10) : ''; + const normalizedAppPhone = normalize(application.phone); + const normalizedUserPhone = normalize(userPhone); + + const hasAccess = + (application.email && userEmail && application.email.toLowerCase() === userEmail.toLowerCase()) || + (normalizedAppPhone && normalizedUserPhone && normalizedAppPhone === normalizedUserPhone); + + if (!hasAccess) { return res.status(403).json({ success: false, message: 'Forbidden: You do not have permission to view this application' }); } @@ -851,6 +868,34 @@ export const generateDealerCodes = async (req: AuthRequest, res: Response) => { where: isUUID ? { [Op.or]: [{ id: targetId }, { applicationId: targetId }] } : { applicationId: targetId } }); if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); + + // Strict Workflow Validation: Dealer Code Generation requires LOI Issued status + if (application.overallStatus !== APPLICATION_STATUS.LOI_ISSUED && application.overallStatus !== APPLICATION_STATUS.DEALER_CODE_GENERATION) { + return res.status(400).json({ + success: false, + message: `Cannot generate dealer codes. The application must be in 'LOI Issued' status (Current: ${application.overallStatus}).` + }); + } + + // Validation: Check for mandatory fields before triggering Dealer Code Generation + const mandatoryFields = [ + { key: 'panNumber', label: 'PAN Number' }, + { key: 'gstNumber', label: 'GST Number' }, + { key: 'bankName', label: 'Bank Name' }, + { key: 'accountNumber', label: 'Account Number' }, + { key: 'ifscCode', label: 'IFSC Code' } + ]; + + const missingFields = mandatoryFields + .filter(f => !application[f.key as keyof typeof application]) + .map(f => f.label); + + if (missingFields.length > 0) { + return res.status(400).json({ + success: false, + message: `Cannot generate dealer codes. Missing mandatory fields: ${missingFields.join(', ')}. Please update application details first.` + }); + } // Trigger Mock SAP Integration const { data: sapData } = await ExternalMocksService.mockGenerateSapCodes(application.applicationId); diff --git a/src/modules/prospective-login/prospective-login.controller.ts b/src/modules/prospective-login/prospective-login.controller.ts index 4167c84..06e6a5d 100644 --- a/src/modules/prospective-login/prospective-login.controller.ts +++ b/src/modules/prospective-login/prospective-login.controller.ts @@ -17,13 +17,15 @@ export class ProspectiveLoginController { console.log(`[ProspectiveLogin] Received OTP request for phone: '${phone}'`); - // Check if application exists and is shortlisted + // Check if ANY application exists for this phone and prioritize shortlisted ones const application = await db.Application.findOne({ - where: { phone: phone } + where: { phone: phone }, + order: [ + [db.sequelize.literal("CASE WHEN \"isShortlisted\" = true OR \"ddLeadShortlisted\" = true THEN 0 ELSE 1 END"), 'ASC'], + ['createdAt', 'DESC'] + ] }); - console.log(`[ProspectiveLogin] DB Search Result:`, application ? `Found AppId: ${application.id}, Shortlisted: ${application.isShortlisted}, DDLeadShortlisted: ${application.ddLeadShortlisted}` : 'Not Found'); - if (!application) { console.log(`[ProspectiveLogin] Application not found for ${phone}, returning 404`); return res.status(404).json({ message: 'No application found with this phone number' }); @@ -60,9 +62,13 @@ export class ProspectiveLoginController { } if (otp === '123456') { - // Fetch application again to get details + // Fetch the latest shortlisted application again to get details for the session const application = await db.Application.findOne({ - where: { phone: phone } + where: { phone: phone }, + order: [ + [db.sequelize.literal("CASE WHEN \"isShortlisted\" = true OR \"ddLeadShortlisted\" = true THEN 0 ELSE 1 END"), 'ASC'], + ['createdAt', 'DESC'] + ] }); if (!application) { diff --git a/src/modules/self-service/resignation.controller.ts b/src/modules/self-service/resignation.controller.ts index b2e5b2b..26d51e2 100644 --- a/src/modules/self-service/resignation.controller.ts +++ b/src/modules/self-service/resignation.controller.ts @@ -1,7 +1,14 @@ import { Response, NextFunction } from 'express'; import db from '../../database/models/index.js'; import logger from '../../common/utils/logger.js'; -import { RESIGNATION_STAGES, AUDIT_ACTIONS, ROLES } from '../../common/config/constants.js'; +import { + RESIGNATION_STAGES, + AUDIT_ACTIONS, + ROLES, + REQUEST_TYPES, + RESIGNATION_DOCUMENT_TYPES, + RESIGNATION_DOCUMENT_STAGES +} 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'; @@ -9,6 +16,7 @@ import ExternalMocksService from '../../common/utils/externalMocks.service.js'; import { ResignationWorkflowService } from '../../services/ResignationWorkflowService.js'; import { NomenclatureService } from '../../common/utils/nomenclature.js'; import { ParticipantService } from '../../services/ParticipantService.js'; +import { getResignationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js'; // Removed generateResignationId and moved to NomenclatureService @@ -51,7 +59,7 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N reason, additionalInfo, currentStage: RESIGNATION_STAGES.ASM, - status: 'ASM Review', + status: getResignationStatusForStage(RESIGNATION_STAGES.ASM), progressPercentage: ResignationWorkflowService.calculateProgress(RESIGNATION_STAGES.ASM), submittedOn: new Date(), documents: [], @@ -184,6 +192,80 @@ export const getResignationById = async (req: AuthRequest, res: Response, next: } }; +export const uploadResignationDocument = async (req: AuthRequest, res: Response, next: NextFunction) => { + const transaction: Transaction = await db.sequelize.transaction(); + try { + if (!req.user) throw new Error('Unauthorized'); + if (!req.file) { + await transaction.rollback(); + return res.status(400).json({ success: false, message: 'File is required' }); + } + + const { id } = req.params; + const { documentType = RESIGNATION_DOCUMENT_TYPES[0], stage = null } = req.body; + if (!RESIGNATION_DOCUMENT_TYPES.includes(documentType)) { + await transaction.rollback(); + return res.status(400).json({ + success: false, + message: `Invalid document type. Allowed values: ${RESIGNATION_DOCUMENT_TYPES.join(', ')}` + }); + } + if (stage && !RESIGNATION_DOCUMENT_STAGES.includes(stage)) { + await transaction.rollback(); + return res.status(400).json({ + success: false, + message: `Invalid stage. Allowed values: ${RESIGNATION_DOCUMENT_STAGES.join(', ')}` + }); + } + const idStr = String(id); + const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr); + const resignation = await db.Resignation.findOne({ + where: isUUID ? { [Op.or]: [{ id: idStr }, { resignationId: idStr }] } : { resignationId: idStr } + }); + + if (!resignation) { + await transaction.rollback(); + return res.status(404).json({ success: false, message: 'Resignation not found' }); + } + + const filePath = `/uploads/documents/${req.file.filename}`; + const document = await db.ResignationDocument.create({ + resignationId: resignation.id, + documentType, + fileName: req.file.originalname, + filePath, + fileSize: req.file.size, + mimeType: req.file.mimetype, + stage, + uploadedBy: req.user.id + }, { transaction }); + + await db.ResignationAudit.create({ + userId: req.user.id, + resignationId: resignation.id, + action: AUDIT_ACTIONS.DOCUMENT_UPLOADED, + remarks: `${documentType} uploaded`, + details: { fileName: req.file.originalname, stage, documentType } + }, { transaction }); + + const timeline = [...(resignation.timeline || []), { + stage: resignation.currentStage, + timestamp: new Date(), + user: req.user.fullName, + action: `Document uploaded: ${documentType}`, + remarks: req.file.originalname + }]; + await resignation.update({ timeline }, { transaction }); + + await transaction.commit(); + res.status(201).json({ success: true, message: 'Document uploaded successfully', document }); + } catch (error) { + await transaction.rollback(); + logger.error('Error uploading resignation document:', error); + next(error); + } +}; + // Approve resignation (move to next stage) export const approveResignation = async (req: AuthRequest, res: Response, next: NextFunction) => { const targetOverride = (req as any).targetStage; @@ -237,7 +319,7 @@ export const approveResignation = async (req: AuthRequest, res: Response, next: // Transition via Workflow Service await ResignationWorkflowService.transitionResignation(resignation, nextStage, req.user.id, { remarks, - status: nextStage === RESIGNATION_STAGES.COMPLETED ? 'Completed' : `${nextStage} Review` + status: getResignationStatusForStage(nextStage) }); // Special logic for F&F and Completion @@ -426,7 +508,7 @@ export const sendBackResignation = async (req: AuthRequest, res: Response, next: await ResignationWorkflowService.transitionResignation(resignation, prevStage, req.user.id, { remarks, action: 'Sent Back', - status: `${prevStage} Review (Sent Back)` + status: `${getResignationStatusForStage(prevStage)} (Sent Back)` }); await transaction.commit(); @@ -438,7 +520,107 @@ export const sendBackResignation = async (req: AuthRequest, res: Response, next: } }; -// Update departmental clearance +// Update departmental clearance (existing code)... + +// Manually assign participant +export const assignResignation = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + if (!req.user) throw new Error('Unauthorized'); + const { id } = req.params; + const { assignTo, remarks } = req.body; // assignTo is a role code or specific userId + + const resignation = await db.Resignation.findOne({ + where: { [Op.or]: [{ id }, { resignationId: id }] }, + include: [{ model: db.User, as: 'dealer' }] + }); + + if (!resignation) { + return res.status(404).json({ success: false, message: 'Resignation not found' }); + } + + let targetUserId = null; + + // If assignTo is a UUID, it's a direct user assignment + const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(assignTo); + + if (isUUID) { + targetUserId = assignTo; + } else { + // Role-based resolution + const user = await db.User.findByPk(resignation.dealerId); + if (user && user.dealerId) { + const dealer = await db.Dealer.findByPk(user.dealerId, { + include: [{ + model: db.Application, + as: 'application', + include: [{ + model: db.District, + as: 'district', + include: [{ model: db.Region, as: 'region' }, { model: db.Zone, as: 'zone' }] + }] + }] + }); + + if (dealer?.application?.district) { + const d = dealer.application.district; + if (assignTo === 'asm') targetUserId = d.asmId; + else if (assignTo === 'rbm') targetUserId = d.region?.rbmId; + else if (assignTo === 'zbh') targetUserId = d.zone?.zbhId; + } + } + + // Fallback for national roles + if (!targetUserId) { + const roleIdMap: Record = { + 'nbh': ROLES.NBH, + 'legal': ROLES.LEGAL_ADMIN, + 'dd_admin': ROLES.DD_ADMIN + }; + const targetRole = roleIdMap[assignTo]; + if (targetRole) { + const roleUser = await db.User.findOne({ where: { roleCode: targetRole, status: 'active' } }); + if (roleUser) targetUserId = roleUser.id; + } + } + } + + if (!targetUserId) { + return res.status(400).json({ + success: false, + message: `Could not resolve a unique user for assignment: ${assignTo}. Please ensure the underlying master data (District/Region/Zone) is correctly mapped.` + }); + } + + await db.RequestParticipant.findOrCreate({ + where: { + requestId: resignation.id, + requestType: REQUEST_TYPES.RESIGNATION, + userId: targetUserId + }, + defaults: { + participantType: 'contributor', + joinedMethod: 'manual', + metadata: { + assignedBy: req.user.id, + remarks: remarks || 'Manual assignment' + } + } + }); + + await db.ResignationAudit.create({ + userId: req.user.id, + resignationId: resignation.id, + action: AUDIT_ACTIONS.UPDATED, + remarks: `Manually assigned user to the request. ${remarks || ''}`, + details: { assignedUserId: targetUserId, roleToAssign: assignTo } + }); + + res.json({ success: true, message: 'Participant assigned successfully' }); + } catch (error) { + logger.error('Error assigning resignation:', error); + next(error); + } +}; export const updateClearance = async (req: AuthRequest, res: Response, next: NextFunction) => { const transaction: Transaction = await db.sequelize.transaction(); try { @@ -495,16 +677,8 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex // Sync with F&F Clearance if settlement exists const fnf = await db.FnF.findOne({ where: { resignationId: resignation.id } }); if (fnf) { - // Mapping UI status to F&F status - // Mapping status to F&F status based on amount logic - let fnfStatus = 'Pending'; const numAmount = parseFloat(amount) || 0; - - if (numAmount === 0) { - fnfStatus = 'NOC Submitted'; - } else { - fnfStatus = 'Dues Pending'; - } + const fnfStatus = normalizeClearanceStatus(status, numAmount); const existingClearance = await db.FffClearance.findOne({ where: { fnfId: fnf.id, department }, @@ -619,6 +793,9 @@ export const updateResignationStatus = async (req: AuthRequest, res: Response, n (req as any).targetStage = RESIGNATION_STAGES.FNF_INITIATED; return approveResignation(req, res, next); + case 'assign': + return assignResignation(req, res, next); + default: return res.status(400).json({ success: false, diff --git a/src/modules/self-service/resignation.routes.ts b/src/modules/self-service/resignation.routes.ts index a4b27bf..262dff2 100644 --- a/src/modules/self-service/resignation.routes.ts +++ b/src/modules/self-service/resignation.routes.ts @@ -21,5 +21,6 @@ router.put('/:id/sendback', authenticate as any, resignationController.sendBackR router.post('/:id/sendback', authenticate as any, resignationController.sendBackResignation); router.put('/:id/clearance', authenticate as any, uploadSingle, resignationController.updateClearance); +router.post('/:id/documents', authenticate as any, uploadSingle, resignationController.uploadResignationDocument); export default router; diff --git a/src/modules/settlement/settlement.controller.ts b/src/modules/settlement/settlement.controller.ts index 5c0456d..5e29de3 100644 --- a/src/modules/settlement/settlement.controller.ts +++ b/src/modules/settlement/settlement.controller.ts @@ -5,6 +5,7 @@ import { AuthRequest } from '../../types/express.types.js'; import { FNF_STATUS, AUDIT_ACTIONS, FNF_DEPARTMENTS, RESIGNATION_STAGES, TERMINATION_STAGES } from '../../common/config/constants.js'; import { ResignationWorkflowService } from '../../services/ResignationWorkflowService.js'; import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js'; +import { normalizeFnFStatus, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js'; export const getDepartments = async (req: Request, res: Response) => { try { @@ -75,8 +76,9 @@ export const updateFnF = async (req: AuthRequest, res: Response) => { const fnf = await FnF.findByPk(id); if (!fnf) return res.status(404).json({ success: false, message: 'F&F settlement not found' }); + const normalizedStatus = normalizeFnFStatus(status || fnf.status); await fnf.update({ - status: status || fnf.status, + status: normalizedStatus, netAmount: finalSettlementAmount || fnf.netAmount, settlementAmount: finalSettlementAmount || fnf.settlementAmount, settlementDate: settlementDate || fnf.settlementDate, @@ -91,21 +93,29 @@ export const updateFnF = async (req: AuthRequest, res: Response) => { action: AUDIT_ACTIONS.FNF_UPDATED, entityType: 'fnf', entityId: id, - newData: { status, netAmount: finalSettlementAmount, remarks } + newData: { status: normalizedStatus, netAmount: finalSettlementAmount, remarks } }); - // If status is being set to Completed, update the parent request status as well - if (status === 'Completed' || status === FNF_STATUS.COMPLETED) { + // If status is being set to Completed, transition parent request via workflow services + if (normalizedStatus === FNF_STATUS.COMPLETED) { if (fnf.resignationId) { - await Resignation.update( - { status: 'Completed', stage: 'Completed' }, - { where: { id: fnf.resignationId } } - ); + const resignation = await Resignation.findByPk(fnf.resignationId); + if (resignation) { + await ResignationWorkflowService.transitionResignation(resignation, RESIGNATION_STAGES.COMPLETED, req.user?.id || null, { + action: 'F&F Settlement Completed', + remarks: remarks || 'F&F marked completed from settlement module.', + status: 'Completed' + }); + } } else if (fnf.terminationRequestId) { - await TerminationRequest.update( - { status: 'Completed' }, - { where: { id: fnf.terminationRequestId } } - ); + const termination = await TerminationRequest.findByPk(fnf.terminationRequestId); + if (termination) { + await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.TERMINATED, req.user?.id || null, { + action: 'F&F Settlement Completed', + remarks: remarks || 'F&F marked completed from settlement module.', + status: 'Terminated' + }); + } } } @@ -305,7 +315,7 @@ const calculateFnFLogic = async (id: string, userId: string | null = null) => { } // Determine Overall F&F Status - let newStatus = fnf.status; + let newStatus = normalizeFnFStatus(fnf.status); if (fnf.status === FNF_STATUS.INITIATED && progressPercentage > 0) { newStatus = FNF_STATUS.DD_CLEARANCE; } @@ -348,17 +358,21 @@ const calculateFnFLogic = async (id: string, userId: string | null = null) => { export const updateClearance = async (req: AuthRequest, res: Response) => { try { const { id, clearanceId } = req.params; - const { status, remarks, documentId, supportingDocument } = req.body; + const body = (req.body || {}) as Record; + const { status, remarks, documentId, supportingDocument } = body; const clearance = await FffClearance.findOne({ where: { id: clearanceId, fnfId: id } }); if (!clearance) return res.status(404).json({ success: false, message: 'Clearance record not found' }); + const uploadedSupportingDocument = req.file ? `/uploads/documents/${req.file.filename}` : undefined; + + const normalizedStatus = normalizeClearanceStatus(status || clearance.status, Number(clearance.amount || 0)); await clearance.update({ - status: status || clearance.status, + status: normalizedStatus, remarks: remarks || clearance.remarks, documentId: documentId || clearance.documentId, - supportingDocument: supportingDocument || clearance.supportingDocument, + supportingDocument: uploadedSupportingDocument || supportingDocument || clearance.supportingDocument, clearedBy: req.user?.id, - clearedAt: status === 'Cleared' ? new Date() : clearance.clearedAt + clearedAt: normalizedStatus !== 'Pending' ? new Date() : clearance.clearedAt }); // Automatically update FnF progress @@ -373,7 +387,7 @@ export const updateClearance = async (req: AuthRequest, res: Response) => { fnfId: id, action: 'CLEARANCE_UPDATED', remarks: remarks || 'No remarks', - details: { department: clearance.department, status } + details: { department: clearance.department, status: normalizedStatus } }); } catch (auditError) { console.error('[SettlementController] Local FnFAudit creation failed:', auditError); @@ -393,7 +407,7 @@ export const updateClearance = async (req: AuthRequest, res: Response) => { [parentKey]: parentId, action: 'STAKEHOLDER_CLEARANCE_UPDATED', remarks: `Automated sync from F&F: ${remarks || 'No remarks'}`, - details: { department: clearance.department, status } + details: { department: clearance.department, status: normalizedStatus } }); } catch (parentAuditError) { console.error('[SettlementController] Parent Audit creation failed:', parentAuditError); diff --git a/src/modules/settlement/settlement.routes.ts b/src/modules/settlement/settlement.routes.ts index 9a44556..d85d05c 100644 --- a/src/modules/settlement/settlement.routes.ts +++ b/src/modules/settlement/settlement.routes.ts @@ -4,6 +4,7 @@ import * as settlementController from './settlement.controller.js'; import { authenticate } from '../../common/middleware/auth.js'; import { checkRole } from '../../common/middleware/roleCheck.js'; import { ROLES } from '../../common/config/constants.js'; +import { uploadSingle } from '../../common/middleware/upload.js'; // All routes require authentication router.use(authenticate as any); @@ -19,7 +20,7 @@ router.put('/payments/:id', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any 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); -router.put('/fnf/:id/clearances/:clearanceId', checkRole([ROLES.FINANCE, ROLES.SPARES_MANAGER, ROLES.SERVICE_MANAGER, ROLES.ACCOUNTS_MANAGER, ROLES.SUPER_ADMIN]) as any, settlementController.updateClearance); +router.put('/fnf/:id/clearances/:clearanceId', uploadSingle, checkRole([ROLES.FINANCE, ROLES.SPARES_MANAGER, ROLES.SERVICE_MANAGER, ROLES.ACCOUNTS_MANAGER, ROLES.SUPER_ADMIN]) as any, settlementController.updateClearance); // Line item management router.post('/fnf/:id/line-items', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.addLineItem); diff --git a/src/modules/termination/termination.controller.ts b/src/modules/termination/termination.controller.ts index d34f94d..cf1da55 100644 --- a/src/modules/termination/termination.controller.ts +++ b/src/modules/termination/termination.controller.ts @@ -1,7 +1,13 @@ import { Response, NextFunction } from 'express'; import db from '../../database/models/index.js'; import logger from '../../common/utils/logger.js'; -import { TERMINATION_STAGES, AUDIT_ACTIONS, ROLES } from '../../common/config/constants.js'; +import { + TERMINATION_STAGES, + AUDIT_ACTIONS, + ROLES, + TERMINATION_DOCUMENT_TYPES, + TERMINATION_DOCUMENT_STAGES +} 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'; @@ -9,6 +15,7 @@ import ExternalMocksService from '../../common/utils/externalMocks.service.js'; import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js'; import { NomenclatureService } from '../../common/utils/nomenclature.js'; import { ParticipantService } from '../../services/ParticipantService.js'; +import { getTerminationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js'; // Create termination request export const createTermination = async (req: AuthRequest, res: Response, next: NextFunction) => { @@ -27,7 +34,7 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N comments, initiatedBy: req.user.id, currentStage: TERMINATION_STAGES.SUBMITTED, - status: 'Submitted', + status: getTerminationStatusForStage(TERMINATION_STAGES.SUBMITTED), progressPercentage: TerminationWorkflowService.calculateProgress(TERMINATION_STAGES.SUBMITTED), timeline: [{ stage: 'Submitted', @@ -151,6 +158,80 @@ export const getTerminationById = async (req: AuthRequest, res: Response, next: } }; +export const uploadTerminationDocument = async (req: AuthRequest, res: Response, next: NextFunction) => { + const transaction: Transaction = await db.sequelize.transaction(); + try { + if (!req.user) throw new Error('Unauthorized'); + if (!req.file) { + await transaction.rollback(); + return res.status(400).json({ success: false, message: 'File is required' }); + } + + const { id } = req.params; + const { documentType = TERMINATION_DOCUMENT_TYPES[0], stage = null } = req.body; + if (!TERMINATION_DOCUMENT_TYPES.includes(documentType)) { + await transaction.rollback(); + return res.status(400).json({ + success: false, + message: `Invalid document type. Allowed values: ${TERMINATION_DOCUMENT_TYPES.join(', ')}` + }); + } + if (stage && !TERMINATION_DOCUMENT_STAGES.includes(stage)) { + await transaction.rollback(); + return res.status(400).json({ + success: false, + message: `Invalid stage. Allowed values: ${TERMINATION_DOCUMENT_STAGES.join(', ')}` + }); + } + const idStr = String(id); + const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr); + const termination = await db.TerminationRequest.findOne({ + where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr } + }); + + if (!termination) { + await transaction.rollback(); + return res.status(404).json({ success: false, message: 'Termination request not found' }); + } + + const filePath = `/uploads/documents/${req.file.filename}`; + const document = await db.TerminationDocument.create({ + terminationRequestId: termination.id, + documentType, + fileName: req.file.originalname, + filePath, + fileSize: req.file.size, + mimeType: req.file.mimetype, + stage, + uploadedBy: req.user.id + }, { transaction }); + + await db.TerminationAudit.create({ + userId: req.user.id, + terminationRequestId: termination.id, + action: AUDIT_ACTIONS.DOCUMENT_UPLOADED, + remarks: `${documentType} uploaded`, + details: { fileName: req.file.originalname, stage, documentType } + }, { transaction }); + + const timeline = [...(termination.timeline || []), { + stage: termination.currentStage, + timestamp: new Date(), + user: req.user.fullName, + action: `Document uploaded: ${documentType}`, + remarks: req.file.originalname + }]; + await termination.update({ timeline }, { transaction }); + + await transaction.commit(); + res.status(201).json({ success: true, message: 'Document uploaded successfully', document }); + } catch (error) { + await transaction.rollback(); + logger.error('Error uploading termination document:', error); + next(error); + } +}; + // Update termination status (Approve/Reject) export const updateTerminationStatus = async (req: AuthRequest, res: Response, next: NextFunction) => { const transaction: Transaction = await db.sequelize.transaction(); @@ -197,7 +278,7 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n await TerminationWorkflowService.transitionTermination(termination, nextStage, req.user.id, { remarks, - status: nextStage === TERMINATION_STAGES.TERMINATED ? 'Terminated' : `${nextStage}` + status: getTerminationStatusForStage(nextStage) }); // If Terminated, trigger F&F initiation via Workflow Service @@ -215,8 +296,8 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n await TerminationWorkflowService.initiateFnF(termination, req.user.id, transaction); } else { logger.info(`[TerminationController] Termination approved but LWD (${termination.proposedLwd}) not yet reached. F&F will be triggered on LWD.`); - // Update status to reflect F&F is pending LWD arrivement - await termination.update({ status: 'Approved (F&F Pending LWD)' }, { transaction }); + // Keep parent status aligned while waiting for LWD-triggered F&F + await termination.update({ status: 'Awaiting F&F (LWD Pending)' }, { transaction }); } } } @@ -286,8 +367,9 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex if (!termination) throw new Error('Termination request not found'); const clearances = { ...(termination.departmentalClearances || {}) }; + const normalizedStatus = normalizeClearanceStatus(status, Number(amount) || 0); clearances[department] = { - status, + status: normalizedStatus, amount: Number(amount) || 0, type: type || 'Receivable', remarks: remarks || '', @@ -301,7 +383,7 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex const fnf = await db.FnF.findOne({ where: { terminationRequestId: id } }); if (fnf) { await db.FffClearance.update( - { status, remarks, amount: Number(amount) || 0 }, + { status: normalizedStatus, remarks, amount: Number(amount) || 0 }, { where: { fnfId: fnf.id, department }, transaction } ); } @@ -311,7 +393,7 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex action: 'CLEARANCE_UPDATED', terminationRequestId: id, remarks: remarks || `Cleared ${department}`, - details: { department, status, amount } + details: { department, status: normalizedStatus, amount } }, { transaction }); if (fnf) { @@ -320,7 +402,7 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex fnfId: fnf.id, action: 'CLEARANCE_UPDATED', remarks: remarks || `Departmental clearance recorded for ${department}`, - details: { department, status, source: 'Termination Workflow' } + details: { department, status: normalizedStatus, source: 'Termination Workflow' } }, { transaction }); } diff --git a/src/modules/termination/termination.routes.ts b/src/modules/termination/termination.routes.ts index dd27f54..af7547b 100644 --- a/src/modules/termination/termination.routes.ts +++ b/src/modules/termination/termination.routes.ts @@ -2,9 +2,10 @@ import express from 'express'; const router = express.Router(); import { createTermination, getTerminations, getTerminationById, updateTerminationStatus, - submitScnResponse, recordPersonalHearing, updateClearance + submitScnResponse, recordPersonalHearing, updateClearance, uploadTerminationDocument } from './termination.controller.js'; import { authenticate } from '../../common/middleware/auth.js'; +import { uploadSingle } from '../../common/middleware/upload.js'; router.use(authenticate as any); @@ -16,5 +17,6 @@ router.post('/:id/status', updateTerminationStatus); router.post('/scn-response', submitScnResponse); router.post('/hearing-record', recordPersonalHearing); router.put('/:id/clearance', updateClearance); +router.post('/:id/documents', uploadSingle, uploadTerminationDocument); export default router; diff --git a/src/services/ConstitutionalWorkflowService.ts b/src/services/ConstitutionalWorkflowService.ts index 505ae92..fc65758 100644 --- a/src/services/ConstitutionalWorkflowService.ts +++ b/src/services/ConstitutionalWorkflowService.ts @@ -7,11 +7,13 @@ export class ConstitutionalWorkflowService { */ static async transitionRequest(request: any, targetStage: string, userId: string, options: any = {}) { const { action, status, remarks, userFullName } = options; + const sourceStage = request.currentStage; const updatedTimeline = [ - ...request.timeline, + ...(request.timeline || []), { - stage: targetStage, + stage: sourceStage, // Correctly Associate remark with the stage where action happened + targetStage: targetStage, timestamp: new Date(), user: userFullName || 'System', action: action || `Moved to ${targetStage}`, @@ -35,7 +37,7 @@ export class ConstitutionalWorkflowService { constitutionalChangeId: request.id, action: action === 'Reject' ? AUDIT_ACTIONS.REJECTED : AUDIT_ACTIONS.UPDATED, remarks: remarks || '', - details: { status: updateData.status, stage: targetStage } + details: { status: updateData.status, stage: sourceStage, targetStage: targetStage } }); return request; diff --git a/src/services/RelocationWorkflowService.ts b/src/services/RelocationWorkflowService.ts index 620f570..18f0504 100644 --- a/src/services/RelocationWorkflowService.ts +++ b/src/services/RelocationWorkflowService.ts @@ -25,13 +25,16 @@ export class RelocationWorkflowService { updateData.progressPercentage = progressPercentage; } + const sourceStage = request.currentStage; + // 1. Update Request Record await request.update(updateData); // 2. Update Timeline (JSON array) const user = userId ? await User.findByPk(userId) : null; const timelineEntry = { - stage: stage || request.currentStage, + stage: sourceStage, // Store the stage where the action happened + targetStage: stage || targetStatus, timestamp: new Date(), user: user ? user.fullName : 'System', action: action || `Transitioned to ${targetStatus}`, @@ -47,7 +50,7 @@ export class RelocationWorkflowService { relocationRequestId: request.id, action: action === 'REJECT' ? AUDIT_ACTIONS.REJECTED : AUDIT_ACTIONS.APPROVED, remarks: reason || '', - details: { status: targetStatus, stage: stage || request.currentStage } + details: { status: targetStatus, stage: sourceStage, targetStage: stage || targetStatus } }); console.log(`[RelocationWorkflowService] Transitioned Request ${request.requestId} to ${targetStatus}`); diff --git a/src/services/ResignationWorkflowService.ts b/src/services/ResignationWorkflowService.ts index 1727ab3..594e3dc 100644 --- a/src/services/ResignationWorkflowService.ts +++ b/src/services/ResignationWorkflowService.ts @@ -1,6 +1,7 @@ import db from '../database/models/index.js'; const { AuditLog, User, Worknote } = db; import { AUDIT_ACTIONS, RESIGNATION_STAGES, ROLES } from '../common/config/constants.js'; +import { getResignationStatusForStage } from '../common/utils/offboardingStatus.js'; import { NotificationService } from './NotificationService.js'; import { Op } from 'sequelize'; import logger from '../common/utils/logger.js'; @@ -12,21 +13,20 @@ export class ResignationWorkflowService { */ static async transitionResignation(resignation: any, targetStage: string, userId: string | null = null, metadata: any = {}) { const { action, remarks, status } = metadata; + const sourceStage = resignation.currentStage; const updateData: any = { currentStage: targetStage, - status: status || targetStage, + status: status || getResignationStatusForStage(targetStage), progressPercentage: this.calculateProgress(targetStage), updatedAt: new Date() }; - // 1. Update Resignation Record - await resignation.update(updateData); - - // 2. Update Timeline (JSON array) + // 2. Update Timeline (JSON array) & Resignation Record const actor = userId ? await User.findByPk(userId) : null; const timelineEntry = { - stage: targetStage, + stage: sourceStage, // Correctly Associate remark with the stage where action happened + targetStage: targetStage, // Store target for reference timestamp: new Date(), user: actor ? actor.fullName : 'System', action: action || `Approved to ${targetStage}`, @@ -34,7 +34,11 @@ export class ResignationWorkflowService { }; const updatedTimeline = [...(resignation.timeline || []), timelineEntry]; - await resignation.update({ timeline: updatedTimeline }); + + await resignation.update({ + ...updateData, + timeline: updatedTimeline + }); // 3. Create Audit Log let auditAction: any = AUDIT_ACTIONS.APPROVED; @@ -47,7 +51,7 @@ export class ResignationWorkflowService { resignationId: resignation.id, action: auditAction, remarks: remarks || '', - details: { status: updateData.status, stage: targetStage } + details: { status: updateData.status, stage: sourceStage, targetStage: targetStage } }); // 4. Create Worknote if it's a "Sent Back" action for communication diff --git a/src/services/TerminationWorkflowService.ts b/src/services/TerminationWorkflowService.ts index aa37c90..6ef774e 100644 --- a/src/services/TerminationWorkflowService.ts +++ b/src/services/TerminationWorkflowService.ts @@ -2,6 +2,7 @@ import db from '../database/models/index.js'; import { Op } from 'sequelize'; const { AuditLog, User, TerminationScnResponse, TerminationHearingRecord, Dealer, FnF, FnFLineItem, FffClearance } = db; import { AUDIT_ACTIONS, TERMINATION_STAGES, ROLES, FNF_DEPARTMENTS } from '../common/config/constants.js'; +import { getTerminationStatusForStage } from '../common/utils/offboardingStatus.js'; import { NotificationService } from './NotificationService.js'; import ExternalMocksService from '../common/utils/externalMocks.service.js'; import logger from '../common/utils/logger.js'; @@ -13,20 +14,22 @@ export class TerminationWorkflowService { */ static async transitionTermination(termination: any, targetStage: string, userId: string | null = null, metadata: any = {}) { const { action, remarks, status } = metadata; + const sourceStage = termination.currentStage; const updateData: any = { currentStage: targetStage, - status: status || (targetStage === TERMINATION_STAGES.TERMINATED ? 'Terminated' : targetStage), + status: status || getTerminationStatusForStage(targetStage), + progressPercentage: this.calculateProgress(targetStage), updatedAt: new Date() }; - // 1. Update Termination Record - await termination.update(updateData); - - // 2. Update Timeline (JSON array) + // 1. Resolve Actor const actor = userId ? await User.findByPk(userId) : null; + + // 2. Prepare Timeline Entry const timelineEntry = { - stage: targetStage, + stage: sourceStage, // Correctly Associate remark with the stage where action happened + targetStage: targetStage, timestamp: new Date(), user: actor ? actor.fullName : 'System', action: action || `Approved to ${targetStage}`, @@ -34,9 +37,14 @@ export class TerminationWorkflowService { }; const updatedTimeline = [...(termination.timeline || []), timelineEntry]; - await termination.update({ timeline: updatedTimeline }); - // 3. Create Audit Log + // 3. Perform Consolidated Update + await termination.update({ + ...updateData, + timeline: updatedTimeline + }); + + // 4. Create Audit Log let auditAction: any = AUDIT_ACTIONS.APPROVED; if (action === 'REJECT' || action === 'Rejected') auditAction = AUDIT_ACTIONS.REJECTED; if (action === 'SCN_SUBMITTED' || action === 'Hearing Recorded') auditAction = AUDIT_ACTIONS.UPDATED; @@ -46,7 +54,7 @@ export class TerminationWorkflowService { terminationRequestId: termination.id, action: auditAction, remarks: remarks || '', - details: { status: updateData.status, stage: targetStage } + details: { status: updateData.status, stage: sourceStage, targetStage: targetStage } }); // 4. Send Notifications diff --git a/src/services/WorkflowIntegrityService.ts b/src/services/WorkflowIntegrityService.ts new file mode 100644 index 0000000..cd85b30 --- /dev/null +++ b/src/services/WorkflowIntegrityService.ts @@ -0,0 +1,131 @@ + +import db from '../database/models/index.js'; +import { WorkflowService } from './WorkflowService.js'; +import { APPLICATION_STATUS } from '../common/config/constants.js'; + +const LOA_STAGE_CODE = 'LOA_APPROVAL'; +const LOI_STAGE_CODE = 'LOI_APPROVAL'; + +export class WorkflowIntegrityService { + /** + * Ensures an application's state is consistent with its approvals and policies. + * This fixes stalled applications automatically without manual "healing" scripts. + */ + static async synchronizeApplicationState(applicationId: string) { + try { + const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(applicationId); + const application = await db.Application.findOne({ + where: isUUID ? { [db.Sequelize.Op.or]: [{ id: applicationId }, { applicationId }] } : { applicationId } + }); + + if (!application) return; + + // LOA STAGE INTEGRITY + if (['LOA Pending', 'LOA Issued'].includes(application.overallStatus)) { + await this.syncLoaIntegrity(application); + } + + // LOI STAGE INTEGRITY + if ([APPLICATION_STATUS.LOI_IN_PROGRESS, APPLICATION_STATUS.PAYMENT_PENDING, APPLICATION_STATUS.SECURITY_DETAILS].includes(application.overallStatus)) { + await this.syncLoiIntegrity(application); + } + + // DEALER CODE INTEGRITY (Parallelizing Architecture/Statutory) + if ([APPLICATION_STATUS.DEALER_CODE_GENERATION, APPLICATION_STATUS.ARCHITECTURE_TEAM_ASSIGNED, APPLICATION_STATUS.ARCHITECTURE_DOCUMENT_UPLOAD, APPLICATION_STATUS.ARCHITECTURE_TEAM_COMPLETION].includes(application.overallStatus) || application.overallStatus.includes('Statutory')) { + await this.syncDealerCodeIntegrity(application); + } + + // Other stages can be added here... + } catch (error) { + console.error(`[WorkflowIntegrityService] Error syncing app ${applicationId}:`, error); + } + } + + private static async syncDealerCodeIntegrity(application: any) { + // Check if Dealer codes exist + const dealerCode = await db.DealerCode.findOne({ + where: { applicationId: application.id, status: 'Active' } + }); + + if (dealerCode) { + console.log(`[WorkflowIntegrityService] Dealer Codes exist for ${application.applicationId}. Transitioning to LOA Pending...`); + + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOA_PENDING, null, { + reason: 'Auto-transitioned by Integrity Service: Dealer codes generated. Architecture/Statutory work will proceed in parallel.', + progressPercentage: 85 + }); + } + } + + private static async syncLoaIntegrity(application: any) { + // 1. Migrate legacy approvals if they exist but aren't in the new system + const legacyApprovals = await db.LoaApproval.findAll({ + where: { action: 'Approved' }, + include: [{ + model: db.LoaRequest, + as: 'request', + where: { applicationId: application.id } + }] + }); + + for (const legacy of legacyApprovals) { + if (legacy.approverId) { + await db.StageApprovalAction.findOrCreate({ + where: { + applicationId: application.id, + stageCode: LOA_STAGE_CODE, + actorUserId: legacy.approverId + }, + defaults: { + actorRole: legacy.approverRole, + decision: 'Approved', + remarks: legacy.remarks || 'Migrated from legacy approval' + } + }); + } + } + + // 2. Evaluate policy and transition if met + const { policyMet } = await WorkflowService.evaluateStagePolicy(application.id, LOA_STAGE_CODE); + + if (policyMet && application.overallStatus !== APPLICATION_STATUS.EOR_IN_PROGRESS) { + console.log(`[WorkflowIntegrityService] Policy met for LOA on ${application.applicationId}. Transitioning...`); + + // Ensure LoaRequest is also updated + const request = await db.LoaRequest.findOne({ where: { applicationId: application.id } }); + if (request && request.status !== 'Approved') { + await request.update({ status: 'Approved', approvedAt: new Date() }); + } + + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.EOR_IN_PROGRESS, null, { + reason: 'Auto-transitioned by Integrity Service: Approval condition satisfied.', + progressPercentage: 97 + }); + } + } + + private static async syncLoiIntegrity(application: any) { + // 1. Evaluate policy + const { policyMet } = await WorkflowService.evaluateStagePolicy(application.id, LOI_STAGE_CODE); + + // 2. Check for Security Deposit verification + const deposit = await db.SecurityDeposit.findOne({ + where: { applicationId: application.id, depositType: 'SECURITY_DEPOSIT', status: 'Verified' } + }); + + if (policyMet && deposit) { + console.log(`[WorkflowIntegrityService] Policy met and Payment Verified for LOI on ${application.applicationId}. Transitioning to LOI Issued...`); + + // Ensure LoiRequest is also updated + const request = await db.LoiRequest.findOne({ where: { applicationId: application.id } }); + if (request && request.status !== 'Approved') { + await request.update({ status: 'Approved', approvedAt: new Date() }); + } + + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOI_ISSUED, null, { + reason: 'Auto-transitioned by Integrity Service: Policy and Payment criteria met.', + progressPercentage: 80 + }); + } + } +} diff --git a/trigger-constitutional.js b/trigger-constitutional.js index 0b1a59b..780228f 100644 --- a/trigger-constitutional.js +++ b/trigger-constitutional.js @@ -1,7 +1,11 @@ -import fs from 'fs'; - -const BASE_URL = 'http://localhost:5000/api'; +const args = Object.fromEntries( + process.argv.slice(2) + .map(arg => arg.replace(/^--/, '').split('=')) + .map(([k, v]) => [k, v ?? 'true']) +); +const BASE_URL = args.baseUrl || process.env.BASE_URL || 'http://localhost:5000/api'; const PASSWORD = 'Admin@123'; +const STEP_DELAY_MS = Number(args.delayMs || 500); const EMAILS = { DD_ADMIN: 'lince@gmail.com', @@ -45,11 +49,14 @@ async function apiRequest(endpoint, method = 'GET', body = null, token = null) { } async function login(email) { + if (!login.cache) login.cache = {}; + if (login.cache[email]) return login.cache[email]; const data = await apiRequest('/auth/login', 'POST', { email, password: PASSWORD }); - return data.token; + login.cache[email] = data.token; + return login.cache[email]; } -const delay = (ms = 500) => new Promise(res => setTimeout(res, ms)); +const delay = (ms = STEP_DELAY_MS) => new Promise(res => setTimeout(res, ms)); async function run() { try { @@ -58,17 +65,21 @@ async function run() { console.log(`[STEP 0] Logging in as Dealer: ${EMAILS.DEALER}...`); const dealerToken = await login(EMAILS.DEALER); - console.log('[STEP 1] Dealer Submitting Constitutional Change...'); - const createRes = await apiRequest('/self-service/constitutional', 'POST', { - changeType: 'LLP Conversion', - reason: 'Converting to LLP for better operational governance.', - currentConstitution: 'Proprietorship', - newPartnersDetails: 'John Doe, Jane Smith', - shareholdingPattern: '60/40' - }, dealerToken); - - const requestId = createRes.requestId; - console.log(`[STEP 1] Request Created. RequestID: ${requestId}`); + let requestId = args.requestId; + if (!requestId) { + console.log('[STEP 1] Dealer Submitting Constitutional Change...'); + const createRes = await apiRequest('/self-service/constitutional', 'POST', { + changeType: args.changeType || 'LLP Conversion', + reason: args.reason || 'Converting to LLP for better operational governance.', + currentConstitution: 'Proprietorship', + newPartnersDetails: 'John Doe, Jane Smith', + shareholdingPattern: '60/40' + }, dealerToken); + requestId = createRes.requestId; + console.log(`[STEP 1] Request Created. RequestID: ${requestId}`); + } else { + console.log(`[STEP 1] Resuming request: ${requestId}`); + } // Sequence of users taking actions to advance stages const approvalSequence = [ @@ -82,8 +93,15 @@ async function run() { { name: 'Legal Finalize', email: EMAILS.LEGAL } ]; - let currentStep = 2; - for (const actor of approvalSequence) { + const adminToken = await login(EMAILS.DD_ADMIN); + const current = await apiRequest(`/self-service/constitutional/${requestId}`, 'GET', null, adminToken); + const currentStage = current?.request?.currentStage; + const stageOrder = ['Submitted', 'ASM Review', 'ZM/RBM Review', 'ZBH Review', 'DD Lead Review', 'DD Head Review', 'NBH Approval', 'Legal Review', 'Completed']; + const startIndex = Math.max(0, stageOrder.indexOf(currentStage)); + + let currentStep = 2 + startIndex; + for (let i = startIndex; i < approvalSequence.length; i++) { + const actor = approvalSequence[i]; console.log(`[STEP ${currentStep}] ${actor.name} (${actor.email}) processing approval...`); const token = await login(actor.email); const res = await apiRequest(`/self-service/constitutional/${requestId}/action`, 'POST', { @@ -96,7 +114,6 @@ async function run() { } console.log('[FINAL STEP] Verifying Completion Status...'); - const adminToken = await login(EMAILS.DD_ADMIN); const finalDetails = await apiRequest(`/self-service/constitutional/${requestId}`, 'GET', null, adminToken); if (finalDetails.request.status === 'Completed' || finalDetails.request.currentStage === 'Completed') { diff --git a/trigger-relocation.js b/trigger-relocation.js new file mode 100644 index 0000000..8ae9a79 --- /dev/null +++ b/trigger-relocation.js @@ -0,0 +1,156 @@ +const args = Object.fromEntries( + process.argv.slice(2) + .map((arg) => arg.replace(/^--/, "").split("=")) + .map(([k, v]) => [k, v ?? "true"]) +); + +const BASE_URL = args.baseUrl || process.env.BASE_URL || "http://localhost:5000/api"; +const PASSWORD = "Admin@123"; +const STEP_DELAY_MS = Number(args.delayMs || 500); + +const EMAILS = { + DD_ADMIN: args.ddAdminEmail || "lince@gmail.com", + DEALER: args.dealerEmail || "dealer@royalenfield.com", + ASM: args.asmEmail || "asm.sdelhi@royalenfield.com", + RBM: args.rbmEmail || "rbm.ncr@royalenfield.com", + DD_ZM: args.ddZmEmail || "ddzm.ncr@royalenfield.com", + ZBH: args.zbhEmail || "yashwin@gmail.com", + DD_LEAD: args.ddLeadEmail || "ddlead@royalenfield.com", + DD_HEAD: args.ddHeadEmail || "ddhead@royalenfield.com", + NBH: args.nbhEmail || "nbh@royalenfield.com", + LEGAL: args.legalEmail || "legal@royalenfield.com", +}; + +const ROLE_BY_STAGE = { + "ASM Review": ["ASM"], + "RBM Review": ["RBM"], + "DD ZM Review": ["DD_ZM", "RBM"], + "ZBH Review": ["ZBH"], + "DD Lead Review": ["DD_LEAD"], + "DD Head Approval": ["DD_HEAD"], + "NBH Approval": ["NBH"], + "Legal Clearance": ["LEGAL"], + "NBH Clearance with EOR": ["NBH"], +}; + +const delay = (ms = STEP_DELAY_MS) => new Promise((r) => setTimeout(r, ms)); + +async function apiRequest(endpoint, method = "GET", body = null, token = null) { + const headers = { "Content-Type": "application/json" }; + if (token) headers.Authorization = `Bearer ${token}`; + const config = { method, headers }; + if (body) config.body = JSON.stringify(body); + + const response = await fetch(`${BASE_URL}${endpoint}`, config); + const data = await response.json(); + if (!response.ok) { + throw new Error(`API Error ${method} ${endpoint}: ${JSON.stringify(data)}`); + } + return data; +} + +async function login(email) { + if (!login.cache) login.cache = {}; + if (login.cache[email]) return login.cache[email]; + const data = await apiRequest("/auth/login", "POST", { email, password: PASSWORD }); + login.cache[email] = data.token; + return data.token; +} + +async function getRelocationByAnyId(id, token) { + return apiRequest(`/self-service/relocation/${id}`, "GET", null, token); +} + +async function approveCurrentStage(requestId, stageName) { + const candidateRoles = ROLE_BY_STAGE[stageName] || []; + if (!candidateRoles.length) { + throw new Error(`No actor mapping found for stage: ${stageName}`); + } + + let lastError = null; + for (const roleKey of candidateRoles) { + const email = EMAILS[roleKey]; + if (!email) continue; + try { + const token = await login(email); + const res = await apiRequest(`/self-service/relocation/${requestId}/action`, "POST", { + action: "APPROVE", + comments: `${roleKey} approved relocation request.`, + }, token); + return { roleKey, email, message: res.message || "Approved" }; + } catch (error) { + lastError = error; + } + } + throw lastError || new Error(`Approval failed for stage: ${stageName}`); +} + +async function resolveDealerOutlet(dealerToken) { + const dashboard = await apiRequest("/dealer/dashboard", "GET", null, dealerToken); + const outlet = dashboard?.data?.outlets?.[0]; + if (!outlet) throw new Error("No dealer outlet found to create relocation request."); + return outlet; +} + +async function run() { + try { + console.log("--- STARTING RELOCATION E2E FLOW ---"); + const adminToken = await login(EMAILS.DD_ADMIN); + const dealerToken = await login(EMAILS.DEALER); + + let requestId = args.requestId; + if (!requestId) { + const outlet = await resolveDealerOutlet(dealerToken); + console.log(`[STEP 1] Submitting relocation for outlet: ${outlet.name} (${outlet.id})`); + const createRes = await apiRequest("/self-service/relocation", "POST", { + outletId: args.outletId || outlet.id, + relocationType: args.relocationType || "Intercity", + newAddress: args.newAddress || "Sector 21, New Premises", + newCity: args.newCity || "Gurugram", + newState: args.newState || "Haryana", + reason: args.reason || "Business expansion and better customer access", + proposedDate: args.proposedDate || new Date().toISOString().split("T")[0], + }, dealerToken); + requestId = createRes.requestId; + console.log(`[STEP 1] Request Created: ${requestId}`); + await delay(); + } else { + console.log(`[STEP 1] Resuming request: ${requestId}`); + } + + let step = 2; + while (true) { + const detailsRes = await getRelocationByAnyId(requestId, adminToken); + const request = detailsRes.request; + const stage = request.currentStage; + const status = request.status; + + if (stage === "Completed" || status === "Completed") { + console.log(`[STEP ${step}] SUCCESS: Relocation request is completed.`); + break; + } + if (stage === "Rejected" || status === "Rejected") { + throw new Error(`Relocation request is rejected at stage: ${stage}`); + } + + console.log(`[STEP ${step}] Current Stage: ${stage} | Status: ${status}`); + const actor = await approveCurrentStage(requestId, stage); + console.log(`[STEP ${step}] ${actor.roleKey} (${actor.email}) -> ${actor.message}`); + step++; + await delay(); + } + + const finalRes = await getRelocationByAnyId(requestId, adminToken); + console.log("--- VERIFICATION RESULTS ---"); + console.log(`RequestId: ${finalRes.request.requestId || requestId}`); + console.log(`Final Stage: ${finalRes.request.currentStage}`); + console.log(`Final Status: ${finalRes.request.status}`); + console.log("Outcome: RELOCATION FLOW COMPLETED SUCCESSFULLY"); + process.exit(0); + } catch (error) { + console.error("Workflow failed:", error.message); + process.exit(1); + } +} + +run(); diff --git a/trigger-resignation.js b/trigger-resignation.js index 1388960..d4625cd 100644 --- a/trigger-resignation.js +++ b/trigger-resignation.js @@ -1,7 +1,13 @@ -import fs from 'fs'; - -const BASE_URL = 'http://localhost:5000/api'; +const args = Object.fromEntries( + process.argv.slice(2) + .map(arg => arg.replace(/^--/, '').split('=')) + .map(([k, v]) => [k, v ?? 'true']) +); +const BASE_URL = args.baseUrl || process.env.BASE_URL || 'http://localhost:5000/api'; const PASSWORD = 'Admin@123'; +const STEP_DELAY_MS = Number(args.delayMs || 500); +const SHOULD_SKIP_CLEARANCES = String(args.skipClearances || 'false') === 'true'; +const SHOULD_SKIP_FINAL_SETTLEMENT = String(args.skipSettlement || 'false') === 'true'; const EMAILS = { DD_ADMIN: 'lince@gmail.com', @@ -44,16 +50,19 @@ async function apiRequest(endpoint, method = 'GET', body = null, token = null) { } async function login(email) { + if (!login.cache) login.cache = {}; + if (login.cache[email]) return login.cache[email]; const isInternal = email.endsWith('@royalenfield.com') || email === 'lince@gmail.com' || email === 'yashwin@gmail.com'; const password = isInternal ? 'Admin@123' : 'Dealer@123'; const data = await apiRequest('/auth/login', 'POST', { email, password }); - return data.token; + login.cache[email] = data.token; + return login.cache[email]; } -const delay = (ms = 500) => new Promise(res => setTimeout(res, ms)); +const delay = (ms = STEP_DELAY_MS) => new Promise(res => setTimeout(res, ms)); const log = (step, msg) => console.log(`[STEP ${step}] ${msg}`); async function run() { @@ -101,29 +110,32 @@ async function run() { console.log(`Found Target Outlet: ${targetOutlet.name} (${targetOutlet.code})`); console.log(`[STEP 1.2] Dealer Submitting Resignation for Outlet...`); - let resignationId; - try { - const createRes = await apiRequest('/self-service/resignations', 'POST', { - outletId: targetOutlet.id, - resignationType: 'Voluntary', - lastOperationalDateSales: new Date().toISOString().split('T')[0], - lastOperationalDateServices: new Date().toISOString().split('T')[0], - reason: 'Focusing on other business ventures', - remarks: 'Initiating voluntary resignation for E2E validation.' - }, dealerToken); - resignationId = createRes.resignation.id; - log(1, `Resignation Created. ID: ${resignationId}`); - } catch (e) { - if (e.message.includes('already has an active resignation request')) { - console.log(`[STEP 1.2] Active resignation already exists. Fetching...`); - // Use plural route for listing - const activeResRes = await apiRequest('/self-service/resignations', 'GET', null, dealerToken); - const activeRes = (activeResRes.resignations || activeResRes.data).find(r => r.outletId === targetOutlet.id && !['Completed', 'Rejected'].includes(r.status)); - resignationId = activeRes.id; - log(1, `Resuming with existig Resignation: ${resignationId}`); - } else { - throw e; + let resignationId = args.resignationId; + if (!resignationId) { + try { + const createRes = await apiRequest('/self-service/resignations', 'POST', { + outletId: targetOutlet.id, + resignationType: 'Voluntary', + lastOperationalDateSales: new Date().toISOString().split('T')[0], + lastOperationalDateServices: new Date().toISOString().split('T')[0], + reason: 'Focusing on other business ventures', + remarks: 'Initiating voluntary resignation for E2E validation.' + }, dealerToken); + resignationId = createRes.resignation.id; + log(1, `Resignation Created. ID: ${resignationId}`); + } catch (e) { + if (e.message.includes('already has an active resignation request')) { + console.log(`[STEP 1.2] Active resignation already exists. Fetching...`); + const activeResRes = await apiRequest('/self-service/resignations', 'GET', null, dealerToken); + const activeRes = (activeResRes.resignations || activeResRes.data).find(r => r.outletId === targetOutlet.id && !['Completed', 'Rejected'].includes(r.status)); + resignationId = activeRes.id; + log(1, `Resuming with existing Resignation: ${resignationId}`); + } else { + throw e; + } } + } else { + log(1, `Resuming provided resignation: ${resignationId}`); } await delay(); @@ -165,7 +177,9 @@ async function run() { } // --- NEW: F&F CLEARANCE LOOP (16 DEPARTMENTS) --- - console.log('[STEP 9] Starting 16-Department F&F Clearance Flow...'); + if (!SHOULD_SKIP_CLEARANCES) { + console.log('[STEP 9] Starting 16-Department F&F Clearance Flow...'); + } // Re-fetch to ensure we have the F&F ID regardless of start point const finalResData = await apiRequest(`/self-service/resignations/${resignationId}`, 'GET', null, adminToken); @@ -199,32 +213,36 @@ async function run() { { name: 'Customer Relations Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Customer complaints resolved.' } ]; - for (const dept of departments) { - log('9.1', `Clearing Dept: ${dept.name} [${dept.status}] - ${dept.remarks}`); - await apiRequest(`/self-service/resignations/${resignationId}/clearance`, 'PUT', { - department: dept.name, - status: dept.status, - remarks: dept.remarks, - amount: dept.amount, - type: dept.type - }, adminToken); - await delay(100); + if (!SHOULD_SKIP_CLEARANCES) { + for (const dept of departments) { + log('9.1', `Clearing Dept: ${dept.name} [${dept.status}] - ${dept.remarks}`); + await apiRequest(`/self-service/resignations/${resignationId}/clearance`, 'PUT', { + department: dept.name, + status: dept.status, + remarks: dept.remarks, + amount: dept.amount, + type: dept.type + }, adminToken); + await delay(100); + } + log(9, 'All 16 Departments Cleared.'); + await delay(); } - log(9, 'All 16 Departments Cleared.'); - await delay(); // --- FINAL FINANCE SETTLEMENT --- - console.log('[STEP 10] Finance Finalizing Settlement...'); - const financeToken = await login(EMAILS.FINANCE); - await apiRequest(`/settlement/fnf/${fnfId}`, 'PUT', { - status: 'Completed', - finalSettlementAmount: 415173, // Matches your observed amount - paymentMode: 'NEFT / Bank Transfer', - transactionReference: `TXN-${Date.now()}`, - settlementDate: new Date().toISOString(), - remarks: 'Settlement completed and verified via automated script.' - }, financeToken); - await delay(); + if (!SHOULD_SKIP_FINAL_SETTLEMENT) { + console.log('[STEP 10] Finance Finalizing Settlement...'); + const financeToken = await login(EMAILS.FINANCE); + await apiRequest(`/settlement/fnf/${fnfId}`, 'PUT', { + status: 'Completed', + finalSettlementAmount: Number(args.finalSettlementAmount || 415173), + paymentMode: 'NEFT / Bank Transfer', + transactionReference: `TXN-${Date.now()}`, + settlementDate: new Date().toISOString(), + remarks: 'Settlement completed and verified via automated script.' + }, financeToken); + await delay(); + } // --- FINAL COMPLETION --- console.log('[STEP 11] Verifying Resignation is now COMPLETED (Auto-transitioned)...'); diff --git a/trigger-termination.js b/trigger-termination.js index d915f62..35c081a 100644 --- a/trigger-termination.js +++ b/trigger-termination.js @@ -1,7 +1,12 @@ -import fs from 'fs'; - -const BASE_URL = 'http://localhost:5000/api'; +const args = Object.fromEntries( + process.argv.slice(2) + .map(arg => arg.replace(/^--/, '').split('=')) + .map(([k, v]) => [k, v ?? 'true']) +); +const BASE_URL = args.baseUrl || process.env.BASE_URL || 'http://localhost:5000/api'; const PASSWORD = 'Admin@123'; +const STEP_DELAY_MS = Number(args.delayMs || 500); +const SHOULD_SKIP_CLEARANCES = String(args.skipClearances || 'false') === 'true'; const EMAILS = { DD_ADMIN: 'lince@gmail.com', @@ -44,11 +49,14 @@ async function apiRequest(endpoint, method = 'GET', body = null, token = null) { } async function login(email) { + if (!login.cache) login.cache = {}; + if (login.cache[email]) return login.cache[email]; const data = await apiRequest('/auth/login', 'POST', { email, password: PASSWORD }); - return data.token; + login.cache[email] = data.token; + return login.cache[email]; } -const delay = (ms = 500) => new Promise(res => setTimeout(res, ms)); +const delay = (ms = STEP_DELAY_MS) => new Promise(res => setTimeout(res, ms)); const log = (step, msg) => console.log(`[STEP ${step}] ${msg}`); async function run() { @@ -63,19 +71,26 @@ async function run() { console.log(`Targeting Dealer: ${targetDealer.legalName} (${targetDealer.id})`); - // STEP 1: Submission (ASM) - console.log('[STEP 1] ASM Initiating Termination...'); - const asmToken = await login(EMAILS.ASM); - const createRes = await apiRequest('/termination', 'POST', { - dealerId: targetDealer.id, - category: 'Performance', - reason: 'Consistently failed to meet commitment targets.', - proposedLwd: new Date().toISOString(), - comments: 'Auto termination for testing flow ending with Legal Team, CCO, CEO.' - }, asmToken); + let terminationId = args.terminationId; + if (!terminationId) { + console.log('[STEP 1] ASM Initiating Termination...'); + const asmToken = await login(EMAILS.ASM); + const createRes = await apiRequest('/termination', 'POST', { + dealerId: targetDealer.id, + category: args.category || 'Performance', + reason: args.reason || 'Consistently failed to meet commitment targets.', + proposedLwd: new Date().toISOString(), + comments: 'Auto termination for testing flow ending with Legal Team, CCO, CEO.' + }, asmToken); + terminationId = createRes.termination.id; + console.log(`[STEP 1] Termination Request Created. ID: ${terminationId}`); + } else { + console.log(`[STEP 1] Resuming existing termination: ${terminationId}`); + } - const terminationId = createRes.termination.id; - console.log(`[STEP 1] Termination Request Created. ID: ${terminationId}`); + const currentTermination = await apiRequest(`/termination/${terminationId}`, 'GET', null, adminToken); + const currentStage = currentTermination?.termination?.currentStage; + console.log(`[INFO] Current stage before progression: ${currentStage}`); const approvals = [ { name: 'RBM Review', email: EMAILS.RBM, remarks: 'Performance concerns validated on-ground. Proceed with termination.' }, @@ -94,8 +109,17 @@ async function run() { ]; - let currentStep = 2; - for (const actor of approvals) { + const stageOrder = [ + 'Submitted', 'RBM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'DD Head Review', + 'NBH Evaluation', 'Show Cause Notice', 'Personal Hearing', 'NBH Final Approval', 'CCO Approval', + 'CEO Final Approval', 'Legal - Termination Letter', 'Terminated' + ]; + const currentIndex = Math.max(0, stageOrder.indexOf(currentStage)); + const currentStepStart = 2 + currentIndex; + let currentStep = currentStepStart; + + for (let i = currentIndex; i < approvals.length; i++) { + const actor = approvals[i]; log(currentStep, `${actor.name} (${actor.email}) processing approval...`); const token = await login(actor.email); await apiRequest(`/termination/${terminationId}/status`, 'PUT', { @@ -108,13 +132,15 @@ async function run() { } // --- NEW: F&F CLEARANCE LOOP (16 DEPARTMENTS) --- - log(13, 'Starting 16-Department F&F Clearance Flow for Termination...'); + if (!SHOULD_SKIP_CLEARANCES) { + log(13, 'Starting 16-Department F&F Clearance Flow for Termination...'); + } const terminationData = await apiRequest(`/termination/${terminationId}`, 'GET', null, adminToken); const fnfId = terminationData.termination.fnfSettlement?.id; if (!fnfId) { log('SKIP', 'FnF Settlement not initialized for this termination case.'); - } else { + } else if (!SHOULD_SKIP_CLEARANCES) { const departments = [ { name: 'Warranty Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'No pending claims.' }, { name: 'Accessories Department', status: 'Dues', amount: 15000, type: 'Recovery', remarks: 'Shortage in accessory stock.' }, diff --git a/trigger-workflow.js b/trigger-workflow.js index 417fac0..f25f83d 100644 --- a/trigger-workflow.js +++ b/trigger-workflow.js @@ -4,9 +4,15 @@ */ import fs from 'fs'; -const BASE_URL = 'http://localhost:5000/api'; +const args = Object.fromEntries( + process.argv.slice(2) + .map(arg => arg.replace(/^--/, '').split('=')) + .map(([k, v]) => [k, v ?? 'true']) +); +const BASE_URL = args.baseUrl || process.env.BASE_URL || 'http://localhost:5000/api'; const PASSWORD = 'Admin@123'; const OTP = '123456'; +const STEP_DELAY_MS = Number(args.delayMs || 1000); // Append timestamp to email to avoid duplicate application error const timestamp = Date.now(); @@ -79,7 +85,7 @@ let loaRequestId = null; /** * HELPERS */ -const delay = (ms = 5000) => new Promise(res => setTimeout(res, ms)); +const delay = (ms = STEP_DELAY_MS) => new Promise(res => setTimeout(res, ms)); const log = (step, msg) => console.log(`[STEP ${step}] ${msg}`); @@ -102,8 +108,11 @@ async function apiRequest(endpoint, method = 'GET', body = null, token = null) { } async function login(email) { + if (!login.cache) login.cache = {}; + if (login.cache[email]) return login.cache[email]; const data = await apiRequest('/auth/login', 'POST', { email, password: PASSWORD }); - return data.token; // Standard login returns token at root + login.cache[email] = data.token; + return login.cache[email]; // Standard login returns token at root } async function prospectLogin(phone) { @@ -131,19 +140,47 @@ async function mockUploadDocument(appId, token, docType) { return response.json(); } +async function getApplicationStatus(appId, token) { + const res = await apiRequest(`/onboarding/applications/${appId}`, 'GET', null, token); + return res?.data?.overallStatus || res?.data?.status || 'Unknown'; +} + +async function ensureMandatoryCodeGenFields(appId, token) { + const details = await apiRequest(`/onboarding/applications/${appId}`, 'GET', null, token); + const app = details?.data || {}; + + const fallbackFields = { + panNumber: app.panNumber || 'ABCDE1234F', + gstNumber: app.gstNumber || '07ABCDE1234F1Z5', + bankName: app.bankName || 'HDFC Bank', + accountNumber: app.accountNumber || '50100223344556', + ifscCode: app.ifscCode || 'HDFC0001234', + accountHolderName: app.accountHolderName || 'Kumar Automobiles Private Limited', + registeredAddress: app.registeredAddress || '123, Main Road, New Delhi' + }; + + await apiRequest(`/onboarding/applications/${appId}`, 'PUT', fallbackFields, token); +} + /** * MAIN WORKFLOW */ async function triggerWorkflow() { console.log('--- STARTING DEALER ONBOARDING E2E FLOW ---\n'); - // 1. PUBLIC APPLY - log(1, 'Public Prospect Application Submission...'); - const appResponse = await apiRequest('/onboarding/apply', 'POST', PROSPECT_PAYLOAD); - applicationId = appResponse.data.applicationId; - applicationUUID = appResponse.data.id; - log(1, `Application Created: ${applicationId} (UUID: ${applicationUUID})`); - await delay(); + if (args.applicationId) { + applicationId = args.applicationId; + applicationUUID = args.applicationId; + log(1, `Resuming with existing application: ${applicationUUID}`); + } else { + // 1. PUBLIC APPLY + log(1, 'Public Prospect Application Submission...'); + const appResponse = await apiRequest('/onboarding/apply', 'POST', PROSPECT_PAYLOAD); + applicationId = appResponse.data.applicationId; + applicationUUID = appResponse.data.id; + log(1, `Application Created: ${applicationId} (UUID: ${applicationUUID})`); + await delay(); + } // 2. ADMIN SHORTLIST log(2, 'Admin Login & Shortlisting...'); @@ -371,14 +408,8 @@ async function triggerWorkflow() { log(7.5, 'LOI Milestone Complete.'); await delay(); - // 8. GENERATE DEALER CODES (Sequence: Post-LOI, Pre-LOA) - log(8, 'Admin Generating SAP Dealer Codes...'); - await apiRequest(`/onboarding/applications/${applicationUUID}/generate-codes`, 'POST', {}, adminToken); - log(8, 'Dealer Codes Generated.'); - await delay(); - - // 9. PAYMENT GATE - log(9, 'Prospect Uploading Payment Receipt (Mock)...'); + // 8. PAYMENT GATE (SECURITY DEPOSIT FIRST AS PER CURRENT FLOW) + log(8, 'Finance Verifying SECURITY_DEPOSIT to unlock LOI Issued...'); const financeToken = await login(EMAILS.FINANCE); await apiRequest('/loa/security-deposit', 'POST', { applicationId: applicationUUID, @@ -387,9 +418,37 @@ async function triggerWorkflow() { depositType: 'SECURITY_DEPOSIT', status: 'Verified' }, financeToken); - log(9, 'Security Deposit Verified.'); + log(8, 'Security Deposit Verified.'); + await delay(); - log(9.1, 'Finance Verifying FIRST FILL (₹15L)...'); + // 9. GENERATE DEALER CODES (NOW RETRY-SAFE WITH STATUS CHECK) + let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken); + log(9, `Current status before code generation: ${statusBeforeCodeGen}`); + log(9, 'Ensuring mandatory PAN/GST/Bank fields before code generation...'); + await ensureMandatoryCodeGenFields(applicationUUID, adminToken); + await delay(300); + + if (statusBeforeCodeGen === 'Security Details') { + log(9, 'Status is Security Details; re-verifying Security Deposit to move to LOI Issued...'); + await apiRequest('/loa/security-deposit', 'POST', { + applicationId: applicationUUID, + amount: 500000, + paymentReference: `PAY-RETRY-${Date.now()}`, + depositType: 'SECURITY_DEPOSIT', + status: 'Verified' + }, financeToken); + await delay(); + statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken); + log(9, `Status after re-verify: ${statusBeforeCodeGen}`); + } + + log(9, 'Admin Generating SAP Dealer Codes...'); + await apiRequest(`/onboarding/applications/${applicationUUID}/generate-codes`, 'POST', {}, adminToken); + log(9, 'Dealer Codes Generated.'); + await delay(); + + // 10. FIRST FILL (POST CODE-GENERATION) + log(10, 'Finance Verifying FIRST FILL (₹15L)...'); await apiRequest('/loa/security-deposit', 'POST', { applicationId: applicationUUID, amount: 1500000, @@ -397,11 +456,11 @@ async function triggerWorkflow() { depositType: 'FIRST_FILL', status: 'Verified' }, financeToken); - log(9.1, 'Final Security Deposit Verified.'); + log(10, 'Final Security Deposit Verified.'); await delay(); - // 9.2 ADMIN UPDATING STATUTORY & BANK DETAILS - log(9.2, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...'); + // 11. ADMIN UPDATING STATUTORY & BANK DETAILS + log(11, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...'); await apiRequest(`/onboarding/applications/${applicationUUID}`, 'PUT', { accountHolderName: 'Ramesh Automobiles Private Limited', panNumber: 'ABCDE1234F', @@ -410,11 +469,11 @@ async function triggerWorkflow() { accountNumber: '50100223344556', ifscCode: 'HDFC0001234' }, adminToken); - log(9.2, 'Statutory & Bank details updated.'); + log(11, 'Statutory & Bank details updated.'); await delay(); - // 10. FINAL LOA APPROVAL - log(10, 'NBH & Head Approving Final LOA...'); + // 12. FINAL LOA APPROVAL + log(12, 'NBH & Head Approving Final LOA...'); const loaRes = await apiRequest('/loa/request', 'POST', { applicationId: applicationUUID }, headToken); const finalLoaRequestId = loaRes.data.id; @@ -427,16 +486,16 @@ async function triggerWorkflow() { action: 'Approved', remarks: 'NBH Approval (Level 2)' }, nbhToken); - log(10, 'LOA Fully Approved.'); + log(12, 'LOA Fully Approved.'); await delay(); - // 11. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION - log(11, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...'); + // 13. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION + log(13, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...'); const eorInit = await apiRequest('/eor', 'POST', { applicationId: applicationUUID }, adminToken); const checklistId = eorInit.data.id; - log(11, `EOR Checklist Created (ID: ${checklistId})`); + log(13, `EOR Checklist Created (ID: ${checklistId})`); - log(11.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...'); + log(13.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...'); const eorItems = [ { itemType: 'Sales', description: 'Sales Standards' }, { itemType: 'Service', description: 'Service & Spares' }, @@ -460,9 +519,9 @@ async function triggerWorkflow() { remarks: 'Verified by Auditor - Compliant' }, adminToken); } - console.log('\n[STEP 11.1] All EOR items marked as compliant.'); + console.log('\n[STEP 13.1] All EOR items marked as compliant.'); - log(11.2, 'Auditor Submitting Final EOR Audit...'); + log(13.2, 'Auditor Submitting Final EOR Audit...'); await apiRequest(`/eor/audit/${checklistId}`, 'POST', { status: 'Completed', overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.' @@ -470,32 +529,32 @@ async function triggerWorkflow() { // Status check const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken); - log(11.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`); + log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`); await delay(); - // 12. FINAL ONBOARDING - log(12, 'Admin Finalizing Dealer Onboarding...'); + // 14. FINAL ONBOARDING + log(14, 'Admin Finalizing Dealer Onboarding...'); await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken); await delay(); - // 13. VERIFICATION - log(13, 'Verifying Dealer Record Creation...'); + // 15. VERIFICATION + log(15, 'Verifying Dealer Record Creation...'); const dealerRes = await apiRequest(`/dealers/application/${applicationUUID}`, 'GET', null, adminToken); if (!dealerRes.success || !dealerRes.data) { throw new Error('Verification Failed: Dealer record not found after onboarding.'); } - log(13, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`); + log(15, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`); - log(13.1, 'Verifying User Account Role Update...'); + log(15.1, 'Verifying User Account Role Update...'); const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken); const dealerUser = userRes.data.find(u => u.email === PROSPECT_EMAIL); if (!dealerUser || dealerUser.roleCode !== 'Dealer') { throw new Error(`Verification Failed: User role not updated to 'Dealer'. Current role: ${dealerUser?.roleCode}`); } - log(13.1, `User role confirmed: ${dealerUser.roleCode}`); + log(15.1, `User role confirmed: ${dealerUser.roleCode}`); - log(13.2, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---'); - log(13.2, `The application ${applicationId} is now at 'ONBOARDED' status and Dealer profile is active.`); + log(15.2, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---'); + log(15.2, `The application ${applicationId} is now at 'ONBOARDED' status and Dealer profile is active.`); } /**