394 lines
14 KiB
JavaScript
394 lines
14 KiB
JavaScript
/**
|
|
* Dealer termination E2E — follows the same stage order and APIs as the UI.
|
|
*
|
|
* Usage:
|
|
* node trigger-termination.js
|
|
* node trigger-termination.js --terminationId=<uuid> --delayMs=800
|
|
* node trigger-termination.js --category=Unethical --skipClearances=true
|
|
*/
|
|
const args = Object.fromEntries(
|
|
process.argv
|
|
.slice(2)
|
|
.map((arg) => arg.replace(/^--/, '').split('='))
|
|
.map(([k, v]) => [k, v ?? 'true'])
|
|
);
|
|
const BASE_URL = args.baseUrl || process.env.BASE_URL || 'http://localhost:5000/api';
|
|
const PASSWORD = 'Admin@123';
|
|
const STEP_DELAY_MS = Number(args.delayMs || 800);
|
|
const SHOULD_SKIP_CLEARANCES = String(args.skipClearances || 'false') === 'true';
|
|
|
|
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: 'cco@royalenfield.com',
|
|
CEO: 'ceo@royalenfield.com'
|
|
};
|
|
|
|
/** Canonical stage labels (match TERMINATION_STAGES + UI timeline). */
|
|
const STAGES = {
|
|
RBM: 'RBM + DD-ZM Review',
|
|
ZBH: 'ZBH Review',
|
|
DD_LEAD: 'DD Lead Review',
|
|
LEGAL: 'Legal Verification',
|
|
DD_HEAD: 'DD Head Review',
|
|
NBH: 'NBH Evaluation',
|
|
SCN: 'Show Cause Notice (SCN)',
|
|
SCN_EVAL: 'Evaluation of Dealer SCN Response',
|
|
NBH_FINAL: 'NBH Final Approval',
|
|
CCO: 'CCO Approval',
|
|
CEO: 'CEO Final Approval',
|
|
LEGAL_LETTER: 'Legal - Termination Letter',
|
|
TERMINATED: 'Terminated'
|
|
};
|
|
|
|
async function apiRequest(endpoint, method = 'GET', body = null, token = null, isFormData = false) {
|
|
const headers = {};
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
if (!isFormData) headers['Content-Type'] = 'application/json';
|
|
|
|
const config = { method, headers };
|
|
if (body) config.body = isFormData ? body : JSON.stringify(body);
|
|
|
|
const response = await fetch(`${BASE_URL}${endpoint}`, config);
|
|
const data = await response.json().catch(() => ({}));
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`API Error ${method} ${endpoint}: ${JSON.stringify(data)}`);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
async function login(email) {
|
|
if (!login.cache) login.cache = {};
|
|
if (login.cache[email]) return login.cache[email];
|
|
const data = await apiRequest('/auth/login', 'POST', { email, password: PASSWORD });
|
|
login.cache[email] = data.token;
|
|
return login.cache[email];
|
|
}
|
|
|
|
const delay = (ms = STEP_DELAY_MS) => new Promise((res) => setTimeout(res, ms));
|
|
const log = (step, msg) => console.log(`[STEP ${step}] ${msg}`);
|
|
|
|
async function fetchTermination(terminationId, token) {
|
|
const data = await apiRequest(`/termination/${terminationId}`, 'GET', null, token);
|
|
return data.termination;
|
|
}
|
|
|
|
async function waitUntilStage(terminationId, expectedStage, token, maxAttempts = 50) {
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
const t = await fetchTermination(terminationId, token);
|
|
if (t.currentStage === expectedStage) return t;
|
|
await delay(250);
|
|
}
|
|
const last = await fetchTermination(terminationId, token);
|
|
throw new Error(
|
|
`Timed out waiting for stage "${expectedStage}" (still at "${last.currentStage}", status="${last.status}")`
|
|
);
|
|
}
|
|
|
|
async function approveTermination(terminationId, email, remarks) {
|
|
const token = await login(email);
|
|
return apiRequest(
|
|
`/termination/${terminationId}/status`,
|
|
'PUT',
|
|
{ action: 'approve', remarks },
|
|
token
|
|
);
|
|
}
|
|
|
|
/** RBM+DD-ZM and SCN evaluation require two/four partial approvals before the stage advances. */
|
|
async function runJointApprovals(terminationId, actors, nextStage, adminToken, stepLabel) {
|
|
log(stepLabel, `Joint approvals at ${nextStage ? 'current' : '?'} → expect "${nextStage}"`);
|
|
|
|
for (const actor of actors) {
|
|
const res = await approveTermination(terminationId, actor.email, actor.remarks);
|
|
const message = res.message || res.data?.message || '';
|
|
log(stepLabel, `${actor.email}: ${message}`);
|
|
await delay(300);
|
|
|
|
const t = await fetchTermination(terminationId, adminToken);
|
|
if (nextStage && t.currentStage === nextStage) {
|
|
log(stepLabel, `Advanced to ${nextStage}`);
|
|
return t;
|
|
}
|
|
}
|
|
|
|
return waitUntilStage(terminationId, nextStage, adminToken);
|
|
}
|
|
|
|
async function runSingleApproval(terminationId, email, remarks, nextStage, adminToken, stepLabel) {
|
|
const res = await approveTermination(terminationId, email, remarks);
|
|
log(stepLabel, `${email}: ${res.message || 'approved'}`);
|
|
await delay(300);
|
|
return waitUntilStage(terminationId, nextStage, adminToken);
|
|
}
|
|
|
|
async function finalizeTermination(terminationId, email, nextStage, remarks, stepLabel, adminToken) {
|
|
const token = await login(email);
|
|
const res = await apiRequest(
|
|
`/termination/${terminationId}/finalize`,
|
|
'POST',
|
|
{ decision: 'Approve', remarks },
|
|
token
|
|
);
|
|
log(stepLabel, `${email} finalize → ${res.message || 'ok'}`);
|
|
await delay(300);
|
|
return waitUntilStage(terminationId, nextStage, adminToken);
|
|
}
|
|
|
|
async function uploadScnResponsePlaceholder(terminationId) {
|
|
const token = await login(EMAILS.DD_ADMIN);
|
|
const form = new FormData();
|
|
// Multer only allows PDF/images/office — use minimal PDF bytes
|
|
const pdfBytes = Buffer.from(
|
|
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 3 3]/Parent 2 0 R>>endobj\nxref\n0 4\ntrailer<</Size 4/Root 1 0 R>>\nstartxref\n0\n%%EOF'
|
|
);
|
|
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
|
|
form.append('file', blob, 'scn-response-e2e.pdf');
|
|
form.append('remarks', 'Dealer SCN response uploaded (E2E script).');
|
|
|
|
await apiRequest(`/termination/${terminationId}/scn-response`, 'POST', form, token, true);
|
|
log('SCN', 'SCN response uploaded by DD Admin');
|
|
}
|
|
|
|
const STAGE_ORDER = [
|
|
STAGES.RBM,
|
|
STAGES.ZBH,
|
|
STAGES.DD_LEAD,
|
|
STAGES.LEGAL,
|
|
STAGES.DD_HEAD,
|
|
STAGES.NBH,
|
|
STAGES.SCN,
|
|
STAGES.SCN_EVAL,
|
|
STAGES.NBH_FINAL,
|
|
STAGES.CCO,
|
|
STAGES.CEO,
|
|
STAGES.LEGAL_LETTER,
|
|
STAGES.TERMINATED
|
|
];
|
|
|
|
function stageIndex(stage) {
|
|
const idx = STAGE_ORDER.indexOf(stage);
|
|
return idx >= 0 ? idx : 0;
|
|
}
|
|
|
|
function shouldRun(resumeFromStage, targetStage) {
|
|
return stageIndex(resumeFromStage) <= stageIndex(targetStage);
|
|
}
|
|
|
|
async function runWorkflowFromStage(terminationId, resumeFromStage, adminToken, isUnethicalCategory) {
|
|
let step = 2;
|
|
|
|
if (isUnethicalCategory) {
|
|
log(step++, 'Unethical category — workflow starts at DD Lead (RBM/ZBH not used).');
|
|
}
|
|
|
|
if (!isUnethicalCategory && shouldRun(resumeFromStage, STAGES.RBM)) {
|
|
await runJointApprovals(
|
|
terminationId,
|
|
[
|
|
{ email: EMAILS.RBM, remarks: 'RBM validation complete.' },
|
|
{ email: EMAILS.DD_ZM, remarks: 'DD-ZM confirmed escalation.' }
|
|
],
|
|
STAGES.ZBH,
|
|
adminToken,
|
|
step
|
|
);
|
|
await delay();
|
|
}
|
|
|
|
if (!isUnethicalCategory && shouldRun(resumeFromStage, STAGES.ZBH)) {
|
|
await runSingleApproval(terminationId, EMAILS.ZBH, 'Strategic decision aligned.', STAGES.DD_LEAD, adminToken, step++);
|
|
await delay();
|
|
}
|
|
|
|
if (shouldRun(resumeFromStage, STAGES.DD_LEAD)) {
|
|
await runSingleApproval(
|
|
terminationId,
|
|
EMAILS.DD_LEAD,
|
|
isUnethicalCategory ? 'Immediate escalation — breaches documented.' : 'Breaches documented.',
|
|
STAGES.LEGAL,
|
|
adminToken,
|
|
step++
|
|
);
|
|
await delay();
|
|
}
|
|
|
|
if (shouldRun(resumeFromStage, STAGES.LEGAL)) {
|
|
await runSingleApproval(terminationId, EMAILS.LEGAL, 'Case is sound.', STAGES.DD_HEAD, adminToken, step++);
|
|
await delay();
|
|
}
|
|
|
|
if (shouldRun(resumeFromStage, STAGES.DD_HEAD)) {
|
|
await runSingleApproval(terminationId, EMAILS.DD_HEAD, 'Strategic impact assessed.', STAGES.NBH, adminToken, step++);
|
|
await delay();
|
|
}
|
|
|
|
if (shouldRun(resumeFromStage, STAGES.NBH)) {
|
|
await runSingleApproval(terminationId, EMAILS.NBH, 'Functional teams aligned.', STAGES.SCN, adminToken, step++);
|
|
await delay();
|
|
}
|
|
|
|
if (shouldRun(resumeFromStage, STAGES.SCN)) {
|
|
const atScn = await fetchTermination(terminationId, adminToken);
|
|
if (atScn.currentStage === STAGES.SCN) {
|
|
await uploadScnResponsePlaceholder(terminationId);
|
|
await waitUntilStage(terminationId, STAGES.SCN_EVAL, adminToken);
|
|
}
|
|
await delay();
|
|
}
|
|
|
|
if (shouldRun(resumeFromStage, STAGES.SCN_EVAL)) {
|
|
await runJointApprovals(
|
|
terminationId,
|
|
[
|
|
{ email: EMAILS.DD_LEAD, remarks: 'SCN response reviewed by DD Lead.' },
|
|
{ email: EMAILS.ZBH, remarks: 'SCN response reviewed by ZBH.' },
|
|
{ email: EMAILS.RBM, remarks: 'SCN response reviewed by RBM.' },
|
|
{ email: EMAILS.DD_HEAD, remarks: 'SCN response reviewed by DD Head.' }
|
|
],
|
|
STAGES.NBH_FINAL,
|
|
adminToken,
|
|
step
|
|
);
|
|
await delay();
|
|
}
|
|
|
|
if (shouldRun(resumeFromStage, STAGES.NBH_FINAL)) {
|
|
await finalizeTermination(terminationId, EMAILS.NBH, STAGES.CCO, 'NBH final authorization.', step++, adminToken);
|
|
await delay();
|
|
}
|
|
|
|
if (shouldRun(resumeFromStage, STAGES.CCO)) {
|
|
await finalizeTermination(terminationId, EMAILS.CCO, STAGES.CEO, 'CCO authorization.', step++, adminToken);
|
|
await delay();
|
|
}
|
|
|
|
if (shouldRun(resumeFromStage, STAGES.CEO)) {
|
|
await finalizeTermination(terminationId, EMAILS.CEO, STAGES.LEGAL_LETTER, 'CEO final authorization.', step++, adminToken);
|
|
await delay();
|
|
}
|
|
|
|
if (shouldRun(resumeFromStage, STAGES.LEGAL_LETTER)) {
|
|
await runSingleApproval(
|
|
terminationId,
|
|
EMAILS.LEGAL,
|
|
'Termination letter issued.',
|
|
STAGES.TERMINATED,
|
|
adminToken,
|
|
step++
|
|
);
|
|
await delay();
|
|
}
|
|
|
|
const afterLegal = await fetchTermination(terminationId, adminToken);
|
|
if (afterLegal.currentStage === STAGES.TERMINATED) {
|
|
log(step, `Terminated (status: ${afterLegal.status}). Use Push to F&F in UI when LWD is reached.`);
|
|
}
|
|
}
|
|
|
|
async function run() {
|
|
try {
|
|
console.log('--- STARTING DEALER TERMINATION E2E FLOW (UI-aligned) ---');
|
|
|
|
const adminToken = await login(EMAILS.DD_ADMIN);
|
|
const dealersRes = await apiRequest('/dealer', 'GET', null, adminToken);
|
|
const targetDealer = dealersRes.data[0];
|
|
|
|
if (!targetDealer) throw new Error('No dealer profiles found. Run seed first.');
|
|
|
|
console.log(`Targeting Dealer: ${targetDealer.legalName} (${targetDealer.id})`);
|
|
|
|
let terminationId = args.terminationId;
|
|
const isUnethical = String(args.category || '').trim().toLowerCase().includes('unethical');
|
|
|
|
if (!terminationId) {
|
|
log(1, 'Creating termination (ASM)...');
|
|
const asmToken = await login(EMAILS.ASM);
|
|
const createRes = await apiRequest(
|
|
'/termination',
|
|
'POST',
|
|
{
|
|
dealerId: targetDealer.id,
|
|
category: args.category || 'Performance',
|
|
reason: args.reason || 'Consistently failed to meet commitment targets.',
|
|
proposedLwd: new Date().toISOString().split('T')[0],
|
|
comments: 'E2E termination — follows UI stage order (no stacked partial approvals).'
|
|
},
|
|
asmToken
|
|
);
|
|
terminationId = createRes.termination.id;
|
|
log(1, `Created: ${terminationId} (${args.category || 'Performance'})`);
|
|
} else {
|
|
log(1, `Resuming: ${terminationId}`);
|
|
}
|
|
|
|
await delay();
|
|
|
|
let termination = await fetchTermination(terminationId, adminToken);
|
|
log('INFO', `Starting stage: ${termination.currentStage} | status: ${termination.status}`);
|
|
|
|
const resumeFrom = STAGE_ORDER.includes(termination.currentStage)
|
|
? termination.currentStage
|
|
: isUnethical
|
|
? STAGES.DD_LEAD
|
|
: STAGES.RBM;
|
|
|
|
if (termination.currentStage === STAGES.TERMINATED || termination.status?.includes('F&F')) {
|
|
log('SKIP', 'Already terminated / in F&F — workflow steps skipped.');
|
|
} else {
|
|
await runWorkflowFromStage(terminationId, resumeFrom, adminToken, isUnethical);
|
|
}
|
|
|
|
if (!SHOULD_SKIP_CLEARANCES) {
|
|
termination = await fetchTermination(terminationId, adminToken);
|
|
const fnfId = termination.fnfSettlement?.id;
|
|
if (!fnfId) {
|
|
log('F&F', 'No FnF record yet (expected until Push to F&F after LWD). Skipping clearances.');
|
|
} else {
|
|
log('F&F', 'Running department clearances...');
|
|
const departments = [
|
|
{ name: 'Warranty Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No pending claims.' },
|
|
{ name: 'Sales Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Allocations transferred.' }
|
|
];
|
|
for (const dept of departments) {
|
|
await apiRequest(
|
|
`/termination/${terminationId}/clearance`,
|
|
'PUT',
|
|
dept,
|
|
adminToken
|
|
);
|
|
await delay(200);
|
|
}
|
|
}
|
|
}
|
|
|
|
const finalDetails = await fetchTermination(terminationId, adminToken);
|
|
console.log(`[FINAL] stage=${finalDetails.currentStage} status=${finalDetails.status}`);
|
|
|
|
const userRes = await apiRequest('/admin/users', 'GET', null, adminToken);
|
|
const dealerUser = userRes.data?.find((u) => u.dealerId === targetDealer.id);
|
|
|
|
if (dealerUser && !dealerUser.isActive && dealerUser.status === 'inactive') {
|
|
console.log(`[VERIFICATION] Dealer portal user ${dealerUser.email} deactivated.`);
|
|
} else if (finalDetails.currentStage === STAGES.TERMINATED) {
|
|
console.log('[VERIFICATION] Terminated — dealer deactivation may occur at Legal Letter stage.');
|
|
}
|
|
|
|
console.log('\n--- TERMINATION E2E COMPLETE ---');
|
|
process.exit(0);
|
|
} catch (error) {
|
|
console.error('Workflow failed:', error.message);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
run();
|