Re_Backend/src/services/dealerClaim.service.ts

3660 lines
145 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Op } from 'sequelize';
import { sequelize } from '../config/database';
import logger from '../utils/logger';
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 { ClaimInvoiceItem } from '../models/ClaimInvoiceItem';
import { DealerCompletionExpense } from '../models/DealerCompletionExpense';
import { ApprovalLevel } from '../models/ApprovalLevel';
import { Participant } from '../models/Participant';
import { User } from '../models/User';
import { DealerClaimHistory, SnapshotType } from '../models/DealerClaimHistory';
import { ActivityType } from '../models/ActivityType';
import { Document } from '../models/Document';
import { Dealer } from '../models/Dealer';
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 { pwcIntegrationService } from './pwcIntegration.service';
import { wfmFileService } from './wfmFile.service';
import { findDealerLocally } from './dealer.service';
import { notificationService } from './notification.service';
import { activityService } from './activity.service';
import { UserService } from './user.service';
import { dmsIntegrationService } from './dmsIntegration.service';
import { validateDealerUser } from './userEnrichment.service';
// findDealerLocally removed (duplicate)
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
let workflowServiceInstance: any;
let approvalServiceInstance: any;
let userServiceInstance: any;
/**
* Dealer Claim Service
* Handles business logic specific to dealer claim management workflow
*/
export class DealerClaimService {
private getWorkflowService(): WorkflowService {
if (!workflowServiceInstance) {
const { WorkflowService } = require('./workflow.service');
workflowServiceInstance = new WorkflowService();
}
return workflowServiceInstance;
}
private getApprovalService(): DealerClaimApprovalService {
if (!approvalServiceInstance) {
const { DealerClaimApprovalService } = require('./dealerClaimApproval.service');
approvalServiceInstance = new DealerClaimApprovalService();
}
return approvalServiceInstance;
}
private getUserService(): UserService {
if (!userServiceInstance) {
const { UserService } = require('./user.service');
userServiceInstance = new UserService();
}
return userServiceInstance;
}
/**
* 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 {
// 0. Validate Dealer User (jobTitle='Dealer' and employeeId=dealerCode)
logger.info(`[DealerClaimService] Validating dealer for code: ${claimData.dealerCode}`);
const dealerUser = await validateDealerUser(claimData.dealerCode);
// Update claim data with validated dealer user details if not provided
claimData.dealerName = dealerUser.displayName || claimData.dealerName;
claimData.dealerEmail = dealerUser.email || claimData.dealerEmail;
claimData.dealerPhone = (dealerUser as any).mobilePhone || (dealerUser as any).phone || claimData.dealerPhone;
// 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');
}
// Fallback: Enrichment from local dealer table if data is missing or incomplete
// We still keep this as a secondary fallback, but validation above is primary
const localDealer = await findDealerLocally(claimData.dealerCode, claimData.dealerEmail);
if (localDealer) {
logger.info(`[DealerClaimService] Enriched claim request with local dealer data: ${localDealer.dealerCode}`);
claimData.dealerName = claimData.dealerName || localDealer.dealerName;
claimData.dealerEmail = claimData.dealerEmail || localDealer.dealerPrincipalEmailId || localDealer.email;
claimData.dealerPhone = claimData.dealerPhone || localDealer.phone;
}
// 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.');
}
// 1. Transform approvers and ensure users exist in database
const userService = this.getUserService();
const transformedLevels = [];
// Define step names mapping
const stepNames: Record<number, string> = {
1: 'Dealer Proposal Submission',
2: 'Requestor Evaluation',
3: 'Department Lead Approval',
4: 'Dealer Completion Documents',
5: 'Requestor Claim Approval'
};
for (const a of claimData.approvers) {
let approverUserId = a.userId;
// Determine level name - use mapped name or fallback to "Step X"
let levelName = stepNames[a.level] || `Step ${a.level}`;
// If this is a Dealer-specific step (Step 1 or Step 4), ensure we use the validated dealerUser
if (a.level === 1 || a.level === 4) {
logger.info(`[DealerClaimService] Assigning validated dealer user to ${levelName} (Step ${a.level})`);
approverUserId = dealerUser.userId;
a.email = dealerUser.email;
a.name = dealerUser.displayName || dealerUser.email;
}
// If userId missing, ensure user exists by email
if (!approverUserId && a.email) {
try {
const user = await userService.ensureUserExists({ email: a.email });
approverUserId = user.userId;
} catch (e) {
logger.warn(`[DealerClaimService] Could not resolve user for email ${a.email}:`, e);
// If it fails, keep it empty and let the workflow service handle it (or fail early)
}
}
let tatHours = 24; // Default
if (a.tat) {
const val = typeof a.tat === 'number' ? a.tat : parseInt(a.tat as string);
tatHours = a.tatType === 'days' ? val * 24 : val;
}
// Already determined levelName above
// If it's an additional approver (not one of the standard steps), label it clearly
// Note: The frontend might send extra steps if approvers are added dynamically
// But for initial creation, we usually stick to the standard flow
transformedLevels.push({
levelNumber: a.level,
levelName: levelName,
approverId: approverUserId || '', // Fallback to empty string if still not resolved
approverEmail: a.email,
approverName: a.name || a.email,
tatHours: tatHours,
// New 5-step flow: Level 5 is the final approver (Requestor Claim Approval)
isFinalApprover: a.level === 5
});
}
// 2. Transform participants
const transformedParticipants = [
{
userId: userId,
userName: initiator.displayName || initiator.email,
userEmail: initiator.email,
participantType: 'INITIATOR' as any,
}
];
// Add approvers as participants
for (const level of transformedLevels) {
if (level.approverId) {
transformedParticipants.push({
userId: level.approverId,
userName: level.approverName,
userEmail: level.approverEmail,
participantType: 'APPROVER' as any
});
}
}
const workflowService = this.getWorkflowService();
const workflowRequest = await workflowService.createWorkflow(userId, {
templateType: 'DEALER CLAIM' as any,
workflowType: 'CLAIM_MANAGEMENT',
title: `${claimData.activityName} - Claim Request`,
description: claimData.requestDescription,
priority: Priority.STANDARD,
approvalLevels: transformedLevels,
participants: transformedParticipants,
isDraft: false
} as any);
// 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',
});
// Redundant level creation removed - handled by workflowService.createWorkflow
// Redundant TAT scheduling removed - handled by workflowService.createWorkflow
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 systemEmails = [`system@${appDomain}`];
const financeEmails = [`finance@${appDomain}`];
const isSystemEmail = systemEmails.includes(approver.email) || financeEmails.includes(approver.email);
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 {
const userService = this.getUserService();
user = await 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@${appDomain}`) {
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 {
const userService = this.getUserService();
dealerUser = await 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@${appDomain}`];
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 userService = this.getUserService();
const oktaUsers = await 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 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 internalOrders = await InternalOrder.findAll({
where: { requestId },
include: [
{ model: User, as: 'organizer', required: false }
],
order: [['createdAt', 'ASC']]
});
// Serialize claim details to ensure proper field names
let serializedClaimDetails = null;
if (claimDetails) {
serializedClaimDetails = (claimDetails as any).toJSON ? (claimDetails as any).toJSON() : claimDetails;
// Fetch default GST rate and taxation type from ActivityType table
try {
const activityTypeTitle = (claimDetails.activityType || '').trim();
logger.info(`[DealerClaimService] Resolving taxationType for activity: "${activityTypeTitle}"`);
let activity = await ActivityType.findOne({
where: { title: activityTypeTitle }
});
// Fallback 1: Try normalized title (handling en-dash vs hyphen)
if (!activity && activityTypeTitle) {
const normalizedTitle = activityTypeTitle.replace(//g, '-');
if (normalizedTitle !== activityTypeTitle) {
activity = await ActivityType.findOne({
where: { title: normalizedTitle }
});
}
}
// Fallback 2: Handle cases where activity is found but taxationType is missing, or activity not found
if (activity && activity.taxationType) {
serializedClaimDetails.defaultGstRate = Number(activity.gstRate) || 18;
serializedClaimDetails.taxationType = activity.taxationType;
logger.info(`[DealerClaimService] Resolved from ActivityType record: ${activity.taxationType}`);
} else {
// Infer from title if record is missing or incomplete
const isNonGst = activityTypeTitle.toLowerCase().includes('non');
serializedClaimDetails.taxationType = isNonGst ? 'Non GST' : 'GST';
serializedClaimDetails.defaultGstRate = isNonGst ? 0 : (activity ? (Number(activity.gstRate) || 18) : 18);
logger.info(`[DealerClaimService] Inferred taxationType from title: ${serializedClaimDetails.taxationType} (Activity record ${activity ? 'found but missing taxationType' : 'not found'})`);
}
} catch (error) {
logger.warn(`[DealerClaimService] Error fetching activity type for ${claimDetails.activityType}:`, error);
serializedClaimDetails.defaultGstRate = 18;
serializedClaimDetails.taxationType = 'GST'; // Safe default
}
serializedClaimDetails.internalOrders = internalOrders.map(io => (io as any).toJSON ? (io as any).toJSON() : io);
// Maintain backward compatibility for single internalOrder field (using first one)
serializedClaimDetails.internalOrder = internalOrders.length > 0 ? (internalOrders[0] as any).toJSON ? (internalOrders[0] as any).toJSON() : internalOrders[0] : null;
// Fetch dealer details (GSTIN, State, City) from dealers table / external API enrichment
try {
const dealer = await findDealerLocally(claimDetails.dealerCode);
if (dealer) {
serializedClaimDetails.dealerGstin = dealer.gstin || null;
serializedClaimDetails.dealerGSTIN = dealer.gstin || null; // Backward compatibility
serializedClaimDetails.dealerState = dealer.state || null;
serializedClaimDetails.dealerCity = dealer.city || null;
logger.info(`[DealerClaimService] Enriched claim details with dealer info for ${claimDetails.dealerCode}: GSTIN=${dealer.gstin}, State=${dealer.state}`);
}
} catch (dealerError) {
logger.warn(`[DealerClaimService] Error fetching dealer details for ${claimDetails.dealerCode}:`, dealerError);
}
}
// 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,
quantity: Number(item.quantity) || 1,
hsnCode: item.hsnCode || '',
gstRate: Number(item.gstRate) || 0,
gstAmt: Number(item.gstAmt) || 0,
cgstRate: Number(item.cgstRate) || 0,
cgstAmt: Number(item.cgstAmt) || 0,
sgstRate: Number(item.sgstRate) || 0,
sgstAmt: Number(item.sgstAmt) || 0,
igstRate: Number(item.igstRate) || 0,
igstAmt: Number(item.igstAmt) || 0,
utgstRate: Number(item.utgstRate) || 0,
utgstAmt: Number(item.utgstAmt) || 0,
cessRate: Number(item.cessRate) || 0,
cessAmt: Number(item.cessAmt) || 0,
totalAmt: Number(item.totalAmt) || 0,
isService: !!item.isService
}));
}
// 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
const serializedInternalOrders = internalOrders.map(io => (io as any).toJSON ? (io as any).toJSON() : io);
// For backward compatibility, also provide serializedInternalOrder (first one)
const serializedInternalOrder = serializedInternalOrders.length > 0 ? serializedInternalOrders[0] : null;
// 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,
gstRate: Number(expenseData.gstRate) || 0,
gstAmt: Number(expenseData.gstAmt) || 0,
cgstAmt: Number(expenseData.cgstAmt) || 0,
sgstAmt: Number(expenseData.sgstAmt) || 0,
igstAmt: Number(expenseData.igstAmt) || 0,
totalAmt: Number(expenseData.totalAmt) || 0,
expenseDate: expenseData.expenseDate
};
});
return {
request: (request as any).toJSON ? (request as any).toJSON() : request,
claimDetails: serializedClaimDetails,
proposalDetails: transformedProposalDetails,
completionDetails: serializedCompletionDetails,
internalOrder: serializedInternalOrder,
internalOrders: serializedInternalOrders, // Return full list for UI
// New normalized tables
budgetTracking: serializedBudgetTracking,
invoice: serializedInvoice,
creditNote: serializedCreditNote,
completionExpenses: expensesBreakdown, // Array of expense items
};
} catch (error) {
logger.error('[DealerClaimService] Error getting claim details:', error);
throw error;
}
}
/**
* Submit dealer proposal (Step 1)
*/
async submitDealerProposal(
requestId: string,
proposalData: {
proposalDocumentPath?: string;
proposalDocumentUrl?: string;
costBreakup: any[];
totalEstimatedBudget: number;
timelineMode: 'date' | 'days';
expectedCompletionDate?: Date;
expectedCompletionDays?: number;
dealerComments: string;
},
dealerUserId?: string // Optional dealer user ID for history tracking
): Promise<void> {
try {
const request = await WorkflowRequest.findByPk(requestId);
if (!request || request.workflowType !== 'CLAIM_MANAGEMENT') {
throw new Error('Invalid claim request');
}
// Get dealer user ID if not provided - try to find by dealer email from claim details
let actualDealerUserId: string | null = dealerUserId || null;
if (!actualDealerUserId) {
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
if (claimDetails?.dealerEmail) {
const dealerUser = await User.findOne({
where: { email: claimDetails.dealerEmail }
});
actualDealerUserId = dealerUser?.userId || null;
}
}
if (request.currentLevel !== 1) {
throw new Error('Proposal can only be submitted at step 1');
}
// Save proposal details (costBreakup removed - now using separate table)
const [proposal] = await DealerProposalDetails.upsert({
requestId,
proposalDocumentPath: proposalData.proposalDocumentPath,
proposalDocumentUrl: proposalData.proposalDocumentUrl,
// costBreakup field removed - now using dealer_proposal_cost_items table
totalEstimatedBudget: proposalData.totalEstimatedBudget,
timelineMode: proposalData.timelineMode,
expectedCompletionDate: proposalData.expectedCompletionDate,
expectedCompletionDays: proposalData.expectedCompletionDays,
dealerComments: proposalData.dealerComments,
submittedAt: new Date(),
}, {
returning: true
});
// Get proposalId - handle both Sequelize instance and plain object
let proposalId = (proposal as any).proposalId
|| (proposal as any).proposal_id;
// If not found, try getDataValue method
if (!proposalId && (proposal as any).getDataValue) {
proposalId = (proposal as any).getDataValue('proposalId');
}
// If still not found, fetch the proposal by requestId
if (!proposalId) {
const existingProposal = await DealerProposalDetails.findOne({
where: { requestId }
});
if (existingProposal) {
proposalId = (existingProposal as any).proposalId
|| (existingProposal as any).proposal_id
|| ((existingProposal as any).getDataValue ? (existingProposal as any).getDataValue('proposalId') : null);
}
}
if (!proposalId) {
throw new Error('Failed to get proposal ID after saving proposal details');
}
// Clear 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,
quantity: Number(item.quantity) || 1,
hsnCode: item.hsnCode || '',
gstRate: Number(item.gstRate) || 0,
gstAmt: Number(item.gstAmt) || 0,
cgstRate: Number(item.cgstRate) || 0,
cgstAmt: Number(item.cgstAmt) || 0,
sgstRate: Number(item.sgstRate) || 0,
sgstAmt: Number(item.sgstAmt) || 0,
igstRate: Number(item.igstRate) || 0,
igstAmt: Number(item.igstAmt) || 0,
utgstRate: Number(item.utgstRate) || 0,
utgstAmt: Number(item.utgstAmt) || 0,
cessRate: Number(item.cessRate) || 0,
cessAmt: Number(item.cessAmt) || 0,
totalAmt: Number(item.totalAmt) || Number(item.amount) || 0,
isService: !!item.isService,
itemOrder: index
}));
await DealerProposalCostItem.bulkCreate(costItems);
logger.info(`[DealerClaimService] Saved ${costItems.length} cost items for proposal ${proposalId}`);
// Calculate total proposed taxable amount for IO blocking
const totalProposedTaxableAmount = proposalData.costBreakup.reduce((sum: number, item: any) => {
const amount = Number(item.amount) || 0;
const quantity = Number(item.quantity) || 1;
return sum + (amount * quantity);
}, 0);
// Update taxable amount in DealerClaimDetails for IO blocking reference
await DealerClaimDetails.update(
{ totalProposedTaxableAmount },
{ where: { requestId } }
);
// Update budget tracking with proposed expenses
await ClaimBudgetTracking.upsert({
requestId,
proposalEstimatedBudget: proposalData.totalEstimatedBudget,
proposalSubmittedAt: new Date(),
budgetStatus: BudgetStatus.PROPOSED,
currency: 'INR',
});
// Approve Dealer Proposal Submission step dynamically (by levelName, not hardcoded step number)
let dealerProposalLevel = await ApprovalLevel.findOne({
where: {
requestId,
levelName: 'Dealer Proposal Submission'
}
});
// Fallback: try to find by levelNumber 1 (for backwards compatibility)
if (!dealerProposalLevel) {
dealerProposalLevel = await ApprovalLevel.findOne({
where: { requestId, levelNumber: 1 }
});
}
if (dealerProposalLevel) {
// Use dealer's comment if provided, otherwise use default message
const approvalComment = proposalData.dealerComments?.trim()
? proposalData.dealerComments.trim()
: 'Dealer proposal submitted';
// Perform the approval action FIRST - only save snapshot if action succeeds
const approvalService = this.getApprovalService();
await approvalService.approveLevel(
dealerProposalLevel.levelId,
{ action: 'APPROVE', comments: approvalComment },
actualDealerUserId || (request as any).initiatorId || 'system', // Use dealer or initiator ID
{ ipAddress: null, userAgent: null }
);
// Save proposal history AFTER approval succeeds (this is the only snapshot needed for dealer submission)
// Use dealer user ID if available, otherwise use initiator ID as fallback
const historyUserId = actualDealerUserId || (request as any).initiatorId || null;
if (!historyUserId) {
logger.warn(`[DealerClaimService] No user ID available for proposal history, skipping history save`);
} else {
try {
await this.saveProposalHistory(
requestId,
dealerProposalLevel.levelId,
dealerProposalLevel.levelNumber,
`Proposal Submitted: ${approvalComment}`,
historyUserId
);
// Note: We don't save workflow history here - proposal history is sufficient
// Workflow history will be saved when the level is approved and moves to next level
} catch (snapshotError) {
// Log error but don't fail the submission - snapshot is for audit, not critical
logger.error(`[DealerClaimService] Failed to save proposal history snapshot (non-critical):`, snapshotError);
}
}
}
logger.info(`[DealerClaimService] Dealer proposal submitted for request: ${requestId}`);
} catch (error) {
logger.error('[DealerClaimService] Error submitting dealer proposal:', error);
throw error;
}
}
/**
* Submit dealer completion documents (Step 5)
*/
async submitCompletionDocuments(
requestId: string,
completionData: {
activityCompletionDate: Date;
numberOfParticipants?: number;
closedExpenses: any[];
totalClosedExpenses: number;
invoicesReceipts?: any[];
attendanceSheet?: any;
completionDescription?: string;
},
dealerUserId?: string // Optional dealer user ID for history tracking
): Promise<void> {
try {
const request = await WorkflowRequest.findByPk(requestId);
// Handle backward compatibility: workflowType may be undefined in old environments
const workflowType = request?.workflowType || 'NON_TEMPLATIZED';
if (!request || workflowType !== 'CLAIM_MANAGEMENT') {
throw new Error('Invalid claim request');
}
// Find the "Dealer Completion Documents" step by levelName (handles step shifts due to additional approvers)
const approvalLevels = await ApprovalLevel.findAll({
where: { requestId },
order: [['levelNumber', 'ASC']]
});
const dealerCompletionStep = approvalLevels.find((level: any) => {
const levelName = (level.levelName || '').toLowerCase();
return levelName.includes('dealer completion') || levelName.includes('completion documents');
});
if (!dealerCompletionStep) {
throw new Error('Dealer Completion Documents step not found');
}
// Check if current level matches the Dealer Completion Documents step (handles step shifts)
if (request.currentLevel !== dealerCompletionStep.levelNumber) {
throw new Error(`Completion documents can only be submitted at the Dealer Completion Documents step (currently at step ${request.currentLevel})`);
}
// Save completion details
const [completionDetails] = await DealerCompletionDetails.upsert({
requestId,
activityCompletionDate: completionData.activityCompletionDate,
numberOfParticipants: completionData.numberOfParticipants,
totalClosedExpenses: completionData.totalClosedExpenses,
submittedAt: new Date(),
});
// Persist individual closed expenses to dealer_completion_expenses
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
const dealer = await findDealerLocally(claimDetails?.dealerCode, claimDetails?.dealerEmail);
const buyerStateCode = "33";
let dealerStateCode = "33";
if (dealer?.gstin && dealer.gstin.length >= 2 && !isNaN(Number(dealer.gstin.substring(0, 2)))) {
dealerStateCode = dealer.gstin.substring(0, 2);
} else if (dealer?.state) {
if (dealer.state.toLowerCase().includes('tamil nadu')) dealerStateCode = "33";
else dealerStateCode = "00";
}
const isIGST = dealerStateCode !== buyerStateCode;
const completionId = (completionDetails as any)?.completionId;
const expenseRows: any[] = [];
if (completionData.closedExpenses && completionData.closedExpenses.length > 0) {
// Determine taxation type for fallback logic
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
let isNonGst = false;
if (claimDetails?.activityType) {
const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } });
const taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST');
isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
}
// Clear existing expenses for this request to avoid duplicates
await DealerCompletionExpense.destroy({ where: { requestId } });
completionData.closedExpenses.forEach((item: any) => {
const amount = Number(item.amount) || 0;
const quantity = Number(item.quantity) || 1;
const baseTotal = amount * quantity;
// Tax calculations (simplified for brevity, matching previous logic)
const gstRate = isNonGst ? 0 : (Number(item.gstRate) || 18);
const totalTaxAmt = baseTotal * (gstRate / 100);
let finalCgstRate = 0, finalCgstAmt = 0, finalSgstRate = 0, finalSgstAmt = 0, finalIgstRate = 0, finalIgstAmt = 0, finalUtgstRate = 0, finalUtgstAmt = 0;
if (!isNonGst) {
if (isIGST) {
finalIgstRate = gstRate;
finalIgstAmt = totalTaxAmt;
} else {
finalCgstRate = gstRate / 2;
finalCgstAmt = totalTaxAmt / 2;
finalSgstRate = gstRate / 2;
finalSgstAmt = totalTaxAmt / 2;
}
}
expenseRows.push({
requestId,
completionId,
description: item.description,
amount,
quantity,
taxableAmt: baseTotal, // Added for Scenario 1 comparison
hsnCode: item.hsnCode || '',
gstRate,
gstAmt: totalTaxAmt,
cgstRate: finalCgstRate,
cgstAmt: finalCgstAmt,
sgstRate: finalSgstRate,
sgstAmt: finalSgstAmt,
igstRate: finalIgstRate,
igstAmt: finalIgstAmt,
utgstRate: finalUtgstRate,
utgstAmt: finalUtgstAmt,
cessRate: Number(item.cessRate) || 0,
cessAmt: Number(item.cessAmt) || 0,
totalAmt: Number(item.totalAmt) || (baseTotal + totalTaxAmt),
isService: !!item.isService,
expenseDate: item.date instanceof Date ? item.date : (item.date ? new Date(item.date) : (completionData.activityCompletionDate || new Date())),
});
});
await DealerCompletionExpense.bulkCreate(expenseRows);
}
// Update budget tracking with closed expenses (Gross and Taxable)
const totalTaxableClosedExpenses = expenseRows.reduce((sum, item) => sum + Number(item.taxableAmt || 0), 0);
await ClaimBudgetTracking.upsert({
requestId,
closedExpenses: completionData.totalClosedExpenses, // Gross amount
taxableClosedExpenses: totalTaxableClosedExpenses, // Taxable amount (for Scenario 1 comparison)
closedExpensesSubmittedAt: new Date(),
budgetStatus: BudgetStatus.CLOSED,
currency: 'INR',
});
// Scenario 1: Unblocking excess budget if actual taxable expenses < total blocked amount
const allInternalOrders = await InternalOrder.findAll({ where: { requestId, status: IOStatus.BLOCKED } });
const totalBlockedAmount = allInternalOrders.reduce((sum, io) => sum + Number(io.ioBlockedAmount || 0), 0);
if (totalTaxableClosedExpenses < totalBlockedAmount) {
const amountToRelease = parseFloat((totalBlockedAmount - totalTaxableClosedExpenses).toFixed(2));
logger.info(`[DealerClaimService] Scenario 1: Actual taxable expenses (₹${totalTaxableClosedExpenses}) < Total blocked (₹${totalBlockedAmount}). Releasing ₹${amountToRelease}.`);
// Release budget from the most recent IO record (or first available)
// In a more complex setup, we might release proportionally, but here we pick the one with enough balance
const ioToRelease = allInternalOrders.sort((a, b) => (b.createdAt as any) - (a.createdAt as any))[0];
if (ioToRelease && amountToRelease > 0) {
try {
const releaseResult = await sapIntegrationService.releaseBudget(
ioToRelease.ioNumber,
amountToRelease,
String((request as any).requestNumber || (request as any).request_number || requestId),
ioToRelease.sapDocumentNumber || undefined
);
if (releaseResult.success) {
logger.info(`[DealerClaimService] Successfully released ₹${amountToRelease} from IO ${ioToRelease.ioNumber}`);
} else {
logger.warn(`[DealerClaimService] SAP release failed: ${releaseResult.error}`);
}
} catch (releaseError) {
logger.error(`[DealerClaimService] Error during budget release:`, releaseError);
}
}
} else if (totalTaxableClosedExpenses > totalBlockedAmount) {
// Scenario 2: Actual taxable expenses > Total blocked amount
const additionalAmountNeeded = parseFloat((totalTaxableClosedExpenses - totalBlockedAmount).toFixed(2));
logger.info(`[DealerClaimService] Scenario 2: Actual taxable expenses (₹${totalTaxableClosedExpenses}) > Total blocked (₹${totalBlockedAmount}). Additional ₹${additionalAmountNeeded} needs to be blocked.`);
// Update DealerClaimDetails with the new total required amount for IO blocking reference
await DealerClaimDetails.update(
{ totalProposedTaxableAmount: totalTaxableClosedExpenses },
{ where: { requestId } }
);
// Signal that re-blocking is needed by updating status back to PROPOSED
await ClaimBudgetTracking.update(
{ budgetStatus: BudgetStatus.PROPOSED },
{ where: { requestId } }
);
}
// Approve Dealer Completion Documents step dynamically (by levelName, not hardcoded step number)
let dealerCompletionLevel = await ApprovalLevel.findOne({
where: {
requestId,
levelName: 'Dealer Completion Documents'
}
});
// Fallback: try to find by levelNumber 4 (new position after removing system steps)
if (!dealerCompletionLevel) {
dealerCompletionLevel = await ApprovalLevel.findOne({
where: { requestId, levelNumber: 4 }
});
}
if (dealerCompletionLevel) {
// Use dealer's completion description if provided, otherwise use default message
const approvalComment = completionData.completionDescription?.trim()
? completionData.completionDescription.trim()
: 'Completion documents submitted';
// Get dealer user ID if not provided - try to find by dealer email from claim details
let actualDealerUserId: string | null = dealerUserId || null;
if (!actualDealerUserId) {
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
if (claimDetails?.dealerEmail) {
const dealerUser = await User.findOne({
where: { email: claimDetails.dealerEmail }
});
actualDealerUserId = dealerUser?.userId || null;
}
}
// Perform the approval action FIRST - only save snapshot if action succeeds
const approvalService = this.getApprovalService();
await approvalService.approveLevel(
dealerCompletionLevel.levelId,
{ action: 'APPROVE', comments: approvalComment },
actualDealerUserId || (request as any).initiatorId || 'system',
{ ipAddress: null, userAgent: null }
);
// Save completion history AFTER approval succeeds (this is the only snapshot needed for dealer completion)
// Use dealer user ID if available, otherwise use initiator ID as fallback
const historyUserId = actualDealerUserId || (request as any).initiatorId || null;
if (!historyUserId) {
logger.warn(`[DealerClaimService] No user ID available for completion history, skipping history save`);
} else {
try {
await this.saveCompletionHistory(
requestId,
dealerCompletionLevel.levelId,
dealerCompletionLevel.levelNumber,
`Completion Submitted: ${approvalComment}`,
historyUserId
);
// Note: We don't save workflow history here - completion history is sufficient
// Workflow history will be saved when the level is approved and moves to next level
} catch (snapshotError) {
// Log error but don't fail the submission - snapshot is for audit, not critical
logger.error(`[DealerClaimService] Failed to save completion history snapshot (non-critical):`, snapshotError);
}
}
}
logger.info(`[DealerClaimService] Completion documents submitted for request: ${requestId}`);
} catch (error) {
logger.error('[DealerClaimService] Error submitting completion documents:', error);
throw error;
}
}
/**
* Update IO details (Step 3 - Department Lead)
* Validates IO number with SAP and blocks budget
*/
/**
* Update IO details and block amount in SAP
* Only stores data when blocking amount > 0
* This method is called when user actually blocks the amount
*/
async updateIODetails(
requestId: string,
ioData: {
ioNumber: string;
ioRemark?: string;
availableBalance?: number;
blockedAmount?: number;
remainingBalance?: number;
},
organizedByUserId?: string
): Promise<void> {
try {
// Ensure blockedAmount is rounded to exactly 2 decimal places from the start
const blockedAmount = ioData.blockedAmount ? parseFloat(ioData.blockedAmount.toFixed(2)) : 0;
// If blocking amount > 0, proceed with SAP integration and blocking
// If blocking amount is 0 but ioNumber is provided, just save the IO details without blocking
if (blockedAmount <= 0) {
// Allow saving IO details (ioNumber only) even without blocking amount
// This is useful when Step 3/Requestor Evaluation is in progress but amount hasn't been blocked yet or for linking IO
if (ioData.ioNumber) {
const organizedBy = organizedByUserId || null;
// Check if an IO record already exists for this request and IO number
// This prevents duplicate 0-amount "provisioned" records when re-saving IO details
const existingIO = await InternalOrder.findOne({
where: {
requestId,
ioNumber: ioData.ioNumber
}
});
if (existingIO) {
// Update existing record with latest remark and organizer info if provided
await existingIO.update({
ioRemark: ioData.ioRemark || existingIO.ioRemark || '',
organizedBy: organizedBy || existingIO.organizedBy || undefined,
organizedAt: new Date(),
});
logger.info(`[DealerClaimService] Existing IO record updated for request: ${requestId}`, {
ioNumber: ioData.ioNumber,
status: existingIO.status
});
return;
}
// Create a new Internal Order record if none exists for this IO and request
await InternalOrder.create({
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,
});
logger.info(`[DealerClaimService] IO provision record created for request: ${requestId}`, {
ioNumber: ioData.ioNumber
});
return; // Exit early - no SAP blocking needed
} else {
throw new Error('Blocked amount must be greater than 0, or ioNumber must be provided');
}
}
// Validate IO number with SAP
const ioValidation = await sapIntegrationService.validateIONumber(ioData.ioNumber);
if (!ioValidation.isValid) {
throw new Error(`Invalid IO number: ${ioValidation.error || 'IO number not found in SAP'}`);
}
// Block budget in SAP
const request = await WorkflowRequest.findByPk(requestId);
const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN';
logger.info(`[DealerClaimService] Blocking budget in SAP:`, {
requestId,
requestNumber,
ioNumber: ioData.ioNumber,
amountToBlock: blockedAmount,
availableBalance: ioData.availableBalance || ioValidation.availableBalance,
});
const blockResult = await sapIntegrationService.blockBudget(
ioData.ioNumber,
blockedAmount,
requestNumber,
`Budget block for claim request ${requestNumber}`
);
if (!blockResult.success) {
throw new Error(`Failed to block budget in SAP: ${blockResult.error}`);
}
const sapReturnedBlockedAmount = blockResult.blockedAmount;
const sapDocumentNumber = blockResult.blockId || undefined;
const availableBalance = parseFloat((ioData.availableBalance || ioValidation.availableBalance).toFixed(2));
// Use the amount we REQUESTED for calculation, unless SAP blocked significantly different amount
const amountDifference = Math.abs(sapReturnedBlockedAmount - blockedAmount);
const useSapAmount = amountDifference > 1.0;
const finalBlockedAmount = useSapAmount ? sapReturnedBlockedAmount : blockedAmount;
// Calculate remaining balance
const calculatedRemainingBalance = parseFloat((availableBalance - finalBlockedAmount).toFixed(2));
const sapRemainingBalance = blockResult.remainingBalance ? parseFloat(blockResult.remainingBalance.toFixed(2)) : 0;
const sapValueIsValid = sapRemainingBalance > 0 &&
sapRemainingBalance <= availableBalance &&
Math.abs(sapRemainingBalance - calculatedRemainingBalance) < 1;
const remainingBalance = sapValueIsValid ? sapRemainingBalance : calculatedRemainingBalance;
const finalRemainingBalance = parseFloat(Math.max(0, remainingBalance).toFixed(2));
// Create new Internal Order record for this block operation (supporting multiple blocks)
const organizedBy = organizedByUserId || null;
await InternalOrder.create({
requestId,
ioNumber: ioData.ioNumber,
ioRemark: ioData.ioRemark || '',
ioAvailableBalance: availableBalance,
ioBlockedAmount: finalBlockedAmount,
ioRemainingBalance: finalRemainingBalance,
organizedBy: organizedBy || undefined,
organizedAt: new Date(),
sapDocumentNumber: sapDocumentNumber || undefined,
status: IOStatus.BLOCKED,
});
// Update budget tracking with TOTAL blocked amount from all records
const allInternalOrders = await InternalOrder.findAll({ where: { requestId, status: IOStatus.BLOCKED } });
const totalBlockedAmount = allInternalOrders.reduce((sum, io) => sum + Number(io.ioBlockedAmount || 0), 0);
await ClaimBudgetTracking.upsert({
requestId,
ioBlockedAmount: totalBlockedAmount,
ioBlockedAt: new Date(),
budgetStatus: BudgetStatus.BLOCKED,
currency: 'INR',
});
logger.info(`[DealerClaimService] Budget block recorded for request: ${requestId}`, {
ioNumber: ioData.ioNumber,
blockedAmount: finalBlockedAmount,
totalBlockedAmount,
sapDocumentNumber,
finalRemainingBalance
});
// Save IO history after successful blocking
// Find the Department Lead IO Approval level (Step 3)
const ioApprovalLevel = await ApprovalLevel.findOne({
where: {
requestId,
levelName: 'Department Lead IO Approval'
}
});
// Fallback: try to find by levelNumber 3
const ioLevel = ioApprovalLevel || await ApprovalLevel.findOne({
where: { requestId, levelNumber: 3 }
});
// Get user ID for history - use organizedBy if it's a UUID, otherwise try to find user
let ioHistoryUserId: string | null = null;
if (ioLevel) {
if (organizedBy) {
// Check if organizedBy is a valid UUID
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (uuidRegex.test(organizedBy)) {
ioHistoryUserId = organizedBy;
} else {
// Try to find user by email or name
const user = await User.findOne({
where: { email: organizedBy }
});
ioHistoryUserId = user?.userId || null;
}
}
// Fallback to initiator if no user found
if (!ioHistoryUserId) {
const request = await WorkflowRequest.findByPk(requestId);
ioHistoryUserId = (request as any)?.initiatorId || null;
}
}
// Save IO history AFTER budget tracking update succeeds (only if ioLevel exists)
if (ioLevel && ioHistoryUserId) {
try {
await this.saveIOHistory(
requestId,
ioLevel.levelId,
ioLevel.levelNumber,
`IO Blocked: ₹${finalBlockedAmount.toFixed(2)} blocked in SAP`,
ioHistoryUserId
);
} catch (snapshotError) {
// Log error but don't fail the IO blocking - snapshot is for audit, not critical
logger.error(`[DealerClaimService] Failed to save IO history snapshot (non-critical):`, snapshotError);
}
} else if (ioLevel && !ioHistoryUserId) {
logger.warn(`[DealerClaimService] No user ID available for IO history, skipping history save`);
}
logger.info(`[DealerClaimService] IO blocked for request: ${requestId}`, {
ioNumber: ioData.ioNumber,
blockedAmount: finalBlockedAmount,
availableBalance,
remainingBalance: finalRemainingBalance
});
} catch (error) {
logger.error('[DealerClaimService] Error blocking IO:', error);
throw error;
}
}
/**
* Update e-invoice details (Step 7)
* Generates e-invoice via DMS integration
*/
async updateEInvoiceDetails(
requestId: string,
invoiceData?: {
eInvoiceNumber?: string;
eInvoiceDate?: Date;
dmsNumber?: string;
amount?: number;
description?: string;
}
): Promise<void> {
try {
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 PWC E-Invoice service
if (!invoiceData?.eInvoiceNumber) {
const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } });
const invoiceAmount = invoiceData?.amount
|| proposalDetails?.totalEstimatedBudget
|| budgetTracking?.proposalEstimatedBudget
|| budgetTracking?.initialEstimatedBudget
|| 0;
// Generate custom invoice number based on specific format: INDC + DealerCode + AB + Sequence
// Format: INDC[DealerCode]AB[Sequence] (e.g., INDC004597AB0001)
logger.info(`[DealerClaimService] Generating custom invoice number for dealer: ${claimDetails.dealerCode}`);
const customInvoiceNumber = await this.generateCustomInvoiceNumber(claimDetails.dealerCode);
logger.info(`[DealerClaimService] Generated custom invoice number: ${customInvoiceNumber} for request: ${requestId}`);
const invoiceResult = await pwcIntegrationService.generateSignedInvoice(requestId, invoiceAmount, customInvoiceNumber);
logger.info(`[DealerClaimService] PWC Generation Result: Success=${invoiceResult.success}, AckNo=${invoiceResult.ackNo}, SignedInv present=${!!invoiceResult.signedInvoice}`);
if (!invoiceResult.success) {
throw new Error(`Failed to generate signed e-invoice via PWC: ${invoiceResult.error}`);
}
await ClaimInvoice.upsert({
requestId,
invoiceNumber: customInvoiceNumber, // Use custom invoice number as primary identifier
invoiceDate: invoiceResult.ackDate instanceof Date ? invoiceResult.ackDate : (invoiceResult.ackDate ? new Date(invoiceResult.ackDate) : new Date()),
irn: invoiceResult.irn,
ackNo: invoiceResult.ackNo,
ackDate: invoiceResult.ackDate instanceof Date ? invoiceResult.ackDate : (invoiceResult.ackDate ? new Date(invoiceResult.ackDate) : null),
signedInvoice: invoiceResult.signedInvoice,
qrCode: invoiceResult.qrCode,
qrImage: invoiceResult.qrImage,
pwcResponse: invoiceResult.rawResponse,
irpResponse: invoiceResult.irpResponse,
amount: invoiceAmount,
taxableValue: invoiceResult.totalAssAmt,
igstTotal: invoiceResult.totalIgstAmt,
cgstTotal: invoiceResult.totalCgstAmt,
sgstTotal: invoiceResult.totalSgstAmt,
status: 'GENERATED',
generatedAt: new Date(),
description: invoiceData?.description || `PWC Signed Invoice for claim request ${requestNumber}`,
});
logger.info(`[DealerClaimService] Signed Invoice generated via PWC for request: ${requestId}`, {
ackNo: invoiceResult.ackNo,
irn: invoiceResult.irn
});
} else {
// Manual entry - just update the fields
await ClaimInvoice.upsert({
requestId,
invoiceNumber: invoiceData.eInvoiceNumber,
invoiceDate: invoiceData.eInvoiceDate || new Date(),
dmsNumber: invoiceData.dmsNumber,
amount: Number(invoiceData.amount) || 0,
status: 'UPDATED',
generatedAt: new Date(),
description: invoiceData.description,
});
logger.info(`[DealerClaimService] E-Invoice details manually updated for request: ${requestId}`);
}
// Generate CSV for WFM system (INCOMING\WFM_MAIN\DLR_INC_CLAIMS)
await this.pushWFMCSV(requestId).catch((err: Error) => {
logger.error('[DealerClaimService] Initial WFM push failed:', err);
});
// Generate PDF Invoice
try {
const { pdfService } = require('./pdf.service');
await pdfService.generateInvoicePdf(requestId);
} catch (error) {
logger.error(`[DealerClaimService] Failed to generate PDF for request ${requestId}:`, error);
// Don't throw, we still want to proceed with auto-approval
}
// 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 E-Invoice generation
if (requestorClaimLevel && request.currentLevel !== requestorClaimLevel.levelNumber) {
throw new Error(`Cannot generate E-Invoice. Request is currently at step ${request.currentLevel}, but Requestor Claim Approval is at step ${requestorClaimLevel.levelNumber}. Please complete all previous steps first.`);
}
// E-Invoice Generation is successful - auto-approve the Requestor Claim Approval step
if (requestorClaimLevel && requestorClaimLevel.status !== 'APPROVED') {
const approvalService = this.getApprovalService();
await approvalService.approveLevel(
requestorClaimLevel.levelId,
{ action: 'APPROVE', comments: 'Auto-approved after successful E-Invoice generation' },
'system'
);
logger.info(`[DealerClaimService] Step "${requestorClaimLevel.levelName}" auto-approved after E-Invoice generation for request ${requestId}`);
}
// 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 PWC integration for request ${requestNumber}. Step "${requestorClaimLevel?.levelName || 'Requestor Claim Approval'}" auto-approved.`,
});
} 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 PWC service
*/
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 PWC. 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: Number(creditNoteResult.creditNoteAmount) || 0,
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: Number(creditNoteData.creditNoteAmount) || 0,
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;
}
}
/**
* Genetate Custom Invoice Number
* Format: INDC - DEALER CODE - AB (For FY) - sequence nos.
* Sample: INDC004597AB0001
*/
private async generateCustomInvoiceNumber(dealerCode: string): Promise<string> {
const fyCode = 'AB'; // Hardcoded FY code as per requirement
// Ensure dealer code is padded/truncated if needed to fit length constraints, but requirement says "004597" which is 6 digits.
// Assuming dealerCode is already correct length or we use it as is.
const cleanDealerCode = (dealerCode || '000000').trim();
const prefix = `INDC${cleanDealerCode}${fyCode}`;
// Find last invoice with this prefix
const lastInvoice = await ClaimInvoice.findOne({
where: {
invoiceNumber: {
[Op.like]: `${prefix}%`
}
},
order: [
[sequelize.fn('LENGTH', sequelize.col('invoice_number')), 'DESC'],
['invoice_number', 'DESC']
]
});
let sequence = 1;
if (lastInvoice && lastInvoice.invoiceNumber) {
// Extract the sequence part (last 4 digits)
const lastSeqStr = lastInvoice.invoiceNumber.replace(prefix, '');
const lastSeq = parseInt(lastSeqStr, 10);
if (!isNaN(lastSeq)) {
sequence = lastSeq + 1;
}
}
// Pad sequence to 4 digits
const sequenceStr = sequence.toString().padStart(4, '0');
return `${prefix}${sequenceStr}`;
}
/**
* 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,
});
} catch (error) {
logger.error('[DealerClaimService] Error sending credit note to dealer:', error);
throw error;
}
}
/**
* Process Activity Creation (now activity log only, not an approval step)
* Creates activity confirmation and sends emails to dealer, requestor, and department lead
* Logs activity instead of creating/approving approval level
*/
async processActivityCreation(requestId: string): Promise<void> {
try {
logger.info(`[DealerClaimService] Processing Activity Creation for request ${requestId}`);
// Get workflow request
const request = await WorkflowRequest.findByPk(requestId);
if (!request) {
throw new Error(`Workflow request ${requestId} not found`);
}
// Verify this is a claim management workflow
const workflowType = (request as any).workflowType;
if (workflowType !== 'CLAIM_MANAGEMENT') {
logger.warn(`[DealerClaimService] Skipping Activity Creation - not a claim management workflow (type: ${workflowType})`);
return;
}
// Get claim details
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
if (!claimDetails) {
throw new Error(`Claim details not found for request ${requestId}`);
}
// Get participants for email notifications
const initiator = await User.findByPk((request as any).initiatorId);
const dealerUser = claimDetails.dealerEmail
? await User.findOne({ where: { email: claimDetails.dealerEmail } })
: null;
// Get department lead dynamically (by levelName, not hardcoded step number)
let deptLeadLevel = await ApprovalLevel.findOne({
where: {
requestId,
levelName: 'Department Lead Approval'
}
});
// Fallback: try to find by levelNumber 3 (for backwards compatibility)
if (!deptLeadLevel) {
deptLeadLevel = await ApprovalLevel.findOne({
where: {
requestId,
levelNumber: 3
}
});
}
const departmentLead = deptLeadLevel?.approverId
? await User.findByPk(deptLeadLevel.approverId)
: null;
const requestNumber = (request as any).requestNumber || (request as any).request_number || 'UNKNOWN';
const activityName = claimDetails.activityName || 'Activity';
const activityType = claimDetails.activityType || 'N/A';
// Prepare email recipients
const emailRecipients: string[] = [];
const userIdsForNotification: string[] = [];
// Add initiator
if (initiator) {
emailRecipients.push(initiator.email);
userIdsForNotification.push(initiator.userId);
}
// Add dealer
if (dealerUser) {
emailRecipients.push(dealerUser.email);
userIdsForNotification.push(dealerUser.userId);
} else if (claimDetails.dealerEmail) {
emailRecipients.push(claimDetails.dealerEmail);
}
// Add department lead
if (departmentLead) {
emailRecipients.push(departmentLead.email);
userIdsForNotification.push(departmentLead.userId);
}
// Send activity confirmation emails
const emailSubject = `Activity Created: ${activityName} - ${requestNumber}`;
const emailBody = `Activity "${activityName}" (${activityType}) has been created successfully for request ${requestNumber}. IO confirmation to be made.`;
// Send notifications to users in the system with proper metadata
if (userIdsForNotification.length > 0) {
// Prepare metadata for activity created email template
const activityData = {
activityName: activityName,
activityType: activityType,
activityDate: claimDetails.activityDate,
location: claimDetails.location || 'Not specified',
dealerName: claimDetails.dealerName || 'Dealer',
dealerCode: claimDetails.dealerCode,
initiatorName: initiator ? (initiator.displayName || initiator.email) : 'Initiator',
departmentLeadName: departmentLead ? (departmentLead.displayName || departmentLead.email) : undefined,
ioNumber: undefined, // IO number will be added later when IO is created
nextSteps: 'IO confirmation to be made. Dealer will proceed with activity execution and submit completion documents.'
};
await notificationService.sendToUsers(userIdsForNotification, {
title: emailSubject,
body: emailBody,
requestId,
requestNumber,
url: `/request/${requestNumber}`,
type: 'activity_created',
priority: 'MEDIUM',
actionRequired: false,
metadata: {
activityData: activityData
}
});
}
// Log Activity Creation as activity (no approval level needed)
await activityService.log({
requestId,
type: 'status_change',
user: { userId: 'system', name: 'System Auto-Process' },
timestamp: new Date().toISOString(),
action: 'Activity Created',
details: `Activity "${activityName}" created. Activity confirmation email auto-triggered to dealer, requestor, and department lead. IO confirmation to be made.`,
});
logger.info(`[DealerClaimService] Activity Creation logged as activity for request ${requestId}. Activity creation completed.`);
} catch (error) {
logger.error(`[DealerClaimService] Error processing Step 4 activity creation for request ${requestId}:`, error);
throw error;
}
}
/**
* Snapshot current claim state for version history before revisions
*/
/**
* Save proposal version history (Step 1)
*/
async saveProposalHistory(
requestId: string,
approvalLevelId: string,
levelNumber: number,
changeReason: string,
userId: string
): Promise<void> {
try {
const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } });
if (!proposalDetails) {
logger.warn(`[DealerClaimService] No proposal found for request ${requestId}, skipping history`);
return;
}
const costItems = await DealerProposalCostItem.findAll({
where: { proposalId: (proposalDetails as any).proposalId || (proposalDetails as any).proposal_id }
});
// Get level name from approval level
const level = await ApprovalLevel.findByPk(approvalLevelId);
const levelName = level?.levelName || undefined;
// Get next version for this level (match by levelName for consistency)
const lastVersion = await DealerClaimHistory.findOne({
where: levelName ? {
requestId,
levelName,
snapshotType: SnapshotType.PROPOSAL
} : {
requestId,
levelNumber,
snapshotType: SnapshotType.PROPOSAL
},
order: [['version', 'DESC']]
});
const nextVersion = lastVersion ? lastVersion.version + 1 : 1;
// Store all proposal data in JSONB
// Handle expectedCompletionDate - it might be a Date object, string, or null
let expectedCompletionDateStr = null;
if (proposalDetails.expectedCompletionDate) {
if (proposalDetails.expectedCompletionDate instanceof Date) {
expectedCompletionDateStr = proposalDetails.expectedCompletionDate.toISOString();
} else if (typeof proposalDetails.expectedCompletionDate === 'string') {
expectedCompletionDateStr = proposalDetails.expectedCompletionDate;
}
}
// Fetch supporting documents
const supportingDocs = await Document.findAll({
where: {
requestId,
category: 'SUPPORTING',
isDeleted: false
},
order: [['uploadedAt', 'DESC']]
});
const snapshotData = {
documentUrl: proposalDetails.proposalDocumentUrl,
totalBudget: Number(proposalDetails.totalEstimatedBudget || 0),
comments: proposalDetails.dealerComments,
expectedCompletionDate: expectedCompletionDateStr,
costItems: costItems.map(i => ({
description: i.itemDescription,
amount: Number(i.amount || 0),
quantity: Number(i.quantity || 1),
hsnCode: i.hsnCode || '',
gstRate: Number(i.gstRate || 0),
gstAmt: Number(i.gstAmt || 0),
cgstRate: Number(i.cgstRate || 0),
cgstAmt: Number(i.cgstAmt || 0),
sgstRate: Number(i.sgstRate || 0),
sgstAmt: Number(i.sgstAmt || 0),
igstRate: Number(i.igstRate || 0),
igstAmt: Number(i.igstAmt || 0),
utgstRate: Number(i.utgstRate || 0),
utgstAmt: Number(i.utgstAmt || 0),
cessRate: Number(i.cessRate || 0),
cessAmt: Number(i.cessAmt || 0),
totalAmt: Number(i.totalAmt || 0),
isService: !!i.isService,
order: i.itemOrder
})),
otherDocuments: supportingDocs.map(doc => ({
documentId: doc.documentId,
fileName: doc.fileName,
originalFileName: doc.originalFileName,
storageUrl: doc.storageUrl,
uploadedAt: doc.uploadedAt
}))
};
await DealerClaimHistory.create({
requestId,
approvalLevelId,
levelNumber,
levelName,
version: nextVersion,
snapshotType: SnapshotType.PROPOSAL,
snapshotData,
changeReason,
changedBy: userId
});
logger.info(`[DealerClaimService] Saved proposal history (v${nextVersion}) for level ${levelNumber}, request ${requestId}`);
} catch (error) {
logger.error(`[DealerClaimService] Error saving proposal history for request ${requestId}:`, error);
}
}
/**
* Save completion version history (Step 4/5)
*/
async saveCompletionHistory(
requestId: string,
approvalLevelId: string,
levelNumber: number,
changeReason: string,
userId: string
): Promise<void> {
try {
const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId } });
if (!completionDetails) {
logger.warn(`[DealerClaimService] No completion found for request ${requestId}, skipping history`);
return;
}
const expenses = await DealerCompletionExpense.findAll({ where: { requestId } });
// Get level name from approval level
const level = await ApprovalLevel.findByPk(approvalLevelId);
const levelName = level?.levelName || undefined;
// Get next version for this level (match by levelName for consistency)
const lastVersion = await DealerClaimHistory.findOne({
where: levelName ? {
requestId,
levelName,
snapshotType: SnapshotType.COMPLETION
} : {
requestId,
levelNumber,
snapshotType: SnapshotType.COMPLETION
},
order: [['version', 'DESC']]
});
const nextVersion = lastVersion ? lastVersion.version + 1 : 1;
// Fetch supporting documents for completion
const supportingDocs = await Document.findAll({
where: {
requestId,
category: 'SUPPORTING',
isDeleted: false
},
order: [['uploadedAt', 'DESC']]
});
// Store all completion data in JSONB
const snapshotData = {
documentUrl: (completionDetails as any).completionDocumentUrl || null,
totalExpenses: Number(completionDetails.totalClosedExpenses || 0),
comments: (completionDetails as any).completionDescription || null,
expenses: expenses.map(e => ({
description: e.description,
amount: Number(e.amount || 0)
})),
otherDocuments: supportingDocs.map(doc => ({
documentId: doc.documentId,
fileName: doc.fileName,
originalFileName: doc.originalFileName,
storageUrl: doc.storageUrl,
uploadedAt: doc.uploadedAt
}))
};
await DealerClaimHistory.create({
requestId,
approvalLevelId,
levelNumber,
levelName,
version: nextVersion,
snapshotType: SnapshotType.COMPLETION,
snapshotData,
changeReason,
changedBy: userId
});
logger.info(`[DealerClaimService] Saved completion history (v${nextVersion}) for level ${levelNumber}, request ${requestId}`);
} catch (error) {
logger.error(`[DealerClaimService] Error saving completion history for request ${requestId}:`, error);
}
}
/**
* Save internal order version history
*/
async saveIOHistory(
requestId: string,
approvalLevelId: string,
levelNumber: number,
changeReason: string,
userId: string
): Promise<void> {
try {
const internalOrder = await InternalOrder.findOne({ where: { requestId } });
if (!internalOrder || !internalOrder.ioBlockedAmount || internalOrder.ioBlockedAmount <= 0) {
logger.warn(`[DealerClaimService] No IO block found for request ${requestId}, skipping history`);
return;
}
// Get level name from approval level
const level = await ApprovalLevel.findByPk(approvalLevelId);
const levelName = level?.levelName || undefined;
// Get next version for this level (match by levelName for consistency)
const lastVersion = await DealerClaimHistory.findOne({
where: levelName ? {
requestId,
levelName,
snapshotType: SnapshotType.INTERNAL_ORDER
} : {
requestId,
levelNumber,
snapshotType: SnapshotType.INTERNAL_ORDER
},
order: [['version', 'DESC']]
});
const nextVersion = lastVersion ? lastVersion.version + 1 : 1;
// Store all IO data in JSONB
const snapshotData = {
ioNumber: internalOrder.ioNumber,
blockedAmount: Number(internalOrder.ioBlockedAmount || 0),
availableBalance: Number(internalOrder.ioAvailableBalance || 0),
remainingBalance: Number(internalOrder.ioRemainingBalance || 0),
sapDocumentNumber: internalOrder.sapDocumentNumber
};
await DealerClaimHistory.create({
requestId,
approvalLevelId,
levelNumber,
levelName,
version: nextVersion,
snapshotType: SnapshotType.INTERNAL_ORDER,
snapshotData,
changeReason,
changedBy: userId
});
logger.info(`[DealerClaimService] Saved IO history (v${nextVersion}) for level ${levelNumber}, request ${requestId}`);
} catch (error) {
logger.error(`[DealerClaimService] Error saving IO history for request ${requestId}:`, error);
}
}
/**
* Save approval version history (for approver actions)
*/
async saveApprovalHistory(
requestId: string,
approvalLevelId: string,
levelNumber: number,
action: 'APPROVE' | 'REJECT',
comments: string,
rejectionReason: string | undefined,
userId: string
): Promise<void> {
try {
const level = await ApprovalLevel.findByPk(approvalLevelId);
if (!level) {
logger.warn(`[DealerClaimService] No approval level found for ${approvalLevelId}, skipping history`);
return;
}
// Get next version for this level (match by levelName for consistency)
const lastVersion = await DealerClaimHistory.findOne({
where: level.levelName ? {
requestId,
levelName: level.levelName,
snapshotType: SnapshotType.APPROVE
} : {
requestId,
levelNumber,
snapshotType: SnapshotType.APPROVE
},
order: [['version', 'DESC']]
});
const nextVersion = lastVersion ? lastVersion.version + 1 : 1;
// Store approval data in JSONB
const snapshotData = {
action,
comments: comments || undefined,
rejectionReason: rejectionReason || undefined,
approverName: level.approverName,
approverEmail: level.approverEmail,
levelName: level.levelName
};
// Build changeReason - will be updated later if moving to next level
// For now, just include the basic approval/rejection info
let changeReason = action === 'APPROVE'
? `Approved by ${level.approverName || level.approverEmail}`
: `Rejected by ${level.approverName || level.approverEmail}`;
if (action === 'REJECT' && (rejectionReason || comments)) {
changeReason += `. Reason: ${rejectionReason || comments}`;
}
await DealerClaimHistory.create({
requestId,
approvalLevelId,
levelNumber,
levelName: level.levelName || undefined,
version: nextVersion,
snapshotType: SnapshotType.APPROVE,
snapshotData,
changeReason,
changedBy: userId
});
logger.info(`[DealerClaimService] Saved approval history (v${nextVersion}) for level ${levelNumber}, request ${requestId}`);
} catch (error) {
logger.error(`[DealerClaimService] Error saving approval history for request ${requestId}:`, error);
}
}
/**
* Save workflow-level version history (for actions that move workflow forward/backward)
*/
async saveWorkflowHistory(
requestId: string,
changeReason: string,
userId: string,
approvalLevelId?: string,
levelNumber?: number,
levelName?: string,
approvalComment?: string
): Promise<void> {
try {
const wf = await WorkflowRequest.findByPk(requestId);
if (!wf) return;
// Get next version for workflow-level snapshots PER LEVEL
// Each level should have its own version numbering starting from 1
// Filter by levelName or levelNumber to get versions for this specific level
const lastVersion = await DealerClaimHistory.findOne({
where: levelName ? {
requestId,
levelName,
snapshotType: SnapshotType.WORKFLOW
} : levelNumber !== undefined ? {
requestId,
levelNumber,
snapshotType: SnapshotType.WORKFLOW
} : {
requestId,
snapshotType: SnapshotType.WORKFLOW
},
order: [['version', 'DESC']]
});
const nextVersion = lastVersion ? lastVersion.version + 1 : 1;
// Store workflow data in JSONB
// Include level information for version tracking and comparison
// Include approval comment if provided (for approval actions)
const snapshotData: any = {
status: wf.status,
currentLevel: wf.currentLevel,
// Include level info in snapshotData for completeness and version tracking
approvalLevelId: approvalLevelId || undefined,
levelNumber: levelNumber || undefined,
levelName: levelName || undefined
};
// Add approval comment to snapshotData if provided
if (approvalComment) {
snapshotData.comments = approvalComment;
}
await DealerClaimHistory.create({
requestId,
approvalLevelId: approvalLevelId || undefined,
levelNumber: levelNumber || undefined,
levelName: levelName || undefined,
version: nextVersion,
snapshotType: SnapshotType.WORKFLOW,
snapshotData,
changeReason,
changedBy: userId
});
logger.info(`[DealerClaimService] Saved workflow history (v${nextVersion}) for request ${requestId}, level ${levelNumber || 'N/A'}`);
} catch (error) {
logger.error(`[DealerClaimService] Error saving workflow history for request ${requestId}:`, error);
}
}
/**
* Create or activate initiator action level when request is rejected
* This allows initiator to take action (REVISE, CANCEL, REOPEN) directly from the step card
*/
async createOrActivateInitiatorLevel(
requestId: string,
userId: string
): Promise<ApprovalLevel | null> {
try {
const wf = await WorkflowRequest.findByPk(requestId);
if (!wf) return null;
// Check if initiator level already exists
let initiatorLevel = await ApprovalLevel.findOne({
where: {
requestId,
levelName: 'Initiator Action'
}
});
if (initiatorLevel) {
// Activate existing level
await initiatorLevel.update({
status: ApprovalStatus.IN_PROGRESS,
levelStartTime: new Date(),
tatStartTime: new Date(),
approverId: wf.initiatorId
});
return initiatorLevel;
}
// Create new initiator level
// Find the highest level number to place it after
const maxLevel = await ApprovalLevel.findOne({
where: { requestId },
order: [['levelNumber', 'DESC']]
});
const nextLevelNumber = maxLevel ? maxLevel.levelNumber + 1 : 0;
// Get initiator user details
const initiatorUser = await User.findByPk(wf.initiatorId);
if (!initiatorUser) {
throw new Error('Initiator user not found');
}
initiatorLevel = await ApprovalLevel.create({
requestId,
levelNumber: nextLevelNumber,
levelName: 'Initiator Action',
approverId: wf.initiatorId,
approverEmail: initiatorUser.email || '',
approverName: initiatorUser.displayName || initiatorUser.email || 'Initiator',
status: ApprovalStatus.IN_PROGRESS,
levelStartTime: new Date(),
tatStartTime: new Date(),
tatHours: 0, // No TAT for initiator action
elapsedHours: 0,
remainingHours: 0,
tatPercentageUsed: 0,
isFinalApprover: false
} as any);
logger.info(`[DealerClaimService] Created/activated initiator level for request ${requestId}`);
return initiatorLevel;
} catch (error) {
logger.error(`[DealerClaimService] Error creating/activating initiator level:`, error);
return null;
}
}
/**
* @deprecated - Removed complex snapshot method. Snapshots are now taken at step execution.
*/
async saveCompleteRevisionSnapshot_DEPRECATED(
requestId: string,
changeReason: string,
userId: string
): Promise<void> {
try {
logger.info(`[DealerClaimService] Capturing complete revision snapshot for request ${requestId}`);
// 1. Capture current proposal snapshot (if exists)
const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } });
if (proposalDetails) {
const costItems = await DealerProposalCostItem.findAll({
where: { proposalId: (proposalDetails as any).proposalId || (proposalDetails as any).proposal_id }
});
// Find dealer proposal level
const dealerLevel = await ApprovalLevel.findOne({
where: {
requestId,
levelName: 'Dealer Proposal Submission'
}
}) || await ApprovalLevel.findOne({
where: { requestId, levelNumber: 1 }
});
if (dealerLevel) {
const proposalSnapshotData = {
documentUrl: proposalDetails.proposalDocumentUrl,
totalBudget: Number(proposalDetails.totalEstimatedBudget || 0),
comments: proposalDetails.dealerComments,
expectedCompletionDate: proposalDetails.expectedCompletionDate ? proposalDetails.expectedCompletionDate.toISOString() : null,
costItems: costItems.map(i => ({
description: i.itemDescription,
amount: Number(i.amount || 0),
order: i.itemOrder
}))
};
// Get next version for this level
const lastProposalVersion = await DealerClaimHistory.findOne({
where: {
requestId,
levelName: dealerLevel.levelName || undefined,
snapshotType: SnapshotType.PROPOSAL
},
order: [['version', 'DESC']]
});
const nextProposalVersion = lastProposalVersion ? lastProposalVersion.version + 1 : 1;
await DealerClaimHistory.create({
requestId,
approvalLevelId: dealerLevel.levelId,
levelNumber: dealerLevel.levelNumber,
levelName: dealerLevel.levelName || undefined,
version: nextProposalVersion,
snapshotType: SnapshotType.PROPOSAL,
snapshotData: proposalSnapshotData,
changeReason: `${changeReason} - Pre-revision snapshot`,
changedBy: userId
});
logger.info(`[DealerClaimService] Captured proposal snapshot (v${nextProposalVersion}) for revision`);
}
}
// 2. Capture current completion snapshot (if exists)
const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId } });
if (completionDetails) {
const expenses = await DealerCompletionExpense.findAll({
where: { completionId: (completionDetails as any).completionId || (completionDetails as any).completion_id }
});
// Find completion level
const completionLevel = await ApprovalLevel.findOne({
where: {
requestId,
levelName: 'Dealer Completion Documents'
}
}) || await ApprovalLevel.findOne({
where: { requestId, levelNumber: 4 }
});
if (completionLevel) {
const completionSnapshotData = {
documentUrl: (completionDetails as any).completionDocumentUrl || null,
totalExpenses: Number(completionDetails.totalClosedExpenses || 0),
comments: (completionDetails as any).completionDescription || null,
expenses: expenses.map(e => ({
description: e.description,
amount: Number(e.amount || 0)
}))
};
// Get next version for this level
const lastCompletionVersion = await DealerClaimHistory.findOne({
where: {
requestId,
levelName: completionLevel.levelName || undefined,
snapshotType: SnapshotType.COMPLETION
},
order: [['version', 'DESC']]
});
const nextCompletionVersion = lastCompletionVersion ? lastCompletionVersion.version + 1 : 1;
await DealerClaimHistory.create({
requestId,
approvalLevelId: completionLevel.levelId,
levelNumber: completionLevel.levelNumber,
levelName: completionLevel.levelName || undefined,
version: nextCompletionVersion,
snapshotType: SnapshotType.COMPLETION,
snapshotData: completionSnapshotData,
changeReason: `${changeReason} - Pre-revision snapshot`,
changedBy: userId
});
logger.info(`[DealerClaimService] Captured completion snapshot (v${nextCompletionVersion}) for revision`);
}
}
// 3. Capture current IO snapshot (if exists)
const internalOrder = await InternalOrder.findOne({ where: { requestId } });
if (internalOrder && internalOrder.ioBlockedAmount && internalOrder.ioBlockedAmount > 0) {
const ioLevel = await ApprovalLevel.findOne({
where: {
requestId,
levelName: 'Department Lead IO Approval'
}
}) || await ApprovalLevel.findOne({
where: { requestId, levelNumber: 3 }
});
if (ioLevel) {
const ioSnapshotData = {
ioNumber: internalOrder.ioNumber,
blockedAmount: Number(internalOrder.ioBlockedAmount || 0),
availableBalance: Number(internalOrder.ioAvailableBalance || 0),
remainingBalance: Number(internalOrder.ioRemainingBalance || 0),
sapDocumentNumber: internalOrder.sapDocumentNumber
};
// Get next version for this level
const lastIOVersion = await DealerClaimHistory.findOne({
where: {
requestId,
levelName: ioLevel.levelName || undefined,
snapshotType: SnapshotType.INTERNAL_ORDER
},
order: [['version', 'DESC']]
});
const nextIOVersion = lastIOVersion ? lastIOVersion.version + 1 : 1;
await DealerClaimHistory.create({
requestId,
approvalLevelId: ioLevel.levelId,
levelNumber: ioLevel.levelNumber,
levelName: ioLevel.levelName || undefined,
version: nextIOVersion,
snapshotType: SnapshotType.INTERNAL_ORDER,
snapshotData: ioSnapshotData,
changeReason: `${changeReason} - Pre-revision snapshot`,
changedBy: userId
});
logger.info(`[DealerClaimService] Captured IO snapshot (v${nextIOVersion}) for revision`);
}
}
// 4. Capture ALL approval comments from all levels (so approvers can see their previous comments)
const allLevels = await ApprovalLevel.findAll({
where: { requestId },
order: [['levelNumber', 'ASC']]
});
for (const level of allLevels) {
// Only capture if level has been acted upon (has comments or action date)
if (level.comments || level.actionDate || level.status === ApprovalStatus.APPROVED || level.status === ApprovalStatus.REJECTED) {
const approver = level.approverId ? await User.findByPk(level.approverId) : null;
const approvalSnapshotData = {
action: level.status === ApprovalStatus.APPROVED ? 'APPROVE' : level.status === ApprovalStatus.REJECTED ? 'REJECT' : 'PENDING',
comments: level.comments || undefined,
rejectionReason: level.status === ApprovalStatus.REJECTED ? (level.comments || undefined) : undefined,
approverName: approver?.displayName || approver?.email || undefined,
approverEmail: approver?.email || undefined,
levelName: level.levelName || undefined
};
// Get next version for this level's approval snapshot
const lastApprovalVersion = await DealerClaimHistory.findOne({
where: {
requestId,
levelName: level.levelName || undefined,
snapshotType: SnapshotType.APPROVE
},
order: [['version', 'DESC']]
});
const nextApprovalVersion = lastApprovalVersion ? lastApprovalVersion.version + 1 : 1;
await DealerClaimHistory.create({
requestId,
approvalLevelId: level.levelId,
levelNumber: level.levelNumber,
levelName: level.levelName || undefined,
version: nextApprovalVersion,
snapshotType: SnapshotType.APPROVE,
snapshotData: approvalSnapshotData,
changeReason: `${changeReason} - Pre-revision approval snapshot`,
changedBy: userId
});
logger.info(`[DealerClaimService] Captured approval snapshot (v${nextApprovalVersion}) for level ${level.levelNumber} (${level.levelName})`);
}
}
// 5. Save workflow-level snapshot
const wf = await WorkflowRequest.findByPk(requestId);
if (wf) {
const lastWorkflowVersion = await DealerClaimHistory.findOne({
where: {
requestId,
snapshotType: SnapshotType.WORKFLOW
},
order: [['version', 'DESC']]
});
const nextWorkflowVersion = lastWorkflowVersion ? lastWorkflowVersion.version + 1 : 1;
await DealerClaimHistory.create({
requestId,
version: nextWorkflowVersion,
snapshotType: SnapshotType.WORKFLOW,
snapshotData: {
status: wf.status,
currentLevel: wf.currentLevel
},
changeReason: `${changeReason} - Pre-revision workflow snapshot`,
changedBy: userId
});
logger.info(`[DealerClaimService] Captured workflow snapshot (v${nextWorkflowVersion}) for revision`);
}
logger.info(`[DealerClaimService] Complete revision snapshot captured for request ${requestId}`);
} catch (error) {
logger.error(`[DealerClaimService] Error saving complete revision snapshot for request ${requestId}:`, error);
// Don't throw - we want to continue even if snapshot fails
}
}
/**
* Handle initiator actions when a request is in RETURNED status
*/
async handleInitiatorAction(
requestId: string,
userId: string,
action: 'REOPEN' | 'DISCUSS' | 'REVISE' | 'CANCEL',
data?: { reason: string }
): Promise<void> {
const wf = await WorkflowRequest.findByPk(requestId);
if (!wf) throw new Error('Request not found');
// Check if the current user is the initiator
if (wf.initiatorId !== userId) {
throw new Error('Only the initiator can perform actions on a rejected/returned request');
}
// A returned request is REJECTED but has NO closureDate
if (wf.status !== WorkflowStatus.REJECTED || wf.closureDate) {
throw new Error(`Request is in ${wf.status} status (Closed: ${!!wf.closureDate}), expected an open REJECTED state to perform this action`);
}
const initiator = await User.findByPk(userId);
const initiatorName = initiator?.displayName || initiator?.email || 'Initiator';
const now = new Date();
switch (action) {
case 'CANCEL': {
// Format change reason to include the comment if provided
const changeReason = data?.reason && data.reason.trim()
? `Request Cancelled: ${data.reason.trim()}`
: 'Request Cancelled';
// Find current level for workflow history
const currentLevel = await ApprovalLevel.findOne({
where: { requestId, levelNumber: wf.currentLevel || 1 }
});
await wf.update({
status: WorkflowStatus.CLOSED,
closureDate: now
});
await activityService.log({
requestId,
type: 'status_change',
user: { userId, name: initiatorName },
timestamp: now.toISOString(),
action: 'Request Cancelled',
details: data?.reason && data.reason.trim()
? `Request was cancelled by initiator. Reason: ${data.reason.trim()}`
: 'Request was cancelled by initiator.'
});
break;
}
case 'REOPEN': {
// Format change reason to include the comment if provided
const changeReason = data?.reason && data.reason.trim()
? `Request Reopened: ${data.reason.trim()}`
: 'Request Reopened';
// Find Department Lead level dynamically (handles step shifts)
const approvalsReopen = await ApprovalLevel.findAll({ where: { requestId } });
const deptLeadLevel = approvalsReopen.find(l => {
const name = (l.levelName || '').toLowerCase();
return name.includes('department lead') || name.includes('dept lead') || l.levelNumber === 3;
});
if (!deptLeadLevel) {
throw new Error('Department Lead approval level not found for this request');
}
const deptLeadLevelNumber = deptLeadLevel.levelNumber;
// Move back to Department Lead Approval level FIRST
await wf.update({
status: WorkflowStatus.PENDING,
currentLevel: deptLeadLevelNumber
});
// Capture workflow snapshot AFTER workflow update succeeds
try {
await this.saveWorkflowHistory(
requestId,
`Reopened and moved to Department Lead level (${deptLeadLevelNumber}) - ${changeReason}`,
userId,
deptLeadLevel.levelId,
deptLeadLevelNumber,
deptLeadLevel.levelName || undefined
);
} catch (snapshotError) {
// Log error but don't fail the reopen - snapshot is for audit, not essential
logger.error(`[DealerClaimService] Failed to save workflow history snapshot (non-critical):`, snapshotError);
}
// Reset the found level status to IN_PROGRESS so Dept Lead can approve again
await deptLeadLevel.update({
status: ApprovalStatus.IN_PROGRESS,
levelStartTime: now,
tatStartTime: now,
actionDate: undefined,
comments: undefined
});
await activityService.log({
requestId,
type: 'approval',
user: { userId, name: initiatorName },
timestamp: now.toISOString(),
action: 'Request Reopened',
details: data?.reason && data.reason.trim()
? `Initiator reopened the request for Department Lead approval. Reason: ${data.reason.trim()}`
: 'Initiator reopened the request for Department Lead approval.'
});
if (deptLeadLevel.approverId) {
await notificationService.sendToUsers([deptLeadLevel.approverId], {
title: `Request Reopened: ${wf.requestNumber}`,
body: `Initiator has reopened the request "${wf.title}" after revision/discussion.`,
requestNumber: wf.requestNumber,
requestId: wf.requestId,
url: `/request/${wf.requestNumber}`,
type: 'assignment',
priority: 'HIGH',
actionRequired: true
});
}
break;
}
case 'DISCUSS': {
// Format change reason to include the comment if provided
const changeReason = data?.reason && data.reason.trim()
? `Discussion Requested: ${data.reason.trim()}`
: 'Discussion Requested';
// Find Dealer level dynamically
const approvalsDiscuss = await ApprovalLevel.findAll({ where: { requestId } });
const dealerLevelDiscuss = approvalsDiscuss.find(l => {
const name = (l.levelName || '').toLowerCase();
return name.includes('dealer proposal') || l.levelNumber === 1;
});
// Note: DISCUSS action doesn't change workflow state, so no snapshot needed
// The action is logged in activity log only
await activityService.log({
requestId,
type: 'status_change',
user: { userId, name: initiatorName },
timestamp: now.toISOString(),
action: 'Discuss with Dealer',
details: data?.reason && data.reason.trim()
? `Initiator indicated they will discuss with the dealer. Reason: ${data.reason.trim()}`
: 'Initiator indicated they will discuss with the dealer.'
});
if (dealerLevelDiscuss?.approverId) {
await notificationService.sendToUsers([dealerLevelDiscuss.approverId], {
title: `Discussion Requested: ${wf.requestNumber}`,
body: `The initiator of request "${wf.title}" wants to discuss the proposal with you.`,
requestNumber: wf.requestNumber,
requestId: wf.requestId,
url: `/request/${wf.requestNumber}`,
type: 'info',
priority: 'MEDIUM'
});
}
break;
}
case 'REVISE': {
// Format change reason
const changeReason = data?.reason && data.reason.trim()
? `Revision Requested: ${data.reason.trim()}`
: 'Revision Requested';
// Find current level and previous level
const allLevels = await ApprovalLevel.findAll({
where: { requestId },
order: [['levelNumber', 'ASC']]
});
const currentLevelNumber = wf.currentLevel || 1;
const currentLevel = allLevels.find(l => l.levelNumber === currentLevelNumber);
if (!currentLevel) {
throw new Error('Current approval level not found');
}
// Find previous level (the one before current)
const previousLevel = allLevels.find(l => l.levelNumber < currentLevelNumber);
if (!previousLevel) {
throw new Error('No previous level found to revise to');
}
// Move back to previous level FIRST
await wf.update({
status: WorkflowStatus.PENDING,
currentLevel: previousLevel.levelNumber
});
// Capture workflow snapshot AFTER workflow update succeeds
try {
await this.saveWorkflowHistory(
requestId,
`Moved back to previous level (${previousLevel.levelNumber}) - ${changeReason}`,
userId,
previousLevel.levelId,
previousLevel.levelNumber,
previousLevel.levelName || undefined
);
} catch (snapshotError) {
// Log error but don't fail the revise - snapshot is for audit, not essential
logger.error(`[DealerClaimService] Failed to save workflow history snapshot (non-critical):`, snapshotError);
}
// Reset current level to PENDING
await currentLevel.update({
status: ApprovalStatus.PENDING,
actionDate: undefined,
levelStartTime: undefined,
levelEndTime: undefined,
tatStartTime: undefined,
elapsedHours: 0,
tatPercentageUsed: 0,
comments: undefined
});
// Activate previous level
await previousLevel.update({
status: ApprovalStatus.IN_PROGRESS,
levelStartTime: now,
tatStartTime: now,
comments: changeReason, // Save revision reason as comment
actionDate: undefined,
levelEndTime: undefined,
elapsedHours: 0,
tatPercentageUsed: 0
});
await activityService.log({
requestId,
type: 'assignment',
user: { userId, name: initiatorName },
timestamp: now.toISOString(),
action: 'Revision Requested',
details: data?.reason && data.reason.trim()
? `Initiator requested revision. Moving back to previous step. Reason: ${data.reason.trim()}`
: 'Initiator requested revision. Moving back to previous step.'
});
// Notify the approver of the previous level
if (previousLevel.approverId) {
await notificationService.sendToUsers([previousLevel.approverId], {
title: `Revision Required: ${wf.requestNumber}`,
body: `Initiator has requested a revision for request "${wf.title}". The request has been moved back to your level.`,
requestNumber: wf.requestNumber,
requestId: wf.requestId,
url: `/request/${wf.requestNumber}`,
type: 'assignment',
priority: 'HIGH',
actionRequired: true
});
}
break;
}
}
const { emitToRequestRoom } = await import('../realtime/socket');
emitToRequestRoom(requestId, 'request:updated', {
requestId,
requestNumber: wf.requestNumber,
action: `INITIATOR_${action}`,
timestamp: now.toISOString()
});
}
async getHistory(requestId: string): Promise<any[]> {
const history = await DealerClaimHistory.findAll({
where: { requestId },
order: [['version', 'DESC']],
include: [
{
model: User,
as: 'changer',
attributes: ['userId', 'displayName', 'email']
}
]
});
// Map to plain objects and sort otherDocuments in snapshots
return history.map(item => {
const plain = item.get({ plain: true });
if (plain.snapshotData && plain.snapshotData.otherDocuments && Array.isArray(plain.snapshotData.otherDocuments)) {
plain.snapshotData.otherDocuments.sort((a: any, b: any) => {
const dateA = a.uploadedAt ? new Date(a.uploadedAt).getTime() : 0;
const dateB = b.uploadedAt ? new Date(b.uploadedAt).getTime() : 0;
return dateB - dateA;
});
}
return plain;
});
}
/**
* Push CSV to WFM folder and track status
* This is used by both auto-trigger and manual re-trigger
*/
async pushWFMCSV(requestId: string): Promise<void> {
const invoice = await ClaimInvoice.findOne({ where: { requestId } });
if (!invoice) {
throw new Error('Invoice not found');
}
try {
const [invoiceItems, claimDetails, internalOrder] = await Promise.all([
ClaimInvoiceItem.findAll({ where: { requestId } }),
DealerClaimDetails.findOne({ where: { requestId } }),
InternalOrder.findOne({ where: { requestId } })
]);
if (!claimDetails) {
throw new Error('Dealer claim details not found');
}
const requestNumber = (await WorkflowRequest.findByPk(requestId))?.requestNumber || 'UNKNOWN';
if (invoiceItems.length > 0) {
let sapRefNo = '';
let isNonGst = false;
if (claimDetails.activityType) {
const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } });
sapRefNo = activity?.sapRefNo || '';
const taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST');
isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
}
const formatDate = (date: any) => {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}${month}${day}`;
};
const csvData = invoiceItems.map((item: any) => {
const row: any = {
TRNS_UNIQ_NO: item.transactionCode || '',
CLAIM_NUMBER: requestNumber,
INV_NUMBER: invoice.invoiceNumber || '',
DEALER_CODE: claimDetails.dealerCode,
IO_NUMBER: internalOrder?.ioNumber || '',
CLAIM_DOC_TYP: sapRefNo,
CLAIM_TYPE: claimDetails.activityType,
CLAIM_DATE: formatDate(invoice.invoiceDate || new Date()),
CLAIM_AMT: item.assAmt
};
if (!isNonGst) {
const totalTax = Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0);
row.GST_AMT = totalTax.toFixed(2);
row.GST_PERCENTAGE = item.gstRt;
}
return row;
});
await wfmFileService.generateIncomingClaimCSV(csvData, `CN_${claimDetails.dealerCode}_${requestNumber}.csv`, isNonGst);
await invoice.update({
wfmPushStatus: 'SUCCESS',
wfmPushError: null
});
logger.info(`[DealerClaimService] WFM CSV successfully pushed for request ${requestNumber}`);
} else {
logger.warn(`[DealerClaimService] No invoice items found for WFM push: ${requestNumber}`);
}
} catch (error: any) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error pushing to WFM';
await invoice.update({
wfmPushStatus: 'FAILED',
wfmPushError: errorMessage
});
throw error;
}
}
}