From 5ddbe525e6b8f23928d336ef5cddfd0e3b7cb613 Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Mon, 4 May 2026 13:28:52 +0530 Subject: [PATCH] stage names modified and calendar added in opportunity requests added and checked resignation and termination flow end to end from chennai --- scratch/update_resignation_enum.js | 24 +++ scripts/verify-offboarding-status.ts | 32 +++- src/common/config/constants.ts | 14 +- src/common/utils/offboardingStatus.ts | 32 +++- src/common/utils/offboardingWorkflow.utils.ts | 2 +- .../utils/terminationJointReviewRound.util.ts | 61 +++++++ .../utils/workflow-email-notifications.ts | 10 +- .../onboarding/onboarding.controller.ts | 83 +++++++-- src/modules/onboarding/onboarding.routes.ts | 3 +- .../self-service/resignation.controller.ts | 92 +++++----- .../termination/termination.controller.ts | 157 ++++++++++++++++-- src/services/NotificationService.ts | 15 +- src/services/ResignationWorkflowService.ts | 67 +++++++- src/services/TerminationWorkflowService.ts | 15 +- trigger-resignation.js | 30 +++- trigger-termination.js | 71 ++++---- 16 files changed, 585 insertions(+), 123 deletions(-) create mode 100644 scratch/update_resignation_enum.js create mode 100644 src/common/utils/terminationJointReviewRound.util.ts diff --git a/scratch/update_resignation_enum.js b/scratch/update_resignation_enum.js new file mode 100644 index 0000000..7f96aee --- /dev/null +++ b/scratch/update_resignation_enum.js @@ -0,0 +1,24 @@ +import db from '../src/database/models/index.js'; + +async function updateEnum() { + try { + console.log('Attempting to update PostgreSQL ENUM: enum_resignations_currentStage...'); + + // Note: ALTER TYPE ... ADD VALUE cannot be executed in a transaction block in some Postgres versions. + // Sequelize's queryInterface.sequelize.query uses a transaction if not specified otherwise. + + await db.sequelize.query('ALTER TYPE "enum_resignations_currentStage" ADD VALUE IF NOT EXISTS \'RBM + DD-ZM Review\''); + + console.log('SUCCESS: ENUM updated successfully.'); + process.exit(0); + } catch (error) { + console.error('FAILED to update ENUM:', error.message); + if (error.message.includes('already exists')) { + console.log('INFO: Value already exists, proceeding.'); + process.exit(0); + } + process.exit(1); + } +} + +updateEnum(); diff --git a/scripts/verify-offboarding-status.ts b/scripts/verify-offboarding-status.ts index 2fcb3d4..72cf17b 100644 --- a/scripts/verify-offboarding-status.ts +++ b/scripts/verify-offboarding-status.ts @@ -1,5 +1,13 @@ import assert from 'node:assert/strict'; -import { getResignationStatusForStage, getTerminationStatusForStage, normalizeClearanceStatus, normalizeFnFStatus } from '../src/common/utils/offboardingStatus.js'; +import { + getResignationStatusForStage, + getTerminationStatusForStage, + normalizeClearanceStatus, + normalizeFnFStatus, + normalizeTerminationCurrentStage, + getLegacyTerminationRowFixes +} from '../src/common/utils/offboardingStatus.js'; +import { getJointRoundCutoffMsFromTimeline } from '../src/common/utils/terminationJointReviewRound.util.js'; assert.equal(normalizeFnFStatus('settled'), 'Completed'); assert.equal(normalizeFnFStatus('finance approval'), 'Finance Approval'); @@ -10,6 +18,28 @@ assert.equal(getResignationStatusForStage('F&F Initiated'), 'F&F Initiated'); assert.equal(getTerminationStatusForStage('Submitted'), 'Submitted'); assert.equal(getTerminationStatusForStage('Terminated'), 'Terminated'); +assert.equal( + normalizeTerminationCurrentStage('Personal Hearing'), + 'Evaluation of Dealer SCN Response' +); +assert.deepEqual(getLegacyTerminationRowFixes({ currentStage: 'Personal Hearing', status: 'Personal Hearing Pending' }), { + currentStage: 'Evaluation of Dealer SCN Response', + status: 'SCN Response Evaluation Pending' +}); + +const reconsiderTimeline = [ + { action: 'Approved', targetStage: 'NBH Final Approval', timestamp: new Date('2024-01-01').toISOString() }, + { + action: 'Sent for Reconsideration', + targetStage: 'Evaluation of Dealer SCN Response', + timestamp: new Date('2025-06-15T12:00:00.000Z').toISOString() + } +]; +assert.equal( + getJointRoundCutoffMsFromTimeline(reconsiderTimeline, 'scn_response_eval'), + new Date('2025-06-15T12:00:00.000Z').getTime() +); + assert.equal(normalizeClearanceStatus('Cleared', 0), 'NOC Submitted'); assert.equal(normalizeClearanceStatus('Cleared', 100), 'Dues Pending'); assert.equal(normalizeClearanceStatus('Pending', 0), 'Pending'); diff --git a/src/common/config/constants.ts b/src/common/config/constants.ts index a9c152c..d5024c1 100644 --- a/src/common/config/constants.ts +++ b/src/common/config/constants.ts @@ -182,8 +182,8 @@ export const TERMINATION_STAGES = { LEGAL_VERIFICATION: 'Legal Verification', DD_HEAD_REVIEW: 'DD Head Review', NBH_EVALUATION: 'NBH Evaluation', - SCN_ISSUED: 'Show Cause Notice', - PERSONAL_HEARING: 'Personal Hearing', + SCN_ISSUED: 'Show Cause Notice (SCN)', + PERSONAL_HEARING: 'Evaluation of Dealer SCN Response', NBH_FINAL_APPROVAL: 'NBH Final Approval', CCO_APPROVAL: 'CCO Approval', CEO_APPROVAL: 'CEO Final Approval', @@ -195,7 +195,7 @@ export const TERMINATION_STAGES = { // Resignation Stages export const RESIGNATION_STAGES = { ASM: 'ASM', - RBM: 'RBM', + RBM: 'RBM + DD-ZM Review', ZBH: 'ZBH', DD_LEAD: 'DD Lead', NBH: 'NBH', @@ -493,7 +493,7 @@ export const RESIGNATION_DOCUMENT_TYPES = [ 'Resignation Letter', 'Dealer Undertaking', 'Approval Note', - 'Legal Communication', + 'Resignation Acceptance Letter', 'Handover Document', 'Settlement Supporting Document', 'PPT Presentation', @@ -501,6 +501,7 @@ export const RESIGNATION_DOCUMENT_TYPES = [ ] as const; export const RESIGNATION_DOCUMENT_STAGES = [ + 'Initiation', 'ASM', 'RBM', 'ZBH', @@ -558,7 +559,8 @@ export const OFFBOARDING_ACTIONS = { PUSH_FNF: 'pushfnf', RECONSIDER: 'reconsider', ISSUE_SCN: 'issueSCN', - SCN_RESPONSE: 'scnResponse' + SCN_RESPONSE: 'scnResponse', + HOLD: 'hold' } as const; // Module List for Document Management @@ -567,7 +569,7 @@ export const MODULE_LIST = ['ONBOARDING', 'RESIGNATION', 'RELOCATION', 'CONSTITU // Process Stages per Module (Source of Truth for Checklists) export const STAGES_MAP = { 'ONBOARDING': ['General', 'KYC', 'Level 1 Interview', 'Level 2 Interview', 'Level 3 Interview', 'FDD', 'LOI Approval', 'LOA Approval', 'LOI Issue', 'Architecture Team Assigned', 'Architecture Document Upload', 'Architecture Team Completion', 'EOR', 'Inauguration'], - 'RESIGNATION': ['Submission', 'Regional Review', 'ZM Review', 'ZBH Review', 'Finance Review', 'DDL Review', 'Approved'], + 'RESIGNATION': ['Submission', 'Regional Review', 'RBM + DD-ZM Review', 'ZBH Review', 'Finance Review', 'DDL Review', 'Approved'], 'RELOCATION': ['Initiated', 'ASM Review', 'ZM Review', 'ZBH Review', 'Completed'], 'CONSTITUTIONAL_CHANGE': ['Draft', 'Legal Review', 'Approved'], 'TERMINATION': ['Submitted', 'RBM + DD-ZM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'NBH Evaluation', 'SCN', 'Personal Hearing', 'Completed'] diff --git a/src/common/utils/offboardingStatus.ts b/src/common/utils/offboardingStatus.ts index 39eb299..0ff84e6 100644 --- a/src/common/utils/offboardingStatus.ts +++ b/src/common/utils/offboardingStatus.ts @@ -22,8 +22,9 @@ export const normalizeFnFStatus = (status: string | null | undefined): string => export const getResignationStatusForStage = (stage: string): string => { switch (stage) { - case RESIGNATION_STAGES.ASM: case RESIGNATION_STAGES.RBM: + return RESIGNATION_STAGES.RBM; // It already contains "Review" + case RESIGNATION_STAGES.ASM: case RESIGNATION_STAGES.ZBH: case RESIGNATION_STAGES.DD_LEAD: case RESIGNATION_STAGES.NBH: @@ -55,6 +56,35 @@ export const getTerminationStatusForStage = (stage: string): string => { } }; +/** Legacy DB rows may still use SRS label "Personal Hearing" while workflow code keys the canonical stage constant. */ +const LEGACY_TERMINATION_STAGE_TO_CANONICAL: Record = { + 'Personal Hearing': TERMINATION_STAGES.PERSONAL_HEARING +}; + +export const normalizeTerminationCurrentStage = (stage: string | null | undefined): string => { + if (stage == null) return ''; + const trimmed = String(stage).trim(); + return LEGACY_TERMINATION_STAGE_TO_CANONICAL[trimmed] || trimmed; +}; + +/** Returns column updates to align legacy termination rows with current stage/status strings (no-op if already canonical). */ +export const getLegacyTerminationRowFixes = (termination: { + currentStage?: string | null; + status?: string | null; +}): Record | null => { + const updates: Record = {}; + const rawStage = termination.currentStage; + if (rawStage) { + const canonical = normalizeTerminationCurrentStage(rawStage); + if (canonical !== rawStage) updates.currentStage = canonical; + } + const st = termination.status; + if (st && /personal hearing/i.test(st)) { + updates.status = st.replace(/personal hearing/gi, 'SCN Response Evaluation'); + } + return Object.keys(updates).length ? updates : null; +}; + export const normalizeClearanceStatus = (status: string | null | undefined, amount: number): string => { const normalizedAmount = Math.abs(Number(amount) || 0); const value = (status || '').toLowerCase(); diff --git a/src/common/utils/offboardingWorkflow.utils.ts b/src/common/utils/offboardingWorkflow.utils.ts index c467541..6fda6be 100644 --- a/src/common/utils/offboardingWorkflow.utils.ts +++ b/src/common/utils/offboardingWorkflow.utils.ts @@ -21,7 +21,7 @@ export const getPreviousStage = (requestType: string, currentStage: string): str [TERMINATION_STAGES.DD_LEAD_REVIEW]: TERMINATION_STAGES.ZBH_REVIEW, [TERMINATION_STAGES.LEGAL_VERIFICATION]: TERMINATION_STAGES.DD_LEAD_REVIEW, [TERMINATION_STAGES.DD_HEAD_REVIEW]: TERMINATION_STAGES.LEGAL_VERIFICATION, - [TERMINATION_STAGES.NBH_EVALUATION]: TERMINATION_STAGES.DD_HEAD_REVIEW, + [TERMINATION_STAGES.NBH_EVALUATION]: TERMINATION_STAGES.DD_LEAD_REVIEW, [TERMINATION_STAGES.SCN_ISSUED]: TERMINATION_STAGES.NBH_EVALUATION, [TERMINATION_STAGES.PERSONAL_HEARING]: TERMINATION_STAGES.SCN_ISSUED, [TERMINATION_STAGES.NBH_FINAL_APPROVAL]: TERMINATION_STAGES.PERSONAL_HEARING, diff --git a/src/common/utils/terminationJointReviewRound.util.ts b/src/common/utils/terminationJointReviewRound.util.ts new file mode 100644 index 0000000..ef9a785 --- /dev/null +++ b/src/common/utils/terminationJointReviewRound.util.ts @@ -0,0 +1,61 @@ +import { Op } from 'sequelize'; +import { TERMINATION_STAGES } from '../config/constants.js'; + +const norm = (s: string | undefined | null) => + String(s || '') + .toLowerCase() + .replace(/\s+/g, ' ') + .trim(); + +export const isScnResponseJointTargetStage = (targetStage: string | undefined | null): boolean => { + const n = norm(targetStage); + if (!n) return false; + if (n === norm(TERMINATION_STAGES.PERSONAL_HEARING)) return true; + if (n.includes('evaluation') && n.includes('scn') && n.includes('response')) return true; + if (n.includes('personal hearing')) return true; + return false; +}; + +export const isRbmJointTargetStage = (targetStage: string | undefined | null): boolean => { + const n = norm(targetStage); + return n.includes('rbm') && (n.includes('dd-zm') || n.includes('dd zm')); +}; + +function isSendBackOrReconsiderTimelineAction(action: string | undefined | null): boolean { + const a = norm(action); + return ( + a.includes('sent back') || + a.includes('send back') || + a.includes('reconsider') || + a.includes('reconsideration') + ); +} + +export type JointRoundTimelineMode = 'scn_response_eval' | 'rbm_review'; + +/** + * When a case is sent back / reconsidered to a joint stage, earlier PARTIAL_APPROVE rows must be ignored. + * Uses workflow timeline entries (written on transition) — newest matching event wins. + */ +export function getJointRoundCutoffMsFromTimeline( + timeline: unknown, + mode: JointRoundTimelineMode +): number | null { + if (!Array.isArray(timeline) || timeline.length === 0) return null; + const matcher = mode === 'scn_response_eval' ? isScnResponseJointTargetStage : isRbmJointTargetStage; + const arr = timeline as any[]; + for (let i = arr.length - 1; i >= 0; i--) { + const e = arr[i]; + if (!isSendBackOrReconsiderTimelineAction(e?.action)) continue; + if (!matcher(e?.targetStage)) continue; + const t = e?.timestamp != null ? new Date(e.timestamp).getTime() : NaN; + if (!Number.isNaN(t)) return t; + } + return null; +} + +/** Only audit rows created at/after send-back / reconsider to this joint stage count for the current round. */ +export function buildJointRoundCreatedAtFilter(cutoffMs: number | null): { createdAt?: { [Op.gte]: Date } } { + if (cutoffMs == null) return {}; + return { createdAt: { [Op.gte]: new Date(cutoffMs) } }; +} diff --git a/src/common/utils/workflow-email-notifications.ts b/src/common/utils/workflow-email-notifications.ts index 52df323..3e8f324 100644 --- a/src/common/utils/workflow-email-notifications.ts +++ b/src/common/utils/workflow-email-notifications.ts @@ -247,7 +247,7 @@ export async function resolveNextActors(requestId: string, requestType: string, 'Spares Clearance': [ROLES.SPARES_MANAGER], 'Service Clearance': [ROLES.SERVICE_MANAGER], 'Accounts Clearance': [ROLES.ACCOUNTS_MANAGER], - 'F&F Initiated': [ROLES.DD_ADMIN], + 'F&F Initiated': [ROLES.DD_ADMIN, ROLES.FINANCE, ROLES.DD_LEAD, ROLES.ZBH, ROLES.RBM], // SRS §7.5.2 — Legal acceptance letter upload triggers notification to DD-Admin + ASM 'Resignation Legal Closure': [ROLES.DD_ADMIN, ROLES.ASM], @@ -340,8 +340,12 @@ export async function notifyStakeholdersOnTransition( const isDealer = u.id === metadata.dealerId; const isActingUser = u.fullName === metadata.actionUserFullName; - // Roles that should receive observer alerts on terminal events - const isKeyObserverRole = ['DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin', 'SUPER_ADMIN', 'DD_ADMIN'].includes(u.roleCode || ''); + // Roles that should receive observer alerts on terminal events or F&F triggers + const isKeyObserverRole = [ + 'DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin', + 'SUPER_ADMIN', 'DD_ADMIN', 'Finance', 'FINANCE', + 'ZBH', 'RBM', 'DD-ZM' + ].includes(u.roleCode || ''); const isASM = (u.roleCode || '').toUpperCase() === 'ASM'; // Phone for WhatsApp — directly on include'd user object diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index aaf12fb..ef868a0 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -278,17 +278,25 @@ export const getApplications = async (req: AuthRequest, res: Response) => { // Apply Filters const { fromDate, toDate, search, status, location, state, isShortlisted, ddLeadShortlisted, assignedTo } = req.query; + // 1. Date Filters (createdAt range) if (fromDate || toDate) { - whereClause.createdAt = {}; - if (fromDate) { + const dateClause: any = {}; + if (fromDate && fromDate !== 'undefined') { const start = new Date(fromDate as string); - start.setHours(0, 0, 0, 0); - whereClause.createdAt[Op.gte] = start; + if (!isNaN(start.getTime())) { + start.setHours(0, 0, 0, 0); + dateClause[Op.gte] = start; + } } - if (toDate) { + if (toDate && toDate !== 'undefined') { const end = new Date(toDate as string); - end.setHours(23, 59, 59, 999); - whereClause.createdAt[Op.lte] = end; + if (!isNaN(end.getTime())) { + end.setHours(23, 59, 59, 999); + dateClause[Op.lte] = end; + } + } + if (Object.keys(dateClause).length > 0) { + whereClause.createdAt = dateClause; } } @@ -313,9 +321,13 @@ export const getApplications = async (req: AuthRequest, res: Response) => { }; // Pipeline Logic - Forced strict filtering by lifecycle stage + // 3. Status Grouping Logic (Prospects vs Leads vs Workflow) const isShortlistedStr = String(isShortlisted ?? '').toLowerCase(); const ddLeadShortlistedStr = String(ddLeadShortlisted ?? '').toLowerCase(); + // Use a conditions array to prevent Op.or overwrites + const conditions: any[] = []; + if (isShortlistedStr === 'false') { // Non-Opportunities (New Leads) MUST be 'Submitted', NOT shortlisted, and NOT linked to an opportunity whereClause.overallStatus = 'Submitted'; @@ -324,10 +336,12 @@ export const getApplications = async (req: AuthRequest, res: Response) => { whereClause.opportunityId = null; // Strictly lead-gen records only } else if (isShortlistedStr === 'true' && ddLeadShortlistedStr !== 'true') { // Opportunities (Prospects): include anything explicitly shortlisted OR in an opportunity status - whereClause[Op.or] = [ - { isShortlisted: true }, - { overallStatus: { [Op.in]: ['Questionnaire Pending', 'Questionnaire Completed', 'Shortlisted'] } } - ]; + conditions.push({ + [Op.or]: [ + { isShortlisted: true }, + { overallStatus: { [Op.in]: ['Questionnaire Pending', 'Questionnaire Completed', 'Shortlisted'] } } + ] + }); // However, must NOT be shortlisted by DD Lead yet (that moves them to Workflow) whereClause.ddLeadShortlisted = { [Op.ne]: true }; @@ -345,6 +359,10 @@ export const getApplications = async (req: AuthRequest, res: Response) => { applyStatusFilter(status); } + if (conditions.length > 0) { + whereClause[Op.and] = [...(whereClause[Op.and] || []), ...conditions]; + } + if (location && location !== 'all') { whereClause.preferredLocation = location; } @@ -1621,3 +1639,46 @@ export const bulkConvertToOpportunity = async (req: AuthRequest, res: Response) res.status(500).json({ success: false, message: 'Internal error during batch conversion' }); } }; + +export const sendBulkReminders = async (req: AuthRequest, res: Response) => { + try { + const { applicationIds } = req.body; + if (!applicationIds || !Array.isArray(applicationIds)) { + return res.status(400).json({ success: false, message: 'Invalid application IDs' }); + } + + const applications = await Application.findAll({ + where: { id: { [Op.in]: applicationIds } } + }); + + for (const app of applications) { + await NotificationService.sendQuestionnaireReminder( + app.email, + app.phone, + app.applicantName, + { + location: app.preferredLocation, + link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/questionnaire/${app.applicationId}` + } + ); + + // Log Audit + await safeAuditLogCreate({ + userId: req.user?.id || null, + action: 'REMINDER_SENT', + entityType: 'application', + entityId: app.id, + newData: { + template: 'QUESTIONNAIRE_REMINDER', + sentAt: new Date(), + context: pickApplicationAuditContext(app) + } + }); + } + + res.json({ success: true, message: `Reminders sent to ${applications.length} applicants` }); + } catch (error) { + console.error('Send bulk reminders error:', error); + res.status(500).json({ success: false, message: 'Error sending reminders' }); + } +}; diff --git a/src/modules/onboarding/onboarding.routes.ts b/src/modules/onboarding/onboarding.routes.ts index 3405306..45d2c4f 100644 --- a/src/modules/onboarding/onboarding.routes.ts +++ b/src/modules/onboarding/onboarding.routes.ts @@ -6,7 +6,7 @@ import { assignArchitectureTeam, updateArchitectureStatus, generateDealerCodes, retriggerEvaluators, getDocumentConfigs, getDocumentConfigMetadata, createDocumentConfig, updateDocumentConfig, deleteDocumentConfig, updateApplication, - exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity + exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity, sendBulkReminders } from './onboarding.controller.js'; import { authenticate } from '../../common/middleware/auth.js'; import { checkRevocation } from '../../common/middleware/checkRevocation.js'; @@ -29,6 +29,7 @@ router.get('/applications/export-responses', exportApplicationResponses); router.get('/document-configs/metadata', getDocumentConfigMetadata); router.get('/document-configs', getDocumentConfigs); router.post('/applications/shortlist', bulkShortlist); // Existing route, updated to named import +router.post('/applications/reminders', sendBulkReminders); router.get('/applications/:id', checkRevocation as any, getApplicationById); router.put('/applications/:id', checkRevocation as any, updateApplication); router.put('/applications/:id/status', checkRevocation as any, updateApplicationStatus); diff --git a/src/modules/self-service/resignation.controller.ts b/src/modules/self-service/resignation.controller.ts index dd7d0d2..7ca1a4d 100644 --- a/src/modules/self-service/resignation.controller.ts +++ b/src/modules/self-service/resignation.controller.ts @@ -36,12 +36,28 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N try { if (!req.user) throw new Error('Unauthorized'); const { outletId, resignationType, lastOperationalDateSales, lastOperationalDateServices, reason, additionalInfo } = req.body; - const dealerId = req.user.id; + const userRole = req.user.roleCode || req.user.role; + const isInternalInitiator = [ROLES.ASM, ROLES.DD_ADMIN, ROLES.SUPER_ADMIN].includes(userRole as any); - const outlet = await db.Outlet.findOne({ where: { id: outletId, dealerId } }); - if (!outlet) { - await transaction.rollback(); - return res.status(404).json({ success: false, message: 'Outlet not found or does not belong to you' }); + let dealerId: string; + let outlet: any; + + if (isInternalInitiator) { + // Internal initiator (ASM/Admin) selects the outlet + outlet = await db.Outlet.findOne({ where: { id: outletId } }); + if (!outlet) { + await transaction.rollback(); + return res.status(404).json({ success: false, message: 'Outlet not found' }); + } + dealerId = outlet.dealerId; + } else { + // Dealer (Self-Service) initiates for their own outlet + dealerId = req.user.id; + outlet = await db.Outlet.findOne({ where: { id: outletId, dealerId } }); + if (!outlet) { + await transaction.rollback(); + return res.status(404).json({ success: false, message: 'Outlet not found or does not belong to you' }); + } } const existingResignation = await db.Resignation.findOne({ @@ -75,10 +91,11 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N documents: [], departmentalClearances: initialClearances, timeline: [{ - stage: 'Submitted', + stage: 'Request Submitted', timestamp: new Date(), user: req.user.fullName, - action: 'Resignation request submitted' + action: isInternalInitiator ? 'Resignation initiated by ASM' : 'Resignation request submitted by dealer', + remarks: reason || '' }] }, { transaction }); @@ -87,7 +104,7 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N userId: req.user.id, action: AUDIT_ACTIONS.CREATED, resignationId: resignation.id, - remarks: 'Dealer submitted resignation request' + remarks: isInternalInitiator ? 'ASM initiated resignation request' : 'Dealer submitted resignation request' }, { transaction }); await transaction.commit(); @@ -390,20 +407,29 @@ export const approveResignation = async (req: AuthRequest, res: Response, next: return res.status(400).json({ success: false, message: 'Cannot move to next stage from current state' }); } - // Guard before transition: F&F initiation is allowed only on/after LWD unless forced. + // Guard before transition: F&F initiation is allowed only on/after LWD as per SRS §4.2.2.8 + let shouldTriggerFnF = false; if (nextStage === RESIGNATION_STAGES.FNF_INITIATED) { const today = new Date(); - const lwd = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales; + const lwdString = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales; const { force } = req.body; + + const lwd = lwdString ? new Date(lwdString) : null; + if (lwd) { + // Clear time for date-only comparison + today.setHours(0, 0, 0, 0); + lwd.setHours(0, 0, 0, 0); + } - if (!force && lwd && today < new Date(lwd)) { + if (!force && lwd && today < lwd) { await transaction.rollback(); return res.status(400).json({ success: false, - message: `F&F can only be initiated on or after the Last Working Day (${lwd}).`, + message: `F&F settlement process is initiated only on the Last Working Day (${lwdString}) of the dealership.`, canForce: true }); } + shouldTriggerFnF = true; } // Sequence guard: resignation can be marked completed only after F&F settlement is complete. @@ -498,41 +524,12 @@ export const approveResignation = async (req: AuthRequest, res: Response, next: .catch(err => logger.error('Error syncing resignation completion to SAP:', err)); } - if (nextStage === RESIGNATION_STAGES.FNF_INITIATED) { + if (shouldTriggerFnF) { const existingFnF = await db.FnF.findOne({ where: { resignationId: resignation.id }, transaction }); - let fnfId = existingFnF?.id; - if (!existingFnF) { - const dealerProfileId = (resignation as any).dealer?.dealerId; - - // No seed line items or mock SAP amounts — totals stay 0 until users/scripts add FnF line items or department clearances. - const fnf = await db.FnF.create({ - settlementId: await NomenclatureService.generateFnFId(), - resignationId: resignation.id, - outletId: resignation.outletId, - dealerId: dealerProfileId, // Correctly using the Dealer model ID - status: 'Initiated', - totalReceivables: 0, - totalPayables: 0, - netAmount: 0 - }, { transaction }); - - const { FNF_DEPARTMENTS } = await import('../../common/config/constants.js'); - await db.FffClearance.bulkCreate( - FNF_DEPARTMENTS.map(dept => ({ - fnfId: fnf.id, - department: dept, - status: 'Pending' - })), - { transaction } - ); - - fnfId = fnf.id; - } - - // Always assign/sync Participants for F&F (Sub-application chat) to ensure robustness - if (fnfId) { - await ParticipantService.assignFnFParticipants(fnfId); + const fnf = await ResignationWorkflowService.initiateFnF(resignation, req.user.id, transaction); + // Assign/sync Participants for F&F (Sub-application chat) to ensure robustness + await ParticipantService.assignFnFParticipants(fnf.id); } } @@ -1060,14 +1057,15 @@ export const updateResignationStatus = async (req: AuthRequest, res: Response, n const hasLegalStageDocument = await db.ResignationDocument.findOne({ where: { resignationId: resignation.id, - stage: RESIGNATION_STAGES.LEGAL + stage: RESIGNATION_STAGES.LEGAL, + documentType: 'Resignation Acceptance Letter' }, attributes: ['id'] }); if (!hasLegalStageDocument) { return res.status(400).json({ success: false, - message: 'Cannot trigger F&F. Legal-stage acceptance/communication document is required first.' + message: 'Cannot trigger F&F. Resignation Acceptance Letter is required first.' }); } } diff --git a/src/modules/termination/termination.controller.ts b/src/modules/termination/termination.controller.ts index 012110f..6f797c4 100644 --- a/src/modules/termination/termination.controller.ts +++ b/src/modules/termination/termination.controller.ts @@ -16,7 +16,11 @@ 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'; +import { getTerminationStatusForStage, normalizeClearanceStatus, getLegacyTerminationRowFixes } from '../../common/utils/offboardingStatus.js'; +import { + buildJointRoundCreatedAtFilter, + getJointRoundCutoffMsFromTimeline +} from '../../common/utils/terminationJointReviewRound.util.js'; import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js'; import { OFFBOARDING_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js'; import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js'; @@ -33,9 +37,21 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N const transaction: Transaction = await db.sequelize.transaction(); try { if (!req.user) throw new Error('Unauthorized'); + + const allowedRoles = [ROLES.DD_LEAD, ROLES.ASM, ROLES.DD_ADMIN, ROLES.DD_AM, ROLES.SUPER_ADMIN]; + if (!allowedRoles.includes(req.user.roleCode as any)) { + return res.status(403).json({ + success: false, + message: 'Only DD Lead, ASM, DD Admin, or DD AM are authorized to initiate termination requests.' + }); + } + const { dealerId, category, reason, proposedLwd, comments } = req.body; const requestId = await NomenclatureService.generateTerminationId(); + const isUnethical = String(category).trim().toLowerCase().includes('unethical'); + const startStage = isUnethical ? TERMINATION_STAGES.DD_LEAD_REVIEW : TERMINATION_STAGES.RBM_REVIEW; + const termination = await db.TerminationRequest.create({ requestId, dealerId, @@ -44,15 +60,15 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N proposedLwd, comments, initiatedBy: req.user.id, - currentStage: TERMINATION_STAGES.RBM_REVIEW, - status: getTerminationStatusForStage(TERMINATION_STAGES.RBM_REVIEW), - progressPercentage: TerminationWorkflowService.calculateProgress(TERMINATION_STAGES.RBM_REVIEW), + currentStage: startStage, + status: getTerminationStatusForStage(startStage), + progressPercentage: TerminationWorkflowService.calculateProgress(startStage), timeline: [{ stage: 'Submitted', - targetStage: TERMINATION_STAGES.RBM_REVIEW, + targetStage: startStage, timestamp: new Date(), user: req.user.fullName, - action: `Termination request initiated and forwarded to ${TERMINATION_STAGES.RBM_REVIEW}`, + action: isUnethical ? 'Immediate escalation due to Unethical Practice' : `Termination request initiated and forwarded to ${startStage}`, remarks: comments }] }, { transaction }); @@ -70,8 +86,8 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N ParticipantService.assignTerminationParticipants(termination.id) .catch(err => logger.error('Error assigning participants to termination:', err)); - // SRS §4.3.2.1 — Notify RBM + DD-ZM that a new termination has been initiated - const notifyOnCreateRoles = [ROLES.RBM, ROLES.DD_ZM]; + // SRS §4.3.2.1 — Notify appropriate stakeholders that a new termination has been initiated + const notifyOnCreateRoles = isUnethical ? [ROLES.DD_LEAD] : [ROLES.RBM, ROLES.DD_ZM]; for (const role of notifyOnCreateRoles) { const roleUsers = await db.User.findAll({ where: { roleCode: role } }); for (const u of roleUsers) { @@ -218,6 +234,14 @@ export const getTerminationById = async (req: AuthRequest, res: Response, next: if (!termination) { return res.status(404).json({ success: false, message: 'Termination request not found' }); } + + const legacyTerminationFixes = getLegacyTerminationRowFixes(termination as any); + if (legacyTerminationFixes) { + await termination.update(legacyTerminationFixes); + (termination as any).setDataValue('currentStage', legacyTerminationFixes.currentStage ?? termination.currentStage); + (termination as any).setDataValue('status', legacyTerminationFixes.status ?? termination.status); + } + res.json({ success: true, termination }); } catch (error) { logger.error('Error fetching termination:', error); @@ -327,6 +351,17 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n return res.status(404).json({ success: false, message: 'Termination not found' }); } + const legacyTerminationFixes = getLegacyTerminationRowFixes(termination as any); + if (legacyTerminationFixes) { + await termination.update(legacyTerminationFixes, { transaction }); + if (legacyTerminationFixes.currentStage) { + (termination as any).setDataValue('currentStage', legacyTerminationFixes.currentStage); + } + if (legacyTerminationFixes.status) { + (termination as any).setDataValue('status', legacyTerminationFixes.status); + } + } + const fromStage = termination.currentStage; let approvedToStage: string | null = null; @@ -336,6 +371,27 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n status: 'Rejected', remarks }); + } else if (action === OFFBOARDING_ACTIONS.HOLD) { + // SRS §4.3.2.7 — Hold Decision (Pause temporarily); NBH may hold at evaluation or final approval + const holdStages = [TERMINATION_STAGES.NBH_EVALUATION, TERMINATION_STAGES.NBH_FINAL_APPROVAL]; + if (!holdStages.includes(termination.currentStage as any) && req.user.roleCode !== ROLES.SUPER_ADMIN) { + await transaction.rollback(); + return res.status(400).json({ + success: false, + message: 'Hold action is only available at NBH Evaluation or NBH Final Approval stage.' + }); + } + await termination.update({ status: 'On Hold' }, { transaction }); + await db.TerminationAudit.create({ + userId: req.user.id, + terminationRequestId: termination.id, + action: 'ON_HOLD', + remarks: remarks || 'Case placed on hold for further monitoring.', + details: { stage: fromStage } + }, { transaction }); + + await transaction.commit(); + return res.json({ success: true, message: 'Termination case placed on hold.' }); } else if (action === OFFBOARDING_ACTIONS.REVOKE) { // Validation: Remarks mandatory for Revoke const validation = validateOffboardingAction(action, remarks); @@ -420,13 +476,17 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n // SRS §4.3.2.2 — JOINT APPROVAL LOGIC FOR RBM STAGE if (sourceStage === TERMINATION_STAGES.RBM_REVIEW && req.user.roleCode !== ROLES.SUPER_ADMIN) { + const rbmRoundTime = buildJointRoundCreatedAtFilter( + getJointRoundCutoffMsFromTimeline(termination.timeline, 'rbm_review') + ); // Prevent duplicate approval from same user const existingUserApproval = await db.TerminationAudit.findOne({ where: { terminationRequestId: termination.id, userId: req.user.id, action: 'PARTIAL_APPROVE', - 'details.stage': sourceStage + 'details.stage': sourceStage, + ...rbmRoundTime }, transaction }); @@ -452,7 +512,8 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n where: { terminationRequestId: termination.id, action: 'PARTIAL_APPROVE', - 'details.stage': sourceStage + 'details.stage': sourceStage, + ...rbmRoundTime }, transaction }); @@ -467,6 +528,7 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n stage: sourceStage, timestamp: new Date(), user: req.user.fullName, + role: req.user.roleCode, action: 'Partial Approved', remarks: remarks || `Partial approval recorded by ${req.user.roleCode}` }]; @@ -483,6 +545,71 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n logger.info(`[TerminationController] Joint approval complete for ${termination.requestId}. Moving to ${nextStage}.`); } + // SRS §4.3.2.9 — JOINT APPROVAL LOGIC FOR SCN EVALUATION (PERSONAL HEARING STAGE) + if (sourceStage === TERMINATION_STAGES.PERSONAL_HEARING && req.user.roleCode !== ROLES.SUPER_ADMIN) { + const scnEvalAuditStages = [TERMINATION_STAGES.PERSONAL_HEARING, 'Personal Hearing']; + const scnRoundTime = buildJointRoundCreatedAtFilter( + getJointRoundCutoffMsFromTimeline(termination.timeline, 'scn_response_eval') + ); + const existingUserApproval = await db.TerminationAudit.findOne({ + where: { + terminationRequestId: termination.id, + userId: req.user.id, + action: 'PARTIAL_APPROVE', + [Op.or]: scnEvalAuditStages.map((s) => ({ 'details.stage': s })), + ...scnRoundTime + }, + transaction + }); + + if (existingUserApproval) { + await transaction.rollback(); + return res.status(400).json({ success: false, message: 'You have already recorded your approval for this stage.' }); + } + + await db.TerminationAudit.create({ + userId: req.user.id, + terminationRequestId: termination.id, + action: 'PARTIAL_APPROVE', + remarks: `SCN Response Review by ${req.user.roleCode}${remarks ? ': ' + remarks : ''}`, + details: { roleCode: req.user.roleCode, stage: sourceStage } + }, { transaction }); + + const requiredRoles = [ROLES.DD_LEAD, ROLES.ZBH, ROLES.RBM, ROLES.DD_HEAD]; + const partialLogs = await db.TerminationAudit.findAll({ + where: { + terminationRequestId: termination.id, + action: 'PARTIAL_APPROVE', + [Op.or]: scnEvalAuditStages.map((s) => ({ 'details.stage': s })), + ...scnRoundTime + }, + transaction + }); + + const approvedRoles = partialLogs.map(log => (log as any).details?.roleCode); + const isComplete = requiredRoles.every(role => approvedRoles.includes(role)); + + if (!isComplete) { + const partialTimeline = [...(termination.timeline || []), { + stage: sourceStage, + timestamp: new Date(), + user: req.user.fullName, + role: req.user.roleCode, + action: 'Partial Approved (SCN Review)', + remarks: remarks || `Review recorded by ${req.user.roleCode}` + }]; + await termination.update({ timeline: partialTimeline }, { transaction }); + + await transaction.commit(); + return res.json({ + success: true, + message: `Review recorded. Waiting for ${requiredRoles.filter(r => !approvedRoles.includes(r)).join(', ')} approval to proceed to NBH Final Approval.`, + isPartial: true + }); + } + logger.info(`[TerminationController] SCN Joint evaluation complete for ${termination.requestId}. Moving to ${nextStage}.`); + } + approvedToStage = nextStage; await TerminationWorkflowService.transitionTermination(termination, nextStage, req.user.id, { @@ -528,6 +655,11 @@ export const submitScnResponse = async (req: AuthRequest, res: Response, next: N const transaction: Transaction = await db.sequelize.transaction(); try { if (!req.user) throw new Error('Unauthorized'); + + const authorizedRoles = [ROLES.DD_ADMIN, ROLES.DD_LEAD, ROLES.LEGAL_ADMIN, ROLES.SUPER_ADMIN]; + if (!authorizedRoles.includes(req.user.roleCode as any)) { + return res.status(403).json({ success: false, message: 'Direct SCN submission is restricted. Please submit your response to DD Admin.' }); + } const { terminationRequestId, responseBody, documents } = req.body; const termination = await db.TerminationRequest.findByPk(terminationRequestId); @@ -620,6 +752,11 @@ export const uploadScnResponse = async (req: AuthRequest, res: Response, next: N const transaction: Transaction = await db.sequelize.transaction(); try { if (!req.user) throw new Error('Unauthorized'); + + const authorizedRoles = [ROLES.DD_ADMIN, ROLES.DD_LEAD, ROLES.LEGAL_ADMIN, ROLES.SUPER_ADMIN]; + if (!authorizedRoles.includes(req.user.roleCode as any)) { + return res.status(403).json({ success: false, message: 'Only DD Admin or DD Lead can upload the dealer SCN response.' }); + } const { id } = req.params; const { remarks } = req.body; const resolvedId = await resolveTerminationUuid(String(id)); diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index 5ffe37c..dc4102b 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -51,7 +51,7 @@ export class NotificationService { } } - // 2. Offload other channels to Job Queue (BullMQ) + // 2. Offload other channels to Job Queue (BullMQ) or Send Synchronously if Redis is disabled const asyncChannels = channels.filter(c => c !== 'system'); if (asyncChannels.length > 0) { if (process.env.ENABLE_REDIS === 'true') { @@ -67,7 +67,18 @@ export class NotificationService { metadata }); } else { - console.log(`[Notification Service] Redis disabled. Skipping async channels: ${asyncChannels.join(', ')}`); + console.log(`[Notification Service] Redis disabled. Sending ${asyncChannels.join(', ')} synchronously...`); + // Fallback: Process immediately if queueing is disabled + await this.processJob({ + userId, + email, + title, + message, + channels: asyncChannels, + templateCode, + placeholders, + metadata + }); } } } diff --git a/src/services/ResignationWorkflowService.ts b/src/services/ResignationWorkflowService.ts index e49e1f3..f1fad7a 100644 --- a/src/services/ResignationWorkflowService.ts +++ b/src/services/ResignationWorkflowService.ts @@ -1,12 +1,13 @@ import db from '../database/models/index.js'; const { User } = db; -import { RESIGNATION_STAGES, ROLES, REQUEST_TYPES } from '../common/config/constants.js'; +import { RESIGNATION_STAGES, ROLES, REQUEST_TYPES, FNF_DEPARTMENTS } from '../common/config/constants.js'; import { getResignationStatusForStage } from '../common/utils/offboardingStatus.js'; import { NotificationService } from './NotificationService.js'; -import { Op } from 'sequelize'; +import { Op, Transaction } from 'sequelize'; import logger from '../common/utils/logger.js'; import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js'; import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js'; +import { NomenclatureService } from '../common/utils/nomenclature.js'; export class ResignationWorkflowService { @@ -165,4 +166,66 @@ export class ResignationWorkflowService { } return user.roleCode === requiredRole; } + + /** + * Initiates the F&F settlement process for a resignation + * SRS §4.2.2.8 — Standardized trigger mechanism + */ + static async initiateFnF(resignation: any, userId: string, transaction: Transaction) { + try { + // 1. Resolve Dealer Entity ID (from User profile) + let dealerEntityId = resignation.dealerId; // Fallback to User ID if not linked, though DB FK prefers dealers.id + if (resignation.dealer && resignation.dealer.dealerId) { + dealerEntityId = resignation.dealer.dealerId; + } else { + // If not eager loaded, fetch the user to get dealerId + const user = await db.User.findByPk(resignation.dealerId); + if (user && user.dealerId) { + dealerEntityId = user.dealerId; + } + } + + const fnf = await db.FnF.create({ + settlementId: await NomenclatureService.generateFnFId(), + resignationId: resignation.id, + dealerId: dealerEntityId, + outletId: resignation.outletId, + status: 'Initiated', + initiatedAt: new Date(), + initiatedBy: userId, + totalPayables: 0, + totalReceivables: 0, + totalDeductions: 0, + netAmount: 0, + departmentalClearances: {} + }, { transaction }); + + // 2. Initialize Departmental Clearances + const clearancePromises = FNF_DEPARTMENTS.map(dept => + db.FffClearance.create({ + fnfId: fnf.id, + department: dept, + status: 'Pending', + amount: 0, + remarks: 'Awaiting departmental input' + }, { transaction }) + ); + await Promise.all(clearancePromises); + + // 3. Create Audit Trail + await db.FnFAudit.create({ + userId, + fnfId: fnf.id, + action: 'INITIATED', + remarks: 'F&F Settlement workflow triggered from Resignation', + details: { source: 'Resignation Workflow', resignationId: resignation.resignationId } + }, { transaction }); + + logger.info(`[ResignationWorkflowService] F&F ${fnf.settlementId} initiated for Resignation ${resignation.resignationId}`); + return fnf; + } catch (error) { + logger.error('[ResignationWorkflowService] Failed to initiate F&F:', error); + throw error; + } + } } diff --git a/src/services/TerminationWorkflowService.ts b/src/services/TerminationWorkflowService.ts index be82559..e128b38 100644 --- a/src/services/TerminationWorkflowService.ts +++ b/src/services/TerminationWorkflowService.ts @@ -35,6 +35,7 @@ export class TerminationWorkflowService { targetStage: targetStage, timestamp: new Date(), user: actor ? actor.fullName : 'System', + role: actor ? actor.roleCode : null, action: action || `Approved to ${targetStage}`, remarks: remarks || '' }; @@ -279,7 +280,7 @@ export class TerminationWorkflowService { return this.transitionTermination(termination, TERMINATION_STAGES.PERSONAL_HEARING, userId, { action: 'SCN_SUBMITTED', - status: 'Personal Hearing Pending', + status: 'SCN Response Evaluation Pending', remarks: 'Dealer response submitted' }); } @@ -300,7 +301,7 @@ export class TerminationWorkflowService { }); const nextStage = recommendation === 'Reject' ? TERMINATION_STAGES.REJECTED : TERMINATION_STAGES.NBH_FINAL_APPROVAL; - const status = recommendation === 'Reject' ? 'Rejected after Hearing' : 'NBH Final Approval Pending'; + const status = recommendation === 'Reject' ? 'Rejected after Evaluation' : 'NBH Final Approval Pending'; return this.transitionTermination(termination, nextStage, userId, { action: `Hearing Recorded - ${recommendation}`, @@ -324,14 +325,20 @@ export class TerminationWorkflowService { [TERMINATION_STAGES.DD_HEAD_REVIEW]: ROLES.DD_HEAD, [TERMINATION_STAGES.NBH_EVALUATION]: ROLES.NBH, [TERMINATION_STAGES.SCN_ISSUED]: [ROLES.LEGAL_ADMIN, ROLES.DD_ADMIN], - [TERMINATION_STAGES.PERSONAL_HEARING]: [ROLES.NBH, ROLES.DD_LEAD], + [TERMINATION_STAGES.PERSONAL_HEARING]: [ROLES.NBH, ROLES.DD_LEAD, ROLES.RBM, ROLES.ZBH, ROLES.DD_HEAD], [TERMINATION_STAGES.NBH_FINAL_APPROVAL]: ROLES.NBH, [TERMINATION_STAGES.CCO_APPROVAL]: ROLES.CCO, [TERMINATION_STAGES.CEO_APPROVAL]: ROLES.CEO, [TERMINATION_STAGES.LEGAL_LETTER]: ROLES.LEGAL_ADMIN }; - const requiredRole = stageToRole[termination.currentStage]; + const stageAliases: Record = { + 'Personal Hearing': TERMINATION_STAGES.PERSONAL_HEARING, + 'Show Cause Notice': TERMINATION_STAGES.SCN_ISSUED + }; + + const normalizedStage = stageAliases[termination.currentStage] || termination.currentStage; + const requiredRole = stageToRole[normalizedStage]; if (Array.isArray(requiredRole)) { return requiredRole.includes(user.roleCode); } diff --git a/trigger-resignation.js b/trigger-resignation.js index 331618f..00b6242 100644 --- a/trigger-resignation.js +++ b/trigger-resignation.js @@ -160,13 +160,13 @@ async function run() { const approvals = [ { stage: 'ASM', name: 'ASM', email: EMAILS.ASM, remarks: 'Verified physical assets and dealer intent. Recommended for resignation.' }, - { stage: 'RBM', name: 'RBM', email: EMAILS.RBM, remarks: 'No active sales pipeline issues in the territory. Transition plan discussed.' }, - { stage: 'RBM', name: 'DD-ZM', email: EMAILS.DD_ZM, remarks: 'Joint approval evaluated and verified by DD-ZM.' }, + { stage: 'RBM + DD-ZM Review', name: 'RBM', email: EMAILS.RBM, remarks: 'No active sales pipeline issues in the territory. Transition plan discussed.' }, + { stage: 'RBM + DD-ZM Review', name: 'DD-ZM', email: EMAILS.DD_ZM, remarks: 'Joint approval evaluated and verified by DD-ZM.' }, { stage: 'ZBH', name: 'ZBH', email: EMAILS.ZBH, remarks: 'Zone-level resource reallocation planned. Approved.' }, { stage: 'DD Lead', name: 'DD Lead', email: EMAILS.DD_LEAD, remarks: 'Dealer development criteria satisfied for exit.' }, { stage: 'NBH', name: 'NBH', email: EMAILS.NBH, remarks: 'HQ clearance granted. Proceed with F&F initiation.' }, - { stage: 'DD Admin', name: 'DD Admin', email: EMAILS.DD_ADMIN, remarks: 'Administrative checks complete. Initiating termination of commercial contract.' }, - { stage: 'Legal', name: 'Legal Admin', email: EMAILS.LEGAL, remarks: 'Legal documentation verified. No active litigation found.' } + { stage: 'Legal', name: 'Legal Admin', email: EMAILS.LEGAL, remarks: 'Legal documentation verified. No active litigation found.' }, + { stage: 'DD Admin', name: 'DD Admin', email: EMAILS.DD_ADMIN, remarks: 'Administrative checks complete. Initiating termination of commercial contract.' } ]; // Fetch resignation data to determine current stage for skipping @@ -176,7 +176,7 @@ async function run() { console.log(`Current Stage: ${currentStage}`); const stageOrder = [ - 'ASM', 'RBM', 'ZBH', 'DD Lead', 'NBH', 'DD Admin', 'Legal', 'F&F Initiated', 'Completed' + 'Request Submitted', 'ASM', 'RBM + DD-ZM Review', 'ZBH', 'DD Lead', 'NBH', 'Legal', 'DD Admin', 'F&F Initiated', 'Completed' ]; let startStageIndex = stageOrder.indexOf(currentStage) === -1 ? 0 : stageOrder.indexOf(currentStage); @@ -190,6 +190,26 @@ async function run() { const actor = approvals[i]; log(currentStep, `${actor.name} (${actor.email}) approving...`); const token = await login(actor.email); + + // Special Case: Legal Admin must upload 'Resignation Acceptance Letter' before approving + if (actor.stage === 'Legal') { + log(currentStep, `[Legal] Uploading mandatory 'Resignation Acceptance Letter'...`); + const formData = new FormData(); + const blob = new Blob(['Mock Acceptance Letter Content'], { type: 'text/plain' }); + formData.append('file', blob, 'Acceptance_Letter.txt'); + formData.append('documentType', 'Resignation Acceptance Letter'); + formData.append('stage', 'Legal'); + + const uploadRes = await fetch(`${BASE_URL}/self-service/resignations/${resignationId}/documents`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData + }); + const uploadData = await uploadRes.json(); + if (!uploadRes.ok) throw new Error(`Document upload failed: ${JSON.stringify(uploadData)}`); + log(currentStep, `[Legal] Document uploaded successfully.`); + } + const res = await apiRequest(`/self-service/resignations/${resignationId}/approve`, 'PUT', { remarks: actor.remarks, force: true diff --git a/trigger-termination.js b/trigger-termination.js index 312e903..21b819b 100644 --- a/trigger-termination.js +++ b/trigger-termination.js @@ -12,8 +12,10 @@ const EMAILS = { DD_ADMIN: 'lince@royalenfield.com', ASM: 'abhishek@royalenfield.com', RBM: 'manish@royalenfield.com', + DD_ZM: 'piyush@royalenfield.com', ZBH: 'manav@royalenfield.com', DD_LEAD: 'jaya@royalenfield.com', + DD_HEAD: 'ganesh@royalenfield.com', LEGAL: 'legal@royalenfield.com', NBH: 'yashwin@royalenfield.com', CCO: 'admin@royalenfield.com', @@ -72,8 +74,10 @@ async function run() { console.log(`Targeting Dealer: ${targetDealer.legalName} (${targetDealer.id})`); let terminationId = args.terminationId; + const isUnethical = String(args.category || '').trim().toLowerCase().includes('unethical'); + if (!terminationId) { - console.log('[STEP 1] ASM Initiating Termination...'); + console.log('[STEP 1] Initiating Termination...'); const asmToken = await login(EMAILS.ASM); const createRes = await apiRequest('/termination', 'POST', { dealerId: targetDealer.id, @@ -83,7 +87,7 @@ async function run() { 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}`); + console.log(`[STEP 1] Termination Request Created. ID: ${terminationId}. Category: ${args.category || 'Performance'}`); } else { console.log(`[STEP 1] Resuming existing termination: ${terminationId}`); } @@ -93,40 +97,49 @@ async function run() { console.log(`[INFO] Current stage before progression: ${currentStage}`); const approvals = [ - { name: 'RBM + DD-ZM Review', email: EMAILS.RBM, remarks: 'Performance concerns validated on-ground. Proceed with termination.' }, - { name: 'ZBH Review', email: EMAILS.ZBH, remarks: 'Strategic decision aligned with regional growth targets. Approved.' }, - { name: 'DD Lead Review', email: EMAILS.DD_LEAD, remarks: 'Contractual breaches documented. Verified.' }, - { name: 'Legal Verification', email: EMAILS.LEGAL, remarks: 'Legal audit complete. Case is legally sound.' }, - { name: 'DD Head Review', email: EMAILS.NBH, remarks: 'Strategic impact assessed. Proceeding with SCN approval.' }, - { name: 'NBH Evaluation', email: EMAILS.NBH, remarks: 'Functional teams aligned. SCN to be issued.' }, - { name: 'SCN Issued', email: EMAILS.NBH, remarks: 'Show Cause Notice formally dispatched.' }, - { name: 'Personal Hearing Outcome', email: EMAILS.DD_LEAD, remarks: 'Hearing completed. Dealer defense not sufficient.' }, - { name: 'NBH Final Approval', email: EMAILS.NBH, remarks: 'Final recommendation for termination sent to CEO.' }, - { name: 'CCO Approval', email: EMAILS.CCO, remarks: 'Commercial impact assessed. Approved.' }, - { name: 'CEO Final Approval', email: EMAILS.CEO, remarks: 'Final authorization granted. Issue termination letter.' }, - { name: 'Legal Termination Letter', email: EMAILS.LEGAL, remarks: 'Termination letter shared via registered mail.' }, - { name: 'Final Terminated Status', email: EMAILS.DD_ADMIN, remarks: 'Closure completed.' } + { stage: 'RBM + DD-ZM Review', actors: [{ email: EMAILS.RBM, remarks: 'Validated.' }, { email: EMAILS.DD_ZM, remarks: 'Confirmed.' }] }, + { stage: 'ZBH Review', actors: [{ email: EMAILS.ZBH, remarks: 'Strategic decision aligned.' }] }, + { stage: 'DD Lead Review', actors: [{ email: EMAILS.DD_LEAD, remarks: 'Breaches documented.' }] }, + { stage: 'Legal Verification', actors: [{ email: EMAILS.LEGAL, remarks: 'Case is sound.' }] }, + { stage: 'DD Head Review', actors: [{ email: EMAILS.DD_HEAD, remarks: 'Strategic impact assessed.' }] }, + { stage: 'NBH Evaluation', actors: [{ email: EMAILS.NBH, remarks: 'Functional teams aligned.' }] }, + { stage: 'Show Cause Notice (SCN)', actors: [{ email: EMAILS.DD_ADMIN, remarks: 'SCN Issued.' }] }, + { stage: 'Personal Hearing', actors: [ + { email: EMAILS.DD_LEAD, remarks: 'Hearing completed.' }, + { email: EMAILS.ZBH, remarks: 'Review recorded.' }, + { email: EMAILS.RBM, remarks: 'Review recorded.' }, + { email: EMAILS.DD_HEAD, remarks: 'Review recorded.' } + ] }, + { stage: 'NBH Final Approval', actors: [{ email: EMAILS.NBH, remarks: 'Final recommendation.' }] }, + { stage: 'CCO Approval', actors: [{ email: EMAILS.CCO, remarks: 'Approved.' }] }, + { stage: 'CEO Final Approval', actors: [{ email: EMAILS.CEO, remarks: 'Final authorization.' }] }, + { stage: 'Legal - Termination Letter', actors: [{ email: EMAILS.LEGAL, remarks: 'Termination letter shared.' }] } ]; - const stageOrder = [ 'Submitted', 'RBM + DD-ZM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'DD Head Review', - 'NBH Evaluation', 'Show Cause Notice', 'Personal Hearing', 'NBH Final Approval', 'CCO Approval', + 'NBH Evaluation', 'Show Cause Notice (SCN)', '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', { - action: 'approve', - remarks: actor.remarks - }, token); - log(currentStep, `${actor.name} Result: SUCCESS`); + // If Unethical, the skip-routing skips to DD Lead Review (index 3 in stageOrder) + const startIndex = isUnethical ? 2 : Math.max(0, stageOrder.indexOf(currentStage)); + let currentStep = 2; + + for (let i = startIndex; i < approvals.length; i++) { + const step = approvals[i]; + log(currentStep, `Stage: ${step.stage} - Processing approvals...`); + + for (const actor of step.actors) { + const token = await login(actor.email); + await apiRequest(`/termination/${terminationId}/status`, 'PUT', { + action: 'approve', + remarks: actor.remarks + }, token); + log(currentStep, `Actor ${actor.email} Result: SUCCESS`); + await delay(100); + } + currentStep++; await delay(); }