progress track enhnced tested upto eor step work flow service file added for deale onboarding
This commit is contained in:
parent
d1d4601ac9
commit
e64b64380d
@ -84,10 +84,13 @@ export const APPLICATION_STATUS = {
|
||||
STATUTORY_LOI_ACK: 'Statutory LOI Ack',
|
||||
EOR_IN_PROGRESS: 'EOR In Progress',
|
||||
LOA_PENDING: 'LOA Pending',
|
||||
LOA_ISSUED: 'LOA Issued',
|
||||
LOA_REJECTED: 'LOA Rejected',
|
||||
EOR_COMPLETE: 'EOR Complete',
|
||||
INAUGURATION: 'Inauguration',
|
||||
ONBOARDED: 'Onboarded',
|
||||
DISQUALIFIED: 'Disqualified'
|
||||
DISQUALIFIED: 'Disqualified',
|
||||
LOI_REJECTED: 'LOI Rejected'
|
||||
} as const;
|
||||
|
||||
// Termination Stages
|
||||
|
||||
@ -49,10 +49,11 @@ export const updateApplicationProgress = async (applicationId: string, stageName
|
||||
await progress.update(updates);
|
||||
}
|
||||
|
||||
// Mark previous stages as completed if this one is completed
|
||||
if (status === 'completed') {
|
||||
// Whenever a stage is marked 'active' or 'completed',
|
||||
// all previous stages MUST be completed.
|
||||
if (status === 'active' || status === 'completed') {
|
||||
await ApplicationProgress.update(
|
||||
{ status: 'completed', completionPercentage: 100 },
|
||||
{ status: 'completed', completionPercentage: 100, stageCompletedAt: new Date() },
|
||||
{
|
||||
where: {
|
||||
applicationId,
|
||||
@ -76,6 +77,7 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
|
||||
// Map overallStatus to stage names
|
||||
const statusToStageMap: Record<string, string> = {
|
||||
'Submitted': 'Submitted',
|
||||
'Questionnaire Pending': 'Submitted',
|
||||
'Questionnaire Completed': 'Questionnaire',
|
||||
'Shortlisted': 'Shortlist',
|
||||
'Level 1 Interview Pending': '1st Level Interview',
|
||||
@ -87,6 +89,7 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
|
||||
'Level 3 Approved': '3rd Level Interview',
|
||||
'FDD Verification': 'FDD',
|
||||
'LOI In Progress': 'LOI Approval',
|
||||
'Security Details': 'Security Details',
|
||||
'Payment Pending': 'Security Details',
|
||||
'LOI Issued': 'LOI Issue',
|
||||
'Statutory LOI Ack': 'LOI Issue',
|
||||
@ -96,7 +99,8 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
|
||||
'Architecture Team Completion': 'Dealer Code Generation',
|
||||
'Statutory GST': 'Dealer Code Generation',
|
||||
'LOA Pending': 'LOA',
|
||||
'EOR In Progress': 'LOA',
|
||||
'LOA Issued': 'LOA',
|
||||
'EOR In Progress': 'EOR Complete',
|
||||
'EOR Complete': 'EOR Complete',
|
||||
'Inauguration': 'Inauguration',
|
||||
'Approved': 'Inauguration',
|
||||
@ -108,11 +112,12 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
|
||||
const stage = ONBOARDING_STAGES.find(s => s.name === currentStageName);
|
||||
if (stage) {
|
||||
// Determine status for this stage
|
||||
// If the status IS exactly the target "Complete" status for a stage, mark it as completed
|
||||
const isCompleted = [
|
||||
'Submitted', 'Questionnaire Completed', 'Shortlisted', 'Level 3 Approved',
|
||||
'LOI Issued', 'EOR Complete', 'Approved', 'Onboarded'
|
||||
'Submitted', 'Questionnaire Completed', 'Shortlisted', 'Level 1 Approved',
|
||||
'Level 2 Approved', 'Level 3 Approved', 'LOI Issued', 'EOR Complete',
|
||||
'Approved', 'Onboarded'
|
||||
].includes(overallStatus);
|
||||
|
||||
|
||||
await updateApplicationProgress(applicationId, currentStageName, isCompleted ? 'completed' : 'active', isCompleted ? 100 : 50);
|
||||
}
|
||||
|
||||
@ -7,7 +7,9 @@ const {
|
||||
import { AuthRequest } from '../../types/express.types.js';
|
||||
import { Op } from 'sequelize';
|
||||
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 district: any = await District.findByPk(locationId);
|
||||
@ -60,22 +62,14 @@ const processStageDecision = async (params: {
|
||||
|
||||
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({
|
||||
where: {
|
||||
requestId: applicationId,
|
||||
requestType: 'application',
|
||||
userId: userId
|
||||
}
|
||||
where: { requestId: applicationId, requestType: 'application', userId }
|
||||
});
|
||||
|
||||
// Strategy: If it's an interview, check interviewLevel. If it's a stage, check stageCode.
|
||||
const isAssigned = userAssignments.some((p: any) => {
|
||||
if (!p.metadata) return false;
|
||||
if (interviewId && p.metadata.interviewLevel) {
|
||||
// Check if this participant is for THIS interview (rough check via level)
|
||||
return true;
|
||||
}
|
||||
if (interviewId && p.metadata.interviewLevel) return true;
|
||||
if (p.metadata.stageCode === stageCode) return true;
|
||||
if (Array.isArray(p.metadata.allAssignments) && p.metadata.allAssignments.includes(stageCode)) return true;
|
||||
return false;
|
||||
@ -83,113 +77,80 @@ const processStageDecision = async (params: {
|
||||
|
||||
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) {
|
||||
return { forbidden: true, policy, requiredRoles, currentRole: roleCode };
|
||||
}
|
||||
|
||||
// Record Action
|
||||
await db.StageApprovalAction.upsert({
|
||||
id: undefined, // Let it generate or find by unique index
|
||||
applicationId,
|
||||
interviewId: interviewId || null,
|
||||
stageCode,
|
||||
actorUserId: userId,
|
||||
actorRole: assignedRole || roleCode,
|
||||
decision,
|
||||
remarks: remarks || null
|
||||
});
|
||||
// Record Action - Robust handle for null interviewId which breaks unique constraint in Postgres
|
||||
if (!interviewId) {
|
||||
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,
|
||||
stageCode,
|
||||
actorUserId: userId,
|
||||
actorRole: assignedRole || roleCode,
|
||||
decision,
|
||||
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) {
|
||||
await InterviewEvaluation.update(
|
||||
{
|
||||
decision: decision,
|
||||
recommendation: decision // Sync for combined dashboard view
|
||||
},
|
||||
{ decision, recommendation: decision },
|
||||
{ where: { interviewId, evaluatorId: userId } }
|
||||
);
|
||||
}
|
||||
|
||||
// Evaluate Policy
|
||||
const actions = await db.StageApprovalAction.findAll({
|
||||
where: { applicationId, stageCode }
|
||||
});
|
||||
|
||||
const approvedActions = actions.filter((a: any) => a.decision === 'Approved');
|
||||
const uniqueApprovalsByRole = new Set(approvedActions.map((a: any) => a.actorRole));
|
||||
// Evaluate Policy via Centralized Service (FIXED unique user count)
|
||||
const evaluation = await WorkflowService.evaluateStagePolicy(applicationId, stageCode);
|
||||
|
||||
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;
|
||||
|
||||
if (hasRejection) {
|
||||
await db.Application.update({
|
||||
overallStatus: 'Rejected',
|
||||
currentStage: 'Rejected'
|
||||
}, { where: { id: applicationId } });
|
||||
|
||||
await db.ApplicationStatusHistory.create({
|
||||
applicationId,
|
||||
previousStatus: 'In Progress',
|
||||
newStatus: 'Rejected',
|
||||
changedBy: userId,
|
||||
reason: `Rejected during ${stageCode} stage`
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
await db.Application.update(updateData, { where: { id: applicationId } });
|
||||
|
||||
await db.ApplicationStatusHistory.create({
|
||||
applicationId,
|
||||
previousStatus: 'In Progress',
|
||||
newStatus: nextStatus,
|
||||
changedBy: userId,
|
||||
reason: `Policy met for ${stageCode}`
|
||||
const application = await db.Application.findByPk(applicationId);
|
||||
if (application) {
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.REJECTED, userId, {
|
||||
reason: `Rejected during ${stageCode} stage: ${remarks}`,
|
||||
stage: APPLICATION_STAGES.REJECTED
|
||||
});
|
||||
statusUpdated = true;
|
||||
}
|
||||
} else if (evaluation.policyMet && nextStatus) {
|
||||
const application = await db.Application.findByPk(applicationId);
|
||||
if (application) {
|
||||
await WorkflowService.transitionApplication(application, nextStatus, userId, {
|
||||
reason: `Policy met for ${stageCode}`,
|
||||
progressPercentage: nextProgress
|
||||
});
|
||||
|
||||
// Sync Progress tracking
|
||||
const { syncApplicationProgress } = await import('../../common/utils/progress.js');
|
||||
await syncApplicationProgress(applicationId, nextStatus);
|
||||
|
||||
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 {
|
||||
success: true,
|
||||
message,
|
||||
message: hasRejection ? 'Rejected' : statusUpdated ? 'Policy satisfied. Stage complete.' : 'Approval recorded.',
|
||||
policy,
|
||||
requiredRoles,
|
||||
uniqueApprovalsByRole,
|
||||
hasAllRequiredRoleApprovals,
|
||||
meetsMinApprovals,
|
||||
requiredRoles: evaluation.policy.requiredRoles,
|
||||
uniqueApprovalsByRole: evaluation.approvedRoles,
|
||||
hasAllRequiredRoleApprovals: evaluation.hasAllRequiredRoleApprovals,
|
||||
meetsMinApprovals: evaluation.meetsMinApprovals,
|
||||
statusUpdated
|
||||
};
|
||||
};
|
||||
@ -304,25 +265,12 @@ export const submitQuestionnaireResponse = async (req: AuthRequest, res: Respons
|
||||
status: 'Completed'
|
||||
});
|
||||
|
||||
// Update Application
|
||||
await application.update({
|
||||
score: totalWeightedScore,
|
||||
overallStatus: 'Questionnaire Completed',
|
||||
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');
|
||||
if (application) {
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.QUESTIONNAIRE_COMPLETED, req.user?.id || null, {
|
||||
reason: 'Questionnaire submitted by applicant',
|
||||
progressPercentage: 20
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({ success: true, message: 'Responses submitted and scored successfully', score: totalWeightedScore });
|
||||
} catch (error) {
|
||||
@ -380,7 +328,12 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
|
||||
|
||||
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
|
||||
// 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 : [];
|
||||
|
||||
// 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) {
|
||||
// Fallback: If no policy, just update application status directly (legacy behavior)
|
||||
if (nextStatus) {
|
||||
await db.Application.update({
|
||||
overallStatus: nextStatus,
|
||||
currentStage: nextStatus,
|
||||
progressPercentage: nextProgress || undefined
|
||||
}, { where: { id: applicationId } });
|
||||
const application = await db.Application.findByPk(applicationId);
|
||||
if (application) {
|
||||
await WorkflowService.transitionApplication(application, nextStatus, req.user?.id || null, {
|
||||
reason: 'Fallback Transition (No Policy)',
|
||||
progressPercentage: nextProgress
|
||||
});
|
||||
}
|
||||
}
|
||||
return res.json({ success: true, message: 'Status updated (No policy found)' });
|
||||
}
|
||||
|
||||
@ -6,8 +6,9 @@ const {
|
||||
Resignation, RelocationRequest, ConstitutionalChange
|
||||
} = db;
|
||||
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 { WorkflowService } from '../../services/WorkflowService.js';
|
||||
|
||||
export const getDealers = async (req: Request, res: Response) => {
|
||||
try {
|
||||
@ -32,6 +33,20 @@ export const createDealer = async (req: AuthRequest, res: Response) => {
|
||||
const application = await Application.findByPk(applicationId);
|
||||
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
|
||||
let targetDealerCodeId = dealerCodeId;
|
||||
if (!targetDealerCodeId) {
|
||||
@ -165,24 +180,12 @@ export const createDealer = async (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
|
||||
// Final Step: Update Application Status to Onboarded
|
||||
await application.update({
|
||||
overallStatus: 'Onboarded',
|
||||
progressPercentage: 100,
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
// 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'
|
||||
});
|
||||
if (application) {
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.ONBOARDED, req.user?.id || null, {
|
||||
reason: 'Dealer Onboarding Finalized',
|
||||
progressPercentage: 100
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
|
||||
@ -2,7 +2,8 @@ import { Request, Response } from 'express';
|
||||
import db from '../../database/models/index.js';
|
||||
const { LoaRequest, LoaApproval, LoaDocumentGenerated, SecurityDeposit, AuditLog, StageApprovalPolicy, StageApprovalAction, User } = db;
|
||||
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';
|
||||
|
||||
@ -63,8 +64,8 @@ export const createRequest = async (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
await application.update({
|
||||
overallStatus: 'LOA Pending',
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOA_PENDING, req.user?.id || null, {
|
||||
reason: 'LOA Request initiated with DD Head approval',
|
||||
progressPercentage: 92
|
||||
});
|
||||
|
||||
@ -133,11 +134,14 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
|
||||
|
||||
if (action === 'Rejected' || hasRejection) {
|
||||
await request.update({ status: 'Rejected' });
|
||||
await db.Application.update({
|
||||
overallStatus: 'LOA Rejected',
|
||||
currentStage: 'Rejected',
|
||||
progressPercentage: 92
|
||||
}, { where: { id: request.applicationId } });
|
||||
const application = await db.Application.findByPk(request.applicationId);
|
||||
if (application) {
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOA_REJECTED, req.user.id, {
|
||||
reason: 'LOA Request rejected during approval',
|
||||
stage: 'Rejected',
|
||||
progressPercentage: 92
|
||||
});
|
||||
}
|
||||
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}`
|
||||
});
|
||||
|
||||
await db.Application.update({
|
||||
overallStatus: 'Authorized for Operations',
|
||||
progressPercentage: 97
|
||||
}, { where: { id: request.applicationId } });
|
||||
const application = await db.Application.findByPk(request.applicationId);
|
||||
if (application) {
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOA_ISSUED, req.user.id, {
|
||||
reason: 'LOA fully approved and issued',
|
||||
progressPercentage: 97
|
||||
});
|
||||
}
|
||||
res.json({ success: true, message: 'LOA fully approved and issued' });
|
||||
} else {
|
||||
res.json({
|
||||
|
||||
@ -2,7 +2,8 @@ import { Request, Response } from 'express';
|
||||
import db from '../../database/models/index.js';
|
||||
const { LoiRequest, LoiApproval, LoiDocumentGenerated, LoiAcknowledgement, AuditLog, StageApprovalPolicy, StageApprovalAction, User } = db;
|
||||
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';
|
||||
|
||||
@ -54,10 +55,13 @@ export const acknowledgeRequest = async (req: AuthRequest, res: Response) => {
|
||||
status: 'Acknowledged'
|
||||
});
|
||||
|
||||
await db.Application.update({
|
||||
overallStatus: 'Dealer Code Generation',
|
||||
progressPercentage: 90
|
||||
}, { where: { id: request.applicationId } });
|
||||
const application = await db.Application.findByPk(request.applicationId);
|
||||
if (application) {
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.DEALER_CODE_GENERATION, req.user?.id || null, {
|
||||
reason: 'LOI Acknowledged by applicant',
|
||||
progressPercentage: 90
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'LOI Acknowledged by applicant' });
|
||||
} catch (error) {
|
||||
@ -90,8 +94,8 @@ export const createRequest = async (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
await application.update({
|
||||
overallStatus: 'LOI In Progress',
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOI_IN_PROGRESS, req.user?.id || null, {
|
||||
reason: 'LOI Request initiated with Finance approval',
|
||||
progressPercentage: 75
|
||||
});
|
||||
|
||||
@ -182,11 +186,14 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
|
||||
// 2. Handle Logic based on Action
|
||||
if (action === 'Rejected' || hasRejection) {
|
||||
await request.update({ status: 'Rejected' });
|
||||
await db.Application.update({
|
||||
overallStatus: 'LOI Rejected',
|
||||
currentStage: 'Rejected',
|
||||
progressPercentage: 75
|
||||
}, { where: { id: request.applicationId } });
|
||||
const application = await db.Application.findByPk(request.applicationId);
|
||||
if (application) {
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOI_REJECTED, req.user.id, {
|
||||
reason: 'LOI Request rejected during approval',
|
||||
stage: 'Rejected',
|
||||
progressPercentage: 75
|
||||
});
|
||||
}
|
||||
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}`
|
||||
});
|
||||
|
||||
await db.Application.update({
|
||||
overallStatus: 'Security Details',
|
||||
progressPercentage: 80
|
||||
}, { where: { id: request.applicationId } });
|
||||
const application = await db.Application.findByPk(request.applicationId);
|
||||
if (application) {
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.SECURITY_DETAILS, req.user.id, {
|
||||
reason: 'LOI Request fully approved and document generated',
|
||||
progressPercentage: 80
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'LOI Request fully approved and document generated' });
|
||||
} else {
|
||||
|
||||
@ -8,6 +8,7 @@ import { AuthRequest } from '../../types/express.types.js';
|
||||
|
||||
import { sendOpportunityEmail, sendNonOpportunityEmail } from '../../common/utils/email.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
|
||||
const findDistrictByName = async (districtName: string, stateName?: string) => {
|
||||
@ -93,13 +94,10 @@ export const submitApplication = async (req: AuthRequest, res: Response) => {
|
||||
timeline: []
|
||||
});
|
||||
|
||||
// Log Status History
|
||||
await ApplicationStatusHistory.create({
|
||||
applicationId: application.id,
|
||||
previousStatus: null,
|
||||
newStatus: application.overallStatus,
|
||||
changedBy: req.user?.id || null,
|
||||
reason: 'Initial Submission'
|
||||
// Use WorkflowService for initial status and progress sync
|
||||
await WorkflowService.transitionApplication(application, application.overallStatus, req.user?.id || null, {
|
||||
reason: 'Initial Submission',
|
||||
stage: application.currentStage
|
||||
});
|
||||
|
||||
// Send Email (Async)
|
||||
@ -207,37 +205,11 @@ export const updateApplicationStatus = async (req: AuthRequest, res: Response) =
|
||||
const application = await Application.findByPk(id);
|
||||
if (!application) return res.status(404).json({ success: false, message: 'Application not found' });
|
||||
|
||||
const previousStatus = application.overallStatus;
|
||||
|
||||
await application.update({
|
||||
overallStatus: status,
|
||||
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);
|
||||
if (application) {
|
||||
await WorkflowService.transitionApplication(application, status, req.user?.id || null, {
|
||||
reason: reason || 'Manual Status Update',
|
||||
stage: stage
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Application status updated successfully' });
|
||||
@ -402,60 +374,35 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => {
|
||||
// but add ALL as participants to enforce dual-responsibility.
|
||||
const primaryAssigneeId = assignedTo[0];
|
||||
|
||||
// Update Applications
|
||||
await Application.update({
|
||||
ddLeadShortlisted: true,
|
||||
isShortlisted: true,
|
||||
overallStatus: 'Shortlisted',
|
||||
progressPercentage: 30,
|
||||
assignedTo: primaryAssigneeId,
|
||||
updatedAt: new Date(),
|
||||
}, {
|
||||
where: {
|
||||
id: { [Op.in]: applicationIds }
|
||||
}
|
||||
});
|
||||
|
||||
// Add all assigned users as participants for each application
|
||||
// Update Applications sequentially via WorkflowService for consistency
|
||||
for (const appId of applicationIds) {
|
||||
for (const userId of assignedTo) {
|
||||
await db.RequestParticipant.findOrCreate({
|
||||
where: {
|
||||
requestId: appId,
|
||||
requestType: 'application',
|
||||
userId,
|
||||
participantType: 'assignee'
|
||||
},
|
||||
defaults: {
|
||||
joinedMethod: 'auto'
|
||||
}
|
||||
const application = await Application.findByPk(appId);
|
||||
if (application) {
|
||||
await application.update({
|
||||
ddLeadShortlisted: true,
|
||||
isShortlisted: true,
|
||||
assignedTo: primaryAssigneeId,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.SHORTLISTED, req.user?.id || null, {
|
||||
reason: remarks || 'Bulk Shortlist',
|
||||
progressPercentage: 30
|
||||
});
|
||||
|
||||
// Add all assigned users as participants
|
||||
for (const userId of assignedTo) {
|
||||
await db.RequestParticipant.findOrCreate({
|
||||
where: { requestId: appId, requestType: 'application', userId, participantType: 'assignee' },
|
||||
defaults: { joinedMethod: 'auto' }
|
||||
});
|
||||
}
|
||||
|
||||
// AUTO-FILL Interview Evaluators
|
||||
await assignStageEvaluators(appId);
|
||||
}
|
||||
|
||||
// AUTO-FILL Interview Evaluators for all 3 levels
|
||||
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({
|
||||
success: true,
|
||||
message: `Successfully shortlisted ${applicationIds.length} application(s) and assigned to ${assignedTo.length} users.`
|
||||
@ -674,10 +621,13 @@ export const assignArchitectureTeam = async (req: AuthRequest, res: Response) =>
|
||||
architectureAssignedTo: targetUserId,
|
||||
architectureStatus: 'IN_PROGRESS',
|
||||
architectureAssignedDate: new Date(),
|
||||
overallStatus: 'Architecture Team Assigned',
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.ARCHITECTURE_TEAM_ASSIGNED, req.user?.id || null, {
|
||||
reason: remarks || 'Architecture team assigned'
|
||||
});
|
||||
|
||||
// Add as participant
|
||||
await db.RequestParticipant.findOrCreate({
|
||||
where: {
|
||||
@ -689,14 +639,6 @@ export const assignArchitectureTeam = async (req: AuthRequest, res: Response) =>
|
||||
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' });
|
||||
} catch (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
|
||||
if (status === 'COMPLETED') {
|
||||
updateData.overallStatus = 'Architecture Team Completion';
|
||||
updateData.architectureCompletionDate = new Date();
|
||||
} else if (status === 'IN_PROGRESS') {
|
||||
updateData.overallStatus = 'Architecture Team Assigned';
|
||||
}
|
||||
const targetOverallStatus = status === 'COMPLETED'
|
||||
? APPLICATION_STATUS.ARCHITECTURE_TEAM_COMPLETION
|
||||
: APPLICATION_STATUS.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({
|
||||
userId: req.user?.id,
|
||||
action: AUDIT_ACTIONS.UPDATED,
|
||||
entityType: 'application',
|
||||
entityId: application.id,
|
||||
newData: { architectureStatus: status, remarks }
|
||||
await WorkflowService.transitionApplication(application, targetOverallStatus, req.user?.id || null, {
|
||||
reason: remarks || `Architecture status updated to ${status}`
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
const previousStatus = application.overallStatus;
|
||||
|
||||
// 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',
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.DEALER_CODE_GENERATION, req.user?.id || null, {
|
||||
reason: 'SAP Dealer Codes Generated',
|
||||
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({
|
||||
success: true,
|
||||
message: 'SAP Dealer Codes generated successfully (Mock)',
|
||||
|
||||
94
src/services/WorkflowService.ts
Normal file
94
src/services/WorkflowService.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user