3660 lines
145 KiB
TypeScript
3660 lines
145 KiB
TypeScript
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;
|
||
}
|
||
}
|
||
}
|
||
|