3354 lines
133 KiB
TypeScript
3354 lines
133 KiB
TypeScript
import { WorkflowRequest } from '../models/WorkflowRequest';
|
|
import { DealerClaimDetails } from '../models/DealerClaimDetails';
|
|
import { DealerProposalDetails } from '../models/DealerProposalDetails';
|
|
import { DealerCompletionDetails } from '../models/DealerCompletionDetails';
|
|
import { DealerProposalCostItem } from '../models/DealerProposalCostItem';
|
|
import { InternalOrder, IOStatus } from '../models/InternalOrder';
|
|
import { ClaimBudgetTracking, BudgetStatus } from '../models/ClaimBudgetTracking';
|
|
import { ClaimInvoice } from '../models/ClaimInvoice';
|
|
import { ClaimCreditNote } from '../models/ClaimCreditNote';
|
|
import { DealerCompletionExpense } from '../models/DealerCompletionExpense';
|
|
import { ApprovalLevel } from '../models/ApprovalLevel';
|
|
import { Participant } from '../models/Participant';
|
|
import { User } from '../models/User';
|
|
import { DealerClaimHistory, SnapshotType } from '../models/DealerClaimHistory';
|
|
import { Document } from '../models/Document';
|
|
import { WorkflowService } from './workflow.service';
|
|
import { DealerClaimApprovalService } from './dealerClaimApproval.service';
|
|
import { generateRequestNumber } from '../utils/helpers';
|
|
import { Priority, WorkflowStatus, ApprovalStatus, ParticipantType } from '../types/common.types';
|
|
import { sapIntegrationService } from './sapIntegration.service';
|
|
import { dmsIntegrationService } from './dmsIntegration.service';
|
|
import { notificationService } from './notification.service';
|
|
import { activityService } from './activity.service';
|
|
import { UserService } from './user.service';
|
|
import logger from '../utils/logger';
|
|
|
|
/**
|
|
* Dealer Claim Service
|
|
* Handles business logic specific to dealer claim management workflow
|
|
*/
|
|
export class DealerClaimService {
|
|
private workflowService = new WorkflowService();
|
|
private approvalService = new DealerClaimApprovalService();
|
|
private userService = new UserService();
|
|
|
|
/**
|
|
* Create a new dealer claim request
|
|
*/
|
|
async createClaimRequest(
|
|
userId: string,
|
|
claimData: {
|
|
activityName: string;
|
|
activityType: string;
|
|
dealerCode: string;
|
|
dealerName: string;
|
|
dealerEmail?: string;
|
|
dealerPhone?: string;
|
|
dealerAddress?: string;
|
|
activityDate?: Date;
|
|
location: string;
|
|
requestDescription: string;
|
|
periodStartDate?: Date;
|
|
periodEndDate?: Date;
|
|
estimatedBudget?: number;
|
|
approvers?: Array<{
|
|
email: string;
|
|
name?: string;
|
|
userId?: string;
|
|
level: number;
|
|
tat?: number | string;
|
|
tatType?: 'hours' | 'days';
|
|
}>;
|
|
}
|
|
): Promise<WorkflowRequest> {
|
|
try {
|
|
// Generate request number
|
|
const requestNumber = await generateRequestNumber();
|
|
|
|
// Validate initiator - check if userId is a valid UUID first
|
|
const isValidUUID = (str: string): boolean => {
|
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
return uuidRegex.test(str);
|
|
};
|
|
|
|
if (!isValidUUID(userId)) {
|
|
// If userId is not a UUID (might be Okta ID), try to find by email or other means
|
|
// This shouldn't happen in normal flow, but handle gracefully
|
|
throw new Error(`Invalid initiator ID format. Expected UUID, got: ${userId}`);
|
|
}
|
|
|
|
const initiator = await User.findByPk(userId);
|
|
if (!initiator) {
|
|
throw new Error('Initiator not found');
|
|
}
|
|
|
|
// Validate approvers array is provided
|
|
if (!claimData.approvers || !Array.isArray(claimData.approvers) || claimData.approvers.length === 0) {
|
|
throw new Error('Approvers array is required. Please assign approvers for all workflow steps.');
|
|
}
|
|
|
|
// Now create workflow request (manager is validated)
|
|
// For claim management, requests are submitted immediately (not drafts)
|
|
// Step 1 will be active for dealer to submit proposal
|
|
const now = new Date();
|
|
const workflowRequest = await WorkflowRequest.create({
|
|
initiatorId: userId,
|
|
requestNumber,
|
|
templateType: 'DEALER CLAIM', // Set template type for dealer claim management
|
|
workflowType: 'CLAIM_MANAGEMENT',
|
|
title: `${claimData.activityName} - Claim Request`,
|
|
description: claimData.requestDescription,
|
|
priority: Priority.STANDARD,
|
|
status: WorkflowStatus.PENDING, // Submitted, not draft
|
|
totalLevels: 5, // Fixed 5-step workflow for claim management (Activity Creation, E-Invoice Generation, and Credit Note Confirmation are now activity logs only)
|
|
currentLevel: 1, // Step 1: Dealer Proposal Submission
|
|
totalTatHours: 0, // Will be calculated from approval levels
|
|
isDraft: false, // Not a draft - submitted and ready for workflow
|
|
isDeleted: false,
|
|
submissionDate: now, // Set submission date for SLA tracking (required for overall SLA calculation)
|
|
});
|
|
|
|
// Create claim details
|
|
await DealerClaimDetails.create({
|
|
requestId: workflowRequest.requestId,
|
|
activityName: claimData.activityName,
|
|
activityType: claimData.activityType,
|
|
dealerCode: claimData.dealerCode,
|
|
dealerName: claimData.dealerName,
|
|
dealerEmail: claimData.dealerEmail,
|
|
dealerPhone: claimData.dealerPhone,
|
|
dealerAddress: claimData.dealerAddress,
|
|
activityDate: claimData.activityDate,
|
|
location: claimData.location,
|
|
periodStartDate: claimData.periodStartDate,
|
|
periodEndDate: claimData.periodEndDate,
|
|
});
|
|
|
|
// Initialize budget tracking with initial estimated budget (if provided)
|
|
await ClaimBudgetTracking.upsert({
|
|
requestId: workflowRequest.requestId,
|
|
initialEstimatedBudget: claimData.estimatedBudget,
|
|
budgetStatus: BudgetStatus.DRAFT,
|
|
currency: 'INR',
|
|
});
|
|
|
|
// Create 8 approval levels for claim management workflow from approvers array
|
|
await this.createClaimApprovalLevelsFromApprovers(workflowRequest.requestId, userId, claimData.dealerEmail, claimData.approvers || []);
|
|
|
|
// Schedule TAT jobs for Step 1 (Dealer Proposal Submission) - first active step
|
|
// This ensures SLA tracking starts immediately from request creation
|
|
const { tatSchedulerService } = await import('./tatScheduler.service');
|
|
const dealerLevel = await ApprovalLevel.findOne({
|
|
where: {
|
|
requestId: workflowRequest.requestId,
|
|
levelNumber: 1 // Step 1: Dealer Proposal Submission
|
|
}
|
|
});
|
|
|
|
if (dealerLevel && dealerLevel.approverId && dealerLevel.levelStartTime) {
|
|
try {
|
|
const workflowPriority = (workflowRequest as any)?.priority || 'STANDARD';
|
|
await tatSchedulerService.scheduleTatJobs(
|
|
workflowRequest.requestId,
|
|
(dealerLevel as any).levelId,
|
|
dealerLevel.approverId,
|
|
Number(dealerLevel.tatHours || 0),
|
|
dealerLevel.levelStartTime,
|
|
workflowPriority
|
|
);
|
|
logger.info(`[DealerClaimService] TAT jobs scheduled for Step 1 (Dealer Proposal Submission) - Priority: ${workflowPriority}`);
|
|
} catch (tatError) {
|
|
logger.error(`[DealerClaimService] Failed to schedule TAT jobs for Step 1:`, tatError);
|
|
// Don't fail request creation if TAT scheduling fails
|
|
}
|
|
}
|
|
|
|
// Create participants (initiator, dealer, department lead, finance - exclude system)
|
|
await this.createClaimParticipants(workflowRequest.requestId, userId, claimData.dealerEmail);
|
|
|
|
// Get initiator details for activity logging and notifications
|
|
const initiatorName = initiator.displayName || initiator.email || 'User';
|
|
|
|
// Log creation activity
|
|
await activityService.log({
|
|
requestId: workflowRequest.requestId,
|
|
type: 'created',
|
|
user: { userId: userId, name: initiatorName },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Claim request created',
|
|
details: `Claim request "${workflowRequest.title}" created by ${initiatorName} for dealer ${claimData.dealerName}`
|
|
});
|
|
|
|
// Send notification to INITIATOR confirming submission
|
|
await notificationService.sendToUsers([userId], {
|
|
title: 'Claim Request Submitted Successfully',
|
|
body: `Your claim request "${workflowRequest.title}" has been submitted successfully.`,
|
|
requestNumber: requestNumber,
|
|
requestId: workflowRequest.requestId,
|
|
url: `/request/${requestNumber}`,
|
|
type: 'request_submitted',
|
|
priority: 'MEDIUM'
|
|
});
|
|
|
|
// Get approval levels for notifications
|
|
// Step 1: Dealer Proposal Submission (first active step - log assignment at creation)
|
|
// Subsequent steps will have assignment logged when they become active (via approval service)
|
|
|
|
// Notify Step 1 (Dealer) - dealerLevel was already fetched above for TAT scheduling
|
|
|
|
if (dealerLevel && dealerLevel.approverId) {
|
|
// Skip notifications for system processes
|
|
const approverEmail = dealerLevel.approverEmail || '';
|
|
const isSystemProcess = approverEmail.toLowerCase() === 'system@royalenfield.com'
|
|
|| approverEmail.toLowerCase().includes('system')
|
|
|| dealerLevel.approverId === 'system'
|
|
|| dealerLevel.approverName === 'System Auto-Process';
|
|
|
|
if (!isSystemProcess) {
|
|
// Send notification to Dealer (Step 1) for proposal submission
|
|
await notificationService.sendToUsers([dealerLevel.approverId], {
|
|
title: 'New Claim Request - Proposal Required',
|
|
body: `Claim request "${workflowRequest.title}" requires your proposal submission.`,
|
|
requestNumber: requestNumber,
|
|
requestId: workflowRequest.requestId,
|
|
url: `/request/${requestNumber}`,
|
|
type: 'assignment',
|
|
priority: 'HIGH',
|
|
actionRequired: true
|
|
});
|
|
|
|
// Log assignment activity for dealer (Step 1 - first active step)
|
|
await activityService.log({
|
|
requestId: workflowRequest.requestId,
|
|
type: 'assignment',
|
|
user: { userId: userId, name: initiatorName },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Assigned to dealer',
|
|
details: `Claim request assigned to dealer ${dealerLevel.approverName || dealerLevel.approverEmail || claimData.dealerName} for proposal submission`
|
|
});
|
|
} else {
|
|
logger.info(`[DealerClaimService] Skipping notification for system process: ${approverEmail} at Step 1`);
|
|
}
|
|
}
|
|
|
|
// Note: Step 2, 3, and subsequent steps will have assignment activities logged
|
|
// when they become active (when previous step is approved) via the approval service
|
|
|
|
logger.info(`[DealerClaimService] Created claim request: ${workflowRequest.requestNumber}`);
|
|
return workflowRequest;
|
|
} catch (error: any) {
|
|
// Log detailed error information for debugging
|
|
const errorDetails: any = {
|
|
message: error.message,
|
|
name: error.name,
|
|
};
|
|
|
|
// Sequelize validation errors
|
|
if (error.errors && Array.isArray(error.errors)) {
|
|
errorDetails.validationErrors = error.errors.map((e: any) => ({
|
|
field: e.path,
|
|
message: e.message,
|
|
value: e.value,
|
|
}));
|
|
}
|
|
|
|
// Sequelize database errors
|
|
if (error.parent) {
|
|
errorDetails.databaseError = {
|
|
message: error.parent.message,
|
|
code: error.parent.code,
|
|
detail: error.parent.detail,
|
|
};
|
|
}
|
|
|
|
logger.error('[DealerClaimService] Error creating claim request:', errorDetails);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create 5-step approval levels for claim management from approvers array
|
|
* Validates and creates approval levels based on user-provided approvers
|
|
* Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are handled as activity logs only, not approval steps
|
|
*/
|
|
private async createClaimApprovalLevelsFromApprovers(
|
|
requestId: string,
|
|
initiatorId: string,
|
|
dealerEmail?: string,
|
|
approvers: Array<{
|
|
email: string;
|
|
name?: string;
|
|
userId?: string;
|
|
level: number;
|
|
tat?: number | string;
|
|
tatType?: 'hours' | 'days';
|
|
stepName?: string; // For additional approvers
|
|
isAdditional?: boolean; // Flag for additional approvers
|
|
originalStepLevel?: number; // Original step level for fixed steps
|
|
}> = []
|
|
): Promise<void> {
|
|
const initiator = await User.findByPk(initiatorId);
|
|
if (!initiator) {
|
|
throw new Error('Initiator not found');
|
|
}
|
|
|
|
// Step definitions with default TAT (only manual approval steps)
|
|
// Note: Activity Creation (was level 4), E-Invoice Generation (was level 7), and Credit Note Confirmation (was level 8)
|
|
// are now handled as activity logs only, not approval steps
|
|
const stepDefinitions = [
|
|
{ level: 1, name: 'Dealer Proposal Submission', defaultTat: 72, isAuto: false },
|
|
{ level: 2, name: 'Requestor Evaluation', defaultTat: 48, isAuto: false },
|
|
{ level: 3, name: 'Department Lead Approval', defaultTat: 72, isAuto: false },
|
|
{ level: 4, name: 'Dealer Completion Documents', defaultTat: 120, isAuto: false },
|
|
{ level: 5, name: 'Requestor Claim Approval', defaultTat: 48, isAuto: false },
|
|
];
|
|
|
|
// Sort approvers by level to process in order
|
|
const sortedApprovers = [...approvers].sort((a, b) => a.level - b.level);
|
|
|
|
// Track which original steps have been processed
|
|
const processedOriginalSteps = new Set<number>();
|
|
|
|
// Process approvers in order by their level
|
|
for (const approver of sortedApprovers) {
|
|
let approverId: string | null = null;
|
|
let approverEmail = '';
|
|
let approverName = 'System';
|
|
let tatHours = 48; // Default TAT
|
|
let levelName = '';
|
|
let isSystemStep = false;
|
|
let isFinalApprover = false;
|
|
|
|
// Find the step definition this approver belongs to
|
|
let stepDef = null;
|
|
|
|
// Check if this is a system step by email (for backwards compatibility)
|
|
const isSystemEmail = approver.email === 'system@royalenfield.com' || approver.email === 'finance@royalenfield.com';
|
|
|
|
if (approver.isAdditional) {
|
|
// Additional approver - use stepName from frontend
|
|
levelName = approver.stepName || 'Additional Approver';
|
|
isSystemStep = false;
|
|
isFinalApprover = false;
|
|
} else {
|
|
// Fixed step - find by originalStepLevel first, then by matching level
|
|
const originalLevel = approver.originalStepLevel || approver.level;
|
|
stepDef = stepDefinitions.find(s => s.level === originalLevel);
|
|
|
|
if (!stepDef) {
|
|
// Try to find by current level if originalStepLevel not provided
|
|
stepDef = stepDefinitions.find(s => s.level === approver.level);
|
|
}
|
|
|
|
// System steps (Activity Creation, E-Invoice Generation, Credit Note Confirmation) are no longer approval steps
|
|
// They are handled as activity logs only
|
|
// If approver has system email but no step definition found, skip creating approval level
|
|
if (!stepDef && isSystemEmail) {
|
|
logger.info(`[DealerClaimService] Skipping system step approver at level ${approver.level} - system steps are now activity logs only`);
|
|
continue; // Skip creating approval level for system steps
|
|
}
|
|
|
|
if (stepDef) {
|
|
levelName = stepDef.name;
|
|
isSystemStep = false; // No system steps in approval levels anymore
|
|
isFinalApprover = stepDef.level === 5; // Last step is now Requestor Claim Approval (level 5)
|
|
processedOriginalSteps.add(stepDef.level);
|
|
} else {
|
|
// Fallback - shouldn't happen but handle gracefully
|
|
levelName = `Step ${approver.level}`;
|
|
isSystemStep = false;
|
|
logger.warn(`[DealerClaimService] Could not find step definition for approver at level ${approver.level}, using fallback name`);
|
|
}
|
|
|
|
// Ensure levelName is never empty and truncate if too long (max 100 chars)
|
|
if (!levelName || levelName.trim() === '') {
|
|
levelName = approver.isAdditional
|
|
? `Additional Approver - Level ${approver.level}`
|
|
: `Step ${approver.level}`;
|
|
logger.warn(`[DealerClaimService] levelName was empty for approver at level ${approver.level}, using fallback: ${levelName}`);
|
|
}
|
|
|
|
// Truncate levelName to max 100 characters (database constraint)
|
|
if (levelName.length > 100) {
|
|
logger.warn(`[DealerClaimService] levelName too long (${levelName.length} chars) for level ${approver.level}, truncating to 100 chars`);
|
|
levelName = levelName.substring(0, 97) + '...';
|
|
}
|
|
}
|
|
|
|
// System steps are no longer created as approval levels - they are activity logs only
|
|
// This code path should not be reached anymore, but kept for safety
|
|
if (isSystemStep) {
|
|
logger.warn(`[DealerClaimService] System step detected but should not create approval level. Skipping.`);
|
|
continue; // Skip creating approval level for system steps
|
|
}
|
|
|
|
{
|
|
// User-provided approver (fixed or additional)
|
|
if (!approver.email) {
|
|
throw new Error(`Approver email is required for level ${approver.level}: ${levelName}`);
|
|
}
|
|
|
|
// Calculate TAT in hours
|
|
if (approver.tat) {
|
|
const tat = Number(approver.tat);
|
|
if (isNaN(tat) || tat <= 0) {
|
|
throw new Error(`Invalid TAT for level ${approver.level}. TAT must be a positive number.`);
|
|
}
|
|
tatHours = approver.tatType === 'days' ? tat * 24 : tat;
|
|
} else if (stepDef) {
|
|
tatHours = stepDef.defaultTat;
|
|
}
|
|
|
|
// Ensure user exists in database (create from Okta if needed)
|
|
let user: User | null = null;
|
|
|
|
// Helper function to check if a string is a valid UUID
|
|
const isValidUUID = (str: string): boolean => {
|
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
return uuidRegex.test(str);
|
|
};
|
|
|
|
// Try to find user by userId if it's a valid UUID
|
|
if (approver.userId && isValidUUID(approver.userId)) {
|
|
try {
|
|
user = await User.findByPk(approver.userId);
|
|
} catch (error: any) {
|
|
// If findByPk fails (e.g., invalid UUID format), log and continue to email lookup
|
|
logger.debug(`[DealerClaimService] Could not find user by userId ${approver.userId}, will try email lookup`);
|
|
}
|
|
}
|
|
|
|
// If user not found by ID (or userId was not a valid UUID), try email
|
|
if (!user && approver.email) {
|
|
user = await User.findOne({ where: { email: approver.email.toLowerCase() } });
|
|
|
|
if (!user) {
|
|
// User doesn't exist - create from Okta
|
|
logger.info(`[DealerClaimService] User ${approver.email} not found in DB, syncing from Okta`);
|
|
try {
|
|
user = await this.userService.ensureUserExists({
|
|
email: approver.email.toLowerCase(),
|
|
userId: approver.userId, // Pass Okta ID if provided (ensureUserExists will handle it)
|
|
}) as any;
|
|
logger.info(`[DealerClaimService] Successfully synced user ${approver.email} from Okta`);
|
|
} catch (oktaError: any) {
|
|
logger.error(`[DealerClaimService] Failed to sync user from Okta: ${approver.email}`, oktaError);
|
|
throw new Error(`User email '${approver.email}' not found in organization directory. Please verify the email address.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!user) {
|
|
throw new Error(`Could not resolve user for level ${approver.level}: ${approver.email}`);
|
|
}
|
|
|
|
approverId = user.userId;
|
|
approverEmail = user.email;
|
|
approverName = approver.name || user.displayName || user.email || 'Approver';
|
|
}
|
|
|
|
// Ensure we have a valid approverId
|
|
if (!approverId) {
|
|
logger.error(`[DealerClaimService] No approverId resolved for level ${approver.level}, using initiator as fallback`);
|
|
approverId = initiatorId;
|
|
approverEmail = approverEmail || initiator.email;
|
|
approverName = approverName || 'Unknown Approver';
|
|
}
|
|
|
|
// Ensure approverId is a valid UUID before creating
|
|
const isValidUUID = (str: string): boolean => {
|
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
return uuidRegex.test(str);
|
|
};
|
|
|
|
if (!approverId || !isValidUUID(approverId)) {
|
|
logger.error(`[DealerClaimService] Invalid approverId for level ${approver.level}: ${approverId}`);
|
|
throw new Error(`Invalid approver ID format for level ${approver.level}. Expected UUID.`);
|
|
}
|
|
|
|
// Create approval level using the approver's level (which may be shifted)
|
|
const now = new Date();
|
|
const isStep1 = approver.level === 1;
|
|
|
|
try {
|
|
// Check for duplicate level_number for this request_id (unique constraint)
|
|
const existingLevel = await ApprovalLevel.findOne({
|
|
where: {
|
|
requestId,
|
|
levelNumber: approver.level
|
|
}
|
|
});
|
|
|
|
if (existingLevel) {
|
|
logger.error(`[DealerClaimService] Duplicate level number ${approver.level} already exists for request ${requestId}`);
|
|
throw new Error(`Level ${approver.level} already exists for this request. This may indicate a duplicate approver.`);
|
|
}
|
|
|
|
await ApprovalLevel.create({
|
|
requestId,
|
|
levelNumber: approver.level, // Use the approver's level (may be shifted)
|
|
levelName: levelName, // Already validated and truncated above
|
|
approverId: approverId,
|
|
approverEmail: approverEmail || '',
|
|
approverName: approverName || 'Unknown',
|
|
tatHours: tatHours || 0,
|
|
status: isStep1 ? ApprovalStatus.PENDING : ApprovalStatus.PENDING,
|
|
isFinalApprover: isFinalApprover || false,
|
|
elapsedHours: 0,
|
|
remainingHours: tatHours || 0,
|
|
tatPercentageUsed: 0,
|
|
levelStartTime: isStep1 ? now : undefined,
|
|
tatStartTime: isStep1 ? now : undefined,
|
|
// Note: tatDays is NOT included - it's auto-calculated by the database
|
|
} as any);
|
|
} catch (createError: any) {
|
|
// Log detailed validation errors
|
|
const errorDetails: any = {
|
|
message: createError.message,
|
|
name: createError.name,
|
|
level: approver.level,
|
|
levelName: levelName?.substring(0, 50), // Truncate for logging
|
|
approverId,
|
|
approverEmail,
|
|
approverName: approverName?.substring(0, 50),
|
|
tatHours,
|
|
};
|
|
|
|
// Sequelize validation errors
|
|
if (createError.errors && Array.isArray(createError.errors)) {
|
|
errorDetails.validationErrors = createError.errors.map((e: any) => ({
|
|
field: e.path,
|
|
message: e.message,
|
|
value: e.value,
|
|
type: e.type,
|
|
}));
|
|
}
|
|
|
|
// Database constraint errors
|
|
if (createError.parent) {
|
|
errorDetails.databaseError = {
|
|
message: createError.parent.message,
|
|
code: createError.parent.code,
|
|
detail: createError.parent.detail,
|
|
constraint: createError.parent.constraint,
|
|
};
|
|
}
|
|
|
|
logger.error(`[DealerClaimService] Failed to create approval level for level ${approver.level}:`, errorDetails);
|
|
throw new Error(`Failed to create approval level ${approver.level} (${levelName}): ${createError.message}`);
|
|
}
|
|
}
|
|
|
|
// Validate that required fixed steps were processed
|
|
const requiredSteps = stepDefinitions.filter(s => !s.isAuto);
|
|
for (const requiredStep of requiredSteps) {
|
|
if (!processedOriginalSteps.has(requiredStep.level)) {
|
|
logger.warn(`[DealerClaimService] Required step ${requiredStep.level} (${requiredStep.name}) was not found in approvers array`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create participants for claim management workflow
|
|
* Includes: Initiator, Dealer, Department Lead, Finance Approver
|
|
* Excludes: System users
|
|
*/
|
|
private async createClaimParticipants(
|
|
requestId: string,
|
|
initiatorId: string,
|
|
dealerEmail?: string
|
|
): Promise<void> {
|
|
try {
|
|
const initiator = await User.findByPk(initiatorId);
|
|
if (!initiator) {
|
|
throw new Error('Initiator not found');
|
|
}
|
|
|
|
// Get all approval levels to extract approvers
|
|
const approvalLevels = await ApprovalLevel.findAll({
|
|
where: { requestId },
|
|
order: [['levelNumber', 'ASC']],
|
|
});
|
|
|
|
const participantsToAdd: Array<{
|
|
userId: string;
|
|
userEmail: string;
|
|
userName: string;
|
|
participantType: ParticipantType;
|
|
}> = [];
|
|
|
|
// 1. Add Initiator
|
|
participantsToAdd.push({
|
|
userId: initiatorId,
|
|
userEmail: initiator.email,
|
|
userName: initiator.displayName || initiator.email || 'Initiator',
|
|
participantType: ParticipantType.INITIATOR,
|
|
});
|
|
|
|
// 2. Add Dealer (treated as Okta/internal user - sync from Okta if needed)
|
|
if (dealerEmail && dealerEmail.toLowerCase() !== 'system@royalenfield.com') {
|
|
let dealerUser = await User.findOne({
|
|
where: { email: dealerEmail.toLowerCase() },
|
|
});
|
|
|
|
if (!dealerUser) {
|
|
logger.info(`[DealerClaimService] Dealer ${dealerEmail} not found in DB for participants, syncing from Okta`);
|
|
try {
|
|
dealerUser = await this.userService.ensureUserExists({
|
|
email: dealerEmail.toLowerCase(),
|
|
}) as any;
|
|
logger.info(`[DealerClaimService] Successfully synced dealer ${dealerEmail} from Okta for participants`);
|
|
} catch (oktaError: any) {
|
|
logger.error(`[DealerClaimService] Failed to sync dealer from Okta for participants: ${dealerEmail}`, oktaError);
|
|
// Don't throw - dealer might be added later, but log the error
|
|
logger.warn(`[DealerClaimService] Skipping dealer participant creation for ${dealerEmail}`);
|
|
}
|
|
}
|
|
|
|
if (dealerUser) {
|
|
participantsToAdd.push({
|
|
userId: dealerUser.userId,
|
|
userEmail: dealerUser.email,
|
|
userName: dealerUser.displayName || dealerUser.email || 'Dealer',
|
|
participantType: ParticipantType.APPROVER,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 3. Add all approvers from approval levels (excluding system and duplicates)
|
|
const addedUserIds = new Set<string>([initiatorId]);
|
|
const systemEmails = ['system@royalenfield.com'];
|
|
|
|
for (const level of approvalLevels) {
|
|
const approverEmail = (level as any).approverEmail?.toLowerCase();
|
|
const approverId = (level as any).approverId;
|
|
|
|
// Skip if system user or already added
|
|
if (
|
|
!approverId ||
|
|
systemEmails.includes(approverEmail || '') ||
|
|
addedUserIds.has(approverId)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
// Skip if email is system email
|
|
if (approverEmail && systemEmails.includes(approverEmail)) {
|
|
continue;
|
|
}
|
|
|
|
// Helper function to check if a string is a valid UUID
|
|
const isValidUUID = (str: string): boolean => {
|
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
return uuidRegex.test(str);
|
|
};
|
|
|
|
// Only try to find user if approverId is a valid UUID
|
|
if (!isValidUUID(approverId)) {
|
|
logger.warn(`[DealerClaimService] Invalid UUID format for approverId: ${approverId}, skipping participant creation`);
|
|
continue;
|
|
}
|
|
|
|
const approverUser = await User.findByPk(approverId);
|
|
if (approverUser) {
|
|
participantsToAdd.push({
|
|
userId: approverId,
|
|
userEmail: approverUser.email,
|
|
userName: approverUser.displayName || approverUser.email || 'Approver',
|
|
participantType: ParticipantType.APPROVER,
|
|
});
|
|
addedUserIds.add(approverId);
|
|
}
|
|
}
|
|
|
|
// Create participants (deduplicate by userId)
|
|
const participantMap = new Map<string, typeof participantsToAdd[0]>();
|
|
const rolePriority: Record<string, number> = {
|
|
'INITIATOR': 3,
|
|
'APPROVER': 2,
|
|
'SPECTATOR': 1,
|
|
};
|
|
|
|
for (const participantData of participantsToAdd) {
|
|
const existing = participantMap.get(participantData.userId);
|
|
if (existing) {
|
|
// Keep higher priority role
|
|
const existingPriority = rolePriority[existing.participantType] || 0;
|
|
const newPriority = rolePriority[participantData.participantType] || 0;
|
|
if (newPriority > existingPriority) {
|
|
participantMap.set(participantData.userId, participantData);
|
|
}
|
|
} else {
|
|
participantMap.set(participantData.userId, participantData);
|
|
}
|
|
}
|
|
|
|
// Create participant records
|
|
for (const participantData of participantMap.values()) {
|
|
await Participant.create({
|
|
requestId,
|
|
userId: participantData.userId,
|
|
userEmail: participantData.userEmail,
|
|
userName: participantData.userName,
|
|
participantType: participantData.participantType,
|
|
canComment: true,
|
|
canViewDocuments: true,
|
|
canDownloadDocuments: true,
|
|
notificationEnabled: true,
|
|
addedBy: initiatorId,
|
|
isActive: true,
|
|
} as any);
|
|
}
|
|
|
|
logger.info(`[DealerClaimService] Created ${participantMap.size} participants for claim request ${requestId}`);
|
|
} catch (error) {
|
|
logger.error('[DealerClaimService] Error creating participants:', error);
|
|
// Don't throw - participants are not critical for request creation
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve Department Lead based on initiator's department/manager
|
|
* If multiple users found with same department, uses the first one
|
|
*/
|
|
/**
|
|
* Resolve Department Lead/Manager by searching Okta using manager's displayName
|
|
* Flow:
|
|
* 1. Get manager displayName from initiator's user record
|
|
* 2. Search Okta directory by displayName
|
|
* 3. If empty: Return null (no manager found, fallback to old method)
|
|
* 4. If single: Use that user, create in DB if doesn't exist, return user
|
|
* 5. If multiple: Throw error with list of users (frontend will show confirmation)
|
|
*
|
|
* @param initiator - The user creating the claim request
|
|
* @returns User object for department lead/manager, or null if not found
|
|
* @throws Error if multiple managers found (frontend should handle confirmation)
|
|
*/
|
|
private async resolveDepartmentLeadFromManager(initiator: User): Promise<User | null> {
|
|
try {
|
|
// Get manager displayName from initiator's user record
|
|
const managerDisplayName = initiator.manager; // This is the displayName of the manager
|
|
|
|
if (!managerDisplayName) {
|
|
logger.warn(`[DealerClaimService] Initiator ${initiator.email} has no manager displayName set`);
|
|
// Return null - caller will handle the error
|
|
return null;
|
|
}
|
|
|
|
logger.info(`[DealerClaimService] Searching Okta for manager with displayName: "${managerDisplayName}"`);
|
|
|
|
// Search Okta by displayName
|
|
const oktaUsers = await this.userService.searchOktaByDisplayName(managerDisplayName);
|
|
|
|
if (oktaUsers.length === 0) {
|
|
logger.warn(`[DealerClaimService] No reporting manager found in Okta for displayName: "${managerDisplayName}"`);
|
|
// Return null - caller will handle the error
|
|
return null;
|
|
}
|
|
|
|
if (oktaUsers.length === 1) {
|
|
// Single match - use this user
|
|
const oktaUser = oktaUsers[0];
|
|
const managerEmail = oktaUser.profile.email || oktaUser.profile.login;
|
|
|
|
logger.info(`[DealerClaimService] Found single manager match: ${managerEmail} for displayName: "${managerDisplayName}"`);
|
|
|
|
// Check if user exists in DB, create if doesn't exist
|
|
const managerUser = await this.userService.ensureUserExists({
|
|
userId: oktaUser.id,
|
|
email: managerEmail,
|
|
displayName: oktaUser.profile.displayName || `${oktaUser.profile.firstName || ''} ${oktaUser.profile.lastName || ''}`.trim(),
|
|
firstName: oktaUser.profile.firstName,
|
|
lastName: oktaUser.profile.lastName,
|
|
department: oktaUser.profile.department,
|
|
phone: oktaUser.profile.mobilePhone,
|
|
});
|
|
|
|
return managerUser as any;
|
|
}
|
|
|
|
// Multiple matches - throw error with list for frontend confirmation
|
|
const managerOptions = oktaUsers.map(u => ({
|
|
userId: u.id,
|
|
email: u.profile.email || u.profile.login,
|
|
displayName: u.profile.displayName || `${u.profile.firstName || ''} ${u.profile.lastName || ''}`.trim(),
|
|
firstName: u.profile.firstName,
|
|
lastName: u.profile.lastName,
|
|
department: u.profile.department,
|
|
}));
|
|
|
|
logger.warn(`[DealerClaimService] Multiple managers found (${oktaUsers.length}) for displayName: "${managerDisplayName}"`);
|
|
|
|
// Create a custom error with the manager options
|
|
const error: any = new Error(`Multiple reporting managers found. Please select one.`);
|
|
error.code = 'MULTIPLE_MANAGERS_FOUND';
|
|
error.managers = managerOptions;
|
|
throw error;
|
|
|
|
} catch (error: any) {
|
|
// If it's our custom multiple managers error, re-throw it
|
|
if (error.code === 'MULTIPLE_MANAGERS_FOUND') {
|
|
throw error;
|
|
}
|
|
|
|
// For other errors, log and fallback to old method
|
|
logger.error(`[DealerClaimService] Error resolving manager from Okta:`, error);
|
|
return await this.resolveDepartmentLead(initiator);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Legacy method: Resolve Department Lead using old logic
|
|
* Kept as fallback when Okta search fails or manager displayName not set
|
|
*/
|
|
private async resolveDepartmentLead(initiator: User): Promise<User | null> {
|
|
try {
|
|
const { Op } = await import('sequelize');
|
|
|
|
logger.info(`[DealerClaimService] Resolving department lead for initiator: ${initiator.email}, department: ${initiator.department}, manager: ${initiator.manager}`);
|
|
|
|
// Priority 1: Find user with MANAGEMENT role in same department
|
|
if (initiator.department) {
|
|
const deptLeads = await User.findAll({
|
|
where: {
|
|
department: initiator.department,
|
|
role: 'MANAGEMENT' as any,
|
|
isActive: true,
|
|
},
|
|
order: [['createdAt', 'ASC']], // Get first one if multiple
|
|
limit: 1,
|
|
});
|
|
if (deptLeads.length > 0) {
|
|
logger.info(`[DealerClaimService] Found department lead by MANAGEMENT role: ${deptLeads[0].email} for department: ${initiator.department}`);
|
|
return deptLeads[0];
|
|
} else {
|
|
logger.debug(`[DealerClaimService] No MANAGEMENT role user found in department: ${initiator.department}`);
|
|
}
|
|
} else {
|
|
logger.debug(`[DealerClaimService] Initiator has no department set`);
|
|
}
|
|
|
|
// Priority 2: Find users with "Department Lead", "Team Lead", "Team Manager", "Group Manager", "Assistant Manager", "Deputy Manager" in designation, same department
|
|
if (initiator.department) {
|
|
const leads = await User.findAll({
|
|
where: {
|
|
department: initiator.department,
|
|
designation: {
|
|
[Op.or]: [
|
|
{ [Op.iLike]: '%department lead%' },
|
|
{ [Op.iLike]: '%departmentlead%' },
|
|
{ [Op.iLike]: '%dept lead%' },
|
|
{ [Op.iLike]: '%deptlead%' },
|
|
{ [Op.iLike]: '%team lead%' },
|
|
{ [Op.iLike]: '%team manager%' },
|
|
{ [Op.iLike]: '%group manager%' },
|
|
{ [Op.iLike]: '%assistant manager%' },
|
|
{ [Op.iLike]: '%deputy manager%' },
|
|
{ [Op.iLike]: '%lead%' },
|
|
{ [Op.iLike]: '%head%' },
|
|
{ [Op.iLike]: '%manager%' },
|
|
],
|
|
} as any,
|
|
isActive: true,
|
|
},
|
|
order: [['createdAt', 'ASC']], // Get first one if multiple
|
|
limit: 1,
|
|
});
|
|
if (leads.length > 0) {
|
|
logger.info(`[DealerClaimService] Found lead by designation: ${leads[0].email} (designation: ${leads[0].designation})`);
|
|
return leads[0];
|
|
}
|
|
}
|
|
|
|
// Priority 3: Use initiator's manager field
|
|
if (initiator.manager) {
|
|
const manager = await User.findOne({
|
|
where: {
|
|
email: initiator.manager,
|
|
isActive: true,
|
|
},
|
|
});
|
|
if (manager) {
|
|
logger.info(`[DealerClaimService] Using initiator's manager as department lead: ${manager.email}`);
|
|
return manager;
|
|
}
|
|
}
|
|
|
|
// Priority 4: Find any user in same department (fallback - use first one)
|
|
if (initiator.department) {
|
|
const anyDeptUser = await User.findOne({
|
|
where: {
|
|
department: initiator.department,
|
|
isActive: true,
|
|
userId: { [Op.ne]: initiator.userId }, // Exclude initiator
|
|
},
|
|
order: [['createdAt', 'ASC']],
|
|
});
|
|
if (anyDeptUser) {
|
|
logger.warn(`[DealerClaimService] Using first available user in department as fallback: ${anyDeptUser.email} (designation: ${anyDeptUser.designation}, role: ${anyDeptUser.role})`);
|
|
return anyDeptUser;
|
|
} else {
|
|
logger.debug(`[DealerClaimService] No other users found in department: ${initiator.department}`);
|
|
}
|
|
}
|
|
|
|
// Priority 5: Search across all departments for users with "Department Lead" designation
|
|
logger.debug(`[DealerClaimService] Trying to find any user with "Department Lead" designation...`);
|
|
const anyDeptLead = await User.findOne({
|
|
where: {
|
|
designation: {
|
|
[Op.iLike]: '%department lead%',
|
|
} as any,
|
|
isActive: true,
|
|
userId: { [Op.ne]: initiator.userId }, // Exclude initiator
|
|
},
|
|
order: [['createdAt', 'ASC']],
|
|
});
|
|
if (anyDeptLead) {
|
|
logger.warn(`[DealerClaimService] Found user with "Department Lead" designation across all departments: ${anyDeptLead.email} (department: ${anyDeptLead.department})`);
|
|
return anyDeptLead;
|
|
}
|
|
|
|
// Priority 6: Find any user with MANAGEMENT role (across all departments)
|
|
logger.debug(`[DealerClaimService] Trying to find any user with MANAGEMENT role...`);
|
|
const anyManagementUser = await User.findOne({
|
|
where: {
|
|
role: 'MANAGEMENT' as any,
|
|
isActive: true,
|
|
userId: { [Op.ne]: initiator.userId }, // Exclude initiator
|
|
},
|
|
order: [['createdAt', 'ASC']],
|
|
});
|
|
if (anyManagementUser) {
|
|
logger.warn(`[DealerClaimService] Found user with MANAGEMENT role across all departments: ${anyManagementUser.email} (department: ${anyManagementUser.department})`);
|
|
return anyManagementUser;
|
|
}
|
|
|
|
// Priority 7: Find any user with ADMIN role (across all departments)
|
|
logger.debug(`[DealerClaimService] Trying to find any user with ADMIN role...`);
|
|
const anyAdminUser = await User.findOne({
|
|
where: {
|
|
role: 'ADMIN' as any,
|
|
isActive: true,
|
|
userId: { [Op.ne]: initiator.userId }, // Exclude initiator
|
|
},
|
|
order: [['createdAt', 'ASC']],
|
|
});
|
|
if (anyAdminUser) {
|
|
logger.warn(`[DealerClaimService] Found user with ADMIN role as fallback: ${anyAdminUser.email} (department: ${anyAdminUser.department})`);
|
|
return anyAdminUser;
|
|
}
|
|
|
|
logger.warn(`[DealerClaimService] Could not resolve department lead for initiator: ${initiator.email} (department: ${initiator.department || 'NOT SET'}, manager: ${initiator.manager || 'NOT SET'})`);
|
|
logger.warn(`[DealerClaimService] No suitable department lead found. Please ensure:`);
|
|
logger.warn(`[DealerClaimService] 1. Initiator has a department set: ${initiator.department || 'MISSING'}`);
|
|
logger.warn(`[DealerClaimService] 2. There is at least one user with MANAGEMENT role in the system`);
|
|
logger.warn(`[DealerClaimService] 3. Initiator's manager field is set: ${initiator.manager || 'MISSING'}`);
|
|
return null;
|
|
} catch (error) {
|
|
logger.error('[DealerClaimService] Error resolving department lead:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve Finance Team approver for Step 8
|
|
*/
|
|
private async resolveFinanceApprover(): Promise<User | null> {
|
|
try {
|
|
const { Op } = await import('sequelize');
|
|
|
|
// Priority 1: Find user with department containing "Finance" and MANAGEMENT role
|
|
const financeManager = await User.findOne({
|
|
where: {
|
|
department: {
|
|
[Op.iLike]: '%finance%',
|
|
} as any,
|
|
role: 'MANAGEMENT' as any,
|
|
},
|
|
order: [['createdAt', 'DESC']],
|
|
});
|
|
if (financeManager) {
|
|
logger.info(`[DealerClaimService] Found finance manager: ${financeManager.email}`);
|
|
return financeManager;
|
|
}
|
|
|
|
// Priority 2: Find user with designation containing "Finance" or "Accountant"
|
|
const financeUser = await User.findOne({
|
|
where: {
|
|
[Op.or]: [
|
|
{ designation: { [Op.iLike]: '%finance%' } as any },
|
|
{ designation: { [Op.iLike]: '%accountant%' } as any },
|
|
],
|
|
},
|
|
order: [['createdAt', 'DESC']],
|
|
});
|
|
if (financeUser) {
|
|
logger.info(`[DealerClaimService] Found finance user by designation: ${financeUser.email}`);
|
|
return financeUser;
|
|
}
|
|
|
|
// Priority 3: Check admin configurations for finance team email
|
|
const { getConfigValue } = await import('./configReader.service');
|
|
const financeEmail = await getConfigValue('FINANCE_TEAM_EMAIL');
|
|
if (financeEmail) {
|
|
const financeUserByEmail = await User.findOne({
|
|
where: { email: financeEmail },
|
|
});
|
|
if (financeUserByEmail) {
|
|
logger.info(`[DealerClaimService] Found finance user from config: ${financeEmail}`);
|
|
return financeUserByEmail;
|
|
}
|
|
}
|
|
|
|
logger.warn('[DealerClaimService] Could not resolve finance approver, will use default email');
|
|
return null;
|
|
} catch (error) {
|
|
logger.error('[DealerClaimService] Error resolving finance approver:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get claim details with all related data
|
|
*/
|
|
async getClaimDetails(requestId: string): Promise<any> {
|
|
try {
|
|
const request = await WorkflowRequest.findByPk(requestId, {
|
|
include: [
|
|
{ model: User, as: 'initiator' },
|
|
{ model: ApprovalLevel, as: 'approvalLevels' },
|
|
]
|
|
});
|
|
|
|
if (!request) {
|
|
throw new Error('Request not found');
|
|
}
|
|
|
|
// Handle backward compatibility: workflowType may be undefined in old environments
|
|
const workflowType = request.workflowType || 'NON_TEMPLATIZED';
|
|
if (workflowType !== 'CLAIM_MANAGEMENT') {
|
|
throw new Error('Request is not a claim management request');
|
|
}
|
|
|
|
// Fetch related claim data separately
|
|
const claimDetails = await DealerClaimDetails.findOne({
|
|
where: { requestId }
|
|
});
|
|
|
|
const proposalDetails = await DealerProposalDetails.findOne({
|
|
where: { requestId },
|
|
include: [
|
|
{
|
|
model: DealerProposalCostItem,
|
|
as: 'costItems',
|
|
required: false,
|
|
separate: true, // Use separate query for ordering
|
|
order: [['itemOrder', 'ASC']]
|
|
}
|
|
]
|
|
});
|
|
|
|
const completionDetails = await DealerCompletionDetails.findOne({
|
|
where: { requestId }
|
|
});
|
|
|
|
// Fetch Internal Order details
|
|
const internalOrder = await InternalOrder.findOne({
|
|
where: { requestId },
|
|
include: [
|
|
{ model: User, as: 'organizer', required: false }
|
|
]
|
|
});
|
|
|
|
// Serialize claim details to ensure proper field names
|
|
let serializedClaimDetails = null;
|
|
if (claimDetails) {
|
|
serializedClaimDetails = (claimDetails as any).toJSON ? (claimDetails as any).toJSON() : claimDetails;
|
|
}
|
|
|
|
// Transform proposal details to include cost items as array
|
|
let transformedProposalDetails = null;
|
|
if (proposalDetails) {
|
|
const proposalData = (proposalDetails as any).toJSON ? (proposalDetails as any).toJSON() : proposalDetails;
|
|
|
|
// Get cost items from separate table (dealer_proposal_cost_items)
|
|
let costBreakup: any[] = [];
|
|
if (proposalData.costItems && Array.isArray(proposalData.costItems) && proposalData.costItems.length > 0) {
|
|
// Use cost items from separate table
|
|
costBreakup = proposalData.costItems.map((item: any) => ({
|
|
description: item.itemDescription || item.description,
|
|
amount: Number(item.amount) || 0
|
|
}));
|
|
}
|
|
// Note: costBreakup JSONB field has been removed - only using separate table now
|
|
|
|
transformedProposalDetails = {
|
|
...proposalData,
|
|
costBreakup, // Always return as array for frontend compatibility
|
|
costItems: proposalData.costItems || [] // Also include raw cost items
|
|
};
|
|
}
|
|
|
|
// Serialize completion details
|
|
let serializedCompletionDetails = null;
|
|
if (completionDetails) {
|
|
serializedCompletionDetails = (completionDetails as any).toJSON ? (completionDetails as any).toJSON() : completionDetails;
|
|
}
|
|
|
|
// Serialize internal order details
|
|
let serializedInternalOrder = null;
|
|
if (internalOrder) {
|
|
serializedInternalOrder = (internalOrder as any).toJSON ? (internalOrder as any).toJSON() : internalOrder;
|
|
}
|
|
|
|
// Fetch Budget Tracking details
|
|
const budgetTracking = await ClaimBudgetTracking.findOne({
|
|
where: { requestId }
|
|
});
|
|
|
|
// Fetch Invoice details
|
|
const claimInvoice = await ClaimInvoice.findOne({
|
|
where: { requestId }
|
|
});
|
|
|
|
// Fetch Credit Note details
|
|
const claimCreditNote = await ClaimCreditNote.findOne({
|
|
where: { requestId }
|
|
});
|
|
|
|
// Fetch Completion Expenses (individual expense items)
|
|
const completionExpenses = await DealerCompletionExpense.findAll({
|
|
where: { requestId },
|
|
order: [['createdAt', 'ASC']]
|
|
});
|
|
|
|
// Serialize new tables
|
|
let serializedBudgetTracking = null;
|
|
if (budgetTracking) {
|
|
serializedBudgetTracking = (budgetTracking as any).toJSON ? (budgetTracking as any).toJSON() : budgetTracking;
|
|
}
|
|
|
|
let serializedInvoice = null;
|
|
if (claimInvoice) {
|
|
serializedInvoice = (claimInvoice as any).toJSON ? (claimInvoice as any).toJSON() : claimInvoice;
|
|
}
|
|
|
|
let serializedCreditNote = null;
|
|
if (claimCreditNote) {
|
|
serializedCreditNote = (claimCreditNote as any).toJSON ? (claimCreditNote as any).toJSON() : claimCreditNote;
|
|
}
|
|
|
|
// Transform completion expenses to array format for frontend
|
|
const expensesBreakdown = completionExpenses.map((expense: any) => {
|
|
const expenseData = expense.toJSON ? expense.toJSON() : expense;
|
|
return {
|
|
description: expenseData.description || '',
|
|
amount: Number(expenseData.amount) || 0
|
|
};
|
|
});
|
|
|
|
return {
|
|
request: (request as any).toJSON ? (request as any).toJSON() : request,
|
|
claimDetails: serializedClaimDetails,
|
|
proposalDetails: transformedProposalDetails,
|
|
completionDetails: serializedCompletionDetails,
|
|
internalOrder: serializedInternalOrder,
|
|
// New normalized tables
|
|
budgetTracking: serializedBudgetTracking,
|
|
invoice: serializedInvoice,
|
|
creditNote: serializedCreditNote,
|
|
completionExpenses: expensesBreakdown, // Array of expense items
|
|
};
|
|
} catch (error) {
|
|
logger.error('[DealerClaimService] Error getting claim details:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Submit dealer proposal (Step 1)
|
|
*/
|
|
async submitDealerProposal(
|
|
requestId: string,
|
|
proposalData: {
|
|
proposalDocumentPath?: string;
|
|
proposalDocumentUrl?: string;
|
|
costBreakup: any[];
|
|
totalEstimatedBudget: number;
|
|
timelineMode: 'date' | 'days';
|
|
expectedCompletionDate?: Date;
|
|
expectedCompletionDays?: number;
|
|
dealerComments: string;
|
|
},
|
|
dealerUserId?: string // Optional dealer user ID for history tracking
|
|
): Promise<void> {
|
|
try {
|
|
const request = await WorkflowRequest.findByPk(requestId);
|
|
if (!request || request.workflowType !== 'CLAIM_MANAGEMENT') {
|
|
throw new Error('Invalid claim request');
|
|
}
|
|
|
|
// Get dealer user ID if not provided - try to find by dealer email from claim details
|
|
let actualDealerUserId: string | null = dealerUserId || null;
|
|
if (!actualDealerUserId) {
|
|
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
|
|
if (claimDetails?.dealerEmail) {
|
|
const dealerUser = await User.findOne({
|
|
where: { email: claimDetails.dealerEmail }
|
|
});
|
|
actualDealerUserId = dealerUser?.userId || null;
|
|
}
|
|
}
|
|
|
|
if (request.currentLevel !== 1) {
|
|
throw new Error('Proposal can only be submitted at step 1');
|
|
}
|
|
|
|
// Save proposal details (costBreakup removed - now using separate table)
|
|
const [proposal] = await DealerProposalDetails.upsert({
|
|
requestId,
|
|
proposalDocumentPath: proposalData.proposalDocumentPath,
|
|
proposalDocumentUrl: proposalData.proposalDocumentUrl,
|
|
// costBreakup field removed - now using dealer_proposal_cost_items table
|
|
totalEstimatedBudget: proposalData.totalEstimatedBudget,
|
|
timelineMode: proposalData.timelineMode,
|
|
expectedCompletionDate: proposalData.expectedCompletionDate,
|
|
expectedCompletionDays: proposalData.expectedCompletionDays,
|
|
dealerComments: proposalData.dealerComments,
|
|
submittedAt: new Date(),
|
|
}, {
|
|
returning: true
|
|
});
|
|
|
|
// Get proposalId - handle both Sequelize instance and plain object
|
|
let proposalId = (proposal as any).proposalId
|
|
|| (proposal as any).proposal_id;
|
|
|
|
// If not found, try getDataValue method
|
|
if (!proposalId && (proposal as any).getDataValue) {
|
|
proposalId = (proposal as any).getDataValue('proposalId');
|
|
}
|
|
|
|
// If still not found, fetch the proposal by requestId
|
|
if (!proposalId) {
|
|
const existingProposal = await DealerProposalDetails.findOne({
|
|
where: { requestId }
|
|
});
|
|
if (existingProposal) {
|
|
proposalId = (existingProposal as any).proposalId
|
|
|| (existingProposal as any).proposal_id
|
|
|| ((existingProposal as any).getDataValue ? (existingProposal as any).getDataValue('proposalId') : null);
|
|
}
|
|
}
|
|
|
|
if (!proposalId) {
|
|
throw new Error('Failed to get proposal ID after saving proposal details');
|
|
}
|
|
|
|
// Save cost items to separate table (preferred approach)
|
|
if (proposalData.costBreakup && proposalData.costBreakup.length > 0) {
|
|
// Delete existing cost items for this proposal (in case of update)
|
|
await DealerProposalCostItem.destroy({
|
|
where: { proposalId }
|
|
});
|
|
|
|
// Insert new cost items
|
|
const costItems = proposalData.costBreakup.map((item: any, index: number) => ({
|
|
proposalId,
|
|
requestId,
|
|
itemDescription: item.description || item.itemDescription || '',
|
|
amount: Number(item.amount) || 0,
|
|
itemOrder: index
|
|
}));
|
|
|
|
await DealerProposalCostItem.bulkCreate(costItems);
|
|
logger.info(`[DealerClaimService] Saved ${costItems.length} cost items for proposal ${proposalId}`);
|
|
}
|
|
|
|
// Update budget tracking with proposal estimate
|
|
await ClaimBudgetTracking.upsert({
|
|
requestId,
|
|
proposalEstimatedBudget: proposalData.totalEstimatedBudget,
|
|
proposalSubmittedAt: new Date(),
|
|
budgetStatus: BudgetStatus.PROPOSED,
|
|
currency: 'INR',
|
|
});
|
|
|
|
// Approve Dealer Proposal Submission step dynamically (by levelName, not hardcoded step number)
|
|
let dealerProposalLevel = await ApprovalLevel.findOne({
|
|
where: {
|
|
requestId,
|
|
levelName: 'Dealer Proposal Submission'
|
|
}
|
|
});
|
|
|
|
// Fallback: try to find by levelNumber 1 (for backwards compatibility)
|
|
if (!dealerProposalLevel) {
|
|
dealerProposalLevel = await ApprovalLevel.findOne({
|
|
where: { requestId, levelNumber: 1 }
|
|
});
|
|
}
|
|
|
|
if (dealerProposalLevel) {
|
|
// Use dealer's comment if provided, otherwise use default message
|
|
const approvalComment = proposalData.dealerComments?.trim()
|
|
? proposalData.dealerComments.trim()
|
|
: 'Dealer proposal submitted';
|
|
|
|
// Perform the approval action FIRST - only save snapshot if action succeeds
|
|
await this.approvalService.approveLevel(
|
|
dealerProposalLevel.levelId,
|
|
{ action: 'APPROVE', comments: approvalComment },
|
|
actualDealerUserId || (request as any).initiatorId || 'system', // Use dealer or initiator ID
|
|
{ ipAddress: null, userAgent: null }
|
|
);
|
|
|
|
// Save proposal history AFTER approval succeeds (this is the only snapshot needed for dealer submission)
|
|
// Use dealer user ID if available, otherwise use initiator ID as fallback
|
|
const historyUserId = actualDealerUserId || (request as any).initiatorId || null;
|
|
if (!historyUserId) {
|
|
logger.warn(`[DealerClaimService] No user ID available for proposal history, skipping history save`);
|
|
} else {
|
|
try {
|
|
await this.saveProposalHistory(
|
|
requestId,
|
|
dealerProposalLevel.levelId,
|
|
dealerProposalLevel.levelNumber,
|
|
`Proposal Submitted: ${approvalComment}`,
|
|
historyUserId
|
|
);
|
|
// Note: We don't save workflow history here - proposal history is sufficient
|
|
// Workflow history will be saved when the level is approved and moves to next level
|
|
} catch (snapshotError) {
|
|
// Log error but don't fail the submission - snapshot is for audit, not critical
|
|
logger.error(`[DealerClaimService] Failed to save proposal history snapshot (non-critical):`, snapshotError);
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.info(`[DealerClaimService] Dealer proposal submitted for request: ${requestId}`);
|
|
} catch (error) {
|
|
logger.error('[DealerClaimService] Error submitting dealer proposal:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Submit dealer completion documents (Step 5)
|
|
*/
|
|
async submitCompletionDocuments(
|
|
requestId: string,
|
|
completionData: {
|
|
activityCompletionDate: Date;
|
|
numberOfParticipants?: number;
|
|
closedExpenses: any[];
|
|
totalClosedExpenses: number;
|
|
invoicesReceipts?: any[];
|
|
attendanceSheet?: any;
|
|
completionDescription?: string;
|
|
},
|
|
dealerUserId?: string // Optional dealer user ID for history tracking
|
|
): Promise<void> {
|
|
try {
|
|
const request = await WorkflowRequest.findByPk(requestId);
|
|
// Handle backward compatibility: workflowType may be undefined in old environments
|
|
const workflowType = request?.workflowType || 'NON_TEMPLATIZED';
|
|
if (!request || workflowType !== 'CLAIM_MANAGEMENT') {
|
|
throw new Error('Invalid claim request');
|
|
}
|
|
|
|
// Find the "Dealer Completion Documents" step by levelName (handles step shifts due to additional approvers)
|
|
const approvalLevels = await ApprovalLevel.findAll({
|
|
where: { requestId },
|
|
order: [['levelNumber', 'ASC']]
|
|
});
|
|
|
|
const dealerCompletionStep = approvalLevels.find((level: any) => {
|
|
const levelName = (level.levelName || '').toLowerCase();
|
|
return levelName.includes('dealer completion') || levelName.includes('completion documents');
|
|
});
|
|
|
|
if (!dealerCompletionStep) {
|
|
throw new Error('Dealer Completion Documents step not found');
|
|
}
|
|
|
|
// Check if current level matches the Dealer Completion Documents step (handles step shifts)
|
|
if (request.currentLevel !== dealerCompletionStep.levelNumber) {
|
|
throw new Error(`Completion documents can only be submitted at the Dealer Completion Documents step (currently at step ${request.currentLevel})`);
|
|
}
|
|
|
|
// Save completion details
|
|
const [completionDetails] = await DealerCompletionDetails.upsert({
|
|
requestId,
|
|
activityCompletionDate: completionData.activityCompletionDate,
|
|
numberOfParticipants: completionData.numberOfParticipants,
|
|
totalClosedExpenses: completionData.totalClosedExpenses,
|
|
submittedAt: new Date(),
|
|
});
|
|
|
|
// Persist individual closed expenses to dealer_completion_expenses
|
|
const completionId = (completionDetails as any)?.completionId;
|
|
if (completionData.closedExpenses && completionData.closedExpenses.length > 0) {
|
|
// Clear existing expenses for this request to avoid duplicates
|
|
await DealerCompletionExpense.destroy({ where: { requestId } });
|
|
const expenseRows = completionData.closedExpenses.map((item: any) => ({
|
|
requestId,
|
|
completionId,
|
|
description: item.description,
|
|
amount: item.amount,
|
|
}));
|
|
await DealerCompletionExpense.bulkCreate(expenseRows);
|
|
}
|
|
|
|
// Update budget tracking with closed expenses
|
|
await ClaimBudgetTracking.upsert({
|
|
requestId,
|
|
closedExpenses: completionData.totalClosedExpenses,
|
|
closedExpensesSubmittedAt: new Date(),
|
|
budgetStatus: BudgetStatus.CLOSED,
|
|
currency: 'INR',
|
|
});
|
|
|
|
// Approve Dealer Completion Documents step dynamically (by levelName, not hardcoded step number)
|
|
let dealerCompletionLevel = await ApprovalLevel.findOne({
|
|
where: {
|
|
requestId,
|
|
levelName: 'Dealer Completion Documents'
|
|
}
|
|
});
|
|
|
|
// Fallback: try to find by levelNumber 4 (new position after removing system steps)
|
|
if (!dealerCompletionLevel) {
|
|
dealerCompletionLevel = await ApprovalLevel.findOne({
|
|
where: { requestId, levelNumber: 4 }
|
|
});
|
|
}
|
|
|
|
if (dealerCompletionLevel) {
|
|
// Use dealer's completion description if provided, otherwise use default message
|
|
const approvalComment = completionData.completionDescription?.trim()
|
|
? completionData.completionDescription.trim()
|
|
: 'Completion documents submitted';
|
|
|
|
// Get dealer user ID if not provided - try to find by dealer email from claim details
|
|
let actualDealerUserId: string | null = dealerUserId || null;
|
|
if (!actualDealerUserId) {
|
|
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
|
|
if (claimDetails?.dealerEmail) {
|
|
const dealerUser = await User.findOne({
|
|
where: { email: claimDetails.dealerEmail }
|
|
});
|
|
actualDealerUserId = dealerUser?.userId || null;
|
|
}
|
|
}
|
|
|
|
// Perform the approval action FIRST - only save snapshot if action succeeds
|
|
await this.approvalService.approveLevel(
|
|
dealerCompletionLevel.levelId,
|
|
{ action: 'APPROVE', comments: approvalComment },
|
|
actualDealerUserId || (request as any).initiatorId || 'system',
|
|
{ ipAddress: null, userAgent: null }
|
|
);
|
|
|
|
// Save completion history AFTER approval succeeds (this is the only snapshot needed for dealer completion)
|
|
// Use dealer user ID if available, otherwise use initiator ID as fallback
|
|
const historyUserId = actualDealerUserId || (request as any).initiatorId || null;
|
|
if (!historyUserId) {
|
|
logger.warn(`[DealerClaimService] No user ID available for completion history, skipping history save`);
|
|
} else {
|
|
try {
|
|
await this.saveCompletionHistory(
|
|
requestId,
|
|
dealerCompletionLevel.levelId,
|
|
dealerCompletionLevel.levelNumber,
|
|
`Completion Submitted: ${approvalComment}`,
|
|
historyUserId
|
|
);
|
|
// Note: We don't save workflow history here - completion history is sufficient
|
|
// Workflow history will be saved when the level is approved and moves to next level
|
|
} catch (snapshotError) {
|
|
// Log error but don't fail the submission - snapshot is for audit, not critical
|
|
logger.error(`[DealerClaimService] Failed to save completion history snapshot (non-critical):`, snapshotError);
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.info(`[DealerClaimService] Completion documents submitted for request: ${requestId}`);
|
|
} catch (error) {
|
|
logger.error('[DealerClaimService] Error submitting completion documents:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update IO details (Step 3 - Department Lead)
|
|
* Validates IO number with SAP and blocks budget
|
|
*/
|
|
/**
|
|
* Update IO details and block amount in SAP
|
|
* Only stores data when blocking amount > 0
|
|
* This method is called when user actually blocks the amount
|
|
*/
|
|
async updateIODetails(
|
|
requestId: string,
|
|
ioData: {
|
|
ioNumber: string;
|
|
ioRemark?: string;
|
|
availableBalance?: number;
|
|
blockedAmount?: number;
|
|
remainingBalance?: number;
|
|
},
|
|
organizedByUserId?: string
|
|
): Promise<void> {
|
|
try {
|
|
// Ensure blockedAmount is rounded to exactly 2 decimal places from the start
|
|
const blockedAmount = ioData.blockedAmount ? parseFloat(ioData.blockedAmount.toFixed(2)) : 0;
|
|
|
|
// If blocking amount > 0, proceed with SAP integration and blocking
|
|
// If blocking amount is 0 but ioNumber is provided, just save the IO details without blocking
|
|
if (blockedAmount <= 0) {
|
|
// Allow saving IO details (ioNumber only) even without blocking amount
|
|
// This is useful when Requestor Evaluation is in progress but amount hasn't been blocked yet
|
|
if (ioData.ioNumber) {
|
|
const organizedBy = organizedByUserId || null;
|
|
|
|
// Create or update Internal Order record with just IO details (no blocking)
|
|
const [internalOrder, created] = await InternalOrder.findOrCreate({
|
|
where: { requestId },
|
|
defaults: {
|
|
requestId,
|
|
ioNumber: ioData.ioNumber,
|
|
ioRemark: ioData.ioRemark || '', // Optional - kept for backward compatibility // Optional - keep for backward compatibility
|
|
ioAvailableBalance: ioData.availableBalance || 0,
|
|
ioBlockedAmount: 0,
|
|
ioRemainingBalance: ioData.remainingBalance || 0,
|
|
organizedBy: organizedBy || undefined,
|
|
organizedAt: new Date(),
|
|
status: IOStatus.PENDING,
|
|
}
|
|
});
|
|
|
|
if (!created) {
|
|
// Update existing IO record with new IO details
|
|
// IMPORTANT: When updating existing record, preserve balance fields from previous blocking
|
|
// Only update ioNumber - don't overwrite balance values
|
|
await internalOrder.update({
|
|
ioNumber: ioData.ioNumber,
|
|
// Don't update balance fields for existing records - preserve values from previous blocking
|
|
// Only update organizedBy and organizedAt
|
|
organizedBy: organizedBy || internalOrder.organizedBy,
|
|
organizedAt: new Date(),
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] IO details updated (preserved existing balance values) for request: ${requestId}`, {
|
|
ioNumber: ioData.ioNumber,
|
|
preservedAvailableBalance: internalOrder.ioAvailableBalance,
|
|
preservedBlockedAmount: internalOrder.ioBlockedAmount,
|
|
preservedRemainingBalance: internalOrder.ioRemainingBalance,
|
|
});
|
|
}
|
|
|
|
logger.info(`[DealerClaimService] IO details saved (without blocking) for request: ${requestId}`, {
|
|
ioNumber: ioData.ioNumber
|
|
});
|
|
|
|
return; // Exit early - no SAP blocking needed
|
|
} else {
|
|
throw new Error('Blocked amount must be greater than 0, or ioNumber must be provided');
|
|
}
|
|
}
|
|
|
|
// Validate IO number with SAP
|
|
const ioValidation = await sapIntegrationService.validateIONumber(ioData.ioNumber);
|
|
|
|
if (!ioValidation.isValid) {
|
|
throw new Error(`Invalid IO number: ${ioValidation.error || 'IO number not found in SAP'}`);
|
|
}
|
|
|
|
// Block budget in SAP
|
|
const request = await WorkflowRequest.findByPk(requestId);
|
|
const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN';
|
|
|
|
logger.info(`[DealerClaimService] Blocking budget in SAP:`, {
|
|
requestId,
|
|
requestNumber,
|
|
ioNumber: ioData.ioNumber,
|
|
amountToBlock: blockedAmount,
|
|
availableBalance: ioData.availableBalance || ioValidation.availableBalance,
|
|
});
|
|
|
|
const blockResult = await sapIntegrationService.blockBudget(
|
|
ioData.ioNumber,
|
|
blockedAmount,
|
|
requestNumber,
|
|
`Budget block for claim request ${requestNumber}`
|
|
);
|
|
|
|
if (!blockResult.success) {
|
|
throw new Error(`Failed to block budget in SAP: ${blockResult.error}`);
|
|
}
|
|
|
|
const sapReturnedBlockedAmount = blockResult.blockedAmount;
|
|
// Extract SAP reference number from blockId (this is the Sap_Reference_no from SAP response)
|
|
// Only use the actual SAP reference number - don't use any generated fallback
|
|
const sapDocumentNumber = blockResult.blockId || undefined;
|
|
// Ensure availableBalance is rounded to 2 decimal places for accurate calculations
|
|
const availableBalance = parseFloat((ioData.availableBalance || ioValidation.availableBalance).toFixed(2));
|
|
|
|
// Log if SAP reference number was received
|
|
if (sapDocumentNumber) {
|
|
logger.info(`[DealerClaimService] ✅ SAP Reference Number received: ${sapDocumentNumber}`);
|
|
} else {
|
|
logger.warn(`[DealerClaimService] ⚠️ No SAP Reference Number received from SAP response`);
|
|
}
|
|
|
|
// Use the amount we REQUESTED for calculation, not what SAP returned
|
|
// SAP might return a slightly different amount due to rounding, but we calculate based on what we requested
|
|
// Only use SAP's returned amount if it's significantly different (more than 1 rupee), which would indicate an actual issue
|
|
const amountDifference = Math.abs(sapReturnedBlockedAmount - blockedAmount);
|
|
const useSapAmount = amountDifference > 1.0; // Only use SAP's amount if difference is more than 1 rupee
|
|
const finalBlockedAmount = useSapAmount ? sapReturnedBlockedAmount : blockedAmount;
|
|
|
|
// Log SAP response vs what we sent
|
|
logger.info(`[DealerClaimService] SAP block result:`, {
|
|
requestedAmount: blockedAmount,
|
|
sapReturnedBlockedAmount: sapReturnedBlockedAmount,
|
|
sapReturnedRemainingBalance: blockResult.remainingBalance,
|
|
sapDocumentNumber: sapDocumentNumber, // SAP reference number from response
|
|
availableBalance,
|
|
amountDifference,
|
|
usingSapAmount: useSapAmount,
|
|
finalBlockedAmountUsed: finalBlockedAmount,
|
|
});
|
|
|
|
// Warn if SAP blocked a significantly different amount than requested
|
|
if (amountDifference > 0.01) {
|
|
if (amountDifference > 1.0) {
|
|
logger.warn(`[DealerClaimService] ⚠️ Significant amount mismatch! Requested: ${blockedAmount}, SAP blocked: ${sapReturnedBlockedAmount}, Difference: ${amountDifference}`);
|
|
} else {
|
|
logger.info(`[DealerClaimService] Minor amount difference (likely rounding): Requested: ${blockedAmount}, SAP returned: ${sapReturnedBlockedAmount}, Using requested amount for calculation`);
|
|
}
|
|
}
|
|
|
|
// Calculate remaining balance: availableBalance - requestedAmount
|
|
// IMPORTANT: Use the amount we REQUESTED, not SAP's returned amount (unless SAP blocked significantly different amount)
|
|
// This ensures accuracy: remaining = available - requested
|
|
// Round to 2 decimal places to avoid floating point precision issues
|
|
const calculatedRemainingBalance = parseFloat((availableBalance - finalBlockedAmount).toFixed(2));
|
|
|
|
// Only use SAP's value if it's valid AND matches our calculation (within 1 rupee tolerance)
|
|
// This is a safety check - if SAP's value is way off, use our calculation
|
|
// Round SAP's value to 2 decimal places for consistency
|
|
const sapRemainingBalance = blockResult.remainingBalance ? parseFloat(blockResult.remainingBalance.toFixed(2)) : 0;
|
|
const sapValueIsValid = sapRemainingBalance > 0 &&
|
|
sapRemainingBalance <= availableBalance &&
|
|
Math.abs(sapRemainingBalance - calculatedRemainingBalance) < 1;
|
|
|
|
const remainingBalance = sapValueIsValid
|
|
? sapRemainingBalance
|
|
: calculatedRemainingBalance;
|
|
|
|
// Ensure remaining balance is not negative and round to 2 decimal places
|
|
const finalRemainingBalance = parseFloat(Math.max(0, remainingBalance).toFixed(2));
|
|
|
|
// Warn if SAP's value doesn't match our calculation
|
|
if (!sapValueIsValid && sapRemainingBalance !== calculatedRemainingBalance) {
|
|
logger.warn(`[DealerClaimService] ⚠️ SAP returned invalid remaining balance (${sapRemainingBalance}), using calculated value (${calculatedRemainingBalance})`);
|
|
}
|
|
|
|
logger.info(`[DealerClaimService] Budget blocking calculation:`, {
|
|
availableBalance,
|
|
blockedAmount: finalBlockedAmount,
|
|
sapRemainingBalance,
|
|
calculatedRemainingBalance,
|
|
finalRemainingBalance
|
|
});
|
|
|
|
// Get the user who is blocking the IO (current user)
|
|
const organizedBy = organizedByUserId || null;
|
|
|
|
// Round amounts to exactly 2 decimal places for database storage (avoid floating point precision issues)
|
|
// Use parseFloat with toFixed to ensure exact 2 decimal precision
|
|
const roundedAvailableBalance = parseFloat(availableBalance.toFixed(2));
|
|
const roundedBlockedAmount = parseFloat(finalBlockedAmount.toFixed(2));
|
|
const roundedRemainingBalance = parseFloat(finalRemainingBalance.toFixed(2));
|
|
|
|
// Create or update Internal Order record (only when blocking)
|
|
const ioRecordData = {
|
|
requestId,
|
|
ioNumber: ioData.ioNumber,
|
|
ioRemark: ioData.ioRemark || '', // Optional - kept for backward compatibility
|
|
ioAvailableBalance: roundedAvailableBalance,
|
|
ioBlockedAmount: roundedBlockedAmount,
|
|
ioRemainingBalance: roundedRemainingBalance,
|
|
sapDocumentNumber: sapDocumentNumber, // Store SAP reference number
|
|
organizedBy: organizedBy || undefined,
|
|
organizedAt: new Date(),
|
|
status: IOStatus.BLOCKED,
|
|
};
|
|
|
|
logger.info(`[DealerClaimService] Storing IO details in database:`, {
|
|
ioNumber: ioData.ioNumber,
|
|
ioAvailableBalance: availableBalance,
|
|
ioBlockedAmount: finalBlockedAmount,
|
|
ioRemainingBalance: finalRemainingBalance,
|
|
sapDocumentNumber: sapDocumentNumber,
|
|
requestId
|
|
});
|
|
|
|
const [internalOrder, created] = await InternalOrder.findOrCreate({
|
|
where: { requestId },
|
|
defaults: ioRecordData
|
|
});
|
|
|
|
if (!created) {
|
|
// Update existing IO record - explicitly update all fields including remainingBalance
|
|
logger.info(`[DealerClaimService] Updating existing IO record for request: ${requestId}`);
|
|
logger.info(`[DealerClaimService] Update data:`, {
|
|
ioRemainingBalance: ioRecordData.ioRemainingBalance,
|
|
ioBlockedAmount: ioRecordData.ioBlockedAmount,
|
|
ioAvailableBalance: ioRecordData.ioAvailableBalance,
|
|
sapDocumentNumber: ioRecordData.sapDocumentNumber
|
|
});
|
|
|
|
// Explicitly update all fields to ensure remainingBalance is saved
|
|
const updateResult = await internalOrder.update({
|
|
ioNumber: ioRecordData.ioNumber,
|
|
ioRemark: ioRecordData.ioRemark,
|
|
ioAvailableBalance: ioRecordData.ioAvailableBalance,
|
|
ioBlockedAmount: ioRecordData.ioBlockedAmount,
|
|
ioRemainingBalance: ioRecordData.ioRemainingBalance, // Explicitly ensure this is updated
|
|
sapDocumentNumber: ioRecordData.sapDocumentNumber, // Update SAP document number
|
|
organizedBy: ioRecordData.organizedBy,
|
|
organizedAt: ioRecordData.organizedAt,
|
|
status: ioRecordData.status
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] Update result:`, updateResult ? 'Success' : 'Failed');
|
|
} else {
|
|
logger.info(`[DealerClaimService] Created new IO record for request: ${requestId}`);
|
|
}
|
|
|
|
// Verify what was actually saved - reload from database
|
|
await internalOrder.reload();
|
|
const savedRemainingBalance = internalOrder.ioRemainingBalance;
|
|
|
|
logger.info(`[DealerClaimService] ✅ IO record after save (verified from database):`, {
|
|
ioId: internalOrder.ioId,
|
|
ioNumber: internalOrder.ioNumber,
|
|
ioAvailableBalance: internalOrder.ioAvailableBalance,
|
|
ioBlockedAmount: internalOrder.ioBlockedAmount,
|
|
ioRemainingBalance: savedRemainingBalance,
|
|
expectedRemainingBalance: finalRemainingBalance,
|
|
match: savedRemainingBalance === finalRemainingBalance || Math.abs((savedRemainingBalance || 0) - finalRemainingBalance) < 0.01,
|
|
status: internalOrder.status
|
|
});
|
|
|
|
// Warn if remaining balance doesn't match
|
|
if (Math.abs((savedRemainingBalance || 0) - finalRemainingBalance) >= 0.01) {
|
|
logger.error(`[DealerClaimService] ⚠️ WARNING: Remaining balance mismatch! Expected: ${finalRemainingBalance}, Saved: ${savedRemainingBalance}`);
|
|
}
|
|
|
|
// Save IO history after successful blocking
|
|
// Find the Department Lead IO Approval level (Step 3)
|
|
const ioApprovalLevel = await ApprovalLevel.findOne({
|
|
where: {
|
|
requestId,
|
|
levelName: 'Department Lead IO Approval'
|
|
}
|
|
});
|
|
|
|
// Fallback: try to find by levelNumber 3
|
|
const ioLevel = ioApprovalLevel || await ApprovalLevel.findOne({
|
|
where: { requestId, levelNumber: 3 }
|
|
});
|
|
|
|
// Get user ID for history - use organizedBy if it's a UUID, otherwise try to find user
|
|
let ioHistoryUserId: string | null = null;
|
|
if (ioLevel) {
|
|
if (organizedBy) {
|
|
// Check if organizedBy is a valid UUID
|
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
if (uuidRegex.test(organizedBy)) {
|
|
ioHistoryUserId = organizedBy;
|
|
} else {
|
|
// Try to find user by email or name
|
|
const user = await User.findOne({
|
|
where: { email: organizedBy }
|
|
});
|
|
ioHistoryUserId = user?.userId || null;
|
|
}
|
|
}
|
|
|
|
// Fallback to initiator if no user found
|
|
if (!ioHistoryUserId) {
|
|
const request = await WorkflowRequest.findByPk(requestId);
|
|
ioHistoryUserId = (request as any)?.initiatorId || null;
|
|
}
|
|
}
|
|
|
|
// Update budget tracking with blocked amount FIRST
|
|
await ClaimBudgetTracking.upsert({
|
|
requestId,
|
|
ioBlockedAmount: finalBlockedAmount,
|
|
ioBlockedAt: new Date(),
|
|
budgetStatus: BudgetStatus.BLOCKED,
|
|
currency: 'INR',
|
|
});
|
|
|
|
// Save IO history AFTER budget tracking update succeeds (only if ioLevel exists)
|
|
if (ioLevel && ioHistoryUserId) {
|
|
try {
|
|
await this.saveIOHistory(
|
|
requestId,
|
|
ioLevel.levelId,
|
|
ioLevel.levelNumber,
|
|
`IO Blocked: ₹${finalBlockedAmount.toFixed(2)} blocked in SAP`,
|
|
ioHistoryUserId
|
|
);
|
|
} catch (snapshotError) {
|
|
// Log error but don't fail the IO blocking - snapshot is for audit, not critical
|
|
logger.error(`[DealerClaimService] Failed to save IO history snapshot (non-critical):`, snapshotError);
|
|
}
|
|
} else if (ioLevel && !ioHistoryUserId) {
|
|
logger.warn(`[DealerClaimService] No user ID available for IO history, skipping history save`);
|
|
}
|
|
|
|
logger.info(`[DealerClaimService] IO blocked for request: ${requestId}`, {
|
|
ioNumber: ioData.ioNumber,
|
|
blockedAmount: finalBlockedAmount,
|
|
availableBalance,
|
|
remainingBalance: finalRemainingBalance
|
|
});
|
|
} catch (error) {
|
|
logger.error('[DealerClaimService] Error blocking IO:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update e-invoice details (Step 7)
|
|
* Generates e-invoice via DMS integration
|
|
*/
|
|
async updateEInvoiceDetails(
|
|
requestId: string,
|
|
invoiceData?: {
|
|
eInvoiceNumber?: string;
|
|
eInvoiceDate?: Date;
|
|
dmsNumber?: string;
|
|
amount?: number;
|
|
description?: string;
|
|
}
|
|
): Promise<void> {
|
|
try {
|
|
// Check if already generated to prevent duplicate pushes or recursion
|
|
const existingInvoice = await ClaimInvoice.findOne({ where: { requestId } });
|
|
if (existingInvoice && !invoiceData?.eInvoiceNumber && (existingInvoice.status === 'GENERATED' || existingInvoice.status === 'COMPLETED')) {
|
|
logger.info(`[DealerClaimService] E-Invoice already generated for request ${requestId}, skipping duplicate push.`);
|
|
return;
|
|
}
|
|
|
|
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
|
|
if (!claimDetails) {
|
|
throw new Error('Claim details not found');
|
|
}
|
|
|
|
const budgetTracking = await ClaimBudgetTracking.findOne({ where: { requestId } });
|
|
const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId } });
|
|
const internalOrder = await InternalOrder.findOne({ where: { requestId } });
|
|
const claimInvoice = await ClaimInvoice.findOne({ where: { requestId } });
|
|
|
|
const request = await WorkflowRequest.findByPk(requestId);
|
|
if (!request) {
|
|
throw new Error('Workflow request not found');
|
|
}
|
|
|
|
const workflowType = (request as any).workflowType;
|
|
if (workflowType !== 'CLAIM_MANAGEMENT') {
|
|
throw new Error('This endpoint is only for claim management workflows');
|
|
}
|
|
|
|
const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN';
|
|
|
|
// If invoice data not provided, generate via DMS
|
|
if (!invoiceData?.eInvoiceNumber) {
|
|
const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } });
|
|
const invoiceAmount = invoiceData?.amount
|
|
|| proposalDetails?.totalEstimatedBudget
|
|
|| budgetTracking?.proposalEstimatedBudget
|
|
|| budgetTracking?.initialEstimatedBudget
|
|
|| 0;
|
|
|
|
const invoiceResult = await dmsIntegrationService.generateEInvoice({
|
|
requestNumber,
|
|
dealerCode: claimDetails.dealerCode,
|
|
dealerName: claimDetails.dealerName,
|
|
amount: invoiceAmount,
|
|
description: invoiceData?.description || `E-Invoice for claim request ${requestNumber}`,
|
|
ioNumber: internalOrder?.ioNumber || undefined,
|
|
});
|
|
|
|
if (!invoiceResult.success) {
|
|
throw new Error(`Failed to generate e-invoice: ${invoiceResult.error}`);
|
|
}
|
|
|
|
await ClaimInvoice.upsert({
|
|
requestId,
|
|
invoiceNumber: invoiceResult.eInvoiceNumber,
|
|
invoiceDate: invoiceResult.invoiceDate || new Date(),
|
|
dmsNumber: invoiceResult.dmsNumber,
|
|
amount: invoiceAmount,
|
|
status: 'GENERATED',
|
|
generatedAt: new Date(),
|
|
description: invoiceData?.description || `E-Invoice for claim request ${requestNumber}`,
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] E-Invoice generated via DMS for request: ${requestId}`, {
|
|
eInvoiceNumber: invoiceResult.eInvoiceNumber,
|
|
dmsNumber: invoiceResult.dmsNumber
|
|
});
|
|
} else {
|
|
// Manual entry - just update the fields
|
|
await ClaimInvoice.upsert({
|
|
requestId,
|
|
invoiceNumber: invoiceData.eInvoiceNumber,
|
|
invoiceDate: invoiceData.eInvoiceDate || new Date(),
|
|
dmsNumber: invoiceData.dmsNumber,
|
|
amount: invoiceData.amount,
|
|
status: 'UPDATED',
|
|
generatedAt: new Date(),
|
|
description: invoiceData.description,
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] E-Invoice details manually updated for request: ${requestId}`);
|
|
}
|
|
|
|
// Check if Requestor Claim Approval is approved - if not, approve it first
|
|
// Find dynamically by levelName (handles step shifts due to additional approvers)
|
|
const approvalLevels = await ApprovalLevel.findAll({
|
|
where: { requestId },
|
|
order: [['levelNumber', 'ASC']]
|
|
});
|
|
|
|
let requestorClaimLevel = approvalLevels.find((level: any) => {
|
|
const levelName = (level.levelName || '').toLowerCase();
|
|
return levelName.includes('requestor') &&
|
|
(levelName.includes('claim') || levelName.includes('approval'));
|
|
});
|
|
|
|
// Fallback: try to find by levelNumber 5 (new position after removing system steps)
|
|
// But only if no match found by name (handles edge cases)
|
|
if (!requestorClaimLevel) {
|
|
requestorClaimLevel = approvalLevels.find((level: any) => level.levelNumber === 5);
|
|
}
|
|
|
|
// Validate that we're at the Requestor Claim Approval step before allowing DMS push
|
|
if (requestorClaimLevel && request.currentLevel !== requestorClaimLevel.levelNumber) {
|
|
throw new Error(`Cannot push to DMS. Request is currently at step ${request.currentLevel}, but Requestor Claim Approval is at step ${requestorClaimLevel.levelNumber}. Please complete all previous steps first.`);
|
|
}
|
|
|
|
if (requestorClaimLevel && requestorClaimLevel.status !== ApprovalStatus.APPROVED) {
|
|
logger.info(`[DealerClaimService] Requestor Claim Approval not approved yet. Auto-approving for request ${requestId}`);
|
|
// Auto-approve Requestor Claim Approval
|
|
await this.approvalService.approveLevel(
|
|
requestorClaimLevel.levelId,
|
|
{ action: 'APPROVE', comments: 'Auto-approved when pushing to DMS. E-Invoice generation will be logged as activity.' },
|
|
'system',
|
|
{ ipAddress: null, userAgent: 'System Auto-Process' }
|
|
);
|
|
logger.info(`[DealerClaimService] Requestor Claim Approval approved. E-Invoice generation will be logged as activity when DMS webhook is received.`);
|
|
} else {
|
|
// Requestor Claim Approval already approved
|
|
logger.info(`[DealerClaimService] Requestor Claim Approval already approved. E-Invoice generation will be logged as activity when DMS webhook is received.`);
|
|
}
|
|
|
|
// Log E-Invoice generation as activity (no approval level needed)
|
|
await activityService.log({
|
|
requestId,
|
|
type: 'status_change',
|
|
user: { userId: 'system', name: 'System Auto-Process' },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'E-Invoice Generation Initiated',
|
|
details: `E-Invoice generation initiated via DMS integration for request ${requestNumber}. Waiting for DMS webhook confirmation.`,
|
|
});
|
|
} catch (error) {
|
|
logger.error('[DealerClaimService] Error updating e-invoice details:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log E-Invoice Generation as activity (no longer an approval step)
|
|
* This method logs the e-invoice generation activity when invoice is generated via DMS webhook
|
|
*/
|
|
async logEInvoiceGenerationActivity(requestId: string, invoiceNumber?: string): Promise<void> {
|
|
try {
|
|
logger.info(`[DealerClaimService] Logging E-Invoice Generation activity for request ${requestId}`);
|
|
|
|
const request = await WorkflowRequest.findByPk(requestId);
|
|
if (!request) {
|
|
throw new Error(`Workflow request ${requestId} not found`);
|
|
}
|
|
|
|
const workflowType = (request as any).workflowType;
|
|
if (workflowType !== 'CLAIM_MANAGEMENT') {
|
|
logger.warn(`[DealerClaimService] Skipping E-Invoice activity logging - not a claim management workflow (type: ${workflowType})`);
|
|
return;
|
|
}
|
|
|
|
const requestNumber = (request as any).requestNumber || (request as any).request_number || 'UNKNOWN';
|
|
const claimInvoice = await ClaimInvoice.findOne({ where: { requestId } });
|
|
const finalInvoiceNumber = invoiceNumber || claimInvoice?.invoiceNumber || 'N/A';
|
|
|
|
// Log E-Invoice Generation as activity
|
|
await activityService.log({
|
|
requestId,
|
|
type: 'status_change',
|
|
user: { userId: 'system', name: 'System Auto-Process' },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'E-Invoice Generated',
|
|
details: `E-Invoice generated via DMS. Invoice Number: ${finalInvoiceNumber}. Request: ${requestNumber}`,
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] E-Invoice Generation activity logged for request ${requestId} (Invoice: ${finalInvoiceNumber})`);
|
|
} catch (error) {
|
|
logger.error(`[DealerClaimService] Error logging E-Invoice Generation activity for request ${requestId}:`, error);
|
|
// Don't throw - activity logging is not critical
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update credit note details (Step 8)
|
|
* Generates credit note via DMS integration
|
|
*/
|
|
async updateCreditNoteDetails(
|
|
requestId: string,
|
|
creditNoteData?: {
|
|
creditNoteNumber?: string;
|
|
creditNoteDate?: Date;
|
|
creditNoteAmount?: number;
|
|
reason?: string;
|
|
description?: string;
|
|
}
|
|
): Promise<void> {
|
|
try {
|
|
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
|
|
if (!claimDetails) {
|
|
throw new Error('Claim details not found');
|
|
}
|
|
|
|
const budgetTracking = await ClaimBudgetTracking.findOne({ where: { requestId } });
|
|
const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId } });
|
|
const claimInvoice = await ClaimInvoice.findOne({ where: { requestId } });
|
|
|
|
const request = await WorkflowRequest.findByPk(requestId);
|
|
const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN';
|
|
|
|
// If credit note data not provided, generate via DMS
|
|
if (!creditNoteData?.creditNoteNumber) {
|
|
const creditNoteAmount = creditNoteData?.creditNoteAmount
|
|
|| budgetTracking?.closedExpenses
|
|
|| completionDetails?.totalClosedExpenses
|
|
|| 0;
|
|
|
|
// Only generate via DMS if invoice exists, otherwise allow manual entry
|
|
if (claimInvoice?.invoiceNumber) {
|
|
const creditNoteResult = await dmsIntegrationService.generateCreditNote({
|
|
requestNumber,
|
|
eInvoiceNumber: claimInvoice.invoiceNumber,
|
|
dealerCode: claimDetails.dealerCode,
|
|
dealerName: claimDetails.dealerName,
|
|
amount: creditNoteAmount,
|
|
reason: creditNoteData?.reason || 'Claim settlement',
|
|
description: creditNoteData?.description || `Credit note for claim request ${requestNumber}`,
|
|
});
|
|
|
|
if (!creditNoteResult.success) {
|
|
throw new Error(`Failed to generate credit note: ${creditNoteResult.error}`);
|
|
}
|
|
|
|
await ClaimCreditNote.upsert({
|
|
requestId,
|
|
invoiceId: claimInvoice.invoiceId,
|
|
creditNoteNumber: creditNoteResult.creditNoteNumber,
|
|
creditNoteDate: creditNoteResult.creditNoteDate || new Date(),
|
|
creditNoteAmount: creditNoteResult.creditNoteAmount,
|
|
status: 'GENERATED',
|
|
confirmedAt: new Date(),
|
|
reason: creditNoteData?.reason || 'Claim settlement',
|
|
description: creditNoteData?.description || `Credit note for claim request ${requestNumber}`,
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] Credit note generated via DMS for request: ${requestId}`, {
|
|
creditNoteNumber: creditNoteResult.creditNoteNumber,
|
|
creditNoteAmount: creditNoteResult.creditNoteAmount
|
|
});
|
|
} else {
|
|
// No invoice exists - create credit note manually without invoice link
|
|
await ClaimCreditNote.upsert({
|
|
requestId,
|
|
invoiceId: undefined, // No invoice linked
|
|
creditNoteNumber: undefined, // Will be set manually later
|
|
creditNoteDate: creditNoteData?.creditNoteDate || new Date(),
|
|
creditNoteAmount: creditNoteAmount,
|
|
status: 'PENDING',
|
|
reason: creditNoteData?.reason || 'Claim settlement',
|
|
description: creditNoteData?.description || `Credit note for claim request ${requestNumber} (no invoice)`,
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] Credit note created without invoice for request: ${requestId}`);
|
|
}
|
|
} else {
|
|
// Manual entry - just update the fields
|
|
await ClaimCreditNote.upsert({
|
|
requestId,
|
|
invoiceId: claimInvoice?.invoiceId || undefined, // Allow undefined if no invoice
|
|
creditNoteNumber: creditNoteData.creditNoteNumber,
|
|
creditNoteDate: creditNoteData.creditNoteDate || new Date(),
|
|
creditNoteAmount: creditNoteData.creditNoteAmount,
|
|
status: 'UPDATED',
|
|
confirmedAt: new Date(),
|
|
reason: creditNoteData?.reason,
|
|
description: creditNoteData?.description,
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] Credit note details manually updated for request: ${requestId}`);
|
|
}
|
|
} catch (error) {
|
|
logger.error('[DealerClaimService] Error updating credit note details:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send credit note to dealer and auto-approve Step 8
|
|
* This method sends the credit note to the dealer via email/notification and auto-approves Step 8
|
|
*/
|
|
async sendCreditNoteToDealer(requestId: string, userId: string): Promise<void> {
|
|
try {
|
|
logger.info(`[DealerClaimService] Sending credit note to dealer for request ${requestId}`);
|
|
|
|
// Get credit note details
|
|
const creditNote = await ClaimCreditNote.findOne({
|
|
where: { requestId }
|
|
});
|
|
|
|
if (!creditNote) {
|
|
throw new Error('Credit note not found. Please ensure credit note is generated before sending to dealer.');
|
|
}
|
|
|
|
// Get claim details for dealer information
|
|
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
|
|
if (!claimDetails) {
|
|
throw new Error('Claim details not found');
|
|
}
|
|
|
|
// Get workflow request
|
|
const request = await WorkflowRequest.findByPk(requestId);
|
|
if (!request) {
|
|
throw new Error('Workflow request not found');
|
|
}
|
|
|
|
const workflowType = (request as any).workflowType;
|
|
if (workflowType !== 'CLAIM_MANAGEMENT') {
|
|
throw new Error('This operation is only available for claim management workflows');
|
|
}
|
|
|
|
// Credit Note Confirmation is now an activity log only, not an approval step
|
|
const requestNumber = (request as any).requestNumber || (request as any).request_number || 'UNKNOWN';
|
|
|
|
// Update credit note status to CONFIRMED
|
|
await creditNote.update({
|
|
status: 'CONFIRMED',
|
|
confirmedAt: new Date(),
|
|
confirmedBy: userId,
|
|
});
|
|
|
|
// Log Credit Note Confirmation as activity (no approval step needed)
|
|
await activityService.log({
|
|
requestId,
|
|
type: 'status_change',
|
|
user: { userId: userId, name: 'Finance Team' },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Credit Note Confirmed and Sent',
|
|
details: `Credit note sent to dealer. Credit Note Number: ${creditNote.creditNoteNumber || 'N/A'}. Credit Note Amount: ₹${creditNote.creditNoteAmount || 0}. Request: ${requestNumber}`,
|
|
});
|
|
|
|
// Send notification to dealer (you can implement email service here)
|
|
logger.info(`[DealerClaimService] Credit note sent to dealer`, {
|
|
requestId,
|
|
creditNoteNumber: creditNote.creditNoteNumber,
|
|
dealerEmail: claimDetails.dealerEmail,
|
|
dealerName: claimDetails.dealerName,
|
|
});
|
|
|
|
// TODO: Implement email service to send credit note to dealer
|
|
// await emailService.sendCreditNoteToDealer({
|
|
// dealerEmail: claimDetails.dealerEmail,
|
|
// dealerName: claimDetails.dealerName,
|
|
// creditNoteNumber: creditNote.creditNoteNumber,
|
|
// creditNoteAmount: creditNote.creditNoteAmount,
|
|
// requestNumber: requestNumber,
|
|
// });
|
|
|
|
} catch (error) {
|
|
logger.error('[DealerClaimService] Error sending credit note to dealer:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process Activity Creation (now activity log only, not an approval step)
|
|
* Creates activity confirmation and sends emails to dealer, requestor, and department lead
|
|
* Logs activity instead of creating/approving approval level
|
|
*/
|
|
async processActivityCreation(requestId: string): Promise<void> {
|
|
try {
|
|
logger.info(`[DealerClaimService] Processing Activity Creation for request ${requestId}`);
|
|
|
|
// Get workflow request
|
|
const request = await WorkflowRequest.findByPk(requestId);
|
|
if (!request) {
|
|
throw new Error(`Workflow request ${requestId} not found`);
|
|
}
|
|
|
|
// Verify this is a claim management workflow
|
|
const workflowType = (request as any).workflowType;
|
|
if (workflowType !== 'CLAIM_MANAGEMENT') {
|
|
logger.warn(`[DealerClaimService] Skipping Activity Creation - not a claim management workflow (type: ${workflowType})`);
|
|
return;
|
|
}
|
|
|
|
// Get claim details
|
|
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
|
|
if (!claimDetails) {
|
|
throw new Error(`Claim details not found for request ${requestId}`);
|
|
}
|
|
|
|
// Get participants for email notifications
|
|
const initiator = await User.findByPk((request as any).initiatorId);
|
|
const dealerUser = claimDetails.dealerEmail
|
|
? await User.findOne({ where: { email: claimDetails.dealerEmail } })
|
|
: null;
|
|
|
|
// Get department lead dynamically (by levelName, not hardcoded step number)
|
|
let deptLeadLevel = await ApprovalLevel.findOne({
|
|
where: {
|
|
requestId,
|
|
levelName: 'Department Lead Approval'
|
|
}
|
|
});
|
|
|
|
// Fallback: try to find by levelNumber 3 (for backwards compatibility)
|
|
if (!deptLeadLevel) {
|
|
deptLeadLevel = await ApprovalLevel.findOne({
|
|
where: {
|
|
requestId,
|
|
levelNumber: 3
|
|
}
|
|
});
|
|
}
|
|
const departmentLead = deptLeadLevel?.approverId
|
|
? await User.findByPk(deptLeadLevel.approverId)
|
|
: null;
|
|
|
|
const requestNumber = (request as any).requestNumber || (request as any).request_number || 'UNKNOWN';
|
|
const activityName = claimDetails.activityName || 'Activity';
|
|
const activityType = claimDetails.activityType || 'N/A';
|
|
|
|
// Prepare email recipients
|
|
const emailRecipients: string[] = [];
|
|
const userIdsForNotification: string[] = [];
|
|
|
|
// Add initiator
|
|
if (initiator) {
|
|
emailRecipients.push(initiator.email);
|
|
userIdsForNotification.push(initiator.userId);
|
|
}
|
|
|
|
// Add dealer
|
|
if (dealerUser) {
|
|
emailRecipients.push(dealerUser.email);
|
|
userIdsForNotification.push(dealerUser.userId);
|
|
} else if (claimDetails.dealerEmail) {
|
|
emailRecipients.push(claimDetails.dealerEmail);
|
|
}
|
|
|
|
// Add department lead
|
|
if (departmentLead) {
|
|
emailRecipients.push(departmentLead.email);
|
|
userIdsForNotification.push(departmentLead.userId);
|
|
}
|
|
|
|
// Send activity confirmation emails
|
|
const emailSubject = `Activity Created: ${activityName} - ${requestNumber}`;
|
|
const emailBody = `Activity "${activityName}" (${activityType}) has been created successfully for request ${requestNumber}. IO confirmation to be made.`;
|
|
|
|
// Send notifications to users in the system with proper metadata
|
|
if (userIdsForNotification.length > 0) {
|
|
// Prepare metadata for activity created email template
|
|
const activityData = {
|
|
activityName: activityName,
|
|
activityType: activityType,
|
|
activityDate: claimDetails.activityDate,
|
|
location: claimDetails.location || 'Not specified',
|
|
dealerName: claimDetails.dealerName || 'Dealer',
|
|
dealerCode: claimDetails.dealerCode,
|
|
initiatorName: initiator ? (initiator.displayName || initiator.email) : 'Initiator',
|
|
departmentLeadName: departmentLead ? (departmentLead.displayName || departmentLead.email) : undefined,
|
|
ioNumber: undefined, // IO number will be added later when IO is created
|
|
nextSteps: 'IO confirmation to be made. Dealer will proceed with activity execution and submit completion documents.'
|
|
};
|
|
|
|
await notificationService.sendToUsers(userIdsForNotification, {
|
|
title: emailSubject,
|
|
body: emailBody,
|
|
requestId,
|
|
requestNumber,
|
|
url: `/request/${requestNumber}`,
|
|
type: 'activity_created',
|
|
priority: 'MEDIUM',
|
|
actionRequired: false,
|
|
metadata: {
|
|
activityData: activityData
|
|
}
|
|
});
|
|
}
|
|
|
|
// Log Activity Creation as activity (no approval level needed)
|
|
await activityService.log({
|
|
requestId,
|
|
type: 'status_change',
|
|
user: { userId: 'system', name: 'System Auto-Process' },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Activity Created',
|
|
details: `Activity "${activityName}" created. Activity confirmation email auto-triggered to dealer, requestor, and department lead. IO confirmation to be made.`,
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] Activity Creation logged as activity for request ${requestId}. Activity creation completed.`);
|
|
} catch (error) {
|
|
logger.error(`[DealerClaimService] Error processing Step 4 activity creation for request ${requestId}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Snapshot current claim state for version history before revisions
|
|
*/
|
|
/**
|
|
* Save proposal version history (Step 1)
|
|
*/
|
|
async saveProposalHistory(
|
|
requestId: string,
|
|
approvalLevelId: string,
|
|
levelNumber: number,
|
|
changeReason: string,
|
|
userId: string
|
|
): Promise<void> {
|
|
try {
|
|
const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } });
|
|
if (!proposalDetails) {
|
|
logger.warn(`[DealerClaimService] No proposal found for request ${requestId}, skipping history`);
|
|
return;
|
|
}
|
|
|
|
const costItems = await DealerProposalCostItem.findAll({
|
|
where: { proposalId: (proposalDetails as any).proposalId || (proposalDetails as any).proposal_id }
|
|
});
|
|
|
|
// Get level name from approval level
|
|
const level = await ApprovalLevel.findByPk(approvalLevelId);
|
|
const levelName = level?.levelName || undefined;
|
|
|
|
// Get next version for this level (match by levelName for consistency)
|
|
const lastVersion = await DealerClaimHistory.findOne({
|
|
where: levelName ? {
|
|
requestId,
|
|
levelName,
|
|
snapshotType: SnapshotType.PROPOSAL
|
|
} : {
|
|
requestId,
|
|
levelNumber,
|
|
snapshotType: SnapshotType.PROPOSAL
|
|
},
|
|
order: [['version', 'DESC']]
|
|
});
|
|
const nextVersion = lastVersion ? lastVersion.version + 1 : 1;
|
|
|
|
// Store all proposal data in JSONB
|
|
// Handle expectedCompletionDate - it might be a Date object, string, or null
|
|
let expectedCompletionDateStr = null;
|
|
if (proposalDetails.expectedCompletionDate) {
|
|
if (proposalDetails.expectedCompletionDate instanceof Date) {
|
|
expectedCompletionDateStr = proposalDetails.expectedCompletionDate.toISOString();
|
|
} else if (typeof proposalDetails.expectedCompletionDate === 'string') {
|
|
expectedCompletionDateStr = proposalDetails.expectedCompletionDate;
|
|
}
|
|
}
|
|
|
|
// Fetch supporting documents
|
|
const supportingDocs = await Document.findAll({
|
|
where: {
|
|
requestId,
|
|
category: 'SUPPORTING',
|
|
isDeleted: false
|
|
},
|
|
order: [['createdAt', 'DESC']]
|
|
});
|
|
|
|
const snapshotData = {
|
|
documentUrl: proposalDetails.proposalDocumentUrl,
|
|
totalBudget: Number(proposalDetails.totalEstimatedBudget || 0),
|
|
comments: proposalDetails.dealerComments,
|
|
expectedCompletionDate: expectedCompletionDateStr,
|
|
costItems: costItems.map(i => ({
|
|
description: i.itemDescription,
|
|
amount: Number(i.amount || 0),
|
|
order: i.itemOrder
|
|
})),
|
|
otherDocuments: supportingDocs.map(doc => ({
|
|
documentId: doc.documentId,
|
|
fileName: doc.fileName,
|
|
originalFileName: doc.originalFileName,
|
|
storageUrl: doc.storageUrl,
|
|
uploadedAt: doc.uploadedAt
|
|
}))
|
|
};
|
|
|
|
await DealerClaimHistory.create({
|
|
requestId,
|
|
approvalLevelId,
|
|
levelNumber,
|
|
levelName,
|
|
version: nextVersion,
|
|
snapshotType: SnapshotType.PROPOSAL,
|
|
snapshotData,
|
|
changeReason,
|
|
changedBy: userId
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] Saved proposal history (v${nextVersion}) for level ${levelNumber}, request ${requestId}`);
|
|
} catch (error) {
|
|
logger.error(`[DealerClaimService] Error saving proposal history for request ${requestId}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save completion version history (Step 4/5)
|
|
*/
|
|
async saveCompletionHistory(
|
|
requestId: string,
|
|
approvalLevelId: string,
|
|
levelNumber: number,
|
|
changeReason: string,
|
|
userId: string
|
|
): Promise<void> {
|
|
try {
|
|
const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId } });
|
|
if (!completionDetails) {
|
|
logger.warn(`[DealerClaimService] No completion found for request ${requestId}, skipping history`);
|
|
return;
|
|
}
|
|
|
|
const expenses = await DealerCompletionExpense.findAll({ where: { requestId } });
|
|
|
|
// Get level name from approval level
|
|
const level = await ApprovalLevel.findByPk(approvalLevelId);
|
|
const levelName = level?.levelName || undefined;
|
|
|
|
// Get next version for this level (match by levelName for consistency)
|
|
const lastVersion = await DealerClaimHistory.findOne({
|
|
where: levelName ? {
|
|
requestId,
|
|
levelName,
|
|
snapshotType: SnapshotType.COMPLETION
|
|
} : {
|
|
requestId,
|
|
levelNumber,
|
|
snapshotType: SnapshotType.COMPLETION
|
|
},
|
|
order: [['version', 'DESC']]
|
|
});
|
|
const nextVersion = lastVersion ? lastVersion.version + 1 : 1;
|
|
|
|
// Fetch supporting documents for completion
|
|
const supportingDocs = await Document.findAll({
|
|
where: {
|
|
requestId,
|
|
category: 'SUPPORTING',
|
|
isDeleted: false
|
|
},
|
|
order: [['createdAt', 'DESC']]
|
|
});
|
|
|
|
// Store all completion data in JSONB
|
|
const snapshotData = {
|
|
documentUrl: (completionDetails as any).completionDocumentUrl || null,
|
|
totalExpenses: Number(completionDetails.totalClosedExpenses || 0),
|
|
comments: (completionDetails as any).completionDescription || null,
|
|
expenses: expenses.map(e => ({
|
|
description: e.description,
|
|
amount: Number(e.amount || 0)
|
|
})),
|
|
otherDocuments: supportingDocs.map(doc => ({
|
|
documentId: doc.documentId,
|
|
fileName: doc.fileName,
|
|
originalFileName: doc.originalFileName,
|
|
storageUrl: doc.storageUrl,
|
|
uploadedAt: doc.uploadedAt
|
|
}))
|
|
};
|
|
|
|
await DealerClaimHistory.create({
|
|
requestId,
|
|
approvalLevelId,
|
|
levelNumber,
|
|
levelName,
|
|
version: nextVersion,
|
|
snapshotType: SnapshotType.COMPLETION,
|
|
snapshotData,
|
|
changeReason,
|
|
changedBy: userId
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] Saved completion history (v${nextVersion}) for level ${levelNumber}, request ${requestId}`);
|
|
} catch (error) {
|
|
logger.error(`[DealerClaimService] Error saving completion history for request ${requestId}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save internal order version history
|
|
*/
|
|
async saveIOHistory(
|
|
requestId: string,
|
|
approvalLevelId: string,
|
|
levelNumber: number,
|
|
changeReason: string,
|
|
userId: string
|
|
): Promise<void> {
|
|
try {
|
|
const internalOrder = await InternalOrder.findOne({ where: { requestId } });
|
|
if (!internalOrder || !internalOrder.ioBlockedAmount || internalOrder.ioBlockedAmount <= 0) {
|
|
logger.warn(`[DealerClaimService] No IO block found for request ${requestId}, skipping history`);
|
|
return;
|
|
}
|
|
|
|
// Get level name from approval level
|
|
const level = await ApprovalLevel.findByPk(approvalLevelId);
|
|
const levelName = level?.levelName || undefined;
|
|
|
|
// Get next version for this level (match by levelName for consistency)
|
|
const lastVersion = await DealerClaimHistory.findOne({
|
|
where: levelName ? {
|
|
requestId,
|
|
levelName,
|
|
snapshotType: SnapshotType.INTERNAL_ORDER
|
|
} : {
|
|
requestId,
|
|
levelNumber,
|
|
snapshotType: SnapshotType.INTERNAL_ORDER
|
|
},
|
|
order: [['version', 'DESC']]
|
|
});
|
|
const nextVersion = lastVersion ? lastVersion.version + 1 : 1;
|
|
|
|
// Store all IO data in JSONB
|
|
const snapshotData = {
|
|
ioNumber: internalOrder.ioNumber,
|
|
blockedAmount: Number(internalOrder.ioBlockedAmount || 0),
|
|
availableBalance: Number(internalOrder.ioAvailableBalance || 0),
|
|
remainingBalance: Number(internalOrder.ioRemainingBalance || 0),
|
|
sapDocumentNumber: internalOrder.sapDocumentNumber
|
|
};
|
|
|
|
await DealerClaimHistory.create({
|
|
requestId,
|
|
approvalLevelId,
|
|
levelNumber,
|
|
levelName,
|
|
version: nextVersion,
|
|
snapshotType: SnapshotType.INTERNAL_ORDER,
|
|
snapshotData,
|
|
changeReason,
|
|
changedBy: userId
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] Saved IO history (v${nextVersion}) for level ${levelNumber}, request ${requestId}`);
|
|
} catch (error) {
|
|
logger.error(`[DealerClaimService] Error saving IO history for request ${requestId}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save approval version history (for approver actions)
|
|
*/
|
|
async saveApprovalHistory(
|
|
requestId: string,
|
|
approvalLevelId: string,
|
|
levelNumber: number,
|
|
action: 'APPROVE' | 'REJECT',
|
|
comments: string,
|
|
rejectionReason: string | undefined,
|
|
userId: string
|
|
): Promise<void> {
|
|
try {
|
|
const level = await ApprovalLevel.findByPk(approvalLevelId);
|
|
if (!level) {
|
|
logger.warn(`[DealerClaimService] No approval level found for ${approvalLevelId}, skipping history`);
|
|
return;
|
|
}
|
|
|
|
// Get next version for this level (match by levelName for consistency)
|
|
const lastVersion = await DealerClaimHistory.findOne({
|
|
where: level.levelName ? {
|
|
requestId,
|
|
levelName: level.levelName,
|
|
snapshotType: SnapshotType.APPROVE
|
|
} : {
|
|
requestId,
|
|
levelNumber,
|
|
snapshotType: SnapshotType.APPROVE
|
|
},
|
|
order: [['version', 'DESC']]
|
|
});
|
|
const nextVersion = lastVersion ? lastVersion.version + 1 : 1;
|
|
|
|
// Store approval data in JSONB
|
|
const snapshotData = {
|
|
action,
|
|
comments: comments || undefined,
|
|
rejectionReason: rejectionReason || undefined,
|
|
approverName: level.approverName,
|
|
approverEmail: level.approverEmail,
|
|
levelName: level.levelName
|
|
};
|
|
|
|
// Build changeReason - will be updated later if moving to next level
|
|
// For now, just include the basic approval/rejection info
|
|
const changeReason = action === 'APPROVE'
|
|
? `Approved by ${level.approverName || level.approverEmail}`
|
|
: `Rejected by ${level.approverName || level.approverEmail}`;
|
|
|
|
await DealerClaimHistory.create({
|
|
requestId,
|
|
approvalLevelId,
|
|
levelNumber,
|
|
levelName: level.levelName || undefined,
|
|
version: nextVersion,
|
|
snapshotType: SnapshotType.APPROVE,
|
|
snapshotData,
|
|
changeReason,
|
|
changedBy: userId
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] Saved approval history (v${nextVersion}) for level ${levelNumber}, request ${requestId}`);
|
|
} catch (error) {
|
|
logger.error(`[DealerClaimService] Error saving approval history for request ${requestId}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save workflow-level version history (for actions that move workflow forward/backward)
|
|
*/
|
|
async saveWorkflowHistory(
|
|
requestId: string,
|
|
changeReason: string,
|
|
userId: string,
|
|
approvalLevelId?: string,
|
|
levelNumber?: number,
|
|
levelName?: string,
|
|
approvalComment?: string
|
|
): Promise<void> {
|
|
try {
|
|
const wf = await WorkflowRequest.findByPk(requestId);
|
|
if (!wf) return;
|
|
|
|
// Get next version for workflow-level snapshots PER LEVEL
|
|
// Each level should have its own version numbering starting from 1
|
|
// Filter by levelName or levelNumber to get versions for this specific level
|
|
const lastVersion = await DealerClaimHistory.findOne({
|
|
where: levelName ? {
|
|
requestId,
|
|
levelName,
|
|
snapshotType: SnapshotType.WORKFLOW
|
|
} : levelNumber !== undefined ? {
|
|
requestId,
|
|
levelNumber,
|
|
snapshotType: SnapshotType.WORKFLOW
|
|
} : {
|
|
requestId,
|
|
snapshotType: SnapshotType.WORKFLOW
|
|
},
|
|
order: [['version', 'DESC']]
|
|
});
|
|
const nextVersion = lastVersion ? lastVersion.version + 1 : 1;
|
|
|
|
// Store workflow data in JSONB
|
|
// Include level information for version tracking and comparison
|
|
// Include approval comment if provided (for approval actions)
|
|
const snapshotData: any = {
|
|
status: wf.status,
|
|
currentLevel: wf.currentLevel,
|
|
// Include level info in snapshotData for completeness and version tracking
|
|
approvalLevelId: approvalLevelId || undefined,
|
|
levelNumber: levelNumber || undefined,
|
|
levelName: levelName || undefined
|
|
};
|
|
|
|
// Add approval comment to snapshotData if provided
|
|
if (approvalComment) {
|
|
snapshotData.comments = approvalComment;
|
|
}
|
|
|
|
await DealerClaimHistory.create({
|
|
requestId,
|
|
approvalLevelId: approvalLevelId || undefined,
|
|
levelNumber: levelNumber || undefined,
|
|
levelName: levelName || undefined,
|
|
version: nextVersion,
|
|
snapshotType: SnapshotType.WORKFLOW,
|
|
snapshotData,
|
|
changeReason,
|
|
changedBy: userId
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] Saved workflow history (v${nextVersion}) for request ${requestId}, level ${levelNumber || 'N/A'}`);
|
|
} catch (error) {
|
|
logger.error(`[DealerClaimService] Error saving workflow history for request ${requestId}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create or activate initiator action level when request is rejected
|
|
* This allows initiator to take action (REVISE, CANCEL, REOPEN) directly from the step card
|
|
*/
|
|
async createOrActivateInitiatorLevel(
|
|
requestId: string,
|
|
userId: string
|
|
): Promise<ApprovalLevel | null> {
|
|
try {
|
|
const wf = await WorkflowRequest.findByPk(requestId);
|
|
if (!wf) return null;
|
|
|
|
// Check if initiator level already exists
|
|
let initiatorLevel = await ApprovalLevel.findOne({
|
|
where: {
|
|
requestId,
|
|
levelName: 'Initiator Action'
|
|
}
|
|
});
|
|
|
|
if (initiatorLevel) {
|
|
// Activate existing level
|
|
await initiatorLevel.update({
|
|
status: ApprovalStatus.IN_PROGRESS,
|
|
levelStartTime: new Date(),
|
|
tatStartTime: new Date(),
|
|
approverId: wf.initiatorId
|
|
});
|
|
return initiatorLevel;
|
|
}
|
|
|
|
// Create new initiator level
|
|
// Find the highest level number to place it after
|
|
const maxLevel = await ApprovalLevel.findOne({
|
|
where: { requestId },
|
|
order: [['levelNumber', 'DESC']]
|
|
});
|
|
const nextLevelNumber = maxLevel ? maxLevel.levelNumber + 1 : 0;
|
|
|
|
// Get initiator user details
|
|
const initiatorUser = await User.findByPk(wf.initiatorId);
|
|
if (!initiatorUser) {
|
|
throw new Error('Initiator user not found');
|
|
}
|
|
|
|
initiatorLevel = await ApprovalLevel.create({
|
|
requestId,
|
|
levelNumber: nextLevelNumber,
|
|
levelName: 'Initiator Action',
|
|
approverId: wf.initiatorId,
|
|
approverEmail: initiatorUser.email || '',
|
|
approverName: initiatorUser.displayName || initiatorUser.email || 'Initiator',
|
|
status: ApprovalStatus.IN_PROGRESS,
|
|
levelStartTime: new Date(),
|
|
tatStartTime: new Date(),
|
|
tatHours: 0, // No TAT for initiator action
|
|
elapsedHours: 0,
|
|
remainingHours: 0,
|
|
tatPercentageUsed: 0,
|
|
isFinalApprover: false
|
|
} as any);
|
|
|
|
logger.info(`[DealerClaimService] Created/activated initiator level for request ${requestId}`);
|
|
return initiatorLevel;
|
|
} catch (error) {
|
|
logger.error(`[DealerClaimService] Error creating/activating initiator level:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @deprecated - Removed complex snapshot method. Snapshots are now taken at step execution.
|
|
*/
|
|
async saveCompleteRevisionSnapshot_DEPRECATED(
|
|
requestId: string,
|
|
changeReason: string,
|
|
userId: string
|
|
): Promise<void> {
|
|
try {
|
|
logger.info(`[DealerClaimService] Capturing complete revision snapshot for request ${requestId}`);
|
|
|
|
// 1. Capture current proposal snapshot (if exists)
|
|
const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } });
|
|
if (proposalDetails) {
|
|
const costItems = await DealerProposalCostItem.findAll({
|
|
where: { proposalId: (proposalDetails as any).proposalId || (proposalDetails as any).proposal_id }
|
|
});
|
|
|
|
// Find dealer proposal level
|
|
const dealerLevel = await ApprovalLevel.findOne({
|
|
where: {
|
|
requestId,
|
|
levelName: 'Dealer Proposal Submission'
|
|
}
|
|
}) || await ApprovalLevel.findOne({
|
|
where: { requestId, levelNumber: 1 }
|
|
});
|
|
|
|
if (dealerLevel) {
|
|
const proposalSnapshotData = {
|
|
documentUrl: proposalDetails.proposalDocumentUrl,
|
|
totalBudget: Number(proposalDetails.totalEstimatedBudget || 0),
|
|
comments: proposalDetails.dealerComments,
|
|
expectedCompletionDate: proposalDetails.expectedCompletionDate ? proposalDetails.expectedCompletionDate.toISOString() : null,
|
|
costItems: costItems.map(i => ({
|
|
description: i.itemDescription,
|
|
amount: Number(i.amount || 0),
|
|
order: i.itemOrder
|
|
}))
|
|
};
|
|
|
|
// Get next version for this level
|
|
const lastProposalVersion = await DealerClaimHistory.findOne({
|
|
where: {
|
|
requestId,
|
|
levelName: dealerLevel.levelName || undefined,
|
|
snapshotType: SnapshotType.PROPOSAL
|
|
},
|
|
order: [['version', 'DESC']]
|
|
});
|
|
const nextProposalVersion = lastProposalVersion ? lastProposalVersion.version + 1 : 1;
|
|
|
|
await DealerClaimHistory.create({
|
|
requestId,
|
|
approvalLevelId: dealerLevel.levelId,
|
|
levelNumber: dealerLevel.levelNumber,
|
|
levelName: dealerLevel.levelName || undefined,
|
|
version: nextProposalVersion,
|
|
snapshotType: SnapshotType.PROPOSAL,
|
|
snapshotData: proposalSnapshotData,
|
|
changeReason: `${changeReason} - Pre-revision snapshot`,
|
|
changedBy: userId
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] Captured proposal snapshot (v${nextProposalVersion}) for revision`);
|
|
}
|
|
}
|
|
|
|
// 2. Capture current completion snapshot (if exists)
|
|
const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId } });
|
|
if (completionDetails) {
|
|
const expenses = await DealerCompletionExpense.findAll({
|
|
where: { completionId: (completionDetails as any).completionId || (completionDetails as any).completion_id }
|
|
});
|
|
|
|
// Find completion level
|
|
const completionLevel = await ApprovalLevel.findOne({
|
|
where: {
|
|
requestId,
|
|
levelName: 'Dealer Completion Documents'
|
|
}
|
|
}) || await ApprovalLevel.findOne({
|
|
where: { requestId, levelNumber: 4 }
|
|
});
|
|
|
|
if (completionLevel) {
|
|
const completionSnapshotData = {
|
|
documentUrl: (completionDetails as any).completionDocumentUrl || null,
|
|
totalExpenses: Number(completionDetails.totalClosedExpenses || 0),
|
|
comments: (completionDetails as any).completionDescription || null,
|
|
expenses: expenses.map(e => ({
|
|
description: e.description,
|
|
amount: Number(e.amount || 0)
|
|
}))
|
|
};
|
|
|
|
// Get next version for this level
|
|
const lastCompletionVersion = await DealerClaimHistory.findOne({
|
|
where: {
|
|
requestId,
|
|
levelName: completionLevel.levelName || undefined,
|
|
snapshotType: SnapshotType.COMPLETION
|
|
},
|
|
order: [['version', 'DESC']]
|
|
});
|
|
const nextCompletionVersion = lastCompletionVersion ? lastCompletionVersion.version + 1 : 1;
|
|
|
|
await DealerClaimHistory.create({
|
|
requestId,
|
|
approvalLevelId: completionLevel.levelId,
|
|
levelNumber: completionLevel.levelNumber,
|
|
levelName: completionLevel.levelName || undefined,
|
|
version: nextCompletionVersion,
|
|
snapshotType: SnapshotType.COMPLETION,
|
|
snapshotData: completionSnapshotData,
|
|
changeReason: `${changeReason} - Pre-revision snapshot`,
|
|
changedBy: userId
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] Captured completion snapshot (v${nextCompletionVersion}) for revision`);
|
|
}
|
|
}
|
|
|
|
// 3. Capture current IO snapshot (if exists)
|
|
const internalOrder = await InternalOrder.findOne({ where: { requestId } });
|
|
if (internalOrder && internalOrder.ioBlockedAmount && internalOrder.ioBlockedAmount > 0) {
|
|
const ioLevel = await ApprovalLevel.findOne({
|
|
where: {
|
|
requestId,
|
|
levelName: 'Department Lead IO Approval'
|
|
}
|
|
}) || await ApprovalLevel.findOne({
|
|
where: { requestId, levelNumber: 3 }
|
|
});
|
|
|
|
if (ioLevel) {
|
|
const ioSnapshotData = {
|
|
ioNumber: internalOrder.ioNumber,
|
|
blockedAmount: Number(internalOrder.ioBlockedAmount || 0),
|
|
availableBalance: Number(internalOrder.ioAvailableBalance || 0),
|
|
remainingBalance: Number(internalOrder.ioRemainingBalance || 0),
|
|
sapDocumentNumber: internalOrder.sapDocumentNumber
|
|
};
|
|
|
|
// Get next version for this level
|
|
const lastIOVersion = await DealerClaimHistory.findOne({
|
|
where: {
|
|
requestId,
|
|
levelName: ioLevel.levelName || undefined,
|
|
snapshotType: SnapshotType.INTERNAL_ORDER
|
|
},
|
|
order: [['version', 'DESC']]
|
|
});
|
|
const nextIOVersion = lastIOVersion ? lastIOVersion.version + 1 : 1;
|
|
|
|
await DealerClaimHistory.create({
|
|
requestId,
|
|
approvalLevelId: ioLevel.levelId,
|
|
levelNumber: ioLevel.levelNumber,
|
|
levelName: ioLevel.levelName || undefined,
|
|
version: nextIOVersion,
|
|
snapshotType: SnapshotType.INTERNAL_ORDER,
|
|
snapshotData: ioSnapshotData,
|
|
changeReason: `${changeReason} - Pre-revision snapshot`,
|
|
changedBy: userId
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] Captured IO snapshot (v${nextIOVersion}) for revision`);
|
|
}
|
|
}
|
|
|
|
// 4. Capture ALL approval comments from all levels (so approvers can see their previous comments)
|
|
const allLevels = await ApprovalLevel.findAll({
|
|
where: { requestId },
|
|
order: [['levelNumber', 'ASC']]
|
|
});
|
|
|
|
for (const level of allLevels) {
|
|
// Only capture if level has been acted upon (has comments or action date)
|
|
if (level.comments || level.actionDate || level.status === ApprovalStatus.APPROVED || level.status === ApprovalStatus.REJECTED) {
|
|
const approver = level.approverId ? await User.findByPk(level.approverId) : null;
|
|
|
|
const approvalSnapshotData = {
|
|
action: level.status === ApprovalStatus.APPROVED ? 'APPROVE' : level.status === ApprovalStatus.REJECTED ? 'REJECT' : 'PENDING',
|
|
comments: level.comments || undefined,
|
|
rejectionReason: level.status === ApprovalStatus.REJECTED ? (level.comments || undefined) : undefined,
|
|
approverName: approver?.displayName || approver?.email || undefined,
|
|
approverEmail: approver?.email || undefined,
|
|
levelName: level.levelName || undefined
|
|
};
|
|
|
|
// Get next version for this level's approval snapshot
|
|
const lastApprovalVersion = await DealerClaimHistory.findOne({
|
|
where: {
|
|
requestId,
|
|
levelName: level.levelName || undefined,
|
|
snapshotType: SnapshotType.APPROVE
|
|
},
|
|
order: [['version', 'DESC']]
|
|
});
|
|
const nextApprovalVersion = lastApprovalVersion ? lastApprovalVersion.version + 1 : 1;
|
|
|
|
await DealerClaimHistory.create({
|
|
requestId,
|
|
approvalLevelId: level.levelId,
|
|
levelNumber: level.levelNumber,
|
|
levelName: level.levelName || undefined,
|
|
version: nextApprovalVersion,
|
|
snapshotType: SnapshotType.APPROVE,
|
|
snapshotData: approvalSnapshotData,
|
|
changeReason: `${changeReason} - Pre-revision approval snapshot`,
|
|
changedBy: userId
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] Captured approval snapshot (v${nextApprovalVersion}) for level ${level.levelNumber} (${level.levelName})`);
|
|
}
|
|
}
|
|
|
|
// 5. Save workflow-level snapshot
|
|
const wf = await WorkflowRequest.findByPk(requestId);
|
|
if (wf) {
|
|
const lastWorkflowVersion = await DealerClaimHistory.findOne({
|
|
where: {
|
|
requestId,
|
|
snapshotType: SnapshotType.WORKFLOW
|
|
},
|
|
order: [['version', 'DESC']]
|
|
});
|
|
const nextWorkflowVersion = lastWorkflowVersion ? lastWorkflowVersion.version + 1 : 1;
|
|
|
|
await DealerClaimHistory.create({
|
|
requestId,
|
|
version: nextWorkflowVersion,
|
|
snapshotType: SnapshotType.WORKFLOW,
|
|
snapshotData: {
|
|
status: wf.status,
|
|
currentLevel: wf.currentLevel
|
|
},
|
|
changeReason: `${changeReason} - Pre-revision workflow snapshot`,
|
|
changedBy: userId
|
|
});
|
|
|
|
logger.info(`[DealerClaimService] Captured workflow snapshot (v${nextWorkflowVersion}) for revision`);
|
|
}
|
|
|
|
logger.info(`[DealerClaimService] Complete revision snapshot captured for request ${requestId}`);
|
|
} catch (error) {
|
|
logger.error(`[DealerClaimService] Error saving complete revision snapshot for request ${requestId}:`, error);
|
|
// Don't throw - we want to continue even if snapshot fails
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle initiator actions when a request is in RETURNED status
|
|
*/
|
|
async handleInitiatorAction(
|
|
requestId: string,
|
|
userId: string,
|
|
action: 'REOPEN' | 'DISCUSS' | 'REVISE' | 'CANCEL',
|
|
data?: { reason: string }
|
|
): Promise<void> {
|
|
const wf = await WorkflowRequest.findByPk(requestId);
|
|
if (!wf) throw new Error('Request not found');
|
|
|
|
// Check if the current user is the initiator
|
|
if (wf.initiatorId !== userId) {
|
|
throw new Error('Only the initiator can perform actions on a rejected/returned request');
|
|
}
|
|
|
|
// A returned request is REJECTED but has NO closureDate
|
|
if (wf.status !== WorkflowStatus.REJECTED || wf.closureDate) {
|
|
throw new Error(`Request is in ${wf.status} status (Closed: ${!!wf.closureDate}), expected an open REJECTED state to perform this action`);
|
|
}
|
|
|
|
const initiator = await User.findByPk(userId);
|
|
const initiatorName = initiator?.displayName || initiator?.email || 'Initiator';
|
|
const now = new Date();
|
|
|
|
switch (action) {
|
|
case 'CANCEL': {
|
|
// Format change reason to include the comment if provided
|
|
const changeReason = data?.reason && data.reason.trim()
|
|
? `Request Cancelled: ${data.reason.trim()}`
|
|
: 'Request Cancelled';
|
|
|
|
// Find current level for workflow history
|
|
const currentLevel = await ApprovalLevel.findOne({
|
|
where: { requestId, levelNumber: wf.currentLevel || 1 }
|
|
});
|
|
|
|
await wf.update({
|
|
status: WorkflowStatus.CLOSED,
|
|
closureDate: now
|
|
});
|
|
|
|
await activityService.log({
|
|
requestId,
|
|
type: 'status_change',
|
|
user: { userId, name: initiatorName },
|
|
timestamp: now.toISOString(),
|
|
action: 'Request Cancelled',
|
|
details: data?.reason && data.reason.trim()
|
|
? `Request was cancelled by initiator. Reason: ${data.reason.trim()}`
|
|
: 'Request was cancelled by initiator.'
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'REOPEN': {
|
|
// Format change reason to include the comment if provided
|
|
const changeReason = data?.reason && data.reason.trim()
|
|
? `Request Reopened: ${data.reason.trim()}`
|
|
: 'Request Reopened';
|
|
|
|
// Find Department Lead level dynamically (handles step shifts)
|
|
const approvalsReopen = await ApprovalLevel.findAll({ where: { requestId } });
|
|
const deptLeadLevel = approvalsReopen.find(l => {
|
|
const name = (l.levelName || '').toLowerCase();
|
|
return name.includes('department lead') || name.includes('dept lead') || l.levelNumber === 3;
|
|
});
|
|
|
|
if (!deptLeadLevel) {
|
|
throw new Error('Department Lead approval level not found for this request');
|
|
}
|
|
|
|
const deptLeadLevelNumber = deptLeadLevel.levelNumber;
|
|
|
|
// Move back to Department Lead Approval level FIRST
|
|
await wf.update({
|
|
status: WorkflowStatus.PENDING,
|
|
currentLevel: deptLeadLevelNumber
|
|
});
|
|
|
|
// Capture workflow snapshot AFTER workflow update succeeds
|
|
try {
|
|
await this.saveWorkflowHistory(
|
|
requestId,
|
|
`Reopened and moved to Department Lead level (${deptLeadLevelNumber}) - ${changeReason}`,
|
|
userId,
|
|
deptLeadLevel.levelId,
|
|
deptLeadLevelNumber,
|
|
deptLeadLevel.levelName || undefined
|
|
);
|
|
} catch (snapshotError) {
|
|
// Log error but don't fail the reopen - snapshot is for audit, not critical
|
|
logger.error(`[DealerClaimService] Failed to save workflow history snapshot (non-critical):`, snapshotError);
|
|
}
|
|
|
|
// Reset the found level status to IN_PROGRESS so Dept Lead can approve again
|
|
await deptLeadLevel.update({
|
|
status: ApprovalStatus.IN_PROGRESS,
|
|
levelStartTime: now,
|
|
tatStartTime: now,
|
|
actionDate: undefined,
|
|
comments: undefined
|
|
});
|
|
|
|
await activityService.log({
|
|
requestId,
|
|
type: 'approval',
|
|
user: { userId, name: initiatorName },
|
|
timestamp: now.toISOString(),
|
|
action: 'Request Reopened',
|
|
details: data?.reason && data.reason.trim()
|
|
? `Initiator reopened the request for Department Lead approval. Reason: ${data.reason.trim()}`
|
|
: 'Initiator reopened the request for Department Lead approval.'
|
|
});
|
|
|
|
if (deptLeadLevel.approverId) {
|
|
await notificationService.sendToUsers([deptLeadLevel.approverId], {
|
|
title: `Request Reopened: ${wf.requestNumber}`,
|
|
body: `Initiator has reopened the request "${wf.title}" after revision/discussion.`,
|
|
requestNumber: wf.requestNumber,
|
|
requestId: wf.requestId,
|
|
url: `/request/${wf.requestNumber}`,
|
|
type: 'assignment',
|
|
priority: 'HIGH',
|
|
actionRequired: true
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'DISCUSS': {
|
|
// Format change reason to include the comment if provided
|
|
const changeReason = data?.reason && data.reason.trim()
|
|
? `Discussion Requested: ${data.reason.trim()}`
|
|
: 'Discussion Requested';
|
|
|
|
// Find Dealer level dynamically
|
|
const approvalsDiscuss = await ApprovalLevel.findAll({ where: { requestId } });
|
|
const dealerLevelDiscuss = approvalsDiscuss.find(l => {
|
|
const name = (l.levelName || '').toLowerCase();
|
|
return name.includes('dealer proposal') || l.levelNumber === 1;
|
|
});
|
|
|
|
// Note: DISCUSS action doesn't change workflow state, so no snapshot needed
|
|
// The action is logged in activity log only
|
|
|
|
await activityService.log({
|
|
requestId,
|
|
type: 'status_change',
|
|
user: { userId, name: initiatorName },
|
|
timestamp: now.toISOString(),
|
|
action: 'Discuss with Dealer',
|
|
details: data?.reason && data.reason.trim()
|
|
? `Initiator indicated they will discuss with the dealer. Reason: ${data.reason.trim()}`
|
|
: 'Initiator indicated they will discuss with the dealer.'
|
|
});
|
|
|
|
if (dealerLevelDiscuss?.approverId) {
|
|
await notificationService.sendToUsers([dealerLevelDiscuss.approverId], {
|
|
title: `Discussion Requested: ${wf.requestNumber}`,
|
|
body: `The initiator of request "${wf.title}" wants to discuss the proposal with you.`,
|
|
requestNumber: wf.requestNumber,
|
|
requestId: wf.requestId,
|
|
url: `/request/${wf.requestNumber}`,
|
|
type: 'info',
|
|
priority: 'MEDIUM'
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'REVISE': {
|
|
// Format change reason
|
|
const changeReason = data?.reason && data.reason.trim()
|
|
? `Revision Requested: ${data.reason.trim()}`
|
|
: 'Revision Requested';
|
|
|
|
// Find current level and previous level
|
|
const allLevels = await ApprovalLevel.findAll({
|
|
where: { requestId },
|
|
order: [['levelNumber', 'ASC']]
|
|
});
|
|
|
|
const currentLevelNumber = wf.currentLevel || 1;
|
|
const currentLevel = allLevels.find(l => l.levelNumber === currentLevelNumber);
|
|
|
|
if (!currentLevel) {
|
|
throw new Error('Current approval level not found');
|
|
}
|
|
|
|
// Find previous level (the one before current)
|
|
const previousLevel = allLevels.find(l => l.levelNumber < currentLevelNumber);
|
|
|
|
if (!previousLevel) {
|
|
throw new Error('No previous level found to revise to');
|
|
}
|
|
|
|
// Move back to previous level FIRST
|
|
await wf.update({
|
|
status: WorkflowStatus.PENDING,
|
|
currentLevel: previousLevel.levelNumber
|
|
});
|
|
|
|
// Capture workflow snapshot AFTER workflow update succeeds
|
|
try {
|
|
await this.saveWorkflowHistory(
|
|
requestId,
|
|
`Moved back to previous level (${previousLevel.levelNumber}) - ${changeReason}`,
|
|
userId,
|
|
previousLevel.levelId,
|
|
previousLevel.levelNumber,
|
|
previousLevel.levelName || undefined
|
|
);
|
|
} catch (snapshotError) {
|
|
// Log error but don't fail the revise - snapshot is for audit, not critical
|
|
logger.error(`[DealerClaimService] Failed to save workflow history snapshot (non-critical):`, snapshotError);
|
|
}
|
|
|
|
// Reset current level to PENDING
|
|
await currentLevel.update({
|
|
status: ApprovalStatus.PENDING,
|
|
actionDate: undefined,
|
|
levelStartTime: undefined,
|
|
levelEndTime: undefined,
|
|
tatStartTime: undefined,
|
|
elapsedHours: 0,
|
|
tatPercentageUsed: 0,
|
|
comments: undefined
|
|
});
|
|
|
|
// Activate previous level
|
|
await previousLevel.update({
|
|
status: ApprovalStatus.IN_PROGRESS,
|
|
levelStartTime: now,
|
|
tatStartTime: now,
|
|
comments: changeReason, // Save revision reason as comment
|
|
actionDate: undefined,
|
|
levelEndTime: undefined,
|
|
elapsedHours: 0,
|
|
tatPercentageUsed: 0
|
|
});
|
|
|
|
await activityService.log({
|
|
requestId,
|
|
type: 'assignment',
|
|
user: { userId, name: initiatorName },
|
|
timestamp: now.toISOString(),
|
|
action: 'Revision Requested',
|
|
details: data?.reason && data.reason.trim()
|
|
? `Initiator requested revision. Moving back to previous step. Reason: ${data.reason.trim()}`
|
|
: 'Initiator requested revision. Moving back to previous step.'
|
|
});
|
|
|
|
// Notify the approver of the previous level
|
|
if (previousLevel.approverId) {
|
|
await notificationService.sendToUsers([previousLevel.approverId], {
|
|
title: `Revision Required: ${wf.requestNumber}`,
|
|
body: `Initiator has requested a revision for request "${wf.title}". The request has been moved back to your level.`,
|
|
requestNumber: wf.requestNumber,
|
|
requestId: wf.requestId,
|
|
url: `/request/${wf.requestNumber}`,
|
|
type: 'assignment',
|
|
priority: 'HIGH',
|
|
actionRequired: true
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
const { emitToRequestRoom } = await import('../realtime/socket');
|
|
emitToRequestRoom(requestId, 'request:updated', {
|
|
requestId,
|
|
requestNumber: wf.requestNumber,
|
|
action: `INITIATOR_${action}`,
|
|
timestamp: now.toISOString()
|
|
});
|
|
}
|
|
|
|
async getHistory(requestId: string): Promise<any[]> {
|
|
const history = await DealerClaimHistory.findAll({
|
|
where: { requestId },
|
|
order: [['version', 'DESC']],
|
|
include: [
|
|
{
|
|
model: User,
|
|
as: 'changer',
|
|
attributes: ['userId', 'displayName', 'email']
|
|
}
|
|
]
|
|
});
|
|
|
|
// Map to plain objects and sort otherDocuments in snapshots
|
|
return history.map(item => {
|
|
const plain = item.get({ plain: true });
|
|
if (plain.snapshotData && plain.snapshotData.otherDocuments && Array.isArray(plain.snapshotData.otherDocuments)) {
|
|
plain.snapshotData.otherDocuments.sort((a: any, b: any) => {
|
|
const dateA = a.uploadedAt ? new Date(a.uploadedAt).getTime() : 0;
|
|
const dateB = b.uploadedAt ? new Date(b.uploadedAt).getTime() : 0;
|
|
return dateB - dateA;
|
|
});
|
|
}
|
|
return plain;
|
|
});
|
|
}
|
|
}
|
|
|