stage names modified and calendar added in opportunity requests added and checked resignation and termination flow end to end from chennai
This commit is contained in:
parent
2b73036bb9
commit
5ddbe525e6
24
scratch/update_resignation_enum.js
Normal file
24
scratch/update_resignation_enum.js
Normal file
@ -0,0 +1,24 @@
|
||||
import db from '../src/database/models/index.js';
|
||||
|
||||
async function updateEnum() {
|
||||
try {
|
||||
console.log('Attempting to update PostgreSQL ENUM: enum_resignations_currentStage...');
|
||||
|
||||
// Note: ALTER TYPE ... ADD VALUE cannot be executed in a transaction block in some Postgres versions.
|
||||
// Sequelize's queryInterface.sequelize.query uses a transaction if not specified otherwise.
|
||||
|
||||
await db.sequelize.query('ALTER TYPE "enum_resignations_currentStage" ADD VALUE IF NOT EXISTS \'RBM + DD-ZM Review\'');
|
||||
|
||||
console.log('SUCCESS: ENUM updated successfully.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('FAILED to update ENUM:', error.message);
|
||||
if (error.message.includes('already exists')) {
|
||||
console.log('INFO: Value already exists, proceeding.');
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
updateEnum();
|
||||
@ -1,5 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { getResignationStatusForStage, getTerminationStatusForStage, normalizeClearanceStatus, normalizeFnFStatus } from '../src/common/utils/offboardingStatus.js';
|
||||
import {
|
||||
getResignationStatusForStage,
|
||||
getTerminationStatusForStage,
|
||||
normalizeClearanceStatus,
|
||||
normalizeFnFStatus,
|
||||
normalizeTerminationCurrentStage,
|
||||
getLegacyTerminationRowFixes
|
||||
} from '../src/common/utils/offboardingStatus.js';
|
||||
import { getJointRoundCutoffMsFromTimeline } from '../src/common/utils/terminationJointReviewRound.util.js';
|
||||
|
||||
assert.equal(normalizeFnFStatus('settled'), 'Completed');
|
||||
assert.equal(normalizeFnFStatus('finance approval'), 'Finance Approval');
|
||||
@ -10,6 +18,28 @@ assert.equal(getResignationStatusForStage('F&F Initiated'), 'F&F Initiated');
|
||||
assert.equal(getTerminationStatusForStage('Submitted'), 'Submitted');
|
||||
assert.equal(getTerminationStatusForStage('Terminated'), 'Terminated');
|
||||
|
||||
assert.equal(
|
||||
normalizeTerminationCurrentStage('Personal Hearing'),
|
||||
'Evaluation of Dealer SCN Response'
|
||||
);
|
||||
assert.deepEqual(getLegacyTerminationRowFixes({ currentStage: 'Personal Hearing', status: 'Personal Hearing Pending' }), {
|
||||
currentStage: 'Evaluation of Dealer SCN Response',
|
||||
status: 'SCN Response Evaluation Pending'
|
||||
});
|
||||
|
||||
const reconsiderTimeline = [
|
||||
{ action: 'Approved', targetStage: 'NBH Final Approval', timestamp: new Date('2024-01-01').toISOString() },
|
||||
{
|
||||
action: 'Sent for Reconsideration',
|
||||
targetStage: 'Evaluation of Dealer SCN Response',
|
||||
timestamp: new Date('2025-06-15T12:00:00.000Z').toISOString()
|
||||
}
|
||||
];
|
||||
assert.equal(
|
||||
getJointRoundCutoffMsFromTimeline(reconsiderTimeline, 'scn_response_eval'),
|
||||
new Date('2025-06-15T12:00:00.000Z').getTime()
|
||||
);
|
||||
|
||||
assert.equal(normalizeClearanceStatus('Cleared', 0), 'NOC Submitted');
|
||||
assert.equal(normalizeClearanceStatus('Cleared', 100), 'Dues Pending');
|
||||
assert.equal(normalizeClearanceStatus('Pending', 0), 'Pending');
|
||||
|
||||
@ -182,8 +182,8 @@ export const TERMINATION_STAGES = {
|
||||
LEGAL_VERIFICATION: 'Legal Verification',
|
||||
DD_HEAD_REVIEW: 'DD Head Review',
|
||||
NBH_EVALUATION: 'NBH Evaluation',
|
||||
SCN_ISSUED: 'Show Cause Notice',
|
||||
PERSONAL_HEARING: 'Personal Hearing',
|
||||
SCN_ISSUED: 'Show Cause Notice (SCN)',
|
||||
PERSONAL_HEARING: 'Evaluation of Dealer SCN Response',
|
||||
NBH_FINAL_APPROVAL: 'NBH Final Approval',
|
||||
CCO_APPROVAL: 'CCO Approval',
|
||||
CEO_APPROVAL: 'CEO Final Approval',
|
||||
@ -195,7 +195,7 @@ export const TERMINATION_STAGES = {
|
||||
// Resignation Stages
|
||||
export const RESIGNATION_STAGES = {
|
||||
ASM: 'ASM',
|
||||
RBM: 'RBM',
|
||||
RBM: 'RBM + DD-ZM Review',
|
||||
ZBH: 'ZBH',
|
||||
DD_LEAD: 'DD Lead',
|
||||
NBH: 'NBH',
|
||||
@ -493,7 +493,7 @@ export const RESIGNATION_DOCUMENT_TYPES = [
|
||||
'Resignation Letter',
|
||||
'Dealer Undertaking',
|
||||
'Approval Note',
|
||||
'Legal Communication',
|
||||
'Resignation Acceptance Letter',
|
||||
'Handover Document',
|
||||
'Settlement Supporting Document',
|
||||
'PPT Presentation',
|
||||
@ -501,6 +501,7 @@ export const RESIGNATION_DOCUMENT_TYPES = [
|
||||
] as const;
|
||||
|
||||
export const RESIGNATION_DOCUMENT_STAGES = [
|
||||
'Initiation',
|
||||
'ASM',
|
||||
'RBM',
|
||||
'ZBH',
|
||||
@ -558,7 +559,8 @@ export const OFFBOARDING_ACTIONS = {
|
||||
PUSH_FNF: 'pushfnf',
|
||||
RECONSIDER: 'reconsider',
|
||||
ISSUE_SCN: 'issueSCN',
|
||||
SCN_RESPONSE: 'scnResponse'
|
||||
SCN_RESPONSE: 'scnResponse',
|
||||
HOLD: 'hold'
|
||||
} as const;
|
||||
|
||||
// Module List for Document Management
|
||||
@ -567,7 +569,7 @@ export const MODULE_LIST = ['ONBOARDING', 'RESIGNATION', 'RELOCATION', 'CONSTITU
|
||||
// Process Stages per Module (Source of Truth for Checklists)
|
||||
export const STAGES_MAP = {
|
||||
'ONBOARDING': ['General', 'KYC', 'Level 1 Interview', 'Level 2 Interview', 'Level 3 Interview', 'FDD', 'LOI Approval', 'LOA Approval', 'LOI Issue', 'Architecture Team Assigned', 'Architecture Document Upload', 'Architecture Team Completion', 'EOR', 'Inauguration'],
|
||||
'RESIGNATION': ['Submission', 'Regional Review', 'ZM Review', 'ZBH Review', 'Finance Review', 'DDL Review', 'Approved'],
|
||||
'RESIGNATION': ['Submission', 'Regional Review', 'RBM + DD-ZM Review', 'ZBH Review', 'Finance Review', 'DDL Review', 'Approved'],
|
||||
'RELOCATION': ['Initiated', 'ASM Review', 'ZM Review', 'ZBH Review', 'Completed'],
|
||||
'CONSTITUTIONAL_CHANGE': ['Draft', 'Legal Review', 'Approved'],
|
||||
'TERMINATION': ['Submitted', 'RBM + DD-ZM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'NBH Evaluation', 'SCN', 'Personal Hearing', 'Completed']
|
||||
|
||||
@ -22,8 +22,9 @@ export const normalizeFnFStatus = (status: string | null | undefined): string =>
|
||||
|
||||
export const getResignationStatusForStage = (stage: string): string => {
|
||||
switch (stage) {
|
||||
case RESIGNATION_STAGES.ASM:
|
||||
case RESIGNATION_STAGES.RBM:
|
||||
return RESIGNATION_STAGES.RBM; // It already contains "Review"
|
||||
case RESIGNATION_STAGES.ASM:
|
||||
case RESIGNATION_STAGES.ZBH:
|
||||
case RESIGNATION_STAGES.DD_LEAD:
|
||||
case RESIGNATION_STAGES.NBH:
|
||||
@ -55,6 +56,35 @@ export const getTerminationStatusForStage = (stage: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Legacy DB rows may still use SRS label "Personal Hearing" while workflow code keys the canonical stage constant. */
|
||||
const LEGACY_TERMINATION_STAGE_TO_CANONICAL: Record<string, string> = {
|
||||
'Personal Hearing': TERMINATION_STAGES.PERSONAL_HEARING
|
||||
};
|
||||
|
||||
export const normalizeTerminationCurrentStage = (stage: string | null | undefined): string => {
|
||||
if (stage == null) return '';
|
||||
const trimmed = String(stage).trim();
|
||||
return LEGACY_TERMINATION_STAGE_TO_CANONICAL[trimmed] || trimmed;
|
||||
};
|
||||
|
||||
/** Returns column updates to align legacy termination rows with current stage/status strings (no-op if already canonical). */
|
||||
export const getLegacyTerminationRowFixes = (termination: {
|
||||
currentStage?: string | null;
|
||||
status?: string | null;
|
||||
}): Record<string, string> | null => {
|
||||
const updates: Record<string, string> = {};
|
||||
const rawStage = termination.currentStage;
|
||||
if (rawStage) {
|
||||
const canonical = normalizeTerminationCurrentStage(rawStage);
|
||||
if (canonical !== rawStage) updates.currentStage = canonical;
|
||||
}
|
||||
const st = termination.status;
|
||||
if (st && /personal hearing/i.test(st)) {
|
||||
updates.status = st.replace(/personal hearing/gi, 'SCN Response Evaluation');
|
||||
}
|
||||
return Object.keys(updates).length ? updates : null;
|
||||
};
|
||||
|
||||
export const normalizeClearanceStatus = (status: string | null | undefined, amount: number): string => {
|
||||
const normalizedAmount = Math.abs(Number(amount) || 0);
|
||||
const value = (status || '').toLowerCase();
|
||||
|
||||
@ -21,7 +21,7 @@ export const getPreviousStage = (requestType: string, currentStage: string): str
|
||||
[TERMINATION_STAGES.DD_LEAD_REVIEW]: TERMINATION_STAGES.ZBH_REVIEW,
|
||||
[TERMINATION_STAGES.LEGAL_VERIFICATION]: TERMINATION_STAGES.DD_LEAD_REVIEW,
|
||||
[TERMINATION_STAGES.DD_HEAD_REVIEW]: TERMINATION_STAGES.LEGAL_VERIFICATION,
|
||||
[TERMINATION_STAGES.NBH_EVALUATION]: TERMINATION_STAGES.DD_HEAD_REVIEW,
|
||||
[TERMINATION_STAGES.NBH_EVALUATION]: TERMINATION_STAGES.DD_LEAD_REVIEW,
|
||||
[TERMINATION_STAGES.SCN_ISSUED]: TERMINATION_STAGES.NBH_EVALUATION,
|
||||
[TERMINATION_STAGES.PERSONAL_HEARING]: TERMINATION_STAGES.SCN_ISSUED,
|
||||
[TERMINATION_STAGES.NBH_FINAL_APPROVAL]: TERMINATION_STAGES.PERSONAL_HEARING,
|
||||
|
||||
61
src/common/utils/terminationJointReviewRound.util.ts
Normal file
61
src/common/utils/terminationJointReviewRound.util.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { Op } from 'sequelize';
|
||||
import { TERMINATION_STAGES } from '../config/constants.js';
|
||||
|
||||
const norm = (s: string | undefined | null) =>
|
||||
String(s || '')
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
export const isScnResponseJointTargetStage = (targetStage: string | undefined | null): boolean => {
|
||||
const n = norm(targetStage);
|
||||
if (!n) return false;
|
||||
if (n === norm(TERMINATION_STAGES.PERSONAL_HEARING)) return true;
|
||||
if (n.includes('evaluation') && n.includes('scn') && n.includes('response')) return true;
|
||||
if (n.includes('personal hearing')) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isRbmJointTargetStage = (targetStage: string | undefined | null): boolean => {
|
||||
const n = norm(targetStage);
|
||||
return n.includes('rbm') && (n.includes('dd-zm') || n.includes('dd zm'));
|
||||
};
|
||||
|
||||
function isSendBackOrReconsiderTimelineAction(action: string | undefined | null): boolean {
|
||||
const a = norm(action);
|
||||
return (
|
||||
a.includes('sent back') ||
|
||||
a.includes('send back') ||
|
||||
a.includes('reconsider') ||
|
||||
a.includes('reconsideration')
|
||||
);
|
||||
}
|
||||
|
||||
export type JointRoundTimelineMode = 'scn_response_eval' | 'rbm_review';
|
||||
|
||||
/**
|
||||
* When a case is sent back / reconsidered to a joint stage, earlier PARTIAL_APPROVE rows must be ignored.
|
||||
* Uses workflow timeline entries (written on transition) — newest matching event wins.
|
||||
*/
|
||||
export function getJointRoundCutoffMsFromTimeline(
|
||||
timeline: unknown,
|
||||
mode: JointRoundTimelineMode
|
||||
): number | null {
|
||||
if (!Array.isArray(timeline) || timeline.length === 0) return null;
|
||||
const matcher = mode === 'scn_response_eval' ? isScnResponseJointTargetStage : isRbmJointTargetStage;
|
||||
const arr = timeline as any[];
|
||||
for (let i = arr.length - 1; i >= 0; i--) {
|
||||
const e = arr[i];
|
||||
if (!isSendBackOrReconsiderTimelineAction(e?.action)) continue;
|
||||
if (!matcher(e?.targetStage)) continue;
|
||||
const t = e?.timestamp != null ? new Date(e.timestamp).getTime() : NaN;
|
||||
if (!Number.isNaN(t)) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Only audit rows created at/after send-back / reconsider to this joint stage count for the current round. */
|
||||
export function buildJointRoundCreatedAtFilter(cutoffMs: number | null): { createdAt?: { [Op.gte]: Date } } {
|
||||
if (cutoffMs == null) return {};
|
||||
return { createdAt: { [Op.gte]: new Date(cutoffMs) } };
|
||||
}
|
||||
@ -247,7 +247,7 @@ export async function resolveNextActors(requestId: string, requestType: string,
|
||||
'Spares Clearance': [ROLES.SPARES_MANAGER],
|
||||
'Service Clearance': [ROLES.SERVICE_MANAGER],
|
||||
'Accounts Clearance': [ROLES.ACCOUNTS_MANAGER],
|
||||
'F&F Initiated': [ROLES.DD_ADMIN],
|
||||
'F&F Initiated': [ROLES.DD_ADMIN, ROLES.FINANCE, ROLES.DD_LEAD, ROLES.ZBH, ROLES.RBM],
|
||||
// SRS §7.5.2 — Legal acceptance letter upload triggers notification to DD-Admin + ASM
|
||||
'Resignation Legal Closure': [ROLES.DD_ADMIN, ROLES.ASM],
|
||||
|
||||
@ -340,8 +340,12 @@ export async function notifyStakeholdersOnTransition(
|
||||
const isDealer = u.id === metadata.dealerId;
|
||||
const isActingUser = u.fullName === metadata.actionUserFullName;
|
||||
|
||||
// Roles that should receive observer alerts on terminal events
|
||||
const isKeyObserverRole = ['DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin', 'SUPER_ADMIN', 'DD_ADMIN'].includes(u.roleCode || '');
|
||||
// Roles that should receive observer alerts on terminal events or F&F triggers
|
||||
const isKeyObserverRole = [
|
||||
'DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin',
|
||||
'SUPER_ADMIN', 'DD_ADMIN', 'Finance', 'FINANCE',
|
||||
'ZBH', 'RBM', 'DD-ZM'
|
||||
].includes(u.roleCode || '');
|
||||
const isASM = (u.roleCode || '').toUpperCase() === 'ASM';
|
||||
|
||||
// Phone for WhatsApp — directly on include'd user object
|
||||
|
||||
@ -278,17 +278,25 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
|
||||
// Apply Filters
|
||||
const { fromDate, toDate, search, status, location, state, isShortlisted, ddLeadShortlisted, assignedTo } = req.query;
|
||||
|
||||
// 1. Date Filters (createdAt range)
|
||||
if (fromDate || toDate) {
|
||||
whereClause.createdAt = {};
|
||||
if (fromDate) {
|
||||
const dateClause: any = {};
|
||||
if (fromDate && fromDate !== 'undefined') {
|
||||
const start = new Date(fromDate as string);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
whereClause.createdAt[Op.gte] = start;
|
||||
if (!isNaN(start.getTime())) {
|
||||
start.setHours(0, 0, 0, 0);
|
||||
dateClause[Op.gte] = start;
|
||||
}
|
||||
}
|
||||
if (toDate) {
|
||||
if (toDate && toDate !== 'undefined') {
|
||||
const end = new Date(toDate as string);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
whereClause.createdAt[Op.lte] = end;
|
||||
if (!isNaN(end.getTime())) {
|
||||
end.setHours(23, 59, 59, 999);
|
||||
dateClause[Op.lte] = end;
|
||||
}
|
||||
}
|
||||
if (Object.keys(dateClause).length > 0) {
|
||||
whereClause.createdAt = dateClause;
|
||||
}
|
||||
}
|
||||
|
||||
@ -313,9 +321,13 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
|
||||
};
|
||||
|
||||
// Pipeline Logic - Forced strict filtering by lifecycle stage
|
||||
// 3. Status Grouping Logic (Prospects vs Leads vs Workflow)
|
||||
const isShortlistedStr = String(isShortlisted ?? '').toLowerCase();
|
||||
const ddLeadShortlistedStr = String(ddLeadShortlisted ?? '').toLowerCase();
|
||||
|
||||
// Use a conditions array to prevent Op.or overwrites
|
||||
const conditions: any[] = [];
|
||||
|
||||
if (isShortlistedStr === 'false') {
|
||||
// Non-Opportunities (New Leads) MUST be 'Submitted', NOT shortlisted, and NOT linked to an opportunity
|
||||
whereClause.overallStatus = 'Submitted';
|
||||
@ -324,10 +336,12 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
|
||||
whereClause.opportunityId = null; // Strictly lead-gen records only
|
||||
} else if (isShortlistedStr === 'true' && ddLeadShortlistedStr !== 'true') {
|
||||
// Opportunities (Prospects): include anything explicitly shortlisted OR in an opportunity status
|
||||
whereClause[Op.or] = [
|
||||
{ isShortlisted: true },
|
||||
{ overallStatus: { [Op.in]: ['Questionnaire Pending', 'Questionnaire Completed', 'Shortlisted'] } }
|
||||
];
|
||||
conditions.push({
|
||||
[Op.or]: [
|
||||
{ isShortlisted: true },
|
||||
{ overallStatus: { [Op.in]: ['Questionnaire Pending', 'Questionnaire Completed', 'Shortlisted'] } }
|
||||
]
|
||||
});
|
||||
// However, must NOT be shortlisted by DD Lead yet (that moves them to Workflow)
|
||||
whereClause.ddLeadShortlisted = { [Op.ne]: true };
|
||||
|
||||
@ -345,6 +359,10 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
|
||||
applyStatusFilter(status);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
whereClause[Op.and] = [...(whereClause[Op.and] || []), ...conditions];
|
||||
}
|
||||
|
||||
if (location && location !== 'all') {
|
||||
whereClause.preferredLocation = location;
|
||||
}
|
||||
@ -1621,3 +1639,46 @@ export const bulkConvertToOpportunity = async (req: AuthRequest, res: Response)
|
||||
res.status(500).json({ success: false, message: 'Internal error during batch conversion' });
|
||||
}
|
||||
};
|
||||
|
||||
export const sendBulkReminders = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { applicationIds } = req.body;
|
||||
if (!applicationIds || !Array.isArray(applicationIds)) {
|
||||
return res.status(400).json({ success: false, message: 'Invalid application IDs' });
|
||||
}
|
||||
|
||||
const applications = await Application.findAll({
|
||||
where: { id: { [Op.in]: applicationIds } }
|
||||
});
|
||||
|
||||
for (const app of applications) {
|
||||
await NotificationService.sendQuestionnaireReminder(
|
||||
app.email,
|
||||
app.phone,
|
||||
app.applicantName,
|
||||
{
|
||||
location: app.preferredLocation,
|
||||
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/questionnaire/${app.applicationId}`
|
||||
}
|
||||
);
|
||||
|
||||
// Log Audit
|
||||
await safeAuditLogCreate({
|
||||
userId: req.user?.id || null,
|
||||
action: 'REMINDER_SENT',
|
||||
entityType: 'application',
|
||||
entityId: app.id,
|
||||
newData: {
|
||||
template: 'QUESTIONNAIRE_REMINDER',
|
||||
sentAt: new Date(),
|
||||
context: pickApplicationAuditContext(app)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `Reminders sent to ${applications.length} applicants` });
|
||||
} catch (error) {
|
||||
console.error('Send bulk reminders error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error sending reminders' });
|
||||
}
|
||||
};
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
assignArchitectureTeam, updateArchitectureStatus, generateDealerCodes,
|
||||
retriggerEvaluators, getDocumentConfigs, getDocumentConfigMetadata,
|
||||
createDocumentConfig, updateDocumentConfig, deleteDocumentConfig, updateApplication,
|
||||
exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity
|
||||
exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity, sendBulkReminders
|
||||
} from './onboarding.controller.js';
|
||||
import { authenticate } from '../../common/middleware/auth.js';
|
||||
import { checkRevocation } from '../../common/middleware/checkRevocation.js';
|
||||
@ -29,6 +29,7 @@ router.get('/applications/export-responses', exportApplicationResponses);
|
||||
router.get('/document-configs/metadata', getDocumentConfigMetadata);
|
||||
router.get('/document-configs', getDocumentConfigs);
|
||||
router.post('/applications/shortlist', bulkShortlist); // Existing route, updated to named import
|
||||
router.post('/applications/reminders', sendBulkReminders);
|
||||
router.get('/applications/:id', checkRevocation as any, getApplicationById);
|
||||
router.put('/applications/:id', checkRevocation as any, updateApplication);
|
||||
router.put('/applications/:id/status', checkRevocation as any, updateApplicationStatus);
|
||||
|
||||
@ -36,12 +36,28 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
|
||||
try {
|
||||
if (!req.user) throw new Error('Unauthorized');
|
||||
const { outletId, resignationType, lastOperationalDateSales, lastOperationalDateServices, reason, additionalInfo } = req.body;
|
||||
const dealerId = req.user.id;
|
||||
const userRole = req.user.roleCode || req.user.role;
|
||||
const isInternalInitiator = [ROLES.ASM, ROLES.DD_ADMIN, ROLES.SUPER_ADMIN].includes(userRole as any);
|
||||
|
||||
const outlet = await db.Outlet.findOne({ where: { id: outletId, dealerId } });
|
||||
if (!outlet) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({ success: false, message: 'Outlet not found or does not belong to you' });
|
||||
let dealerId: string;
|
||||
let outlet: any;
|
||||
|
||||
if (isInternalInitiator) {
|
||||
// Internal initiator (ASM/Admin) selects the outlet
|
||||
outlet = await db.Outlet.findOne({ where: { id: outletId } });
|
||||
if (!outlet) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({ success: false, message: 'Outlet not found' });
|
||||
}
|
||||
dealerId = outlet.dealerId;
|
||||
} else {
|
||||
// Dealer (Self-Service) initiates for their own outlet
|
||||
dealerId = req.user.id;
|
||||
outlet = await db.Outlet.findOne({ where: { id: outletId, dealerId } });
|
||||
if (!outlet) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({ success: false, message: 'Outlet not found or does not belong to you' });
|
||||
}
|
||||
}
|
||||
|
||||
const existingResignation = await db.Resignation.findOne({
|
||||
@ -75,10 +91,11 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
|
||||
documents: [],
|
||||
departmentalClearances: initialClearances,
|
||||
timeline: [{
|
||||
stage: 'Submitted',
|
||||
stage: 'Request Submitted',
|
||||
timestamp: new Date(),
|
||||
user: req.user.fullName,
|
||||
action: 'Resignation request submitted'
|
||||
action: isInternalInitiator ? 'Resignation initiated by ASM' : 'Resignation request submitted by dealer',
|
||||
remarks: reason || ''
|
||||
}]
|
||||
}, { transaction });
|
||||
|
||||
@ -87,7 +104,7 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
|
||||
userId: req.user.id,
|
||||
action: AUDIT_ACTIONS.CREATED,
|
||||
resignationId: resignation.id,
|
||||
remarks: 'Dealer submitted resignation request'
|
||||
remarks: isInternalInitiator ? 'ASM initiated resignation request' : 'Dealer submitted resignation request'
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
@ -390,20 +407,29 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
|
||||
return res.status(400).json({ success: false, message: 'Cannot move to next stage from current state' });
|
||||
}
|
||||
|
||||
// Guard before transition: F&F initiation is allowed only on/after LWD unless forced.
|
||||
// Guard before transition: F&F initiation is allowed only on/after LWD as per SRS §4.2.2.8
|
||||
let shouldTriggerFnF = false;
|
||||
if (nextStage === RESIGNATION_STAGES.FNF_INITIATED) {
|
||||
const today = new Date();
|
||||
const lwd = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
|
||||
const lwdString = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
|
||||
const { force } = req.body;
|
||||
|
||||
if (!force && lwd && today < new Date(lwd)) {
|
||||
const lwd = lwdString ? new Date(lwdString) : null;
|
||||
if (lwd) {
|
||||
// Clear time for date-only comparison
|
||||
today.setHours(0, 0, 0, 0);
|
||||
lwd.setHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
if (!force && lwd && today < lwd) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `F&F can only be initiated on or after the Last Working Day (${lwd}).`,
|
||||
message: `F&F settlement process is initiated only on the Last Working Day (${lwdString}) of the dealership.`,
|
||||
canForce: true
|
||||
});
|
||||
}
|
||||
shouldTriggerFnF = true;
|
||||
}
|
||||
|
||||
// Sequence guard: resignation can be marked completed only after F&F settlement is complete.
|
||||
@ -498,41 +524,12 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
|
||||
.catch(err => logger.error('Error syncing resignation completion to SAP:', err));
|
||||
}
|
||||
|
||||
if (nextStage === RESIGNATION_STAGES.FNF_INITIATED) {
|
||||
if (shouldTriggerFnF) {
|
||||
const existingFnF = await db.FnF.findOne({ where: { resignationId: resignation.id }, transaction });
|
||||
let fnfId = existingFnF?.id;
|
||||
|
||||
if (!existingFnF) {
|
||||
const dealerProfileId = (resignation as any).dealer?.dealerId;
|
||||
|
||||
// No seed line items or mock SAP amounts — totals stay 0 until users/scripts add FnF line items or department clearances.
|
||||
const fnf = await db.FnF.create({
|
||||
settlementId: await NomenclatureService.generateFnFId(),
|
||||
resignationId: resignation.id,
|
||||
outletId: resignation.outletId,
|
||||
dealerId: dealerProfileId, // Correctly using the Dealer model ID
|
||||
status: 'Initiated',
|
||||
totalReceivables: 0,
|
||||
totalPayables: 0,
|
||||
netAmount: 0
|
||||
}, { transaction });
|
||||
|
||||
const { FNF_DEPARTMENTS } = await import('../../common/config/constants.js');
|
||||
await db.FffClearance.bulkCreate(
|
||||
FNF_DEPARTMENTS.map(dept => ({
|
||||
fnfId: fnf.id,
|
||||
department: dept,
|
||||
status: 'Pending'
|
||||
})),
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
fnfId = fnf.id;
|
||||
}
|
||||
|
||||
// Always assign/sync Participants for F&F (Sub-application chat) to ensure robustness
|
||||
if (fnfId) {
|
||||
await ParticipantService.assignFnFParticipants(fnfId);
|
||||
const fnf = await ResignationWorkflowService.initiateFnF(resignation, req.user.id, transaction);
|
||||
// Assign/sync Participants for F&F (Sub-application chat) to ensure robustness
|
||||
await ParticipantService.assignFnFParticipants(fnf.id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1060,14 +1057,15 @@ export const updateResignationStatus = async (req: AuthRequest, res: Response, n
|
||||
const hasLegalStageDocument = await db.ResignationDocument.findOne({
|
||||
where: {
|
||||
resignationId: resignation.id,
|
||||
stage: RESIGNATION_STAGES.LEGAL
|
||||
stage: RESIGNATION_STAGES.LEGAL,
|
||||
documentType: 'Resignation Acceptance Letter'
|
||||
},
|
||||
attributes: ['id']
|
||||
});
|
||||
if (!hasLegalStageDocument) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Cannot trigger F&F. Legal-stage acceptance/communication document is required first.'
|
||||
message: 'Cannot trigger F&F. Resignation Acceptance Letter is required first.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,7 +16,11 @@ import ExternalMocksService from '../../common/utils/externalMocks.service.js';
|
||||
import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js';
|
||||
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
||||
import { ParticipantService } from '../../services/ParticipantService.js';
|
||||
import { getTerminationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js';
|
||||
import { getTerminationStatusForStage, normalizeClearanceStatus, getLegacyTerminationRowFixes } from '../../common/utils/offboardingStatus.js';
|
||||
import {
|
||||
buildJointRoundCreatedAtFilter,
|
||||
getJointRoundCutoffMsFromTimeline
|
||||
} from '../../common/utils/terminationJointReviewRound.util.js';
|
||||
import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
|
||||
import { OFFBOARDING_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js';
|
||||
import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js';
|
||||
@ -33,9 +37,21 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
|
||||
const transaction: Transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
if (!req.user) throw new Error('Unauthorized');
|
||||
|
||||
const allowedRoles = [ROLES.DD_LEAD, ROLES.ASM, ROLES.DD_ADMIN, ROLES.DD_AM, ROLES.SUPER_ADMIN];
|
||||
if (!allowedRoles.includes(req.user.roleCode as any)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Only DD Lead, ASM, DD Admin, or DD AM are authorized to initiate termination requests.'
|
||||
});
|
||||
}
|
||||
|
||||
const { dealerId, category, reason, proposedLwd, comments } = req.body;
|
||||
|
||||
const requestId = await NomenclatureService.generateTerminationId();
|
||||
const isUnethical = String(category).trim().toLowerCase().includes('unethical');
|
||||
const startStage = isUnethical ? TERMINATION_STAGES.DD_LEAD_REVIEW : TERMINATION_STAGES.RBM_REVIEW;
|
||||
|
||||
const termination = await db.TerminationRequest.create({
|
||||
requestId,
|
||||
dealerId,
|
||||
@ -44,15 +60,15 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
|
||||
proposedLwd,
|
||||
comments,
|
||||
initiatedBy: req.user.id,
|
||||
currentStage: TERMINATION_STAGES.RBM_REVIEW,
|
||||
status: getTerminationStatusForStage(TERMINATION_STAGES.RBM_REVIEW),
|
||||
progressPercentage: TerminationWorkflowService.calculateProgress(TERMINATION_STAGES.RBM_REVIEW),
|
||||
currentStage: startStage,
|
||||
status: getTerminationStatusForStage(startStage),
|
||||
progressPercentage: TerminationWorkflowService.calculateProgress(startStage),
|
||||
timeline: [{
|
||||
stage: 'Submitted',
|
||||
targetStage: TERMINATION_STAGES.RBM_REVIEW,
|
||||
targetStage: startStage,
|
||||
timestamp: new Date(),
|
||||
user: req.user.fullName,
|
||||
action: `Termination request initiated and forwarded to ${TERMINATION_STAGES.RBM_REVIEW}`,
|
||||
action: isUnethical ? 'Immediate escalation due to Unethical Practice' : `Termination request initiated and forwarded to ${startStage}`,
|
||||
remarks: comments
|
||||
}]
|
||||
}, { transaction });
|
||||
@ -70,8 +86,8 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
|
||||
ParticipantService.assignTerminationParticipants(termination.id)
|
||||
.catch(err => logger.error('Error assigning participants to termination:', err));
|
||||
|
||||
// SRS §4.3.2.1 — Notify RBM + DD-ZM that a new termination has been initiated
|
||||
const notifyOnCreateRoles = [ROLES.RBM, ROLES.DD_ZM];
|
||||
// SRS §4.3.2.1 — Notify appropriate stakeholders that a new termination has been initiated
|
||||
const notifyOnCreateRoles = isUnethical ? [ROLES.DD_LEAD] : [ROLES.RBM, ROLES.DD_ZM];
|
||||
for (const role of notifyOnCreateRoles) {
|
||||
const roleUsers = await db.User.findAll({ where: { roleCode: role } });
|
||||
for (const u of roleUsers) {
|
||||
@ -218,6 +234,14 @@ export const getTerminationById = async (req: AuthRequest, res: Response, next:
|
||||
if (!termination) {
|
||||
return res.status(404).json({ success: false, message: 'Termination request not found' });
|
||||
}
|
||||
|
||||
const legacyTerminationFixes = getLegacyTerminationRowFixes(termination as any);
|
||||
if (legacyTerminationFixes) {
|
||||
await termination.update(legacyTerminationFixes);
|
||||
(termination as any).setDataValue('currentStage', legacyTerminationFixes.currentStage ?? termination.currentStage);
|
||||
(termination as any).setDataValue('status', legacyTerminationFixes.status ?? termination.status);
|
||||
}
|
||||
|
||||
res.json({ success: true, termination });
|
||||
} catch (error) {
|
||||
logger.error('Error fetching termination:', error);
|
||||
@ -327,6 +351,17 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
||||
return res.status(404).json({ success: false, message: 'Termination not found' });
|
||||
}
|
||||
|
||||
const legacyTerminationFixes = getLegacyTerminationRowFixes(termination as any);
|
||||
if (legacyTerminationFixes) {
|
||||
await termination.update(legacyTerminationFixes, { transaction });
|
||||
if (legacyTerminationFixes.currentStage) {
|
||||
(termination as any).setDataValue('currentStage', legacyTerminationFixes.currentStage);
|
||||
}
|
||||
if (legacyTerminationFixes.status) {
|
||||
(termination as any).setDataValue('status', legacyTerminationFixes.status);
|
||||
}
|
||||
}
|
||||
|
||||
const fromStage = termination.currentStage;
|
||||
let approvedToStage: string | null = null;
|
||||
|
||||
@ -336,6 +371,27 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
||||
status: 'Rejected',
|
||||
remarks
|
||||
});
|
||||
} else if (action === OFFBOARDING_ACTIONS.HOLD) {
|
||||
// SRS §4.3.2.7 — Hold Decision (Pause temporarily); NBH may hold at evaluation or final approval
|
||||
const holdStages = [TERMINATION_STAGES.NBH_EVALUATION, TERMINATION_STAGES.NBH_FINAL_APPROVAL];
|
||||
if (!holdStages.includes(termination.currentStage as any) && req.user.roleCode !== ROLES.SUPER_ADMIN) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Hold action is only available at NBH Evaluation or NBH Final Approval stage.'
|
||||
});
|
||||
}
|
||||
await termination.update({ status: 'On Hold' }, { transaction });
|
||||
await db.TerminationAudit.create({
|
||||
userId: req.user.id,
|
||||
terminationRequestId: termination.id,
|
||||
action: 'ON_HOLD',
|
||||
remarks: remarks || 'Case placed on hold for further monitoring.',
|
||||
details: { stage: fromStage }
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
return res.json({ success: true, message: 'Termination case placed on hold.' });
|
||||
} else if (action === OFFBOARDING_ACTIONS.REVOKE) {
|
||||
// Validation: Remarks mandatory for Revoke
|
||||
const validation = validateOffboardingAction(action, remarks);
|
||||
@ -420,13 +476,17 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
||||
|
||||
// SRS §4.3.2.2 — JOINT APPROVAL LOGIC FOR RBM STAGE
|
||||
if (sourceStage === TERMINATION_STAGES.RBM_REVIEW && req.user.roleCode !== ROLES.SUPER_ADMIN) {
|
||||
const rbmRoundTime = buildJointRoundCreatedAtFilter(
|
||||
getJointRoundCutoffMsFromTimeline(termination.timeline, 'rbm_review')
|
||||
);
|
||||
// Prevent duplicate approval from same user
|
||||
const existingUserApproval = await db.TerminationAudit.findOne({
|
||||
where: {
|
||||
terminationRequestId: termination.id,
|
||||
userId: req.user.id,
|
||||
action: 'PARTIAL_APPROVE',
|
||||
'details.stage': sourceStage
|
||||
'details.stage': sourceStage,
|
||||
...rbmRoundTime
|
||||
},
|
||||
transaction
|
||||
});
|
||||
@ -452,7 +512,8 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
||||
where: {
|
||||
terminationRequestId: termination.id,
|
||||
action: 'PARTIAL_APPROVE',
|
||||
'details.stage': sourceStage
|
||||
'details.stage': sourceStage,
|
||||
...rbmRoundTime
|
||||
},
|
||||
transaction
|
||||
});
|
||||
@ -467,6 +528,7 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
||||
stage: sourceStage,
|
||||
timestamp: new Date(),
|
||||
user: req.user.fullName,
|
||||
role: req.user.roleCode,
|
||||
action: 'Partial Approved',
|
||||
remarks: remarks || `Partial approval recorded by ${req.user.roleCode}`
|
||||
}];
|
||||
@ -483,6 +545,71 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
||||
logger.info(`[TerminationController] Joint approval complete for ${termination.requestId}. Moving to ${nextStage}.`);
|
||||
}
|
||||
|
||||
// SRS §4.3.2.9 — JOINT APPROVAL LOGIC FOR SCN EVALUATION (PERSONAL HEARING STAGE)
|
||||
if (sourceStage === TERMINATION_STAGES.PERSONAL_HEARING && req.user.roleCode !== ROLES.SUPER_ADMIN) {
|
||||
const scnEvalAuditStages = [TERMINATION_STAGES.PERSONAL_HEARING, 'Personal Hearing'];
|
||||
const scnRoundTime = buildJointRoundCreatedAtFilter(
|
||||
getJointRoundCutoffMsFromTimeline(termination.timeline, 'scn_response_eval')
|
||||
);
|
||||
const existingUserApproval = await db.TerminationAudit.findOne({
|
||||
where: {
|
||||
terminationRequestId: termination.id,
|
||||
userId: req.user.id,
|
||||
action: 'PARTIAL_APPROVE',
|
||||
[Op.or]: scnEvalAuditStages.map((s) => ({ 'details.stage': s })),
|
||||
...scnRoundTime
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
if (existingUserApproval) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({ success: false, message: 'You have already recorded your approval for this stage.' });
|
||||
}
|
||||
|
||||
await db.TerminationAudit.create({
|
||||
userId: req.user.id,
|
||||
terminationRequestId: termination.id,
|
||||
action: 'PARTIAL_APPROVE',
|
||||
remarks: `SCN Response Review by ${req.user.roleCode}${remarks ? ': ' + remarks : ''}`,
|
||||
details: { roleCode: req.user.roleCode, stage: sourceStage }
|
||||
}, { transaction });
|
||||
|
||||
const requiredRoles = [ROLES.DD_LEAD, ROLES.ZBH, ROLES.RBM, ROLES.DD_HEAD];
|
||||
const partialLogs = await db.TerminationAudit.findAll({
|
||||
where: {
|
||||
terminationRequestId: termination.id,
|
||||
action: 'PARTIAL_APPROVE',
|
||||
[Op.or]: scnEvalAuditStages.map((s) => ({ 'details.stage': s })),
|
||||
...scnRoundTime
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
const approvedRoles = partialLogs.map(log => (log as any).details?.roleCode);
|
||||
const isComplete = requiredRoles.every(role => approvedRoles.includes(role));
|
||||
|
||||
if (!isComplete) {
|
||||
const partialTimeline = [...(termination.timeline || []), {
|
||||
stage: sourceStage,
|
||||
timestamp: new Date(),
|
||||
user: req.user.fullName,
|
||||
role: req.user.roleCode,
|
||||
action: 'Partial Approved (SCN Review)',
|
||||
remarks: remarks || `Review recorded by ${req.user.roleCode}`
|
||||
}];
|
||||
await termination.update({ timeline: partialTimeline }, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `Review recorded. Waiting for ${requiredRoles.filter(r => !approvedRoles.includes(r)).join(', ')} approval to proceed to NBH Final Approval.`,
|
||||
isPartial: true
|
||||
});
|
||||
}
|
||||
logger.info(`[TerminationController] SCN Joint evaluation complete for ${termination.requestId}. Moving to ${nextStage}.`);
|
||||
}
|
||||
|
||||
approvedToStage = nextStage;
|
||||
|
||||
await TerminationWorkflowService.transitionTermination(termination, nextStage, req.user.id, {
|
||||
@ -528,6 +655,11 @@ export const submitScnResponse = async (req: AuthRequest, res: Response, next: N
|
||||
const transaction: Transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
if (!req.user) throw new Error('Unauthorized');
|
||||
|
||||
const authorizedRoles = [ROLES.DD_ADMIN, ROLES.DD_LEAD, ROLES.LEGAL_ADMIN, ROLES.SUPER_ADMIN];
|
||||
if (!authorizedRoles.includes(req.user.roleCode as any)) {
|
||||
return res.status(403).json({ success: false, message: 'Direct SCN submission is restricted. Please submit your response to DD Admin.' });
|
||||
}
|
||||
const { terminationRequestId, responseBody, documents } = req.body;
|
||||
|
||||
const termination = await db.TerminationRequest.findByPk(terminationRequestId);
|
||||
@ -620,6 +752,11 @@ export const uploadScnResponse = async (req: AuthRequest, res: Response, next: N
|
||||
const transaction: Transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
if (!req.user) throw new Error('Unauthorized');
|
||||
|
||||
const authorizedRoles = [ROLES.DD_ADMIN, ROLES.DD_LEAD, ROLES.LEGAL_ADMIN, ROLES.SUPER_ADMIN];
|
||||
if (!authorizedRoles.includes(req.user.roleCode as any)) {
|
||||
return res.status(403).json({ success: false, message: 'Only DD Admin or DD Lead can upload the dealer SCN response.' });
|
||||
}
|
||||
const { id } = req.params;
|
||||
const { remarks } = req.body;
|
||||
const resolvedId = await resolveTerminationUuid(String(id));
|
||||
|
||||
@ -51,7 +51,7 @@ export class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Offload other channels to Job Queue (BullMQ)
|
||||
// 2. Offload other channels to Job Queue (BullMQ) or Send Synchronously if Redis is disabled
|
||||
const asyncChannels = channels.filter(c => c !== 'system');
|
||||
if (asyncChannels.length > 0) {
|
||||
if (process.env.ENABLE_REDIS === 'true') {
|
||||
@ -67,7 +67,18 @@ export class NotificationService {
|
||||
metadata
|
||||
});
|
||||
} else {
|
||||
console.log(`[Notification Service] Redis disabled. Skipping async channels: ${asyncChannels.join(', ')}`);
|
||||
console.log(`[Notification Service] Redis disabled. Sending ${asyncChannels.join(', ')} synchronously...`);
|
||||
// Fallback: Process immediately if queueing is disabled
|
||||
await this.processJob({
|
||||
userId,
|
||||
email,
|
||||
title,
|
||||
message,
|
||||
channels: asyncChannels,
|
||||
templateCode,
|
||||
placeholders,
|
||||
metadata
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import db from '../database/models/index.js';
|
||||
const { User } = db;
|
||||
import { RESIGNATION_STAGES, ROLES, REQUEST_TYPES } from '../common/config/constants.js';
|
||||
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 } from 'sequelize';
|
||||
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 {
|
||||
@ -165,4 +166,66 @@ export class ResignationWorkflowService {
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,6 +35,7 @@ export class TerminationWorkflowService {
|
||||
targetStage: targetStage,
|
||||
timestamp: new Date(),
|
||||
user: actor ? actor.fullName : 'System',
|
||||
role: actor ? actor.roleCode : null,
|
||||
action: action || `Approved to ${targetStage}`,
|
||||
remarks: remarks || ''
|
||||
};
|
||||
@ -279,7 +280,7 @@ export class TerminationWorkflowService {
|
||||
|
||||
return this.transitionTermination(termination, TERMINATION_STAGES.PERSONAL_HEARING, userId, {
|
||||
action: 'SCN_SUBMITTED',
|
||||
status: 'Personal Hearing Pending',
|
||||
status: 'SCN Response Evaluation Pending',
|
||||
remarks: 'Dealer response submitted'
|
||||
});
|
||||
}
|
||||
@ -300,7 +301,7 @@ export class TerminationWorkflowService {
|
||||
});
|
||||
|
||||
const nextStage = recommendation === 'Reject' ? TERMINATION_STAGES.REJECTED : TERMINATION_STAGES.NBH_FINAL_APPROVAL;
|
||||
const status = recommendation === 'Reject' ? 'Rejected after Hearing' : 'NBH Final Approval Pending';
|
||||
const status = recommendation === 'Reject' ? 'Rejected after Evaluation' : 'NBH Final Approval Pending';
|
||||
|
||||
return this.transitionTermination(termination, nextStage, userId, {
|
||||
action: `Hearing Recorded - ${recommendation}`,
|
||||
@ -324,14 +325,20 @@ export class TerminationWorkflowService {
|
||||
[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],
|
||||
[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 requiredRole = stageToRole[termination.currentStage];
|
||||
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);
|
||||
}
|
||||
|
||||
@ -160,13 +160,13 @@ async function run() {
|
||||
|
||||
const approvals = [
|
||||
{ stage: 'ASM', name: 'ASM', email: EMAILS.ASM, remarks: 'Verified physical assets and dealer intent. Recommended for resignation.' },
|
||||
{ stage: 'RBM', name: 'RBM', email: EMAILS.RBM, remarks: 'No active sales pipeline issues in the territory. Transition plan discussed.' },
|
||||
{ stage: 'RBM', name: 'DD-ZM', email: EMAILS.DD_ZM, remarks: 'Joint approval evaluated and verified by DD-ZM.' },
|
||||
{ stage: 'RBM + DD-ZM Review', name: 'RBM', email: EMAILS.RBM, remarks: 'No active sales pipeline issues in the territory. Transition plan discussed.' },
|
||||
{ stage: 'RBM + DD-ZM Review', name: 'DD-ZM', email: EMAILS.DD_ZM, remarks: 'Joint approval evaluated and verified by DD-ZM.' },
|
||||
{ stage: 'ZBH', name: 'ZBH', email: EMAILS.ZBH, remarks: 'Zone-level resource reallocation planned. Approved.' },
|
||||
{ stage: 'DD Lead', name: 'DD Lead', email: EMAILS.DD_LEAD, remarks: 'Dealer development criteria satisfied for exit.' },
|
||||
{ stage: 'NBH', name: 'NBH', email: EMAILS.NBH, remarks: 'HQ clearance granted. Proceed with F&F initiation.' },
|
||||
{ stage: 'DD Admin', name: 'DD Admin', email: EMAILS.DD_ADMIN, remarks: 'Administrative checks complete. Initiating termination of commercial contract.' },
|
||||
{ stage: 'Legal', name: 'Legal Admin', email: EMAILS.LEGAL, remarks: 'Legal documentation verified. No active litigation found.' }
|
||||
{ stage: 'Legal', name: 'Legal Admin', email: EMAILS.LEGAL, remarks: 'Legal documentation verified. No active litigation found.' },
|
||||
{ stage: 'DD Admin', name: 'DD Admin', email: EMAILS.DD_ADMIN, remarks: 'Administrative checks complete. Initiating termination of commercial contract.' }
|
||||
];
|
||||
|
||||
// Fetch resignation data to determine current stage for skipping
|
||||
@ -176,7 +176,7 @@ async function run() {
|
||||
console.log(`Current Stage: ${currentStage}`);
|
||||
|
||||
const stageOrder = [
|
||||
'ASM', 'RBM', 'ZBH', 'DD Lead', 'NBH', 'DD Admin', 'Legal', 'F&F Initiated', 'Completed'
|
||||
'Request Submitted', 'ASM', 'RBM + DD-ZM Review', 'ZBH', 'DD Lead', 'NBH', 'Legal', 'DD Admin', 'F&F Initiated', 'Completed'
|
||||
];
|
||||
|
||||
let startStageIndex = stageOrder.indexOf(currentStage) === -1 ? 0 : stageOrder.indexOf(currentStage);
|
||||
@ -190,6 +190,26 @@ async function run() {
|
||||
const actor = approvals[i];
|
||||
log(currentStep, `${actor.name} (${actor.email}) approving...`);
|
||||
const token = await login(actor.email);
|
||||
|
||||
// Special Case: Legal Admin must upload 'Resignation Acceptance Letter' before approving
|
||||
if (actor.stage === 'Legal') {
|
||||
log(currentStep, `[Legal] Uploading mandatory 'Resignation Acceptance Letter'...`);
|
||||
const formData = new FormData();
|
||||
const blob = new Blob(['Mock Acceptance Letter Content'], { type: 'text/plain' });
|
||||
formData.append('file', blob, 'Acceptance_Letter.txt');
|
||||
formData.append('documentType', 'Resignation Acceptance Letter');
|
||||
formData.append('stage', 'Legal');
|
||||
|
||||
const uploadRes = await fetch(`${BASE_URL}/self-service/resignations/${resignationId}/documents`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData
|
||||
});
|
||||
const uploadData = await uploadRes.json();
|
||||
if (!uploadRes.ok) throw new Error(`Document upload failed: ${JSON.stringify(uploadData)}`);
|
||||
log(currentStep, `[Legal] Document uploaded successfully.`);
|
||||
}
|
||||
|
||||
const res = await apiRequest(`/self-service/resignations/${resignationId}/approve`, 'PUT', {
|
||||
remarks: actor.remarks,
|
||||
force: true
|
||||
|
||||
@ -12,8 +12,10 @@ const EMAILS = {
|
||||
DD_ADMIN: 'lince@royalenfield.com',
|
||||
ASM: 'abhishek@royalenfield.com',
|
||||
RBM: 'manish@royalenfield.com',
|
||||
DD_ZM: 'piyush@royalenfield.com',
|
||||
ZBH: 'manav@royalenfield.com',
|
||||
DD_LEAD: 'jaya@royalenfield.com',
|
||||
DD_HEAD: 'ganesh@royalenfield.com',
|
||||
LEGAL: 'legal@royalenfield.com',
|
||||
NBH: 'yashwin@royalenfield.com',
|
||||
CCO: 'admin@royalenfield.com',
|
||||
@ -72,8 +74,10 @@ async function run() {
|
||||
console.log(`Targeting Dealer: ${targetDealer.legalName} (${targetDealer.id})`);
|
||||
|
||||
let terminationId = args.terminationId;
|
||||
const isUnethical = String(args.category || '').trim().toLowerCase().includes('unethical');
|
||||
|
||||
if (!terminationId) {
|
||||
console.log('[STEP 1] ASM Initiating Termination...');
|
||||
console.log('[STEP 1] Initiating Termination...');
|
||||
const asmToken = await login(EMAILS.ASM);
|
||||
const createRes = await apiRequest('/termination', 'POST', {
|
||||
dealerId: targetDealer.id,
|
||||
@ -83,7 +87,7 @@ async function run() {
|
||||
comments: 'Auto termination for testing flow ending with Legal Team, CCO, CEO.'
|
||||
}, asmToken);
|
||||
terminationId = createRes.termination.id;
|
||||
console.log(`[STEP 1] Termination Request Created. ID: ${terminationId}`);
|
||||
console.log(`[STEP 1] Termination Request Created. ID: ${terminationId}. Category: ${args.category || 'Performance'}`);
|
||||
} else {
|
||||
console.log(`[STEP 1] Resuming existing termination: ${terminationId}`);
|
||||
}
|
||||
@ -93,40 +97,49 @@ async function run() {
|
||||
console.log(`[INFO] Current stage before progression: ${currentStage}`);
|
||||
|
||||
const approvals = [
|
||||
{ name: 'RBM + DD-ZM Review', email: EMAILS.RBM, remarks: 'Performance concerns validated on-ground. Proceed with termination.' },
|
||||
{ name: 'ZBH Review', email: EMAILS.ZBH, remarks: 'Strategic decision aligned with regional growth targets. Approved.' },
|
||||
{ name: 'DD Lead Review', email: EMAILS.DD_LEAD, remarks: 'Contractual breaches documented. Verified.' },
|
||||
{ name: 'Legal Verification', email: EMAILS.LEGAL, remarks: 'Legal audit complete. Case is legally sound.' },
|
||||
{ name: 'DD Head Review', email: EMAILS.NBH, remarks: 'Strategic impact assessed. Proceeding with SCN approval.' },
|
||||
{ name: 'NBH Evaluation', email: EMAILS.NBH, remarks: 'Functional teams aligned. SCN to be issued.' },
|
||||
{ name: 'SCN Issued', email: EMAILS.NBH, remarks: 'Show Cause Notice formally dispatched.' },
|
||||
{ name: 'Personal Hearing Outcome', email: EMAILS.DD_LEAD, remarks: 'Hearing completed. Dealer defense not sufficient.' },
|
||||
{ name: 'NBH Final Approval', email: EMAILS.NBH, remarks: 'Final recommendation for termination sent to CEO.' },
|
||||
{ name: 'CCO Approval', email: EMAILS.CCO, remarks: 'Commercial impact assessed. Approved.' },
|
||||
{ name: 'CEO Final Approval', email: EMAILS.CEO, remarks: 'Final authorization granted. Issue termination letter.' },
|
||||
{ name: 'Legal Termination Letter', email: EMAILS.LEGAL, remarks: 'Termination letter shared via registered mail.' },
|
||||
{ name: 'Final Terminated Status', email: EMAILS.DD_ADMIN, remarks: 'Closure completed.' }
|
||||
{ stage: 'RBM + DD-ZM Review', actors: [{ email: EMAILS.RBM, remarks: 'Validated.' }, { email: EMAILS.DD_ZM, remarks: 'Confirmed.' }] },
|
||||
{ stage: 'ZBH Review', actors: [{ email: EMAILS.ZBH, remarks: 'Strategic decision aligned.' }] },
|
||||
{ stage: 'DD Lead Review', actors: [{ email: EMAILS.DD_LEAD, remarks: 'Breaches documented.' }] },
|
||||
{ stage: 'Legal Verification', actors: [{ email: EMAILS.LEGAL, remarks: 'Case is sound.' }] },
|
||||
{ stage: 'DD Head Review', actors: [{ email: EMAILS.DD_HEAD, remarks: 'Strategic impact assessed.' }] },
|
||||
{ stage: 'NBH Evaluation', actors: [{ email: EMAILS.NBH, remarks: 'Functional teams aligned.' }] },
|
||||
{ stage: 'Show Cause Notice (SCN)', actors: [{ email: EMAILS.DD_ADMIN, remarks: 'SCN Issued.' }] },
|
||||
{ stage: 'Personal Hearing', actors: [
|
||||
{ email: EMAILS.DD_LEAD, remarks: 'Hearing completed.' },
|
||||
{ email: EMAILS.ZBH, remarks: 'Review recorded.' },
|
||||
{ email: EMAILS.RBM, remarks: 'Review recorded.' },
|
||||
{ email: EMAILS.DD_HEAD, remarks: 'Review recorded.' }
|
||||
] },
|
||||
{ stage: 'NBH Final Approval', actors: [{ email: EMAILS.NBH, remarks: 'Final recommendation.' }] },
|
||||
{ stage: 'CCO Approval', actors: [{ email: EMAILS.CCO, remarks: 'Approved.' }] },
|
||||
{ stage: 'CEO Final Approval', actors: [{ email: EMAILS.CEO, remarks: 'Final authorization.' }] },
|
||||
{ stage: 'Legal - Termination Letter', actors: [{ email: EMAILS.LEGAL, remarks: 'Termination letter shared.' }] }
|
||||
];
|
||||
|
||||
|
||||
const stageOrder = [
|
||||
'Submitted', 'RBM + DD-ZM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'DD Head Review',
|
||||
'NBH Evaluation', 'Show Cause Notice', 'Personal Hearing', 'NBH Final Approval', 'CCO Approval',
|
||||
'NBH Evaluation', 'Show Cause Notice (SCN)', 'Personal Hearing', 'NBH Final Approval', 'CCO Approval',
|
||||
'CEO Final Approval', 'Legal - Termination Letter', 'Terminated'
|
||||
];
|
||||
const currentIndex = Math.max(0, stageOrder.indexOf(currentStage));
|
||||
const currentStepStart = 2 + currentIndex;
|
||||
let currentStep = currentStepStart;
|
||||
|
||||
for (let i = currentIndex; i < approvals.length; i++) {
|
||||
const actor = approvals[i];
|
||||
log(currentStep, `${actor.name} (${actor.email}) processing approval...`);
|
||||
const token = await login(actor.email);
|
||||
await apiRequest(`/termination/${terminationId}/status`, 'PUT', {
|
||||
action: 'approve',
|
||||
remarks: actor.remarks
|
||||
}, token);
|
||||
log(currentStep, `${actor.name} Result: SUCCESS`);
|
||||
// If Unethical, the skip-routing skips to DD Lead Review (index 3 in stageOrder)
|
||||
const startIndex = isUnethical ? 2 : Math.max(0, stageOrder.indexOf(currentStage));
|
||||
let currentStep = 2;
|
||||
|
||||
for (let i = startIndex; i < approvals.length; i++) {
|
||||
const step = approvals[i];
|
||||
log(currentStep, `Stage: ${step.stage} - Processing approvals...`);
|
||||
|
||||
for (const actor of step.actors) {
|
||||
const token = await login(actor.email);
|
||||
await apiRequest(`/termination/${terminationId}/status`, 'PUT', {
|
||||
action: 'approve',
|
||||
remarks: actor.remarks
|
||||
}, token);
|
||||
log(currentStep, `Actor ${actor.email} Result: SUCCESS`);
|
||||
await delay(100);
|
||||
}
|
||||
|
||||
currentStep++;
|
||||
await delay();
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user