232 lines
9.6 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|