dedicated approval service file added and io deduction bug resolved
This commit is contained in:
parent
b864b20bfa
commit
8ce8b2a659
@ -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<void> {
|
||||
@ -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<void> {
|
||||
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<void> {
|
||||
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) {
|
||||
|
||||
@ -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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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<ApprovalLevel | null> {
|
||||
@ -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,14 +385,35 @@ 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: {
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// 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 } }
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
595
src/services/dealerClaimApproval.service.ts
Normal file
595
src/services/dealerClaimApproval.service.ts
Normal 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']]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 <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)
|
||||
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;
|
||||
const content = ioEntry.content || {};
|
||||
const properties = content['m:properties'] || content.properties || (content['@_type'] === 'application/xml' ? content : null);
|
||||
|
||||
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<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) : []
|
||||
});
|
||||
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
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user