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 = { [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 = { [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; } } }