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 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('settled'), 'Completed');
|
||||||
assert.equal(normalizeFnFStatus('finance approval'), 'Finance Approval');
|
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('Submitted'), 'Submitted');
|
||||||
assert.equal(getTerminationStatusForStage('Terminated'), 'Terminated');
|
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', 0), 'NOC Submitted');
|
||||||
assert.equal(normalizeClearanceStatus('Cleared', 100), 'Dues Pending');
|
assert.equal(normalizeClearanceStatus('Cleared', 100), 'Dues Pending');
|
||||||
assert.equal(normalizeClearanceStatus('Pending', 0), 'Pending');
|
assert.equal(normalizeClearanceStatus('Pending', 0), 'Pending');
|
||||||
|
|||||||
@ -182,8 +182,8 @@ export const TERMINATION_STAGES = {
|
|||||||
LEGAL_VERIFICATION: 'Legal Verification',
|
LEGAL_VERIFICATION: 'Legal Verification',
|
||||||
DD_HEAD_REVIEW: 'DD Head Review',
|
DD_HEAD_REVIEW: 'DD Head Review',
|
||||||
NBH_EVALUATION: 'NBH Evaluation',
|
NBH_EVALUATION: 'NBH Evaluation',
|
||||||
SCN_ISSUED: 'Show Cause Notice',
|
SCN_ISSUED: 'Show Cause Notice (SCN)',
|
||||||
PERSONAL_HEARING: 'Personal Hearing',
|
PERSONAL_HEARING: 'Evaluation of Dealer SCN Response',
|
||||||
NBH_FINAL_APPROVAL: 'NBH Final Approval',
|
NBH_FINAL_APPROVAL: 'NBH Final Approval',
|
||||||
CCO_APPROVAL: 'CCO Approval',
|
CCO_APPROVAL: 'CCO Approval',
|
||||||
CEO_APPROVAL: 'CEO Final Approval',
|
CEO_APPROVAL: 'CEO Final Approval',
|
||||||
@ -195,7 +195,7 @@ export const TERMINATION_STAGES = {
|
|||||||
// Resignation Stages
|
// Resignation Stages
|
||||||
export const RESIGNATION_STAGES = {
|
export const RESIGNATION_STAGES = {
|
||||||
ASM: 'ASM',
|
ASM: 'ASM',
|
||||||
RBM: 'RBM',
|
RBM: 'RBM + DD-ZM Review',
|
||||||
ZBH: 'ZBH',
|
ZBH: 'ZBH',
|
||||||
DD_LEAD: 'DD Lead',
|
DD_LEAD: 'DD Lead',
|
||||||
NBH: 'NBH',
|
NBH: 'NBH',
|
||||||
@ -493,7 +493,7 @@ export const RESIGNATION_DOCUMENT_TYPES = [
|
|||||||
'Resignation Letter',
|
'Resignation Letter',
|
||||||
'Dealer Undertaking',
|
'Dealer Undertaking',
|
||||||
'Approval Note',
|
'Approval Note',
|
||||||
'Legal Communication',
|
'Resignation Acceptance Letter',
|
||||||
'Handover Document',
|
'Handover Document',
|
||||||
'Settlement Supporting Document',
|
'Settlement Supporting Document',
|
||||||
'PPT Presentation',
|
'PPT Presentation',
|
||||||
@ -501,6 +501,7 @@ export const RESIGNATION_DOCUMENT_TYPES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const RESIGNATION_DOCUMENT_STAGES = [
|
export const RESIGNATION_DOCUMENT_STAGES = [
|
||||||
|
'Initiation',
|
||||||
'ASM',
|
'ASM',
|
||||||
'RBM',
|
'RBM',
|
||||||
'ZBH',
|
'ZBH',
|
||||||
@ -558,7 +559,8 @@ export const OFFBOARDING_ACTIONS = {
|
|||||||
PUSH_FNF: 'pushfnf',
|
PUSH_FNF: 'pushfnf',
|
||||||
RECONSIDER: 'reconsider',
|
RECONSIDER: 'reconsider',
|
||||||
ISSUE_SCN: 'issueSCN',
|
ISSUE_SCN: 'issueSCN',
|
||||||
SCN_RESPONSE: 'scnResponse'
|
SCN_RESPONSE: 'scnResponse',
|
||||||
|
HOLD: 'hold'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Module List for Document Management
|
// 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)
|
// Process Stages per Module (Source of Truth for Checklists)
|
||||||
export const STAGES_MAP = {
|
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'],
|
'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'],
|
'RELOCATION': ['Initiated', 'ASM Review', 'ZM Review', 'ZBH Review', 'Completed'],
|
||||||
'CONSTITUTIONAL_CHANGE': ['Draft', 'Legal Review', 'Approved'],
|
'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']
|
'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 => {
|
export const getResignationStatusForStage = (stage: string): string => {
|
||||||
switch (stage) {
|
switch (stage) {
|
||||||
case RESIGNATION_STAGES.ASM:
|
|
||||||
case RESIGNATION_STAGES.RBM:
|
case RESIGNATION_STAGES.RBM:
|
||||||
|
return RESIGNATION_STAGES.RBM; // It already contains "Review"
|
||||||
|
case RESIGNATION_STAGES.ASM:
|
||||||
case RESIGNATION_STAGES.ZBH:
|
case RESIGNATION_STAGES.ZBH:
|
||||||
case RESIGNATION_STAGES.DD_LEAD:
|
case RESIGNATION_STAGES.DD_LEAD:
|
||||||
case RESIGNATION_STAGES.NBH:
|
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 => {
|
export const normalizeClearanceStatus = (status: string | null | undefined, amount: number): string => {
|
||||||
const normalizedAmount = Math.abs(Number(amount) || 0);
|
const normalizedAmount = Math.abs(Number(amount) || 0);
|
||||||
const value = (status || '').toLowerCase();
|
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.DD_LEAD_REVIEW]: TERMINATION_STAGES.ZBH_REVIEW,
|
||||||
[TERMINATION_STAGES.LEGAL_VERIFICATION]: TERMINATION_STAGES.DD_LEAD_REVIEW,
|
[TERMINATION_STAGES.LEGAL_VERIFICATION]: TERMINATION_STAGES.DD_LEAD_REVIEW,
|
||||||
[TERMINATION_STAGES.DD_HEAD_REVIEW]: TERMINATION_STAGES.LEGAL_VERIFICATION,
|
[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.SCN_ISSUED]: TERMINATION_STAGES.NBH_EVALUATION,
|
||||||
[TERMINATION_STAGES.PERSONAL_HEARING]: TERMINATION_STAGES.SCN_ISSUED,
|
[TERMINATION_STAGES.PERSONAL_HEARING]: TERMINATION_STAGES.SCN_ISSUED,
|
||||||
[TERMINATION_STAGES.NBH_FINAL_APPROVAL]: TERMINATION_STAGES.PERSONAL_HEARING,
|
[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],
|
'Spares Clearance': [ROLES.SPARES_MANAGER],
|
||||||
'Service Clearance': [ROLES.SERVICE_MANAGER],
|
'Service Clearance': [ROLES.SERVICE_MANAGER],
|
||||||
'Accounts Clearance': [ROLES.ACCOUNTS_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
|
// SRS §7.5.2 — Legal acceptance letter upload triggers notification to DD-Admin + ASM
|
||||||
'Resignation Legal Closure': [ROLES.DD_ADMIN, ROLES.ASM],
|
'Resignation Legal Closure': [ROLES.DD_ADMIN, ROLES.ASM],
|
||||||
|
|
||||||
@ -340,8 +340,12 @@ export async function notifyStakeholdersOnTransition(
|
|||||||
const isDealer = u.id === metadata.dealerId;
|
const isDealer = u.id === metadata.dealerId;
|
||||||
const isActingUser = u.fullName === metadata.actionUserFullName;
|
const isActingUser = u.fullName === metadata.actionUserFullName;
|
||||||
|
|
||||||
// Roles that should receive observer alerts on terminal events
|
// 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'].includes(u.roleCode || '');
|
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';
|
const isASM = (u.roleCode || '').toUpperCase() === 'ASM';
|
||||||
|
|
||||||
// Phone for WhatsApp — directly on include'd user object
|
// Phone for WhatsApp — directly on include'd user object
|
||||||
|
|||||||
@ -278,17 +278,25 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
|
|||||||
// Apply Filters
|
// Apply Filters
|
||||||
const { fromDate, toDate, search, status, location, state, isShortlisted, ddLeadShortlisted, assignedTo } = req.query;
|
const { fromDate, toDate, search, status, location, state, isShortlisted, ddLeadShortlisted, assignedTo } = req.query;
|
||||||
|
|
||||||
|
// 1. Date Filters (createdAt range)
|
||||||
if (fromDate || toDate) {
|
if (fromDate || toDate) {
|
||||||
whereClause.createdAt = {};
|
const dateClause: any = {};
|
||||||
if (fromDate) {
|
if (fromDate && fromDate !== 'undefined') {
|
||||||
const start = new Date(fromDate as string);
|
const start = new Date(fromDate as string);
|
||||||
start.setHours(0, 0, 0, 0);
|
if (!isNaN(start.getTime())) {
|
||||||
whereClause.createdAt[Op.gte] = start;
|
start.setHours(0, 0, 0, 0);
|
||||||
|
dateClause[Op.gte] = start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (toDate) {
|
if (toDate && toDate !== 'undefined') {
|
||||||
const end = new Date(toDate as string);
|
const end = new Date(toDate as string);
|
||||||
end.setHours(23, 59, 59, 999);
|
if (!isNaN(end.getTime())) {
|
||||||
whereClause.createdAt[Op.lte] = end;
|
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
|
// Pipeline Logic - Forced strict filtering by lifecycle stage
|
||||||
|
// 3. Status Grouping Logic (Prospects vs Leads vs Workflow)
|
||||||
const isShortlistedStr = String(isShortlisted ?? '').toLowerCase();
|
const isShortlistedStr = String(isShortlisted ?? '').toLowerCase();
|
||||||
const ddLeadShortlistedStr = String(ddLeadShortlisted ?? '').toLowerCase();
|
const ddLeadShortlistedStr = String(ddLeadShortlisted ?? '').toLowerCase();
|
||||||
|
|
||||||
|
// Use a conditions array to prevent Op.or overwrites
|
||||||
|
const conditions: any[] = [];
|
||||||
|
|
||||||
if (isShortlistedStr === 'false') {
|
if (isShortlistedStr === 'false') {
|
||||||
// Non-Opportunities (New Leads) MUST be 'Submitted', NOT shortlisted, and NOT linked to an opportunity
|
// Non-Opportunities (New Leads) MUST be 'Submitted', NOT shortlisted, and NOT linked to an opportunity
|
||||||
whereClause.overallStatus = 'Submitted';
|
whereClause.overallStatus = 'Submitted';
|
||||||
@ -324,10 +336,12 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
|
|||||||
whereClause.opportunityId = null; // Strictly lead-gen records only
|
whereClause.opportunityId = null; // Strictly lead-gen records only
|
||||||
} else if (isShortlistedStr === 'true' && ddLeadShortlistedStr !== 'true') {
|
} else if (isShortlistedStr === 'true' && ddLeadShortlistedStr !== 'true') {
|
||||||
// Opportunities (Prospects): include anything explicitly shortlisted OR in an opportunity status
|
// Opportunities (Prospects): include anything explicitly shortlisted OR in an opportunity status
|
||||||
whereClause[Op.or] = [
|
conditions.push({
|
||||||
{ isShortlisted: true },
|
[Op.or]: [
|
||||||
{ overallStatus: { [Op.in]: ['Questionnaire Pending', 'Questionnaire Completed', 'Shortlisted'] } }
|
{ isShortlisted: true },
|
||||||
];
|
{ overallStatus: { [Op.in]: ['Questionnaire Pending', 'Questionnaire Completed', 'Shortlisted'] } }
|
||||||
|
]
|
||||||
|
});
|
||||||
// However, must NOT be shortlisted by DD Lead yet (that moves them to Workflow)
|
// However, must NOT be shortlisted by DD Lead yet (that moves them to Workflow)
|
||||||
whereClause.ddLeadShortlisted = { [Op.ne]: true };
|
whereClause.ddLeadShortlisted = { [Op.ne]: true };
|
||||||
|
|
||||||
@ -345,6 +359,10 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
|
|||||||
applyStatusFilter(status);
|
applyStatusFilter(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
whereClause[Op.and] = [...(whereClause[Op.and] || []), ...conditions];
|
||||||
|
}
|
||||||
|
|
||||||
if (location && location !== 'all') {
|
if (location && location !== 'all') {
|
||||||
whereClause.preferredLocation = location;
|
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' });
|
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,
|
assignArchitectureTeam, updateArchitectureStatus, generateDealerCodes,
|
||||||
retriggerEvaluators, getDocumentConfigs, getDocumentConfigMetadata,
|
retriggerEvaluators, getDocumentConfigs, getDocumentConfigMetadata,
|
||||||
createDocumentConfig, updateDocumentConfig, deleteDocumentConfig, updateApplication,
|
createDocumentConfig, updateDocumentConfig, deleteDocumentConfig, updateApplication,
|
||||||
exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity
|
exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity, sendBulkReminders
|
||||||
} from './onboarding.controller.js';
|
} from './onboarding.controller.js';
|
||||||
import { authenticate } from '../../common/middleware/auth.js';
|
import { authenticate } from '../../common/middleware/auth.js';
|
||||||
import { checkRevocation } from '../../common/middleware/checkRevocation.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/metadata', getDocumentConfigMetadata);
|
||||||
router.get('/document-configs', getDocumentConfigs);
|
router.get('/document-configs', getDocumentConfigs);
|
||||||
router.post('/applications/shortlist', bulkShortlist); // Existing route, updated to named import
|
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.get('/applications/:id', checkRevocation as any, getApplicationById);
|
||||||
router.put('/applications/:id', checkRevocation as any, updateApplication);
|
router.put('/applications/:id', checkRevocation as any, updateApplication);
|
||||||
router.put('/applications/:id/status', checkRevocation as any, updateApplicationStatus);
|
router.put('/applications/:id/status', checkRevocation as any, updateApplicationStatus);
|
||||||
|
|||||||
@ -36,12 +36,28 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
|
|||||||
try {
|
try {
|
||||||
if (!req.user) throw new Error('Unauthorized');
|
if (!req.user) throw new Error('Unauthorized');
|
||||||
const { outletId, resignationType, lastOperationalDateSales, lastOperationalDateServices, reason, additionalInfo } = req.body;
|
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 } });
|
let dealerId: string;
|
||||||
if (!outlet) {
|
let outlet: any;
|
||||||
await transaction.rollback();
|
|
||||||
return res.status(404).json({ success: false, message: 'Outlet not found or does not belong to you' });
|
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({
|
const existingResignation = await db.Resignation.findOne({
|
||||||
@ -75,10 +91,11 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
|
|||||||
documents: [],
|
documents: [],
|
||||||
departmentalClearances: initialClearances,
|
departmentalClearances: initialClearances,
|
||||||
timeline: [{
|
timeline: [{
|
||||||
stage: 'Submitted',
|
stage: 'Request Submitted',
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
user: req.user.fullName,
|
user: req.user.fullName,
|
||||||
action: 'Resignation request submitted'
|
action: isInternalInitiator ? 'Resignation initiated by ASM' : 'Resignation request submitted by dealer',
|
||||||
|
remarks: reason || ''
|
||||||
}]
|
}]
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
|
|
||||||
@ -87,7 +104,7 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
|
|||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
action: AUDIT_ACTIONS.CREATED,
|
action: AUDIT_ACTIONS.CREATED,
|
||||||
resignationId: resignation.id,
|
resignationId: resignation.id,
|
||||||
remarks: 'Dealer submitted resignation request'
|
remarks: isInternalInitiator ? 'ASM initiated resignation request' : 'Dealer submitted resignation request'
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
|
|
||||||
await transaction.commit();
|
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' });
|
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) {
|
if (nextStage === RESIGNATION_STAGES.FNF_INITIATED) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const lwd = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
|
const lwdString = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
|
||||||
const { force } = req.body;
|
const { force } = req.body;
|
||||||
|
|
||||||
|
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 < new Date(lwd)) {
|
if (!force && lwd && today < lwd) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
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
|
canForce: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
shouldTriggerFnF = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sequence guard: resignation can be marked completed only after F&F settlement is complete.
|
// 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));
|
.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 });
|
const existingFnF = await db.FnF.findOne({ where: { resignationId: resignation.id }, transaction });
|
||||||
let fnfId = existingFnF?.id;
|
|
||||||
|
|
||||||
if (!existingFnF) {
|
if (!existingFnF) {
|
||||||
const dealerProfileId = (resignation as any).dealer?.dealerId;
|
const fnf = await ResignationWorkflowService.initiateFnF(resignation, req.user.id, transaction);
|
||||||
|
// Assign/sync Participants for F&F (Sub-application chat) to ensure robustness
|
||||||
// No seed line items or mock SAP amounts — totals stay 0 until users/scripts add FnF line items or department clearances.
|
await ParticipantService.assignFnFParticipants(fnf.id);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1060,14 +1057,15 @@ export const updateResignationStatus = async (req: AuthRequest, res: Response, n
|
|||||||
const hasLegalStageDocument = await db.ResignationDocument.findOne({
|
const hasLegalStageDocument = await db.ResignationDocument.findOne({
|
||||||
where: {
|
where: {
|
||||||
resignationId: resignation.id,
|
resignationId: resignation.id,
|
||||||
stage: RESIGNATION_STAGES.LEGAL
|
stage: RESIGNATION_STAGES.LEGAL,
|
||||||
|
documentType: 'Resignation Acceptance Letter'
|
||||||
},
|
},
|
||||||
attributes: ['id']
|
attributes: ['id']
|
||||||
});
|
});
|
||||||
if (!hasLegalStageDocument) {
|
if (!hasLegalStageDocument) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
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 { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js';
|
||||||
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
||||||
import { ParticipantService } from '../../services/ParticipantService.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 { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
|
||||||
import { OFFBOARDING_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js';
|
import { OFFBOARDING_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js';
|
||||||
import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.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();
|
const transaction: Transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
if (!req.user) throw new Error('Unauthorized');
|
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 { dealerId, category, reason, proposedLwd, comments } = req.body;
|
||||||
|
|
||||||
const requestId = await NomenclatureService.generateTerminationId();
|
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({
|
const termination = await db.TerminationRequest.create({
|
||||||
requestId,
|
requestId,
|
||||||
dealerId,
|
dealerId,
|
||||||
@ -44,15 +60,15 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
|
|||||||
proposedLwd,
|
proposedLwd,
|
||||||
comments,
|
comments,
|
||||||
initiatedBy: req.user.id,
|
initiatedBy: req.user.id,
|
||||||
currentStage: TERMINATION_STAGES.RBM_REVIEW,
|
currentStage: startStage,
|
||||||
status: getTerminationStatusForStage(TERMINATION_STAGES.RBM_REVIEW),
|
status: getTerminationStatusForStage(startStage),
|
||||||
progressPercentage: TerminationWorkflowService.calculateProgress(TERMINATION_STAGES.RBM_REVIEW),
|
progressPercentage: TerminationWorkflowService.calculateProgress(startStage),
|
||||||
timeline: [{
|
timeline: [{
|
||||||
stage: 'Submitted',
|
stage: 'Submitted',
|
||||||
targetStage: TERMINATION_STAGES.RBM_REVIEW,
|
targetStage: startStage,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
user: req.user.fullName,
|
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
|
remarks: comments
|
||||||
}]
|
}]
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
@ -70,8 +86,8 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
|
|||||||
ParticipantService.assignTerminationParticipants(termination.id)
|
ParticipantService.assignTerminationParticipants(termination.id)
|
||||||
.catch(err => logger.error('Error assigning participants to termination:', err));
|
.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
|
// SRS §4.3.2.1 — Notify appropriate stakeholders that a new termination has been initiated
|
||||||
const notifyOnCreateRoles = [ROLES.RBM, ROLES.DD_ZM];
|
const notifyOnCreateRoles = isUnethical ? [ROLES.DD_LEAD] : [ROLES.RBM, ROLES.DD_ZM];
|
||||||
for (const role of notifyOnCreateRoles) {
|
for (const role of notifyOnCreateRoles) {
|
||||||
const roleUsers = await db.User.findAll({ where: { roleCode: role } });
|
const roleUsers = await db.User.findAll({ where: { roleCode: role } });
|
||||||
for (const u of roleUsers) {
|
for (const u of roleUsers) {
|
||||||
@ -218,6 +234,14 @@ export const getTerminationById = async (req: AuthRequest, res: Response, next:
|
|||||||
if (!termination) {
|
if (!termination) {
|
||||||
return res.status(404).json({ success: false, message: 'Termination request not found' });
|
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 });
|
res.json({ success: true, termination });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error fetching termination:', 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' });
|
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;
|
const fromStage = termination.currentStage;
|
||||||
let approvedToStage: string | null = null;
|
let approvedToStage: string | null = null;
|
||||||
|
|
||||||
@ -336,6 +371,27 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
|||||||
status: 'Rejected',
|
status: 'Rejected',
|
||||||
remarks
|
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) {
|
} else if (action === OFFBOARDING_ACTIONS.REVOKE) {
|
||||||
// Validation: Remarks mandatory for Revoke
|
// Validation: Remarks mandatory for Revoke
|
||||||
const validation = validateOffboardingAction(action, remarks);
|
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
|
// SRS §4.3.2.2 — JOINT APPROVAL LOGIC FOR RBM STAGE
|
||||||
if (sourceStage === TERMINATION_STAGES.RBM_REVIEW && req.user.roleCode !== ROLES.SUPER_ADMIN) {
|
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
|
// Prevent duplicate approval from same user
|
||||||
const existingUserApproval = await db.TerminationAudit.findOne({
|
const existingUserApproval = await db.TerminationAudit.findOne({
|
||||||
where: {
|
where: {
|
||||||
terminationRequestId: termination.id,
|
terminationRequestId: termination.id,
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
action: 'PARTIAL_APPROVE',
|
action: 'PARTIAL_APPROVE',
|
||||||
'details.stage': sourceStage
|
'details.stage': sourceStage,
|
||||||
|
...rbmRoundTime
|
||||||
},
|
},
|
||||||
transaction
|
transaction
|
||||||
});
|
});
|
||||||
@ -452,7 +512,8 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
|||||||
where: {
|
where: {
|
||||||
terminationRequestId: termination.id,
|
terminationRequestId: termination.id,
|
||||||
action: 'PARTIAL_APPROVE',
|
action: 'PARTIAL_APPROVE',
|
||||||
'details.stage': sourceStage
|
'details.stage': sourceStage,
|
||||||
|
...rbmRoundTime
|
||||||
},
|
},
|
||||||
transaction
|
transaction
|
||||||
});
|
});
|
||||||
@ -467,6 +528,7 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
|||||||
stage: sourceStage,
|
stage: sourceStage,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
user: req.user.fullName,
|
user: req.user.fullName,
|
||||||
|
role: req.user.roleCode,
|
||||||
action: 'Partial Approved',
|
action: 'Partial Approved',
|
||||||
remarks: remarks || `Partial approval recorded by ${req.user.roleCode}`
|
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}.`);
|
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;
|
approvedToStage = nextStage;
|
||||||
|
|
||||||
await TerminationWorkflowService.transitionTermination(termination, nextStage, req.user.id, {
|
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();
|
const transaction: Transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
if (!req.user) throw new Error('Unauthorized');
|
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 { terminationRequestId, responseBody, documents } = req.body;
|
||||||
|
|
||||||
const termination = await db.TerminationRequest.findByPk(terminationRequestId);
|
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();
|
const transaction: Transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
if (!req.user) throw new Error('Unauthorized');
|
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 { id } = req.params;
|
||||||
const { remarks } = req.body;
|
const { remarks } = req.body;
|
||||||
const resolvedId = await resolveTerminationUuid(String(id));
|
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');
|
const asyncChannels = channels.filter(c => c !== 'system');
|
||||||
if (asyncChannels.length > 0) {
|
if (asyncChannels.length > 0) {
|
||||||
if (process.env.ENABLE_REDIS === 'true') {
|
if (process.env.ENABLE_REDIS === 'true') {
|
||||||
@ -67,7 +67,18 @@ export class NotificationService {
|
|||||||
metadata
|
metadata
|
||||||
});
|
});
|
||||||
} else {
|
} 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';
|
import db from '../database/models/index.js';
|
||||||
const { User } = db;
|
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 { getResignationStatusForStage } from '../common/utils/offboardingStatus.js';
|
||||||
import { NotificationService } from './NotificationService.js';
|
import { NotificationService } from './NotificationService.js';
|
||||||
import { Op } from 'sequelize';
|
import { Op, Transaction } from 'sequelize';
|
||||||
import logger from '../common/utils/logger.js';
|
import logger from '../common/utils/logger.js';
|
||||||
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
||||||
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
||||||
|
import { NomenclatureService } from '../common/utils/nomenclature.js';
|
||||||
|
|
||||||
|
|
||||||
export class ResignationWorkflowService {
|
export class ResignationWorkflowService {
|
||||||
@ -165,4 +166,66 @@ export class ResignationWorkflowService {
|
|||||||
}
|
}
|
||||||
return user.roleCode === requiredRole;
|
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,
|
targetStage: targetStage,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
user: actor ? actor.fullName : 'System',
|
user: actor ? actor.fullName : 'System',
|
||||||
|
role: actor ? actor.roleCode : null,
|
||||||
action: action || `Approved to ${targetStage}`,
|
action: action || `Approved to ${targetStage}`,
|
||||||
remarks: remarks || ''
|
remarks: remarks || ''
|
||||||
};
|
};
|
||||||
@ -279,7 +280,7 @@ export class TerminationWorkflowService {
|
|||||||
|
|
||||||
return this.transitionTermination(termination, TERMINATION_STAGES.PERSONAL_HEARING, userId, {
|
return this.transitionTermination(termination, TERMINATION_STAGES.PERSONAL_HEARING, userId, {
|
||||||
action: 'SCN_SUBMITTED',
|
action: 'SCN_SUBMITTED',
|
||||||
status: 'Personal Hearing Pending',
|
status: 'SCN Response Evaluation Pending',
|
||||||
remarks: 'Dealer response submitted'
|
remarks: 'Dealer response submitted'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -300,7 +301,7 @@ export class TerminationWorkflowService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const nextStage = recommendation === 'Reject' ? TERMINATION_STAGES.REJECTED : TERMINATION_STAGES.NBH_FINAL_APPROVAL;
|
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, {
|
return this.transitionTermination(termination, nextStage, userId, {
|
||||||
action: `Hearing Recorded - ${recommendation}`,
|
action: `Hearing Recorded - ${recommendation}`,
|
||||||
@ -324,14 +325,20 @@ export class TerminationWorkflowService {
|
|||||||
[TERMINATION_STAGES.DD_HEAD_REVIEW]: ROLES.DD_HEAD,
|
[TERMINATION_STAGES.DD_HEAD_REVIEW]: ROLES.DD_HEAD,
|
||||||
[TERMINATION_STAGES.NBH_EVALUATION]: ROLES.NBH,
|
[TERMINATION_STAGES.NBH_EVALUATION]: ROLES.NBH,
|
||||||
[TERMINATION_STAGES.SCN_ISSUED]: [ROLES.LEGAL_ADMIN, ROLES.DD_ADMIN],
|
[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.NBH_FINAL_APPROVAL]: ROLES.NBH,
|
||||||
[TERMINATION_STAGES.CCO_APPROVAL]: ROLES.CCO,
|
[TERMINATION_STAGES.CCO_APPROVAL]: ROLES.CCO,
|
||||||
[TERMINATION_STAGES.CEO_APPROVAL]: ROLES.CEO,
|
[TERMINATION_STAGES.CEO_APPROVAL]: ROLES.CEO,
|
||||||
[TERMINATION_STAGES.LEGAL_LETTER]: ROLES.LEGAL_ADMIN
|
[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)) {
|
if (Array.isArray(requiredRole)) {
|
||||||
return requiredRole.includes(user.roleCode);
|
return requiredRole.includes(user.roleCode);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -160,13 +160,13 @@ async function run() {
|
|||||||
|
|
||||||
const approvals = [
|
const approvals = [
|
||||||
{ stage: 'ASM', name: 'ASM', email: EMAILS.ASM, remarks: 'Verified physical assets and dealer intent. Recommended for resignation.' },
|
{ 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 + DD-ZM Review', 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: '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: '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: '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: '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
|
// Fetch resignation data to determine current stage for skipping
|
||||||
@ -176,7 +176,7 @@ async function run() {
|
|||||||
console.log(`Current Stage: ${currentStage}`);
|
console.log(`Current Stage: ${currentStage}`);
|
||||||
|
|
||||||
const stageOrder = [
|
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);
|
let startStageIndex = stageOrder.indexOf(currentStage) === -1 ? 0 : stageOrder.indexOf(currentStage);
|
||||||
@ -190,6 +190,26 @@ async function run() {
|
|||||||
const actor = approvals[i];
|
const actor = approvals[i];
|
||||||
log(currentStep, `${actor.name} (${actor.email}) approving...`);
|
log(currentStep, `${actor.name} (${actor.email}) approving...`);
|
||||||
const token = await login(actor.email);
|
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', {
|
const res = await apiRequest(`/self-service/resignations/${resignationId}/approve`, 'PUT', {
|
||||||
remarks: actor.remarks,
|
remarks: actor.remarks,
|
||||||
force: true
|
force: true
|
||||||
|
|||||||
@ -12,8 +12,10 @@ const EMAILS = {
|
|||||||
DD_ADMIN: 'lince@royalenfield.com',
|
DD_ADMIN: 'lince@royalenfield.com',
|
||||||
ASM: 'abhishek@royalenfield.com',
|
ASM: 'abhishek@royalenfield.com',
|
||||||
RBM: 'manish@royalenfield.com',
|
RBM: 'manish@royalenfield.com',
|
||||||
|
DD_ZM: 'piyush@royalenfield.com',
|
||||||
ZBH: 'manav@royalenfield.com',
|
ZBH: 'manav@royalenfield.com',
|
||||||
DD_LEAD: 'jaya@royalenfield.com',
|
DD_LEAD: 'jaya@royalenfield.com',
|
||||||
|
DD_HEAD: 'ganesh@royalenfield.com',
|
||||||
LEGAL: 'legal@royalenfield.com',
|
LEGAL: 'legal@royalenfield.com',
|
||||||
NBH: 'yashwin@royalenfield.com',
|
NBH: 'yashwin@royalenfield.com',
|
||||||
CCO: 'admin@royalenfield.com',
|
CCO: 'admin@royalenfield.com',
|
||||||
@ -72,8 +74,10 @@ async function run() {
|
|||||||
console.log(`Targeting Dealer: ${targetDealer.legalName} (${targetDealer.id})`);
|
console.log(`Targeting Dealer: ${targetDealer.legalName} (${targetDealer.id})`);
|
||||||
|
|
||||||
let terminationId = args.terminationId;
|
let terminationId = args.terminationId;
|
||||||
|
const isUnethical = String(args.category || '').trim().toLowerCase().includes('unethical');
|
||||||
|
|
||||||
if (!terminationId) {
|
if (!terminationId) {
|
||||||
console.log('[STEP 1] ASM Initiating Termination...');
|
console.log('[STEP 1] Initiating Termination...');
|
||||||
const asmToken = await login(EMAILS.ASM);
|
const asmToken = await login(EMAILS.ASM);
|
||||||
const createRes = await apiRequest('/termination', 'POST', {
|
const createRes = await apiRequest('/termination', 'POST', {
|
||||||
dealerId: targetDealer.id,
|
dealerId: targetDealer.id,
|
||||||
@ -83,7 +87,7 @@ async function run() {
|
|||||||
comments: 'Auto termination for testing flow ending with Legal Team, CCO, CEO.'
|
comments: 'Auto termination for testing flow ending with Legal Team, CCO, CEO.'
|
||||||
}, asmToken);
|
}, asmToken);
|
||||||
terminationId = createRes.termination.id;
|
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 {
|
} else {
|
||||||
console.log(`[STEP 1] Resuming existing termination: ${terminationId}`);
|
console.log(`[STEP 1] Resuming existing termination: ${terminationId}`);
|
||||||
}
|
}
|
||||||
@ -93,40 +97,49 @@ async function run() {
|
|||||||
console.log(`[INFO] Current stage before progression: ${currentStage}`);
|
console.log(`[INFO] Current stage before progression: ${currentStage}`);
|
||||||
|
|
||||||
const approvals = [
|
const approvals = [
|
||||||
{ name: 'RBM + DD-ZM Review', email: EMAILS.RBM, remarks: 'Performance concerns validated on-ground. Proceed with termination.' },
|
{ stage: 'RBM + DD-ZM Review', actors: [{ email: EMAILS.RBM, remarks: 'Validated.' }, { email: EMAILS.DD_ZM, remarks: 'Confirmed.' }] },
|
||||||
{ name: 'ZBH Review', email: EMAILS.ZBH, remarks: 'Strategic decision aligned with regional growth targets. Approved.' },
|
{ stage: 'ZBH Review', actors: [{ email: EMAILS.ZBH, remarks: 'Strategic decision aligned.' }] },
|
||||||
{ name: 'DD Lead Review', email: EMAILS.DD_LEAD, remarks: 'Contractual breaches documented. Verified.' },
|
{ stage: 'DD Lead Review', actors: [{ email: EMAILS.DD_LEAD, remarks: 'Breaches documented.' }] },
|
||||||
{ name: 'Legal Verification', email: EMAILS.LEGAL, remarks: 'Legal audit complete. Case is legally sound.' },
|
{ stage: 'Legal Verification', actors: [{ email: EMAILS.LEGAL, remarks: 'Case is sound.' }] },
|
||||||
{ name: 'DD Head Review', email: EMAILS.NBH, remarks: 'Strategic impact assessed. Proceeding with SCN approval.' },
|
{ stage: 'DD Head Review', actors: [{ email: EMAILS.DD_HEAD, remarks: 'Strategic impact assessed.' }] },
|
||||||
{ name: 'NBH Evaluation', email: EMAILS.NBH, remarks: 'Functional teams aligned. SCN to be issued.' },
|
{ stage: 'NBH Evaluation', actors: [{ email: EMAILS.NBH, remarks: 'Functional teams aligned.' }] },
|
||||||
{ name: 'SCN Issued', email: EMAILS.NBH, remarks: 'Show Cause Notice formally dispatched.' },
|
{ stage: 'Show Cause Notice (SCN)', actors: [{ email: EMAILS.DD_ADMIN, remarks: 'SCN Issued.' }] },
|
||||||
{ name: 'Personal Hearing Outcome', email: EMAILS.DD_LEAD, remarks: 'Hearing completed. Dealer defense not sufficient.' },
|
{ stage: 'Personal Hearing', actors: [
|
||||||
{ name: 'NBH Final Approval', email: EMAILS.NBH, remarks: 'Final recommendation for termination sent to CEO.' },
|
{ email: EMAILS.DD_LEAD, remarks: 'Hearing completed.' },
|
||||||
{ name: 'CCO Approval', email: EMAILS.CCO, remarks: 'Commercial impact assessed. Approved.' },
|
{ email: EMAILS.ZBH, remarks: 'Review recorded.' },
|
||||||
{ name: 'CEO Final Approval', email: EMAILS.CEO, remarks: 'Final authorization granted. Issue termination letter.' },
|
{ email: EMAILS.RBM, remarks: 'Review recorded.' },
|
||||||
{ name: 'Legal Termination Letter', email: EMAILS.LEGAL, remarks: 'Termination letter shared via registered mail.' },
|
{ email: EMAILS.DD_HEAD, remarks: 'Review recorded.' }
|
||||||
{ name: 'Final Terminated Status', email: EMAILS.DD_ADMIN, remarks: 'Closure completed.' }
|
] },
|
||||||
|
{ 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 = [
|
const stageOrder = [
|
||||||
'Submitted', 'RBM + DD-ZM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'DD Head Review',
|
'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'
|
'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++) {
|
// If Unethical, the skip-routing skips to DD Lead Review (index 3 in stageOrder)
|
||||||
const actor = approvals[i];
|
const startIndex = isUnethical ? 2 : Math.max(0, stageOrder.indexOf(currentStage));
|
||||||
log(currentStep, `${actor.name} (${actor.email}) processing approval...`);
|
let currentStep = 2;
|
||||||
const token = await login(actor.email);
|
|
||||||
await apiRequest(`/termination/${terminationId}/status`, 'PUT', {
|
for (let i = startIndex; i < approvals.length; i++) {
|
||||||
action: 'approve',
|
const step = approvals[i];
|
||||||
remarks: actor.remarks
|
log(currentStep, `Stage: ${step.stage} - Processing approvals...`);
|
||||||
}, token);
|
|
||||||
log(currentStep, `${actor.name} Result: SUCCESS`);
|
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++;
|
currentStep++;
|
||||||
await delay();
|
await delay();
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user