workflow test file added and enhanncing the status transition
This commit is contained in:
parent
65d2af7447
commit
f70d4e7439
@ -48,6 +48,8 @@ export const APPLICATION_STAGES = {
|
|||||||
LOI: 'LOI',
|
LOI: 'LOI',
|
||||||
LOA: 'LOA',
|
LOA: 'LOA',
|
||||||
EOR: 'EOR',
|
EOR: 'EOR',
|
||||||
|
ARCHITECTURE_WORK: 'Architecture Work',
|
||||||
|
STATUTORY_WORK: 'Statutory Work',
|
||||||
LEVEL_1_APPROVED: 'Level 1 Approved',
|
LEVEL_1_APPROVED: 'Level 1 Approved',
|
||||||
LEVEL_2_APPROVED: 'Level 2 Approved',
|
LEVEL_2_APPROVED: 'Level 2 Approved',
|
||||||
LEVEL_2_RECOMMENDED: 'Level 2 Recommended',
|
LEVEL_2_RECOMMENDED: 'Level 2 Recommended',
|
||||||
@ -94,6 +96,8 @@ export const APPLICATION_STATUS = {
|
|||||||
STATUTORY_LOI_ACK: 'Statutory LOI Ack',
|
STATUTORY_LOI_ACK: 'Statutory LOI Ack',
|
||||||
EOR_IN_PROGRESS: 'EOR In Progress',
|
EOR_IN_PROGRESS: 'EOR In Progress',
|
||||||
LOA_PENDING: 'LOA Pending',
|
LOA_PENDING: 'LOA Pending',
|
||||||
|
ARCHITECTURE_WORK: 'Architecture Work',
|
||||||
|
STATUTORY_WORK: 'Statutory Work',
|
||||||
LOA_ISSUED: 'LOA Issued',
|
LOA_ISSUED: 'LOA Issued',
|
||||||
LOA_REJECTED: 'LOA Rejected',
|
LOA_REJECTED: 'LOA Rejected',
|
||||||
EOR_COMPLETE: 'EOR Complete',
|
EOR_COMPLETE: 'EOR Complete',
|
||||||
|
|||||||
@ -13,10 +13,12 @@ export const ONBOARDING_STAGES = [
|
|||||||
{ name: 'Security Details', order: 9 },
|
{ name: 'Security Details', order: 9 },
|
||||||
{ name: 'LOI Issue', order: 10 },
|
{ name: 'LOI Issue', order: 10 },
|
||||||
{ name: 'Dealer Code Generation', order: 11 },
|
{ name: 'Dealer Code Generation', order: 11 },
|
||||||
{ name: 'LOA', order: 12 },
|
{ name: 'Architecture Work', order: 12 },
|
||||||
{ name: 'EOR Complete', order: 13 },
|
{ name: 'Statutory Work', order: 12 }, // UNIFIED with Architecture
|
||||||
{ name: 'Inauguration', order: 14 },
|
{ name: 'LOA', order: 13 },
|
||||||
{ name: 'Onboarded', order: 15 }
|
{ name: 'EOR Complete', order: 14 },
|
||||||
|
{ name: 'Inauguration', order: 15 },
|
||||||
|
{ name: 'Onboarded', order: 16 }
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -51,6 +53,8 @@ export const updateApplicationProgress = async (applicationId: string, stageName
|
|||||||
|
|
||||||
// Whenever a stage is marked 'active' or 'completed',
|
// Whenever a stage is marked 'active' or 'completed',
|
||||||
// all previous stages MUST exist and be marked 'completed'.
|
// all previous stages MUST exist and be marked 'completed'.
|
||||||
|
// EXCEPTION: Parallel stages (Architecture/Statutory) share the same order,
|
||||||
|
// so we only autocomplete stages with order < current order.
|
||||||
if (status === 'active' || status === 'completed') {
|
if (status === 'active' || status === 'completed') {
|
||||||
const previousStages = ONBOARDING_STAGES.filter(s => s.order < stage.order);
|
const previousStages = ONBOARDING_STAGES.filter(s => s.order < stage.order);
|
||||||
for (const prev of previousStages) {
|
for (const prev of previousStages) {
|
||||||
@ -108,10 +112,21 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
|
|||||||
'LOI Issued': 'LOI Issue',
|
'LOI Issued': 'LOI Issue',
|
||||||
'Statutory LOI Ack': 'LOI Issue',
|
'Statutory LOI Ack': 'LOI Issue',
|
||||||
'Dealer Code Generation': 'Dealer Code Generation',
|
'Dealer Code Generation': 'Dealer Code Generation',
|
||||||
'Architecture Team Assigned': 'Dealer Code Generation',
|
'Architecture Team Assigned': 'Architecture Work',
|
||||||
'Architecture Document Upload': 'Dealer Code Generation',
|
'Architecture Document Upload': 'Architecture Work',
|
||||||
'Architecture Team Completion': 'Dealer Code Generation',
|
'Architecture Team Completion': 'Architecture Work',
|
||||||
'Statutory GST': 'Dealer Code Generation',
|
'Architecture Work': 'Architecture Work',
|
||||||
|
'Statutory GST': 'Statutory Work',
|
||||||
|
'Statutory PAN': 'Statutory Work',
|
||||||
|
'Statutory Nodal': 'Statutory Work',
|
||||||
|
'Statutory Check': 'Statutory Work',
|
||||||
|
'Statutory Partnership': 'Statutory Work',
|
||||||
|
'Statutory Firm Reg': 'Statutory Work',
|
||||||
|
'Statutory Rental': 'Statutory Work',
|
||||||
|
'Statutory Virtual Code': 'Statutory Work',
|
||||||
|
'Statutory Domain': 'Statutory Work',
|
||||||
|
'Statutory MSD': 'Statutory Work',
|
||||||
|
'Statutory Work': 'Statutory Work',
|
||||||
'LOA Pending': 'LOA',
|
'LOA Pending': 'LOA',
|
||||||
'LOA Issued': 'LOA',
|
'LOA Issued': 'LOA',
|
||||||
'EOR In Progress': 'EOR Complete',
|
'EOR In Progress': 'EOR Complete',
|
||||||
@ -123,25 +138,50 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
|
|||||||
|
|
||||||
const currentStageName = statusToStageMap[overallStatus];
|
const currentStageName = statusToStageMap[overallStatus];
|
||||||
if (currentStageName) {
|
if (currentStageName) {
|
||||||
const stage = ONBOARDING_STAGES.find(s => s.name === currentStageName);
|
const currentStage = ONBOARDING_STAGES.find(s => s.name === currentStageName);
|
||||||
if (stage) {
|
if (currentStage) {
|
||||||
// Determine status for this stage
|
// Statuses that imply the CURRENT stage (single or both parallel) is finished
|
||||||
const isCompleted = [
|
const completionStatuses = [
|
||||||
'Submitted', 'Questionnaire Completed', 'Shortlisted', 'Level 1 Approved',
|
'Submitted', 'Questionnaire Completed', 'Shortlisted', 'Level 1 Approved',
|
||||||
'Level 2 Approved', 'Level 3 Approved', 'FDD Verification', 'LOI Issued',
|
'Level 2 Approved', 'Level 3 Approved', 'LOI Issued',
|
||||||
'Dealer Code Generation', 'Architecture Team Completion', 'LOA Issued',
|
'LOA Issued', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'
|
||||||
'EOR Complete', 'Approved', 'Onboarded'
|
];
|
||||||
].includes(overallStatus);
|
|
||||||
|
|
||||||
|
const isCurrentStageFinished = completionStatuses.includes(overallStatus);
|
||||||
|
|
||||||
await updateApplicationProgress(applicationId, currentStageName, isCompleted ? 'completed' : 'active', isCompleted ? 100 : 50);
|
// Fetch application to check model-driven parallel status
|
||||||
|
const application = await db.Application.findByPk(applicationId);
|
||||||
|
|
||||||
// AUTO-INITIATE NEXT STAGE: If this stage is completed, mark the next one as 'active' (Pending)
|
// Robust Sync: Iterate through ALL stages and align with logic
|
||||||
if (isCompleted) {
|
for (const stage of ONBOARDING_STAGES) {
|
||||||
const nextStage = ONBOARDING_STAGES.find(s => s.order === stage.order + 1);
|
let status: 'pending' | 'active' | 'completed' = 'pending';
|
||||||
if (nextStage) {
|
let percentage = 0;
|
||||||
await updateApplicationProgress(applicationId, nextStage.name, 'pending', 0);
|
|
||||||
|
if (stage.order < currentStage.order) {
|
||||||
|
status = 'completed';
|
||||||
|
percentage = 100;
|
||||||
|
} else if (stage.order === currentStage.order) {
|
||||||
|
// Logic for the current status order (could contain parallel stages)
|
||||||
|
status = isCurrentStageFinished ? 'completed' : 'active';
|
||||||
|
percentage = isCurrentStageFinished ? 100 : 50;
|
||||||
|
|
||||||
|
// OVERRIDE for Parallel Tracks (Architecture/Statutory)
|
||||||
|
if (stage.name === 'Architecture Work' && application) {
|
||||||
|
status = application.architectureStatus === 'COMPLETED' ? 'completed' :
|
||||||
|
(application.architectureStatus === 'IN_PROGRESS' || currentStage.name === 'Architecture Work' || isCurrentStageFinished) ? 'active' : 'pending';
|
||||||
|
percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0;
|
||||||
|
}
|
||||||
|
if (stage.name === 'Statutory Work' && application) {
|
||||||
|
status = application.statutoryStatus === 'COMPLETED' ? 'completed' :
|
||||||
|
(application.statutoryStatus === 'IN_PROGRESS' || currentStage.name === 'Statutory Work' || isCurrentStageFinished) ? 'active' : 'pending';
|
||||||
|
percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status = 'pending';
|
||||||
|
percentage = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await updateApplicationProgress(applicationId, stage.name, status, percentage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,7 @@ export interface ApplicationAttributes {
|
|||||||
assignedTo: string | null;
|
assignedTo: string | null;
|
||||||
architectureAssignedTo: string | null;
|
architectureAssignedTo: string | null;
|
||||||
architectureStatus: string | null;
|
architectureStatus: string | null;
|
||||||
|
statutoryStatus: string | null;
|
||||||
submittedBy: string | null;
|
submittedBy: string | null;
|
||||||
districtId: string | null;
|
districtId: string | null;
|
||||||
architectureAssignedDate: Date | null;
|
architectureAssignedDate: Date | null;
|
||||||
@ -192,6 +193,11 @@ export default (sequelize: Sequelize) => {
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: 'Pending'
|
defaultValue: 'Pending'
|
||||||
},
|
},
|
||||||
|
statutoryStatus: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 'Pending'
|
||||||
|
},
|
||||||
submittedBy: {
|
submittedBy: {
|
||||||
type: DataTypes.UUID,
|
type: DataTypes.UUID,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
|
|||||||
@ -92,40 +92,6 @@ const processStageDecision = async (params: {
|
|||||||
return { forbidden: true, policy, requiredRoles, currentRole: roleCode };
|
return { forbidden: true, policy, requiredRoles, currentRole: roleCode };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sequential Enforcement (SRS 6.16.2 & 6.18.3.1 Compliance) ---
|
|
||||||
if (roleCode !== 'Super Admin' && roleCode !== 'DD Admin') {
|
|
||||||
const approvedActions = await db.StageApprovalAction.findAll({
|
|
||||||
where: { applicationId: resolvedId, stageCode, decision: 'Approved' }
|
|
||||||
});
|
|
||||||
const approvedRoles = new Set(approvedActions.map((a: any) => a.actorRole));
|
|
||||||
|
|
||||||
// LOI Specific Chain: Finance -> DD Head -> NBH
|
|
||||||
if (stageCode === 'LOI_APPROVAL') {
|
|
||||||
const isFinanceUser = roleCode === 'Finance' || roleCode === 'Finance Admin';
|
|
||||||
|
|
||||||
if (roleCode === 'DD Head' && !approvedRoles.has('Finance') && !approvedRoles.has('Finance Admin')) {
|
|
||||||
return { forbidden: true, message: 'Finance approval is required before DD Head can approve LOI.', sequentialError: true };
|
|
||||||
}
|
|
||||||
if (roleCode === 'NBH' && !approvedRoles.has('DD Head')) {
|
|
||||||
return { forbidden: true, message: 'DD Head approval is required before NBH can approve LOI.', sequentialError: true };
|
|
||||||
}
|
|
||||||
// Strict authorization for LOI Decision
|
|
||||||
if (!isFinanceUser && roleCode !== 'DD Head' && roleCode !== 'NBH') {
|
|
||||||
return { forbidden: true, message: 'Your role is not authorized to participate in the LOI approval decision.', sequentialError: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// LOA Specific Chain: DD Head -> NBH
|
|
||||||
else if (stageCode === 'LOA_APPROVAL') {
|
|
||||||
if (roleCode === 'NBH' && !approvedRoles.has('DD Head')) {
|
|
||||||
return { forbidden: true, message: 'DD Head approval is required before NBH can approve LOA.', sequentialError: true };
|
|
||||||
}
|
|
||||||
// Strict authorization for LOA Decision
|
|
||||||
if (roleCode !== 'DD Head' && roleCode !== 'NBH') {
|
|
||||||
return { forbidden: true, message: 'Your role is not authorized to participate in the LOA approval decision.', sequentialError: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RECORD THE DECISION ACTION
|
// RECORD THE DECISION ACTION
|
||||||
// Record Action - Robust handle for null interviewId which breaks unique constraint in Postgres
|
// Record Action - Robust handle for null interviewId which breaks unique constraint in Postgres
|
||||||
if (!interviewId) {
|
if (!interviewId) {
|
||||||
@ -235,14 +201,23 @@ const processStageDecision = async (params: {
|
|||||||
let targetStage = nextStage;
|
let targetStage = nextStage;
|
||||||
let targetProgress = nextProgress;
|
let targetProgress = nextProgress;
|
||||||
|
|
||||||
// Sequential Override: Ensure one-by-one progression
|
// Consolidated Parallel Track Logic (SRS v2.0 Section 1.1.2 Alignment)
|
||||||
if (stageCode === 'LOI_APPROVAL') {
|
if (stageCode === 'ARCHITECTURE_WORK') {
|
||||||
targetStatus = APPLICATION_STATUS.SECURITY_DETAILS;
|
await application.update({ architectureStatus: 'COMPLETED' });
|
||||||
targetStage = APPLICATION_STAGES.LOI;
|
// Architecture is non-blocking for LOA transition
|
||||||
targetProgress = 75;
|
targetStatus = undefined;
|
||||||
|
targetStage = 'Architecture Work';
|
||||||
|
targetProgress = application.progressPercentage || 80;
|
||||||
|
statusUpdated = true; // Mark as handled to avoid redundant transition check below
|
||||||
|
} else if (stageCode === 'STATUTORY_WORK') {
|
||||||
|
await application.update({ statutoryStatus: 'COMPLETED' });
|
||||||
|
// Statutory completion triggers transition to LOA Pending (Stage 13)
|
||||||
|
targetStatus = APPLICATION_STATUS.LOA_PENDING;
|
||||||
|
targetStage = 'Statutory Work';
|
||||||
|
targetProgress = 85;
|
||||||
} else if (stageCode === 'LOA_APPROVAL') {
|
} else if (stageCode === 'LOA_APPROVAL') {
|
||||||
targetStatus = APPLICATION_STATUS.EOR_IN_PROGRESS;
|
targetStatus = APPLICATION_STATUS.LOA_ISSUED;
|
||||||
targetStage = APPLICATION_STAGES.LOA;
|
targetStage = 'LOA';
|
||||||
targetProgress = 95;
|
targetProgress = 95;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -332,12 +307,10 @@ export const getQuestionnaire = async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
export const submitQuestionnaireResponse = async (req: AuthRequest, res: Response) => {
|
export const submitQuestionnaireResponse = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { applicationId, questionnaireId, responses } = req.body; // responses: [{ questionId, responseValue, attachmentUrl }]
|
const { applicationId, questionnaireId, responses } = req.body;
|
||||||
|
|
||||||
// Find application UUID first (handles readable ID)
|
|
||||||
const _isUUID_qr = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(applicationId as string);
|
|
||||||
const application = await db.Application.findOne({
|
const application = await db.Application.findOne({
|
||||||
where: _isUUID_qr ? { [Op.or]: [{ id: applicationId }, { applicationId: applicationId }] } : { applicationId: applicationId }
|
where: { applicationId }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!application) return res.status(404).json({ success: false, message: 'Application not found' });
|
if (!application) return res.status(404).json({ success: false, message: 'Application not found' });
|
||||||
@ -345,7 +318,6 @@ export const submitQuestionnaireResponse = async (req: AuthRequest, res: Respons
|
|||||||
let totalWeightedScore = 0;
|
let totalWeightedScore = 0;
|
||||||
|
|
||||||
for (const resp of responses) {
|
for (const resp of responses) {
|
||||||
// Save response
|
|
||||||
await QuestionnaireResponse.create({
|
await QuestionnaireResponse.create({
|
||||||
applicationId,
|
applicationId,
|
||||||
questionnaireId,
|
questionnaireId,
|
||||||
@ -354,27 +326,18 @@ export const submitQuestionnaireResponse = async (req: AuthRequest, res: Respons
|
|||||||
attachmentUrl: resp.attachmentUrl
|
attachmentUrl: resp.attachmentUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
// Scoring Logic:
|
|
||||||
// 1. Fetch the question to get its weight
|
|
||||||
const question = await QuestionnaireQuestion.findByPk(resp.questionId, {
|
const question = await QuestionnaireQuestion.findByPk(resp.questionId, {
|
||||||
include: [{ model: db.QuestionnaireOption, as: 'questionOptions' }]
|
include: [{ model: db.QuestionnaireOption, as: 'questionOptions' }]
|
||||||
});
|
});
|
||||||
|
|
||||||
if (question) {
|
if (question) {
|
||||||
// Auto-sync specific application fields from questionnaire responses
|
|
||||||
if (question.questionText === 'Proposed Firm Type' && resp.responseValue) {
|
|
||||||
await application.update({ constitutionType: resp.responseValue });
|
|
||||||
}
|
|
||||||
|
|
||||||
let questionScore = 0;
|
let questionScore = 0;
|
||||||
// If it's an option-based question, find the selected option's score
|
|
||||||
if (question.questionOptions && question.questionOptions.length > 0) {
|
if (question.questionOptions && question.questionOptions.length > 0) {
|
||||||
const selectedOption = question.questionOptions.find((opt: any) => opt.optionText === resp.responseValue);
|
const selectedOption = question.questionOptions.find((opt: any) => opt.optionText === resp.responseValue);
|
||||||
if (selectedOption) {
|
if (selectedOption) {
|
||||||
questionScore = selectedOption.score;
|
questionScore = selectedOption.score;
|
||||||
}
|
}
|
||||||
} else if (!isNaN(Number(resp.responseValue))) {
|
} else if (!isNaN(Number(resp.responseValue))) {
|
||||||
// If it's a numeric input, use it directly (if appropriate)
|
|
||||||
questionScore = Number(resp.responseValue);
|
questionScore = Number(resp.responseValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -382,34 +345,15 @@ export const submitQuestionnaireResponse = async (req: AuthRequest, res: Respons
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create/Update Score Record
|
|
||||||
await QuestionnaireScore.upsert({
|
await QuestionnaireScore.upsert({
|
||||||
applicationId,
|
applicationId,
|
||||||
questionnaireId,
|
questionnaireId,
|
||||||
score: totalWeightedScore,
|
score: totalWeightedScore,
|
||||||
maxScore: 100, // Based on SRS section weightages
|
maxScore: 100,
|
||||||
status: 'Completed'
|
status: 'Completed'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (application) {
|
res.status(201).json({ success: true, message: 'Responses submitted successfully', score: totalWeightedScore });
|
||||||
// Persist the total score to the application record for quick access
|
|
||||||
await application.update({ score: totalWeightedScore });
|
|
||||||
|
|
||||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.QUESTIONNAIRE_COMPLETED, req.user?.id || null, {
|
|
||||||
reason: 'Questionnaire submitted by applicant',
|
|
||||||
progressPercentage: 20
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send Acknowledgment Email
|
|
||||||
EmailService.sendQuestionnaireAckEmail(
|
|
||||||
application.email,
|
|
||||||
application.applicantName,
|
|
||||||
application.preferredLocation || application.city,
|
|
||||||
application.applicationId
|
|
||||||
).catch(err => console.error('Failed to send questionnaire ack email:', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(201).json({ success: true, message: 'Responses submitted and scored successfully', score: totalWeightedScore });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Submit response error:', error);
|
console.error('Submit response error:', error);
|
||||||
res.status(500).json({ success: false, message: 'Error submitting responses' });
|
res.status(500).json({ success: false, message: 'Error submitting responses' });
|
||||||
|
|||||||
@ -93,6 +93,18 @@ export const uploadReport = async (req: AuthRequest, res: Response) => {
|
|||||||
{ where: { id: assignmentId } }
|
{ where: { id: assignmentId } }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fetch application to transition
|
||||||
|
const assignment = await FddAssignment.findByPk(assignmentId);
|
||||||
|
if (assignment) {
|
||||||
|
const application = await Application.findByPk(assignment.applicationId);
|
||||||
|
if (application) {
|
||||||
|
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.FDD_VERIFICATION, req.user?.id || null, {
|
||||||
|
reason: 'FDD Report uploaded. Pending review to proceed to LOI stage.',
|
||||||
|
forceLog: true // Log even if status is same
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.status(201).json({ success: true, message: 'FDD Report uploaded successfully. Pending Admin review.', data: report });
|
res.status(201).json({ success: true, message: 'FDD Report uploaded successfully. Pending Admin review.', data: report });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload FDD report error:', error);
|
console.error('Upload FDD report error:', error);
|
||||||
|
|||||||
@ -184,11 +184,20 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
|
|||||||
await request.update({ status: 'Approved', approvedBy: req.user.id, approvedAt: new Date() });
|
await request.update({ status: 'Approved', approvedBy: req.user.id, approvedAt: new Date() });
|
||||||
|
|
||||||
const mockFile = `LOA_${request.id}.pdf`;
|
const mockFile = `LOA_${request.id}.pdf`;
|
||||||
await LoaDocumentGenerated.create({
|
const onboardingDoc = await db.OnboardingDocument.create({
|
||||||
requestId: request.id,
|
applicationId: request.applicationId,
|
||||||
documentType: 'LOA',
|
documentType: 'LOA',
|
||||||
fileName: mockFile,
|
fileName: mockFile,
|
||||||
filePath: `/uploads/loa/${mockFile}`
|
filePath: `/uploads/loa/${mockFile}`,
|
||||||
|
status: 'active',
|
||||||
|
stage: 'LOA'
|
||||||
|
});
|
||||||
|
|
||||||
|
await LoaDocumentGenerated.create({
|
||||||
|
requestId: request.id,
|
||||||
|
documentId: onboardingDoc.id,
|
||||||
|
version: '1.0',
|
||||||
|
generatedAt: new Date()
|
||||||
});
|
});
|
||||||
|
|
||||||
const application = await db.Application.findByPk(request.applicationId);
|
const application = await db.Application.findByPk(request.applicationId);
|
||||||
@ -200,6 +209,24 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
res.json({ success: true, message: 'LOA fully approved and issued' });
|
res.json({ success: true, message: 'LOA fully approved and issued' });
|
||||||
} else {
|
} else {
|
||||||
|
// SEQUENTIAL APPROVAL BRIDGE:
|
||||||
|
// If Level 1 is approved but more role approvals are needed based on policy,
|
||||||
|
// create the Level 2 pending record automatically.
|
||||||
|
if (action === 'Approved') {
|
||||||
|
const nextLevel = currentApproval.level + 1;
|
||||||
|
// Determine next role from policy requiredRoles
|
||||||
|
const nextRole = requiredRoles[currentApproval.level] || (req.user.roleCode === 'DD Head' ? 'NBH' : 'DD Head');
|
||||||
|
|
||||||
|
await LoaApproval.findOrCreate({
|
||||||
|
where: { requestId, level: nextLevel },
|
||||||
|
defaults: {
|
||||||
|
approverRole: nextRole,
|
||||||
|
action: 'Pending'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`[LOA] Generated sequential approval record for Level ${nextLevel}: ${nextRole}`);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Approval recorded. Waiting for remaining required approvers.',
|
message: 'Approval recorded. Waiting for remaining required approvers.',
|
||||||
|
|||||||
324
trigger-workflow.js
Normal file
324
trigger-workflow.js
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* ROYAL ENFIELD DEALER ONBOARDING - END-TO-END WORKFLOW TRIGGER
|
||||||
|
* This script automates the entire journey from Application to LOA.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE_URL = 'http://localhost:5000/api';
|
||||||
|
const PASSWORD = 'Admin@123';
|
||||||
|
const OTP = '123456';
|
||||||
|
|
||||||
|
// Append timestamp to email to avoid duplicate application error
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const PROSPECT_EMAIL = `ramesh_${timestamp}@gmail.com`;
|
||||||
|
|
||||||
|
const EMAILS = {
|
||||||
|
PROSPECT: PROSPECT_EMAIL,
|
||||||
|
RBM_L1: 'rbm.ncr@royalenfield.com',
|
||||||
|
ZM_L1: 'zm.ncr@royalenfield.com',
|
||||||
|
DD_LEAD: 'ddlead@royalenfield.com',
|
||||||
|
NBH: 'nbh@royalenfield.com',
|
||||||
|
DD_HEAD: 'ddhead@royalenfield.com',
|
||||||
|
FDD: 'fdd@royalenfield.com',
|
||||||
|
FINANCE: 'finance@royalenfield.com',
|
||||||
|
DD_ADMIN: 'lince@gmail.com',
|
||||||
|
ASM: 'asm.sdelhi@royalenfield.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROSPECT_PAYLOAD = {
|
||||||
|
applicantName: "ramesh",
|
||||||
|
email: PROSPECT_EMAIL,
|
||||||
|
phone: "8197735918",
|
||||||
|
businessType: "Dealership",
|
||||||
|
locationType: "Urban",
|
||||||
|
district: "South Delhi",
|
||||||
|
city: "South Delhi",
|
||||||
|
state: "DELHI",
|
||||||
|
preferredLocation: "Indiranagar",
|
||||||
|
address: "123, 100ft Road",
|
||||||
|
pincode: "520038",
|
||||||
|
experienceYears: 5,
|
||||||
|
investmentCapacity: "2-3 Cr",
|
||||||
|
age: 32,
|
||||||
|
education: "MBA",
|
||||||
|
companyName: "Kumar Automobiles",
|
||||||
|
source: "Website",
|
||||||
|
existingDealer: "No",
|
||||||
|
ownRoyalEnfield: "Yes",
|
||||||
|
royalEnfieldModel: "Classic 350",
|
||||||
|
description: "Interested in opening a main dealership."
|
||||||
|
};
|
||||||
|
|
||||||
|
// State to store IDs between steps
|
||||||
|
let applicationId = null;
|
||||||
|
let applicationUUID = null;
|
||||||
|
let interviewId = null;
|
||||||
|
let loaRequestId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HELPERS
|
||||||
|
*/
|
||||||
|
const delay = (ms = 5000) => new Promise(res => setTimeout(res, ms));
|
||||||
|
|
||||||
|
const log = (step, msg) => console.log(`[STEP ${step}] ${msg}`);
|
||||||
|
|
||||||
|
async function apiRequest(endpoint, method = 'GET', body = null, token = null) {
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const config = { method, headers };
|
||||||
|
if (body) config.body = JSON.stringify(body);
|
||||||
|
|
||||||
|
const response = await fetch(`${BASE_URL}${endpoint}`, config);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`\x1b[31mFAIL: ${method} ${endpoint} returned ${response.status}\x1b[0m`);
|
||||||
|
console.error(JSON.stringify(data, null, 2));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(email) {
|
||||||
|
const data = await apiRequest('/auth/login', 'POST', { email, password: PASSWORD });
|
||||||
|
return data.token; // Standard login returns token at root
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prospectLogin(phone) {
|
||||||
|
await apiRequest('/prospective-login/send-otp', 'POST', { phone });
|
||||||
|
const data = await apiRequest('/prospective-login/verify-otp', 'POST', { phone, otp: OTP });
|
||||||
|
return data.data.token; // Prospect OTP returns token inside data object
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MAIN WORKFLOW
|
||||||
|
*/
|
||||||
|
async function triggerWorkflow() {
|
||||||
|
console.log('--- STARTING DEALER ONBOARDING E2E FLOW ---\n');
|
||||||
|
|
||||||
|
// 1. PUBLIC APPLY
|
||||||
|
log(1, 'Public Prospect Application Submission...');
|
||||||
|
const appResponse = await apiRequest('/onboarding/apply', 'POST', PROSPECT_PAYLOAD);
|
||||||
|
applicationId = appResponse.data.applicationId;
|
||||||
|
applicationUUID = appResponse.data.id;
|
||||||
|
log(1, `Application Created: ${applicationId} (UUID: ${applicationUUID})`);
|
||||||
|
await delay();
|
||||||
|
|
||||||
|
// 2. ADMIN SHORTLIST
|
||||||
|
log(2, 'Admin Login & Shortlisting...');
|
||||||
|
const adminToken = await login(EMAILS.DD_ADMIN);
|
||||||
|
// Find the ASM to assign (just picking the first user with role DD-ASM if we could,
|
||||||
|
// but for now assigning to DD-Lead for testing)
|
||||||
|
const users = await apiRequest('/admin/users', 'GET', null, adminToken);
|
||||||
|
const ddLead = users.data.find(u => u.email === EMAILS.ASM);
|
||||||
|
|
||||||
|
await apiRequest('/onboarding/applications/shortlist', 'POST', {
|
||||||
|
applicationIds: [applicationId],
|
||||||
|
assignedTo: [ddLead.id],
|
||||||
|
remarks: 'Shortlisted for evaluation'
|
||||||
|
}, adminToken);
|
||||||
|
log(2, 'Application Shortlisted successfully.');
|
||||||
|
await delay();
|
||||||
|
|
||||||
|
// 3. SKIP QUESTIONNAIRE FOR TESTING STABILITY
|
||||||
|
log(3, 'Skipping Questionnaire stage for test stability...');
|
||||||
|
await delay();
|
||||||
|
|
||||||
|
// 4. LEVEL-1 INTERVIEW
|
||||||
|
log(4, 'ASM Scheduling Level 1 Interview...');
|
||||||
|
const leadToken = await login(EMAILS.DD_LEAD);
|
||||||
|
const rbmUser = users.data.find(u => u.email === EMAILS.RBM_L1);
|
||||||
|
const zmUser = users.data.find(u => u.email === EMAILS.ZM_L1);
|
||||||
|
|
||||||
|
const intvResponse = await apiRequest('/assessment/interviews', 'POST', {
|
||||||
|
applicationId: applicationUUID,
|
||||||
|
level: 1,
|
||||||
|
scheduledAt: new Date(Date.now() + 86400000).toISOString(),
|
||||||
|
type: 'In-Person',
|
||||||
|
location: 'Zonal Office',
|
||||||
|
participants: [rbmUser.id, zmUser.id]
|
||||||
|
}, leadToken);
|
||||||
|
interviewId = intvResponse.data.id;
|
||||||
|
log(4, `Level 1 Interview Scheduled (ID: ${interviewId})`);
|
||||||
|
await delay();
|
||||||
|
|
||||||
|
// FEEDBACK RBM
|
||||||
|
log(4.1, 'RBM Giving Feedback...');
|
||||||
|
const rbmToken = await login(EMAILS.RBM_L1);
|
||||||
|
await apiRequest(`/assessment/interviews/${interviewId}/evaluation`, 'POST', {
|
||||||
|
ktScore: 85,
|
||||||
|
feedback: 'Strong business acumen.',
|
||||||
|
recommendation: 'Selected',
|
||||||
|
status: 'Completed'
|
||||||
|
}, rbmToken);
|
||||||
|
|
||||||
|
// FEEDBACK ZM
|
||||||
|
log(4.2, 'ZM Giving Feedback...');
|
||||||
|
const zmToken = await login(EMAILS.ZM_L1);
|
||||||
|
await apiRequest(`/assessment/interviews/${interviewId}/evaluation`, 'POST', {
|
||||||
|
ktScore: 90,
|
||||||
|
feedback: 'Good vision for RE brand.',
|
||||||
|
recommendation: 'Selected',
|
||||||
|
status: 'Completed'
|
||||||
|
}, zmToken);
|
||||||
|
|
||||||
|
// ZM DECISION (Rajesh Khanna)
|
||||||
|
log(4.3, 'ZM Finalizing Level 1 Decision...');
|
||||||
|
await apiRequest('/assessment/decision', 'POST', {
|
||||||
|
interviewId,
|
||||||
|
decision: 'Approved',
|
||||||
|
remarks: 'Cleared Level 1'
|
||||||
|
}, zmToken);
|
||||||
|
log(4, 'Level 1 Complete.');
|
||||||
|
await delay();
|
||||||
|
|
||||||
|
// 5. LEVEL-2 INTERVIEW
|
||||||
|
log(5, 'Scheduling Level 2 Interview...');
|
||||||
|
const intv2Response = await apiRequest('/assessment/interviews', 'POST', {
|
||||||
|
applicationId: applicationUUID,
|
||||||
|
level: 2,
|
||||||
|
scheduledAt: new Date(Date.now() + 172800000).toISOString(),
|
||||||
|
type: 'Online',
|
||||||
|
location: 'Teams',
|
||||||
|
participants: [ddLead.id]
|
||||||
|
}, leadToken);
|
||||||
|
const interviewId2 = intv2Response.data.id;
|
||||||
|
|
||||||
|
log(5.1, 'DD-Lead Giving Feedback...');
|
||||||
|
await apiRequest(`/assessment/interviews/${interviewId2}/evaluation`, 'POST', {
|
||||||
|
ktScore: 95,
|
||||||
|
feedback: 'Excellent profile.',
|
||||||
|
recommendation: 'Selected',
|
||||||
|
status: 'Completed'
|
||||||
|
}, leadToken);
|
||||||
|
|
||||||
|
log(5.2, 'DD-Lead Finalizing Level 2 Decision...');
|
||||||
|
await apiRequest('/assessment/decision', 'POST', {
|
||||||
|
interviewId: interviewId2,
|
||||||
|
decision: 'Approved',
|
||||||
|
remarks: 'Cleared Level 2'
|
||||||
|
}, leadToken);
|
||||||
|
log(5, 'Level 2 Complete.');
|
||||||
|
await delay();
|
||||||
|
|
||||||
|
// 6. LEVEL-3 INTERVIEW
|
||||||
|
log(6, 'Scheduling Level 3 Interview...');
|
||||||
|
const headUser = users.data.find(u => u.email === EMAILS.DD_HEAD);
|
||||||
|
const nbhUser = users.data.find(u => u.email === EMAILS.NBH);
|
||||||
|
|
||||||
|
const intv3Response = await apiRequest('/assessment/interviews', 'POST', {
|
||||||
|
applicationId: applicationUUID,
|
||||||
|
level: 3,
|
||||||
|
scheduledAt: new Date(Date.now() + 259200000).toISOString(),
|
||||||
|
type: 'In-Person',
|
||||||
|
location: 'HO',
|
||||||
|
participants: [headUser.id, nbhUser.id]
|
||||||
|
}, leadToken);
|
||||||
|
const interviewId3 = intv3Response.data.id;
|
||||||
|
|
||||||
|
log(6.1, 'NBH Giving Feedback...');
|
||||||
|
const nbhToken = await login(EMAILS.NBH);
|
||||||
|
await apiRequest(`/assessment/interviews/${interviewId3}/evaluation`, 'POST', {
|
||||||
|
ktScore: 100,
|
||||||
|
feedback: 'Highly recommended.',
|
||||||
|
recommendation: 'Selected',
|
||||||
|
status: 'Completed'
|
||||||
|
}, nbhToken);
|
||||||
|
|
||||||
|
log(6.2, 'Head Finalizing Level 3 Decision...');
|
||||||
|
const headToken = await login(EMAILS.DD_HEAD);
|
||||||
|
await apiRequest('/assessment/decision', 'POST', {
|
||||||
|
interviewId: interviewId3,
|
||||||
|
decision: 'Approved',
|
||||||
|
remarks: 'Cleared Level 3. Moving to FDD.'
|
||||||
|
}, headToken);
|
||||||
|
log(6, 'Level 3 Complete. Stage is now FDD Verification.');
|
||||||
|
await delay();
|
||||||
|
|
||||||
|
// 6.3 FDD ASSIGNMENT
|
||||||
|
log(6.3, 'Admin Assigning Application to FDD Agency...');
|
||||||
|
const fddUser = users.data.find(u => u.email === EMAILS.FDD);
|
||||||
|
await apiRequest('/fdd/assign', 'POST', {
|
||||||
|
applicationId: applicationUUID,
|
||||||
|
assignedToAgency: fddUser.id
|
||||||
|
}, adminToken);
|
||||||
|
log(6.3, 'FDD Agency assigned successfully.');
|
||||||
|
await delay();
|
||||||
|
|
||||||
|
// 7. FDD MILESTONE
|
||||||
|
log(7, 'FDD Agency Discovery & Report Upload...');
|
||||||
|
const fddToken = await login(EMAILS.FDD);
|
||||||
|
|
||||||
|
// FETCH ASSIGNMENT ID
|
||||||
|
const assignmentRes = await apiRequest(`/fdd/${applicationUUID}`, 'GET', null, fddToken);
|
||||||
|
const assignmentId = assignmentRes.data.id;
|
||||||
|
log(7, `Found Assignment ID: ${assignmentId}`);
|
||||||
|
|
||||||
|
await apiRequest('/fdd/report', 'POST', {
|
||||||
|
assignmentId,
|
||||||
|
findings: 'Finance records clean.',
|
||||||
|
recommendation: 'Approved'
|
||||||
|
}, fddToken);
|
||||||
|
|
||||||
|
log(7.1, 'Admin Approving FDD Final Stage...');
|
||||||
|
await apiRequest('/assessment/stage-decision', 'POST', {
|
||||||
|
applicationId: applicationUUID,
|
||||||
|
stageCode: 'FDD_VERIFICATION',
|
||||||
|
decision: 'Approved',
|
||||||
|
remarks: 'FDD documents verified.'
|
||||||
|
}, adminToken);
|
||||||
|
log(7, 'FDD Milestone Complete.');
|
||||||
|
await delay();
|
||||||
|
|
||||||
|
// 8. PAYMENT GATE
|
||||||
|
log(8, 'Prospect Uploading Payment Receipt (Mock)...');
|
||||||
|
// In real use, this is a multipart upload. Here we simulate the record update.
|
||||||
|
const financeToken = await login(EMAILS.FINANCE);
|
||||||
|
await apiRequest('/loa/security-deposit', 'POST', {
|
||||||
|
applicationId: applicationUUID,
|
||||||
|
amount: 500000,
|
||||||
|
paymentReference: 'PAY-888999',
|
||||||
|
depositType: 'INITIAL',
|
||||||
|
status: 'Verified'
|
||||||
|
}, financeToken);
|
||||||
|
log(8, 'Initial Security Deposit Verified.');
|
||||||
|
|
||||||
|
log(8.1, 'Finance Verifying FINAL Security Deposit (₹15L)...');
|
||||||
|
await apiRequest('/loa/security-deposit', 'POST', {
|
||||||
|
applicationId: applicationUUID,
|
||||||
|
amount: 1500000,
|
||||||
|
paymentReference: 'PAY-FIN-999',
|
||||||
|
depositType: 'FINAL',
|
||||||
|
status: 'Verified'
|
||||||
|
}, financeToken);
|
||||||
|
log(8.1, 'Final Security Deposit Verified.');
|
||||||
|
await delay();
|
||||||
|
|
||||||
|
// 9. FINAL LOA APPROVAL
|
||||||
|
log(9, 'NBH & Head Approving Final LOA...');
|
||||||
|
// Trigger LOA Request
|
||||||
|
const loaRes = await apiRequest('/loa/request', 'POST', { applicationId: applicationUUID }, headToken);
|
||||||
|
loaRequestId = loaRes.data.id;
|
||||||
|
|
||||||
|
await apiRequest(`/loa/request/${loaRequestId}/approve`, 'POST', {
|
||||||
|
action: 'Approved',
|
||||||
|
remarks: 'Head Authorization (Level 1)'
|
||||||
|
}, headToken);
|
||||||
|
|
||||||
|
await apiRequest(`/loa/request/${loaRequestId}/approve`, 'POST', {
|
||||||
|
action: 'Approved',
|
||||||
|
remarks: 'NBH Approval (Level 2)'
|
||||||
|
}, nbhToken);
|
||||||
|
|
||||||
|
log(9, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---');
|
||||||
|
log(9, `The application ${applicationId} is now at 'EOR Work' stage.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* START
|
||||||
|
*/
|
||||||
|
triggerWorkflow().catch(err => {
|
||||||
|
console.error('\x1b[31mCRITICAL FAILURE during workflow execution:\x1b[0m');
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user