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 { Document } from '../models/Document';
|
||||
import { constants } from '../config/constants';
|
||||
import { sapIntegrationService } from '../services/sapIntegration.service';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
@ -38,6 +39,7 @@ export class DealerClaimController {
|
||||
periodStartDate,
|
||||
periodEndDate,
|
||||
estimatedBudget,
|
||||
selectedManagerEmail, // Optional: When multiple managers found, user selects one
|
||||
} = req.body;
|
||||
|
||||
// Validation
|
||||
@ -59,13 +61,46 @@ export class DealerClaimController {
|
||||
periodStartDate: periodStartDate ? new Date(periodStartDate) : undefined,
|
||||
periodEndDate: periodEndDate ? new Date(periodEndDate) : undefined,
|
||||
estimatedBudget: estimatedBudget ? parseFloat(estimatedBudget) : undefined,
|
||||
selectedManagerEmail, // Pass selected manager email if provided
|
||||
});
|
||||
|
||||
return ResponseHandler.success(res, {
|
||||
request: claimRequest,
|
||||
message: 'Claim request created successfully'
|
||||
}, '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';
|
||||
logger.error('[DealerClaimController] Error creating claim request:', error);
|
||||
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
|
||||
* Only stores data when blocking amount > 0
|
||||
* Accepts either UUID or requestNumber
|
||||
*/
|
||||
async updateIODetails(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
@ -568,23 +641,36 @@ export class DealerClaimController {
|
||||
return ResponseHandler.error(res, 'Invalid workflow request', 400);
|
||||
}
|
||||
|
||||
if (!ioNumber || availableBalance === undefined || blockedAmount === undefined) {
|
||||
return ResponseHandler.error(res, 'Missing required IO fields', 400);
|
||||
if (!ioNumber) {
|
||||
return ResponseHandler.error(res, 'IO number is required', 400);
|
||||
}
|
||||
|
||||
await this.dealerClaimService.updateIODetails(
|
||||
requestId,
|
||||
{
|
||||
ioNumber,
|
||||
ioRemark: ioRemark || '',
|
||||
availableBalance: parseFloat(availableBalance),
|
||||
blockedAmount: parseFloat(blockedAmount),
|
||||
remainingBalance: remainingBalance ? parseFloat(remainingBalance) : parseFloat(availableBalance) - parseFloat(blockedAmount),
|
||||
},
|
||||
userId
|
||||
);
|
||||
const blockAmount = blockedAmount ? parseFloat(blockedAmount) : 0;
|
||||
|
||||
return ResponseHandler.success(res, { message: 'IO details updated successfully' }, 'IO details updated');
|
||||
// Only store in database when blocking amount > 0
|
||||
if (blockAmount > 0) {
|
||||
if (availableBalance === undefined) {
|
||||
return ResponseHandler.error(res, 'Available balance is required when blocking amount', 400);
|
||||
}
|
||||
|
||||
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) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown 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)
|
||||
* Called when user is selected/tagged in the frontend
|
||||
|
||||
@ -7,7 +7,7 @@ interface WorkflowRequestAttributes {
|
||||
requestId: string;
|
||||
requestNumber: string;
|
||||
initiatorId: string;
|
||||
templateType: 'CUSTOM' | 'TEMPLATE';
|
||||
templateType: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM';
|
||||
workflowType?: string; // 'NON_TEMPLATIZED' | 'CLAIM_MANAGEMENT' | etc.
|
||||
templateId?: string; // Reference to workflow_templates if using admin template
|
||||
title: string;
|
||||
@ -39,7 +39,7 @@ class WorkflowRequest extends Model<WorkflowRequestAttributes, WorkflowRequestCr
|
||||
public requestId!: string;
|
||||
public requestNumber!: string;
|
||||
public initiatorId!: string;
|
||||
public templateType!: 'CUSTOM' | 'TEMPLATE';
|
||||
public templateType!: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM';
|
||||
public workflowType?: string;
|
||||
public templateId?: string;
|
||||
public title!: string;
|
||||
|
||||
@ -58,9 +58,16 @@ router.post('/:requestId/completion', authenticateToken, upload.fields([
|
||||
{ name: 'attendanceSheet', maxCount: 1 },
|
||||
]), 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
|
||||
* @desc Update IO details (Step 3)
|
||||
* @desc Block IO amount in SAP and store in database
|
||||
* @access Private
|
||||
*/
|
||||
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>
|
||||
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)
|
||||
router.get('/configurations', authenticateToken, asyncHandler(getPublicConfigurations));
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ import { sapIntegrationService } from './sapIntegration.service';
|
||||
import { dmsIntegrationService } from './dmsIntegration.service';
|
||||
import { notificationService } from './notification.service';
|
||||
import { activityService } from './activity.service';
|
||||
import { UserService } from './user.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
/**
|
||||
@ -28,6 +29,7 @@ import logger from '../utils/logger';
|
||||
export class DealerClaimService {
|
||||
private workflowService = new WorkflowService();
|
||||
private approvalService = new ApprovalService();
|
||||
private userService = new UserService();
|
||||
|
||||
/**
|
||||
* Create a new dealer claim request
|
||||
@ -48,19 +50,49 @@ export class DealerClaimService {
|
||||
periodStartDate?: Date;
|
||||
periodEndDate?: Date;
|
||||
estimatedBudget?: number;
|
||||
selectedManagerEmail?: string; // Optional: When multiple managers found, user selects one
|
||||
}
|
||||
): Promise<WorkflowRequest> {
|
||||
try {
|
||||
// Generate request number
|
||||
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)
|
||||
// Step 1 will be active for dealer to submit proposal
|
||||
const workflowRequest = await WorkflowRequest.create({
|
||||
initiatorId: userId,
|
||||
requestNumber,
|
||||
templateType: 'CUSTOM',
|
||||
templateType: 'DEALER CLAIM', // Set template type for dealer claim management
|
||||
workflowType: 'CLAIM_MANAGEMENT',
|
||||
title: `${claimData.activityName} - Claim Request`,
|
||||
description: claimData.requestDescription,
|
||||
@ -98,7 +130,8 @@ export class DealerClaimService {
|
||||
});
|
||||
|
||||
// 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)
|
||||
await this.createClaimParticipants(workflowRequest.requestId, userId, claimData.dealerEmail);
|
||||
@ -115,35 +148,47 @@ export class DealerClaimService {
|
||||
* Create 8-step approval levels for claim management
|
||||
* Maps approvers based on step requirements:
|
||||
* - Step 1 & 5: Dealer (external user via email)
|
||||
* - Step 2 & 6: Initiator (requestor)
|
||||
* - Step 3: Department Lead (resolved from initiator's department/manager)
|
||||
* - Step 2, 6 & 8: Initiator (requestor) - Step 8 is credit note action taken by initiator
|
||||
* - Step 3: Department Lead/Manager (resolved from initiator's manager displayName via Okta search)
|
||||
* - Step 4 & 7: System (auto-processed)
|
||||
* - Step 8: Finance Team (resolved from department/role)
|
||||
*/
|
||||
private async createClaimApprovalLevels(
|
||||
requestId: string,
|
||||
initiatorId: string,
|
||||
dealerEmail?: string
|
||||
dealerEmail?: string,
|
||||
selectedManagerEmail?: string,
|
||||
departmentLead?: User | null // Pre-resolved department lead (to avoid re-searching)
|
||||
): Promise<void> {
|
||||
const initiator = await User.findByPk(initiatorId);
|
||||
if (!initiator) {
|
||||
throw new Error('Initiator not found');
|
||||
}
|
||||
|
||||
// Resolve Department Lead for Step 3
|
||||
const departmentLead = await this.resolveDepartmentLead(initiator);
|
||||
// Use pre-resolved department lead if provided, otherwise resolve it
|
||||
let finalDepartmentLead: User | null = departmentLead || null;
|
||||
|
||||
// Resolve Finance Team for Step 8
|
||||
const financeApprover = await this.resolveFinanceApprover();
|
||||
if (!finalDepartmentLead) {
|
||||
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 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 5: Dealer Completion Documents (120 hours) - Dealer submits completion docs
|
||||
// Step 6: Requestor Claim Approval (48 hours) - Initiator approves completion
|
||||
// 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 = [
|
||||
{
|
||||
@ -214,9 +259,9 @@ export class DealerClaimService {
|
||||
name: 'Credit Note Confirmation',
|
||||
tatHours: 48,
|
||||
isAuto: false,
|
||||
approverType: 'finance' as const,
|
||||
approverId: financeApprover?.userId || null,
|
||||
approverEmail: financeApprover?.email || 'finance@royalenfield.com',
|
||||
approverType: 'initiator' as const,
|
||||
approverId: initiatorId,
|
||||
approverEmail: initiator.email,
|
||||
},
|
||||
];
|
||||
|
||||
@ -251,28 +296,16 @@ export class DealerClaimService {
|
||||
approverName = initiator.displayName || initiator.email || 'Requestor';
|
||||
approverEmail = initiator.email;
|
||||
} else if (step.approverType === 'department_lead') {
|
||||
if (departmentLead) {
|
||||
approverId = departmentLead.userId;
|
||||
approverName = departmentLead.displayName || departmentLead.email || 'Department Lead';
|
||||
approverEmail = departmentLead.email;
|
||||
if (finalDepartmentLead) {
|
||||
approverId = finalDepartmentLead.userId;
|
||||
approverName = finalDepartmentLead.displayName || finalDepartmentLead.email || 'Department Lead';
|
||||
approverEmail = finalDepartmentLead.email;
|
||||
} else {
|
||||
// Department lead not found - use initiator as fallback
|
||||
logger.warn(`[DealerClaimService] Department lead not found for department ${initiator.department}, using initiator as fallback`);
|
||||
approverId = initiatorId;
|
||||
approverName = 'Department Lead (Not Found)';
|
||||
approverEmail = initiator.email;
|
||||
}
|
||||
} 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;
|
||||
// This should never happen as we validate manager before creating records
|
||||
// But keeping as safety check
|
||||
const error: any = new Error('Department lead not found. This should have been validated before creating the request.');
|
||||
error.code = 'DEPARTMENT_LEAD_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@ -442,6 +475,96 @@ export class DealerClaimService {
|
||||
* Resolve Department Lead based on initiator's department/manager
|
||||
* 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> {
|
||||
try {
|
||||
const { Op } = await import('sequelize');
|
||||
@ -1007,6 +1130,11 @@ export class DealerClaimService {
|
||||
* Update IO details (Step 3 - Department Lead)
|
||||
* 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(
|
||||
requestId: string,
|
||||
ioData: {
|
||||
@ -1019,6 +1147,13 @@ export class DealerClaimService {
|
||||
organizedByUserId?: string
|
||||
): Promise<void> {
|
||||
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
|
||||
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'}`);
|
||||
}
|
||||
|
||||
// If amount is provided, block budget in SAP
|
||||
let blockedAmount = ioData.blockedAmount || 0;
|
||||
let remainingBalance = ioValidation.remainingBalance;
|
||||
// Block budget in SAP
|
||||
const request = await WorkflowRequest.findByPk(requestId);
|
||||
const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN';
|
||||
|
||||
if (ioData.blockedAmount && ioData.blockedAmount > 0) {
|
||||
const request = await WorkflowRequest.findByPk(requestId);
|
||||
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}`
|
||||
);
|
||||
|
||||
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;
|
||||
if (!blockResult.success) {
|
||||
throw new Error(`Failed to block budget in SAP: ${blockResult.error}`);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Create or update Internal Order record
|
||||
// Create or update Internal Order record (only when blocking)
|
||||
const [internalOrder, created] = await InternalOrder.findOrCreate({
|
||||
where: { requestId },
|
||||
defaults: {
|
||||
requestId,
|
||||
ioNumber: ioData.ioNumber,
|
||||
ioRemark: ioData.ioRemark || '',
|
||||
ioAvailableBalance: ioValidation.availableBalance,
|
||||
ioBlockedAmount: blockedAmount,
|
||||
ioAvailableBalance: availableBalance,
|
||||
ioBlockedAmount: finalBlockedAmount,
|
||||
ioRemainingBalance: remainingBalance,
|
||||
organizedBy: organizedBy || undefined,
|
||||
organizedAt: new Date(),
|
||||
@ -1073,11 +1204,12 @@ export class DealerClaimService {
|
||||
await internalOrder.update({
|
||||
ioNumber: ioData.ioNumber,
|
||||
ioRemark: ioData.ioRemark || '',
|
||||
ioAvailableBalance: ioValidation.availableBalance,
|
||||
ioBlockedAmount: blockedAmount,
|
||||
ioAvailableBalance: availableBalance,
|
||||
ioBlockedAmount: finalBlockedAmount,
|
||||
ioRemainingBalance: remainingBalance,
|
||||
// Update to current user who is blocking
|
||||
organizedBy: organizedBy || internalOrder.organizedBy,
|
||||
organizedAt: internalOrder.organizedAt || new Date(),
|
||||
organizedAt: new Date(),
|
||||
status: IOStatus.BLOCKED,
|
||||
});
|
||||
}
|
||||
@ -1085,19 +1217,19 @@ export class DealerClaimService {
|
||||
// Update budget tracking with blocked amount
|
||||
await ClaimBudgetTracking.upsert({
|
||||
requestId,
|
||||
ioBlockedAmount: blockedAmount,
|
||||
ioBlockedAmount: finalBlockedAmount,
|
||||
ioBlockedAt: new Date(),
|
||||
budgetStatus: BudgetStatus.BLOCKED,
|
||||
currency: 'INR',
|
||||
});
|
||||
|
||||
logger.info(`[DealerClaimService] IO details updated for request: ${requestId}`, {
|
||||
logger.info(`[DealerClaimService] IO blocked for request: ${requestId}`, {
|
||||
ioNumber: ioData.ioNumber,
|
||||
blockedAmount,
|
||||
blockedAmount: finalBlockedAmount,
|
||||
remainingBalance
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[DealerClaimService] Error updating IO details:', error);
|
||||
logger.error('[DealerClaimService] Error blocking IO:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { User as UserModel } from '../models/User';
|
||||
import { Op } from 'sequelize';
|
||||
import { SSOUserData } from '../types/auth.types'; // Use shared type
|
||||
import axios from 'axios';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
// 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)
|
||||
* @deprecated Use fetchAndExtractOktaUserByEmail instead for full profile extraction
|
||||
|
||||
Loading…
Reference in New Issue
Block a user