reporting manager is picked using display name while creating claim request
This commit is contained in:
parent
d5e195d507
commit
ee361a0c4b
@ -6,6 +6,7 @@ import logger from '../utils/logger';
|
|||||||
import { gcsStorageService } from '../services/gcsStorage.service';
|
import { gcsStorageService } from '../services/gcsStorage.service';
|
||||||
import { Document } from '../models/Document';
|
import { Document } from '../models/Document';
|
||||||
import { constants } from '../config/constants';
|
import { constants } from '../config/constants';
|
||||||
|
import { sapIntegrationService } from '../services/sapIntegration.service';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
@ -38,6 +39,7 @@ export class DealerClaimController {
|
|||||||
periodStartDate,
|
periodStartDate,
|
||||||
periodEndDate,
|
periodEndDate,
|
||||||
estimatedBudget,
|
estimatedBudget,
|
||||||
|
selectedManagerEmail, // Optional: When multiple managers found, user selects one
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
@ -59,13 +61,46 @@ export class DealerClaimController {
|
|||||||
periodStartDate: periodStartDate ? new Date(periodStartDate) : undefined,
|
periodStartDate: periodStartDate ? new Date(periodStartDate) : undefined,
|
||||||
periodEndDate: periodEndDate ? new Date(periodEndDate) : undefined,
|
periodEndDate: periodEndDate ? new Date(periodEndDate) : undefined,
|
||||||
estimatedBudget: estimatedBudget ? parseFloat(estimatedBudget) : undefined,
|
estimatedBudget: estimatedBudget ? parseFloat(estimatedBudget) : undefined,
|
||||||
|
selectedManagerEmail, // Pass selected manager email if provided
|
||||||
});
|
});
|
||||||
|
|
||||||
return ResponseHandler.success(res, {
|
return ResponseHandler.success(res, {
|
||||||
request: claimRequest,
|
request: claimRequest,
|
||||||
message: 'Claim request created successfully'
|
message: 'Claim request created successfully'
|
||||||
}, 'Claim request created');
|
}, 'Claim request created');
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
// Handle multiple managers found error
|
||||||
|
if (error.code === 'MULTIPLE_MANAGERS_FOUND') {
|
||||||
|
const response: any = {
|
||||||
|
success: false,
|
||||||
|
message: 'Multiple reporting managers found. Please select one.',
|
||||||
|
error: {
|
||||||
|
code: 'MULTIPLE_MANAGERS_FOUND',
|
||||||
|
managers: error.managers || []
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
logger.warn('[DealerClaimController] Multiple managers found:', { managers: error.managers });
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle no manager found error
|
||||||
|
if (error.code === 'NO_MANAGER_FOUND') {
|
||||||
|
const response: any = {
|
||||||
|
success: false,
|
||||||
|
message: error.message || 'No reporting manager found. Please ensure your manager is correctly configured in the system.',
|
||||||
|
error: {
|
||||||
|
code: 'NO_MANAGER_FOUND',
|
||||||
|
message: error.message
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
logger.warn('[DealerClaimController] No manager found:', { message: error.message });
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
logger.error('[DealerClaimController] Error creating claim request:', error);
|
logger.error('[DealerClaimController] Error creating claim request:', error);
|
||||||
return ResponseHandler.error(res, 'Failed to create claim request', 500, errorMessage);
|
return ResponseHandler.error(res, 'Failed to create claim request', 500, errorMessage);
|
||||||
@ -541,8 +576,46 @@ export class DealerClaimController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update IO details (Step 3 - Department Lead)
|
* Validate/Fetch IO details from SAP
|
||||||
|
* GET /api/v1/dealer-claims/:requestId/io/validate?ioNumber=IO1234
|
||||||
|
* This endpoint fetches IO details from SAP and returns them, does not store anything
|
||||||
|
* Flow: Fetch from SAP -> Return to frontend (no database storage)
|
||||||
|
*/
|
||||||
|
async validateIO(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { ioNumber } = req.query;
|
||||||
|
|
||||||
|
if (!ioNumber || typeof ioNumber !== 'string') {
|
||||||
|
return ResponseHandler.error(res, 'IO number is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch IO details from SAP (will return mock data until SAP is integrated)
|
||||||
|
const ioValidation = await sapIntegrationService.validateIONumber(ioNumber.trim());
|
||||||
|
|
||||||
|
if (!ioValidation.isValid) {
|
||||||
|
return ResponseHandler.error(res, ioValidation.error || 'Invalid IO number', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, {
|
||||||
|
ioNumber: ioValidation.ioNumber,
|
||||||
|
availableBalance: ioValidation.availableBalance,
|
||||||
|
blockedAmount: ioValidation.blockedAmount,
|
||||||
|
remainingBalance: ioValidation.remainingBalance,
|
||||||
|
currency: ioValidation.currency,
|
||||||
|
description: ioValidation.description,
|
||||||
|
isValid: true,
|
||||||
|
}, 'IO fetched successfully from SAP');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[DealerClaimController] Error validating IO:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to fetch IO from SAP', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update IO details and block amount in SAP
|
||||||
* PUT /api/v1/dealer-claims/:requestId/io
|
* PUT /api/v1/dealer-claims/:requestId/io
|
||||||
|
* Only stores data when blocking amount > 0
|
||||||
* Accepts either UUID or requestNumber
|
* Accepts either UUID or requestNumber
|
||||||
*/
|
*/
|
||||||
async updateIODetails(req: AuthenticatedRequest, res: Response): Promise<void> {
|
async updateIODetails(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
@ -568,23 +641,36 @@ export class DealerClaimController {
|
|||||||
return ResponseHandler.error(res, 'Invalid workflow request', 400);
|
return ResponseHandler.error(res, 'Invalid workflow request', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ioNumber || availableBalance === undefined || blockedAmount === undefined) {
|
if (!ioNumber) {
|
||||||
return ResponseHandler.error(res, 'Missing required IO fields', 400);
|
return ResponseHandler.error(res, 'IO number is required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dealerClaimService.updateIODetails(
|
const blockAmount = blockedAmount ? parseFloat(blockedAmount) : 0;
|
||||||
requestId,
|
|
||||||
{
|
// Only store in database when blocking amount > 0
|
||||||
ioNumber,
|
if (blockAmount > 0) {
|
||||||
ioRemark: ioRemark || '',
|
if (availableBalance === undefined) {
|
||||||
availableBalance: parseFloat(availableBalance),
|
return ResponseHandler.error(res, 'Available balance is required when blocking amount', 400);
|
||||||
blockedAmount: parseFloat(blockedAmount),
|
}
|
||||||
remainingBalance: remainingBalance ? parseFloat(remainingBalance) : parseFloat(availableBalance) - parseFloat(blockedAmount),
|
|
||||||
},
|
|
||||||
userId
|
|
||||||
);
|
|
||||||
|
|
||||||
return ResponseHandler.success(res, { message: 'IO details updated successfully' }, 'IO details updated');
|
await this.dealerClaimService.updateIODetails(
|
||||||
|
requestId,
|
||||||
|
{
|
||||||
|
ioNumber,
|
||||||
|
ioRemark: ioRemark || '',
|
||||||
|
availableBalance: parseFloat(availableBalance),
|
||||||
|
blockedAmount: blockAmount,
|
||||||
|
remainingBalance: remainingBalance ? parseFloat(remainingBalance) : parseFloat(availableBalance) - blockAmount,
|
||||||
|
},
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, { message: 'IO blocked successfully in SAP' }, 'IO blocked');
|
||||||
|
} else {
|
||||||
|
// Just validate IO number without storing
|
||||||
|
// This is for validation only (fetch amount scenario)
|
||||||
|
return ResponseHandler.success(res, { message: 'IO validated successfully' }, 'IO validated');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
logger.error('[DealerClaimController] Error updating IO details:', error);
|
logger.error('[DealerClaimController] Error updating IO details:', error);
|
||||||
|
|||||||
@ -36,6 +36,39 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search users in Okta by displayName
|
||||||
|
* GET /api/v1/users/search-by-displayname?displayName=John Doe
|
||||||
|
* Used when creating claim requests to find manager by displayName
|
||||||
|
*/
|
||||||
|
async searchByDisplayName(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const displayName = String(req.query.displayName || '').trim();
|
||||||
|
|
||||||
|
if (!displayName) {
|
||||||
|
ResponseHandler.error(res, 'displayName query parameter is required', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oktaUsers = await this.userService.searchOktaByDisplayName(displayName);
|
||||||
|
|
||||||
|
const result = oktaUsers.map(u => ({
|
||||||
|
userId: u.id,
|
||||||
|
email: u.profile.email || u.profile.login,
|
||||||
|
displayName: u.profile.displayName || `${u.profile.firstName || ''} ${u.profile.lastName || ''}`.trim(),
|
||||||
|
firstName: u.profile.firstName,
|
||||||
|
lastName: u.profile.lastName,
|
||||||
|
department: u.profile.department,
|
||||||
|
status: u.status,
|
||||||
|
}));
|
||||||
|
|
||||||
|
ResponseHandler.success(res, result, 'Users found by displayName');
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Search by displayName failed', { error });
|
||||||
|
ResponseHandler.error(res, error.message || 'Search by displayName failed', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure user exists in database (create if not exists)
|
* Ensure user exists in database (create if not exists)
|
||||||
* Called when user is selected/tagged in the frontend
|
* Called when user is selected/tagged in the frontend
|
||||||
|
|||||||
@ -7,7 +7,7 @@ interface WorkflowRequestAttributes {
|
|||||||
requestId: string;
|
requestId: string;
|
||||||
requestNumber: string;
|
requestNumber: string;
|
||||||
initiatorId: string;
|
initiatorId: string;
|
||||||
templateType: 'CUSTOM' | 'TEMPLATE';
|
templateType: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM';
|
||||||
workflowType?: string; // 'NON_TEMPLATIZED' | 'CLAIM_MANAGEMENT' | etc.
|
workflowType?: string; // 'NON_TEMPLATIZED' | 'CLAIM_MANAGEMENT' | etc.
|
||||||
templateId?: string; // Reference to workflow_templates if using admin template
|
templateId?: string; // Reference to workflow_templates if using admin template
|
||||||
title: string;
|
title: string;
|
||||||
@ -39,7 +39,7 @@ class WorkflowRequest extends Model<WorkflowRequestAttributes, WorkflowRequestCr
|
|||||||
public requestId!: string;
|
public requestId!: string;
|
||||||
public requestNumber!: string;
|
public requestNumber!: string;
|
||||||
public initiatorId!: string;
|
public initiatorId!: string;
|
||||||
public templateType!: 'CUSTOM' | 'TEMPLATE';
|
public templateType!: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM';
|
||||||
public workflowType?: string;
|
public workflowType?: string;
|
||||||
public templateId?: string;
|
public templateId?: string;
|
||||||
public title!: string;
|
public title!: string;
|
||||||
|
|||||||
@ -58,9 +58,16 @@ router.post('/:requestId/completion', authenticateToken, upload.fields([
|
|||||||
{ name: 'attendanceSheet', maxCount: 1 },
|
{ name: 'attendanceSheet', maxCount: 1 },
|
||||||
]), asyncHandler(dealerClaimController.submitCompletion.bind(dealerClaimController)));
|
]), asyncHandler(dealerClaimController.submitCompletion.bind(dealerClaimController)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/v1/dealer-claims/:requestId/io/validate
|
||||||
|
* @desc Validate/Fetch IO details from SAP (returns dummy data for now)
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.get('/:requestId/io/validate', authenticateToken, asyncHandler(dealerClaimController.validateIO.bind(dealerClaimController)));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route PUT /api/v1/dealer-claims/:requestId/io
|
* @route PUT /api/v1/dealer-claims/:requestId/io
|
||||||
* @desc Update IO details (Step 3)
|
* @desc Block IO amount in SAP and store in database
|
||||||
* @access Private
|
* @access Private
|
||||||
*/
|
*/
|
||||||
router.put('/:requestId/io', authenticateToken, asyncHandler(dealerClaimController.updateIODetails.bind(dealerClaimController)));
|
router.put('/:requestId/io', authenticateToken, asyncHandler(dealerClaimController.updateIODetails.bind(dealerClaimController)));
|
||||||
|
|||||||
@ -10,6 +10,9 @@ const userController = new UserController();
|
|||||||
// GET /api/v1/users/search?q=<email or name>
|
// GET /api/v1/users/search?q=<email or name>
|
||||||
router.get('/search', authenticateToken, asyncHandler(userController.searchUsers.bind(userController)));
|
router.get('/search', authenticateToken, asyncHandler(userController.searchUsers.bind(userController)));
|
||||||
|
|
||||||
|
// GET /api/v1/users/search-by-displayname?displayName=John Doe
|
||||||
|
router.get('/search-by-displayname', authenticateToken, asyncHandler(userController.searchByDisplayName.bind(userController)));
|
||||||
|
|
||||||
// GET /api/v1/users/configurations - Get public configurations (document policy, workflow sharing, TAT settings)
|
// GET /api/v1/users/configurations - Get public configurations (document policy, workflow sharing, TAT settings)
|
||||||
router.get('/configurations', authenticateToken, asyncHandler(getPublicConfigurations));
|
router.get('/configurations', authenticateToken, asyncHandler(getPublicConfigurations));
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { sapIntegrationService } from './sapIntegration.service';
|
|||||||
import { dmsIntegrationService } from './dmsIntegration.service';
|
import { dmsIntegrationService } from './dmsIntegration.service';
|
||||||
import { notificationService } from './notification.service';
|
import { notificationService } from './notification.service';
|
||||||
import { activityService } from './activity.service';
|
import { activityService } from './activity.service';
|
||||||
|
import { UserService } from './user.service';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -28,6 +29,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 ApprovalService();
|
||||||
|
private userService = new UserService();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new dealer claim request
|
* Create a new dealer claim request
|
||||||
@ -48,19 +50,49 @@ export class DealerClaimService {
|
|||||||
periodStartDate?: Date;
|
periodStartDate?: Date;
|
||||||
periodEndDate?: Date;
|
periodEndDate?: Date;
|
||||||
estimatedBudget?: number;
|
estimatedBudget?: number;
|
||||||
|
selectedManagerEmail?: string; // Optional: When multiple managers found, user selects one
|
||||||
}
|
}
|
||||||
): Promise<WorkflowRequest> {
|
): Promise<WorkflowRequest> {
|
||||||
try {
|
try {
|
||||||
// Generate request number
|
// Generate request number
|
||||||
const requestNumber = await generateRequestNumber();
|
const requestNumber = await generateRequestNumber();
|
||||||
|
|
||||||
// Create workflow request
|
// First, validate that manager can be resolved BEFORE creating any records
|
||||||
|
// This ensures no partial records are created if manager is not found
|
||||||
|
const initiator = await User.findByPk(userId);
|
||||||
|
if (!initiator) {
|
||||||
|
throw new Error('Initiator not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve Department Lead/Manager BEFORE creating workflow
|
||||||
|
let departmentLead: User | null = null;
|
||||||
|
|
||||||
|
if (claimData.selectedManagerEmail) {
|
||||||
|
// User selected a manager from multiple options
|
||||||
|
logger.info(`[DealerClaimService] Using selected manager email: ${claimData.selectedManagerEmail}`);
|
||||||
|
departmentLead = await this.userService.ensureUserExists({
|
||||||
|
email: claimData.selectedManagerEmail,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Search Okta using manager displayName from initiator's user record
|
||||||
|
departmentLead = await this.resolveDepartmentLeadFromManager(initiator);
|
||||||
|
|
||||||
|
// If no manager found, throw error BEFORE creating any records
|
||||||
|
if (!departmentLead) {
|
||||||
|
const managerDisplayName = initiator.manager || 'Unknown';
|
||||||
|
const error: any = new Error(`No reporting manager found for displayName: "${managerDisplayName}". Please ensure your manager is correctly configured in the system.`);
|
||||||
|
error.code = 'NO_MANAGER_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now create workflow request (manager is validated)
|
||||||
// For claim management, requests are submitted immediately (not drafts)
|
// For claim management, requests are submitted immediately (not drafts)
|
||||||
// Step 1 will be active for dealer to submit proposal
|
// Step 1 will be active for dealer to submit proposal
|
||||||
const workflowRequest = await WorkflowRequest.create({
|
const workflowRequest = await WorkflowRequest.create({
|
||||||
initiatorId: userId,
|
initiatorId: userId,
|
||||||
requestNumber,
|
requestNumber,
|
||||||
templateType: 'CUSTOM',
|
templateType: 'DEALER CLAIM', // Set template type for dealer claim management
|
||||||
workflowType: 'CLAIM_MANAGEMENT',
|
workflowType: 'CLAIM_MANAGEMENT',
|
||||||
title: `${claimData.activityName} - Claim Request`,
|
title: `${claimData.activityName} - Claim Request`,
|
||||||
description: claimData.requestDescription,
|
description: claimData.requestDescription,
|
||||||
@ -98,7 +130,8 @@ export class DealerClaimService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create 8 approval levels for claim management workflow
|
// Create 8 approval levels for claim management workflow
|
||||||
await this.createClaimApprovalLevels(workflowRequest.requestId, userId, claimData.dealerEmail);
|
// Pass the already-resolved departmentLead to avoid re-searching
|
||||||
|
await this.createClaimApprovalLevels(workflowRequest.requestId, userId, claimData.dealerEmail, claimData.selectedManagerEmail, departmentLead);
|
||||||
|
|
||||||
// Create participants (initiator, dealer, department lead, finance - exclude system)
|
// Create participants (initiator, dealer, department lead, finance - exclude system)
|
||||||
await this.createClaimParticipants(workflowRequest.requestId, userId, claimData.dealerEmail);
|
await this.createClaimParticipants(workflowRequest.requestId, userId, claimData.dealerEmail);
|
||||||
@ -115,35 +148,47 @@ export class DealerClaimService {
|
|||||||
* Create 8-step approval levels for claim management
|
* Create 8-step approval levels for claim management
|
||||||
* Maps approvers based on step requirements:
|
* Maps approvers based on step requirements:
|
||||||
* - Step 1 & 5: Dealer (external user via email)
|
* - Step 1 & 5: Dealer (external user via email)
|
||||||
* - Step 2 & 6: Initiator (requestor)
|
* - Step 2, 6 & 8: Initiator (requestor) - Step 8 is credit note action taken by initiator
|
||||||
* - Step 3: Department Lead (resolved from initiator's department/manager)
|
* - Step 3: Department Lead/Manager (resolved from initiator's manager displayName via Okta search)
|
||||||
* - Step 4 & 7: System (auto-processed)
|
* - Step 4 & 7: System (auto-processed)
|
||||||
* - Step 8: Finance Team (resolved from department/role)
|
|
||||||
*/
|
*/
|
||||||
private async createClaimApprovalLevels(
|
private async createClaimApprovalLevels(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
initiatorId: string,
|
initiatorId: string,
|
||||||
dealerEmail?: string
|
dealerEmail?: string,
|
||||||
|
selectedManagerEmail?: string,
|
||||||
|
departmentLead?: User | null // Pre-resolved department lead (to avoid re-searching)
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const initiator = await User.findByPk(initiatorId);
|
const initiator = await User.findByPk(initiatorId);
|
||||||
if (!initiator) {
|
if (!initiator) {
|
||||||
throw new Error('Initiator not found');
|
throw new Error('Initiator not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve Department Lead for Step 3
|
// Use pre-resolved department lead if provided, otherwise resolve it
|
||||||
const departmentLead = await this.resolveDepartmentLead(initiator);
|
let finalDepartmentLead: User | null = departmentLead || null;
|
||||||
|
|
||||||
// Resolve Finance Team for Step 8
|
if (!finalDepartmentLead) {
|
||||||
const financeApprover = await this.resolveFinanceApprover();
|
if (selectedManagerEmail) {
|
||||||
|
// User selected a manager from multiple options
|
||||||
|
logger.info(`[DealerClaimService] Using selected manager email: ${selectedManagerEmail}`);
|
||||||
|
const managerUser = await this.userService.ensureUserExists({
|
||||||
|
email: selectedManagerEmail,
|
||||||
|
});
|
||||||
|
finalDepartmentLead = managerUser;
|
||||||
|
} else {
|
||||||
|
// Search Okta using manager displayName from initiator's user record
|
||||||
|
finalDepartmentLead = await this.resolveDepartmentLeadFromManager(initiator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Step 1: Dealer Proposal Submission (72 hours) - Dealer submits proposal
|
// Step 1: Dealer Proposal Submission (72 hours) - Dealer submits proposal
|
||||||
// Step 2: Requestor Evaluation (48 hours) - Initiator evaluates
|
// Step 2: Requestor Evaluation (48 hours) - Initiator evaluates
|
||||||
// Step 3: Department Lead Approval (72 hours) - Department Lead approves and blocks IO
|
// Step 3: Department Lead Approval (72 hours) - Department Lead/Manager approves and blocks IO
|
||||||
// Step 4: Activity Creation (Auto - 1 hour) - System auto-processes
|
// Step 4: Activity Creation (Auto - 1 hour) - System auto-processes
|
||||||
// Step 5: Dealer Completion Documents (120 hours) - Dealer submits completion docs
|
// Step 5: Dealer Completion Documents (120 hours) - Dealer submits completion docs
|
||||||
// Step 6: Requestor Claim Approval (48 hours) - Initiator approves completion
|
// Step 6: Requestor Claim Approval (48 hours) - Initiator approves completion
|
||||||
// Step 7: E-Invoice Generation (Auto - 1 hour) - System generates via DMS
|
// Step 7: E-Invoice Generation (Auto - 1 hour) - System generates via DMS
|
||||||
// Step 8: Credit Note Confirmation (48 hours) - Finance confirms credit note (FINAL)
|
// Step 8: Credit Note Confirmation (48 hours) - Initiator confirms credit note (FINAL)
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
@ -214,9 +259,9 @@ export class DealerClaimService {
|
|||||||
name: 'Credit Note Confirmation',
|
name: 'Credit Note Confirmation',
|
||||||
tatHours: 48,
|
tatHours: 48,
|
||||||
isAuto: false,
|
isAuto: false,
|
||||||
approverType: 'finance' as const,
|
approverType: 'initiator' as const,
|
||||||
approverId: financeApprover?.userId || null,
|
approverId: initiatorId,
|
||||||
approverEmail: financeApprover?.email || 'finance@royalenfield.com',
|
approverEmail: initiator.email,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -251,28 +296,16 @@ export class DealerClaimService {
|
|||||||
approverName = initiator.displayName || initiator.email || 'Requestor';
|
approverName = initiator.displayName || initiator.email || 'Requestor';
|
||||||
approverEmail = initiator.email;
|
approverEmail = initiator.email;
|
||||||
} else if (step.approverType === 'department_lead') {
|
} else if (step.approverType === 'department_lead') {
|
||||||
if (departmentLead) {
|
if (finalDepartmentLead) {
|
||||||
approverId = departmentLead.userId;
|
approverId = finalDepartmentLead.userId;
|
||||||
approverName = departmentLead.displayName || departmentLead.email || 'Department Lead';
|
approverName = finalDepartmentLead.displayName || finalDepartmentLead.email || 'Department Lead';
|
||||||
approverEmail = departmentLead.email;
|
approverEmail = finalDepartmentLead.email;
|
||||||
} else {
|
} else {
|
||||||
// Department lead not found - use initiator as fallback
|
// This should never happen as we validate manager before creating records
|
||||||
logger.warn(`[DealerClaimService] Department lead not found for department ${initiator.department}, using initiator as fallback`);
|
// But keeping as safety check
|
||||||
approverId = initiatorId;
|
const error: any = new Error('Department lead not found. This should have been validated before creating the request.');
|
||||||
approverName = 'Department Lead (Not Found)';
|
error.code = 'DEPARTMENT_LEAD_NOT_FOUND';
|
||||||
approverEmail = initiator.email;
|
throw error;
|
||||||
}
|
|
||||||
} else if (step.approverType === 'finance') {
|
|
||||||
if (financeApprover) {
|
|
||||||
approverId = financeApprover.userId;
|
|
||||||
approverName = financeApprover.displayName || financeApprover.email || 'Finance Team';
|
|
||||||
approverEmail = financeApprover.email;
|
|
||||||
} else {
|
|
||||||
// Finance approver not found - use initiator as fallback
|
|
||||||
logger.warn(`[DealerClaimService] Finance approver not found, using initiator as fallback`);
|
|
||||||
approverId = initiatorId;
|
|
||||||
approverName = 'Finance Team (Not Found)';
|
|
||||||
approverEmail = initiator.email;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -442,6 +475,96 @@ export class DealerClaimService {
|
|||||||
* Resolve Department Lead based on initiator's department/manager
|
* Resolve Department Lead based on initiator's department/manager
|
||||||
* If multiple users found with same department, uses the first one
|
* If multiple users found with same department, uses the first one
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Resolve Department Lead/Manager by searching Okta using manager's displayName
|
||||||
|
* Flow:
|
||||||
|
* 1. Get manager displayName from initiator's user record
|
||||||
|
* 2. Search Okta directory by displayName
|
||||||
|
* 3. If empty: Return null (no manager found, fallback to old method)
|
||||||
|
* 4. If single: Use that user, create in DB if doesn't exist, return user
|
||||||
|
* 5. If multiple: Throw error with list of users (frontend will show confirmation)
|
||||||
|
*
|
||||||
|
* @param initiator - The user creating the claim request
|
||||||
|
* @returns User object for department lead/manager, or null if not found
|
||||||
|
* @throws Error if multiple managers found (frontend should handle confirmation)
|
||||||
|
*/
|
||||||
|
private async resolveDepartmentLeadFromManager(initiator: User): Promise<User | null> {
|
||||||
|
try {
|
||||||
|
// Get manager displayName from initiator's user record
|
||||||
|
const managerDisplayName = initiator.manager; // This is the displayName of the manager
|
||||||
|
|
||||||
|
if (!managerDisplayName) {
|
||||||
|
logger.warn(`[DealerClaimService] Initiator ${initiator.email} has no manager displayName set`);
|
||||||
|
// Return null - caller will handle the error
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimService] Searching Okta for manager with displayName: "${managerDisplayName}"`);
|
||||||
|
|
||||||
|
// Search Okta by displayName
|
||||||
|
const oktaUsers = await this.userService.searchOktaByDisplayName(managerDisplayName);
|
||||||
|
|
||||||
|
if (oktaUsers.length === 0) {
|
||||||
|
logger.warn(`[DealerClaimService] No reporting manager found in Okta for displayName: "${managerDisplayName}"`);
|
||||||
|
// Return null - caller will handle the error
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oktaUsers.length === 1) {
|
||||||
|
// Single match - use this user
|
||||||
|
const oktaUser = oktaUsers[0];
|
||||||
|
const managerEmail = oktaUser.profile.email || oktaUser.profile.login;
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimService] Found single manager match: ${managerEmail} for displayName: "${managerDisplayName}"`);
|
||||||
|
|
||||||
|
// Check if user exists in DB, create if doesn't exist
|
||||||
|
const managerUser = await this.userService.ensureUserExists({
|
||||||
|
userId: oktaUser.id,
|
||||||
|
email: managerEmail,
|
||||||
|
displayName: oktaUser.profile.displayName || `${oktaUser.profile.firstName || ''} ${oktaUser.profile.lastName || ''}`.trim(),
|
||||||
|
firstName: oktaUser.profile.firstName,
|
||||||
|
lastName: oktaUser.profile.lastName,
|
||||||
|
department: oktaUser.profile.department,
|
||||||
|
phone: oktaUser.profile.mobilePhone,
|
||||||
|
});
|
||||||
|
|
||||||
|
return managerUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple matches - throw error with list for frontend confirmation
|
||||||
|
const managerOptions = oktaUsers.map(u => ({
|
||||||
|
userId: u.id,
|
||||||
|
email: u.profile.email || u.profile.login,
|
||||||
|
displayName: u.profile.displayName || `${u.profile.firstName || ''} ${u.profile.lastName || ''}`.trim(),
|
||||||
|
firstName: u.profile.firstName,
|
||||||
|
lastName: u.profile.lastName,
|
||||||
|
department: u.profile.department,
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.warn(`[DealerClaimService] Multiple managers found (${oktaUsers.length}) for displayName: "${managerDisplayName}"`);
|
||||||
|
|
||||||
|
// Create a custom error with the manager options
|
||||||
|
const error: any = new Error(`Multiple reporting managers found. Please select one.`);
|
||||||
|
error.code = 'MULTIPLE_MANAGERS_FOUND';
|
||||||
|
error.managers = managerOptions;
|
||||||
|
throw error;
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
// If it's our custom multiple managers error, re-throw it
|
||||||
|
if (error.code === 'MULTIPLE_MANAGERS_FOUND') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other errors, log and fallback to old method
|
||||||
|
logger.error(`[DealerClaimService] Error resolving manager from Okta:`, error);
|
||||||
|
return await this.resolveDepartmentLead(initiator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy method: Resolve Department Lead using old logic
|
||||||
|
* Kept as fallback when Okta search fails or manager displayName not set
|
||||||
|
*/
|
||||||
private async resolveDepartmentLead(initiator: User): Promise<User | null> {
|
private async resolveDepartmentLead(initiator: User): Promise<User | null> {
|
||||||
try {
|
try {
|
||||||
const { Op } = await import('sequelize');
|
const { Op } = await import('sequelize');
|
||||||
@ -1007,6 +1130,11 @@ export class DealerClaimService {
|
|||||||
* Update IO details (Step 3 - Department Lead)
|
* Update IO details (Step 3 - Department Lead)
|
||||||
* Validates IO number with SAP and blocks budget
|
* Validates IO number with SAP and blocks budget
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Update IO details and block amount in SAP
|
||||||
|
* Only stores data when blocking amount > 0
|
||||||
|
* This method is called when user actually blocks the amount
|
||||||
|
*/
|
||||||
async updateIODetails(
|
async updateIODetails(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
ioData: {
|
ioData: {
|
||||||
@ -1019,6 +1147,13 @@ export class DealerClaimService {
|
|||||||
organizedByUserId?: string
|
organizedByUserId?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const blockedAmount = ioData.blockedAmount || 0;
|
||||||
|
|
||||||
|
// Only proceed if blocking amount > 0
|
||||||
|
if (blockedAmount <= 0) {
|
||||||
|
throw new Error('Blocked amount must be greater than 0');
|
||||||
|
}
|
||||||
|
|
||||||
// Validate IO number with SAP
|
// Validate IO number with SAP
|
||||||
const ioValidation = await sapIntegrationService.validateIONumber(ioData.ioNumber);
|
const ioValidation = await sapIntegrationService.validateIONumber(ioData.ioNumber);
|
||||||
|
|
||||||
@ -1026,41 +1161,37 @@ export class DealerClaimService {
|
|||||||
throw new Error(`Invalid IO number: ${ioValidation.error || 'IO number not found in SAP'}`);
|
throw new Error(`Invalid IO number: ${ioValidation.error || 'IO number not found in SAP'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If amount is provided, block budget in SAP
|
// Block budget in SAP
|
||||||
let blockedAmount = ioData.blockedAmount || 0;
|
const request = await WorkflowRequest.findByPk(requestId);
|
||||||
let remainingBalance = ioValidation.remainingBalance;
|
const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN';
|
||||||
|
|
||||||
|
const blockResult = await sapIntegrationService.blockBudget(
|
||||||
|
ioData.ioNumber,
|
||||||
|
blockedAmount,
|
||||||
|
requestNumber,
|
||||||
|
`Budget block for claim request ${requestNumber}`
|
||||||
|
);
|
||||||
|
|
||||||
if (ioData.blockedAmount && ioData.blockedAmount > 0) {
|
if (!blockResult.success) {
|
||||||
const request = await WorkflowRequest.findByPk(requestId);
|
throw new Error(`Failed to block budget in SAP: ${blockResult.error}`);
|
||||||
const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN';
|
|
||||||
|
|
||||||
const blockResult = await sapIntegrationService.blockBudget(
|
|
||||||
ioData.ioNumber,
|
|
||||||
ioData.blockedAmount,
|
|
||||||
requestNumber,
|
|
||||||
`Budget block for claim request ${requestNumber}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!blockResult.success) {
|
|
||||||
throw new Error(`Failed to block budget in SAP: ${blockResult.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
blockedAmount = blockResult.blockedAmount;
|
|
||||||
remainingBalance = blockResult.remainingBalance;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the user who is organizing the IO
|
const finalBlockedAmount = blockResult.blockedAmount;
|
||||||
|
const remainingBalance = blockResult.remainingBalance;
|
||||||
|
const availableBalance = ioData.availableBalance || ioValidation.availableBalance;
|
||||||
|
|
||||||
|
// Get the user who is blocking the IO (current user)
|
||||||
const organizedBy = organizedByUserId || null;
|
const organizedBy = organizedByUserId || null;
|
||||||
|
|
||||||
// Create or update Internal Order record
|
// Create or update Internal Order record (only when blocking)
|
||||||
const [internalOrder, created] = await InternalOrder.findOrCreate({
|
const [internalOrder, created] = await InternalOrder.findOrCreate({
|
||||||
where: { requestId },
|
where: { requestId },
|
||||||
defaults: {
|
defaults: {
|
||||||
requestId,
|
requestId,
|
||||||
ioNumber: ioData.ioNumber,
|
ioNumber: ioData.ioNumber,
|
||||||
ioRemark: ioData.ioRemark || '',
|
ioRemark: ioData.ioRemark || '',
|
||||||
ioAvailableBalance: ioValidation.availableBalance,
|
ioAvailableBalance: availableBalance,
|
||||||
ioBlockedAmount: blockedAmount,
|
ioBlockedAmount: finalBlockedAmount,
|
||||||
ioRemainingBalance: remainingBalance,
|
ioRemainingBalance: remainingBalance,
|
||||||
organizedBy: organizedBy || undefined,
|
organizedBy: organizedBy || undefined,
|
||||||
organizedAt: new Date(),
|
organizedAt: new Date(),
|
||||||
@ -1073,11 +1204,12 @@ export class DealerClaimService {
|
|||||||
await internalOrder.update({
|
await internalOrder.update({
|
||||||
ioNumber: ioData.ioNumber,
|
ioNumber: ioData.ioNumber,
|
||||||
ioRemark: ioData.ioRemark || '',
|
ioRemark: ioData.ioRemark || '',
|
||||||
ioAvailableBalance: ioValidation.availableBalance,
|
ioAvailableBalance: availableBalance,
|
||||||
ioBlockedAmount: blockedAmount,
|
ioBlockedAmount: finalBlockedAmount,
|
||||||
ioRemainingBalance: remainingBalance,
|
ioRemainingBalance: remainingBalance,
|
||||||
|
// Update to current user who is blocking
|
||||||
organizedBy: organizedBy || internalOrder.organizedBy,
|
organizedBy: organizedBy || internalOrder.organizedBy,
|
||||||
organizedAt: internalOrder.organizedAt || new Date(),
|
organizedAt: new Date(),
|
||||||
status: IOStatus.BLOCKED,
|
status: IOStatus.BLOCKED,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1085,19 +1217,19 @@ export class DealerClaimService {
|
|||||||
// Update budget tracking with blocked amount
|
// Update budget tracking with blocked amount
|
||||||
await ClaimBudgetTracking.upsert({
|
await ClaimBudgetTracking.upsert({
|
||||||
requestId,
|
requestId,
|
||||||
ioBlockedAmount: blockedAmount,
|
ioBlockedAmount: finalBlockedAmount,
|
||||||
ioBlockedAt: new Date(),
|
ioBlockedAt: new Date(),
|
||||||
budgetStatus: BudgetStatus.BLOCKED,
|
budgetStatus: BudgetStatus.BLOCKED,
|
||||||
currency: 'INR',
|
currency: 'INR',
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`[DealerClaimService] IO details updated for request: ${requestId}`, {
|
logger.info(`[DealerClaimService] IO blocked for request: ${requestId}`, {
|
||||||
ioNumber: ioData.ioNumber,
|
ioNumber: ioData.ioNumber,
|
||||||
blockedAmount,
|
blockedAmount: finalBlockedAmount,
|
||||||
remainingBalance
|
remainingBalance
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[DealerClaimService] Error updating IO details:', error);
|
logger.error('[DealerClaimService] Error blocking IO:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { User as UserModel } from '../models/User';
|
|||||||
import { Op } from 'sequelize';
|
import { Op } from 'sequelize';
|
||||||
import { SSOUserData } from '../types/auth.types'; // Use shared type
|
import { SSOUserData } from '../types/auth.types'; // Use shared type
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
// Using UserModel type directly - interface removed to avoid duplication
|
// Using UserModel type directly - interface removed to avoid duplication
|
||||||
|
|
||||||
@ -320,6 +321,45 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search users in Okta by displayName
|
||||||
|
* Uses Okta search API: /api/v1/users?search=profile.displayName eq "displayName"
|
||||||
|
* @param displayName - Display name to search for
|
||||||
|
* @returns Array of matching users from Okta
|
||||||
|
*/
|
||||||
|
async searchOktaByDisplayName(displayName: string): Promise<OktaUser[]> {
|
||||||
|
try {
|
||||||
|
const oktaDomain = process.env.OKTA_DOMAIN;
|
||||||
|
const oktaApiToken = process.env.OKTA_API_TOKEN;
|
||||||
|
|
||||||
|
if (!oktaDomain || !oktaApiToken) {
|
||||||
|
logger.warn('[UserService] Okta not configured, returning empty array for displayName search');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search Okta users by displayName
|
||||||
|
const response = await axios.get(`${oktaDomain}/api/v1/users`, {
|
||||||
|
params: {
|
||||||
|
search: `profile.displayName eq "${displayName}"`,
|
||||||
|
limit: 50
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Authorization': `SSWS ${oktaApiToken}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
const oktaUsers: OktaUser[] = response.data || [];
|
||||||
|
|
||||||
|
// Filter only active users
|
||||||
|
return oktaUsers.filter(u => u.status === 'ACTIVE');
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`[UserService] Error searching Okta by displayName "${displayName}":`, error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch user from Okta by email (legacy method, kept for backward compatibility)
|
* Fetch user from Okta by email (legacy method, kept for backward compatibility)
|
||||||
* @deprecated Use fetchAndExtractOktaUserByEmail instead for full profile extraction
|
* @deprecated Use fetchAndExtractOktaUserByEmail instead for full profile extraction
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user