diff --git a/src/controllers/approval.controller.ts b/src/controllers/approval.controller.ts index 350b642..c5b7c5e 100644 --- a/src/controllers/approval.controller.ts +++ b/src/controllers/approval.controller.ts @@ -1,11 +1,15 @@ import { Request, Response } from 'express'; 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 { ResponseHandler } from '@utils/responseHandler'; import type { AuthenticatedRequest } from '../types/express'; import { getRequestMetadata } from '@utils/requestUtils'; const approvalService = new ApprovalService(); +const dealerClaimApprovalService = new DealerClaimApprovalService(); export class ApprovalController { async approveLevel(req: AuthenticatedRequest, res: Response): Promise { @@ -13,18 +17,54 @@ export class ApprovalController { const { levelId } = req.params; const validatedData = validateApprovalAction(req.body); - const requestMeta = getRequestMetadata(req); - const level = await approvalService.approveLevel(levelId, validatedData, req.user.userId, { - ipAddress: requestMeta.ipAddress, - userAgent: requestMeta.userAgent - }); - + // Determine which service to use based on workflow type + const level = await ApprovalLevel.findByPk(levelId); if (!level) { ResponseHandler.notFound(res, 'Approval level not found'); 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) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ResponseHandler.error(res, 'Failed to update approval level', 400, errorMessage); @@ -34,7 +74,23 @@ export class ApprovalController { async getCurrentApprovalLevel(req: Request, res: Response): Promise { try { 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'); } catch (error) { @@ -46,7 +102,23 @@ export class ApprovalController { async getApprovalLevels(req: Request, res: Response): Promise { try { 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'); } catch (error) { diff --git a/src/controllers/dealerClaim.controller.ts b/src/controllers/dealerClaim.controller.ts index 710650a..e8a1055 100644 --- a/src/controllers/dealerClaim.controller.ts +++ b/src/controllers/dealerClaim.controller.ts @@ -838,5 +838,88 @@ export class DealerClaimController { 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 { + 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); + } + } } diff --git a/src/routes/dealerClaim.routes.ts b/src/routes/dealerClaim.routes.ts index 848f926..6a33ee4 100644 --- a/src/routes/dealerClaim.routes.ts +++ b/src/routes/dealerClaim.routes.ts @@ -93,5 +93,13 @@ router.put('/:requestId/credit-note', authenticateToken, asyncHandler(dealerClai */ 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; diff --git a/src/services/approval.service.ts b/src/services/approval.service.ts index bcb20d9..46c7bdf 100644 --- a/src/services/approval.service.ts +++ b/src/services/approval.service.ts @@ -12,7 +12,7 @@ import { notificationService } from './notification.service'; import { activityService } from './activity.service'; import { tatSchedulerService } from './tatScheduler.service'; import { emitToRequestRoom } from '../realtime/socket'; -import { DealerClaimService } from './dealerClaim.service'; +// Note: DealerClaimService import removed - dealer claim approvals are handled by DealerClaimApprovalService export class ApprovalService { async approveLevel(levelId: string, action: ApprovalAction, _userId: string, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): Promise { @@ -24,6 +24,13 @@ export class ApprovalService { const wf = await WorkflowRequest.findByPk(level.requestId); 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 isPaused = (wf as any).isPaused || (level as any).isPaused; @@ -378,13 +385,34 @@ export class ApprovalService { 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({ - where: { + where: { 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) { // Check if next level is paused - if so, don't activate it @@ -419,44 +447,20 @@ export class ApprovalService { // Don't fail the approval if TAT scheduling fails } - // Update workflow current level - await WorkflowRequest.update( - { currentLevel: nextLevelNumber }, - { where: { requestId: level.requestId } } - ); - - logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`); - - // Check if this is Department Lead approval in a claim management workflow - // 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.`); + // Update workflow current level (only if nextLevelNumber is not null) + if (nextLevelNumber !== null) { + await WorkflowRequest.update( + { currentLevel: nextLevelNumber }, + { where: { requestId: level.requestId } } + ); + logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`); + } else { + logger.warn(`Approved level ${level.levelNumber} but no next level found - workflow may be complete`); } + // Note: Dealer claim-specific logic (Activity Creation, E-Invoice) is handled by DealerClaimApprovalService + // This service is for custom workflows only + // Log approval activity activityService.log({ requestId: level.requestId, @@ -545,38 +549,17 @@ export class ApprovalService { 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) - 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`); - } + // Note: Dealer-specific notifications (proposal/completion submissions) are handled by DealerClaimApprovalService } } else { // 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}`); + // Use current level number since there's no next level (workflow is complete) await WorkflowRequest.update( { status: WorkflowStatus.APPROVED, closureDate: now, - currentLevel: nextLevelNumber + currentLevel: level.levelNumber || 0 }, { where: { requestId: level.requestId } } ); diff --git a/src/services/dealerClaim.service.ts b/src/services/dealerClaim.service.ts index 8572711..9d5fe47 100644 --- a/src/services/dealerClaim.service.ts +++ b/src/services/dealerClaim.service.ts @@ -12,7 +12,7 @@ import { ApprovalLevel } from '../models/ApprovalLevel'; import { Participant } from '../models/Participant'; import { User } from '../models/User'; import { WorkflowService } from './workflow.service'; -import { ApprovalService } from './approval.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'; @@ -28,7 +28,7 @@ import logger from '../utils/logger'; */ export class DealerClaimService { private workflowService = new WorkflowService(); - private approvalService = new ApprovalService(); + private approvalService = new DealerClaimApprovalService(); private userService = new UserService(); /** @@ -1508,8 +1508,18 @@ export class DealerClaimService { } const sapReturnedBlockedAmount = blockResult.blockedAmount; + // Extract SAP reference number from blockId (this is the Sap_Reference_no from SAP response) + // Only use the actual SAP reference number - don't use any generated fallback + const sapDocumentNumber = blockResult.blockId || undefined; const availableBalance = ioData.availableBalance || ioValidation.availableBalance; + // Log if SAP reference number was received + if (sapDocumentNumber) { + logger.info(`[DealerClaimService] ✅ SAP Reference Number received: ${sapDocumentNumber}`); + } else { + logger.warn(`[DealerClaimService] ⚠️ No SAP Reference Number received from SAP response`); + } + // Use the amount we REQUESTED for calculation, not what SAP returned // SAP might return a slightly different amount due to rounding, but we calculate based on what we requested // Only use SAP's returned amount if it's significantly different (more than 1 rupee), which would indicate an actual issue @@ -1522,6 +1532,7 @@ export class DealerClaimService { requestedAmount: blockedAmount, sapReturnedBlockedAmount: sapReturnedBlockedAmount, sapReturnedRemainingBalance: blockResult.remainingBalance, + sapDocumentNumber: sapDocumentNumber, // SAP reference number from response availableBalance, amountDifference, usingSapAmount: useSapAmount, @@ -1585,6 +1596,7 @@ export class DealerClaimService { ioAvailableBalance: roundedAvailableBalance, ioBlockedAmount: roundedBlockedAmount, ioRemainingBalance: roundedRemainingBalance, + sapDocumentNumber: sapDocumentNumber, // Store SAP reference number organizedBy: organizedBy || undefined, organizedAt: new Date(), status: IOStatus.BLOCKED, @@ -1595,6 +1607,7 @@ export class DealerClaimService { ioAvailableBalance: availableBalance, ioBlockedAmount: finalBlockedAmount, ioRemainingBalance: finalRemainingBalance, + sapDocumentNumber: sapDocumentNumber, requestId }); @@ -1609,7 +1622,8 @@ export class DealerClaimService { logger.info(`[DealerClaimService] Update data:`, { ioRemainingBalance: ioRecordData.ioRemainingBalance, ioBlockedAmount: ioRecordData.ioBlockedAmount, - ioAvailableBalance: ioRecordData.ioAvailableBalance + ioAvailableBalance: ioRecordData.ioAvailableBalance, + sapDocumentNumber: ioRecordData.sapDocumentNumber }); // Explicitly update all fields to ensure remainingBalance is saved @@ -1619,6 +1633,7 @@ export class DealerClaimService { ioAvailableBalance: ioRecordData.ioAvailableBalance, ioBlockedAmount: ioRecordData.ioBlockedAmount, ioRemainingBalance: ioRecordData.ioRemainingBalance, // Explicitly ensure this is updated + sapDocumentNumber: ioRecordData.sapDocumentNumber, // Update SAP document number organizedBy: ioRecordData.organizedBy, organizedAt: ioRecordData.organizedAt, status: ioRecordData.status diff --git a/src/services/dealerClaimApproval.service.ts b/src/services/dealerClaimApproval.service.ts new file mode 100644 index 0000000..71703da --- /dev/null +++ b/src/services/dealerClaimApproval.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + return await ApprovalLevel.findAll({ + where: { requestId }, + order: [['levelNumber', 'ASC']] + }); + } +} + diff --git a/src/services/sapIntegration.service.ts b/src/services/sapIntegration.service.ts index 6b59464..db8681b 100644 --- a/src/services/sapIntegration.service.ts +++ b/src/services/sapIntegration.service.ts @@ -611,9 +611,11 @@ export class SAPIntegrationService { attributeNamePrefix: '@_', textNodeName: '#text', parseAttributeValue: true, - trimValues: true - // Note: fast-xml-parser preserves namespace prefixes by default + trimValues: true, + 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 + // If namespaces are being stripped, we check both 'd:Available_Amount' and 'Available_Amount' }); responseData = parser.parse(response.data); 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) { - try { - logger.info(`[SAP] POST Response Data:`, JSON.stringify(responseData, null, 2)); - } catch (e) { - logger.info(`[SAP] POST Response Data (raw):`, responseData); + if (responseData.entry) { + logger.info(`[SAP] Response has 'entry' structure`); + } else if (responseData.d) { + 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 @@ -672,8 +672,11 @@ export class SAPIntegrationService { // Try various field name variations (both JSON and XML formats) // XML namespace prefixes: 'd:Available_Amount', 'd:RemainingBalance', etc. // 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) - 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('RemainingBalance') ?? getFieldValue('RemainingAmount') ?? @@ -739,8 +742,57 @@ export class SAPIntegrationService { let mainEntryProperties: any = null; // XML structure: entry.link (array) -> find link with @rel='lt_io_output' -> inline.feed.entry - if (responseData.entry) { - const entry = responseData.entry; + // OR JSON OData format: { d: { lt_io_output: { results: [...] } } } + 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 ) + 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) const mainContent = entry.content || {}; @@ -750,139 +802,32 @@ export class SAPIntegrationService { logger.info(`[SAP] Found main entry properties, keys:`, Object.keys(mainEntryProperties)); } - // Find lt_io_output link - // 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" + // Find lt_io_output link in XML structure 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 rel = link['@_rel'] || link.rel || ''; const title = link['@_title'] || link.title || ''; return rel.includes('lt_io_output') || title === 'IOOutputSet' || title === 'lt_io_output'; }); - if (ioOutputLink) { - 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) + ? ioOutputLink.inline.feed.entry[0] + : ioOutputLink.inline.feed.entry; - if (ioOutputLink.inline?.feed?.entry) { - const ioEntry = Array.isArray(ioOutputLink.inline.feed.entry) - ? ioOutputLink.inline.feed.entry[0] - : 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 properties = content['m:properties'] || content.properties || (content['@_type'] === 'application/xml' ? content : null); - - if (properties) { - ioOutputData = properties; - // Try both namespace-prefixed and non-prefixed field names - message = ioOutputData['d:Message'] || ioOutputData.Message || ioOutputData['#text'] || ''; - - 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 = {}; - 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) : [] - }); + const content = ioEntry.content || {}; + const properties = content['m:properties'] || content.properties || (content['@_type'] === 'application/xml' ? content : null); + + if (properties) { + ioOutputData = properties; + message = ioOutputData['d:Message'] || ioOutputData.Message || ioOutputData['#text'] || ''; + logger.info(`[SAP] ✅ Found XML structure - lt_io_output data extracted`); } - } 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 - if (responseData.d) { - // 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) { + // Extract data from ioOutputData (already extracted above for both XML and JSON formats) + if (ioOutputData) { // XML parsed structure - extract from lt_io_output properties // Available_Amount is the remaining balance after blocking 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 - blockId = ioOutputData['d:Sap_Reference_no'] || - ioOutputData.Sap_Reference_no || - ioOutputData['d:Reference'] || - ioOutputData.Reference || - ioOutputData['d:BlockId'] || - ioOutputData.BlockId || - undefined; + // Helper function to extract SAP reference number (similar to extractRemainingBalance) + const extractSapReference = (obj: any): string | undefined => { + if (!obj) return 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:`, { message, availableAmount: remainingBalance, sapReference: blockId, 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) { // Response in ls_response array @@ -950,9 +941,23 @@ export class SAPIntegrationService { remainingBalance, blockId, 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 (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)); @@ -960,9 +965,18 @@ export class SAPIntegrationService { if (success) { 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 { success: true, - blockId: blockId || `BLOCK-${Date.now()}`, + blockId: blockId || undefined, // Only return actual SAP reference number, no fallback blockedAmount, remainingBalance };