dedicated approval service file added and io deduction bug resolved

This commit is contained in:
laxmanhalaki 2025-12-30 20:46:55 +05:30
parent b864b20bfa
commit 8ce8b2a659
7 changed files with 990 additions and 220 deletions

View File

@ -1,11 +1,15 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { ApprovalService } from '@services/approval.service'; import { ApprovalService } from '@services/approval.service';
import { DealerClaimApprovalService } from '@services/dealerClaimApproval.service';
import { ApprovalLevel } from '@models/ApprovalLevel';
import { WorkflowRequest } from '@models/WorkflowRequest';
import { validateApprovalAction } from '@validators/approval.validator'; import { validateApprovalAction } from '@validators/approval.validator';
import { ResponseHandler } from '@utils/responseHandler'; import { ResponseHandler } from '@utils/responseHandler';
import type { AuthenticatedRequest } from '../types/express'; import type { AuthenticatedRequest } from '../types/express';
import { getRequestMetadata } from '@utils/requestUtils'; import { getRequestMetadata } from '@utils/requestUtils';
const approvalService = new ApprovalService(); const approvalService = new ApprovalService();
const dealerClaimApprovalService = new DealerClaimApprovalService();
export class ApprovalController { export class ApprovalController {
async approveLevel(req: AuthenticatedRequest, res: Response): Promise<void> { async approveLevel(req: AuthenticatedRequest, res: Response): Promise<void> {
@ -13,18 +17,54 @@ export class ApprovalController {
const { levelId } = req.params; const { levelId } = req.params;
const validatedData = validateApprovalAction(req.body); const validatedData = validateApprovalAction(req.body);
const requestMeta = getRequestMetadata(req); // Determine which service to use based on workflow type
const level = await approvalService.approveLevel(levelId, validatedData, req.user.userId, { const level = await ApprovalLevel.findByPk(levelId);
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent
});
if (!level) { if (!level) {
ResponseHandler.notFound(res, 'Approval level not found'); ResponseHandler.notFound(res, 'Approval level not found');
return; return;
} }
ResponseHandler.success(res, level, 'Approval level updated successfully'); const workflow = await WorkflowRequest.findByPk(level.requestId);
if (!workflow) {
ResponseHandler.notFound(res, 'Workflow not found');
return;
}
const workflowType = (workflow as any)?.workflowType;
const requestMeta = getRequestMetadata(req);
// Route to appropriate service based on workflow type
let approvedLevel: any;
if (workflowType === 'CLAIM_MANAGEMENT') {
// Use DealerClaimApprovalService for claim management workflows
approvedLevel = await dealerClaimApprovalService.approveLevel(
levelId,
validatedData,
req.user.userId,
{
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent
}
);
} else {
// Use ApprovalService for custom workflows
approvedLevel = await approvalService.approveLevel(
levelId,
validatedData,
req.user.userId,
{
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent
}
);
}
if (!approvedLevel) {
ResponseHandler.notFound(res, 'Approval level not found');
return;
}
ResponseHandler.success(res, approvedLevel, 'Approval level updated successfully');
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';
ResponseHandler.error(res, 'Failed to update approval level', 400, errorMessage); ResponseHandler.error(res, 'Failed to update approval level', 400, errorMessage);
@ -34,7 +74,23 @@ export class ApprovalController {
async getCurrentApprovalLevel(req: Request, res: Response): Promise<void> { async getCurrentApprovalLevel(req: Request, res: Response): Promise<void> {
try { try {
const { id } = req.params; const { id } = req.params;
const level = await approvalService.getCurrentApprovalLevel(id);
// Determine which service to use based on workflow type
const workflow = await WorkflowRequest.findByPk(id);
if (!workflow) {
ResponseHandler.notFound(res, 'Workflow not found');
return;
}
const workflowType = (workflow as any)?.workflowType;
// Route to appropriate service based on workflow type
let level: any;
if (workflowType === 'CLAIM_MANAGEMENT') {
level = await dealerClaimApprovalService.getCurrentApprovalLevel(id);
} else {
level = await approvalService.getCurrentApprovalLevel(id);
}
ResponseHandler.success(res, level, 'Current approval level retrieved successfully'); ResponseHandler.success(res, level, 'Current approval level retrieved successfully');
} catch (error) { } catch (error) {
@ -46,7 +102,23 @@ export class ApprovalController {
async getApprovalLevels(req: Request, res: Response): Promise<void> { async getApprovalLevels(req: Request, res: Response): Promise<void> {
try { try {
const { id } = req.params; const { id } = req.params;
const levels = await approvalService.getApprovalLevels(id);
// Determine which service to use based on workflow type
const workflow = await WorkflowRequest.findByPk(id);
if (!workflow) {
ResponseHandler.notFound(res, 'Workflow not found');
return;
}
const workflowType = (workflow as any)?.workflowType;
// Route to appropriate service based on workflow type
let levels: any[];
if (workflowType === 'CLAIM_MANAGEMENT') {
levels = await dealerClaimApprovalService.getApprovalLevels(id);
} else {
levels = await approvalService.getApprovalLevels(id);
}
ResponseHandler.success(res, levels, 'Approval levels retrieved successfully'); ResponseHandler.success(res, levels, 'Approval levels retrieved successfully');
} catch (error) { } catch (error) {

View File

@ -838,5 +838,88 @@ export class DealerClaimController {
return ResponseHandler.error(res, 'Failed to send credit note to dealer', 500, errorMessage); return ResponseHandler.error(res, 'Failed to send credit note to dealer', 500, errorMessage);
} }
} }
/**
* Test SAP Budget Blocking (for testing/debugging)
* POST /api/v1/dealer-claims/test/sap-block
*
* This endpoint allows direct testing of SAP budget blocking without creating a full request
*/
async testSapBudgetBlock(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
return ResponseHandler.error(res, 'Unauthorized', 401);
}
const { ioNumber, amount, requestNumber } = req.body;
// Validation
if (!ioNumber || !amount) {
return ResponseHandler.error(res, 'Missing required fields: ioNumber and amount are required', 400);
}
const blockAmount = parseFloat(amount);
if (isNaN(blockAmount) || blockAmount <= 0) {
return ResponseHandler.error(res, 'Amount must be a positive number', 400);
}
logger.info(`[DealerClaimController] Testing SAP budget block:`, {
ioNumber,
amount: blockAmount,
requestNumber: requestNumber || 'TEST-REQUEST',
userId
});
// First validate IO number
const ioValidation = await sapIntegrationService.validateIONumber(ioNumber);
if (!ioValidation.isValid) {
return ResponseHandler.error(res, `Invalid IO number: ${ioValidation.error || 'IO number not found in SAP'}`, 400);
}
logger.info(`[DealerClaimController] IO validation successful:`, {
ioNumber,
availableBalance: ioValidation.availableBalance
});
// Block budget in SAP
const testRequestNumber = requestNumber || `TEST-${Date.now()}`;
const blockResult = await sapIntegrationService.blockBudget(
ioNumber,
blockAmount,
testRequestNumber,
`Test budget block for ${testRequestNumber}`
);
if (!blockResult.success) {
return ResponseHandler.error(res, `Failed to block budget in SAP: ${blockResult.error}`, 500);
}
// Return detailed response
return ResponseHandler.success(res, {
message: 'SAP budget block test successful',
ioNumber,
requestedAmount: blockAmount,
availableBalance: ioValidation.availableBalance,
sapResponse: {
success: blockResult.success,
blockedAmount: blockResult.blockedAmount,
remainingBalance: blockResult.remainingBalance,
sapDocumentNumber: blockResult.blockId || null,
error: blockResult.error || null
},
calculatedRemainingBalance: ioValidation.availableBalance - blockResult.blockedAmount,
validation: {
isValid: ioValidation.isValid,
availableBalance: ioValidation.availableBalance,
error: ioValidation.error || null
}
}, 'SAP budget block test completed');
} catch (error: any) {
logger.error('[DealerClaimController] Error testing SAP budget block:', error);
return ResponseHandler.error(res, error.message || 'Failed to test SAP budget block', 500);
}
}
} }

View File

@ -93,5 +93,13 @@ router.put('/:requestId/credit-note', authenticateToken, asyncHandler(dealerClai
*/ */
router.post('/:requestId/credit-note/send', authenticateToken, asyncHandler(dealerClaimController.sendCreditNoteToDealer.bind(dealerClaimController))); router.post('/:requestId/credit-note/send', authenticateToken, asyncHandler(dealerClaimController.sendCreditNoteToDealer.bind(dealerClaimController)));
/**
* @route POST /api/v1/dealer-claims/test/sap-block
* @desc Test SAP budget blocking directly (for testing/debugging)
* @access Private
* @body { ioNumber: string, amount: number, requestNumber?: string }
*/
router.post('/test/sap-block', authenticateToken, asyncHandler(dealerClaimController.testSapBudgetBlock.bind(dealerClaimController)));
export default router; export default router;

View File

@ -12,7 +12,7 @@ import { notificationService } from './notification.service';
import { activityService } from './activity.service'; import { activityService } from './activity.service';
import { tatSchedulerService } from './tatScheduler.service'; import { tatSchedulerService } from './tatScheduler.service';
import { emitToRequestRoom } from '../realtime/socket'; import { emitToRequestRoom } from '../realtime/socket';
import { DealerClaimService } from './dealerClaim.service'; // Note: DealerClaimService import removed - dealer claim approvals are handled by DealerClaimApprovalService
export class ApprovalService { export class ApprovalService {
async approveLevel(levelId: string, action: ApprovalAction, _userId: string, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): Promise<ApprovalLevel | null> { async approveLevel(levelId: string, action: ApprovalAction, _userId: string, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): Promise<ApprovalLevel | null> {
@ -24,6 +24,13 @@ export class ApprovalService {
const wf = await WorkflowRequest.findByPk(level.requestId); const wf = await WorkflowRequest.findByPk(level.requestId);
if (!wf) return null; if (!wf) return null;
// Verify this is NOT a claim management workflow (should use DealerClaimApprovalService)
const workflowType = (wf as any)?.workflowType;
if (workflowType === 'CLAIM_MANAGEMENT') {
logger.error(`[Approval] Attempted to use ApprovalService for CLAIM_MANAGEMENT workflow ${level.requestId}. Use DealerClaimApprovalService instead.`);
throw new Error('ApprovalService cannot be used for CLAIM_MANAGEMENT workflows. Use DealerClaimApprovalService instead.');
}
const priority = ((wf as any)?.priority || 'standard').toString().toLowerCase(); const priority = ((wf as any)?.priority || 'standard').toString().toLowerCase();
const isPaused = (wf as any).isPaused || (level as any).isPaused; const isPaused = (wf as any).isPaused || (level as any).isPaused;
@ -378,14 +385,35 @@ export class ApprovalService {
throw new Error('Cannot advance workflow - workflow is currently paused. Please resume the workflow first.'); throw new Error('Cannot advance workflow - workflow is currently paused. Please resume the workflow first.');
} }
const nextLevelNumber = (level.levelNumber || 0) + 1; // Find the next PENDING level
// Custom workflows use strict sequential ordering (levelNumber + 1) to maintain intended order
// This ensures custom workflows work predictably and don't skip levels
const currentLevelNumber = level.levelNumber || 0;
logger.info(`[Approval] Finding next level after level ${currentLevelNumber} for request ${level.requestId} (Custom workflow)`);
// Use strict sequential approach for custom workflows
const nextLevel = await ApprovalLevel.findOne({ const nextLevel = await ApprovalLevel.findOne({
where: { where: {
requestId: level.requestId, requestId: level.requestId,
levelNumber: nextLevelNumber levelNumber: currentLevelNumber + 1
} }
}); });
if (!nextLevel) {
logger.info(`[Approval] Sequential level ${currentLevelNumber + 1} not found for custom workflow - this may be the final approval`);
} else if (nextLevel.status !== ApprovalStatus.PENDING) {
// Sequential level exists but not PENDING - log warning but proceed
logger.warn(`[Approval] Sequential level ${currentLevelNumber + 1} exists but status is ${nextLevel.status}, expected PENDING. Proceeding with sequential level to maintain workflow order.`);
}
const nextLevelNumber = nextLevel ? (nextLevel.levelNumber || 0) : null;
if (nextLevel) {
logger.info(`[Approval] Found next level: ${nextLevelNumber} (${(nextLevel as any).levelName || 'unnamed'}), approver: ${(nextLevel as any).approverName || (nextLevel as any).approverEmail || 'unknown'}, status: ${nextLevel.status}`);
} else {
logger.info(`[Approval] No next level found after level ${currentLevelNumber} - this may be the final approval`);
}
if (nextLevel) { if (nextLevel) {
// Check if next level is paused - if so, don't activate it // Check if next level is paused - if so, don't activate it
if ((nextLevel as any).isPaused || (nextLevel as any).status === 'PAUSED') { if ((nextLevel as any).isPaused || (nextLevel as any).status === 'PAUSED') {
@ -419,44 +447,20 @@ export class ApprovalService {
// Don't fail the approval if TAT scheduling fails // Don't fail the approval if TAT scheduling fails
} }
// Update workflow current level // Update workflow current level (only if nextLevelNumber is not null)
if (nextLevelNumber !== null) {
await WorkflowRequest.update( await WorkflowRequest.update(
{ currentLevel: nextLevelNumber }, { currentLevel: nextLevelNumber },
{ where: { requestId: level.requestId } } { where: { requestId: level.requestId } }
); );
logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`); logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`);
} else {
// Check if this is Department Lead approval in a claim management workflow logger.warn(`Approved level ${level.levelNumber} but no next level found - workflow may be complete`);
// Activity Creation is now an activity log only, not an approval step
const workflowType = (wf as any)?.workflowType;
const isClaimManagement = workflowType === 'CLAIM_MANAGEMENT';
// Check if current level is Department Lead (by levelName, not hardcoded step number)
const currentLevelName = (level.levelName || '').toLowerCase();
const isDeptLeadApproval = currentLevelName.includes('department lead') || level.levelNumber === 3;
// Check if current level is Requestor Claim Approval (Step 5, was Step 6)
const isRequestorClaimApproval = currentLevelName.includes('requestor') &&
(currentLevelName.includes('claim') || currentLevelName.includes('approval')) ||
level.levelNumber === 5;
if (isClaimManagement && isDeptLeadApproval) {
// Activity Creation is now an activity log only - process it automatically
logger.info(`[Approval] Department Lead approved for claim management workflow. Processing Activity Creation as activity log.`);
try {
const dealerClaimService = new DealerClaimService();
await dealerClaimService.processActivityCreation(level.requestId);
logger.info(`[Approval] Activity Creation activity logged for request ${level.requestId}`);
} catch (activityError) {
logger.error(`[Approval] Error processing Activity Creation activity for request ${level.requestId}:`, activityError);
// Don't fail the Department Lead approval if Activity Creation logging fails - log and continue
}
} else if (isClaimManagement && isRequestorClaimApproval) {
// E-Invoice Generation is now an activity log only - will be logged when invoice is generated via DMS webhook
logger.info(`[Approval] Requestor Claim Approval approved for claim management workflow. E-Invoice generation will be logged as activity when DMS webhook is received.`);
} }
// Note: Dealer claim-specific logic (Activity Creation, E-Invoice) is handled by DealerClaimApprovalService
// This service is for custom workflows only
// Log approval activity // Log approval activity
activityService.log({ activityService.log({
requestId: level.requestId, requestId: level.requestId,
@ -545,38 +549,17 @@ export class ApprovalService {
logger.info(`[Approval] Skipping notification for auto-step at level ${nextLevelNumber}`); logger.info(`[Approval] Skipping notification for auto-step at level ${nextLevelNumber}`);
} }
// Notify initiator when dealer submits documents (Dealer Proposal or Dealer Completion Documents approval in claim management) // Note: Dealer-specific notifications (proposal/completion submissions) are handled by DealerClaimApprovalService
const levelName = (level.levelName || '').toLowerCase();
const isDealerProposalApproval = levelName.includes('dealer') && levelName.includes('proposal') || level.levelNumber === 1;
const isDealerCompletionApproval = levelName.includes('dealer') && (levelName.includes('completion') || levelName.includes('documents')) || level.levelNumber === 5;
if (isClaimManagement && (isDealerProposalApproval || isDealerCompletionApproval) && (wf as any).initiatorId) {
const stepMessage = isDealerProposalApproval
? 'Dealer proposal has been submitted and is now under review.'
: 'Dealer completion documents have been submitted and are now under review.';
await notificationService.sendToUsers([(wf as any).initiatorId], {
title: isDealerProposalApproval ? 'Proposal Submitted' : 'Completion Documents Submitted',
body: `Your claim request "${(wf as any).title}" - ${stepMessage}`,
requestNumber: (wf as any).requestNumber,
requestId: (wf as any).requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 'approval',
priority: 'MEDIUM',
actionRequired: false
});
logger.info(`[Approval] Sent notification to initiator for ${isDealerProposalApproval ? 'Dealer Proposal Submission' : 'Dealer Completion Documents'} approval in claim management workflow`);
}
} }
} else { } else {
// No next level found but not final approver - this shouldn't happen // No next level found but not final approver - this shouldn't happen
logger.warn(`No next level found for workflow ${level.requestId} after approving level ${level.levelNumber}`); logger.warn(`No next level found for workflow ${level.requestId} after approving level ${level.levelNumber}`);
// Use current level number since there's no next level (workflow is complete)
await WorkflowRequest.update( await WorkflowRequest.update(
{ {
status: WorkflowStatus.APPROVED, status: WorkflowStatus.APPROVED,
closureDate: now, closureDate: now,
currentLevel: nextLevelNumber currentLevel: level.levelNumber || 0
}, },
{ where: { requestId: level.requestId } } { where: { requestId: level.requestId } }
); );

View File

@ -12,7 +12,7 @@ import { ApprovalLevel } from '../models/ApprovalLevel';
import { Participant } from '../models/Participant'; import { Participant } from '../models/Participant';
import { User } from '../models/User'; import { User } from '../models/User';
import { WorkflowService } from './workflow.service'; import { WorkflowService } from './workflow.service';
import { ApprovalService } from './approval.service'; import { DealerClaimApprovalService } from './dealerClaimApproval.service';
import { generateRequestNumber } from '../utils/helpers'; import { generateRequestNumber } from '../utils/helpers';
import { Priority, WorkflowStatus, ApprovalStatus, ParticipantType } from '../types/common.types'; import { Priority, WorkflowStatus, ApprovalStatus, ParticipantType } from '../types/common.types';
import { sapIntegrationService } from './sapIntegration.service'; import { sapIntegrationService } from './sapIntegration.service';
@ -28,7 +28,7 @@ import logger from '../utils/logger';
*/ */
export class DealerClaimService { export class DealerClaimService {
private workflowService = new WorkflowService(); private workflowService = new WorkflowService();
private approvalService = new ApprovalService(); private approvalService = new DealerClaimApprovalService();
private userService = new UserService(); private userService = new UserService();
/** /**
@ -1508,8 +1508,18 @@ export class DealerClaimService {
} }
const sapReturnedBlockedAmount = blockResult.blockedAmount; const sapReturnedBlockedAmount = blockResult.blockedAmount;
// Extract SAP reference number from blockId (this is the Sap_Reference_no from SAP response)
// Only use the actual SAP reference number - don't use any generated fallback
const sapDocumentNumber = blockResult.blockId || undefined;
const availableBalance = ioData.availableBalance || ioValidation.availableBalance; const availableBalance = ioData.availableBalance || ioValidation.availableBalance;
// Log if SAP reference number was received
if (sapDocumentNumber) {
logger.info(`[DealerClaimService] ✅ SAP Reference Number received: ${sapDocumentNumber}`);
} else {
logger.warn(`[DealerClaimService] ⚠️ No SAP Reference Number received from SAP response`);
}
// Use the amount we REQUESTED for calculation, not what SAP returned // Use the amount we REQUESTED for calculation, not what SAP returned
// SAP might return a slightly different amount due to rounding, but we calculate based on what we requested // SAP might return a slightly different amount due to rounding, but we calculate based on what we requested
// Only use SAP's returned amount if it's significantly different (more than 1 rupee), which would indicate an actual issue // Only use SAP's returned amount if it's significantly different (more than 1 rupee), which would indicate an actual issue
@ -1522,6 +1532,7 @@ export class DealerClaimService {
requestedAmount: blockedAmount, requestedAmount: blockedAmount,
sapReturnedBlockedAmount: sapReturnedBlockedAmount, sapReturnedBlockedAmount: sapReturnedBlockedAmount,
sapReturnedRemainingBalance: blockResult.remainingBalance, sapReturnedRemainingBalance: blockResult.remainingBalance,
sapDocumentNumber: sapDocumentNumber, // SAP reference number from response
availableBalance, availableBalance,
amountDifference, amountDifference,
usingSapAmount: useSapAmount, usingSapAmount: useSapAmount,
@ -1585,6 +1596,7 @@ export class DealerClaimService {
ioAvailableBalance: roundedAvailableBalance, ioAvailableBalance: roundedAvailableBalance,
ioBlockedAmount: roundedBlockedAmount, ioBlockedAmount: roundedBlockedAmount,
ioRemainingBalance: roundedRemainingBalance, ioRemainingBalance: roundedRemainingBalance,
sapDocumentNumber: sapDocumentNumber, // Store SAP reference number
organizedBy: organizedBy || undefined, organizedBy: organizedBy || undefined,
organizedAt: new Date(), organizedAt: new Date(),
status: IOStatus.BLOCKED, status: IOStatus.BLOCKED,
@ -1595,6 +1607,7 @@ export class DealerClaimService {
ioAvailableBalance: availableBalance, ioAvailableBalance: availableBalance,
ioBlockedAmount: finalBlockedAmount, ioBlockedAmount: finalBlockedAmount,
ioRemainingBalance: finalRemainingBalance, ioRemainingBalance: finalRemainingBalance,
sapDocumentNumber: sapDocumentNumber,
requestId requestId
}); });
@ -1609,7 +1622,8 @@ export class DealerClaimService {
logger.info(`[DealerClaimService] Update data:`, { logger.info(`[DealerClaimService] Update data:`, {
ioRemainingBalance: ioRecordData.ioRemainingBalance, ioRemainingBalance: ioRecordData.ioRemainingBalance,
ioBlockedAmount: ioRecordData.ioBlockedAmount, ioBlockedAmount: ioRecordData.ioBlockedAmount,
ioAvailableBalance: ioRecordData.ioAvailableBalance ioAvailableBalance: ioRecordData.ioAvailableBalance,
sapDocumentNumber: ioRecordData.sapDocumentNumber
}); });
// Explicitly update all fields to ensure remainingBalance is saved // Explicitly update all fields to ensure remainingBalance is saved
@ -1619,6 +1633,7 @@ export class DealerClaimService {
ioAvailableBalance: ioRecordData.ioAvailableBalance, ioAvailableBalance: ioRecordData.ioAvailableBalance,
ioBlockedAmount: ioRecordData.ioBlockedAmount, ioBlockedAmount: ioRecordData.ioBlockedAmount,
ioRemainingBalance: ioRecordData.ioRemainingBalance, // Explicitly ensure this is updated ioRemainingBalance: ioRecordData.ioRemainingBalance, // Explicitly ensure this is updated
sapDocumentNumber: ioRecordData.sapDocumentNumber, // Update SAP document number
organizedBy: ioRecordData.organizedBy, organizedBy: ioRecordData.organizedBy,
organizedAt: ioRecordData.organizedAt, organizedAt: ioRecordData.organizedAt,
status: ioRecordData.status status: ioRecordData.status

View File

@ -0,0 +1,595 @@
/**
* Dealer Claim Approval Service
*
* Dedicated approval service for dealer claim workflows (CLAIM_MANAGEMENT).
* Handles dealer claim-specific logic including:
* - Dynamic approver support (additional approvers added between steps)
* - Activity Creation processing
* - Dealer-specific notifications
*
* This service is separate from ApprovalService to prevent conflicts with custom workflows.
*/
import { ApprovalLevel } from '@models/ApprovalLevel';
import { WorkflowRequest } from '@models/WorkflowRequest';
import { ApprovalAction } from '../types/approval.types';
import { ApprovalStatus, WorkflowStatus } from '../types/common.types';
import { calculateTATPercentage } from '@utils/helpers';
import { calculateElapsedWorkingHours } from '@utils/tatTimeUtils';
import logger from '@utils/logger';
import { Op } from 'sequelize';
import { notificationService } from './notification.service';
import { activityService } from './activity.service';
import { tatSchedulerService } from './tatScheduler.service';
import { DealerClaimService } from './dealerClaim.service';
import { emitToRequestRoom } from '../realtime/socket';
export class DealerClaimApprovalService {
/**
* Approve a level in a dealer claim workflow
* Handles dealer claim-specific logic including dynamic approvers and activity creation
*/
async approveLevel(
levelId: string,
action: ApprovalAction,
userId: string,
requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }
): Promise<ApprovalLevel | null> {
try {
const level = await ApprovalLevel.findByPk(levelId);
if (!level) return null;
// Get workflow to determine priority for working hours calculation
const wf = await WorkflowRequest.findByPk(level.requestId);
if (!wf) return null;
// Verify this is a claim management workflow
const workflowType = (wf as any)?.workflowType;
if (workflowType !== 'CLAIM_MANAGEMENT') {
logger.warn(`[DealerClaimApproval] Attempted to use DealerClaimApprovalService for non-claim-management workflow ${level.requestId}. Workflow type: ${workflowType}`);
throw new Error('DealerClaimApprovalService can only be used for CLAIM_MANAGEMENT workflows');
}
const priority = ((wf as any)?.priority || 'standard').toString().toLowerCase();
const isPaused = (wf as any).isPaused || (level as any).isPaused;
// If paused, resume automatically when approving/rejecting
if (isPaused) {
const { pauseService } = await import('./pause.service');
try {
await pauseService.resumeWorkflow(level.requestId, userId);
logger.info(`[DealerClaimApproval] Auto-resumed paused workflow ${level.requestId} when ${action.action === 'APPROVE' ? 'approving' : 'rejecting'}`);
} catch (pauseError) {
logger.warn(`[DealerClaimApproval] Failed to auto-resume paused workflow:`, pauseError);
// Continue with approval/rejection even if resume fails
}
}
const now = new Date();
// Calculate elapsed hours using working hours logic (with pause handling)
const isPausedLevel = (level as any).isPaused;
const wasResumed = !isPausedLevel &&
(level as any).pauseElapsedHours !== null &&
(level as any).pauseElapsedHours !== undefined &&
(level as any).pauseResumeDate !== null;
const pauseInfo = isPausedLevel ? {
// Level is currently paused - return frozen elapsed hours at pause time
isPaused: true,
pausedAt: (level as any).pausedAt,
pauseElapsedHours: (level as any).pauseElapsedHours,
pauseResumeDate: (level as any).pauseResumeDate
} : wasResumed ? {
// Level was paused but has been resumed - add pre-pause elapsed hours + time since resume
isPaused: false,
pausedAt: null,
pauseElapsedHours: Number((level as any).pauseElapsedHours), // Pre-pause elapsed hours
pauseResumeDate: (level as any).pauseResumeDate // Actual resume timestamp
} : undefined;
const elapsedHours = await calculateElapsedWorkingHours(
(level as any).levelStartTime || (level as any).tatStartTime || now,
now,
priority,
pauseInfo
);
const tatPercentage = calculateTATPercentage(elapsedHours, level.tatHours);
// Handle rejection
if (action.action === 'REJECT') {
return await this.handleRejection(level, action, userId, requestMetadata, elapsedHours, tatPercentage, now);
}
// Update level status and elapsed time for approval
await level.update({
status: ApprovalStatus.APPROVED,
actionDate: now,
levelEndTime: now,
elapsedHours: elapsedHours,
tatPercentageUsed: tatPercentage,
comments: action.comments || undefined
});
// Check if this is the final approver
const allLevels = await ApprovalLevel.findAll({
where: { requestId: level.requestId }
});
const approvedCount = allLevels.filter((l: any) => l.status === ApprovalStatus.APPROVED).length;
const isFinalApprover = approvedCount === allLevels.length;
if (isFinalApprover) {
// Final approval - close workflow
await WorkflowRequest.update(
{
status: WorkflowStatus.APPROVED,
closureDate: now,
currentLevel: level.levelNumber || 0
},
{ where: { requestId: level.requestId } }
);
// Notify all participants
const participants = await import('@models/Participant').then(m => m.Participant.findAll({
where: { requestId: level.requestId, isActive: true }
}));
if (participants && participants.length > 0) {
const participantIds = participants.map((p: any) => p.userId).filter(Boolean);
await notificationService.sendToUsers(participantIds, {
title: `Request Approved: ${(wf as any).requestNumber}`,
body: `${(wf as any).title}`,
requestNumber: (wf as any).requestNumber,
requestId: level.requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 'approval',
priority: 'MEDIUM'
});
logger.info(`[DealerClaimApproval] Final approval complete. ${participants.length} participant(s) notified.`);
}
} else {
// Not final - move to next level
// Check if workflow is paused - if so, don't advance
if ((wf as any).isPaused || (wf as any).status === 'PAUSED') {
logger.warn(`[DealerClaimApproval] Cannot advance workflow ${level.requestId} - workflow is paused`);
throw new Error('Cannot advance workflow - workflow is currently paused. Please resume the workflow first.');
}
// Find the next PENDING level (supports dynamically added approvers)
// Strategy: First try sequential, then find next PENDING level if sequential doesn't exist
const currentLevelNumber = level.levelNumber || 0;
logger.info(`[DealerClaimApproval] Finding next level after level ${currentLevelNumber} for request ${level.requestId}`);
// First, try sequential approach
let nextLevel = await ApprovalLevel.findOne({
where: {
requestId: level.requestId,
levelNumber: currentLevelNumber + 1
}
});
// If sequential level doesn't exist, search for next PENDING level
// This handles cases where additional approvers are added dynamically between steps
if (!nextLevel) {
logger.info(`[DealerClaimApproval] Sequential level ${currentLevelNumber + 1} not found, searching for next PENDING level (dynamic approvers)`);
nextLevel = await ApprovalLevel.findOne({
where: {
requestId: level.requestId,
levelNumber: { [Op.gt]: currentLevelNumber },
status: ApprovalStatus.PENDING
},
order: [['levelNumber', 'ASC']]
});
if (nextLevel) {
logger.info(`[DealerClaimApproval] Using fallback level ${nextLevel.levelNumber} (${(nextLevel as any).levelName || 'unnamed'})`);
}
} else if (nextLevel.status !== ApprovalStatus.PENDING) {
// Sequential level exists but not PENDING - check if it's already approved/rejected
if (nextLevel.status === ApprovalStatus.APPROVED || nextLevel.status === ApprovalStatus.REJECTED) {
logger.warn(`[DealerClaimApproval] Sequential level ${currentLevelNumber + 1} already ${nextLevel.status}. Skipping activation.`);
nextLevel = null; // Don't activate an already completed level
} else {
// Level exists but in unexpected status - log warning but proceed
logger.warn(`[DealerClaimApproval] Sequential level ${currentLevelNumber + 1} exists but status is ${nextLevel.status}, expected PENDING. Proceeding with sequential level.`);
}
}
const nextLevelNumber = nextLevel ? (nextLevel.levelNumber || 0) : null;
if (nextLevel) {
logger.info(`[DealerClaimApproval] Found next level: ${nextLevelNumber} (${(nextLevel as any).levelName || 'unnamed'}), approver: ${(nextLevel as any).approverName || (nextLevel as any).approverEmail || 'unknown'}, status: ${nextLevel.status}`);
} else {
logger.info(`[DealerClaimApproval] No next level found after level ${currentLevelNumber} - this may be the final approval`);
}
if (nextLevel) {
// Check if next level is paused - if so, don't activate it
if ((nextLevel as any).isPaused || (nextLevel as any).status === 'PAUSED') {
logger.warn(`[DealerClaimApproval] Cannot activate next level ${nextLevelNumber} - level is paused`);
throw new Error('Cannot activate next level - the next approval level is currently paused. Please resume it first.');
}
// Activate next level
await nextLevel.update({
status: ApprovalStatus.IN_PROGRESS,
levelStartTime: now,
tatStartTime: now
});
// Schedule TAT jobs for the next level
try {
const workflowPriority = (wf as any)?.priority || 'STANDARD';
await tatSchedulerService.scheduleTatJobs(
level.requestId,
(nextLevel as any).levelId,
(nextLevel as any).approverId,
Number((nextLevel as any).tatHours),
now,
workflowPriority
);
logger.info(`[DealerClaimApproval] TAT jobs scheduled for next level ${nextLevelNumber} (Priority: ${workflowPriority})`);
} catch (tatError) {
logger.error(`[DealerClaimApproval] Failed to schedule TAT jobs for next level:`, tatError);
// Don't fail the approval if TAT scheduling fails
}
// Update workflow current level
if (nextLevelNumber !== null) {
await WorkflowRequest.update(
{ currentLevel: nextLevelNumber },
{ where: { requestId: level.requestId } }
);
logger.info(`[DealerClaimApproval] Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`);
}
// Handle dealer claim-specific step processing
const currentLevelName = (level.levelName || '').toLowerCase();
const isDeptLeadApproval = currentLevelName.includes('department lead') || level.levelNumber === 3;
const isRequestorClaimApproval = currentLevelName.includes('requestor') &&
(currentLevelName.includes('claim') || currentLevelName.includes('approval')) ||
level.levelNumber === 5;
if (isDeptLeadApproval) {
// Activity Creation is now an activity log only - process it automatically
logger.info(`[DealerClaimApproval] Department Lead approved. Processing Activity Creation as activity log.`);
try {
const dealerClaimService = new DealerClaimService();
await dealerClaimService.processActivityCreation(level.requestId);
logger.info(`[DealerClaimApproval] Activity Creation activity logged for request ${level.requestId}`);
} catch (activityError) {
logger.error(`[DealerClaimApproval] Error processing Activity Creation activity for request ${level.requestId}:`, activityError);
// Don't fail the Department Lead approval if Activity Creation logging fails
}
} else if (isRequestorClaimApproval) {
// E-Invoice Generation is now an activity log only - will be logged when invoice is generated via DMS webhook
logger.info(`[DealerClaimApproval] Requestor Claim Approval approved. E-Invoice generation will be logged as activity when DMS webhook is received.`);
}
// Log approval activity
activityService.log({
requestId: level.requestId,
type: 'approval',
user: { userId: level.approverId, name: level.approverName },
timestamp: new Date().toISOString(),
action: 'Approved',
details: `Request approved and forwarded to ${(nextLevel as any).approverName || (nextLevel as any).approverEmail} by ${level.approverName || level.approverEmail}`,
ipAddress: requestMetadata?.ipAddress || undefined,
userAgent: requestMetadata?.userAgent || undefined
});
// Notify initiator about the approval
if (wf) {
await notificationService.sendToUsers([(wf as any).initiatorId], {
title: `Request Approved - Level ${level.levelNumber}`,
body: `Your request "${(wf as any).title}" has been approved by ${level.approverName || level.approverEmail} and forwarded to the next approver.`,
requestNumber: (wf as any).requestNumber,
requestId: level.requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 'approval',
priority: 'MEDIUM'
});
}
// Notify next approver - ALWAYS send notification when there's a next level
if (wf && nextLevel) {
const nextApproverId = (nextLevel as any).approverId;
const nextApproverEmail = (nextLevel as any).approverEmail || '';
const nextApproverName = (nextLevel as any).approverName || nextApproverEmail || 'approver';
// Check if it's an auto-step or system process
const isAutoStep = nextApproverEmail === 'system@royalenfield.com'
|| (nextLevel as any).approverName === 'System Auto-Process'
|| nextApproverId === 'system';
const isSystemEmail = nextApproverEmail.toLowerCase() === 'system@royalenfield.com'
|| nextApproverEmail.toLowerCase().includes('system');
const isSystemName = nextApproverName.toLowerCase() === 'system auto-process'
|| nextApproverName.toLowerCase().includes('system');
// Only send notifications to real users, NOT system processes
if (!isAutoStep && !isSystemEmail && !isSystemName && nextApproverId && nextApproverId !== 'system') {
try {
logger.info(`[DealerClaimApproval] Sending assignment notification to next approver: ${nextApproverName} (${nextApproverId}) at level ${nextLevelNumber} for request ${(wf as any).requestNumber}`);
await notificationService.sendToUsers([ nextApproverId ], {
title: `Action required: ${(wf as any).requestNumber}`,
body: `${(wf as any).title}`,
requestNumber: (wf as any).requestNumber,
requestId: (wf as any).requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 'assignment',
priority: 'HIGH',
actionRequired: true
});
logger.info(`[DealerClaimApproval] ✅ Assignment notification sent successfully to ${nextApproverName} (${nextApproverId}) for level ${nextLevelNumber}`);
// Log assignment activity for the next approver
await activityService.log({
requestId: level.requestId,
type: 'assignment',
user: { userId: level.approverId, name: level.approverName },
timestamp: new Date().toISOString(),
action: 'Assigned to approver',
details: `Request assigned to ${nextApproverName} for ${(nextLevel as any).levelName || `level ${nextLevelNumber}`}`,
ipAddress: requestMetadata?.ipAddress || undefined,
userAgent: requestMetadata?.userAgent || undefined
});
} catch (notifError) {
logger.error(`[DealerClaimApproval] ❌ Failed to send notification to next approver ${nextApproverId} at level ${nextLevelNumber}:`, notifError);
// Don't throw - continue with workflow even if notification fails
}
} else {
logger.info(`[DealerClaimApproval] ⚠️ Skipping notification for system/auto-step: ${nextApproverEmail} (${nextApproverId}) at level ${nextLevelNumber}`);
}
// Notify initiator when dealer submits documents (Dealer Proposal or Dealer Completion Documents)
const levelName = (level.levelName || '').toLowerCase();
const isDealerProposalApproval = levelName.includes('dealer') && levelName.includes('proposal') || level.levelNumber === 1;
const isDealerCompletionApproval = levelName.includes('dealer') && (levelName.includes('completion') || levelName.includes('documents')) || level.levelNumber === 5;
if ((isDealerProposalApproval || isDealerCompletionApproval) && (wf as any).initiatorId) {
const stepMessage = isDealerProposalApproval
? 'Dealer proposal has been submitted and is now under review.'
: 'Dealer completion documents have been submitted and are now under review.';
await notificationService.sendToUsers([(wf as any).initiatorId], {
title: isDealerProposalApproval ? 'Proposal Submitted' : 'Completion Documents Submitted',
body: `Your claim request "${(wf as any).title}" - ${stepMessage}`,
requestNumber: (wf as any).requestNumber,
requestId: (wf as any).requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 'approval',
priority: 'MEDIUM',
actionRequired: false
});
logger.info(`[DealerClaimApproval] Sent notification to initiator for ${isDealerProposalApproval ? 'Dealer Proposal Submission' : 'Dealer Completion Documents'}`);
}
}
} else {
// No next level found but not final approver - this shouldn't happen
logger.warn(`[DealerClaimApproval] No next level found for workflow ${level.requestId} after approving level ${level.levelNumber}`);
await WorkflowRequest.update(
{
status: WorkflowStatus.APPROVED,
closureDate: now,
currentLevel: level.levelNumber || 0
},
{ where: { requestId: level.requestId } }
);
if (wf) {
await notificationService.sendToUsers([ (wf as any).initiatorId ], {
title: `Approved: ${(wf as any).requestNumber}`,
body: `${(wf as any).title}`,
requestNumber: (wf as any).requestNumber,
requestId: level.requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 'approval',
priority: 'MEDIUM'
});
}
}
}
// Emit real-time update to all users viewing this request
emitToRequestRoom(level.requestId, 'request:updated', {
requestId: level.requestId,
requestNumber: (wf as any)?.requestNumber,
action: action.action,
levelNumber: level.levelNumber,
timestamp: now.toISOString()
});
logger.info(`[DealerClaimApproval] Approval level ${levelId} ${action.action.toLowerCase()}ed and socket event emitted`);
return level;
} catch (error) {
logger.error('[DealerClaimApproval] Error approving level:', error);
throw error;
}
}
/**
* Handle rejection (internal method called from approveLevel)
*/
private async handleRejection(
level: ApprovalLevel,
action: ApprovalAction,
userId: string,
requestMetadata?: { ipAddress?: string | null; userAgent?: string | null },
elapsedHours?: number,
tatPercentage?: number,
now?: Date
): Promise<ApprovalLevel | null> {
const rejectionNow = now || new Date();
const wf = await WorkflowRequest.findByPk(level.requestId);
if (!wf) return null;
// Update level status
await level.update({
status: ApprovalStatus.REJECTED,
actionDate: rejectionNow,
levelEndTime: rejectionNow,
elapsedHours: elapsedHours || 0,
tatPercentageUsed: tatPercentage || 0,
comments: action.comments || action.rejectionReason || undefined
});
// Close workflow
await WorkflowRequest.update(
{
status: WorkflowStatus.REJECTED,
closureDate: rejectionNow
},
{ where: { requestId: level.requestId } }
);
// Log rejection activity
activityService.log({
requestId: level.requestId,
type: 'rejection',
user: { userId: level.approverId, name: level.approverName },
timestamp: new Date().toISOString(),
action: 'Rejected',
details: `Request rejected by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`,
ipAddress: requestMetadata?.ipAddress || undefined,
userAgent: requestMetadata?.userAgent || undefined
});
// Notify initiator and participants
const participants = await import('@models/Participant').then(m => m.Participant.findAll({
where: { requestId: level.requestId, isActive: true }
}));
const userIdsToNotify = [(wf as any).initiatorId];
if (participants && participants.length > 0) {
participants.forEach((p: any) => {
if (p.userId && p.userId !== (wf as any).initiatorId) {
userIdsToNotify.push(p.userId);
}
});
}
await notificationService.sendToUsers(userIdsToNotify, {
title: `Request Rejected: ${(wf as any).requestNumber}`,
body: `${(wf as any).title} - Rejected by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`,
requestNumber: (wf as any).requestNumber,
requestId: level.requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 'rejection',
priority: 'HIGH'
});
// Emit real-time update to all users viewing this request
emitToRequestRoom(level.requestId, 'request:updated', {
requestId: level.requestId,
requestNumber: (wf as any)?.requestNumber,
action: 'REJECT',
levelNumber: level.levelNumber,
timestamp: rejectionNow.toISOString()
});
return level;
}
/**
* Reject a level in a dealer claim workflow (legacy method - kept for backward compatibility)
*/
async rejectLevel(
levelId: string,
reason: string,
comments: string,
userId: string,
requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }
): Promise<ApprovalLevel | null> {
try {
const level = await ApprovalLevel.findByPk(levelId);
if (!level) return null;
const wf = await WorkflowRequest.findByPk(level.requestId);
if (!wf) return null;
// Verify this is a claim management workflow
const workflowType = (wf as any)?.workflowType;
if (workflowType !== 'CLAIM_MANAGEMENT') {
logger.warn(`[DealerClaimApproval] Attempted to use DealerClaimApprovalService for non-claim-management workflow ${level.requestId}. Workflow type: ${workflowType}`);
throw new Error('DealerClaimApprovalService can only be used for CLAIM_MANAGEMENT workflows');
}
const now = new Date();
// Calculate elapsed hours
const priority = ((wf as any)?.priority || 'standard').toString().toLowerCase();
const isPausedLevel = (level as any).isPaused;
const wasResumed = !isPausedLevel &&
(level as any).pauseElapsedHours !== null &&
(level as any).pauseElapsedHours !== undefined &&
(level as any).pauseResumeDate !== null;
const pauseInfo = isPausedLevel ? {
// Level is currently paused - return frozen elapsed hours at pause time
isPaused: true,
pausedAt: (level as any).pausedAt,
pauseElapsedHours: (level as any).pauseElapsedHours,
pauseResumeDate: (level as any).pauseResumeDate
} : wasResumed ? {
// Level was paused but has been resumed - add pre-pause elapsed hours + time since resume
isPaused: false,
pausedAt: null,
pauseElapsedHours: Number((level as any).pauseElapsedHours), // Pre-pause elapsed hours
pauseResumeDate: (level as any).pauseResumeDate // Actual resume timestamp
} : undefined;
// Use the internal handleRejection method
const elapsedHours = await calculateElapsedWorkingHours(
(level as any).levelStartTime || (level as any).tatStartTime || now,
now,
priority,
pauseInfo
);
const tatPercentage = calculateTATPercentage(elapsedHours, level.tatHours);
return await this.handleRejection(
level,
{ action: 'REJECT', comments: comments || reason, rejectionReason: reason || comments },
userId,
requestMetadata,
elapsedHours,
tatPercentage,
now
);
} catch (error) {
logger.error('[DealerClaimApproval] Error rejecting level:', error);
throw error;
}
}
/**
* Get current approval level for a request
*/
async getCurrentApprovalLevel(requestId: string): Promise<ApprovalLevel | null> {
const workflow = await WorkflowRequest.findByPk(requestId);
if (!workflow) return null;
const currentLevel = (workflow as any).currentLevel;
if (!currentLevel) return null;
return await ApprovalLevel.findOne({
where: { requestId, levelNumber: currentLevel }
});
}
/**
* Get all approval levels for a request
*/
async getApprovalLevels(requestId: string): Promise<ApprovalLevel[]> {
return await ApprovalLevel.findAll({
where: { requestId },
order: [['levelNumber', 'ASC']]
});
}
}

View File

@ -611,9 +611,11 @@ export class SAPIntegrationService {
attributeNamePrefix: '@_', attributeNamePrefix: '@_',
textNodeName: '#text', textNodeName: '#text',
parseAttributeValue: true, parseAttributeValue: true,
trimValues: true trimValues: true,
// Note: fast-xml-parser preserves namespace prefixes by default preserveOrder: false,
// Note: fast-xml-parser v4+ preserves namespace prefixes by default
// So 'd:Available_Amount' should remain as 'd:Available_Amount' in the parsed object // So 'd:Available_Amount' should remain as 'd:Available_Amount' in the parsed object
// If namespaces are being stripped, we check both 'd:Available_Amount' and 'Available_Amount'
}); });
responseData = parser.parse(response.data); responseData = parser.parse(response.data);
logger.info(`[SAP] XML parsed successfully`); logger.info(`[SAP] XML parsed successfully`);
@ -623,15 +625,13 @@ export class SAPIntegrationService {
} }
} }
// Log response data - handle different formats // Log response data summary
if (responseData) { if (responseData) {
try { if (responseData.entry) {
logger.info(`[SAP] POST Response Data:`, JSON.stringify(responseData, null, 2)); logger.info(`[SAP] Response has 'entry' structure`);
} catch (e) { } else if (responseData.d) {
logger.info(`[SAP] POST Response Data (raw):`, responseData); logger.info(`[SAP] Response has OData 'd' wrapper`);
} }
} else {
logger.info(`[SAP] POST Response Data: (empty or null)`);
} }
// Also log the request that was sent // Also log the request that was sent
@ -672,8 +672,11 @@ export class SAPIntegrationService {
// Try various field name variations (both JSON and XML formats) // Try various field name variations (both JSON and XML formats)
// XML namespace prefixes: 'd:Available_Amount', 'd:RemainingBalance', etc. // XML namespace prefixes: 'd:Available_Amount', 'd:RemainingBalance', etc.
// IMPORTANT: Check 'd:Available_Amount' first as that's what SAP returns in XML // IMPORTANT: Check 'd:Available_Amount' first as that's what SAP returns in XML
// Also check without namespace prefix as parser might strip it
const value = getFieldValue('d:Available_Amount') ?? // XML format with namespace prefix (PRIORITY) const value = getFieldValue('d:Available_Amount') ?? // XML format with namespace prefix (PRIORITY)
getFieldValue('Available_Amount') ?? // XML format without prefix getFieldValue('Available_Amount') ?? // XML format without prefix (parser might strip 'd:')
getFieldValue('d:AvailableAmount') ?? // CamelCase variation with prefix
getFieldValue('AvailableAmount') ?? // CamelCase variation without prefix
getFieldValue('d:RemainingBalance') ?? getFieldValue('d:RemainingBalance') ??
getFieldValue('RemainingBalance') ?? getFieldValue('RemainingBalance') ??
getFieldValue('RemainingAmount') ?? getFieldValue('RemainingAmount') ??
@ -739,8 +742,57 @@ export class SAPIntegrationService {
let mainEntryProperties: any = null; let mainEntryProperties: any = null;
// XML structure: entry.link (array) -> find link with @rel='lt_io_output' -> inline.feed.entry // XML structure: entry.link (array) -> find link with @rel='lt_io_output' -> inline.feed.entry
if (responseData.entry) { // OR JSON OData format: { d: { lt_io_output: { results: [...] } } }
const entry = responseData.entry; if (responseData.d) {
// Handle OData JSON format
if (responseData.d.entry) {
responseData = responseData.d;
} else if (responseData.d.lt_io_output) {
// Extract from d.lt_io_output
const ltIoOutput = responseData.d.lt_io_output;
if (ltIoOutput.results && Array.isArray(ltIoOutput.results) && ltIoOutput.results.length > 0) {
ioOutputData = ltIoOutput.results[0];
message = ioOutputData.Message || ioOutputData['d:Message'] || '';
logger.info(`[SAP] ✅ Extracted data from JSON OData format (d.lt_io_output.results[0])`);
} else if (typeof ltIoOutput === 'object' && !Array.isArray(ltIoOutput)) {
if (ltIoOutput.results && Array.isArray(ltIoOutput.results) && ltIoOutput.results.length > 0) {
ioOutputData = ltIoOutput.results[0];
message = ioOutputData?.Message || ioOutputData?.['d:Message'] || '';
logger.info(`[SAP] ✅ Extracted data from JSON OData format (d.lt_io_output.results[0])`);
} else {
ioOutputData = ltIoOutput;
message = ioOutputData.Message || ioOutputData['d:Message'] || '';
logger.info(`[SAP] ✅ Extracted data from JSON OData format (d.lt_io_output)`);
}
} else if (Array.isArray(ltIoOutput) && ltIoOutput.length > 0) {
ioOutputData = ltIoOutput[0];
message = ioOutputData?.Message || ioOutputData?.['d:Message'] || '';
logger.info(`[SAP] ✅ Extracted data from JSON OData format (d.lt_io_output[0])`);
}
} else if (Object.keys(responseData.d).some(key => key.includes('link') || key.includes('content'))) {
responseData = responseData.d;
}
}
// Sometimes XML parser might create the root element with a different name
// Check if responseData itself IS the entry (if root element was <entry>)
let actualEntry = responseData.entry;
if (!actualEntry && responseData && typeof responseData === 'object' && !Array.isArray(responseData)) {
const currentKeys = Object.keys(responseData);
if (currentKeys.includes('link') || currentKeys.includes('content') || currentKeys.includes('id') || currentKeys.includes('title')) {
actualEntry = responseData;
}
}
// Check if responseData might be an array (sometimes XML parser returns arrays)
if (Array.isArray(responseData) && responseData.length > 0 && responseData[0]?.entry) {
responseData = responseData[0];
}
// Use actualEntry if we found it, otherwise try responseData.entry
const entry = actualEntry || responseData.entry;
if (entry && !ioOutputData) {
// Also check main entry properties (sometimes Available_Amount is here) // Also check main entry properties (sometimes Available_Amount is here)
const mainContent = entry.content || {}; const mainContent = entry.content || {};
@ -750,139 +802,32 @@ export class SAPIntegrationService {
logger.info(`[SAP] Found main entry properties, keys:`, Object.keys(mainEntryProperties)); logger.info(`[SAP] Found main entry properties, keys:`, Object.keys(mainEntryProperties));
} }
// Find lt_io_output link // Find lt_io_output link in XML structure
// The rel attribute can be a full URL like "http://schemas.microsoft.com/ado/2007/08/dataservices/related/lt_io_output"
// or just "lt_io_output", so we check if it includes "lt_io_output"
const links = Array.isArray(entry.link) ? entry.link : (entry.link ? [entry.link] : []); const links = Array.isArray(entry.link) ? entry.link : (entry.link ? [entry.link] : []);
logger.debug(`[SAP] All links in entry:`, links.map((l: any) => ({
rel: l['@_rel'] || l.rel,
title: l['@_title'] || l.title,
href: l['@_href'] || l.href
})));
const ioOutputLink = links.find((link: any) => { const ioOutputLink = links.find((link: any) => {
const rel = link['@_rel'] || link.rel || ''; const rel = link['@_rel'] || link.rel || '';
const title = link['@_title'] || link.title || ''; const title = link['@_title'] || link.title || '';
return rel.includes('lt_io_output') || title === 'IOOutputSet' || title === 'lt_io_output'; return rel.includes('lt_io_output') || title === 'IOOutputSet' || title === 'lt_io_output';
}); });
if (ioOutputLink) { if (ioOutputLink?.inline?.feed?.entry) {
logger.info(`[SAP] Found lt_io_output link:`, {
rel: ioOutputLink['@_rel'] || ioOutputLink.rel,
title: ioOutputLink['@_title'] || ioOutputLink.title,
hasInline: !!ioOutputLink.inline,
hasFeed: !!ioOutputLink.inline?.feed,
hasEntry: !!ioOutputLink.inline?.feed?.entry
});
if (ioOutputLink.inline?.feed?.entry) {
const ioEntry = Array.isArray(ioOutputLink.inline.feed.entry) const ioEntry = Array.isArray(ioOutputLink.inline.feed.entry)
? ioOutputLink.inline.feed.entry[0] ? ioOutputLink.inline.feed.entry[0]
: ioOutputLink.inline.feed.entry; : ioOutputLink.inline.feed.entry;
logger.debug(`[SAP] IO Entry structure:`, {
hasContent: !!ioEntry.content,
contentType: ioEntry.content?.['@_type'] || ioEntry.content?.type,
hasMProperties: !!ioEntry.content?.['m:properties'],
hasProperties: !!ioEntry.content?.properties
});
// Handle both namespace-prefixed and non-prefixed property names
const content = ioEntry.content || {}; const content = ioEntry.content || {};
const properties = content['m:properties'] || content.properties || (content['@_type'] === 'application/xml' ? content : null); const properties = content['m:properties'] || content.properties || (content['@_type'] === 'application/xml' ? content : null);
if (properties) { if (properties) {
ioOutputData = properties; ioOutputData = properties;
// Try both namespace-prefixed and non-prefixed field names
message = ioOutputData['d:Message'] || ioOutputData.Message || ioOutputData['#text'] || ''; message = ioOutputData['d:Message'] || ioOutputData.Message || ioOutputData['#text'] || '';
logger.info(`[SAP] ✅ Found XML structure - lt_io_output data extracted`); logger.info(`[SAP] ✅ Found XML structure - lt_io_output data extracted`);
logger.info(`[SAP] XML properties keys (${Object.keys(ioOutputData).length} keys):`, Object.keys(ioOutputData));
// Log the raw properties object to see exact structure
logger.info(`[SAP] XML properties full object:`, JSON.stringify(ioOutputData, null, 2));
// Check ALL keys that might contain "Available" or "Amount"
const allKeys = Object.keys(ioOutputData);
const availableKeys = allKeys.filter(key =>
key.toLowerCase().includes('available') ||
key.toLowerCase().includes('amount') ||
key.toLowerCase().includes('remaining') ||
key.toLowerCase().includes('balance')
);
logger.info(`[SAP] Keys containing 'available', 'amount', 'remaining', or 'balance':`, availableKeys);
// Log all possible Available_Amount field variations with their actual values
const availableAmountVariations: Record<string, any> = {};
const variationsToCheck = [
'd:Available_Amount',
'Available_Amount',
'd:AvailableAmount',
'AvailableAmount',
'RemainingBalance',
'd:RemainingBalance',
'Remaining',
'RemainingAmount',
'AvailableBalance',
'Balance',
'Available'
];
variationsToCheck.forEach(key => {
if (ioOutputData[key] !== undefined) {
availableAmountVariations[key] = {
value: ioOutputData[key],
type: typeof ioOutputData[key],
stringValue: String(ioOutputData[key])
};
} }
});
logger.info(`[SAP] Available Amount field variations found:`, availableAmountVariations);
// Also check if the XML parser might have stripped the namespace prefix
// Sometimes XML parsers convert 'd:Available_Amount' to just 'Available_Amount'
if (allKeys.includes('Available_Amount') && !allKeys.includes('d:Available_Amount')) {
logger.info(`[SAP] Found 'Available_Amount' without 'd:' prefix (parser may have stripped namespace)`);
}
// Explicitly check for d:Available_Amount and log its value
if (ioOutputData['d:Available_Amount'] !== undefined) {
logger.info(`[SAP] ✅ Found d:Available_Amount: ${ioOutputData['d:Available_Amount']} (type: ${typeof ioOutputData['d:Available_Amount']})`);
} else if (ioOutputData.Available_Amount !== undefined) {
logger.info(`[SAP] ✅ Found Available_Amount (without d: prefix): ${ioOutputData.Available_Amount} (type: ${typeof ioOutputData.Available_Amount})`);
} else {
logger.warn(`[SAP] ⚠️ d:Available_Amount not found in properties. All property keys:`, allKeys);
}
} else {
logger.warn(`[SAP] ⚠️ Properties not found in ioEntry.content. Content structure:`, JSON.stringify(content, null, 2));
}
} else {
logger.warn(`[SAP] ⚠️ No entry found in ioOutputLink.inline.feed. Structure:`, {
hasInline: !!ioOutputLink.inline,
hasFeed: !!ioOutputLink.inline?.feed,
hasEntry: !!ioOutputLink.inline?.feed?.entry,
inlineKeys: ioOutputLink.inline ? Object.keys(ioOutputLink.inline) : []
});
}
} else {
logger.warn(`[SAP] ⚠️ No lt_io_output link found in entry.links. Available links:`, links.map((l: any) => ({
rel: l['@_rel'] || l.rel || 'unknown',
title: l['@_title'] || l.title || 'unknown'
})));
} }
} }
// Check for JSON OData format // Extract data from ioOutputData (already extracted above for both XML and JSON formats)
if (responseData.d) { if (ioOutputData) {
// OData format: { d: { ... } }
const data = responseData.d;
success = data.Success !== false && data.Message !== 'Error';
blockedAmount = extractBlockedAmount(data);
remainingBalance = extractRemainingBalance(data);
blockId = data.BlockId || data.Reference || data.RequestId || data.DocumentNumber || data.Sap_Reference_no || undefined;
} else if (ioOutputData) {
// XML parsed structure - extract from lt_io_output properties // XML parsed structure - extract from lt_io_output properties
// Available_Amount is the remaining balance after blocking // Available_Amount is the remaining balance after blocking
success = message.includes('Successful') || message.includes('Success') || !message.includes('Error'); success = message.includes('Successful') || message.includes('Success') || !message.includes('Error');
@ -898,21 +843,67 @@ export class SAPIntegrationService {
} }
} }
// Try both namespace-prefixed and non-prefixed field names // Helper function to extract SAP reference number (similar to extractRemainingBalance)
blockId = ioOutputData['d:Sap_Reference_no'] || const extractSapReference = (obj: any): string | undefined => {
ioOutputData.Sap_Reference_no || if (!obj) return undefined;
ioOutputData['d:Reference'] ||
ioOutputData.Reference ||
ioOutputData['d:BlockId'] ||
ioOutputData.BlockId ||
undefined;
const getFieldValue = (fieldName: string): any => {
const field = obj[fieldName];
if (field === undefined || field === null) return null;
// If it's an object with #text property (XML parser sometimes does this)
if (typeof field === 'object' && field['#text'] !== undefined) {
return field['#text'];
}
// Direct value
return field;
};
// Try various field name variations for SAP reference number
const value = getFieldValue('d:Sap_Reference_no') ?? // XML format with namespace prefix (PRIORITY)
getFieldValue('Sap_Reference_no') ?? // XML format without prefix
getFieldValue('d:SapReferenceNo') ??
getFieldValue('SapReferenceNo') ??
getFieldValue('d:Reference') ??
getFieldValue('Reference') ??
getFieldValue('d:BlockId') ??
getFieldValue('BlockId') ??
getFieldValue('d:DocumentNumber') ??
getFieldValue('DocumentNumber') ??
null;
if (value === null || value === undefined) {
logger.debug(`[SAP] extractSapReference: No value found. Object keys:`, Object.keys(obj));
return undefined;
}
// Convert to string and trim
const valueStr = String(value).trim();
logger.debug(`[SAP] extractSapReference: Extracted value "${valueStr}"`);
return valueStr || undefined;
};
// Extract SAP reference number using helper function
blockId = extractSapReference(ioOutputData) || extractSapReference(mainEntryProperties) || undefined;
// Log detailed information for debugging
logger.info(`[SAP] Extracted from XML lt_io_output:`, { logger.info(`[SAP] Extracted from XML lt_io_output:`, {
message, message,
availableAmount: remainingBalance, availableAmount: remainingBalance,
sapReference: blockId, sapReference: blockId,
allKeys: Object.keys(ioOutputData), allKeys: Object.keys(ioOutputData),
foundInMainEntry: remainingBalance > 0 && mainEntryProperties ? true : false sampleKeys: Object.keys(ioOutputData).slice(0, 10), // First 10 keys for debugging
foundInMainEntry: remainingBalance > 0 && mainEntryProperties ? true : false,
ioOutputDataSample: Object.keys(ioOutputData).reduce((acc: any, key: string) => {
if (key.toLowerCase().includes('available') ||
key.toLowerCase().includes('amount') ||
key.toLowerCase().includes('reference') ||
key.toLowerCase().includes('sap')) {
acc[key] = ioOutputData[key];
}
return acc;
}, {})
}); });
} else if (responseData.ls_response && Array.isArray(responseData.ls_response) && responseData.ls_response.length > 0) { } else if (responseData.ls_response && Array.isArray(responseData.ls_response) && responseData.ls_response.length > 0) {
// Response in ls_response array // Response in ls_response array
@ -950,9 +941,23 @@ export class SAPIntegrationService {
remainingBalance, remainingBalance,
blockId, blockId,
responseStructure: ioOutputData ? 'XML lt_io_output' : responseData.d ? 'JSON d' : responseData.ls_response ? 'ls_response' : responseData.lt_io_output ? 'lt_io_output' : 'unknown', responseStructure: ioOutputData ? 'XML lt_io_output' : responseData.d ? 'JSON d' : responseData.ls_response ? 'ls_response' : responseData.lt_io_output ? 'lt_io_output' : 'unknown',
note: remainingBalance === 0 ? '⚠️ Remaining balance is 0 - will be calculated from availableBalance - blockedAmount' : '✅ Remaining balance from SAP response' note: remainingBalance === 0 ? '⚠️ Remaining balance is 0 - will be calculated from availableBalance - blockedAmount' : '✅ Remaining balance from SAP response',
hasIoOutputData: !!ioOutputData,
ioOutputDataKeys: ioOutputData ? Object.keys(ioOutputData) : null,
hasMainEntryProperties: !!mainEntryProperties,
mainEntryPropertiesKeys: mainEntryProperties ? Object.keys(mainEntryProperties) : null
}); });
// If ioOutputData exists but we didn't extract values, log detailed info
if (ioOutputData && (remainingBalance === 0 || !blockId)) {
logger.warn(`[SAP] ⚠️ ioOutputData exists but extraction failed. Full ioOutputData:`, JSON.stringify(ioOutputData, null, 2));
logger.warn(`[SAP] ⚠️ All keys in ioOutputData:`, Object.keys(ioOutputData));
logger.warn(`[SAP] ⚠️ Sample values:`, Object.keys(ioOutputData).slice(0, 10).reduce((acc: any, key: string) => {
acc[key] = ioOutputData[key];
return acc;
}, {}));
}
// If remaining balance is 0, log the full response structure for debugging // If remaining balance is 0, log the full response structure for debugging
if (remainingBalance === 0 && response.status === 200 || response.status === 201) { if (remainingBalance === 0 && response.status === 200 || response.status === 201) {
logger.warn(`[SAP] ⚠️ Remaining balance is 0, but request was successful. Full response structure:`, JSON.stringify(responseData, null, 2)); logger.warn(`[SAP] ⚠️ Remaining balance is 0, but request was successful. Full response structure:`, JSON.stringify(responseData, null, 2));
@ -960,9 +965,18 @@ export class SAPIntegrationService {
if (success) { if (success) {
logger.info(`[SAP] Budget blocked successfully for IO ${ioNumber}. Blocked: ${blockedAmount}, Remaining: ${remainingBalance}`); logger.info(`[SAP] Budget blocked successfully for IO ${ioNumber}. Blocked: ${blockedAmount}, Remaining: ${remainingBalance}`);
// Only return blockId if SAP provided a reference number
// Don't generate a fallback - we want the actual SAP document number
if (blockId) {
logger.info(`[SAP] SAP Reference Number received: ${blockId}`);
} else {
logger.warn(`[SAP] ⚠️ No SAP Reference Number (Sap_Reference_no) found in response`);
}
return { return {
success: true, success: true,
blockId: blockId || `BLOCK-${Date.now()}`, blockId: blockId || undefined, // Only return actual SAP reference number, no fallback
blockedAmount, blockedAmount,
remainingBalance remainingBalance
}; };