2171 lines
88 KiB
TypeScript
2171 lines
88 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 { 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;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
): Promise<void> {
|
|
try {
|
|
const request = await WorkflowRequest.findByPk(requestId);
|
|
if (!request || request.workflowType !== 'CLAIM_MANAGEMENT') {
|
|
throw new Error('Invalid claim request');
|
|
}
|
|
|
|
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) {
|
|
await this.approvalService.approveLevel(
|
|
dealerProposalLevel.levelId,
|
|
{ action: 'APPROVE', comments: 'Dealer proposal submitted' },
|
|
'system', // System approval
|
|
{ ipAddress: null, userAgent: null }
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
): 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) {
|
|
await this.approvalService.approveLevel(
|
|
dealerCompletionLevel.levelId,
|
|
{ action: 'APPROVE', comments: 'Completion documents submitted' },
|
|
'system',
|
|
{ ipAddress: null, userAgent: null }
|
|
);
|
|
}
|
|
|
|
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 {
|
|
const blockedAmount = ioData.blockedAmount || 0;
|
|
|
|
// If blocking amount > 0, proceed with SAP integration and blocking
|
|
// If blocking amount is 0 but ioNumber and ioRemark are provided, just save the IO details without blocking
|
|
if (blockedAmount <= 0) {
|
|
// Allow saving IO details (ioNumber, ioRemark) even without blocking amount
|
|
// This is useful when Step 3 is approved but amount hasn't been blocked yet
|
|
if (ioData.ioNumber && (ioData.ioRemark !== undefined)) {
|
|
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 || '',
|
|
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 and ioRemark - don't overwrite balance values
|
|
await internalOrder.update({
|
|
ioNumber: ioData.ioNumber,
|
|
ioRemark: ioData.ioRemark || '',
|
|
// 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,
|
|
ioRemark: ioData.ioRemark,
|
|
preservedAvailableBalance: internalOrder.ioAvailableBalance,
|
|
preservedBlockedAmount: internalOrder.ioBlockedAmount,
|
|
preservedRemainingBalance: internalOrder.ioRemainingBalance,
|
|
});
|
|
}
|
|
|
|
logger.info(`[DealerClaimService] IO details saved (without blocking) for request: ${requestId}`, {
|
|
ioNumber: ioData.ioNumber,
|
|
ioRemark: ioData.ioRemark
|
|
});
|
|
|
|
return; // Exit early - no SAP blocking needed
|
|
} else {
|
|
throw new Error('Blocked amount must be greater than 0, or ioNumber and ioRemark 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;
|
|
const availableBalance = ioData.availableBalance || ioValidation.availableBalance;
|
|
|
|
// 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
|
|
const calculatedRemainingBalance = availableBalance - finalBlockedAmount;
|
|
|
|
// 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
|
|
const sapRemainingBalance = blockResult.remainingBalance;
|
|
const sapValueIsValid = sapRemainingBalance > 0 &&
|
|
sapRemainingBalance <= availableBalance &&
|
|
Math.abs(sapRemainingBalance - calculatedRemainingBalance) < 1;
|
|
|
|
const remainingBalance = sapValueIsValid
|
|
? sapRemainingBalance
|
|
: calculatedRemainingBalance;
|
|
|
|
// Ensure remaining balance is not negative
|
|
const finalRemainingBalance = Math.max(0, remainingBalance);
|
|
|
|
// 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 2 decimal places for database storage (avoid floating point precision issues)
|
|
const roundedAvailableBalance = Math.round(availableBalance * 100) / 100;
|
|
const roundedBlockedAmount = Math.round(finalBlockedAmount * 100) / 100;
|
|
const roundedRemainingBalance = Math.round(finalRemainingBalance * 100) / 100;
|
|
|
|
// Create or update Internal Order record (only when blocking)
|
|
const ioRecordData = {
|
|
requestId,
|
|
ioNumber: ioData.ioNumber,
|
|
ioRemark: ioData.ioRemark || '',
|
|
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}`);
|
|
}
|
|
|
|
// Update budget tracking with blocked amount
|
|
await ClaimBudgetTracking.upsert({
|
|
requestId,
|
|
ioBlockedAmount: finalBlockedAmount,
|
|
ioBlockedAt: new Date(),
|
|
budgetStatus: BudgetStatus.BLOCKED,
|
|
currency: 'INR',
|
|
});
|
|
|
|
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 {
|
|
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
|
|
if (userIdsForNotification.length > 0) {
|
|
await notificationService.sendToUsers(userIdsForNotification, {
|
|
title: emailSubject,
|
|
body: emailBody,
|
|
requestId,
|
|
requestNumber,
|
|
url: `/request/${requestNumber}`,
|
|
type: 'activity_created',
|
|
priority: 'MEDIUM',
|
|
actionRequired: false
|
|
});
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
|