stage names modified and calendar added in opportunity requests added and checked resignation and termination flow end to end from chennai

This commit is contained in:
laxmanhalaki 2026-05-04 13:28:52 +05:30
parent 2b73036bb9
commit 5ddbe525e6
16 changed files with 585 additions and 123 deletions

View File

@ -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();

View File

@ -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');

View File

@ -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']

View File

@ -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<string, string> = {
'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<string, string> | null => {
const updates: Record<string, string> = {};
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();

View File

@ -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,

View File

@ -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) } };
}

View File

@ -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

View File

@ -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' });
}
};

View File

@ -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);

View File

@ -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;
if (!force && lwd && today < new Date(lwd)) {
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 < 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.'
});
}
}

View File

@ -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));

View File

@ -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
});
}
}
}

View File

@ -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;
}
}
}

View File

@ -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<string, string> = {
'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);
}

View File

@ -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

View File

@ -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();
}