dealer from external source implemented and re-iteration and multiple io block implemented need to test end to end

This commit is contained in:
laxmanhalaki 2026-03-02 21:34:59 +05:30
parent 4c745297d4
commit 5be1e319b0
21 changed files with 685 additions and 311 deletions

View File

@ -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.

View File

@ -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<void> {
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();

View File

@ -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 || [];

View File

@ -0,0 +1,57 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
// 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<void> {
// 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);
}
}

View File

@ -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<ClaimBudgetTrackingAttributes, 'budgetId' | 'initialEstimatedBudget' | 'proposalEstimatedBudget' | 'proposalSubmittedAt' | 'approvedBudget' | 'approvedAt' | 'approvedBy' | 'ioBlockedAmount' | 'ioBlockedAt' | 'closedExpenses' | 'closedExpensesSubmittedAt' | 'finalClaimAmount' | 'finalClaimAmountApprovedAt' | 'finalClaimAmountApprovedBy' | 'creditNoteAmount' | 'creditNoteIssuedAt' | 'varianceAmount' | 'variancePercentage' | 'lastModifiedBy' | 'lastModifiedAt' | 'modificationReason' | 'budgetStatus' | 'currency' | 'createdAt' | 'updatedAt'> {}
interface ClaimBudgetTrackingCreationAttributes extends Optional<ClaimBudgetTrackingAttributes, 'budgetId' | 'initialEstimatedBudget' | 'proposalEstimatedBudget' | 'proposalSubmittedAt' | 'approvedBudget' | 'approvedAt' | 'approvedBy' | 'ioBlockedAmount' | 'ioBlockedAt' | 'closedExpenses' | 'taxableClosedExpenses' | 'closedExpensesSubmittedAt' | 'finalClaimAmount' | 'finalClaimAmountApprovedAt' | 'finalClaimAmountApprovedBy' | 'creditNoteAmount' | 'creditNoteIssuedAt' | 'varianceAmount' | 'variancePercentage' | 'lastModifiedBy' | 'lastModifiedAt' | 'modificationReason' | 'budgetStatus' | 'currency' | 'createdAt' | 'updatedAt'> { }
class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudgetTrackingCreationAttributes> implements ClaimBudgetTrackingAttributes {
public budgetId!: string;
@ -64,6 +65,7 @@ class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudg
public ioBlockedAmount?: number;
public ioBlockedAt?: Date;
public closedExpenses?: number;
public taxableClosedExpenses?: number;
public closedExpensesSubmittedAt?: Date;
public finalClaimAmount?: number;
public finalClaimAmountApprovedAt?: Date;
@ -159,6 +161,11 @@ ClaimBudgetTracking.init(
allowNull: true,
field: 'closed_expenses_submitted_at'
},
taxableClosedExpenses: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'taxable_closed_expenses'
},
finalClaimAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,

View File

