Re_Backend/src/services/dealerClaim.service.ts

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;
}
}
}