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 { Request, Response } from 'express';
|
||||||
import { ApprovalService } from '@services/approval.service';
|
import { ApprovalService } from '@services/approval.service';
|
||||||
|
import { DealerClaimApprovalService } from '@services/dealerClaimApproval.service';
|
||||||
|
import { ApprovalLevel } from '@models/ApprovalLevel';
|
||||||
|
import { WorkflowRequest } from '@models/WorkflowRequest';
|
||||||
import { validateApprovalAction } from '@validators/approval.validator';
|
import { validateApprovalAction } from '@validators/approval.validator';
|
||||||
import { ResponseHandler } from '@utils/responseHandler';
|
import { ResponseHandler } from '@utils/responseHandler';
|
||||||
import type { AuthenticatedRequest } from '../types/express';
|
import type { AuthenticatedRequest } from '../types/express';
|
||||||
import { getRequestMetadata } from '@utils/requestUtils';
|
import { getRequestMetadata } from '@utils/requestUtils';
|
||||||
|
|
||||||
const approvalService = new ApprovalService();
|
const approvalService = new ApprovalService();
|
||||||
|
const dealerClaimApprovalService = new DealerClaimApprovalService();
|
||||||
|
|
||||||
export class ApprovalController {
|
export class ApprovalController {
|
||||||
async approveLevel(req: AuthenticatedRequest, res: Response): Promise<void> {
|
async approveLevel(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
@ -13,18 +17,54 @@ export class ApprovalController {
|
|||||||
const { levelId } = req.params;
|
const { levelId } = req.params;
|
||||||
const validatedData = validateApprovalAction(req.body);
|
const validatedData = validateApprovalAction(req.body);
|
||||||
|
|
||||||
const requestMeta = getRequestMetadata(req);
|
// Determine which service to use based on workflow type
|
||||||
const level = await approvalService.approveLevel(levelId, validatedData, req.user.userId, {
|
const level = await ApprovalLevel.findByPk(levelId);
|
||||||
ipAddress: requestMeta.ipAddress,
|
|
||||||
userAgent: requestMeta.userAgent
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!level) {
|
if (!level) {
|
||||||
ResponseHandler.notFound(res, 'Approval level not found');
|
ResponseHandler.notFound(res, 'Approval level not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ResponseHandler.success(res, level, 'Approval level updated successfully');
|
const workflow = await WorkflowRequest.findByPk(level.requestId);
|
||||||
|
if (!workflow) {
|
||||||
|
ResponseHandler.notFound(res, 'Workflow not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowType = (workflow as any)?.workflowType;
|
||||||
|
const requestMeta = getRequestMetadata(req);
|
||||||
|
|
||||||
|
// Route to appropriate service based on workflow type
|
||||||
|
let approvedLevel: any;
|
||||||
|
if (workflowType === 'CLAIM_MANAGEMENT') {
|
||||||
|
// Use DealerClaimApprovalService for claim management workflows
|
||||||
|
approvedLevel = await dealerClaimApprovalService.approveLevel(
|
||||||
|
levelId,
|
||||||
|
validatedData,
|
||||||
|
req.user.userId,
|
||||||
|
{
|
||||||
|
ipAddress: requestMeta.ipAddress,
|
||||||
|
userAgent: requestMeta.userAgent
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Use ApprovalService for custom workflows
|
||||||
|
approvedLevel = await approvalService.approveLevel(
|
||||||
|
levelId,
|
||||||
|
validatedData,
|
||||||
|
req.user.userId,
|
||||||
|
{
|
||||||
|
ipAddress: requestMeta.ipAddress,
|
||||||
|
userAgent: requestMeta.userAgent
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!approvedLevel) {
|
||||||
|
ResponseHandler.notFound(res, 'Approval level not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResponseHandler.success(res, approvedLevel, 'Approval level updated successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
ResponseHandler.error(res, 'Failed to update approval level', 400, errorMessage);
|
ResponseHandler.error(res, 'Failed to update approval level', 400, errorMessage);
|
||||||
@ -34,7 +74,23 @@ export class ApprovalController {
|
|||||||
async getCurrentApprovalLevel(req: Request, res: Response): Promise<void> {
|
async getCurrentApprovalLevel(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const level = await approvalService.getCurrentApprovalLevel(id);
|
|
||||||
|
// Determine which service to use based on workflow type
|
||||||
|
const workflow = await WorkflowRequest.findByPk(id);
|
||||||
|
if (!workflow) {
|
||||||
|
ResponseHandler.notFound(res, 'Workflow not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowType = (workflow as any)?.workflowType;
|
||||||
|
|
||||||
|
// Route to appropriate service based on workflow type
|
||||||
|
let level: any;
|
||||||
|
if (workflowType === 'CLAIM_MANAGEMENT') {
|
||||||
|
level = await dealerClaimApprovalService.getCurrentApprovalLevel(id);
|
||||||
|
} else {
|
||||||
|
level = await approvalService.getCurrentApprovalLevel(id);
|
||||||
|
}
|
||||||
|
|
||||||
ResponseHandler.success(res, level, 'Current approval level retrieved successfully');
|
ResponseHandler.success(res, level, 'Current approval level retrieved successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -46,7 +102,23 @@ export class ApprovalController {
|
|||||||
async getApprovalLevels(req: Request, res: Response): Promise<void> {
|
async getApprovalLevels(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const levels = await approvalService.getApprovalLevels(id);
|
|
||||||
|
// Determine which service to use based on workflow type
|
||||||
|
const workflow = await WorkflowRequest.findByPk(id);
|
||||||
|
if (!workflow) {
|
||||||
|
ResponseHandler.notFound(res, 'Workflow not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowType = (workflow as any)?.workflowType;
|
||||||
|
|
||||||
|
// Route to appropriate service based on workflow type
|
||||||
|
let levels: any[];
|
||||||
|
if (workflowType === 'CLAIM_MANAGEMENT') {
|
||||||
|
levels = await dealerClaimApprovalService.getApprovalLevels(id);
|
||||||
|
} else {
|
||||||
|
levels = await approvalService.getApprovalLevels(id);
|
||||||
|
}
|
||||||
|
|
||||||
ResponseHandler.success(res, levels, 'Approval levels retrieved successfully');
|
ResponseHandler.success(res, levels, 'Approval levels retrieved successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -838,5 +838,88 @@ export class DealerClaimController {
|
|||||||
return ResponseHandler.error(res, 'Failed to send credit note to dealer', 500, errorMessage);
|
return ResponseHandler.error(res, 'Failed to send credit note to dealer', 500, errorMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test SAP Budget Blocking (for testing/debugging)
|
||||||
|
* POST /api/v1/dealer-claims/test/sap-block
|
||||||
|
*
|
||||||
|
* This endpoint allows direct testing of SAP budget blocking without creating a full request
|
||||||
|
*/
|
||||||
|
async testSapBudgetBlock(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!userId) {
|
||||||
|
return ResponseHandler.error(res, 'Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ioNumber, amount, requestNumber } = req.body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!ioNumber || !amount) {
|
||||||
|
return ResponseHandler.error(res, 'Missing required fields: ioNumber and amount are required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockAmount = parseFloat(amount);
|
||||||
|
if (isNaN(blockAmount) || blockAmount <= 0) {
|
||||||
|
return ResponseHandler.error(res, 'Amount must be a positive number', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimController] Testing SAP budget block:`, {
|
||||||
|
ioNumber,
|
||||||
|
amount: blockAmount,
|
||||||
|
requestNumber: requestNumber || 'TEST-REQUEST',
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
|
||||||
|
// First validate IO number
|
||||||
|
const ioValidation = await sapIntegrationService.validateIONumber(ioNumber);
|
||||||
|
|
||||||
|
if (!ioValidation.isValid) {
|
||||||
|
return ResponseHandler.error(res, `Invalid IO number: ${ioValidation.error || 'IO number not found in SAP'}`, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimController] IO validation successful:`, {
|
||||||
|
ioNumber,
|
||||||
|
availableBalance: ioValidation.availableBalance
|
||||||
|
});
|
||||||
|
|
||||||
|
// Block budget in SAP
|
||||||
|
const testRequestNumber = requestNumber || `TEST-${Date.now()}`;
|
||||||
|
const blockResult = await sapIntegrationService.blockBudget(
|
||||||
|
ioNumber,
|
||||||
|
blockAmount,
|
||||||
|
testRequestNumber,
|
||||||
|
`Test budget block for ${testRequestNumber}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!blockResult.success) {
|
||||||
|
return ResponseHandler.error(res, `Failed to block budget in SAP: ${blockResult.error}`, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return detailed response
|
||||||
|
return ResponseHandler.success(res, {
|
||||||
|
message: 'SAP budget block test successful',
|
||||||
|
ioNumber,
|
||||||
|
requestedAmount: blockAmount,
|
||||||
|
availableBalance: ioValidation.availableBalance,
|
||||||
|
sapResponse: {
|
||||||
|
success: blockResult.success,
|
||||||
|
blockedAmount: blockResult.blockedAmount,
|
||||||
|
remainingBalance: blockResult.remainingBalance,
|
||||||
|
sapDocumentNumber: blockResult.blockId || null,
|
||||||
|
error: blockResult.error || null
|
||||||
|
},
|
||||||
|
calculatedRemainingBalance: ioValidation.availableBalance - blockResult.blockedAmount,
|
||||||
|
validation: {
|
||||||
|
isValid: ioValidation.isValid,
|
||||||
|
availableBalance: ioValidation.availableBalance,
|
||||||
|
error: ioValidation.error || null
|
||||||
|
}
|
||||||
|
}, 'SAP budget block test completed');
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('[DealerClaimController] Error testing SAP budget block:', error);
|
||||||
|
return ResponseHandler.error(res, error.message || 'Failed to test SAP budget block', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -93,5 +93,13 @@ router.put('/:requestId/credit-note', authenticateToken, asyncHandler(dealerClai
|
|||||||
*/
|
*/
|
||||||
router.post('/:requestId/credit-note/send', authenticateToken, asyncHandler(dealerClaimController.sendCreditNoteToDealer.bind(dealerClaimController)));
|
router.post('/:requestId/credit-note/send', authenticateToken, asyncHandler(dealerClaimController.sendCreditNoteToDealer.bind(dealerClaimController)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/v1/dealer-claims/test/sap-block
|
||||||
|
* @desc Test SAP budget blocking directly (for testing/debugging)
|
||||||
|
* @access Private
|
||||||
|
* @body { ioNumber: string, amount: number, requestNumber?: string }
|
||||||
|
*/
|
||||||
|
router.post('/test/sap-block', authenticateToken, asyncHandler(dealerClaimController.testSapBudgetBlock.bind(dealerClaimController)));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { notificationService } from './notification.service';
|
|||||||
import { activityService } from './activity.service';
|
import { activityService } from './activity.service';
|
||||||
import { tatSchedulerService } from './tatScheduler.service';
|
import { tatSchedulerService } from './tatScheduler.service';
|
||||||
import { emitToRequestRoom } from '../realtime/socket';
|
import { emitToRequestRoom } from '../realtime/socket';
|
||||||
import { DealerClaimService } from './dealerClaim.service';
|
// Note: DealerClaimService import removed - dealer claim approvals are handled by DealerClaimApprovalService
|
||||||
|
|
||||||
export class ApprovalService {
|
export class ApprovalService {
|
||||||
async approveLevel(levelId: string, action: ApprovalAction, _userId: string, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): Promise<ApprovalLevel | null> {
|
async approveLevel(levelId: string, action: ApprovalAction, _userId: string, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): Promise<ApprovalLevel | null> {
|
||||||
@ -24,6 +24,13 @@ export class ApprovalService {
|
|||||||
const wf = await WorkflowRequest.findByPk(level.requestId);
|
const wf = await WorkflowRequest.findByPk(level.requestId);
|
||||||
if (!wf) return null;
|
if (!wf) return null;
|
||||||
|
|
||||||
|
// Verify this is NOT a claim management workflow (should use DealerClaimApprovalService)
|
||||||
|
const workflowType = (wf as any)?.workflowType;
|
||||||
|
if (workflowType === 'CLAIM_MANAGEMENT') {
|
||||||
|
logger.error(`[Approval] Attempted to use ApprovalService for CLAIM_MANAGEMENT workflow ${level.requestId}. Use DealerClaimApprovalService instead.`);
|
||||||
|
throw new Error('ApprovalService cannot be used for CLAIM_MANAGEMENT workflows. Use DealerClaimApprovalService instead.');
|
||||||
|
}
|
||||||
|
|
||||||
const priority = ((wf as any)?.priority || 'standard').toString().toLowerCase();
|
const priority = ((wf as any)?.priority || 'standard').toString().toLowerCase();
|
||||||
const isPaused = (wf as any).isPaused || (level as any).isPaused;
|
const isPaused = (wf as any).isPaused || (level as any).isPaused;
|
||||||
|
|
||||||
@ -378,13 +385,34 @@ export class ApprovalService {
|
|||||||
throw new Error('Cannot advance workflow - workflow is currently paused. Please resume the workflow first.');
|
throw new Error('Cannot advance workflow - workflow is currently paused. Please resume the workflow first.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextLevelNumber = (level.levelNumber || 0) + 1;
|
// Find the next PENDING level
|
||||||
|
// Custom workflows use strict sequential ordering (levelNumber + 1) to maintain intended order
|
||||||
|
// This ensures custom workflows work predictably and don't skip levels
|
||||||
|
const currentLevelNumber = level.levelNumber || 0;
|
||||||
|
logger.info(`[Approval] Finding next level after level ${currentLevelNumber} for request ${level.requestId} (Custom workflow)`);
|
||||||
|
|
||||||
|
// Use strict sequential approach for custom workflows
|
||||||
const nextLevel = await ApprovalLevel.findOne({
|
const nextLevel = await ApprovalLevel.findOne({
|
||||||
where: {
|
where: {
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
levelNumber: nextLevelNumber
|
levelNumber: currentLevelNumber + 1
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!nextLevel) {
|
||||||
|
logger.info(`[Approval] Sequential level ${currentLevelNumber + 1} not found for custom workflow - this may be the final approval`);
|
||||||
|
} else if (nextLevel.status !== ApprovalStatus.PENDING) {
|
||||||
|
// Sequential level exists but not PENDING - log warning but proceed
|
||||||
|
logger.warn(`[Approval] Sequential level ${currentLevelNumber + 1} exists but status is ${nextLevel.status}, expected PENDING. Proceeding with sequential level to maintain workflow order.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLevelNumber = nextLevel ? (nextLevel.levelNumber || 0) : null;
|
||||||
|
|
||||||
|
if (nextLevel) {
|
||||||
|
logger.info(`[Approval] Found next level: ${nextLevelNumber} (${(nextLevel as any).levelName || 'unnamed'}), approver: ${(nextLevel as any).approverName || (nextLevel as any).approverEmail || 'unknown'}, status: ${nextLevel.status}`);
|
||||||
|
} else {
|
||||||
|
logger.info(`[Approval] No next level found after level ${currentLevelNumber} - this may be the final approval`);
|
||||||
|
}
|
||||||
|
|
||||||
if (nextLevel) {
|
if (nextLevel) {
|
||||||
// Check if next level is paused - if so, don't activate it
|
// Check if next level is paused - if so, don't activate it
|
||||||
@ -419,44 +447,20 @@ export class ApprovalService {
|
|||||||
// Don't fail the approval if TAT scheduling fails
|
// Don't fail the approval if TAT scheduling fails
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update workflow current level
|
// Update workflow current level (only if nextLevelNumber is not null)
|
||||||
await WorkflowRequest.update(
|
if (nextLevelNumber !== null) {
|
||||||
{ currentLevel: nextLevelNumber },
|
await WorkflowRequest.update(
|
||||||
{ where: { requestId: level.requestId } }
|
{ currentLevel: nextLevelNumber },
|
||||||
);
|
{ where: { requestId: level.requestId } }
|
||||||
|
);
|
||||||
logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`);
|
logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`);
|
||||||
|
} else {
|
||||||
// Check if this is Department Lead approval in a claim management workflow
|
logger.warn(`Approved level ${level.levelNumber} but no next level found - workflow may be complete`);
|
||||||
// Activity Creation is now an activity log only, not an approval step
|
|
||||||
const workflowType = (wf as any)?.workflowType;
|
|
||||||
const isClaimManagement = workflowType === 'CLAIM_MANAGEMENT';
|
|
||||||
|
|
||||||
// Check if current level is Department Lead (by levelName, not hardcoded step number)
|
|
||||||
const currentLevelName = (level.levelName || '').toLowerCase();
|
|
||||||
const isDeptLeadApproval = currentLevelName.includes('department lead') || level.levelNumber === 3;
|
|
||||||
|
|
||||||
// Check if current level is Requestor Claim Approval (Step 5, was Step 6)
|
|
||||||
const isRequestorClaimApproval = currentLevelName.includes('requestor') &&
|
|
||||||
(currentLevelName.includes('claim') || currentLevelName.includes('approval')) ||
|
|
||||||
level.levelNumber === 5;
|
|
||||||
|
|
||||||
if (isClaimManagement && isDeptLeadApproval) {
|
|
||||||
// Activity Creation is now an activity log only - process it automatically
|
|
||||||
logger.info(`[Approval] Department Lead approved for claim management workflow. Processing Activity Creation as activity log.`);
|
|
||||||
try {
|
|
||||||
const dealerClaimService = new DealerClaimService();
|
|
||||||
await dealerClaimService.processActivityCreation(level.requestId);
|
|
||||||
logger.info(`[Approval] Activity Creation activity logged for request ${level.requestId}`);
|
|
||||||
} catch (activityError) {
|
|
||||||
logger.error(`[Approval] Error processing Activity Creation activity for request ${level.requestId}:`, activityError);
|
|
||||||
// Don't fail the Department Lead approval if Activity Creation logging fails - log and continue
|
|
||||||
}
|
|
||||||
} else if (isClaimManagement && isRequestorClaimApproval) {
|
|
||||||
// E-Invoice Generation is now an activity log only - will be logged when invoice is generated via DMS webhook
|
|
||||||
logger.info(`[Approval] Requestor Claim Approval approved for claim management workflow. E-Invoice generation will be logged as activity when DMS webhook is received.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: Dealer claim-specific logic (Activity Creation, E-Invoice) is handled by DealerClaimApprovalService
|
||||||
|
// This service is for custom workflows only
|
||||||
|
|
||||||
// Log approval activity
|
// Log approval activity
|
||||||
activityService.log({
|
activityService.log({
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
@ -545,38 +549,17 @@ export class ApprovalService {
|
|||||||
logger.info(`[Approval] Skipping notification for auto-step at level ${nextLevelNumber}`);
|
logger.info(`[Approval] Skipping notification for auto-step at level ${nextLevelNumber}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify initiator when dealer submits documents (Dealer Proposal or Dealer Completion Documents approval in claim management)
|
// Note: Dealer-specific notifications (proposal/completion submissions) are handled by DealerClaimApprovalService
|
||||||
const levelName = (level.levelName || '').toLowerCase();
|
|
||||||
const isDealerProposalApproval = levelName.includes('dealer') && levelName.includes('proposal') || level.levelNumber === 1;
|
|
||||||
const isDealerCompletionApproval = levelName.includes('dealer') && (levelName.includes('completion') || levelName.includes('documents')) || level.levelNumber === 5;
|
|
||||||
|
|
||||||
if (isClaimManagement && (isDealerProposalApproval || isDealerCompletionApproval) && (wf as any).initiatorId) {
|
|
||||||
const stepMessage = isDealerProposalApproval
|
|
||||||
? 'Dealer proposal has been submitted and is now under review.'
|
|
||||||
: 'Dealer completion documents have been submitted and are now under review.';
|
|
||||||
|
|
||||||
await notificationService.sendToUsers([(wf as any).initiatorId], {
|
|
||||||
title: isDealerProposalApproval ? 'Proposal Submitted' : 'Completion Documents Submitted',
|
|
||||||
body: `Your claim request "${(wf as any).title}" - ${stepMessage}`,
|
|
||||||
requestNumber: (wf as any).requestNumber,
|
|
||||||
requestId: (wf as any).requestId,
|
|
||||||
url: `/request/${(wf as any).requestNumber}`,
|
|
||||||
type: 'approval',
|
|
||||||
priority: 'MEDIUM',
|
|
||||||
actionRequired: false
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`[Approval] Sent notification to initiator for ${isDealerProposalApproval ? 'Dealer Proposal Submission' : 'Dealer Completion Documents'} approval in claim management workflow`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No next level found but not final approver - this shouldn't happen
|
// No next level found but not final approver - this shouldn't happen
|
||||||
logger.warn(`No next level found for workflow ${level.requestId} after approving level ${level.levelNumber}`);
|
logger.warn(`No next level found for workflow ${level.requestId} after approving level ${level.levelNumber}`);
|
||||||
|
// Use current level number since there's no next level (workflow is complete)
|
||||||
await WorkflowRequest.update(
|
await WorkflowRequest.update(
|
||||||
{
|
{
|
||||||
status: WorkflowStatus.APPROVED,
|
status: WorkflowStatus.APPROVED,
|
||||||
closureDate: now,
|
closureDate: now,
|
||||||
currentLevel: nextLevelNumber
|
currentLevel: level.levelNumber || 0
|
||||||
},
|
},
|
||||||
{ where: { requestId: level.requestId } }
|
{ where: { requestId: level.requestId } }
|
||||||
);
|
);
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { ApprovalLevel } from '../models/ApprovalLevel';
|
|||||||
import { Participant } from '../models/Participant';
|
import { Participant } from '../models/Participant';
|
||||||
import { User } from '../models/User';
|
import { User } from '../models/User';
|
||||||
import { WorkflowService } from './workflow.service';
|
import { WorkflowService } from './workflow.service';
|
||||||
import { ApprovalService } from './approval.service';
|
import { DealerClaimApprovalService } from './dealerClaimApproval.service';
|
||||||
import { generateRequestNumber } from '../utils/helpers';
|
import { generateRequestNumber } from '../utils/helpers';
|
||||||
import { Priority, WorkflowStatus, ApprovalStatus, ParticipantType } from '../types/common.types';
|
import { Priority, WorkflowStatus, ApprovalStatus, ParticipantType } from '../types/common.types';
|
||||||
import { sapIntegrationService } from './sapIntegration.service';
|
import { sapIntegrationService } from './sapIntegration.service';
|
||||||
@ -28,7 +28,7 @@ import logger from '../utils/logger';
|
|||||||
*/
|
*/
|
||||||
export class DealerClaimService {
|
export class DealerClaimService {
|
||||||
private workflowService = new WorkflowService();
|
private workflowService = new WorkflowService();
|
||||||
private approvalService = new ApprovalService();
|
private approvalService = new DealerClaimApprovalService();
|
||||||
private userService = new UserService();
|
private userService = new UserService();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1508,8 +1508,18 @@ export class DealerClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sapReturnedBlockedAmount = blockResult.blockedAmount;
|
const sapReturnedBlockedAmount = blockResult.blockedAmount;
|
||||||
|
// Extract SAP reference number from blockId (this is the Sap_Reference_no from SAP response)
|
||||||
|
// Only use the actual SAP reference number - don't use any generated fallback
|
||||||
|
const sapDocumentNumber = blockResult.blockId || undefined;
|
||||||
const availableBalance = ioData.availableBalance || ioValidation.availableBalance;
|
const availableBalance = ioData.availableBalance || ioValidation.availableBalance;
|
||||||
|
|
||||||
|
// Log if SAP reference number was received
|
||||||
|
if (sapDocumentNumber) {
|
||||||
|
logger.info(`[DealerClaimService] ✅ SAP Reference Number received: ${sapDocumentNumber}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`[DealerClaimService] ⚠️ No SAP Reference Number received from SAP response`);
|
||||||
|
}
|
||||||
|
|
||||||
// Use the amount we REQUESTED for calculation, not what SAP returned
|
// Use the amount we REQUESTED for calculation, not what SAP returned
|
||||||
// SAP might return a slightly different amount due to rounding, but we calculate based on what we requested
|
// SAP might return a slightly different amount due to rounding, but we calculate based on what we requested
|
||||||
// Only use SAP's returned amount if it's significantly different (more than 1 rupee), which would indicate an actual issue
|
// Only use SAP's returned amount if it's significantly different (more than 1 rupee), which would indicate an actual issue
|
||||||
@ -1522,6 +1532,7 @@ export class DealerClaimService {
|
|||||||
requestedAmount: blockedAmount,
|
requestedAmount: blockedAmount,
|
||||||
sapReturnedBlockedAmount: sapReturnedBlockedAmount,
|
sapReturnedBlockedAmount: sapReturnedBlockedAmount,
|
||||||
sapReturnedRemainingBalance: blockResult.remainingBalance,
|
sapReturnedRemainingBalance: blockResult.remainingBalance,
|
||||||
|
sapDocumentNumber: sapDocumentNumber, // SAP reference number from response
|
||||||
availableBalance,
|
availableBalance,
|
||||||
amountDifference,
|
amountDifference,
|
||||||
usingSapAmount: useSapAmount,
|
usingSapAmount: useSapAmount,
|
||||||
@ -1585,6 +1596,7 @@ export class DealerClaimService {
|
|||||||
ioAvailableBalance: roundedAvailableBalance,
|
ioAvailableBalance: roundedAvailableBalance,
|
||||||
ioBlockedAmount: roundedBlockedAmount,
|
ioBlockedAmount: roundedBlockedAmount,
|
||||||
ioRemainingBalance: roundedRemainingBalance,
|
ioRemainingBalance: roundedRemainingBalance,
|
||||||
|
sapDocumentNumber: sapDocumentNumber, // Store SAP reference number
|
||||||
organizedBy: organizedBy || undefined,
|
organizedBy: organizedBy || undefined,
|
||||||
organizedAt: new Date(),
|
organizedAt: new Date(),
|
||||||
status: IOStatus.BLOCKED,
|
status: IOStatus.BLOCKED,
|
||||||
@ -1595,6 +1607,7 @@ export class DealerClaimService {
|
|||||||
ioAvailableBalance: availableBalance,
|
ioAvailableBalance: availableBalance,
|
||||||
ioBlockedAmount: finalBlockedAmount,
|
ioBlockedAmount: finalBlockedAmount,
|
||||||
ioRemainingBalance: finalRemainingBalance,
|
ioRemainingBalance: finalRemainingBalance,
|
||||||
|
sapDocumentNumber: sapDocumentNumber,
|
||||||
requestId
|
requestId
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1609,7 +1622,8 @@ export class DealerClaimService {
|
|||||||
logger.info(`[DealerClaimService] Update data:`, {
|
logger.info(`[DealerClaimService] Update data:`, {
|
||||||
ioRemainingBalance: ioRecordData.ioRemainingBalance,
|
ioRemainingBalance: ioRecordData.ioRemainingBalance,
|
||||||
ioBlockedAmount: ioRecordData.ioBlockedAmount,
|
ioBlockedAmount: ioRecordData.ioBlockedAmount,
|
||||||
ioAvailableBalance: ioRecordData.ioAvailableBalance
|
ioAvailableBalance: ioRecordData.ioAvailableBalance,
|
||||||
|
sapDocumentNumber: ioRecordData.sapDocumentNumber
|
||||||
});
|
});
|
||||||
|
|
||||||
// Explicitly update all fields to ensure remainingBalance is saved
|
// Explicitly update all fields to ensure remainingBalance is saved
|
||||||
@ -1619,6 +1633,7 @@ export class DealerClaimService {
|
|||||||
ioAvailableBalance: ioRecordData.ioAvailableBalance,
|
ioAvailableBalance: ioRecordData.ioAvailableBalance,
|
||||||
ioBlockedAmount: ioRecordData.ioBlockedAmount,
|
ioBlockedAmount: ioRecordData.ioBlockedAmount,
|
||||||
ioRemainingBalance: ioRecordData.ioRemainingBalance, // Explicitly ensure this is updated
|
ioRemainingBalance: ioRecordData.ioRemainingBalance, // Explicitly ensure this is updated
|
||||||
|
sapDocumentNumber: ioRecordData.sapDocumentNumber, // Update SAP document number
|
||||||
organizedBy: ioRecordData.organizedBy,
|
organizedBy: ioRecordData.organizedBy,
|
||||||
organizedAt: ioRecordData.organizedAt,
|
organizedAt: ioRecordData.organizedAt,
|
||||||
status: ioRecordData.status
|
status: ioRecordData.status
|
||||||
|
|||||||
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: '@_',
|
attributeNamePrefix: '@_',
|
||||||
textNodeName: '#text',
|
textNodeName: '#text',
|
||||||
parseAttributeValue: true,
|
parseAttributeValue: true,
|
||||||
trimValues: true
|
trimValues: true,
|
||||||
// Note: fast-xml-parser preserves namespace prefixes by default
|
preserveOrder: false,
|
||||||
|
// Note: fast-xml-parser v4+ preserves namespace prefixes by default
|
||||||
// So 'd:Available_Amount' should remain as 'd:Available_Amount' in the parsed object
|
// So 'd:Available_Amount' should remain as 'd:Available_Amount' in the parsed object
|
||||||
|
// If namespaces are being stripped, we check both 'd:Available_Amount' and 'Available_Amount'
|
||||||
});
|
});
|
||||||
responseData = parser.parse(response.data);
|
responseData = parser.parse(response.data);
|
||||||
logger.info(`[SAP] XML parsed successfully`);
|
logger.info(`[SAP] XML parsed successfully`);
|
||||||
@ -623,15 +625,13 @@ export class SAPIntegrationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log response data - handle different formats
|
// Log response data summary
|
||||||
if (responseData) {
|
if (responseData) {
|
||||||
try {
|
if (responseData.entry) {
|
||||||
logger.info(`[SAP] POST Response Data:`, JSON.stringify(responseData, null, 2));
|
logger.info(`[SAP] Response has 'entry' structure`);
|
||||||
} catch (e) {
|
} else if (responseData.d) {
|
||||||
logger.info(`[SAP] POST Response Data (raw):`, responseData);
|
logger.info(`[SAP] Response has OData 'd' wrapper`);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
logger.info(`[SAP] POST Response Data: (empty or null)`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also log the request that was sent
|
// Also log the request that was sent
|
||||||
@ -672,8 +672,11 @@ export class SAPIntegrationService {
|
|||||||
// Try various field name variations (both JSON and XML formats)
|
// Try various field name variations (both JSON and XML formats)
|
||||||
// XML namespace prefixes: 'd:Available_Amount', 'd:RemainingBalance', etc.
|
// XML namespace prefixes: 'd:Available_Amount', 'd:RemainingBalance', etc.
|
||||||
// IMPORTANT: Check 'd:Available_Amount' first as that's what SAP returns in XML
|
// IMPORTANT: Check 'd:Available_Amount' first as that's what SAP returns in XML
|
||||||
|
// Also check without namespace prefix as parser might strip it
|
||||||
const value = getFieldValue('d:Available_Amount') ?? // XML format with namespace prefix (PRIORITY)
|
const value = getFieldValue('d:Available_Amount') ?? // XML format with namespace prefix (PRIORITY)
|
||||||
getFieldValue('Available_Amount') ?? // XML format without prefix
|
getFieldValue('Available_Amount') ?? // XML format without prefix (parser might strip 'd:')
|
||||||
|
getFieldValue('d:AvailableAmount') ?? // CamelCase variation with prefix
|
||||||
|
getFieldValue('AvailableAmount') ?? // CamelCase variation without prefix
|
||||||
getFieldValue('d:RemainingBalance') ??
|
getFieldValue('d:RemainingBalance') ??
|
||||||
getFieldValue('RemainingBalance') ??
|
getFieldValue('RemainingBalance') ??
|
||||||
getFieldValue('RemainingAmount') ??
|
getFieldValue('RemainingAmount') ??
|
||||||
@ -739,8 +742,57 @@ export class SAPIntegrationService {
|
|||||||
let mainEntryProperties: any = null;
|
let mainEntryProperties: any = null;
|
||||||
|
|
||||||
// XML structure: entry.link (array) -> find link with @rel='lt_io_output' -> inline.feed.entry
|
// XML structure: entry.link (array) -> find link with @rel='lt_io_output' -> inline.feed.entry
|
||||||
if (responseData.entry) {
|
// OR JSON OData format: { d: { lt_io_output: { results: [...] } } }
|
||||||
const entry = responseData.entry;
|
if (responseData.d) {
|
||||||
|
// Handle OData JSON format
|
||||||
|
if (responseData.d.entry) {
|
||||||
|
responseData = responseData.d;
|
||||||
|
} else if (responseData.d.lt_io_output) {
|
||||||
|
// Extract from d.lt_io_output
|
||||||
|
const ltIoOutput = responseData.d.lt_io_output;
|
||||||
|
if (ltIoOutput.results && Array.isArray(ltIoOutput.results) && ltIoOutput.results.length > 0) {
|
||||||
|
ioOutputData = ltIoOutput.results[0];
|
||||||
|
message = ioOutputData.Message || ioOutputData['d:Message'] || '';
|
||||||
|
logger.info(`[SAP] ✅ Extracted data from JSON OData format (d.lt_io_output.results[0])`);
|
||||||
|
} else if (typeof ltIoOutput === 'object' && !Array.isArray(ltIoOutput)) {
|
||||||
|
if (ltIoOutput.results && Array.isArray(ltIoOutput.results) && ltIoOutput.results.length > 0) {
|
||||||
|
ioOutputData = ltIoOutput.results[0];
|
||||||
|
message = ioOutputData?.Message || ioOutputData?.['d:Message'] || '';
|
||||||
|
logger.info(`[SAP] ✅ Extracted data from JSON OData format (d.lt_io_output.results[0])`);
|
||||||
|
} else {
|
||||||
|
ioOutputData = ltIoOutput;
|
||||||
|
message = ioOutputData.Message || ioOutputData['d:Message'] || '';
|
||||||
|
logger.info(`[SAP] ✅ Extracted data from JSON OData format (d.lt_io_output)`);
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(ltIoOutput) && ltIoOutput.length > 0) {
|
||||||
|
ioOutputData = ltIoOutput[0];
|
||||||
|
message = ioOutputData?.Message || ioOutputData?.['d:Message'] || '';
|
||||||
|
logger.info(`[SAP] ✅ Extracted data from JSON OData format (d.lt_io_output[0])`);
|
||||||
|
}
|
||||||
|
} else if (Object.keys(responseData.d).some(key => key.includes('link') || key.includes('content'))) {
|
||||||
|
responseData = responseData.d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sometimes XML parser might create the root element with a different name
|
||||||
|
// Check if responseData itself IS the entry (if root element was <entry>)
|
||||||
|
let actualEntry = responseData.entry;
|
||||||
|
if (!actualEntry && responseData && typeof responseData === 'object' && !Array.isArray(responseData)) {
|
||||||
|
const currentKeys = Object.keys(responseData);
|
||||||
|
if (currentKeys.includes('link') || currentKeys.includes('content') || currentKeys.includes('id') || currentKeys.includes('title')) {
|
||||||
|
actualEntry = responseData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if responseData might be an array (sometimes XML parser returns arrays)
|
||||||
|
if (Array.isArray(responseData) && responseData.length > 0 && responseData[0]?.entry) {
|
||||||
|
responseData = responseData[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use actualEntry if we found it, otherwise try responseData.entry
|
||||||
|
const entry = actualEntry || responseData.entry;
|
||||||
|
|
||||||
|
if (entry && !ioOutputData) {
|
||||||
|
|
||||||
// Also check main entry properties (sometimes Available_Amount is here)
|
// Also check main entry properties (sometimes Available_Amount is here)
|
||||||
const mainContent = entry.content || {};
|
const mainContent = entry.content || {};
|
||||||
@ -750,139 +802,32 @@ export class SAPIntegrationService {
|
|||||||
logger.info(`[SAP] Found main entry properties, keys:`, Object.keys(mainEntryProperties));
|
logger.info(`[SAP] Found main entry properties, keys:`, Object.keys(mainEntryProperties));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find lt_io_output link
|
// Find lt_io_output link in XML structure
|
||||||
// The rel attribute can be a full URL like "http://schemas.microsoft.com/ado/2007/08/dataservices/related/lt_io_output"
|
|
||||||
// or just "lt_io_output", so we check if it includes "lt_io_output"
|
|
||||||
const links = Array.isArray(entry.link) ? entry.link : (entry.link ? [entry.link] : []);
|
const links = Array.isArray(entry.link) ? entry.link : (entry.link ? [entry.link] : []);
|
||||||
|
|
||||||
logger.debug(`[SAP] All links in entry:`, links.map((l: any) => ({
|
|
||||||
rel: l['@_rel'] || l.rel,
|
|
||||||
title: l['@_title'] || l.title,
|
|
||||||
href: l['@_href'] || l.href
|
|
||||||
})));
|
|
||||||
|
|
||||||
const ioOutputLink = links.find((link: any) => {
|
const ioOutputLink = links.find((link: any) => {
|
||||||
const rel = link['@_rel'] || link.rel || '';
|
const rel = link['@_rel'] || link.rel || '';
|
||||||
const title = link['@_title'] || link.title || '';
|
const title = link['@_title'] || link.title || '';
|
||||||
return rel.includes('lt_io_output') || title === 'IOOutputSet' || title === 'lt_io_output';
|
return rel.includes('lt_io_output') || title === 'IOOutputSet' || title === 'lt_io_output';
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ioOutputLink) {
|
if (ioOutputLink?.inline?.feed?.entry) {
|
||||||
logger.info(`[SAP] Found lt_io_output link:`, {
|
const ioEntry = Array.isArray(ioOutputLink.inline.feed.entry)
|
||||||
rel: ioOutputLink['@_rel'] || ioOutputLink.rel,
|
? ioOutputLink.inline.feed.entry[0]
|
||||||
title: ioOutputLink['@_title'] || ioOutputLink.title,
|
: ioOutputLink.inline.feed.entry;
|
||||||
hasInline: !!ioOutputLink.inline,
|
|
||||||
hasFeed: !!ioOutputLink.inline?.feed,
|
|
||||||
hasEntry: !!ioOutputLink.inline?.feed?.entry
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ioOutputLink.inline?.feed?.entry) {
|
const content = ioEntry.content || {};
|
||||||
const ioEntry = Array.isArray(ioOutputLink.inline.feed.entry)
|
const properties = content['m:properties'] || content.properties || (content['@_type'] === 'application/xml' ? content : null);
|
||||||
? ioOutputLink.inline.feed.entry[0]
|
|
||||||
: ioOutputLink.inline.feed.entry;
|
if (properties) {
|
||||||
|
ioOutputData = properties;
|
||||||
logger.debug(`[SAP] IO Entry structure:`, {
|
message = ioOutputData['d:Message'] || ioOutputData.Message || ioOutputData['#text'] || '';
|
||||||
hasContent: !!ioEntry.content,
|
logger.info(`[SAP] ✅ Found XML structure - lt_io_output data extracted`);
|
||||||
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) : []
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
logger.warn(`[SAP] ⚠️ No lt_io_output link found in entry.links. Available links:`, links.map((l: any) => ({
|
|
||||||
rel: l['@_rel'] || l.rel || 'unknown',
|
|
||||||
title: l['@_title'] || l.title || 'unknown'
|
|
||||||
})));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for JSON OData format
|
// Extract data from ioOutputData (already extracted above for both XML and JSON formats)
|
||||||
if (responseData.d) {
|
if (ioOutputData) {
|
||||||
// OData format: { d: { ... } }
|
|
||||||
const data = responseData.d;
|
|
||||||
success = data.Success !== false && data.Message !== 'Error';
|
|
||||||
blockedAmount = extractBlockedAmount(data);
|
|
||||||
remainingBalance = extractRemainingBalance(data);
|
|
||||||
blockId = data.BlockId || data.Reference || data.RequestId || data.DocumentNumber || data.Sap_Reference_no || undefined;
|
|
||||||
} else if (ioOutputData) {
|
|
||||||
// XML parsed structure - extract from lt_io_output properties
|
// XML parsed structure - extract from lt_io_output properties
|
||||||
// Available_Amount is the remaining balance after blocking
|
// Available_Amount is the remaining balance after blocking
|
||||||
success = message.includes('Successful') || message.includes('Success') || !message.includes('Error');
|
success = message.includes('Successful') || message.includes('Success') || !message.includes('Error');
|
||||||
@ -898,21 +843,67 @@ export class SAPIntegrationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try both namespace-prefixed and non-prefixed field names
|
// Helper function to extract SAP reference number (similar to extractRemainingBalance)
|
||||||
blockId = ioOutputData['d:Sap_Reference_no'] ||
|
const extractSapReference = (obj: any): string | undefined => {
|
||||||
ioOutputData.Sap_Reference_no ||
|
if (!obj) return undefined;
|
||||||
ioOutputData['d:Reference'] ||
|
|
||||||
ioOutputData.Reference ||
|
const getFieldValue = (fieldName: string): any => {
|
||||||
ioOutputData['d:BlockId'] ||
|
const field = obj[fieldName];
|
||||||
ioOutputData.BlockId ||
|
if (field === undefined || field === null) return null;
|
||||||
undefined;
|
|
||||||
|
// If it's an object with #text property (XML parser sometimes does this)
|
||||||
|
if (typeof field === 'object' && field['#text'] !== undefined) {
|
||||||
|
return field['#text'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct value
|
||||||
|
return field;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try various field name variations for SAP reference number
|
||||||
|
const value = getFieldValue('d:Sap_Reference_no') ?? // XML format with namespace prefix (PRIORITY)
|
||||||
|
getFieldValue('Sap_Reference_no') ?? // XML format without prefix
|
||||||
|
getFieldValue('d:SapReferenceNo') ??
|
||||||
|
getFieldValue('SapReferenceNo') ??
|
||||||
|
getFieldValue('d:Reference') ??
|
||||||
|
getFieldValue('Reference') ??
|
||||||
|
getFieldValue('d:BlockId') ??
|
||||||
|
getFieldValue('BlockId') ??
|
||||||
|
getFieldValue('d:DocumentNumber') ??
|
||||||
|
getFieldValue('DocumentNumber') ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
logger.debug(`[SAP] extractSapReference: No value found. Object keys:`, Object.keys(obj));
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to string and trim
|
||||||
|
const valueStr = String(value).trim();
|
||||||
|
logger.debug(`[SAP] extractSapReference: Extracted value "${valueStr}"`);
|
||||||
|
return valueStr || undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract SAP reference number using helper function
|
||||||
|
blockId = extractSapReference(ioOutputData) || extractSapReference(mainEntryProperties) || undefined;
|
||||||
|
|
||||||
|
// Log detailed information for debugging
|
||||||
logger.info(`[SAP] Extracted from XML lt_io_output:`, {
|
logger.info(`[SAP] Extracted from XML lt_io_output:`, {
|
||||||
message,
|
message,
|
||||||
availableAmount: remainingBalance,
|
availableAmount: remainingBalance,
|
||||||
sapReference: blockId,
|
sapReference: blockId,
|
||||||
allKeys: Object.keys(ioOutputData),
|
allKeys: Object.keys(ioOutputData),
|
||||||
foundInMainEntry: remainingBalance > 0 && mainEntryProperties ? true : false
|
sampleKeys: Object.keys(ioOutputData).slice(0, 10), // First 10 keys for debugging
|
||||||
|
foundInMainEntry: remainingBalance > 0 && mainEntryProperties ? true : false,
|
||||||
|
ioOutputDataSample: Object.keys(ioOutputData).reduce((acc: any, key: string) => {
|
||||||
|
if (key.toLowerCase().includes('available') ||
|
||||||
|
key.toLowerCase().includes('amount') ||
|
||||||
|
key.toLowerCase().includes('reference') ||
|
||||||
|
key.toLowerCase().includes('sap')) {
|
||||||
|
acc[key] = ioOutputData[key];
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
});
|
});
|
||||||
} else if (responseData.ls_response && Array.isArray(responseData.ls_response) && responseData.ls_response.length > 0) {
|
} else if (responseData.ls_response && Array.isArray(responseData.ls_response) && responseData.ls_response.length > 0) {
|
||||||
// Response in ls_response array
|
// Response in ls_response array
|
||||||
@ -950,9 +941,23 @@ export class SAPIntegrationService {
|
|||||||
remainingBalance,
|
remainingBalance,
|
||||||
blockId,
|
blockId,
|
||||||
responseStructure: ioOutputData ? 'XML lt_io_output' : responseData.d ? 'JSON d' : responseData.ls_response ? 'ls_response' : responseData.lt_io_output ? 'lt_io_output' : 'unknown',
|
responseStructure: ioOutputData ? 'XML lt_io_output' : responseData.d ? 'JSON d' : responseData.ls_response ? 'ls_response' : responseData.lt_io_output ? 'lt_io_output' : 'unknown',
|
||||||
note: remainingBalance === 0 ? '⚠️ Remaining balance is 0 - will be calculated from availableBalance - blockedAmount' : '✅ Remaining balance from SAP response'
|
note: remainingBalance === 0 ? '⚠️ Remaining balance is 0 - will be calculated from availableBalance - blockedAmount' : '✅ Remaining balance from SAP response',
|
||||||
|
hasIoOutputData: !!ioOutputData,
|
||||||
|
ioOutputDataKeys: ioOutputData ? Object.keys(ioOutputData) : null,
|
||||||
|
hasMainEntryProperties: !!mainEntryProperties,
|
||||||
|
mainEntryPropertiesKeys: mainEntryProperties ? Object.keys(mainEntryProperties) : null
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If ioOutputData exists but we didn't extract values, log detailed info
|
||||||
|
if (ioOutputData && (remainingBalance === 0 || !blockId)) {
|
||||||
|
logger.warn(`[SAP] ⚠️ ioOutputData exists but extraction failed. Full ioOutputData:`, JSON.stringify(ioOutputData, null, 2));
|
||||||
|
logger.warn(`[SAP] ⚠️ All keys in ioOutputData:`, Object.keys(ioOutputData));
|
||||||
|
logger.warn(`[SAP] ⚠️ Sample values:`, Object.keys(ioOutputData).slice(0, 10).reduce((acc: any, key: string) => {
|
||||||
|
acc[key] = ioOutputData[key];
|
||||||
|
return acc;
|
||||||
|
}, {}));
|
||||||
|
}
|
||||||
|
|
||||||
// If remaining balance is 0, log the full response structure for debugging
|
// If remaining balance is 0, log the full response structure for debugging
|
||||||
if (remainingBalance === 0 && response.status === 200 || response.status === 201) {
|
if (remainingBalance === 0 && response.status === 200 || response.status === 201) {
|
||||||
logger.warn(`[SAP] ⚠️ Remaining balance is 0, but request was successful. Full response structure:`, JSON.stringify(responseData, null, 2));
|
logger.warn(`[SAP] ⚠️ Remaining balance is 0, but request was successful. Full response structure:`, JSON.stringify(responseData, null, 2));
|
||||||
@ -960,9 +965,18 @@ export class SAPIntegrationService {
|
|||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
logger.info(`[SAP] Budget blocked successfully for IO ${ioNumber}. Blocked: ${blockedAmount}, Remaining: ${remainingBalance}`);
|
logger.info(`[SAP] Budget blocked successfully for IO ${ioNumber}. Blocked: ${blockedAmount}, Remaining: ${remainingBalance}`);
|
||||||
|
|
||||||
|
// Only return blockId if SAP provided a reference number
|
||||||
|
// Don't generate a fallback - we want the actual SAP document number
|
||||||
|
if (blockId) {
|
||||||
|
logger.info(`[SAP] SAP Reference Number received: ${blockId}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`[SAP] ⚠️ No SAP Reference Number (Sap_Reference_no) found in response`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
blockId: blockId || `BLOCK-${Date.now()}`,
|
blockId: blockId || undefined, // Only return actual SAP reference number, no fallback
|
||||||
blockedAmount,
|
blockedAmount,
|
||||||
remainingBalance
|
remainingBalance
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user