Dealer_Onboarding_Backend/src/services/TerminationWorkflowService.ts

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