progress track enhnced tested upto eor step work flow service file added for deale onboarding

This commit is contained in:
laxmanhalaki 2026-04-02 01:41:10 +05:30
parent d1d4601ac9
commit e64b64380d
8 changed files with 304 additions and 311 deletions

View File

@ -84,10 +84,13 @@ 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',
LOA_ISSUED: 'LOA Issued',
LOA_REJECTED: 'LOA Rejected',
EOR_COMPLETE: 'EOR Complete', EOR_COMPLETE: 'EOR Complete',
INAUGURATION: 'Inauguration', INAUGURATION: 'Inauguration',
ONBOARDED: 'Onboarded', ONBOARDED: 'Onboarded',
DISQUALIFIED: 'Disqualified' DISQUALIFIED: 'Disqualified',
LOI_REJECTED: 'LOI Rejected'
} as const; } as const;
// Termination Stages // Termination Stages

View File

@ -49,10 +49,11 @@ export const updateApplicationProgress = async (applicationId: string, stageName
await progress.update(updates); await progress.update(updates);
} }
// Mark previous stages as completed if this one is completed // Whenever a stage is marked 'active' or 'completed',
if (status === 'completed') { // all previous stages MUST be completed.
if (status === 'active' || status === 'completed') {
await ApplicationProgress.update( await ApplicationProgress.update(
{ status: 'completed', completionPercentage: 100 }, { status: 'completed', completionPercentage: 100, stageCompletedAt: new Date() },
{ {
where: { where: {
applicationId, applicationId,
@ -76,6 +77,7 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
// Map overallStatus to stage names // Map overallStatus to stage names
const statusToStageMap: Record<string, string> = { const statusToStageMap: Record<string, string> = {
'Submitted': 'Submitted', 'Submitted': 'Submitted',
'Questionnaire Pending': 'Submitted',
'Questionnaire Completed': 'Questionnaire', 'Questionnaire Completed': 'Questionnaire',
'Shortlisted': 'Shortlist', 'Shortlisted': 'Shortlist',
'Level 1 Interview Pending': '1st Level Interview', 'Level 1 Interview Pending': '1st Level Interview',
@ -87,6 +89,7 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
'Level 3 Approved': '3rd Level Interview', 'Level 3 Approved': '3rd Level Interview',
'FDD Verification': 'FDD', 'FDD Verification': 'FDD',
'LOI In Progress': 'LOI Approval', 'LOI In Progress': 'LOI Approval',
'Security Details': 'Security Details',
'Payment Pending': 'Security Details', 'Payment Pending': 'Security Details',
'LOI Issued': 'LOI Issue', 'LOI Issued': 'LOI Issue',
'Statutory LOI Ack': 'LOI Issue', 'Statutory LOI Ack': 'LOI Issue',
@ -96,7 +99,8 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
'Architecture Team Completion': 'Dealer Code Generation', 'Architecture Team Completion': 'Dealer Code Generation',
'Statutory GST': 'Dealer Code Generation', 'Statutory GST': 'Dealer Code Generation',
'LOA Pending': 'LOA', 'LOA Pending': 'LOA',
'EOR In Progress': 'LOA', 'LOA Issued': 'LOA',
'EOR In Progress': 'EOR Complete',
'EOR Complete': 'EOR Complete', 'EOR Complete': 'EOR Complete',
'Inauguration': 'Inauguration', 'Inauguration': 'Inauguration',
'Approved': 'Inauguration', 'Approved': 'Inauguration',
@ -108,12 +112,13 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
const stage = ONBOARDING_STAGES.find(s => s.name === currentStageName); const stage = ONBOARDING_STAGES.find(s => s.name === currentStageName);
if (stage) { if (stage) {
// Determine status for this stage // Determine status for this stage
// If the status IS exactly the target "Complete" status for a stage, mark it as completed
const isCompleted = [ const isCompleted = [
'Submitted', 'Questionnaire Completed', 'Shortlisted', 'Level 3 Approved', 'Submitted', 'Questionnaire Completed', 'Shortlisted', 'Level 1 Approved',
'LOI Issued', 'EOR Complete', 'Approved', 'Onboarded' 'Level 2 Approved', 'Level 3 Approved', 'LOI Issued', 'EOR Complete',
'Approved', 'Onboarded'
].includes(overallStatus); ].includes(overallStatus);
await updateApplicationProgress(applicationId, currentStageName, isCompleted ? 'completed' : 'active', isCompleted ? 100 : 50); await updateApplicationProgress(applicationId, currentStageName, isCompleted ? 'completed' : 'active', isCompleted ? 100 : 50);
} }
} }

View File

@ -7,7 +7,9 @@ const {
import { AuthRequest } from '../../types/express.types.js'; import { AuthRequest } from '../../types/express.types.js';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import * as EmailService from '../../common/utils/email.service.js'; import * as EmailService from '../../common/utils/email.service.js';
import { APPLICATION_STAGES } from '../../common/config/constants.js'; import { APPLICATION_STAGES, APPLICATION_STATUS } from '../../common/config/constants.js';
import { WorkflowService } from '../../services/WorkflowService.js';
import { syncApplicationProgress } from '../../common/utils/progress.js';
const getLocationAncestors = async (locationId: string): Promise<string[]> => { const getLocationAncestors = async (locationId: string): Promise<string[]> => {
const district: any = await District.findByPk(locationId); const district: any = await District.findByPk(locationId);
@ -60,22 +62,14 @@ const processStageDecision = async (params: {
const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : []; const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : [];
// Check if user is an assigned participant for this specifically (Interviews use metadata mapping) // Check if user is an assigned participant
const userAssignments = await db.RequestParticipant.findAll({ const userAssignments = await db.RequestParticipant.findAll({
where: { where: { requestId: applicationId, requestType: 'application', userId }
requestId: applicationId,
requestType: 'application',
userId: userId
}
}); });
// Strategy: If it's an interview, check interviewLevel. If it's a stage, check stageCode.
const isAssigned = userAssignments.some((p: any) => { const isAssigned = userAssignments.some((p: any) => {
if (!p.metadata) return false; if (!p.metadata) return false;
if (interviewId && p.metadata.interviewLevel) { if (interviewId && p.metadata.interviewLevel) return true;
// Check if this participant is for THIS interview (rough check via level)
return true;
}
if (p.metadata.stageCode === stageCode) return true; if (p.metadata.stageCode === stageCode) return true;
if (Array.isArray(p.metadata.allAssignments) && p.metadata.allAssignments.includes(stageCode)) return true; if (Array.isArray(p.metadata.allAssignments) && p.metadata.allAssignments.includes(stageCode)) return true;
return false; return false;
@ -83,113 +77,80 @@ const processStageDecision = async (params: {
const assignedRole = userAssignments.find((p: any) => p.metadata?.role)?.metadata?.role; const assignedRole = userAssignments.find((p: any) => p.metadata?.role)?.metadata?.role;
console.log(`[decision] User: ${userId}, Role: ${roleCode}, Stage: ${stageCode}, isAssigned: ${isAssigned}`);
// Forbidden if not Super Admin AND not in required roles AND not an assigned participant for this stage
if (roleCode !== 'Super Admin' && requiredRoles.length > 0 && !requiredRoles.includes(roleCode) && !isAssigned) { if (roleCode !== 'Super Admin' && requiredRoles.length > 0 && !requiredRoles.includes(roleCode) && !isAssigned) {
return { forbidden: true, policy, requiredRoles, currentRole: roleCode }; return { forbidden: true, policy, requiredRoles, currentRole: roleCode };
} }
// Record Action // Record Action - Robust handle for null interviewId which breaks unique constraint in Postgres
await db.StageApprovalAction.upsert({ if (!interviewId) {
id: undefined, // Let it generate or find by unique index const existing = await db.StageApprovalAction.findOne({
where: { applicationId, stageCode, actorUserId: userId, interviewId: null }
});
if (existing) {
await existing.update({ decision, remarks: remarks || null, actorRole: assignedRole || roleCode });
} else {
await db.StageApprovalAction.create({
applicationId, applicationId,
interviewId: interviewId || null,
stageCode, stageCode,
actorUserId: userId, actorUserId: userId,
actorRole: assignedRole || roleCode, actorRole: assignedRole || roleCode,
decision, decision,
remarks: remarks || null remarks: remarks || null
}); });
}
} else {
await db.StageApprovalAction.upsert({
applicationId,
interviewId,
stageCode,
actorUserId: userId,
actorRole: assignedRole || roleCode,
decision,
remarks: remarks || null
});
}
// Update the evaluation decision and recommendation for dashboard consistency
if (interviewId) { if (interviewId) {
await InterviewEvaluation.update( await InterviewEvaluation.update(
{ { decision, recommendation: decision },
decision: decision,
recommendation: decision // Sync for combined dashboard view
},
{ where: { interviewId, evaluatorId: userId } } { where: { interviewId, evaluatorId: userId } }
); );
} }
// Evaluate Policy // Evaluate Policy via Centralized Service (FIXED unique user count)
const actions = await db.StageApprovalAction.findAll({ const evaluation = await WorkflowService.evaluateStagePolicy(applicationId, stageCode);
where: { applicationId, stageCode }
});
const approvedActions = actions.filter((a: any) => a.decision === 'Approved');
const uniqueApprovalsByRole = new Set(approvedActions.map((a: any) => a.actorRole));
const isSuperAdminApproval = Array.from(uniqueApprovalsByRole).includes('Super Admin');
const hasRejection = actions.some((a: any) => a.decision === 'Rejected');
const hasAllRequiredRoleApprovals = (requiredRoles.length === 0 || isSuperAdminApproval)
? true
: requiredRoles.every((role: string) => uniqueApprovalsByRole.has(role));
const meetsMinApprovals = isSuperAdminApproval || uniqueApprovalsByRole.size >= (policy.minApprovals || 1);
console.log(`[decision] Policy Meet: ${hasAllRequiredRoleApprovals && meetsMinApprovals} (Rejection: ${hasRejection})`);
const hasRejection = decision === 'Rejected'; // Immediate rejection if ANY required actor rejects (business rule)
let statusUpdated = false; let statusUpdated = false;
if (hasRejection) { if (hasRejection) {
await db.Application.update({ const application = await db.Application.findByPk(applicationId);
overallStatus: 'Rejected', if (application) {
currentStage: 'Rejected' await WorkflowService.transitionApplication(application, APPLICATION_STATUS.REJECTED, userId, {
}, { where: { id: applicationId } }); reason: `Rejected during ${stageCode} stage: ${remarks}`,
stage: APPLICATION_STAGES.REJECTED
await db.ApplicationStatusHistory.create({
applicationId,
previousStatus: 'In Progress',
newStatus: 'Rejected',
changedBy: userId,
reason: `Rejected during ${stageCode} stage`
}); });
statusUpdated = true; statusUpdated = true;
} else if (hasAllRequiredRoleApprovals && meetsMinApprovals) {
if (nextStatus) {
const validStages = Object.values(APPLICATION_STAGES);
const updateData: any = {
overallStatus: nextStatus,
progressPercentage: nextProgress || undefined,
updatedAt: new Date()
};
if (nextStatus && validStages.includes(nextStatus as any)) {
updateData.currentStage = nextStatus;
} }
} else if (evaluation.policyMet && nextStatus) {
await db.Application.update(updateData, { where: { id: applicationId } }); const application = await db.Application.findByPk(applicationId);
if (application) {
await db.ApplicationStatusHistory.create({ await WorkflowService.transitionApplication(application, nextStatus, userId, {
applicationId, reason: `Policy met for ${stageCode}`,
previousStatus: 'In Progress', progressPercentage: nextProgress
newStatus: nextStatus,
changedBy: userId,
reason: `Policy met for ${stageCode}`
}); });
// Sync Progress tracking
const { syncApplicationProgress } = await import('../../common/utils/progress.js');
await syncApplicationProgress(applicationId, nextStatus);
statusUpdated = true; statusUpdated = true;
} }
} }
const message = hasRejection ? 'Rejected'
: statusUpdated ? 'Policy satisfied. Stage complete.'
: `Approval recorded. Waiting for ${requiredRoles.filter(r => !uniqueApprovalsByRole.has(r)).join(', ') || 'other approvers'}.`;
return { return {
success: true, success: true,
message, message: hasRejection ? 'Rejected' : statusUpdated ? 'Policy satisfied. Stage complete.' : 'Approval recorded.',
policy, policy,
requiredRoles, requiredRoles: evaluation.policy.requiredRoles,
uniqueApprovalsByRole, uniqueApprovalsByRole: evaluation.approvedRoles,
hasAllRequiredRoleApprovals, hasAllRequiredRoleApprovals: evaluation.hasAllRequiredRoleApprovals,
meetsMinApprovals, meetsMinApprovals: evaluation.meetsMinApprovals,
statusUpdated statusUpdated
}; };
}; };
@ -304,25 +265,12 @@ export const submitQuestionnaireResponse = async (req: AuthRequest, res: Respons
status: 'Completed' status: 'Completed'
}); });
// Update Application if (application) {
await application.update({ await WorkflowService.transitionApplication(application, APPLICATION_STATUS.QUESTIONNAIRE_COMPLETED, req.user?.id || null, {
score: totalWeightedScore, reason: 'Questionnaire submitted by applicant',
overallStatus: 'Questionnaire Completed',
progressPercentage: 20 progressPercentage: 20
}); });
}
// Log Status History
await db.ApplicationStatusHistory.create({
applicationId: application.id,
previousStatus: 'Questionnaire Pending',
newStatus: 'Questionnaire Completed',
changedBy: req.user?.id || null,
reason: 'Questionnaire submitted by applicant'
});
// Sync Progress tracking
const { syncApplicationProgress } = await import('../../common/utils/progress.js');
await syncApplicationProgress(application.id, 'Questionnaire Completed');
res.status(201).json({ success: true, message: 'Responses submitted and scored successfully', score: totalWeightedScore }); res.status(201).json({ success: true, message: 'Responses submitted and scored successfully', score: totalWeightedScore });
} catch (error) { } catch (error) {
@ -380,7 +328,12 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
const newStatus = statusMap[levelNum] || 'Interview Scheduled'; const newStatus = statusMap[levelNum] || 'Interview Scheduled';
await db.Application.update({ overallStatus: newStatus }, { where: { id: applicationId } }); const application = await db.Application.findByPk(applicationId);
if (application) {
await WorkflowService.transitionApplication(application, newStatus, req.user?.id || null, {
reason: `Interview Level ${levelNum} Scheduled`
});
}
// MOCK INTEGRATIONS // MOCK INTEGRATIONS
// 1. Google Calendar Mock // 1. Google Calendar Mock
@ -400,7 +353,6 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
); );
} }
const application = await db.Application.findByPk(applicationId);
let participantIds: string[] = Array.isArray(participants) ? participants : []; let participantIds: string[] = Array.isArray(participants) ? participants : [];
// Auto-fill participants from pre-assigned RequestParticipants if not provided // Auto-fill participants from pre-assigned RequestParticipants if not provided
@ -964,11 +916,13 @@ export const submitStageDecision = async (req: AuthRequest, res: Response) => {
if (result.noPolicy) { if (result.noPolicy) {
// Fallback: If no policy, just update application status directly (legacy behavior) // Fallback: If no policy, just update application status directly (legacy behavior)
if (nextStatus) { if (nextStatus) {
await db.Application.update({ const application = await db.Application.findByPk(applicationId);
overallStatus: nextStatus, if (application) {
currentStage: nextStatus, await WorkflowService.transitionApplication(application, nextStatus, req.user?.id || null, {
progressPercentage: nextProgress || undefined reason: 'Fallback Transition (No Policy)',
}, { where: { id: applicationId } }); progressPercentage: nextProgress
});
}
} }
return res.json({ success: true, message: 'Status updated (No policy found)' }); return res.json({ success: true, message: 'Status updated (No policy found)' });
} }

View File

@ -6,8 +6,9 @@ const {
Resignation, RelocationRequest, ConstitutionalChange Resignation, RelocationRequest, ConstitutionalChange
} = db; } = db;
import { AuthRequest } from '../../types/express.types.js'; import { AuthRequest } from '../../types/express.types.js';
import { AUDIT_ACTIONS } from '../../common/config/constants.js'; import { AUDIT_ACTIONS, APPLICATION_STATUS } from '../../common/config/constants.js';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import { WorkflowService } from '../../services/WorkflowService.js';
export const getDealers = async (req: Request, res: Response) => { export const getDealers = async (req: Request, res: Response) => {
try { try {
@ -32,6 +33,20 @@ export const createDealer = async (req: AuthRequest, res: Response) => {
const application = await Application.findByPk(applicationId); const application = await Application.findByPk(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' });
// SRS Validation: Only allow onboarding at the 'Inauguration' stage
if (application.overallStatus !== APPLICATION_STATUS.INAUGURATION) {
return res.status(400).json({
success: false,
message: `Application must be in the '${APPLICATION_STATUS.INAUGURATION}' stage before final onboarding. Current status: ${application.overallStatus}`
});
}
// Optional: Check EOR Progress (if progressPercentage is tracked accurately)
if (application.progressPercentage < 95 && application.overallStatus !== APPLICATION_STATUS.INAUGURATION) {
// We can be conservative here or strictly check 100%
// For now, enforcing the Status is the most critical SRS requirement.
}
// Find existing dealer or auto-detect dealer code // Find existing dealer or auto-detect dealer code
let targetDealerCodeId = dealerCodeId; let targetDealerCodeId = dealerCodeId;
if (!targetDealerCodeId) { if (!targetDealerCodeId) {
@ -165,24 +180,12 @@ export const createDealer = async (req: AuthRequest, res: Response) => {
} }
// Final Step: Update Application Status to Onboarded // Final Step: Update Application Status to Onboarded
await application.update({ if (application) {
overallStatus: 'Onboarded', await WorkflowService.transitionApplication(application, APPLICATION_STATUS.ONBOARDED, req.user?.id || null, {
progressPercentage: 100, reason: 'Dealer Onboarding Finalized',
updatedAt: new Date() progressPercentage: 100
});
// Update progress tracking
const { updateApplicationProgress } = await import('../../common/utils/progress.js');
await updateApplicationProgress(application.id, 'Onboarded', 'completed', 100);
// Add history entry
await db.ApplicationStatusHistory.create({
applicationId: application.id,
previousStatus: application.overallStatus,
newStatus: 'Onboarded',
changedBy: req.user?.id,
reason: 'Dealer Onboarding Finalized'
}); });
}
res.status(201).json({ res.status(201).json({
success: true, success: true,

View File

@ -2,7 +2,8 @@ import { Request, Response } from 'express';
import db from '../../database/models/index.js'; import db from '../../database/models/index.js';
const { LoaRequest, LoaApproval, LoaDocumentGenerated, SecurityDeposit, AuditLog, StageApprovalPolicy, StageApprovalAction, User } = db; const { LoaRequest, LoaApproval, LoaDocumentGenerated, SecurityDeposit, AuditLog, StageApprovalPolicy, StageApprovalAction, User } = db;
import { AuthRequest } from '../../types/express.types.js'; import { AuthRequest } from '../../types/express.types.js';
import { AUDIT_ACTIONS } from '../../common/config/constants.js'; import { AUDIT_ACTIONS, APPLICATION_STATUS } from '../../common/config/constants.js';
import { WorkflowService } from '../../services/WorkflowService.js';
const LOA_STAGE_CODE = 'LOA_APPROVAL'; const LOA_STAGE_CODE = 'LOA_APPROVAL';
@ -63,8 +64,8 @@ export const createRequest = async (req: AuthRequest, res: Response) => {
} }
}); });
await application.update({ await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOA_PENDING, req.user?.id || null, {
overallStatus: 'LOA Pending', reason: 'LOA Request initiated with DD Head approval',
progressPercentage: 92 progressPercentage: 92
}); });
@ -133,11 +134,14 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
if (action === 'Rejected' || hasRejection) { if (action === 'Rejected' || hasRejection) {
await request.update({ status: 'Rejected' }); await request.update({ status: 'Rejected' });
await db.Application.update({ const application = await db.Application.findByPk(request.applicationId);
overallStatus: 'LOA Rejected', if (application) {
currentStage: 'Rejected', await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOA_REJECTED, req.user.id, {
reason: 'LOA Request rejected during approval',
stage: 'Rejected',
progressPercentage: 92 progressPercentage: 92
}, { where: { id: request.applicationId } }); });
}
return res.json({ success: true, message: 'LOA Request rejected' }); return res.json({ success: true, message: 'LOA Request rejected' });
} }
@ -152,10 +156,13 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
filePath: `/uploads/loa/${mockFile}` filePath: `/uploads/loa/${mockFile}`
}); });
await db.Application.update({ const application = await db.Application.findByPk(request.applicationId);
overallStatus: 'Authorized for Operations', if (application) {
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOA_ISSUED, req.user.id, {
reason: 'LOA fully approved and issued',
progressPercentage: 97 progressPercentage: 97
}, { where: { id: request.applicationId } }); });
}
res.json({ success: true, message: 'LOA fully approved and issued' }); res.json({ success: true, message: 'LOA fully approved and issued' });
} else { } else {
res.json({ res.json({

View File

@ -2,7 +2,8 @@ import { Request, Response } from 'express';
import db from '../../database/models/index.js'; import db from '../../database/models/index.js';
const { LoiRequest, LoiApproval, LoiDocumentGenerated, LoiAcknowledgement, AuditLog, StageApprovalPolicy, StageApprovalAction, User } = db; const { LoiRequest, LoiApproval, LoiDocumentGenerated, LoiAcknowledgement, AuditLog, StageApprovalPolicy, StageApprovalAction, User } = db;
import { AuthRequest } from '../../types/express.types.js'; import { AuthRequest } from '../../types/express.types.js';
import { AUDIT_ACTIONS } from '../../common/config/constants.js'; import { AUDIT_ACTIONS, APPLICATION_STATUS } from '../../common/config/constants.js';
import { WorkflowService } from '../../services/WorkflowService.js';
const LOI_STAGE_CODE = 'LOI_APPROVAL'; const LOI_STAGE_CODE = 'LOI_APPROVAL';
@ -54,10 +55,13 @@ export const acknowledgeRequest = async (req: AuthRequest, res: Response) => {
status: 'Acknowledged' status: 'Acknowledged'
}); });
await db.Application.update({ const application = await db.Application.findByPk(request.applicationId);
overallStatus: 'Dealer Code Generation', if (application) {
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.DEALER_CODE_GENERATION, req.user?.id || null, {
reason: 'LOI Acknowledged by applicant',
progressPercentage: 90 progressPercentage: 90
}, { where: { id: request.applicationId } }); });
}
res.json({ success: true, message: 'LOI Acknowledged by applicant' }); res.json({ success: true, message: 'LOI Acknowledged by applicant' });
} catch (error) { } catch (error) {
@ -90,8 +94,8 @@ export const createRequest = async (req: AuthRequest, res: Response) => {
} }
}); });
await application.update({ await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOI_IN_PROGRESS, req.user?.id || null, {
overallStatus: 'LOI In Progress', reason: 'LOI Request initiated with Finance approval',
progressPercentage: 75 progressPercentage: 75
}); });
@ -182,11 +186,14 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
// 2. Handle Logic based on Action // 2. Handle Logic based on Action
if (action === 'Rejected' || hasRejection) { if (action === 'Rejected' || hasRejection) {
await request.update({ status: 'Rejected' }); await request.update({ status: 'Rejected' });
await db.Application.update({ const application = await db.Application.findByPk(request.applicationId);
overallStatus: 'LOI Rejected', if (application) {
currentStage: 'Rejected', await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOI_REJECTED, req.user.id, {
reason: 'LOI Request rejected during approval',
stage: 'Rejected',
progressPercentage: 75 progressPercentage: 75
}, { where: { id: request.applicationId } }); });
}
return res.json({ success: true, message: 'LOI Request rejected' }); return res.json({ success: true, message: 'LOI Request rejected' });
} }
@ -203,10 +210,13 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
filePath: `/uploads/loi/${mockFile}` filePath: `/uploads/loi/${mockFile}`
}); });
await db.Application.update({ const application = await db.Application.findByPk(request.applicationId);
overallStatus: 'Security Details', if (application) {
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.SECURITY_DETAILS, req.user.id, {
reason: 'LOI Request fully approved and document generated',
progressPercentage: 80 progressPercentage: 80
}, { where: { id: request.applicationId } }); });
}
res.json({ success: true, message: 'LOI Request fully approved and document generated' }); res.json({ success: true, message: 'LOI Request fully approved and document generated' });
} else { } else {

View File

@ -8,6 +8,7 @@ import { AuthRequest } from '../../types/express.types.js';
import { sendOpportunityEmail, sendNonOpportunityEmail } from '../../common/utils/email.service.js'; import { sendOpportunityEmail, sendNonOpportunityEmail } from '../../common/utils/email.service.js';
import { syncLocationManagers } from '../master/syncHierarchy.service.js'; import { syncLocationManagers } from '../master/syncHierarchy.service.js';
import { WorkflowService } from '../../services/WorkflowService.js';
// Helper to find district by name and state name combination // Helper to find district by name and state name combination
const findDistrictByName = async (districtName: string, stateName?: string) => { const findDistrictByName = async (districtName: string, stateName?: string) => {
@ -93,13 +94,10 @@ export const submitApplication = async (req: AuthRequest, res: Response) => {
timeline: [] timeline: []
}); });
// Log Status History // Use WorkflowService for initial status and progress sync
await ApplicationStatusHistory.create({ await WorkflowService.transitionApplication(application, application.overallStatus, req.user?.id || null, {
applicationId: application.id, reason: 'Initial Submission',
previousStatus: null, stage: application.currentStage
newStatus: application.overallStatus,
changedBy: req.user?.id || null,
reason: 'Initial Submission'
}); });
// Send Email (Async) // Send Email (Async)
@ -207,37 +205,11 @@ export const updateApplicationStatus = async (req: AuthRequest, res: Response) =
const application = await Application.findByPk(id); const application = await Application.findByPk(id);
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' });
const previousStatus = application.overallStatus; if (application) {
await WorkflowService.transitionApplication(application, status, req.user?.id || null, {
await application.update({ reason: reason || 'Manual Status Update',
overallStatus: status, stage: stage
currentStage: stage || application.currentStage,
updatedAt: new Date()
}); });
// Log Status History
await ApplicationStatusHistory.create({
applicationId: application.id,
previousStatus,
newStatus: status,
changedBy: req.user?.id,
reason
});
await AuditLog.create({
userId: req.user?.id,
action: AUDIT_ACTIONS.UPDATED,
entityType: 'application',
entityId: application.id,
newData: { status, stage }
});
// Sync Progress tracking based on new status
try {
const { syncApplicationProgress } = await import('../../common/utils/progress.js');
await syncApplicationProgress(application.id, status);
} catch (progErr) {
console.error('Progress sync error:', progErr);
} }
res.json({ success: true, message: 'Application status updated successfully' }); res.json({ success: true, message: 'Application status updated successfully' });
@ -402,59 +374,34 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => {
// but add ALL as participants to enforce dual-responsibility. // but add ALL as participants to enforce dual-responsibility.
const primaryAssigneeId = assignedTo[0]; const primaryAssigneeId = assignedTo[0];
// Update Applications // Update Applications sequentially via WorkflowService for consistency
await Application.update({ for (const appId of applicationIds) {
const application = await Application.findByPk(appId);
if (application) {
await application.update({
ddLeadShortlisted: true, ddLeadShortlisted: true,
isShortlisted: true, isShortlisted: true,
overallStatus: 'Shortlisted',
progressPercentage: 30,
assignedTo: primaryAssigneeId, assignedTo: primaryAssigneeId,
updatedAt: new Date(), updatedAt: new Date(),
}, {
where: {
id: { [Op.in]: applicationIds }
}
}); });
// Add all assigned users as participants for each application await WorkflowService.transitionApplication(application, APPLICATION_STATUS.SHORTLISTED, req.user?.id || null, {
for (const appId of applicationIds) { reason: remarks || 'Bulk Shortlist',
progressPercentage: 30
});
// Add all assigned users as participants
for (const userId of assignedTo) { for (const userId of assignedTo) {
await db.RequestParticipant.findOrCreate({ await db.RequestParticipant.findOrCreate({
where: { where: { requestId: appId, requestType: 'application', userId, participantType: 'assignee' },
requestId: appId, defaults: { joinedMethod: 'auto' }
requestType: 'application',
userId,
participantType: 'assignee'
},
defaults: {
joinedMethod: 'auto'
}
}); });
} }
// AUTO-FILL Interview Evaluators for all 3 levels // AUTO-FILL Interview Evaluators
await assignStageEvaluators(appId); await assignStageEvaluators(appId);
} }
}
// Create Status History Entries
const historyEntries = applicationIds.map(appId => ({
applicationId: appId,
previousStatus: 'Questionnaire Completed',
newStatus: 'Shortlisted',
changedBy: req.user?.id,
reason: remarks || 'Bulk Shortlist'
}));
await ApplicationStatusHistory.bulkCreate(historyEntries);
// Audit Log
const auditEntries = applicationIds.map(appId => ({
userId: req.user?.id,
action: AUDIT_ACTIONS.SHORTLISTED,
entityType: 'application',
entityId: appId,
newData: { isShortlisted: true, assignedTo }
}));
await AuditLog.bulkCreate(auditEntries);
res.json({ res.json({
success: true, success: true,
@ -674,10 +621,13 @@ export const assignArchitectureTeam = async (req: AuthRequest, res: Response) =>
architectureAssignedTo: targetUserId, architectureAssignedTo: targetUserId,
architectureStatus: 'IN_PROGRESS', architectureStatus: 'IN_PROGRESS',
architectureAssignedDate: new Date(), architectureAssignedDate: new Date(),
overallStatus: 'Architecture Team Assigned',
updatedAt: new Date() updatedAt: new Date()
}); });
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.ARCHITECTURE_TEAM_ASSIGNED, req.user?.id || null, {
reason: remarks || 'Architecture team assigned'
});
// Add as participant // Add as participant
await db.RequestParticipant.findOrCreate({ await db.RequestParticipant.findOrCreate({
where: { where: {
@ -689,14 +639,6 @@ export const assignArchitectureTeam = async (req: AuthRequest, res: Response) =>
defaults: { joinedMethod: 'auto' } defaults: { joinedMethod: 'auto' }
}); });
await AuditLog.create({
userId: req.user?.id,
action: AUDIT_ACTIONS.UPDATED,
entityType: 'application',
entityId: application.id,
newData: { architectureAssignedTo: targetUserId, remarks }
});
res.json({ success: true, message: 'Architecture team assigned successfully' }); res.json({ success: true, message: 'Architecture team assigned successfully' });
} catch (error) { } catch (error) {
console.error('Assign architecture team error:', error); console.error('Assign architecture team error:', error);
@ -718,21 +660,18 @@ export const updateArchitectureStatus = async (req: AuthRequest, res: Response)
}; };
// Sync overall status if architecture is completed // Sync overall status if architecture is completed
if (status === 'COMPLETED') { const targetOverallStatus = status === 'COMPLETED'
updateData.overallStatus = 'Architecture Team Completion'; ? APPLICATION_STATUS.ARCHITECTURE_TEAM_COMPLETION
updateData.architectureCompletionDate = new Date(); : APPLICATION_STATUS.ARCHITECTURE_TEAM_ASSIGNED;
} else if (status === 'IN_PROGRESS') {
updateData.overallStatus = 'Architecture Team Assigned';
}
await application.update(updateData); await application.update({
architectureStatus: status,
architectureCompletionDate: status === 'COMPLETED' ? new Date() : application.architectureCompletionDate,
updatedAt: new Date()
});
await AuditLog.create({ await WorkflowService.transitionApplication(application, targetOverallStatus, req.user?.id || null, {
userId: req.user?.id, reason: remarks || `Architecture status updated to ${status}`
action: AUDIT_ACTIONS.UPDATED,
entityType: 'application',
entityId: application.id,
newData: { architectureStatus: status, remarks }
}); });
res.json({ success: true, message: 'Architecture status updated successfully' }); res.json({ success: true, message: 'Architecture status updated successfully' });
@ -767,33 +706,11 @@ export const generateDealerCodes = async (req: AuthRequest, res: Response) => {
generatedBy: req.user?.id generatedBy: req.user?.id
}); });
const previousStatus = application.overallStatus; await WorkflowService.transitionApplication(application, APPLICATION_STATUS.DEALER_CODE_GENERATION, req.user?.id || null, {
reason: 'SAP Dealer Codes Generated',
// Update application status to reflect codes are generated
// We STAY in Dealer Code Generation until architecture is assigned
await application.update({
overallStatus: 'Dealer Code Generation',
progressPercentage: 80 progressPercentage: 80
}); });
// Log Status History
await db.ApplicationStatusHistory.create({
applicationId: application.id,
previousStatus,
newStatus: 'Dealer Code Generation',
changedBy: req.user?.id,
reason: 'SAP Dealer Codes Generated'
});
// Audit Log
await db.AuditLog.create({
userId: req.user?.id,
action: AUDIT_ACTIONS.DEALER_CODE_GENERATED,
entityType: 'application',
entityId: application.id,
newData: { dealerCode: sapData.salesCode }
});
res.json({ res.json({
success: true, success: true,
message: 'SAP Dealer Codes generated successfully (Mock)', message: 'SAP Dealer Codes generated successfully (Mock)',

View File

@ -0,0 +1,94 @@
import db from '../database/models/index.js';
const { Application, ApplicationStatusHistory, AuditLog } = db;
import { syncApplicationProgress } from '../common/utils/progress.js';
import { AUDIT_ACTIONS, APPLICATION_STAGES } from '../common/config/constants.js';
export class WorkflowService {
/**
* Standardized method to transition an application's status
* Handles: DB Update, Status History, Audit Log, and Progress Synchronization
*/
static async transitionApplication(application: any, targetStatus: string, userId: string | null = null, metadata: any = {}) {
const previousStatus = application.overallStatus;
const { reason, stage, progressPercentage } = metadata;
const updateData: any = {
overallStatus: targetStatus,
updatedAt: new Date()
};
// Update stage if provided and valid
if (stage && Object.values(APPLICATION_STAGES).includes(stage)) {
updateData.currentStage = stage;
}
// Update progress percentage if explicitly provided
if (progressPercentage !== undefined) {
updateData.progressPercentage = progressPercentage;
}
// 1. Update Application Record
await application.update(updateData);
// 2. Log Status History
await ApplicationStatusHistory.create({
applicationId: application.id,
previousStatus,
newStatus: targetStatus,
changedBy: userId,
changeReason: reason || `Transitioned to ${targetStatus}`
});
// 3. Create Audit Log
await AuditLog.create({
userId: userId,
action: AUDIT_ACTIONS.UPDATED,
entityType: 'application',
entityId: application.id,
newData: { status: targetStatus, stage: stage || application.currentStage }
});
// 4. Synchronize Progress Tracker (The true source of truth for the frontend UI)
await syncApplicationProgress(application.id, targetStatus);
console.log(`[WorkflowService] Transitioned Application ${application.applicationId} from ${previousStatus} to ${targetStatus}`);
return application;
}
/**
* Centralized policy evaluation for multi-role stage approvals
* FIXED: Counts unique users instead of unique roles to allow same-role approvals
*/
static async evaluateStagePolicy(applicationId: string, stageCode: string) {
const policy = await db.StageApprovalPolicy.findOne({ where: { stageCode } });
if (!policy) return { policyMet: true }; // No policy means no restriction
const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : [];
// Fetch all approved actions for this stage
const actions = await db.StageApprovalAction.findAll({
where: { applicationId, stageCode, decision: 'Approved' }
});
const uniqueApprovers = new Set(actions.map((a: any) => a.actorUserId));
const approvedRoles = new Set(actions.map((a: any) => a.actorRole));
const isSuperAdminApproval = Array.from(approvedRoles).includes('Super Admin');
const hasAllRequiredRoleApprovals = (requiredRoles.length === 0 || isSuperAdminApproval)
? true
: requiredRoles.every((role: string) => approvedRoles.has(role));
const meetsMinApprovals = isSuperAdminApproval || uniqueApprovers.size >= (policy.minApprovals || 1);
return {
policyMet: hasAllRequiredRoleApprovals && meetsMinApprovals,
policy,
uniqueApprovers: Array.from(uniqueApprovers),
approvedRoles: Array.from(approvedRoles),
hasAllRequiredRoleApprovals,
meetsMinApprovals
};
}
}