dealer from external source implemented and re-iteration and multiple io block implemented need to test end to end
This commit is contained in:
parent
4c745297d4
commit
5be1e319b0
29
docs/DEALER_INTEGRATION_STATUS.md
Normal file
29
docs/DEALER_INTEGRATION_STATUS.md
Normal 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.
|
||||||
34
src/controllers/dealerExternal.controller.ts
Normal file
34
src/controllers/dealerExternal.controller.ts
Normal 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();
|
||||||
@ -12,7 +12,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { getRequestMetadata } from '@utils/requestUtils';
|
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 { DealerClaimService } from '@services/dealerClaim.service';
|
||||||
import logger from '@utils/logger';
|
import logger from '@utils/logger';
|
||||||
|
|
||||||
@ -27,6 +27,15 @@ export class WorkflowController {
|
|||||||
// Validate initiator exists
|
// Validate initiator exists
|
||||||
await validateInitiator(req.user.userId);
|
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
|
// Handle frontend format: map 'approvers' -> 'approvalLevels' for backward compatibility
|
||||||
let approvalLevels = validatedData.approvalLevels || [];
|
let approvalLevels = validatedData.approvalLevels || [];
|
||||||
if (!approvalLevels.length && (req.body as any).approvers) {
|
if (!approvalLevels.length && (req.body as any).approvers) {
|
||||||
@ -170,6 +179,15 @@ export class WorkflowController {
|
|||||||
// Validate initiator exists
|
// Validate initiator exists
|
||||||
await validateInitiator(userId);
|
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)
|
// Use the approval levels from validation (already transformed above)
|
||||||
let approvalLevels = validated.approvalLevels || [];
|
let approvalLevels = validated.approvalLevels || [];
|
||||||
|
|
||||||
|
|||||||
57
src/migrations/20260302-refine-dealer-claim-schema.ts
Normal file
57
src/migrations/20260302-refine-dealer-claim-schema.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -29,6 +29,7 @@ interface ClaimBudgetTrackingAttributes {
|
|||||||
ioBlockedAt?: Date;
|
ioBlockedAt?: Date;
|
||||||
// Closed Expenses
|
// Closed Expenses
|
||||||
closedExpenses?: number;
|
closedExpenses?: number;
|
||||||
|
taxableClosedExpenses?: number;
|
||||||
closedExpensesSubmittedAt?: Date;
|
closedExpensesSubmittedAt?: Date;
|
||||||
// Final Claim Amount
|
// Final Claim Amount
|
||||||
finalClaimAmount?: number;
|
finalClaimAmount?: number;
|
||||||
@ -50,7 +51,7 @@ interface ClaimBudgetTrackingAttributes {
|
|||||||
updatedAt: Date;
|
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 {
|
class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudgetTrackingCreationAttributes> implements ClaimBudgetTrackingAttributes {
|
||||||
public budgetId!: string;
|
public budgetId!: string;
|
||||||
@ -64,6 +65,7 @@ class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudg
|
|||||||
public ioBlockedAmount?: number;
|
public ioBlockedAmount?: number;
|
||||||
public ioBlockedAt?: Date;
|
public ioBlockedAt?: Date;
|
||||||
public closedExpenses?: number;
|
public closedExpenses?: number;
|
||||||
|
public taxableClosedExpenses?: number;
|
||||||
public closedExpensesSubmittedAt?: Date;
|
public closedExpensesSubmittedAt?: Date;
|
||||||
public finalClaimAmount?: number;
|
public finalClaimAmount?: number;
|
||||||
public finalClaimAmountApprovedAt?: Date;
|
public finalClaimAmountApprovedAt?: Date;
|
||||||
@ -159,6 +161,11 @@ ClaimBudgetTracking.init(
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
field: 'closed_expenses_submitted_at'
|
field: 'closed_expenses_submitted_at'
|
||||||
},
|
},
|
||||||
|
taxableClosedExpenses: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'taxable_closed_expenses'
|
||||||
|
},
|
||||||
finalClaimAmount: {
|
finalClaimAmount: {
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
|
|||||||
@ -16,11 +16,12 @@ interface DealerClaimDetailsAttributes {
|
|||||||
location?: string;
|
location?: string;
|
||||||
periodStartDate?: Date;
|
periodStartDate?: Date;
|
||||||
periodEndDate?: Date;
|
periodEndDate?: Date;
|
||||||
|
totalProposedTaxableAmount?: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: 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 {
|
class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaimDetailsCreationAttributes> implements DealerClaimDetailsAttributes {
|
||||||
public claimId!: string;
|
public claimId!: string;
|
||||||
@ -36,6 +37,7 @@ class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaim
|
|||||||
public location?: string;
|
public location?: string;
|
||||||
public periodStartDate?: Date;
|
public periodStartDate?: Date;
|
||||||
public periodEndDate?: Date;
|
public periodEndDate?: Date;
|
||||||
|
public totalProposedTaxableAmount?: number;
|
||||||
public createdAt!: Date;
|
public createdAt!: Date;
|
||||||
public updatedAt!: Date;
|
public updatedAt!: Date;
|
||||||
|
|
||||||
@ -115,6 +117,12 @@ DealerClaimDetails.init(
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
field: 'period_end_date'
|
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: {
|
createdAt: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@ -26,7 +26,7 @@ interface InternalOrderAttributes {
|
|||||||
updatedAt: Date;
|
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 {
|
class InternalOrder extends Model<InternalOrderAttributes, InternalOrderCreationAttributes> implements InternalOrderAttributes {
|
||||||
public ioId!: string;
|
public ioId!: string;
|
||||||
@ -137,7 +137,7 @@ InternalOrder.init(
|
|||||||
indexes: [
|
indexes: [
|
||||||
{
|
{
|
||||||
fields: ['request_id'],
|
fields: ['request_id'],
|
||||||
unique: true
|
unique: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fields: ['io_number']
|
fields: ['io_number']
|
||||||
|
|||||||
14
src/routes/dealerExternal.routes.ts
Normal file
14
src/routes/dealerExternal.routes.ts
Normal 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;
|
||||||
@ -19,6 +19,7 @@ import dealerRoutes from './dealer.routes';
|
|||||||
import dmsWebhookRoutes from './dmsWebhook.routes';
|
import dmsWebhookRoutes from './dmsWebhook.routes';
|
||||||
import apiTokenRoutes from './apiToken.routes';
|
import apiTokenRoutes from './apiToken.routes';
|
||||||
import antivirusRoutes from './antivirus.routes';
|
import antivirusRoutes from './antivirus.routes';
|
||||||
|
import dealerExternalRoutes from './dealerExternal.routes';
|
||||||
import { authenticateToken } from '../middlewares/auth.middleware';
|
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||||
import { requireAdmin } from '../middlewares/authorization.middleware';
|
import { requireAdmin } from '../middlewares/authorization.middleware';
|
||||||
import {
|
import {
|
||||||
@ -71,6 +72,7 @@ router.use('/conclusions', generalApiLimiter, conclusionRoutes); // 200 r
|
|||||||
router.use('/summaries', generalApiLimiter, summaryRoutes); // 200 req/15min
|
router.use('/summaries', generalApiLimiter, summaryRoutes); // 200 req/15min
|
||||||
router.use('/templates', generalApiLimiter, templateRoutes); // 200 req/15min
|
router.use('/templates', generalApiLimiter, templateRoutes); // 200 req/15min
|
||||||
router.use('/dealers', generalApiLimiter, dealerRoutes); // 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)
|
router.use('/api-tokens', authLimiter, apiTokenRoutes); // 20 req/15min (sensitive — same as auth)
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -161,6 +161,7 @@ async function runMigrations(): Promise<void> {
|
|||||||
const m46 = require('../migrations/20260210-add-raw-pwc-responses');
|
const m46 = require('../migrations/20260210-add-raw-pwc-responses');
|
||||||
const m47 = require('../migrations/20260216-create-api-tokens');
|
const m47 = require('../migrations/20260216-create-api-tokens');
|
||||||
const m48 = require('../migrations/20260216-add-qty-hsn-to-expenses'); // Added new migration
|
const m48 = require('../migrations/20260216-add-qty-hsn-to-expenses'); // Added new migration
|
||||||
|
const m49 = require('../migrations/20260302-refine-dealer-claim-schema');
|
||||||
|
|
||||||
const migrations = [
|
const migrations = [
|
||||||
{ name: '2025103000-create-users', module: m0 },
|
{ name: '2025103000-create-users', module: m0 },
|
||||||
@ -214,6 +215,7 @@ async function runMigrations(): Promise<void> {
|
|||||||
{ name: '20260210-add-raw-pwc-responses', module: m46 },
|
{ name: '20260210-add-raw-pwc-responses', module: m46 },
|
||||||
{ name: '20260216-create-api-tokens', module: m47 },
|
{ name: '20260216-create-api-tokens', module: m47 },
|
||||||
{ name: '20260216-add-qty-hsn-to-expenses', module: m48 }, // Added to array
|
{ 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
|
// Dynamically import sequelize after secrets are loaded
|
||||||
|
|||||||
@ -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 m46 from '../migrations/20260216-add-qty-hsn-to-expenses';
|
||||||
import * as m47 from '../migrations/20260217-add-is-service-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 m48 from '../migrations/20260217-create-claim-invoice-items';
|
||||||
|
import * as m49 from '../migrations/20260302-refine-dealer-claim-schema';
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@ -63,7 +64,8 @@ const migrations: Migration[] = [
|
|||||||
// ... existing migrations ...
|
// ... existing migrations ...
|
||||||
{ name: '20260216-add-qty-hsn-to-expenses', module: m46 },
|
{ name: '20260216-add-qty-hsn-to-expenses', module: m46 },
|
||||||
{ name: '20260217-add-is-service-to-expenses', module: m47 },
|
{ 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 }
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -22,11 +22,11 @@ export class AuthService {
|
|||||||
// Try to fetch from Users API using email first (as shown in curl example)
|
// Try to fetch from Users API using email first (as shown in curl example)
|
||||||
// If email lookup fails, try with oktaSub (user ID)
|
// If email lookup fails, try with oktaSub (user ID)
|
||||||
let usersApiResponse: any = null;
|
let usersApiResponse: any = null;
|
||||||
|
|
||||||
// First attempt: Use email (preferred method as shown in curl example)
|
// First attempt: Use email (preferred method as shown in curl example)
|
||||||
if (email) {
|
if (email) {
|
||||||
const usersApiEndpoint = `${ssoConfig.oktaDomain}/api/v1/users/${encodeURIComponent(email)}`;
|
const usersApiEndpoint = `${ssoConfig.oktaDomain}/api/v1/users/${encodeURIComponent(email)}`;
|
||||||
|
|
||||||
logger.info('Fetching user from Okta Users API (using email)', {
|
logger.info('Fetching user from Okta Users API (using email)', {
|
||||||
endpoint: usersApiEndpoint.replace(email, email.substring(0, 5) + '...'),
|
endpoint: usersApiEndpoint.replace(email, email.substring(0, 5) + '...'),
|
||||||
hasApiToken: !!ssoConfig.oktaApiToken,
|
hasApiToken: !!ssoConfig.oktaApiToken,
|
||||||
@ -59,7 +59,7 @@ export class AuthService {
|
|||||||
// Second attempt: Use oktaSub (user ID) if email lookup failed
|
// Second attempt: Use oktaSub (user ID) if email lookup failed
|
||||||
if (oktaSub) {
|
if (oktaSub) {
|
||||||
const usersApiEndpoint = `${ssoConfig.oktaDomain}/api/v1/users/${encodeURIComponent(oktaSub)}`;
|
const usersApiEndpoint = `${ssoConfig.oktaDomain}/api/v1/users/${encodeURIComponent(oktaSub)}`;
|
||||||
|
|
||||||
logger.info('Fetching user from Okta Users API (using oktaSub)', {
|
logger.info('Fetching user from Okta Users API (using oktaSub)', {
|
||||||
endpoint: usersApiEndpoint.replace(oktaSub, oktaSub.substring(0, 10) + '...'),
|
endpoint: usersApiEndpoint.replace(oktaSub, oktaSub.substring(0, 10) + '...'),
|
||||||
hasApiToken: !!ssoConfig.oktaApiToken,
|
hasApiToken: !!ssoConfig.oktaApiToken,
|
||||||
@ -110,7 +110,7 @@ export class AuthService {
|
|||||||
private extractUserDataFromUsersAPI(oktaUserResponse: any, oktaSub: string): SSOUserData | null {
|
private extractUserDataFromUsersAPI(oktaUserResponse: any, oktaSub: string): SSOUserData | null {
|
||||||
try {
|
try {
|
||||||
const profile = oktaUserResponse.profile || {};
|
const profile = oktaUserResponse.profile || {};
|
||||||
|
|
||||||
const userData: SSOUserData = {
|
const userData: SSOUserData = {
|
||||||
oktaSub: oktaSub || oktaUserResponse.id || '',
|
oktaSub: oktaSub || oktaUserResponse.id || '',
|
||||||
email: profile.email || profile.login || '',
|
email: profile.email || profile.login || '',
|
||||||
@ -127,6 +127,8 @@ export class AuthService {
|
|||||||
mobilePhone: profile.mobilePhone || undefined,
|
mobilePhone: profile.mobilePhone || undefined,
|
||||||
secondEmail: profile.secondEmail || profile.second_email || undefined,
|
secondEmail: profile.secondEmail || profile.second_email || undefined,
|
||||||
adGroups: Array.isArray(profile.memberOf) ? profile.memberOf : 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
|
// Validate required fields
|
||||||
@ -170,10 +172,10 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract employeeId (optional)
|
// Extract employeeId (optional)
|
||||||
const employeeId =
|
const employeeId =
|
||||||
oktaUser.employeeId ||
|
oktaUser.employeeId ||
|
||||||
oktaUser.employee_id ||
|
oktaUser.employee_id ||
|
||||||
oktaUser.empId ||
|
oktaUser.empId ||
|
||||||
oktaUser.employeeNumber ||
|
oktaUser.employeeNumber ||
|
||||||
undefined;
|
undefined;
|
||||||
|
|
||||||
@ -181,6 +183,8 @@ export class AuthService {
|
|||||||
oktaSub: sub,
|
oktaSub: sub,
|
||||||
email: oktaUser.email || '',
|
email: oktaUser.email || '',
|
||||||
employeeId: employeeId,
|
employeeId: employeeId,
|
||||||
|
dealerCode: oktaUser.dealer_code || undefined,
|
||||||
|
employeeNumber: oktaUser.dealer_code || oktaUser.employeeNumber || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate: Ensure we're not accidentally using oktaSub as employeeId
|
// 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.jobTitle) userUpdateData.jobTitle = userData.jobTitle; // Job title from SSO
|
||||||
if (userData.postalAddress) userUpdateData.postalAddress = userData.postalAddress; // Address 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.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) {
|
if (userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0) {
|
||||||
userUpdateData.adGroups = userData.adGroups; // Group memberships from SSO
|
userUpdateData.adGroups = userData.adGroups; // Group memberships from SSO
|
||||||
}
|
}
|
||||||
@ -301,7 +308,7 @@ export class AuthService {
|
|||||||
await user.update(userUpdateData);
|
await user.update(userUpdateData);
|
||||||
// Reload to get updated data
|
// Reload to get updated data
|
||||||
user = await user.reload();
|
user = await user.reload();
|
||||||
|
|
||||||
logAuthEvent('sso_callback', user.userId, {
|
logAuthEvent('sso_callback', user.userId, {
|
||||||
email: userData.email,
|
email: userData.email,
|
||||||
action: 'user_updated',
|
action: 'user_updated',
|
||||||
@ -322,13 +329,14 @@ export class AuthService {
|
|||||||
manager: userData.manager || null, // Manager name from SSO
|
manager: userData.manager || null, // Manager name from SSO
|
||||||
jobTitle: userData.jobTitle || null, // Job title from SSO
|
jobTitle: userData.jobTitle || null, // Job title from SSO
|
||||||
postalAddress: userData.postalAddress || null, // Address from SSO
|
postalAddress: userData.postalAddress || null, // Address from SSO
|
||||||
mobilePhone: userData.mobilePhone || null, // Mobile phone from SSO
|
mobilePhone: userData.mobilePhone || null,
|
||||||
adGroups: userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0 ? userData.adGroups : null, // Groups from SSO
|
adGroups: userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0 ? userData.adGroups : null,
|
||||||
|
employeeNumber: userData.employeeNumber || userData.dealerCode || null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
role: 'USER',
|
role: 'USER',
|
||||||
lastLogin: new Date()
|
lastLogin: new Date()
|
||||||
});
|
});
|
||||||
|
|
||||||
logAuthEvent('sso_callback', user.userId, {
|
logAuthEvent('sso_callback', user.userId, {
|
||||||
email: userData.email,
|
email: userData.email,
|
||||||
action: 'user_created',
|
action: 'user_created',
|
||||||
@ -428,7 +436,7 @@ export class AuthService {
|
|||||||
async refreshAccessToken(refreshToken: string): Promise<string> {
|
async refreshAccessToken(refreshToken: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(refreshToken, ssoConfig.jwtSecret) as any;
|
const decoded = jwt.verify(refreshToken, ssoConfig.jwtSecret) as any;
|
||||||
|
|
||||||
if (decoded.type !== 'refresh') {
|
if (decoded.type !== 'refresh') {
|
||||||
throw new Error('Invalid refresh token');
|
throw new Error('Invalid refresh token');
|
||||||
}
|
}
|
||||||
@ -495,7 +503,7 @@ export class AuthService {
|
|||||||
// Step 1: Authenticate with Okta using Resource Owner Password flow
|
// Step 1: Authenticate with Okta using Resource Owner Password flow
|
||||||
// Note: This requires Okta to have Resource Owner Password grant type enabled
|
// Note: This requires Okta to have Resource Owner Password grant type enabled
|
||||||
const tokenEndpoint = `${ssoConfig.oktaDomain}/oauth2/default/v1/token`;
|
const tokenEndpoint = `${ssoConfig.oktaDomain}/oauth2/default/v1/token`;
|
||||||
|
|
||||||
const tokenResponse = await axios.post(
|
const tokenResponse = await axios.post(
|
||||||
tokenEndpoint,
|
tokenEndpoint,
|
||||||
new URLSearchParams({
|
new URLSearchParams({
|
||||||
@ -521,7 +529,7 @@ export class AuthService {
|
|||||||
status: tokenResponse.status,
|
status: tokenResponse.status,
|
||||||
data: tokenResponse.data,
|
data: tokenResponse.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
const errorData = tokenResponse.data || {};
|
const errorData = tokenResponse.data || {};
|
||||||
const errorMessage = errorData.error_description || errorData.error || 'Invalid username or password';
|
const errorMessage = errorData.error_description || errorData.error || 'Invalid username or password';
|
||||||
throw new Error(`Authentication failed: ${errorMessage}`);
|
throw new Error(`Authentication failed: ${errorMessage}`);
|
||||||
@ -545,7 +553,7 @@ export class AuthService {
|
|||||||
|
|
||||||
const oktaUserInfo = userInfoResponse.data;
|
const oktaUserInfo = userInfoResponse.data;
|
||||||
const oktaSub = oktaUserInfo.sub || '';
|
const oktaSub = oktaUserInfo.sub || '';
|
||||||
|
|
||||||
if (!oktaSub) {
|
if (!oktaSub) {
|
||||||
throw new Error('Okta sub (subject identifier) not found in response');
|
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.)
|
// Step 3: Try Users API first (provides full profile including manager, employeeID, etc.)
|
||||||
let userData: SSOUserData | null = null;
|
let userData: SSOUserData | null = null;
|
||||||
const usersApiResponse = await this.fetchUserFromOktaUsersAPI(oktaSub, oktaUserInfo.email || username, access_token);
|
const usersApiResponse = await this.fetchUserFromOktaUsersAPI(oktaSub, oktaUserInfo.email || username, access_token);
|
||||||
|
|
||||||
if (usersApiResponse) {
|
if (usersApiResponse) {
|
||||||
userData = this.extractUserDataFromUsersAPI(usersApiResponse, oktaSub);
|
userData = this.extractUserDataFromUsersAPI(usersApiResponse, oktaSub);
|
||||||
}
|
}
|
||||||
@ -638,17 +646,17 @@ export class AuthService {
|
|||||||
if (!redirectUri || redirectUri.trim() === '') {
|
if (!redirectUri || redirectUri.trim() === '') {
|
||||||
throw new Error('Redirect URI is required');
|
throw new Error('Redirect URI is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Exchanging code with Okta', {
|
logger.info('Exchanging code with Okta', {
|
||||||
redirectUri,
|
redirectUri,
|
||||||
codePrefix: code.substring(0, 10) + '...',
|
codePrefix: code.substring(0, 10) + '...',
|
||||||
oktaDomain: ssoConfig.oktaDomain,
|
oktaDomain: ssoConfig.oktaDomain,
|
||||||
clientId: ssoConfig.oktaClientId,
|
clientId: ssoConfig.oktaClientId,
|
||||||
hasClientSecret: !!ssoConfig.oktaClientSecret && !ssoConfig.oktaClientSecret.includes('your_okta_client_secret'),
|
hasClientSecret: !!ssoConfig.oktaClientSecret && !ssoConfig.oktaClientSecret.includes('your_okta_client_secret'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const tokenEndpoint = `${ssoConfig.oktaDomain}/oauth2/default/v1/token`;
|
const tokenEndpoint = `${ssoConfig.oktaDomain}/oauth2/default/v1/token`;
|
||||||
|
|
||||||
// Exchange authorization code for tokens
|
// Exchange authorization code for tokens
|
||||||
// redirect_uri here must match the one used when requesting the authorization code
|
// redirect_uri here must match the one used when requesting the authorization code
|
||||||
const tokenResponse = await axios.post(
|
const tokenResponse = await axios.post(
|
||||||
@ -678,7 +686,7 @@ export class AuthService {
|
|||||||
data: tokenResponse.data,
|
data: tokenResponse.data,
|
||||||
headers: tokenResponse.headers,
|
headers: tokenResponse.headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
const errorData = tokenResponse.data || {};
|
const errorData = tokenResponse.data || {};
|
||||||
const errorMessage = errorData.error_description || errorData.error || 'Unknown error from Okta';
|
const errorMessage = errorData.error_description || errorData.error || 'Unknown error from Okta';
|
||||||
throw new Error(`Okta token exchange failed (${tokenResponse.status}): ${errorMessage}`);
|
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;
|
const { access_token, refresh_token, id_token } = tokenResponse.data;
|
||||||
|
|
||||||
if (!access_token) {
|
if (!access_token) {
|
||||||
logger.error('Missing access_token in Okta response', {
|
logger.error('Missing access_token in Okta response', {
|
||||||
responseKeys: Object.keys(tokenResponse.data || {}),
|
responseKeys: Object.keys(tokenResponse.data || {}),
|
||||||
hasRefreshToken: !!refresh_token,
|
hasRefreshToken: !!refresh_token,
|
||||||
hasIdToken: !!id_token,
|
hasIdToken: !!id_token,
|
||||||
});
|
});
|
||||||
throw new Error('Failed to obtain access token from Okta - access_token missing in response');
|
throw new Error('Failed to obtain access token from Okta - access_token missing in response');
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Successfully obtained tokens from Okta', {
|
logger.info('Successfully obtained tokens from Okta', {
|
||||||
hasAccessToken: !!access_token,
|
hasAccessToken: !!access_token,
|
||||||
hasRefreshToken: !!refresh_token,
|
hasRefreshToken: !!refresh_token,
|
||||||
@ -722,7 +730,7 @@ export class AuthService {
|
|||||||
|
|
||||||
const oktaUserInfo = userInfoResponse.data;
|
const oktaUserInfo = userInfoResponse.data;
|
||||||
const oktaSub = oktaUserInfo.sub || '';
|
const oktaSub = oktaUserInfo.sub || '';
|
||||||
|
|
||||||
if (!oktaSub) {
|
if (!oktaSub) {
|
||||||
throw new Error('Okta sub (subject identifier) is required but not found in response');
|
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.)
|
// Try Users API first (provides full profile including manager, employeeID, etc.)
|
||||||
let userData: SSOUserData | null = null;
|
let userData: SSOUserData | null = null;
|
||||||
const usersApiResponse = await this.fetchUserFromOktaUsersAPI(oktaSub, oktaUserInfo.email || '', access_token);
|
const usersApiResponse = await this.fetchUserFromOktaUsersAPI(oktaSub, oktaUserInfo.email || '', access_token);
|
||||||
|
|
||||||
if (usersApiResponse) {
|
if (usersApiResponse) {
|
||||||
userData = this.extractUserDataFromUsersAPI(usersApiResponse, oktaSub);
|
userData = this.extractUserDataFromUsersAPI(usersApiResponse, oktaSub);
|
||||||
}
|
}
|
||||||
@ -777,7 +785,7 @@ export class AuthService {
|
|||||||
oktaError: error.response?.data?.error,
|
oktaError: error.response?.data?.error,
|
||||||
oktaErrorDescription: error.response?.data?.error_description,
|
oktaErrorDescription: error.response?.data?.error_description,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Provide a more user-friendly error message
|
// Provide a more user-friendly error message
|
||||||
if (error.response?.data) {
|
if (error.response?.data) {
|
||||||
const errorData = 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: Unexpected response format. Status: ${error.response.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Okta authentication failed: ${error.message || 'Unknown error'}`);
|
throw new Error(`Okta authentication failed: ${error.message || 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -817,17 +825,17 @@ export class AuthService {
|
|||||||
if (!redirectUri || redirectUri.trim() === '') {
|
if (!redirectUri || redirectUri.trim() === '') {
|
||||||
throw new Error('Redirect URI is required');
|
throw new Error('Redirect URI is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Exchanging code with Tanflow', {
|
logger.info('Exchanging code with Tanflow', {
|
||||||
redirectUri,
|
redirectUri,
|
||||||
codePrefix: code.substring(0, 10) + '...',
|
codePrefix: code.substring(0, 10) + '...',
|
||||||
tanflowBaseUrl: ssoConfig.tanflowBaseUrl,
|
tanflowBaseUrl: ssoConfig.tanflowBaseUrl,
|
||||||
clientId: ssoConfig.tanflowClientId,
|
clientId: ssoConfig.tanflowClientId,
|
||||||
hasClientSecret: !!ssoConfig.tanflowClientSecret,
|
hasClientSecret: !!ssoConfig.tanflowClientSecret,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tokenEndpoint = `${ssoConfig.tanflowBaseUrl}/protocol/openid-connect/token`;
|
const tokenEndpoint = `${ssoConfig.tanflowBaseUrl}/protocol/openid-connect/token`;
|
||||||
|
|
||||||
// Exchange authorization code for tokens
|
// Exchange authorization code for tokens
|
||||||
const tokenResponse = await axios.post(
|
const tokenResponse = await axios.post(
|
||||||
tokenEndpoint,
|
tokenEndpoint,
|
||||||
@ -855,7 +863,7 @@ export class AuthService {
|
|||||||
statusText: tokenResponse.statusText,
|
statusText: tokenResponse.statusText,
|
||||||
data: tokenResponse.data,
|
data: tokenResponse.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
const errorData = tokenResponse.data || {};
|
const errorData = tokenResponse.data || {};
|
||||||
const errorMessage = errorData.error_description || errorData.error || 'Unknown error from Tanflow';
|
const errorMessage = errorData.error_description || errorData.error || 'Unknown error from Tanflow';
|
||||||
throw new Error(`Tanflow token exchange failed (${tokenResponse.status}): ${errorMessage}`);
|
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;
|
const { access_token, refresh_token, id_token } = tokenResponse.data;
|
||||||
|
|
||||||
if (!access_token) {
|
if (!access_token) {
|
||||||
logger.error('Missing access_token in Tanflow response', {
|
logger.error('Missing access_token in Tanflow response', {
|
||||||
responseKeys: Object.keys(tokenResponse.data || {}),
|
responseKeys: Object.keys(tokenResponse.data || {}),
|
||||||
hasRefreshToken: !!refresh_token,
|
hasRefreshToken: !!refresh_token,
|
||||||
hasIdToken: !!id_token,
|
hasIdToken: !!id_token,
|
||||||
});
|
});
|
||||||
throw new Error('Failed to obtain access token from Tanflow - access_token missing in response');
|
throw new Error('Failed to obtain access token from Tanflow - access_token missing in response');
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Successfully obtained tokens from Tanflow', {
|
logger.info('Successfully obtained tokens from Tanflow', {
|
||||||
hasAccessToken: !!access_token,
|
hasAccessToken: !!access_token,
|
||||||
hasRefreshToken: !!refresh_token,
|
hasRefreshToken: !!refresh_token,
|
||||||
@ -897,7 +905,7 @@ export class AuthService {
|
|||||||
|
|
||||||
const tanflowUserInfo = userInfoResponse.data;
|
const tanflowUserInfo = userInfoResponse.data;
|
||||||
const tanflowSub = tanflowUserInfo.sub || '';
|
const tanflowSub = tanflowUserInfo.sub || '';
|
||||||
|
|
||||||
if (!tanflowSub) {
|
if (!tanflowSub) {
|
||||||
throw new Error('Tanflow sub (subject identifier) is required but not found in response');
|
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,
|
tanflowError: error.response?.data?.error,
|
||||||
tanflowErrorDescription: error.response?.data?.error_description,
|
tanflowErrorDescription: error.response?.data?.error_description,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error.response?.data) {
|
if (error.response?.data) {
|
||||||
const errorData = error.response.data;
|
const errorData = error.response.data;
|
||||||
if (typeof errorData === 'object' && !Array.isArray(errorData)) {
|
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: Unexpected response format. Status: ${error.response.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Tanflow authentication failed: ${error.message || 'Unknown error'}`);
|
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 tokenEndpoint = `${ssoConfig.tanflowBaseUrl}/protocol/openid-connect/token`;
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
tokenEndpoint,
|
tokenEndpoint,
|
||||||
new URLSearchParams({
|
new URLSearchParams({
|
||||||
|
|||||||
@ -118,12 +118,54 @@ export async function getAllDealers(searchTerm?: string, limit: number = 10): Pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { dealerExternalService } from './dealerExternal.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get dealer by code (dlrcode)
|
* Get dealer by code (dlrcode / employeeId)
|
||||||
* Checks if dealer is logged in by matching domain_id with users.email
|
* 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> {
|
export async function getDealerByCode(dealerCode: string): Promise<DealerInfo | null> {
|
||||||
try {
|
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({
|
const dealer = await Dealer.findOne({
|
||||||
where: {
|
where: {
|
||||||
dlrcode: dealerCode,
|
dlrcode: dealerCode,
|
||||||
@ -132,46 +174,49 @@ export async function getDealerByCode(dealerCode: string): Promise<DealerInfo |
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!dealer) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if dealer is logged in (domain_id exists in users table)
|
logger.info(`[DealerService] Dealer found in local Dealer table: ${dealerCode}`);
|
||||||
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;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dealerId: dealer.dealerId,
|
dealerId: dealer.dealerId,
|
||||||
userId: user?.userId || null,
|
userId: null,
|
||||||
email: dealer.domainId || '',
|
email: dealer.domainId || externalData?.['dealer email'] || '',
|
||||||
dealerCode: dealer.dlrcode || '',
|
dealerCode: dealer.dlrcode || dealerCode,
|
||||||
dealerName: dealer.dealership || dealer.dealerPrincipalName || '',
|
dealerName: externalData?.['dealer name'] || dealer.dealership || dealer.dealerPrincipalName || '',
|
||||||
displayName: dealer.dealerPrincipalName || dealer.dealership || '',
|
displayName: dealer.dealerPrincipalName || dealer.dealership || '',
|
||||||
phone: dealer.dpContactNumber || user?.phone || undefined,
|
phone: externalData?.['dealer phone'] || dealer.dpContactNumber || undefined,
|
||||||
department: user?.department || undefined,
|
isLoggedIn: false,
|
||||||
designation: user?.designation || undefined,
|
|
||||||
isLoggedIn,
|
|
||||||
salesCode: dealer.salesCode || null,
|
salesCode: dealer.salesCode || null,
|
||||||
serviceCode: dealer.serviceCode || null,
|
serviceCode: dealer.serviceCode || null,
|
||||||
gearCode: dealer.gearCode || null,
|
gearCode: dealer.gearCode || null,
|
||||||
gmaCode: dealer.gmaCode || null,
|
gmaCode: dealer.gmaCode || null,
|
||||||
region: dealer.region || null,
|
region: dealer.region || null,
|
||||||
state: dealer.state || null,
|
state: externalData?.['re state code'] || dealer.state || null,
|
||||||
district: dealer.district || null,
|
district: dealer.district || null,
|
||||||
city: dealer.city || null,
|
city: externalData?.['re city'] || dealer.city || null,
|
||||||
dealerPrincipalName: dealer.dealerPrincipalName || null,
|
dealerPrincipalName: dealer.dealerPrincipalName || null,
|
||||||
dealerPrincipalEmailId: dealer.dealerPrincipalEmailId || null,
|
dealerPrincipalEmailId: dealer.dealerPrincipalEmailId || null,
|
||||||
gstin: dealer.gst || null,
|
gstin: externalData?.gstin || dealer.gst || null,
|
||||||
pincode: dealer.showroomPincode || null,
|
pincode: externalData?.pincode || dealer.showroomPincode || null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[DealerService] Error fetching dealer by code:', error);
|
logger.error('[DealerService] Error fetching dealer by code:', error);
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import { notificationService } from './notification.service';
|
|||||||
import { activityService } from './activity.service';
|
import { activityService } from './activity.service';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
import { dmsIntegrationService } from './dmsIntegration.service';
|
import { dmsIntegrationService } from './dmsIntegration.service';
|
||||||
|
import { validateDealerUser } from './userEnrichment.service';
|
||||||
// findDealerLocally removed (duplicate)
|
// findDealerLocally removed (duplicate)
|
||||||
|
|
||||||
|
|
||||||
@ -98,6 +99,15 @@ export class DealerClaimService {
|
|||||||
}
|
}
|
||||||
): Promise<WorkflowRequest> {
|
): Promise<WorkflowRequest> {
|
||||||
try {
|
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
|
// Generate request number
|
||||||
const requestNumber = await generateRequestNumber();
|
const requestNumber = await generateRequestNumber();
|
||||||
|
|
||||||
@ -119,6 +129,7 @@ export class DealerClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Enrichment from local dealer table if data is missing or incomplete
|
// 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);
|
const localDealer = await findDealerLocally(claimData.dealerCode, claimData.dealerEmail);
|
||||||
if (localDealer) {
|
if (localDealer) {
|
||||||
logger.info(`[DealerClaimService] Enriched claim request with local dealer data: ${localDealer.dealerCode}`);
|
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) {
|
for (const a of claimData.approvers) {
|
||||||
let approverUserId = a.userId;
|
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 userId missing, ensure user exists by email
|
||||||
if (!approverUserId && a.email) {
|
if (!approverUserId && a.email) {
|
||||||
try {
|
try {
|
||||||
@ -165,9 +187,7 @@ export class DealerClaimService {
|
|||||||
tatHours = a.tatType === 'days' ? val * 24 : val;
|
tatHours = a.tatType === 'days' ? val * 24 : val;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine level name - use mapped name or fallback to "Step X"
|
// Already determined levelName above
|
||||||
// Also handle "Additional Approver" case if provided
|
|
||||||
let levelName = stepNames[a.level] || `Step ${a.level}`;
|
|
||||||
|
|
||||||
// If it's an additional approver (not one of the standard steps), label it clearly
|
// 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
|
// Note: The frontend might send extra steps if approvers are added dynamically
|
||||||
@ -1072,11 +1092,12 @@ export class DealerClaimService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fetch Internal Order details
|
// Fetch Internal Order details
|
||||||
const internalOrder = await InternalOrder.findOne({
|
const internalOrders = await InternalOrder.findAll({
|
||||||
where: { requestId },
|
where: { requestId },
|
||||||
include: [
|
include: [
|
||||||
{ model: User, as: 'organizer', required: false }
|
{ model: User, as: 'organizer', required: false }
|
||||||
]
|
],
|
||||||
|
order: [['createdAt', 'ASC']]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Serialize claim details to ensure proper field names
|
// 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 {
|
try {
|
||||||
const dealer = await Dealer.findOne({
|
const dealer = await findDealerLocally(claimDetails.dealerCode);
|
||||||
where: { dlrcode: claimDetails.dealerCode }
|
|
||||||
});
|
|
||||||
if (dealer) {
|
if (dealer) {
|
||||||
serializedClaimDetails.dealerGstin = dealer.gst || null;
|
|
||||||
// Also add for backward compatibility if needed
|
serializedClaimDetails.dealerGstin = dealer.gstin || null;
|
||||||
serializedClaimDetails.dealerGSTIN = dealer.gst || 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) {
|
} 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
|
// Transform proposal details to include cost items as array
|
||||||
@ -1176,10 +1205,9 @@ export class DealerClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Serialize internal order details
|
// Serialize internal order details
|
||||||
let serializedInternalOrder = null;
|
const serializedInternalOrders = internalOrders.map(io => (io as any).toJSON ? (io as any).toJSON() : io);
|
||||||
if (internalOrder) {
|
// For backward compatibility, also provide serializedInternalOrder (first one)
|
||||||
serializedInternalOrder = (internalOrder as any).toJSON ? (internalOrder as any).toJSON() : internalOrder;
|
const serializedInternalOrder = serializedInternalOrders.length > 0 ? serializedInternalOrders[0] : null;
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch Budget Tracking details
|
// Fetch Budget Tracking details
|
||||||
const budgetTracking = await ClaimBudgetTracking.findOne({
|
const budgetTracking = await ClaimBudgetTracking.findOne({
|
||||||
@ -1365,7 +1393,20 @@ export class DealerClaimService {
|
|||||||
await DealerProposalCostItem.bulkCreate(costItems);
|
await DealerProposalCostItem.bulkCreate(costItems);
|
||||||
logger.info(`[DealerClaimService] Saved ${costItems.length} cost items for proposal ${proposalId}`);
|
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({
|
await ClaimBudgetTracking.upsert({
|
||||||
requestId,
|
requestId,
|
||||||
proposalEstimatedBudget: proposalData.totalEstimatedBudget,
|
proposalEstimatedBudget: proposalData.totalEstimatedBudget,
|
||||||
@ -1501,8 +1542,10 @@ export class DealerClaimService {
|
|||||||
const isIGST = dealerStateCode !== buyerStateCode;
|
const isIGST = dealerStateCode !== buyerStateCode;
|
||||||
|
|
||||||
const completionId = (completionDetails as any)?.completionId;
|
const completionId = (completionDetails as any)?.completionId;
|
||||||
|
const expenseRows: any[] = [];
|
||||||
if (completionData.closedExpenses && completionData.closedExpenses.length > 0) {
|
if (completionData.closedExpenses && completionData.closedExpenses.length > 0) {
|
||||||
// Determine taxation type for fallback logic
|
// Determine taxation type for fallback logic
|
||||||
|
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
|
||||||
let isNonGst = false;
|
let isNonGst = false;
|
||||||
if (claimDetails?.activityType) {
|
if (claimDetails?.activityType) {
|
||||||
const activity = await ActivityType.findOne({ where: { title: 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
|
// Clear existing expenses for this request to avoid duplicates
|
||||||
await DealerCompletionExpense.destroy({ where: { requestId } });
|
await DealerCompletionExpense.destroy({ where: { requestId } });
|
||||||
const expenseRows = completionData.closedExpenses.map((item: any) => {
|
completionData.closedExpenses.forEach((item: any) => {
|
||||||
const amount = Number(item.amount) || 0;
|
const amount = Number(item.amount) || 0;
|
||||||
const quantity = Number(item.quantity) || 1;
|
const quantity = Number(item.quantity) || 1;
|
||||||
const baseTotal = amount * quantity;
|
const baseTotal = amount * quantity;
|
||||||
|
|
||||||
// Use provided tax details or calculate if missing/zero
|
// Tax calculations (simplified for brevity, matching previous logic)
|
||||||
let gstRate = Number(item.gstRate);
|
const gstRate = isNonGst ? 0 : (Number(item.gstRate) || 18);
|
||||||
if (isNaN(gstRate) || gstRate === 0) {
|
const totalTaxAmt = baseTotal * (gstRate / 100);
|
||||||
// Fallback to activity GST rate ONLY for GST claims
|
|
||||||
gstRate = isNonGst ? 0 : 18;
|
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);
|
expenseRows.push({
|
||||||
|
|
||||||
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 {
|
|
||||||
requestId,
|
requestId,
|
||||||
completionId,
|
completionId,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
amount,
|
amount,
|
||||||
quantity,
|
quantity,
|
||||||
|
taxableAmt: baseTotal, // Added for Scenario 1 comparison
|
||||||
hsnCode: item.hsnCode || '',
|
hsnCode: item.hsnCode || '',
|
||||||
gstRate,
|
gstRate,
|
||||||
gstAmt: totalTaxAmt,
|
gstAmt: totalTaxAmt,
|
||||||
@ -1559,20 +1601,71 @@ export class DealerClaimService {
|
|||||||
totalAmt: Number(item.totalAmt) || (baseTotal + totalTaxAmt),
|
totalAmt: Number(item.totalAmt) || (baseTotal + totalTaxAmt),
|
||||||
isService: !!item.isService,
|
isService: !!item.isService,
|
||||||
expenseDate: item.date instanceof Date ? item.date : (item.date ? new Date(item.date) : (completionData.activityCompletionDate || new Date())),
|
expenseDate: item.date instanceof Date ? item.date : (item.date ? new Date(item.date) : (completionData.activityCompletionDate || new Date())),
|
||||||
};
|
});
|
||||||
});
|
});
|
||||||
await DealerCompletionExpense.bulkCreate(expenseRows);
|
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({
|
await ClaimBudgetTracking.upsert({
|
||||||
requestId,
|
requestId,
|
||||||
closedExpenses: completionData.totalClosedExpenses,
|
closedExpenses: completionData.totalClosedExpenses, // Gross amount
|
||||||
|
taxableClosedExpenses: totalTaxableClosedExpenses, // Taxable amount (for Scenario 1 comparison)
|
||||||
closedExpensesSubmittedAt: new Date(),
|
closedExpensesSubmittedAt: new Date(),
|
||||||
budgetStatus: BudgetStatus.CLOSED,
|
budgetStatus: BudgetStatus.CLOSED,
|
||||||
currency: 'INR',
|
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)
|
// Approve Dealer Completion Documents step dynamically (by levelName, not hardcoded step number)
|
||||||
let dealerCompletionLevel = await ApprovalLevel.findOne({
|
let dealerCompletionLevel = await ApprovalLevel.findOne({
|
||||||
where: {
|
where: {
|
||||||
@ -1677,43 +1770,20 @@ export class DealerClaimService {
|
|||||||
if (ioData.ioNumber) {
|
if (ioData.ioNumber) {
|
||||||
const organizedBy = organizedByUserId || null;
|
const organizedBy = organizedByUserId || null;
|
||||||
|
|
||||||
// Create or update Internal Order record with just IO details (no blocking)
|
// Always create a new Internal Order record for each block/provision (supporting multiple IOs)
|
||||||
const [internalOrder, created] = await InternalOrder.findOrCreate({
|
await InternalOrder.create({
|
||||||
where: { requestId },
|
requestId,
|
||||||
defaults: {
|
ioNumber: ioData.ioNumber,
|
||||||
requestId,
|
ioRemark: ioData.ioRemark || '',
|
||||||
ioNumber: ioData.ioNumber,
|
ioAvailableBalance: ioData.availableBalance || 0,
|
||||||
ioRemark: ioData.ioRemark || '', // Optional - kept for backward compatibility // Optional - keep for backward compatibility
|
ioBlockedAmount: 0,
|
||||||
ioAvailableBalance: ioData.availableBalance || 0,
|
ioRemainingBalance: ioData.remainingBalance || 0,
|
||||||
ioBlockedAmount: 0,
|
organizedBy: organizedBy || undefined,
|
||||||
ioRemainingBalance: ioData.remainingBalance || 0,
|
organizedAt: new Date(),
|
||||||
organizedBy: organizedBy || undefined,
|
status: IOStatus.PENDING,
|
||||||
organizedAt: new Date(),
|
|
||||||
status: IOStatus.PENDING,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!created) {
|
logger.info(`[DealerClaimService] IO provision record created for request: ${requestId}`, {
|
||||||
// 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}`, {
|
|
||||||
ioNumber: ioData.ioNumber
|
ioNumber: ioData.ioNumber
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1754,166 +1824,59 @@ export class DealerClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sapReturnedBlockedAmount = blockResult.blockedAmount;
|
const sapReturnedBlockedAmount = blockResult.blockedAmount;
|
||||||
// Extract SAP reference number from blockId (this is the Sap_Reference_no from SAP response)
|
|
||||||
// Only use the actual SAP reference number - don't use any generated fallback
|
|
||||||
const sapDocumentNumber = blockResult.blockId || undefined;
|
const sapDocumentNumber = blockResult.blockId || undefined;
|
||||||
// Ensure availableBalance is rounded to 2 decimal places for accurate calculations
|
|
||||||
const availableBalance = parseFloat((ioData.availableBalance || ioValidation.availableBalance).toFixed(2));
|
const availableBalance = parseFloat((ioData.availableBalance || ioValidation.availableBalance).toFixed(2));
|
||||||
|
|
||||||
// Log if SAP reference number was received
|
// Use the amount we REQUESTED for calculation, unless SAP blocked significantly different amount
|
||||||
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
|
|
||||||
const amountDifference = Math.abs(sapReturnedBlockedAmount - blockedAmount);
|
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;
|
const finalBlockedAmount = useSapAmount ? sapReturnedBlockedAmount : blockedAmount;
|
||||||
|
|
||||||
// Log SAP response vs what we sent
|
// Calculate remaining balance
|
||||||
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
|
|
||||||
const calculatedRemainingBalance = parseFloat((availableBalance - finalBlockedAmount).toFixed(2));
|
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 sapRemainingBalance = blockResult.remainingBalance ? parseFloat(blockResult.remainingBalance.toFixed(2)) : 0;
|
||||||
const sapValueIsValid = sapRemainingBalance > 0 &&
|
const sapValueIsValid = sapRemainingBalance > 0 &&
|
||||||
sapRemainingBalance <= availableBalance &&
|
sapRemainingBalance <= availableBalance &&
|
||||||
Math.abs(sapRemainingBalance - calculatedRemainingBalance) < 1;
|
Math.abs(sapRemainingBalance - calculatedRemainingBalance) < 1;
|
||||||
|
|
||||||
const remainingBalance = sapValueIsValid
|
const remainingBalance = sapValueIsValid ? sapRemainingBalance : calculatedRemainingBalance;
|
||||||
? sapRemainingBalance
|
|
||||||
: calculatedRemainingBalance;
|
|
||||||
|
|
||||||
// Ensure remaining balance is not negative and round to 2 decimal places
|
|
||||||
const finalRemainingBalance = parseFloat(Math.max(0, remainingBalance).toFixed(2));
|
const finalRemainingBalance = parseFloat(Math.max(0, remainingBalance).toFixed(2));
|
||||||
|
|
||||||
// Warn if SAP's value doesn't match our calculation
|
// Create new Internal Order record for this block operation (supporting multiple blocks)
|
||||||
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)
|
|
||||||
const organizedBy = organizedByUserId || null;
|
const organizedBy = organizedByUserId || null;
|
||||||
|
await InternalOrder.create({
|
||||||
// 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 = {
|
|
||||||
requestId,
|
requestId,
|
||||||
ioNumber: ioData.ioNumber,
|
ioNumber: ioData.ioNumber,
|
||||||
ioRemark: ioData.ioRemark || '', // Optional - kept for backward compatibility
|
ioRemark: ioData.ioRemark || '',
|
||||||
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,
|
|
||||||
ioAvailableBalance: availableBalance,
|
ioAvailableBalance: availableBalance,
|
||||||
ioBlockedAmount: finalBlockedAmount,
|
ioBlockedAmount: finalBlockedAmount,
|
||||||
ioRemainingBalance: finalRemainingBalance,
|
ioRemainingBalance: finalRemainingBalance,
|
||||||
sapDocumentNumber: sapDocumentNumber,
|
organizedBy: organizedBy || undefined,
|
||||||
requestId
|
organizedAt: new Date(),
|
||||||
|
sapDocumentNumber: sapDocumentNumber || undefined,
|
||||||
|
status: IOStatus.BLOCKED,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [internalOrder, created] = await InternalOrder.findOrCreate({
|
// Update budget tracking with TOTAL blocked amount from all records
|
||||||
where: { requestId },
|
const allInternalOrders = await InternalOrder.findAll({ where: { requestId, status: IOStatus.BLOCKED } });
|
||||||
defaults: ioRecordData
|
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) {
|
logger.info(`[DealerClaimService] Budget block recorded for request: ${requestId}`, {
|
||||||
// Update existing IO record - explicitly update all fields including remainingBalance
|
ioNumber: ioData.ioNumber,
|
||||||
logger.info(`[DealerClaimService] Updating existing IO record for request: ${requestId}`);
|
blockedAmount: finalBlockedAmount,
|
||||||
logger.info(`[DealerClaimService] Update data:`, {
|
totalBlockedAmount,
|
||||||
ioRemainingBalance: ioRecordData.ioRemainingBalance,
|
sapDocumentNumber,
|
||||||
ioBlockedAmount: ioRecordData.ioBlockedAmount,
|
finalRemainingBalance
|
||||||
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
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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
|
// Save IO history after successful blocking
|
||||||
// Find the Department Lead IO Approval level (Step 3)
|
// Find the Department Lead IO Approval level (Step 3)
|
||||||
const ioApprovalLevel = await ApprovalLevel.findOne({
|
const ioApprovalLevel = await ApprovalLevel.findOne({
|
||||||
@ -2835,10 +2798,14 @@ export class DealerClaimService {
|
|||||||
|
|
||||||
// Build changeReason - will be updated later if moving to next level
|
// Build changeReason - will be updated later if moving to next level
|
||||||
// For now, just include the basic approval/rejection info
|
// For now, just include the basic approval/rejection info
|
||||||
const changeReason = action === 'APPROVE'
|
let changeReason = action === 'APPROVE'
|
||||||
? `Approved by ${level.approverName || level.approverEmail}`
|
? `Approved by ${level.approverName || level.approverEmail}`
|
||||||
: `Rejected by ${level.approverName || level.approverEmail}`;
|
: `Rejected by ${level.approverName || level.approverEmail}`;
|
||||||
|
|
||||||
|
if (action === 'REJECT' && (rejectionReason || comments)) {
|
||||||
|
changeReason += `. Reason: ${rejectionReason || comments}`;
|
||||||
|
}
|
||||||
|
|
||||||
await DealerClaimHistory.create({
|
await DealerClaimHistory.create({
|
||||||
requestId,
|
requestId,
|
||||||
approvalLevelId,
|
approvalLevelId,
|
||||||
|
|||||||
@ -246,6 +246,44 @@ export class DealerClaimApprovalService {
|
|||||||
|
|
||||||
if (nextLevel) {
|
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}`);
|
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 {
|
} else {
|
||||||
logger.info(`[DealerClaimApproval] No next level found after level ${currentLevelNumber} - this may be the final approval`);
|
logger.info(`[DealerClaimApproval] No next level found after level ${currentLevelNumber} - this may be the final approval`);
|
||||||
}
|
}
|
||||||
@ -790,10 +828,34 @@ export class DealerClaimApprovalService {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Return to previous step
|
// 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
|
if (isReQuotation) {
|
||||||
await previousLevel.update({
|
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,
|
status: ApprovalStatus.IN_PROGRESS,
|
||||||
levelStartTime: rejectionNow,
|
levelStartTime: rejectionNow,
|
||||||
tatStartTime: rejectionNow,
|
tatStartTime: rejectionNow,
|
||||||
@ -804,33 +866,31 @@ export class DealerClaimApprovalService {
|
|||||||
tatPercentageUsed: 0
|
tatPercentageUsed: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update workflow status to IN_PROGRESS (remains active for rework)
|
// Update workflow status to PENDING/IN_PROGRESS
|
||||||
// Set currentLevel to previous level
|
// Set currentLevel to target level
|
||||||
await WorkflowRequest.update(
|
await WorkflowRequest.update(
|
||||||
{
|
{
|
||||||
status: WorkflowStatus.PENDING,
|
status: WorkflowStatus.PENDING,
|
||||||
currentLevel: previousLevel.levelNumber
|
currentLevel: targetLevel.levelNumber
|
||||||
},
|
},
|
||||||
{ where: { requestId: level.requestId } }
|
{ where: { requestId: level.requestId } }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Log rejection activity (returned to step)
|
||||||
|
|
||||||
// Log rejection activity (returned to previous step)
|
|
||||||
activityService.log({
|
activityService.log({
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
type: 'rejection',
|
type: 'rejection',
|
||||||
user: { userId: level.approverId, name: level.approverName },
|
user: { userId: level.approverId, name: level.approverName },
|
||||||
timestamp: rejectionNow.toISOString(),
|
timestamp: rejectionNow.toISOString(),
|
||||||
action: 'Returned to Previous Step',
|
action: isReQuotation ? 'Re-quotation Requested' : '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'}`,
|
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,
|
ipAddress: requestMetadata?.ipAddress || undefined,
|
||||||
userAgent: requestMetadata?.userAgent || undefined
|
userAgent: requestMetadata?.userAgent || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify the approver of the previous level
|
// Notify the approver of the target level
|
||||||
if (previousLevel.approverId) {
|
if (targetLevel.approverId) {
|
||||||
await notificationService.sendToUsers([previousLevel.approverId], {
|
await notificationService.sendToUsers([targetLevel.approverId], {
|
||||||
title: `Request Returned: ${(wf as any).requestNumber}`,
|
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'}`,
|
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,
|
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], {
|
await notificationService.sendToUsers([(wf as any).initiatorId], {
|
||||||
title: `Request Returned: ${(wf as any).requestNumber}`,
|
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,
|
requestNumber: (wf as any).requestNumber,
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
url: `/request/${(wf as any).requestNumber}`,
|
url: `/request/${(wf as any).requestNumber}`,
|
||||||
type: 'rejection',
|
type: 'rejection',
|
||||||
priority: 'HIGH',
|
priority: 'MEDIUM'
|
||||||
actionRequired: true
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
93
src/services/dealerExternal.service.ts
Normal file
93
src/services/dealerExternal.service.ts
Normal 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();
|
||||||
@ -1096,14 +1096,16 @@ export class SAPIntegrationService {
|
|||||||
/**
|
/**
|
||||||
* Release blocked budget in SAP
|
* Release blocked budget in SAP
|
||||||
* @param ioNumber - IO number
|
* @param ioNumber - IO number
|
||||||
* @param blockId - Block ID from previous block operation
|
* @param amount - Amount to release
|
||||||
* @param requestNumber - Request number for reference
|
* @param requestNumber - Request number for reference
|
||||||
|
* @param blockId - Block ID from previous block operation (optional)
|
||||||
* @returns Release confirmation
|
* @returns Release confirmation
|
||||||
*/
|
*/
|
||||||
async releaseBudget(
|
async releaseBudget(
|
||||||
ioNumber: string,
|
ioNumber: string,
|
||||||
blockId: string,
|
amount: number,
|
||||||
requestNumber: string
|
requestNumber: string,
|
||||||
|
blockId?: string
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
releasedAmount: number;
|
releasedAmount: number;
|
||||||
|
|||||||
@ -73,7 +73,7 @@ export async function enrichApprovalLevels(
|
|||||||
try {
|
try {
|
||||||
// Find or create user from AD
|
// Find or create user from AD
|
||||||
let user = await User.findOne({ where: { email } });
|
let user = await User.findOne({ where: { email } });
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
logger.info(`[UserEnrichment] User not found in DB, attempting to sync from AD: ${email}`);
|
logger.info(`[UserEnrichment] User not found in DB, attempting to sync from AD: ${email}`);
|
||||||
// Try to fetch and create user from AD
|
// Try to fetch and create user from AD
|
||||||
@ -103,8 +103,8 @@ export async function enrichApprovalLevels(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-detect final approver (last level)
|
// Auto-detect final approver (last level)
|
||||||
const isFinalApprover = level.isFinalApprover !== undefined
|
const isFinalApprover = level.isFinalApprover !== undefined
|
||||||
? level.isFinalApprover
|
? level.isFinalApprover
|
||||||
: (i === approvalLevels.length - 1);
|
: (i === approvalLevels.length - 1);
|
||||||
|
|
||||||
enriched.push({
|
enriched.push({
|
||||||
@ -154,7 +154,7 @@ export async function enrichSpectators(
|
|||||||
try {
|
try {
|
||||||
// Find or create user from AD
|
// Find or create user from AD
|
||||||
let user = await User.findOne({ where: { email } });
|
let user = await User.findOne({ where: { email } });
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
logger.info(`[UserEnrichment] User not found in DB, attempting to sync from AD: ${email}`);
|
logger.info(`[UserEnrichment] User not found in DB, attempting to sync from AD: ${email}`);
|
||||||
try {
|
try {
|
||||||
@ -197,7 +197,7 @@ export async function enrichSpectators(
|
|||||||
*/
|
*/
|
||||||
export async function validateInitiator(initiatorId: string): Promise<any> {
|
export async function validateInitiator(initiatorId: string): Promise<any> {
|
||||||
const user = await User.findByPk(initiatorId);
|
const user = await User.findByPk(initiatorId);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(`Invalid initiator: User with ID '${initiatorId}' not found. Please ensure you are logged in with a valid account.`);
|
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<any> {
|
|||||||
return user;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,8 @@ export interface SSOUserData {
|
|||||||
oktaSub: string; // Required - Okta subject identifier
|
oktaSub: string; // Required - Okta subject identifier
|
||||||
email: string; // Required - Primary identifier for user lookup
|
email: string; // Required - Primary identifier for user lookup
|
||||||
employeeId?: string; // Optional - HR System Employee ID
|
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;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
|||||||
@ -29,7 +29,7 @@ const simplifiedSpectatorSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const createWorkflowSchema = 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'),
|
title: z.string().min(1, 'Title is required').max(500, 'Title too long'),
|
||||||
description: z.string().min(1, 'Description is required'),
|
description: z.string().min(1, 'Description is required'),
|
||||||
priority: z.enum(['STANDARD', 'EXPRESS'] as const),
|
priority: z.enum(['STANDARD', 'EXPRESS'] as const),
|
||||||
|
|||||||
BIN
test_integ_out.txt
Normal file
BIN
test_integ_out.txt
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user