Dealer_Onboarding_Backend/src/common/utils/progress.ts

235 lines
10 KiB
TypeScript

import db from '../../database/models/index.js';
const { ApplicationProgress } = db;
export const ONBOARDING_STAGES = [
{ name: 'Submitted', order: 1 },
{ name: 'Questionnaire', order: 2 },
{ name: 'Shortlist', order: 3 },
{ name: '1st Level Interview', order: 4 },
{ name: '2nd Level Interview', order: 5 },
{ name: '3rd Level Interview', order: 6 },
{ name: 'FDD', order: 7 },
{ name: 'LOI Approval', order: 8 },
{ name: 'Security Details', order: 9 },
{ name: 'LOI Issue', order: 10 },
{ name: 'Dealer Code Generation', order: 11 },
{ name: 'Architecture Work', order: 12 },
{ name: 'Statutory Work', order: 12 }, // UNIFIED with Architecture
{ name: 'LOA', order: 13 },
{ name: 'EOR Complete', order: 14 },
{ name: 'Inauguration', order: 15 },
{ name: 'Onboarded', order: 16 }
];
/**
* Updates application progress for a specific stage
*/
export const updateApplicationProgress = async (applicationId: string, stageName: string, status: 'pending' | 'active' | 'completed', percentage: number = 0) => {
try {
const stage = ONBOARDING_STAGES.find(s => s.name === stageName);
if (!stage) return;
const [progress, created] = await ApplicationProgress.findOrCreate({
where: { applicationId, stageName },
defaults: {
stageOrder: stage.order,
status,
completionPercentage: percentage,
stageStartedAt: status === 'active' ? new Date() : null,
stageCompletedAt: status === 'completed' ? new Date() : null
}
});
if (!created) {
const updates: any = { status, completionPercentage: percentage };
if (status === 'active' && !progress.stageStartedAt) {
updates.stageStartedAt = new Date();
}
if (status === 'completed' && !progress.stageCompletedAt) {
updates.stageCompletedAt = new Date();
}
await progress.update(updates);
}
// Whenever a stage is marked 'active' or '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') {
const previousStages = ONBOARDING_STAGES.filter(s => s.order < stage.order);
for (const prev of previousStages) {
await ApplicationProgress.findOrCreate({
where: { applicationId, stageName: prev.name },
defaults: {
stageOrder: prev.order,
status: 'completed',
completionPercentage: 100,
stageCompletedAt: new Date()
}
});
}
// Also update any existing ones that weren't completed
await ApplicationProgress.update(
{ status: 'completed', completionPercentage: 100, stageCompletedAt: new Date() },
{
where: {
applicationId,
stageOrder: { [db.Sequelize.Op.lt]: stage.order },
status: { [db.Sequelize.Op.ne]: 'completed' }
}
}
);
}
return progress;
} catch (error) {
console.error('Error updating application progress:', error);
}
};
/**
* Maps application `overallStatus` to the pipeline stage label used in ApplicationProgress
* and (via WorkflowService) `Application.currentStage` + audit `newData.stage`.
* Keeps audit trail aligned with the post-LOI milestones (dealer code → LOA → EOR → inauguration).
*/
export const PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS: Record<string, string> = {
Submitted: 'Submitted',
'Questionnaire Pending': 'Questionnaire',
'Questionnaire Completed': 'Questionnaire',
Shortlisted: 'Shortlist',
'Level 1 Interview Pending': '1st Level Interview',
'Level 1 Approved': '1st Level Interview',
'Level 2 Interview Pending': '2nd Level Interview',
'Level 2 Approved': '2nd Level Interview',
'Level 2 Recommended': '2nd Level Interview',
'Level 3 Interview Pending': '3rd Level Interview',
'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',
'Dealer Code Generation': 'Dealer Code Generation',
'Architecture Team Assigned': 'Architecture Work',
'Architecture Document Upload': 'Architecture Work',
'Architecture Team Completion': 'Architecture Work',
'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 Issued': 'LOA',
'LOA Rejected': 'LOA',
'LOI Rejected': 'LOI Issue',
'EOR In Progress': 'EOR Complete',
'EOR Complete': 'EOR Complete',
Inauguration: 'Inauguration',
Approved: 'Inauguration',
Onboarded: 'Onboarded',
Rejected: 'Rejected',
Disqualified: 'Disqualified',
'Returned to FDD': 'FDD',
Pending: 'Submitted',
'In Review': 'Shortlist',
};
/**
* Syncs all progress stages based on current overall status
*/
export const syncApplicationProgress = async (applicationId: string, overallStatus: string) => {
const currentStageName = PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS[overallStatus];
if (currentStageName) {
const currentStage = ONBOARDING_STAGES.find(s => s.name === currentStageName);
if (currentStage) {
// Statuses that imply the CURRENT stage (single or both parallel) is finished
const completionStatuses = [
'Submitted', 'Questionnaire Completed', 'Shortlisted', 'Level 1 Approved',
'Level 2 Approved', 'Level 2 Recommended', 'Level 3 Approved',
'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'
];
const isCurrentStageFinished = completionStatuses.includes(overallStatus);
// Fetch application to check model-driven parallel status
const application = await db.Application.findByPk(applicationId);
// Robust Sync: Prepare ALL stages for batch processing
const upsertData: any[] = [];
for (const stage of ONBOARDING_STAGES) {
let status: 'pending' | 'active' | 'completed' = 'pending';
let percentage = 0;
if (stage.order < currentStage.order) {
status = 'completed';
percentage = 100;
} else if (stage.order === currentStage.order) {
status = isCurrentStageFinished ? 'completed' : 'active';
percentage = isCurrentStageFinished ? 100 : 50;
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;
}
}
upsertData.push({
applicationId,
stageName: stage.name,
stageOrder: stage.order,
status,
completionPercentage: percentage,
stageStartedAt: (status === 'active' || status === 'completed') ? new Date() : null,
stageCompletedAt: status === 'completed' ? new Date() : null
});
}
// DB Duplication Prevention without Schema Changes (Healing corrupted data loops)
const existingRecords = await ApplicationProgress.findAll({ where: { applicationId } });
const seenStages = new Set<string>();
// Purge any ghost duplicates created by old logic
for (const record of existingRecords) {
if (seenStages.has(record.stageName)) {
await record.destroy();
} else {
seenStages.add(record.stageName);
}
}
// Perform single row updates/inserts to enforce exact 1:1 mapping safely
const cleanedRecords = await ApplicationProgress.findAll({ where: { applicationId } });
for (const data of upsertData) {
const existing = cleanedRecords.find((r: any) => r.stageName === data.stageName);
if (existing) {
await existing.update({
stageOrder: data.stageOrder,
status: data.status,
completionPercentage: data.completionPercentage,
stageStartedAt: data.stageStartedAt || existing.stageStartedAt,
stageCompletedAt: data.stageCompletedAt || existing.stageCompletedAt
});
} else {
await ApplicationProgress.create(data);
}
}
}
}
};