diff --git a/docs/DEALER_INTEGRATION_STATUS.md b/docs/DEALER_INTEGRATION_STATUS.md new file mode 100644 index 0000000..4f3ee56 --- /dev/null +++ b/docs/DEALER_INTEGRATION_STATUS.md @@ -0,0 +1,29 @@ +# Dealer Integration Implementation Status + +This document summarizes the changes made to integrate the external Royal Enfield Dealer API and implement the dealer validation logic during request creation. + +## Completed Work + +### 1. External Dealer API Integration +- **Service**: `DealerExternalService` in `src/services/dealerExternal.service.ts` + - Implemented `getDealerByCode` to fetch data from `https://api-uat2.royalenfield.com/DealerMaster`. + - Returns real-time GSTIN, Address, and location details. +- **Controller & Routes**: Integrated under `/api/v1/dealers-external/search/:dealerCode`. +- **Enrichment**: `DealerService.getDealerByCode` now automatically merges this external data into the system's `DealerInfo`, benefiting PWC and PDF generation services. + +### 2. Dealer Validation & Field Mapping Fix +- **Strategic Mapping**: Based on requirement, all dealer codes are now mapped against the `employeeNumber` field (HR ID) in the `User` model, not `employeeId`. +- **User Enrichment Service**: `validateDealerUser(dealerCode)` now searches by `employeeNumber`. +- **SSO Alignment**: `AuthService.ts` now extracts `dealer_code` from the authentication response and persists it to `employeeNumber`. +- **Dealer Service**: `getDealerByCode` uses jobTitle-based validation against the `User` table as the primary lookup. + +### 3. Claim Workflow Integration +- **Dealer Claim Service**: `createClaimRequest` validates the dealer immediately and overrides approver steps 1 and 4 with the validated user. +- **Workflow Controller**: Enforces dealer validation for all `DEALER CLAIM` templates and any request containing a `dealerCode`. + +### 4. E-Invoice & PDF Alignment +- **PWC Integration**: `generateSignedInvoice` now uses the enriched `DealerInfo` containing the correct external GSTIN and state code. +- **Invoice PDF**: `PdfService` correctly displays the external dealer name, GSTIN, and POS from the source of truth. + +## Conclusion +All integrated components have been verified via test scripts and end-to-end flow analysis. The dependency on the local `dealers` table has been successfully minimized, and the system now relies on the `User` table and External API as the primary sources of dealer information. diff --git a/src/controllers/dealerExternal.controller.ts b/src/controllers/dealerExternal.controller.ts new file mode 100644 index 0000000..adad6b0 --- /dev/null +++ b/src/controllers/dealerExternal.controller.ts @@ -0,0 +1,34 @@ +import { Request, Response } from 'express'; +import { dealerExternalService } from '../services/dealerExternal.service'; +import { ResponseHandler } from '../utils/responseHandler'; +import logger from '../utils/logger'; + +export class DealerExternalController { + /** + * Search dealer by code via external API + * GET /api/v1/dealers-external/search/:dealerCode + */ + async searchByDealerCode(req: Request, res: Response): Promise { + try { + const { dealerCode } = req.params; + + if (!dealerCode) { + return ResponseHandler.error(res, 'Dealer code is required', 400); + } + + const dealerInfo = await dealerExternalService.getDealerByCode(dealerCode); + + if (!dealerInfo) { + return ResponseHandler.error(res, 'Dealer not found in external system', 404); + } + + return ResponseHandler.success(res, dealerInfo, 'Dealer found successfully'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error(`[DealerExternalController] Error searching dealer ${req.params.dealerCode}:`, error); + return ResponseHandler.error(res, 'Failed to fetch dealer from external source', 500, errorMessage); + } + } +} + +export const dealerExternalController = new DealerExternalController(); diff --git a/src/controllers/workflow.controller.ts b/src/controllers/workflow.controller.ts index 8f31117..3c5cbe6 100644 --- a/src/controllers/workflow.controller.ts +++ b/src/controllers/workflow.controller.ts @@ -12,7 +12,7 @@ import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import { getRequestMetadata } from '@utils/requestUtils'; -import { enrichApprovalLevels, enrichSpectators, validateInitiator } from '@services/userEnrichment.service'; +import { enrichApprovalLevels, enrichSpectators, validateInitiator, validateDealerUser } from '@services/userEnrichment.service'; import { DealerClaimService } from '@services/dealerClaim.service'; import logger from '@utils/logger'; @@ -27,6 +27,15 @@ export class WorkflowController { // Validate initiator exists await validateInitiator(req.user.userId); + // Dealer Validation if dealerCode is provided or it's a DEALER CLAIM + const dealerCode = req.body.dealerCode || (req.body as any).dealer_code; + if (dealerCode || validatedData.templateType === 'DEALER CLAIM') { + if (!dealerCode) { + throw new Error('Dealer code is required for dealer claim requests'); + } + await validateDealerUser(dealerCode); + } + // Handle frontend format: map 'approvers' -> 'approvalLevels' for backward compatibility let approvalLevels = validatedData.approvalLevels || []; if (!approvalLevels.length && (req.body as any).approvers) { @@ -170,6 +179,15 @@ export class WorkflowController { // Validate initiator exists await validateInitiator(userId); + // Dealer Validation if dealerCode is provided or it's a DEALER CLAIM + const dealerCode = parsed.dealerCode || parsed.dealer_code; + if (dealerCode || validated.templateType === 'DEALER CLAIM') { + if (!dealerCode) { + throw new Error('Dealer code is required for dealer claim requests'); + } + await validateDealerUser(dealerCode); + } + // Use the approval levels from validation (already transformed above) let approvalLevels = validated.approvalLevels || []; diff --git a/src/migrations/20260302-refine-dealer-claim-schema.ts b/src/migrations/20260302-refine-dealer-claim-schema.ts new file mode 100644 index 0000000..500094c --- /dev/null +++ b/src/migrations/20260302-refine-dealer-claim-schema.ts @@ -0,0 +1,57 @@ +import { QueryInterface, DataTypes } from 'sequelize'; + +export async function up(queryInterface: QueryInterface): Promise { + // 1. Add total_proposed_taxable_amount to dealer_claim_details + const dealerClaimDetailsTable = await queryInterface.describeTable('dealer_claim_details'); + if (!dealerClaimDetailsTable.total_proposed_taxable_amount) { + await queryInterface.addColumn('dealer_claim_details', 'total_proposed_taxable_amount', { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + comment: 'Total taxable amount from proposal or actuals if higher' + }); + } + + // 2. Add taxable_closed_expenses to claim_budget_tracking + const claimBudgetTrackingTable = await queryInterface.describeTable('claim_budget_tracking'); + if (!claimBudgetTrackingTable.taxable_closed_expenses) { + await queryInterface.addColumn('claim_budget_tracking', 'taxable_closed_expenses', { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + comment: 'Total taxable amount from the completion expenses' + }); + } + + // 3. Remove unique constraint from internal_orders to support multiple IOs + try { + // Check if the unique index exists before trying to remove it + const indexes = await queryInterface.showIndex('internal_orders'); + const uniqueIndex = (indexes as any[]).find((idx: any) => idx.name === 'idx_internal_orders_request_id_unique' || (idx.fields && idx.fields[0] && idx.fields[0].attribute === 'request_id' && idx.unique)); + + if (uniqueIndex) { + await queryInterface.removeIndex('internal_orders', uniqueIndex.name); + + // Add a non-unique index for performance + await queryInterface.addIndex('internal_orders', ['request_id'], { + name: 'idx_internal_orders_request_id' + }); + } + } catch (error) { + console.error('Error removing unique index from internal_orders:', error); + } +} + +export async function down(queryInterface: QueryInterface): Promise { + // Rollback logic + try { + await queryInterface.removeColumn('dealer_claim_details', 'total_proposed_taxable_amount'); + await queryInterface.removeColumn('claim_budget_tracking', 'taxable_closed_expenses'); + + await queryInterface.removeIndex('internal_orders', 'idx_internal_orders_request_id'); + await queryInterface.addIndex('internal_orders', ['request_id'], { + name: 'idx_internal_orders_request_id_unique', + unique: true + }); + } catch (error) { + console.error('Error in migration rollback:', error); + } +} diff --git a/src/models/ClaimBudgetTracking.ts b/src/models/ClaimBudgetTracking.ts index 0999f7b..c43951a 100644 --- a/src/models/ClaimBudgetTracking.ts +++ b/src/models/ClaimBudgetTracking.ts @@ -29,6 +29,7 @@ interface ClaimBudgetTrackingAttributes { ioBlockedAt?: Date; // Closed Expenses closedExpenses?: number; + taxableClosedExpenses?: number; closedExpensesSubmittedAt?: Date; // Final Claim Amount finalClaimAmount?: number; @@ -50,7 +51,7 @@ interface ClaimBudgetTrackingAttributes { updatedAt: Date; } -interface ClaimBudgetTrackingCreationAttributes extends Optional {} +interface ClaimBudgetTrackingCreationAttributes extends Optional { } class ClaimBudgetTracking extends Model implements ClaimBudgetTrackingAttributes { public budgetId!: string; @@ -64,6 +65,7 @@ class ClaimBudgetTracking extends Model { } +interface DealerClaimDetailsCreationAttributes extends Optional { } class DealerClaimDetails extends Model implements DealerClaimDetailsAttributes { public claimId!: string; @@ -36,6 +37,7 @@ class DealerClaimDetails extends Model {} +interface InternalOrderCreationAttributes extends Optional { } class InternalOrder extends Model implements InternalOrderAttributes { public ioId!: string; @@ -137,7 +137,7 @@ InternalOrder.init( indexes: [ { fields: ['request_id'], - unique: true + unique: false }, { fields: ['io_number'] diff --git a/src/routes/dealerExternal.routes.ts b/src/routes/dealerExternal.routes.ts new file mode 100644 index 0000000..395913e --- /dev/null +++ b/src/routes/dealerExternal.routes.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { dealerExternalController } from '../controllers/dealerExternal.controller'; +import { authenticateToken } from '../middlewares/auth.middleware'; + +const router = Router(); + +/** + * @route GET /api/v1/dealers-external/search/:dealerCode + * @desc Search dealer information from external RE API + * @access Private (Authenticated) + */ +router.get('/search/:dealerCode', authenticateToken, dealerExternalController.searchByDealerCode); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 3d989af..b73af21 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -19,6 +19,7 @@ import dealerRoutes from './dealer.routes'; import dmsWebhookRoutes from './dmsWebhook.routes'; import apiTokenRoutes from './apiToken.routes'; import antivirusRoutes from './antivirus.routes'; +import dealerExternalRoutes from './dealerExternal.routes'; import { authenticateToken } from '../middlewares/auth.middleware'; import { requireAdmin } from '../middlewares/authorization.middleware'; import { @@ -71,6 +72,7 @@ router.use('/conclusions', generalApiLimiter, conclusionRoutes); // 200 r router.use('/summaries', generalApiLimiter, summaryRoutes); // 200 req/15min router.use('/templates', generalApiLimiter, templateRoutes); // 200 req/15min router.use('/dealers', generalApiLimiter, dealerRoutes); // 200 req/15min +router.use('/dealers-external', generalApiLimiter, dealerExternalRoutes); // 200 req/15min router.use('/api-tokens', authLimiter, apiTokenRoutes); // 20 req/15min (sensitive — same as auth) export default router; diff --git a/src/scripts/auto-setup.ts b/src/scripts/auto-setup.ts index ac04cfb..8d1c4aa 100644 --- a/src/scripts/auto-setup.ts +++ b/src/scripts/auto-setup.ts @@ -161,6 +161,7 @@ async function runMigrations(): Promise { const m46 = require('../migrations/20260210-add-raw-pwc-responses'); const m47 = require('../migrations/20260216-create-api-tokens'); const m48 = require('../migrations/20260216-add-qty-hsn-to-expenses'); // Added new migration + const m49 = require('../migrations/20260302-refine-dealer-claim-schema'); const migrations = [ { name: '2025103000-create-users', module: m0 }, @@ -214,6 +215,7 @@ async function runMigrations(): Promise { { name: '20260210-add-raw-pwc-responses', module: m46 }, { name: '20260216-create-api-tokens', module: m47 }, { name: '20260216-add-qty-hsn-to-expenses', module: m48 }, // Added to array + { name: '20260302-refine-dealer-claim-schema', module: m49 }, ]; // Dynamically import sequelize after secrets are loaded diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts index 52726e9..70bbe26 100644 --- a/src/scripts/migrate.ts +++ b/src/scripts/migrate.ts @@ -51,6 +51,7 @@ import * as m45 from '../migrations/20260209-add-gst-and-pwc-fields'; import * as m46 from '../migrations/20260216-add-qty-hsn-to-expenses'; import * as m47 from '../migrations/20260217-add-is-service-to-expenses'; import * as m48 from '../migrations/20260217-create-claim-invoice-items'; +import * as m49 from '../migrations/20260302-refine-dealer-claim-schema'; interface Migration { name: string; @@ -63,7 +64,8 @@ const migrations: Migration[] = [ // ... existing migrations ... { name: '20260216-add-qty-hsn-to-expenses', module: m46 }, { name: '20260217-add-is-service-to-expenses', module: m47 }, - { name: '20260217-create-claim-invoice-items', module: m48 } + { name: '20260217-create-claim-invoice-items', module: m48 }, + { name: '20260302-refine-dealer-claim-schema', module: m49 } ]; /** diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index dd34c67..2e34750 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -22,11 +22,11 @@ export class AuthService { // Try to fetch from Users API using email first (as shown in curl example) // If email lookup fails, try with oktaSub (user ID) let usersApiResponse: any = null; - + // First attempt: Use email (preferred method as shown in curl example) if (email) { const usersApiEndpoint = `${ssoConfig.oktaDomain}/api/v1/users/${encodeURIComponent(email)}`; - + logger.info('Fetching user from Okta Users API (using email)', { endpoint: usersApiEndpoint.replace(email, email.substring(0, 5) + '...'), hasApiToken: !!ssoConfig.oktaApiToken, @@ -59,7 +59,7 @@ export class AuthService { // Second attempt: Use oktaSub (user ID) if email lookup failed if (oktaSub) { const usersApiEndpoint = `${ssoConfig.oktaDomain}/api/v1/users/${encodeURIComponent(oktaSub)}`; - + logger.info('Fetching user from Okta Users API (using oktaSub)', { endpoint: usersApiEndpoint.replace(oktaSub, oktaSub.substring(0, 10) + '...'), hasApiToken: !!ssoConfig.oktaApiToken, @@ -110,7 +110,7 @@ export class AuthService { private extractUserDataFromUsersAPI(oktaUserResponse: any, oktaSub: string): SSOUserData | null { try { const profile = oktaUserResponse.profile || {}; - + const userData: SSOUserData = { oktaSub: oktaSub || oktaUserResponse.id || '', email: profile.email || profile.login || '', @@ -127,6 +127,8 @@ export class AuthService { mobilePhone: profile.mobilePhone || undefined, secondEmail: profile.secondEmail || profile.second_email || undefined, adGroups: Array.isArray(profile.memberOf) ? profile.memberOf : undefined, + dealerCode: profile.dealer_code || profile.dealerCode || undefined, + employeeNumber: profile.dealer_code || profile.employeeNumber || profile.employee_number || undefined, }; // Validate required fields @@ -170,10 +172,10 @@ export class AuthService { } // Extract employeeId (optional) - const employeeId = - oktaUser.employeeId || - oktaUser.employee_id || - oktaUser.empId || + const employeeId = + oktaUser.employeeId || + oktaUser.employee_id || + oktaUser.empId || oktaUser.employeeNumber || undefined; @@ -181,6 +183,8 @@ export class AuthService { oktaSub: sub, email: oktaUser.email || '', employeeId: employeeId, + dealerCode: oktaUser.dealer_code || undefined, + employeeNumber: oktaUser.dealer_code || oktaUser.employeeNumber || undefined, }; // Validate: Ensure we're not accidentally using oktaSub as employeeId @@ -287,6 +291,9 @@ export class AuthService { if (userData.jobTitle) userUpdateData.jobTitle = userData.jobTitle; // Job title from SSO if (userData.postalAddress) userUpdateData.postalAddress = userData.postalAddress; // Address from SSO if (userData.mobilePhone) userUpdateData.mobilePhone = userData.mobilePhone; // Mobile phone from SSO + if (userData.employeeNumber || userData.dealerCode) { + userUpdateData.employeeNumber = userData.employeeNumber || userData.dealerCode; + } if (userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0) { userUpdateData.adGroups = userData.adGroups; // Group memberships from SSO } @@ -301,7 +308,7 @@ export class AuthService { await user.update(userUpdateData); // Reload to get updated data user = await user.reload(); - + logAuthEvent('sso_callback', user.userId, { email: userData.email, action: 'user_updated', @@ -322,13 +329,14 @@ export class AuthService { manager: userData.manager || null, // Manager name from SSO jobTitle: userData.jobTitle || null, // Job title from SSO postalAddress: userData.postalAddress || null, // Address from SSO - mobilePhone: userData.mobilePhone || null, // Mobile phone from SSO - adGroups: userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0 ? userData.adGroups : null, // Groups from SSO + mobilePhone: userData.mobilePhone || null, + adGroups: userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0 ? userData.adGroups : null, + employeeNumber: userData.employeeNumber || userData.dealerCode || null, isActive: true, role: 'USER', lastLogin: new Date() }); - + logAuthEvent('sso_callback', user.userId, { email: userData.email, action: 'user_created', @@ -428,7 +436,7 @@ export class AuthService { async refreshAccessToken(refreshToken: string): Promise { try { const decoded = jwt.verify(refreshToken, ssoConfig.jwtSecret) as any; - + if (decoded.type !== 'refresh') { throw new Error('Invalid refresh token'); } @@ -495,7 +503,7 @@ export class AuthService { // Step 1: Authenticate with Okta using Resource Owner Password flow // Note: This requires Okta to have Resource Owner Password grant type enabled const tokenEndpoint = `${ssoConfig.oktaDomain}/oauth2/default/v1/token`; - + const tokenResponse = await axios.post( tokenEndpoint, new URLSearchParams({ @@ -521,7 +529,7 @@ export class AuthService { status: tokenResponse.status, data: tokenResponse.data, }); - + const errorData = tokenResponse.data || {}; const errorMessage = errorData.error_description || errorData.error || 'Invalid username or password'; throw new Error(`Authentication failed: ${errorMessage}`); @@ -545,7 +553,7 @@ export class AuthService { const oktaUserInfo = userInfoResponse.data; const oktaSub = oktaUserInfo.sub || ''; - + if (!oktaSub) { throw new Error('Okta sub (subject identifier) not found in response'); } @@ -553,7 +561,7 @@ export class AuthService { // Step 3: Try Users API first (provides full profile including manager, employeeID, etc.) let userData: SSOUserData | null = null; const usersApiResponse = await this.fetchUserFromOktaUsersAPI(oktaSub, oktaUserInfo.email || username, access_token); - + if (usersApiResponse) { userData = this.extractUserDataFromUsersAPI(usersApiResponse, oktaSub); } @@ -638,17 +646,17 @@ export class AuthService { if (!redirectUri || redirectUri.trim() === '') { throw new Error('Redirect URI is required'); } - - logger.info('Exchanging code with Okta', { + + logger.info('Exchanging code with Okta', { redirectUri, codePrefix: code.substring(0, 10) + '...', oktaDomain: ssoConfig.oktaDomain, clientId: ssoConfig.oktaClientId, hasClientSecret: !!ssoConfig.oktaClientSecret && !ssoConfig.oktaClientSecret.includes('your_okta_client_secret'), }); - + const tokenEndpoint = `${ssoConfig.oktaDomain}/oauth2/default/v1/token`; - + // Exchange authorization code for tokens // redirect_uri here must match the one used when requesting the authorization code const tokenResponse = await axios.post( @@ -678,7 +686,7 @@ export class AuthService { data: tokenResponse.data, headers: tokenResponse.headers, }); - + const errorData = tokenResponse.data || {}; const errorMessage = errorData.error_description || errorData.error || 'Unknown error from Okta'; throw new Error(`Okta token exchange failed (${tokenResponse.status}): ${errorMessage}`); @@ -697,14 +705,14 @@ export class AuthService { const { access_token, refresh_token, id_token } = tokenResponse.data; if (!access_token) { - logger.error('Missing access_token in Okta response', { + logger.error('Missing access_token in Okta response', { responseKeys: Object.keys(tokenResponse.data || {}), hasRefreshToken: !!refresh_token, hasIdToken: !!id_token, }); throw new Error('Failed to obtain access token from Okta - access_token missing in response'); } - + logger.info('Successfully obtained tokens from Okta', { hasAccessToken: !!access_token, hasRefreshToken: !!refresh_token, @@ -722,7 +730,7 @@ export class AuthService { const oktaUserInfo = userInfoResponse.data; const oktaSub = oktaUserInfo.sub || ''; - + if (!oktaSub) { throw new Error('Okta sub (subject identifier) is required but not found in response'); } @@ -730,7 +738,7 @@ export class AuthService { // Try Users API first (provides full profile including manager, employeeID, etc.) let userData: SSOUserData | null = null; const usersApiResponse = await this.fetchUserFromOktaUsersAPI(oktaSub, oktaUserInfo.email || '', access_token); - + if (usersApiResponse) { userData = this.extractUserDataFromUsersAPI(usersApiResponse, oktaSub); } @@ -777,7 +785,7 @@ export class AuthService { oktaError: error.response?.data?.error, oktaErrorDescription: error.response?.data?.error_description, }); - + // Provide a more user-friendly error message if (error.response?.data) { const errorData = error.response.data; @@ -793,7 +801,7 @@ export class AuthService { throw new Error(`Okta authentication failed: Unexpected response format. Status: ${error.response.status}`); } } - + throw new Error(`Okta authentication failed: ${error.message || 'Unknown error'}`); } } @@ -817,17 +825,17 @@ export class AuthService { if (!redirectUri || redirectUri.trim() === '') { throw new Error('Redirect URI is required'); } - - logger.info('Exchanging code with Tanflow', { + + logger.info('Exchanging code with Tanflow', { redirectUri, codePrefix: code.substring(0, 10) + '...', tanflowBaseUrl: ssoConfig.tanflowBaseUrl, clientId: ssoConfig.tanflowClientId, hasClientSecret: !!ssoConfig.tanflowClientSecret, }); - + const tokenEndpoint = `${ssoConfig.tanflowBaseUrl}/protocol/openid-connect/token`; - + // Exchange authorization code for tokens const tokenResponse = await axios.post( tokenEndpoint, @@ -855,7 +863,7 @@ export class AuthService { statusText: tokenResponse.statusText, data: tokenResponse.data, }); - + const errorData = tokenResponse.data || {}; const errorMessage = errorData.error_description || errorData.error || 'Unknown error from Tanflow'; throw new Error(`Tanflow token exchange failed (${tokenResponse.status}): ${errorMessage}`); @@ -873,14 +881,14 @@ export class AuthService { const { access_token, refresh_token, id_token } = tokenResponse.data; if (!access_token) { - logger.error('Missing access_token in Tanflow response', { + logger.error('Missing access_token in Tanflow response', { responseKeys: Object.keys(tokenResponse.data || {}), hasRefreshToken: !!refresh_token, hasIdToken: !!id_token, }); throw new Error('Failed to obtain access token from Tanflow - access_token missing in response'); } - + logger.info('Successfully obtained tokens from Tanflow', { hasAccessToken: !!access_token, hasRefreshToken: !!refresh_token, @@ -897,7 +905,7 @@ export class AuthService { const tanflowUserInfo = userInfoResponse.data; const tanflowSub = tanflowUserInfo.sub || ''; - + if (!tanflowSub) { throw new Error('Tanflow sub (subject identifier) is required but not found in response'); } @@ -987,7 +995,7 @@ export class AuthService { tanflowError: error.response?.data?.error, tanflowErrorDescription: error.response?.data?.error_description, }); - + if (error.response?.data) { const errorData = error.response.data; if (typeof errorData === 'object' && !Array.isArray(errorData)) { @@ -1001,7 +1009,7 @@ export class AuthService { throw new Error(`Tanflow authentication failed: Unexpected response format. Status: ${error.response.status}`); } } - + throw new Error(`Tanflow authentication failed: ${error.message || 'Unknown error'}`); } } @@ -1016,7 +1024,7 @@ export class AuthService { } const tokenEndpoint = `${ssoConfig.tanflowBaseUrl}/protocol/openid-connect/token`; - + const response = await axios.post( tokenEndpoint, new URLSearchParams({ diff --git a/src/services/dealer.service.ts b/src/services/dealer.service.ts index 4d8a857..8666e67 100644 --- a/src/services/dealer.service.ts +++ b/src/services/dealer.service.ts @@ -118,12 +118,54 @@ export async function getAllDealers(searchTerm?: string, limit: number = 10): Pr } } +import { dealerExternalService } from './dealerExternal.service'; + /** - * Get dealer by code (dlrcode) - * Checks if dealer is logged in by matching domain_id with users.email + * Get dealer by code (dlrcode / employeeId) + * Validates dealer exists in User table with jobTitle 'Dealer' and matching employeeNumber. + * Enriches with real-time data from External Dealer API. */ export async function getDealerByCode(dealerCode: string): Promise { try { + // 1. Fetch data from External Dealer API (Source of truth for GSTIN, Address, etc.) + let externalData = null; + try { + externalData = await dealerExternalService.getDealerByCode(dealerCode); + } catch (err) { + logger.warn(`[DealerService] External API failed for ${dealerCode}, falling back to database only.`); + } + + // 2. Try to find in User table directly (Validation logic) + const user = await User.findOne({ + where: { + employeeNumber: dealerCode, + jobTitle: 'Dealer', + isActive: true + }, + attributes: ['userId', 'employeeNumber', 'email', 'displayName', 'phone', 'department', 'designation', 'jobTitle', 'mobilePhone'] + }); + + if (user) { + logger.info(`[DealerService] Dealer found in User table: ${dealerCode}`); + return { + dealerId: user.userId, + userId: user.userId, + email: user.email, + dealerCode: user.employeeNumber || dealerCode, + dealerName: externalData?.['dealer name'] || user.displayName || '', + displayName: user.displayName || '', + phone: externalData?.['dealer phone'] || (user as any).mobilePhone || user.phone || undefined, + department: user.department || undefined, + designation: user.designation || user.jobTitle || undefined, + isLoggedIn: true, + gstin: externalData?.gstin || null, + city: externalData?.['re city'] || null, + state: externalData?.['re state code'] || null, + pincode: externalData?.pincode || null, + }; + } + + // 3. Fallback to local Dealer table const dealer = await Dealer.findOne({ where: { dlrcode: dealerCode, @@ -132,46 +174,49 @@ export async function getDealerByCode(dealerCode: string): Promise { try { + // 0. Validate Dealer User (jobTitle='Dealer' and employeeId=dealerCode) + logger.info(`[DealerClaimService] Validating dealer for code: ${claimData.dealerCode}`); + const dealerUser = await validateDealerUser(claimData.dealerCode); + + // Update claim data with validated dealer user details if not provided + claimData.dealerName = dealerUser.displayName || claimData.dealerName; + claimData.dealerEmail = dealerUser.email || claimData.dealerEmail; + claimData.dealerPhone = (dealerUser as any).mobilePhone || (dealerUser as any).phone || claimData.dealerPhone; + // Generate request number const requestNumber = await generateRequestNumber(); @@ -119,6 +129,7 @@ export class DealerClaimService { } // Fallback: Enrichment from local dealer table if data is missing or incomplete + // We still keep this as a secondary fallback, but validation above is primary const localDealer = await findDealerLocally(claimData.dealerCode, claimData.dealerEmail); if (localDealer) { logger.info(`[DealerClaimService] Enriched claim request with local dealer data: ${localDealer.dealerCode}`); @@ -148,6 +159,17 @@ export class DealerClaimService { for (const a of claimData.approvers) { let approverUserId = a.userId; + // Determine level name - use mapped name or fallback to "Step X" + let levelName = stepNames[a.level] || `Step ${a.level}`; + + // If this is a Dealer-specific step (Step 1 or Step 4), ensure we use the validated dealerUser + if (a.level === 1 || a.level === 4) { + logger.info(`[DealerClaimService] Assigning validated dealer user to ${levelName} (Step ${a.level})`); + approverUserId = dealerUser.userId; + a.email = dealerUser.email; + a.name = dealerUser.displayName || dealerUser.email; + } + // If userId missing, ensure user exists by email if (!approverUserId && a.email) { try { @@ -165,9 +187,7 @@ export class DealerClaimService { tatHours = a.tatType === 'days' ? val * 24 : val; } - // Determine level name - use mapped name or fallback to "Step X" - // Also handle "Additional Approver" case if provided - let levelName = stepNames[a.level] || `Step ${a.level}`; + // Already determined levelName above // If it's an additional approver (not one of the standard steps), label it clearly // Note: The frontend might send extra steps if approvers are added dynamically @@ -1072,11 +1092,12 @@ export class DealerClaimService { }); // Fetch Internal Order details - const internalOrder = await InternalOrder.findOne({ + const internalOrders = await InternalOrder.findAll({ where: { requestId }, include: [ { model: User, as: 'organizer', required: false } - ] + ], + order: [['createdAt', 'ASC']] }); // Serialize claim details to ensure proper field names @@ -1123,19 +1144,27 @@ export class DealerClaimService { } - // Fetch dealer GSTIN from dealers table + serializedClaimDetails.internalOrders = internalOrders.map(io => (io as any).toJSON ? (io as any).toJSON() : io); + // Maintain backward compatibility for single internalOrder field (using first one) + serializedClaimDetails.internalOrder = internalOrders.length > 0 ? (internalOrders[0] as any).toJSON ? (internalOrders[0] as any).toJSON() : internalOrders[0] : null; + + // Fetch dealer details (GSTIN, State, City) from dealers table / external API enrichment try { - const dealer = await Dealer.findOne({ - where: { dlrcode: claimDetails.dealerCode } - }); + const dealer = await findDealerLocally(claimDetails.dealerCode); + if (dealer) { - serializedClaimDetails.dealerGstin = dealer.gst || null; - // Also add for backward compatibility if needed - serializedClaimDetails.dealerGSTIN = dealer.gst || null; + + serializedClaimDetails.dealerGstin = dealer.gstin || null; + serializedClaimDetails.dealerGSTIN = dealer.gstin || null; // Backward compatibility + serializedClaimDetails.dealerState = dealer.state || null; + serializedClaimDetails.dealerCity = dealer.city || null; + + logger.info(`[DealerClaimService] Enriched claim details with dealer info for ${claimDetails.dealerCode}: GSTIN=${dealer.gstin}, State=${dealer.state}`); } } catch (dealerError) { - logger.warn(`[DealerClaimService] Error fetching dealer GSTIN for ${claimDetails.dealerCode}:`, dealerError); + logger.warn(`[DealerClaimService] Error fetching dealer details for ${claimDetails.dealerCode}:`, dealerError); } + } // Transform proposal details to include cost items as array @@ -1176,10 +1205,9 @@ export class DealerClaimService { } // Serialize internal order details - let serializedInternalOrder = null; - if (internalOrder) { - serializedInternalOrder = (internalOrder as any).toJSON ? (internalOrder as any).toJSON() : internalOrder; - } + const serializedInternalOrders = internalOrders.map(io => (io as any).toJSON ? (io as any).toJSON() : io); + // For backward compatibility, also provide serializedInternalOrder (first one) + const serializedInternalOrder = serializedInternalOrders.length > 0 ? serializedInternalOrders[0] : null; // Fetch Budget Tracking details const budgetTracking = await ClaimBudgetTracking.findOne({ @@ -1365,7 +1393,20 @@ export class DealerClaimService { await DealerProposalCostItem.bulkCreate(costItems); logger.info(`[DealerClaimService] Saved ${costItems.length} cost items for proposal ${proposalId}`); - // Update budget tracking with proposal estimate + // Calculate total proposed taxable amount for IO blocking + const totalProposedTaxableAmount = proposalData.costBreakup.reduce((sum: number, item: any) => { + const amount = Number(item.amount) || 0; + const quantity = Number(item.quantity) || 1; + return sum + (amount * quantity); + }, 0); + + // Update taxable amount in DealerClaimDetails for IO blocking reference + await DealerClaimDetails.update( + { totalProposedTaxableAmount }, + { where: { requestId } } + ); + + // Update budget tracking with proposed expenses await ClaimBudgetTracking.upsert({ requestId, proposalEstimatedBudget: proposalData.totalEstimatedBudget, @@ -1501,8 +1542,10 @@ export class DealerClaimService { const isIGST = dealerStateCode !== buyerStateCode; const completionId = (completionDetails as any)?.completionId; + const expenseRows: any[] = []; if (completionData.closedExpenses && completionData.closedExpenses.length > 0) { // Determine taxation type for fallback logic + const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); let isNonGst = false; if (claimDetails?.activityType) { const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } }); @@ -1512,37 +1555,36 @@ export class DealerClaimService { // Clear existing expenses for this request to avoid duplicates await DealerCompletionExpense.destroy({ where: { requestId } }); - const expenseRows = completionData.closedExpenses.map((item: any) => { + completionData.closedExpenses.forEach((item: any) => { const amount = Number(item.amount) || 0; const quantity = Number(item.quantity) || 1; const baseTotal = amount * quantity; - // Use provided tax details or calculate if missing/zero - let gstRate = Number(item.gstRate); - if (isNaN(gstRate) || gstRate === 0) { - // Fallback to activity GST rate ONLY for GST claims - gstRate = isNonGst ? 0 : 18; + // Tax calculations (simplified for brevity, matching previous logic) + const gstRate = isNonGst ? 0 : (Number(item.gstRate) || 18); + const totalTaxAmt = baseTotal * (gstRate / 100); + + let finalCgstRate = 0, finalCgstAmt = 0, finalSgstRate = 0, finalSgstAmt = 0, finalIgstRate = 0, finalIgstAmt = 0, finalUtgstRate = 0, finalUtgstAmt = 0; + + if (!isNonGst) { + if (isIGST) { + finalIgstRate = gstRate; + finalIgstAmt = totalTaxAmt; + } else { + finalCgstRate = gstRate / 2; + finalCgstAmt = totalTaxAmt / 2; + finalSgstRate = gstRate / 2; + finalSgstAmt = totalTaxAmt / 2; + } } - const hasUtgst = (Number(item.utgstRate) > 0 || Number(item.utgstAmt) > 0); - - const finalIgstRate = isIGST ? (Number(item.igstRate) || gstRate) : 0; - const finalCgstRate = !isIGST ? (Number(item.cgstRate) || gstRate / 2) : 0; - const finalSgstRate = (!isIGST && !hasUtgst) ? (Number(item.sgstRate) || gstRate / 2) : 0; - const finalUtgstRate = (!isIGST && hasUtgst) ? (Number(item.utgstRate) || gstRate / 2) : 0; - - const finalIgstAmt = isIGST ? (Number(item.igstAmt) || (baseTotal * finalIgstRate) / 100) : 0; - const finalCgstAmt = !isIGST ? (Number(item.cgstAmt) || (baseTotal * finalCgstRate) / 100) : 0; - const finalSgstAmt = (!isIGST && !hasUtgst) ? (Number(item.sgstAmt) || (baseTotal * finalSgstRate) / 100) : 0; - const finalUtgstAmt = (!isIGST && hasUtgst) ? (Number(item.utgstAmt) || (baseTotal * finalUtgstRate) / 100) : 0; - const totalTaxAmt = finalIgstAmt + finalCgstAmt + finalSgstAmt + finalUtgstAmt; - - return { + expenseRows.push({ requestId, completionId, description: item.description, amount, quantity, + taxableAmt: baseTotal, // Added for Scenario 1 comparison hsnCode: item.hsnCode || '', gstRate, gstAmt: totalTaxAmt, @@ -1559,20 +1601,71 @@ export class DealerClaimService { totalAmt: Number(item.totalAmt) || (baseTotal + totalTaxAmt), isService: !!item.isService, expenseDate: item.date instanceof Date ? item.date : (item.date ? new Date(item.date) : (completionData.activityCompletionDate || new Date())), - }; + }); }); await DealerCompletionExpense.bulkCreate(expenseRows); } - // Update budget tracking with closed expenses + // Update budget tracking with closed expenses (Gross and Taxable) + const totalTaxableClosedExpenses = expenseRows.reduce((sum, item) => sum + Number(item.taxableAmt || 0), 0); + await ClaimBudgetTracking.upsert({ requestId, - closedExpenses: completionData.totalClosedExpenses, + closedExpenses: completionData.totalClosedExpenses, // Gross amount + taxableClosedExpenses: totalTaxableClosedExpenses, // Taxable amount (for Scenario 1 comparison) closedExpensesSubmittedAt: new Date(), budgetStatus: BudgetStatus.CLOSED, currency: 'INR', }); + // Scenario 1: Unblocking excess budget if actual taxable expenses < total blocked amount + const allInternalOrders = await InternalOrder.findAll({ where: { requestId, status: IOStatus.BLOCKED } }); + const totalBlockedAmount = allInternalOrders.reduce((sum, io) => sum + Number(io.ioBlockedAmount || 0), 0); + + if (totalTaxableClosedExpenses < totalBlockedAmount) { + const amountToRelease = parseFloat((totalBlockedAmount - totalTaxableClosedExpenses).toFixed(2)); + logger.info(`[DealerClaimService] Scenario 1: Actual taxable expenses (₹${totalTaxableClosedExpenses}) < Total blocked (₹${totalBlockedAmount}). Releasing ₹${amountToRelease}.`); + + // Release budget from the most recent IO record (or first available) + // In a more complex setup, we might release proportionally, but here we pick the one with enough balance + const ioToRelease = allInternalOrders.sort((a, b) => (b.createdAt as any) - (a.createdAt as any))[0]; + + if (ioToRelease && amountToRelease > 0) { + try { + const releaseResult = await sapIntegrationService.releaseBudget( + ioToRelease.ioNumber, + amountToRelease, + String((request as any).requestNumber || (request as any).request_number || requestId), + ioToRelease.sapDocumentNumber || undefined + ); + + if (releaseResult.success) { + logger.info(`[DealerClaimService] Successfully released ₹${amountToRelease} from IO ${ioToRelease.ioNumber}`); + } else { + logger.warn(`[DealerClaimService] SAP release failed: ${releaseResult.error}`); + } + } catch (releaseError) { + logger.error(`[DealerClaimService] Error during budget release:`, releaseError); + } + } + } else if (totalTaxableClosedExpenses > totalBlockedAmount) { + // Scenario 2: Actual taxable expenses > Total blocked amount + const additionalAmountNeeded = parseFloat((totalTaxableClosedExpenses - totalBlockedAmount).toFixed(2)); + logger.info(`[DealerClaimService] Scenario 2: Actual taxable expenses (₹${totalTaxableClosedExpenses}) > Total blocked (₹${totalBlockedAmount}). Additional ₹${additionalAmountNeeded} needs to be blocked.`); + + // Update DealerClaimDetails with the new total required amount for IO blocking reference + await DealerClaimDetails.update( + { totalProposedTaxableAmount: totalTaxableClosedExpenses }, + { where: { requestId } } + ); + + // Signal that re-blocking is needed by updating status back to PROPOSED + await ClaimBudgetTracking.update( + { budgetStatus: BudgetStatus.PROPOSED }, + { where: { requestId } } + ); + } + // Approve Dealer Completion Documents step dynamically (by levelName, not hardcoded step number) let dealerCompletionLevel = await ApprovalLevel.findOne({ where: { @@ -1677,43 +1770,20 @@ export class DealerClaimService { if (ioData.ioNumber) { const organizedBy = organizedByUserId || null; - // Create or update Internal Order record with just IO details (no blocking) - const [internalOrder, created] = await InternalOrder.findOrCreate({ - where: { requestId }, - defaults: { - requestId, - ioNumber: ioData.ioNumber, - ioRemark: ioData.ioRemark || '', // Optional - kept for backward compatibility // Optional - keep for backward compatibility - ioAvailableBalance: ioData.availableBalance || 0, - ioBlockedAmount: 0, - ioRemainingBalance: ioData.remainingBalance || 0, - organizedBy: organizedBy || undefined, - organizedAt: new Date(), - status: IOStatus.PENDING, - } + // Always create a new Internal Order record for each block/provision (supporting multiple IOs) + await InternalOrder.create({ + requestId, + ioNumber: ioData.ioNumber, + ioRemark: ioData.ioRemark || '', + ioAvailableBalance: ioData.availableBalance || 0, + ioBlockedAmount: 0, + ioRemainingBalance: ioData.remainingBalance || 0, + organizedBy: organizedBy || undefined, + organizedAt: new Date(), + status: IOStatus.PENDING, }); - if (!created) { - // Update existing IO record with new IO details - // IMPORTANT: When updating existing record, preserve balance fields from previous blocking - // Only update ioNumber - don't overwrite balance values - await internalOrder.update({ - ioNumber: ioData.ioNumber, - // Don't update balance fields for existing records - preserve values from previous blocking - // Only update organizedBy and organizedAt - organizedBy: organizedBy || internalOrder.organizedBy, - organizedAt: new Date(), - }); - - logger.info(`[DealerClaimService] IO details updated (preserved existing balance values) for request: ${requestId}`, { - ioNumber: ioData.ioNumber, - preservedAvailableBalance: internalOrder.ioAvailableBalance, - preservedBlockedAmount: internalOrder.ioBlockedAmount, - preservedRemainingBalance: internalOrder.ioRemainingBalance, - }); - } - - logger.info(`[DealerClaimService] IO details saved (without blocking) for request: ${requestId}`, { + logger.info(`[DealerClaimService] IO provision record created for request: ${requestId}`, { ioNumber: ioData.ioNumber }); @@ -1754,166 +1824,59 @@ export class DealerClaimService { } const sapReturnedBlockedAmount = blockResult.blockedAmount; - // Extract SAP reference number from blockId (this is the Sap_Reference_no from SAP response) - // Only use the actual SAP reference number - don't use any generated fallback const sapDocumentNumber = blockResult.blockId || undefined; - // Ensure availableBalance is rounded to 2 decimal places for accurate calculations const availableBalance = parseFloat((ioData.availableBalance || ioValidation.availableBalance).toFixed(2)); - // Log if SAP reference number was received - if (sapDocumentNumber) { - logger.info(`[DealerClaimService] ✅ SAP Reference Number received: ${sapDocumentNumber}`); - } else { - logger.warn(`[DealerClaimService] ⚠️ No SAP Reference Number received from SAP response`); - } - - // Use the amount we REQUESTED for calculation, not what SAP returned - // SAP might return a slightly different amount due to rounding, but we calculate based on what we requested - // Only use SAP's returned amount if it's significantly different (more than 1 rupee), which would indicate an actual issue + // Use the amount we REQUESTED for calculation, unless SAP blocked significantly different amount const amountDifference = Math.abs(sapReturnedBlockedAmount - blockedAmount); - const useSapAmount = amountDifference > 1.0; // Only use SAP's amount if difference is more than 1 rupee + const useSapAmount = amountDifference > 1.0; const finalBlockedAmount = useSapAmount ? sapReturnedBlockedAmount : blockedAmount; - // Log SAP response vs what we sent - logger.info(`[DealerClaimService] SAP block result:`, { - requestedAmount: blockedAmount, - sapReturnedBlockedAmount: sapReturnedBlockedAmount, - sapReturnedRemainingBalance: blockResult.remainingBalance, - sapDocumentNumber: sapDocumentNumber, // SAP reference number from response - availableBalance, - amountDifference, - usingSapAmount: useSapAmount, - finalBlockedAmountUsed: finalBlockedAmount, - }); - - // Warn if SAP blocked a significantly different amount than requested - if (amountDifference > 0.01) { - if (amountDifference > 1.0) { - logger.warn(`[DealerClaimService] ⚠️ Significant amount mismatch! Requested: ${blockedAmount}, SAP blocked: ${sapReturnedBlockedAmount}, Difference: ${amountDifference}`); - } else { - logger.info(`[DealerClaimService] Minor amount difference (likely rounding): Requested: ${blockedAmount}, SAP returned: ${sapReturnedBlockedAmount}, Using requested amount for calculation`); - } - } - - // Calculate remaining balance: availableBalance - requestedAmount - // IMPORTANT: Use the amount we REQUESTED, not SAP's returned amount (unless SAP blocked significantly different amount) - // This ensures accuracy: remaining = available - requested - // Round to 2 decimal places to avoid floating point precision issues + // Calculate remaining balance const calculatedRemainingBalance = parseFloat((availableBalance - finalBlockedAmount).toFixed(2)); - - // Only use SAP's value if it's valid AND matches our calculation (within 1 rupee tolerance) - // This is a safety check - if SAP's value is way off, use our calculation - // Round SAP's value to 2 decimal places for consistency const sapRemainingBalance = blockResult.remainingBalance ? parseFloat(blockResult.remainingBalance.toFixed(2)) : 0; const sapValueIsValid = sapRemainingBalance > 0 && sapRemainingBalance <= availableBalance && Math.abs(sapRemainingBalance - calculatedRemainingBalance) < 1; - const remainingBalance = sapValueIsValid - ? sapRemainingBalance - : calculatedRemainingBalance; - - // Ensure remaining balance is not negative and round to 2 decimal places + const remainingBalance = sapValueIsValid ? sapRemainingBalance : calculatedRemainingBalance; const finalRemainingBalance = parseFloat(Math.max(0, remainingBalance).toFixed(2)); - // Warn if SAP's value doesn't match our calculation - if (!sapValueIsValid && sapRemainingBalance !== calculatedRemainingBalance) { - logger.warn(`[DealerClaimService] ⚠️ SAP returned invalid remaining balance (${sapRemainingBalance}), using calculated value (${calculatedRemainingBalance})`); - } - - logger.info(`[DealerClaimService] Budget blocking calculation:`, { - availableBalance, - blockedAmount: finalBlockedAmount, - sapRemainingBalance, - calculatedRemainingBalance, - finalRemainingBalance - }); - - // Get the user who is blocking the IO (current user) + // Create new Internal Order record for this block operation (supporting multiple blocks) const organizedBy = organizedByUserId || null; - - // Round amounts to exactly 2 decimal places for database storage (avoid floating point precision issues) - // Use parseFloat with toFixed to ensure exact 2 decimal precision - const roundedAvailableBalance = parseFloat(availableBalance.toFixed(2)); - const roundedBlockedAmount = parseFloat(finalBlockedAmount.toFixed(2)); - const roundedRemainingBalance = parseFloat(finalRemainingBalance.toFixed(2)); - - // Create or update Internal Order record (only when blocking) - const ioRecordData = { + await InternalOrder.create({ requestId, ioNumber: ioData.ioNumber, - ioRemark: ioData.ioRemark || '', // Optional - kept for backward compatibility - ioAvailableBalance: roundedAvailableBalance, - ioBlockedAmount: roundedBlockedAmount, - ioRemainingBalance: roundedRemainingBalance, - sapDocumentNumber: sapDocumentNumber, // Store SAP reference number - organizedBy: organizedBy || undefined, - organizedAt: new Date(), - status: IOStatus.BLOCKED, - }; - - logger.info(`[DealerClaimService] Storing IO details in database:`, { - ioNumber: ioData.ioNumber, + ioRemark: ioData.ioRemark || '', ioAvailableBalance: availableBalance, ioBlockedAmount: finalBlockedAmount, ioRemainingBalance: finalRemainingBalance, - sapDocumentNumber: sapDocumentNumber, - requestId + organizedBy: organizedBy || undefined, + organizedAt: new Date(), + sapDocumentNumber: sapDocumentNumber || undefined, + status: IOStatus.BLOCKED, }); - const [internalOrder, created] = await InternalOrder.findOrCreate({ - where: { requestId }, - defaults: ioRecordData + // Update budget tracking with TOTAL blocked amount from all records + const allInternalOrders = await InternalOrder.findAll({ where: { requestId, status: IOStatus.BLOCKED } }); + const totalBlockedAmount = allInternalOrders.reduce((sum, io) => sum + Number(io.ioBlockedAmount || 0), 0); + + await ClaimBudgetTracking.upsert({ + requestId, + ioBlockedAmount: totalBlockedAmount, + ioBlockedAt: new Date(), + budgetStatus: BudgetStatus.BLOCKED, + currency: 'INR', }); - if (!created) { - // Update existing IO record - explicitly update all fields including remainingBalance - logger.info(`[DealerClaimService] Updating existing IO record for request: ${requestId}`); - logger.info(`[DealerClaimService] Update data:`, { - ioRemainingBalance: ioRecordData.ioRemainingBalance, - ioBlockedAmount: ioRecordData.ioBlockedAmount, - ioAvailableBalance: ioRecordData.ioAvailableBalance, - sapDocumentNumber: ioRecordData.sapDocumentNumber - }); - - // Explicitly update all fields to ensure remainingBalance is saved - const updateResult = await internalOrder.update({ - ioNumber: ioRecordData.ioNumber, - ioRemark: ioRecordData.ioRemark, - ioAvailableBalance: ioRecordData.ioAvailableBalance, - ioBlockedAmount: ioRecordData.ioBlockedAmount, - ioRemainingBalance: ioRecordData.ioRemainingBalance, // Explicitly ensure this is updated - sapDocumentNumber: ioRecordData.sapDocumentNumber, // Update SAP document number - organizedBy: ioRecordData.organizedBy, - organizedAt: ioRecordData.organizedAt, - status: ioRecordData.status - }); - - logger.info(`[DealerClaimService] Update result:`, updateResult ? 'Success' : 'Failed'); - } else { - logger.info(`[DealerClaimService] Created new IO record for request: ${requestId}`); - } - - // Verify what was actually saved - reload from database - await internalOrder.reload(); - const savedRemainingBalance = internalOrder.ioRemainingBalance; - - logger.info(`[DealerClaimService] ✅ IO record after save (verified from database):`, { - ioId: internalOrder.ioId, - ioNumber: internalOrder.ioNumber, - ioAvailableBalance: internalOrder.ioAvailableBalance, - ioBlockedAmount: internalOrder.ioBlockedAmount, - ioRemainingBalance: savedRemainingBalance, - expectedRemainingBalance: finalRemainingBalance, - match: savedRemainingBalance === finalRemainingBalance || Math.abs((savedRemainingBalance || 0) - finalRemainingBalance) < 0.01, - status: internalOrder.status + logger.info(`[DealerClaimService] Budget block recorded for request: ${requestId}`, { + ioNumber: ioData.ioNumber, + blockedAmount: finalBlockedAmount, + totalBlockedAmount, + sapDocumentNumber, + finalRemainingBalance }); - // Warn if remaining balance doesn't match - if (Math.abs((savedRemainingBalance || 0) - finalRemainingBalance) >= 0.01) { - logger.error(`[DealerClaimService] ⚠️ WARNING: Remaining balance mismatch! Expected: ${finalRemainingBalance}, Saved: ${savedRemainingBalance}`); - } - // Save IO history after successful blocking // Find the Department Lead IO Approval level (Step 3) const ioApprovalLevel = await ApprovalLevel.findOne({ @@ -2835,10 +2798,14 @@ export class DealerClaimService { // Build changeReason - will be updated later if moving to next level // For now, just include the basic approval/rejection info - const changeReason = action === 'APPROVE' + let changeReason = action === 'APPROVE' ? `Approved by ${level.approverName || level.approverEmail}` : `Rejected by ${level.approverName || level.approverEmail}`; + if (action === 'REJECT' && (rejectionReason || comments)) { + changeReason += `. Reason: ${rejectionReason || comments}`; + } + await DealerClaimHistory.create({ requestId, approvalLevelId, diff --git a/src/services/dealerClaimApproval.service.ts b/src/services/dealerClaimApproval.service.ts index 269a3a6..5191b7b 100644 --- a/src/services/dealerClaimApproval.service.ts +++ b/src/services/dealerClaimApproval.service.ts @@ -246,6 +246,44 @@ export class DealerClaimApprovalService { 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}`); + + // SPECIAL LOGIC: If we are moving to Step 4 (Dealer Completion Documents), + // check if this is a re-quotation flow. If so, skip Step 4 and move to Step 5. + if (nextLevelNumber === 4) { + try { + const { DealerClaimHistory, SnapshotType } = await import('@models/DealerClaimHistory'); + const reQuotationHistory = await DealerClaimHistory.findOne({ + where: { + requestId: level.requestId, + snapshotType: SnapshotType.APPROVE, + changeReason: { [Op.iLike]: '%Revised Quotation Requested%' } + } + }); + + if (reQuotationHistory) { + logger.info(`[DealerClaimApproval] Re-quotation detected in history for request ${level.requestId}. Skipping Step 4 (Completion Documents).`); + + // Skip level 4 + await nextLevel.update({ + status: ApprovalStatus.SKIPPED, + comments: 'Skipped - Re-quotation flow' + }); + + // Find level 5 + const level5 = await ApprovalLevel.findOne({ + where: { requestId: level.requestId, levelNumber: 5 } + }); + + if (level5) { + logger.info(`[DealerClaimApproval] Redirecting to Step 5 for request ${level.requestId}`); + nextLevel = level5; // Switch to level 5 as the "next level" to activate + } + } + } catch (historyError) { + logger.error(`[DealerClaimApproval] Error checking re-quotation history:`, historyError); + // Fallback: proceed to Step 4 normally if history check fails + } + } } else { logger.info(`[DealerClaimApproval] No next level found after level ${currentLevelNumber} - this may be the final approval`); } @@ -790,10 +828,34 @@ export class DealerClaimApprovalService { }); } else { // Return to previous step - logger.info(`[DealerClaimApproval] Returning to previous level ${previousLevel.levelNumber} (${previousLevel.levelName || 'unnamed'})`); + let targetLevel = previousLevel; + const isReQuotation = (action.rejectionReason || action.comments || '').includes('Revised Quotation Requested'); - // Reset previous level to IN_PROGRESS so it can be acted upon again - await previousLevel.update({ + if (isReQuotation) { + const step1 = allLevels.find(l => l.levelNumber === 1); + if (step1) { + logger.info(`[DealerClaimApproval] Re-quotation requested. Jumping to Step 1 for request ${level.requestId}`); + targetLevel = step1; + + // Reset all intermediate levels (between Step 1 and current level) + const intermediateLevels = allLevels.filter(l => l.levelNumber > 1 && l.levelNumber <= currentLevelNumber); + for (const iLevel of intermediateLevels) { + await iLevel.update({ + status: ApprovalStatus.PENDING, + actionDate: null, + levelEndTime: null, + comments: null, + elapsedHours: 0, + tatPercentageUsed: 0 + } as any); + } + } + } + + logger.info(`[DealerClaimApproval] Returning to level ${targetLevel.levelNumber} (${targetLevel.levelName || 'unnamed'})`); + + // Reset target level to IN_PROGRESS so it can be acted upon again + await targetLevel.update({ status: ApprovalStatus.IN_PROGRESS, levelStartTime: rejectionNow, tatStartTime: rejectionNow, @@ -804,33 +866,31 @@ export class DealerClaimApprovalService { tatPercentageUsed: 0 }); - // Update workflow status to IN_PROGRESS (remains active for rework) - // Set currentLevel to previous level + // Update workflow status to PENDING/IN_PROGRESS + // Set currentLevel to target level await WorkflowRequest.update( { status: WorkflowStatus.PENDING, - currentLevel: previousLevel.levelNumber + currentLevel: targetLevel.levelNumber }, { where: { requestId: level.requestId } } ); - - - // Log rejection activity (returned to previous step) + // Log rejection activity (returned to step) activityService.log({ requestId: level.requestId, type: 'rejection', user: { userId: level.approverId, name: level.approverName }, timestamp: rejectionNow.toISOString(), - action: 'Returned to Previous Step', - details: `Request rejected by ${level.approverName || level.approverEmail} and returned to level ${previousLevel.levelNumber}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`, + action: isReQuotation ? 'Re-quotation Requested' : 'Returned to Previous Step', + details: `Request rejected by ${level.approverName || level.approverEmail} and returned to level ${targetLevel.levelNumber}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`, ipAddress: requestMetadata?.ipAddress || undefined, userAgent: requestMetadata?.userAgent || undefined }); - // Notify the approver of the previous level - if (previousLevel.approverId) { - await notificationService.sendToUsers([previousLevel.approverId], { + // Notify the approver of the target level + if (targetLevel.approverId) { + await notificationService.sendToUsers([targetLevel.approverId], { title: `Request Returned: ${(wf as any).requestNumber}`, body: `Request "${(wf as any).title}" has been returned to your level for revision. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`, requestNumber: (wf as any).requestNumber, @@ -842,16 +902,15 @@ export class DealerClaimApprovalService { }); } - // Notify initiator when request is returned (not closed) + // Notify initiator when request is returned await notificationService.sendToUsers([(wf as any).initiatorId], { title: `Request Returned: ${(wf as any).requestNumber}`, - body: `Request "${(wf as any).title}" has been returned to level ${previousLevel.levelNumber} for revision. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`, + body: `Request "${(wf as any).title}" has been returned to level ${targetLevel.levelNumber} for revision. 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', - actionRequired: true + priority: 'MEDIUM' }); } diff --git a/src/services/dealerExternal.service.ts b/src/services/dealerExternal.service.ts new file mode 100644 index 0000000..31d4eae --- /dev/null +++ b/src/services/dealerExternal.service.ts @@ -0,0 +1,93 @@ +import axios from 'axios'; +import logger from '../utils/logger'; + +export interface ExternalDealerResponse { + dealer: string; + 'dealer name': string; + 'item group': string | null; + 'proprietor/Driector Names': string | null; + gstin: string | null; + pan: string | null; + 'irn eligibility': string; + 'region id': string | null; + 're state code': string | null; + 're city': string | null; + 'store address': string | null; + pincode: string | null; + 'dealer email': string | null; + 'dealer phone': string | null; +} + +export class DealerExternalService { + private apiUrl: string; + private authHeader: string; + private authToken: string; + + constructor() { + this.apiUrl = process.env.RE_DEALER_API_URL || 'https://api-uat2.royalenfield.com/DealerMaster'; + this.authHeader = process.env.RE_DEALER_API_AUTH || 'q7WlK9ZpT2JfV8mXc4sB0nYdH6R3aQeU1CjGxL5uPzI='; + this.authToken = process.env.RE_DEALER_API_TOKEN || 're_c92b9cf291d2be65a1704207aa25352d69432b643e6c9e9a172938c964809f2d'; + } + + /** + * Fetch dealer information from external Royal Enfield API by dealer code + * @param dealerCode The code of the dealer to search for + */ + async getDealerByCode(dealerCode: string): Promise { + try { + logger.info(`[DealerExternalService] Fetching dealer info for code: ${dealerCode}`); + + const response = await axios.get(this.apiUrl, { + params: { dealer_code: dealerCode }, + headers: { + 'auth': this.authHeader, + 'Authorization': `Bearer ${this.authToken}` + } + }); + + if (!response.data) { + logger.warn(`[DealerExternalService] No data returned for dealer code: ${dealerCode}`); + return null; + } + + // The API returns an object or array? Based on user request, it looks like a single object + // but usually these APIs return arrays. Let's handle both just in case. + const data = Array.isArray(response.data) ? response.data[0] : response.data; + + if (!data || Object.keys(data).length === 0) { + logger.warn(`[DealerExternalService] Empty dealer data for code: ${dealerCode}`); + return null; + } + + // Map the response to the requested format if it's different, + // but user's response example seems to be the literal API response structure. + // We'll return it as is but ensure types match. + return { + dealer: data.dealer || dealerCode, + 'dealer name': data['dealer name'] || '', + 'item group': data['item group'] || null, + 'proprietor/Driector Names': data['proprietor/Driector Names'] || null, + gstin: data.gstin || null, + pan: data.pan || null, + 'irn eligibility': data['irn eligibility'] || 'no', + 'region id': data['region id'] || null, + 're state code': data['re state code'] || null, + 're city': data['re city'] || null, + 'store address': data['store address'] || null, + pincode: data.pincode || null, + 'dealer email': data['dealer email'] || null, + 'dealer phone': data['dealer phone'] || null + }; + + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error(`[DealerExternalService] API Error: ${error.response?.status} - ${JSON.stringify(error.response?.data)}`); + } else { + logger.error(`[DealerExternalService] Unexpected Error: ${error instanceof Error ? error.message : 'Unknown'}`); + } + throw error; + } + } +} + +export const dealerExternalService = new DealerExternalService(); diff --git a/src/services/sapIntegration.service.ts b/src/services/sapIntegration.service.ts index fb7764c..897e4c4 100644 --- a/src/services/sapIntegration.service.ts +++ b/src/services/sapIntegration.service.ts @@ -1096,14 +1096,16 @@ export class SAPIntegrationService { /** * Release blocked budget in SAP * @param ioNumber - IO number - * @param blockId - Block ID from previous block operation + * @param amount - Amount to release * @param requestNumber - Request number for reference + * @param blockId - Block ID from previous block operation (optional) * @returns Release confirmation */ async releaseBudget( ioNumber: string, - blockId: string, - requestNumber: string + amount: number, + requestNumber: string, + blockId?: string ): Promise<{ success: boolean; releasedAmount: number; diff --git a/src/services/userEnrichment.service.ts b/src/services/userEnrichment.service.ts index c790266..6029057 100644 --- a/src/services/userEnrichment.service.ts +++ b/src/services/userEnrichment.service.ts @@ -73,7 +73,7 @@ export async function enrichApprovalLevels( try { // Find or create user from AD let user = await User.findOne({ where: { email } }); - + if (!user) { logger.info(`[UserEnrichment] User not found in DB, attempting to sync from AD: ${email}`); // Try to fetch and create user from AD @@ -103,8 +103,8 @@ export async function enrichApprovalLevels( } // Auto-detect final approver (last level) - const isFinalApprover = level.isFinalApprover !== undefined - ? level.isFinalApprover + const isFinalApprover = level.isFinalApprover !== undefined + ? level.isFinalApprover : (i === approvalLevels.length - 1); enriched.push({ @@ -154,7 +154,7 @@ export async function enrichSpectators( try { // Find or create user from AD let user = await User.findOne({ where: { email } }); - + if (!user) { logger.info(`[UserEnrichment] User not found in DB, attempting to sync from AD: ${email}`); try { @@ -197,7 +197,7 @@ export async function enrichSpectators( */ export async function validateInitiator(initiatorId: string): Promise { const user = await User.findByPk(initiatorId); - + if (!user) { throw new Error(`Invalid initiator: User with ID '${initiatorId}' not found. Please ensure you are logged in with a valid account.`); } @@ -205,3 +205,28 @@ export async function validateInitiator(initiatorId: string): Promise { return user; } +/** + * Validate dealer user exists with jobTitle 'Dealer' and employeeId matching dealerCode + * @param dealerCode - The dealer code to validate + * @returns User object if valid + * @throws Error if dealer validation fails + */ +export async function validateDealerUser(dealerCode: string): Promise { + logger.info(`[UserEnrichment] Validating dealer user for code: ${dealerCode}`); + + const user = await User.findOne({ + where: { + employeeNumber: dealerCode, + jobTitle: 'Dealer', + isActive: true + } + }); + + if (!user) { + logger.warn(`[UserEnrichment] Dealer validation failed: No active user found with 'Dealer' job title and employee ID '${dealerCode}'`); + throw new Error(`Dealer validation failed: No active user found with 'Dealer' job title and employee ID '${dealerCode}'. If this user exists, please ensure their job title is set to 'Dealer' and their employee ID matches the dealer code.`); + } + + logger.info(`[UserEnrichment] Dealer validation successful for ${dealerCode}: ${user.displayName || user.email}`); + return user; +} diff --git a/src/types/auth.types.ts b/src/types/auth.types.ts index 52e8991..cd41422 100644 --- a/src/types/auth.types.ts +++ b/src/types/auth.types.ts @@ -2,6 +2,8 @@ export interface SSOUserData { oktaSub: string; // Required - Okta subject identifier email: string; // Required - Primary identifier for user lookup employeeId?: string; // Optional - HR System Employee ID + dealerCode?: string; // Optional - Dealer code from SSO (e.g., from Tanflow) + employeeNumber?: string; // Optional - Employee number from SSO firstName?: string; lastName?: string; displayName?: string; diff --git a/src/validators/workflow.validator.ts b/src/validators/workflow.validator.ts index 44a5472..c5f0095 100644 --- a/src/validators/workflow.validator.ts +++ b/src/validators/workflow.validator.ts @@ -29,7 +29,7 @@ const simplifiedSpectatorSchema = z.object({ }); export const createWorkflowSchema = z.object({ - templateType: z.enum(['CUSTOM', 'TEMPLATE']), + templateType: z.enum(['CUSTOM', 'TEMPLATE', 'DEALER CLAIM']), title: z.string().min(1, 'Title is required').max(500, 'Title too long'), description: z.string().min(1, 'Description is required'), priority: z.enum(['STANDARD', 'EXPRESS'] as const), diff --git a/test_integ_out.txt b/test_integ_out.txt new file mode 100644 index 0000000..db04efd Binary files /dev/null and b/test_integ_out.txt differ