Dealer_Onboarding_Backend/src/services/ResignationWorkflowService.ts

232 lines
9.6 KiB
TypeScript

import db from '../database/models/index.js';
const { User } = db;
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, 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 {
/**
* Standardized method to transition a resignation request status
*/
static async transitionResignation(resignation: any, targetStage: string, userId: string | null = null, metadata: any = {}) {
const { action, remarks, status, transaction } = metadata;
const sourceStage = resignation.currentStage;
const updateData: any = {
currentStage: targetStage,
status: status || getResignationStatusForStage(targetStage),
progressPercentage: this.calculateProgress(targetStage),
updatedAt: new Date()
};
// 1. Resolve Actor
const actor = userId ? await User.findByPk(userId) : null;
// 2. Update Timeline (JSON array) & Resignation Record
const timelineEntry = {
stage: sourceStage, // Correctly Associate remark with the stage where action happened
targetStage: targetStage, // Store target for reference
timestamp: new Date(),
user: actor ? actor.fullName : 'System',
action: action || `Approved to ${targetStage}`,
remarks: remarks || ''
};
const updatedTimeline = [...(resignation.timeline || []), timelineEntry];
await resignation.update({
...updateData,
timeline: updatedTimeline
}, transaction ? { transaction } : undefined);
// 3. Create Audit Log using standardized mapper
const { actionType } = metadata;
const auditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.RESIGNATION);
await db.ResignationAudit.create({
userId: userId,
resignationId: resignation.id,
action: formatOffboardingAction(auditAction),
remarks: remarks || '',
details: { status: updateData.status, stage: sourceStage, targetStage: formatOffboardingAction(targetStage) }
}, transaction ? { transaction } : undefined);
// 4. 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: resignation.id,
requestType: 'resignation',
userId: userId,
noteText: `${actionPrefix}${remarks}`,
noteType: (action && action.toLowerCase().includes('send back')) ? 'workflow' : 'internal'
});
} catch (wnErr) {
logger.error('[ResignationWorkflowService] failed to write worknote:', wnErr);
}
}
console.log(`[ResignationWorkflowService] Transitioned Resignation ${resignation.resignationId} to ${targetStage}`);
// 5. Send Notifications
const user = await User.findOne({
where: {
[Op.or]: [
{ id: resignation.dealerId },
{ dealerId: resignation.dealerId }
]
}
});
if (user) {
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js');
await notifyStakeholdersOnTransition(
resignation.id,
REQUEST_TYPES.RESIGNATION,
targetStage,
{
code: resignation.resignationId || resignation.id,
dealerName: user.fullName || 'Dealer',
dealerId: user.id,
actionUserFullName: actor ? actor.fullName : 'System',
action: action || `Approved to ${targetStage}`,
remarks: remarks || 'N/A',
link: `${portalBase}/dealer-resignation/${resignation.id}`
}
);
// 6. Deactivate User Account on final completion (SRS 1.1.5 / 2.3.5)
if (targetStage === RESIGNATION_STAGES.COMPLETED || targetStage === RESIGNATION_STAGES.LEGAL) {
logger.info(`[ResignationWorkflowService] Revoking portal access for user ${user.email} (Stage: ${targetStage})`);
await user.update({
status: 'inactive',
isActive: false
}, transaction ? { transaction } : undefined);
}
} else {
logger.warn(`[ResignationWorkflowService] No user account found with dealerId ${resignation.dealerId}`);
}
return resignation;
}
/**
* Maps resignation stages to progress percentage
*/
static calculateProgress(stage: string): number {
const progress: Record<string, number> = {
[RESIGNATION_STAGES.ASM]: 15,
[RESIGNATION_STAGES.RBM]: 30,
[RESIGNATION_STAGES.ZBH]: 40,
[RESIGNATION_STAGES.DD_LEAD]: 50,
[RESIGNATION_STAGES.NBH]: 65,
[RESIGNATION_STAGES.LEGAL]: 80,
[RESIGNATION_STAGES.DD_ADMIN]: 90,
[RESIGNATION_STAGES.FNF_INITIATED]: 95,
[RESIGNATION_STAGES.COMPLETED]: 100,
[RESIGNATION_STAGES.REJECTED]: 100
};
return progress[stage] || 0;
}
/**
* Checks if a user is authorized to perform an action based on their role and current stage
*/
static async canUserAction(resignation: any, user: any) {
if (!user) return false;
if (user.roleCode === ROLES.SUPER_ADMIN) return true;
const stageToRole: Record<string, string | string[]> = {
[RESIGNATION_STAGES.ASM]: ROLES.ASM,
[RESIGNATION_STAGES.RBM]: [ROLES.RBM, ROLES.DD_ZM],
[RESIGNATION_STAGES.ZBH]: ROLES.ZBH,
[RESIGNATION_STAGES.DD_LEAD]: ROLES.DD_LEAD,
[RESIGNATION_STAGES.NBH]: ROLES.NBH,
[RESIGNATION_STAGES.LEGAL]: ROLES.LEGAL_ADMIN,
[RESIGNATION_STAGES.DD_ADMIN]: ROLES.DD_ADMIN,
[RESIGNATION_STAGES.FNF_INITIATED]: ROLES.DD_ADMIN
};
const requiredRole = stageToRole[resignation.currentStage];
if (Array.isArray(requiredRole)) {
return requiredRole.includes(user.roleCode);
}
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;
}
}
}