395 lines
17 KiB
TypeScript
395 lines
17 KiB
TypeScript
import db from '../database/models/index.js';
|
|
import { Op } from 'sequelize';
|
|
const { User, TerminationScnResponse, TerminationHearingRecord, Dealer, FnF, FffClearance } = db;
|
|
import { TERMINATION_STAGES, ROLES, FNF_DEPARTMENTS, REQUEST_TYPES } from '../common/config/constants.js';
|
|
import { getTerminationStatusForStage } from '../common/utils/offboardingStatus.js';
|
|
import { NotificationService } from './NotificationService.js';
|
|
import ExternalMocksService from '../common/utils/externalMocks.service.js';
|
|
import logger from '../common/utils/logger.js';
|
|
import { NomenclatureService } from '../common/utils/nomenclature.js';
|
|
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
|
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
|
import { ParticipantService } from './ParticipantService.js';
|
|
import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js';
|
|
import { getFrontendBaseUrl } from '../common/utils/frontendUrl.js';
|
|
|
|
export class TerminationWorkflowService {
|
|
/**
|
|
* Standardized method to transition a termination request status
|
|
*/
|
|
static async transitionTermination(termination: any, targetStage: string, userId: string | null = null, metadata: any = {}) {
|
|
const { action, remarks, status, transaction } = metadata;
|
|
const sourceStage = termination.currentStage;
|
|
const wasOnHold = String(termination.status || '').toLowerCase() === 'on hold';
|
|
|
|
const updateData: any = {
|
|
currentStage: targetStage,
|
|
status: status || getTerminationStatusForStage(targetStage),
|
|
progressPercentage: this.calculateProgress(targetStage),
|
|
updatedAt: new Date()
|
|
};
|
|
|
|
// 1. Resolve Actor
|
|
const actor = userId ? await User.findByPk(userId) : null;
|
|
|
|
// 2. Prepare Timeline Entry
|
|
const timelineEntry = {
|
|
stage: sourceStage, // Correctly Associate remark with the stage where action happened
|
|
targetStage: targetStage,
|
|
timestamp: new Date(),
|
|
user: actor ? actor.fullName : 'System',
|
|
role: actor ? actor.roleCode : null,
|
|
action: action || `Approved to ${targetStage}`,
|
|
remarks: remarks || ''
|
|
};
|
|
|
|
const updatedTimeline = [...(termination.timeline || []), timelineEntry];
|
|
|
|
// 3. Perform Consolidated Update
|
|
await termination.update({
|
|
...updateData,
|
|
timeline: updatedTimeline
|
|
}, transaction ? { transaction } : undefined);
|
|
|
|
await syncSlaOnStageTransition({
|
|
entityType: 'termination',
|
|
entityId: termination.id,
|
|
fromStage: sourceStage,
|
|
toStage: targetStage
|
|
});
|
|
|
|
if (wasOnHold) {
|
|
const { SLAService } = await import('./SLAService.js');
|
|
await SLAService.resumeEntityTracks('termination', termination.id);
|
|
}
|
|
|
|
// 4. Create Audit Log using standardized mapper
|
|
const { actionType } = metadata;
|
|
const auditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.TERMINATION);
|
|
|
|
await db.TerminationAudit.create({
|
|
userId: userId,
|
|
terminationRequestId: termination.id,
|
|
action: formatOffboardingAction(auditAction),
|
|
remarks: remarks || '',
|
|
details: { status: updateData.status, stage: sourceStage, targetStage: formatOffboardingAction(targetStage) }
|
|
}, transaction ? { transaction } : undefined);
|
|
|
|
// 5. Create Worknote for standardized communication trail
|
|
if (remarks && userId) {
|
|
try {
|
|
// Prepend action to remarks for better context in Work Notes
|
|
const actionPrefix = action ? `${formatOffboardingAction(action)}: ` : '';
|
|
await writeWorkflowActivityWorknote({
|
|
requestId: termination.id,
|
|
requestType: 'termination',
|
|
userId: userId,
|
|
noteText: `${actionPrefix}${remarks}`,
|
|
noteType: (action && action.toLowerCase().includes('send back')) ? 'workflow' : 'internal'
|
|
});
|
|
} catch (wnErr) {
|
|
logger.error('[TerminationWorkflowService] failed to write worknote:', wnErr);
|
|
}
|
|
}
|
|
|
|
// 4. Send Notifications
|
|
const user = await User.findOne({
|
|
where: {
|
|
[Op.or]: [
|
|
{ id: termination.dealerId },
|
|
{ dealerId: termination.dealerId }
|
|
]
|
|
},
|
|
attributes: ['id', 'email', 'fullName', 'mobileNumber']
|
|
});
|
|
|
|
if (user) {
|
|
const portalBase = getFrontendBaseUrl();
|
|
const dealerPortalLink = `${portalBase}/termination/${termination.id}`;
|
|
const isScnIssued = targetStage === TERMINATION_STAGES.SCN_ISSUED;
|
|
|
|
const deadlineRaw = metadata.scnDeadline ?? metadata.deadline ?? termination.proposedLwd;
|
|
let deadlineStr = '';
|
|
try {
|
|
if (deadlineRaw instanceof Date) {
|
|
deadlineStr = deadlineRaw.toLocaleDateString('en-IN', { dateStyle: 'medium' });
|
|
} else if (deadlineRaw) {
|
|
deadlineStr = String(deadlineRaw);
|
|
} else {
|
|
deadlineStr = new Date(Date.now() + 7 * 86400000).toLocaleDateString('en-IN', {
|
|
dateStyle: 'medium'
|
|
});
|
|
}
|
|
} catch {
|
|
deadlineStr = String(deadlineRaw || '');
|
|
}
|
|
|
|
if (isScnIssued) {
|
|
await NotificationService.notify(user.id, user.email, {
|
|
title: `URGENT: Show Cause Notice issued — ${termination.requestId}`,
|
|
message: `A Show Cause Notice has been issued for your termination request.`,
|
|
channels: ['email', 'whatsapp', 'system'],
|
|
templateCode: 'TERMINATION_SCN_ISSUED',
|
|
placeholders: {
|
|
dealerName: user.fullName || 'Dealer',
|
|
terminationId: termination.requestId,
|
|
deadline: deadlineStr,
|
|
link: dealerPortalLink,
|
|
remarks: remarks || '',
|
|
ctaLabel: 'Submit response',
|
|
phone: user?.mobileNumber || user?.phone || ''
|
|
}
|
|
});
|
|
}
|
|
|
|
const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js');
|
|
|
|
await notifyStakeholdersOnTransition(
|
|
termination.id,
|
|
REQUEST_TYPES.TERMINATION,
|
|
targetStage,
|
|
{
|
|
code: termination.requestId,
|
|
dealerName: user.fullName || 'Dealer',
|
|
dealerId: user.id,
|
|
actionUserFullName: actor ? actor.fullName : 'System',
|
|
action: action || `Approved to ${targetStage}`,
|
|
remarks: remarks || 'N/A',
|
|
link: dealerPortalLink
|
|
}
|
|
);
|
|
|
|
// 5. Deactivate User Account on final completion stages (SRS 1.1.5 / 2.3.5)
|
|
// We deactivate at Legal Letter stage to ensure access is revoked as soon as the formal letter is issued
|
|
if (targetStage === TERMINATION_STAGES.TERMINATED || targetStage === TERMINATION_STAGES.LEGAL_LETTER) {
|
|
logger.info(`[TerminationWorkflowService] Revoking portal access for user ${user.email} (Stage: ${targetStage})`);
|
|
await user.update({
|
|
status: 'inactive',
|
|
isActive: false
|
|
});
|
|
}
|
|
} else {
|
|
logger.warn(`[TerminationWorkflowService] No user account found with dealerId ${termination.dealerId}`);
|
|
}
|
|
|
|
return termination;
|
|
}
|
|
|
|
/**
|
|
* Creates the FnF settlement record — ONLY call from explicit Push to F&F (`manualTrigger: true`).
|
|
*/
|
|
static async initiateFnF(
|
|
termination: any,
|
|
userId: string,
|
|
transaction: any,
|
|
options: { manualTrigger?: boolean } = {}
|
|
) {
|
|
if (!options.manualTrigger) {
|
|
throw new Error(
|
|
'F&F settlement for termination must be started via Push to F&F (manual trigger only).'
|
|
);
|
|
}
|
|
// 1. Get Dealer User with associated Outlets
|
|
const dealerUser = await User.findOne({
|
|
where: { dealerId: termination.dealerId, roleCode: ROLES.DEALER },
|
|
include: [{ model: db.Outlet, as: 'outlets' }]
|
|
});
|
|
|
|
const primaryOutlet = dealerUser?.outlets?.find((o: any) => o.isPrimary) || dealerUser?.outlets?.[0];
|
|
|
|
const dealerProfile = await Dealer.findByPk(termination.dealerId);
|
|
if (!dealerProfile) throw new Error('Dealer record not found for termination');
|
|
|
|
// 2. Resolve or Create FnF Settlement
|
|
let fnf = await db.FnF.findOne({ where: { terminationRequestId: termination.id } });
|
|
let fnfId = fnf?.id;
|
|
|
|
if (!fnf) {
|
|
fnf = await db.FnF.create(
|
|
{
|
|
settlementId: await NomenclatureService.generateFnFId(),
|
|
terminationRequestId: termination.id,
|
|
dealerId: termination.dealerId,
|
|
outletId: primaryOutlet?.id || null,
|
|
status: 'Initiated',
|
|
totalReceivables: 0,
|
|
totalPayables: 0,
|
|
netAmount: 0
|
|
},
|
|
transaction ? { transaction } : undefined
|
|
);
|
|
|
|
await db.FffClearance.bulkCreate(
|
|
FNF_DEPARTMENTS.map(dept => ({
|
|
fnfId: fnf.id,
|
|
department: dept,
|
|
status: 'Pending'
|
|
})),
|
|
transaction ? { transaction } : undefined
|
|
);
|
|
|
|
const { startAllPendingFnfClearanceSlas } = await import('../common/utils/slaFnfSync.js');
|
|
await startAllPendingFnfClearanceSlas(fnf.id);
|
|
|
|
await db.FnFAudit.create(
|
|
{
|
|
userId,
|
|
fnfId: fnf.id,
|
|
action: 'INITIATED',
|
|
remarks: 'F&F settlement created via manual Push to F&F (termination)',
|
|
details: {
|
|
source: 'Termination Workflow',
|
|
terminationRequestId: termination.requestId,
|
|
manualTrigger: true
|
|
}
|
|
},
|
|
transaction ? { transaction } : undefined
|
|
);
|
|
|
|
fnfId = fnf.id;
|
|
}
|
|
|
|
// 3. External SAP Sync
|
|
ExternalMocksService.mockSyncDealerStatusToSap(dealerProfile.dealerCode, 'Inactive')
|
|
.catch(err => console.error('Error syncing termination deactivation to SAP:', err));
|
|
|
|
// 4. Assign Participants for F&F (Sub-application chat)
|
|
if (fnfId) {
|
|
await ParticipantService.assignFnFParticipants(fnfId);
|
|
|
|
// SRS §1.1.1: Notify DD Admin and Finance via Email & WhatsApp
|
|
try {
|
|
const adminUsers = await User.findAll({
|
|
where: { roleCode: [ROLES.DD_ADMIN, ROLES.FINANCE] },
|
|
attributes: ['id', 'email', 'fullName', 'mobileNumber']
|
|
});
|
|
|
|
const portalBase = getFrontendBaseUrl();
|
|
for (const u of adminUsers) {
|
|
const phone = u.mobileNumber || null;
|
|
await NotificationService.notify(u.id, u.email, {
|
|
title: `F&F Settlement Initiated: ${termination.requestId}`,
|
|
message: `Full & Final Settlement has been initiated for ${dealerUser?.fullName || 'Dealer'}.`,
|
|
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
|
|
templateCode: 'FNF_INITIATED',
|
|
placeholders: {
|
|
dealerName: dealerUser?.fullName || 'Dealer',
|
|
requestId: termination.requestId,
|
|
link: `${portalBase}/fnf/${fnf.id}`,
|
|
phone: phone || ''
|
|
}
|
|
});
|
|
}
|
|
} catch (notifyErr) {
|
|
console.error('[TerminationWorkflowService] F&F initiation notification failed:', notifyErr);
|
|
}
|
|
}
|
|
|
|
return fnf;
|
|
}
|
|
|
|
/**
|
|
* Maps termination stages to progress percentage
|
|
*/
|
|
static calculateProgress(stage: string): number {
|
|
const progress: Record<string, number> = {
|
|
[TERMINATION_STAGES.SUBMITTED]: 10,
|
|
[TERMINATION_STAGES.RBM_REVIEW]: 20,
|
|
[TERMINATION_STAGES.ZBH_REVIEW]: 30,
|
|
[TERMINATION_STAGES.DD_LEAD_REVIEW]: 40,
|
|
[TERMINATION_STAGES.LEGAL_VERIFICATION]: 45,
|
|
[TERMINATION_STAGES.DD_HEAD_REVIEW]: 50,
|
|
[TERMINATION_STAGES.NBH_EVALUATION]: 60,
|
|
[TERMINATION_STAGES.SCN_ISSUED]: 70,
|
|
[TERMINATION_STAGES.PERSONAL_HEARING]: 75,
|
|
[TERMINATION_STAGES.NBH_FINAL_APPROVAL]: 80,
|
|
[TERMINATION_STAGES.CCO_APPROVAL]: 85,
|
|
[TERMINATION_STAGES.CEO_APPROVAL]: 90,
|
|
[TERMINATION_STAGES.LEGAL_LETTER]: 95,
|
|
[TERMINATION_STAGES.TERMINATED]: 100,
|
|
[TERMINATION_STAGES.REJECTED]: 100
|
|
};
|
|
return progress[stage] || 0;
|
|
}
|
|
|
|
/**
|
|
* Records a dealer's response to SCN and moves to personal hearing stage
|
|
*/
|
|
static async handleScnResponse(termination: any, data: any, userId: string) {
|
|
const { responseBody, documents } = data;
|
|
|
|
await TerminationScnResponse.create({
|
|
terminationRequestId: termination.id,
|
|
submittedBy: userId,
|
|
responseBody,
|
|
documents: documents || []
|
|
});
|
|
|
|
return this.transitionTermination(termination, TERMINATION_STAGES.PERSONAL_HEARING, userId, {
|
|
action: 'SCN_SUBMITTED',
|
|
status: 'SCN Response Evaluation Pending',
|
|
remarks: 'Dealer response submitted'
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Records a personal hearing outcome and moves to next stage or rejection
|
|
*/
|
|
static async handleHearingOutcome(termination: any, data: any, userId: string) {
|
|
const { attendees, summary, recommendation, momDocumentId } = data;
|
|
|
|
await TerminationHearingRecord.create({
|
|
terminationRequestId: termination.id,
|
|
conductedBy: userId,
|
|
attendees,
|
|
summary,
|
|
recommendation,
|
|
momDocumentId
|
|
});
|
|
|
|
const nextStage = recommendation === 'Reject' ? TERMINATION_STAGES.REJECTED : TERMINATION_STAGES.NBH_FINAL_APPROVAL;
|
|
const status = recommendation === 'Reject' ? 'Rejected after Evaluation' : 'NBH Final Approval Pending';
|
|
|
|
return this.transitionTermination(termination, nextStage, userId, {
|
|
action: `Hearing Recorded - ${recommendation}`,
|
|
status,
|
|
remarks: summary
|
|
});
|
|
}
|
|
/**
|
|
* Checks if a user is authorized to perform an action based on their role and current stage
|
|
*/
|
|
static async canUserAction(termination: any, user: any) {
|
|
if (!user) return false;
|
|
if (user.roleCode === ROLES.SUPER_ADMIN) return true;
|
|
|
|
const stageToRole: Record<string, string | string[]> = {
|
|
[TERMINATION_STAGES.SUBMITTED]: ROLES.ASM,
|
|
[TERMINATION_STAGES.RBM_REVIEW]: [ROLES.RBM, ROLES.DD_ZM],
|
|
[TERMINATION_STAGES.ZBH_REVIEW]: ROLES.ZBH,
|
|
[TERMINATION_STAGES.DD_LEAD_REVIEW]: ROLES.DD_LEAD,
|
|
[TERMINATION_STAGES.LEGAL_VERIFICATION]: ROLES.LEGAL_ADMIN,
|
|
[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, 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 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);
|
|
}
|
|
return user.roleCode === requiredRole;
|
|
}
|
|
}
|