@ -16,11 +16,12 @@ interface DealerClaimDetailsAttributes {
location?: string;
periodStartDate?: Date;
periodEndDate?: Date;
totalProposedTaxableAmount?: number;
createdAt: Date;
updatedAt: Date;
}
interface DealerClaimDetailsCreationAttributes extends Optional<DealerClaimDetailsAttributes, 'claimId' | 'dealerEmail' | 'dealerPhone' | 'dealerAddress' | 'activityDate' | 'location' | 'periodStartDate' | 'periodEndDate' | 'createdAt' | 'updatedAt'> { }
interface DealerClaimDetailsCreationAttributes extends Optional<DealerClaimDetailsAttributes, 'claimId' | 'dealerEmail' | 'dealerPhone' | 'dealerAddress' | 'activityDate' | 'location' | 'periodStartDate' | 'periodEndDate' | 'totalProposedTaxableAmount' | 'createdAt' | 'updatedAt'> { }
class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaimDetailsCreationAttributes> implements DealerClaimDetailsAttributes {
public claimId!: string;
@ -36,6 +37,7 @@ class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaim
public location?: string;
public periodStartDate?: Date;
public periodEndDate?: Date;
public totalProposedTaxableAmount?: number;
public createdAt!: Date;
public updatedAt!: Date;
@ -115,6 +117,12 @@ DealerClaimDetails.init(
allowNull: true,
field: 'period_end_date'
},
totalProposedTaxableAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'total_proposed_taxable_amount',
comment: 'Total taxable amount from proposal or actuals if higher'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,

View File

@ -26,7 +26,7 @@ interface InternalOrderAttributes {
updatedAt: Date;
}
interface InternalOrderCreationAttributes extends Optional<InternalOrderAttributes, 'ioId' | 'ioRemark' | 'ioAvailableBalance' | 'ioBlockedAmount' | 'ioRemainingBalance' | 'organizedBy' | 'organizedAt' | 'sapDocumentNumber' | 'status' | 'createdAt' | 'updatedAt'> {}
interface InternalOrderCreationAttributes extends Optional<InternalOrderAttributes, 'ioId' | 'ioRemark' | 'ioAvailableBalance' | 'ioBlockedAmount' | 'ioRemainingBalance' | 'organizedBy' | 'organizedAt' | 'sapDocumentNumber' | 'status' | 'createdAt' | 'updatedAt'> { }
class InternalOrder extends Model<InternalOrderAttributes, InternalOrderCreationAttributes> implements InternalOrderAttributes {
public ioId!: string;
@ -137,7 +137,7 @@ InternalOrder.init(
indexes: [
{
fields: ['request_id'],
unique: true
unique: false
},
{
fields: ['io_number']

View File

@ -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;

View File

@ -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;

View File

@ -161,6 +161,7 @@ async function runMigrations(): Promise<void> {
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<void> {
{ 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

View File

@ -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 }
];
/**

View File

@ -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
@ -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
}
@ -322,8 +329,9 @@ 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()

View File

@ -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<DealerInfo | null> {
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<DealerInfo |
});
if (!dealer) {
if (externalData) {
logger.info(`[DealerService] Dealer found ONLY in External API: ${dealerCode}`);
return {
dealerId: dealerCode,
userId: null,
email: externalData['dealer email'] || '',
dealerCode: dealerCode,
dealerName: externalData['dealer name'],
displayName: externalData['dealer name'],
phone: externalData['dealer phone'] || undefined,
isLoggedIn: false,
gstin: externalData.gstin,
city: externalData['re city'],
state: externalData['re state code'],
pincode: externalData.pincode,
};
}
logger.warn(`[DealerService] Dealer not found in any source: ${dealerCode}`);
return null;
}
// Check if dealer is logged in (domain_id exists in users table)
let user = null;
if (dealer.domainId) {
user = await User.findOne({
where: {
email: dealer.domainId.toLowerCase(),
isActive: true,
},
attributes: ['userId', 'email', 'displayName', 'phone', 'department', 'designation'],
});
}
const isLoggedIn = !!user;
logger.info(`[DealerService] Dealer found in local Dealer table: ${dealerCode}`);
return {
dealerId: dealer.dealerId,
userId: user?.userId || null,
email: dealer.domainId || '',
dealerCode: dealer.dlrcode || '',
dealerName: dealer.dealership || dealer.dealerPrincipalName || '',
userId: null,
email: dealer.domainId || externalData?.['dealer email'] || '',
dealerCode: dealer.dlrcode || dealerCode,
dealerName: externalData?.['dealer name'] || dealer.dealership || dealer.dealerPrincipalName || '',
displayName: dealer.dealerPrincipalName || dealer.dealership || '',
phone: dealer.dpContactNumber || user?.phone || undefined,
department: user?.department || undefined,
designation: user?.designation || undefined,
isLoggedIn,
phone: externalData?.['dealer phone'] || dealer.dpContactNumber || undefined,
isLoggedIn: false,
salesCode: dealer.salesCode || null,
serviceCode: dealer.serviceCode || null,
gearCode: dealer.gearCode || null,
gmaCode: dealer.gmaCode || null,
region: dealer.region || null,
state: dealer.state || null,
state: externalData?.['re state code'] || dealer.state || null,
district: dealer.district || null,
city: dealer.city || null,
city: externalData?.['re city'] || dealer.city || null,
dealerPrincipalName: dealer.dealerPrincipalName || null,
dealerPrincipalEmailId: dealer.dealerPrincipalEmailId || null,
gstin: dealer.gst || null,
pincode: dealer.showroomPincode || null,
gstin: externalData?.gstin || dealer.gst || null,
pincode: externalData?.pincode || dealer.showroomPincode || null,
};
} catch (error) {
logger.error('[DealerService] Error fetching dealer by code:', error);

View File

@ -29,6 +29,7 @@ import { notificationService } from './notification.service';
import { activityService } from './activity.service';
import { UserService } from './user.service';
import { dmsIntegrationService } from './dmsIntegration.service';
import { validateDealerUser } from './userEnrichment.service';
// findDealerLocally removed (duplicate)
@ -98,6 +99,15 @@ export class DealerClaimService {
}
): Promise<WorkflowRequest> {
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,

View File

@ -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'
});
}

View File

@ -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<ExternalDealerResponse | null> {
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();

View File

@ -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;

View File

@ -205,3 +205,28 @@ export async function validateInitiator(initiatorId: string): Promise<any> {
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<User> {
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;
}

View File

@ -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;

View File

@ -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),

BIN
test_integ_out.txt Normal file

Binary file not shown.