Re_Backend/src/controllers/dealerClaim.controller.ts

1086 lines
42 KiB
TypeScript

import { Request, Response } from 'express';
import type { AuthenticatedRequest } from '../types/express';
import { DealerClaimService } from '../services/dealerClaim.service';
import { ResponseHandler } from '../utils/responseHandler';
import { translateEInvoiceError } from '../utils/einvoiceErrors';
import logger from '../utils/logger';
import { gcsStorageService } from '../services/gcsStorage.service';
import { Document } from '../models/Document';
import { InternalOrder } from '../models/InternalOrder';
import { constants } from '../config/constants';
import { sapIntegrationService } from '../services/sapIntegration.service';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { WorkflowRequest } from '../models/WorkflowRequest';
import { DealerClaimDetails } from '../models/DealerClaimDetails';
import { ClaimInvoice } from '../models/ClaimInvoice';
import { ClaimInvoiceItem } from '../models/ClaimInvoiceItem';
import { ActivityType } from '../models/ActivityType';
export class DealerClaimController {
private dealerClaimService = new DealerClaimService();
/**
* Create a new dealer claim request
* POST /api/v1/dealer-claims
*/
async createClaimRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
return ResponseHandler.error(res, 'Unauthorized', 401);
}
const {
activityName,
activityType,
dealerCode,
dealerName,
dealerEmail,
dealerPhone,
dealerAddress,
activityDate,
location,
requestDescription,
periodStartDate,
periodEndDate,
estimatedBudget,
approvers, // Array of approvers for all 8 steps
} = req.body;
// Validation
if (!activityName || !activityType || !dealerCode || !dealerName || !location || !requestDescription) {
return ResponseHandler.error(res, 'Missing required fields', 400);
}
const claimRequest = await this.dealerClaimService.createClaimRequest(userId, {
activityName,
activityType,
dealerCode,
dealerName,
dealerEmail,
dealerPhone,
dealerAddress,
activityDate: activityDate ? new Date(activityDate) : undefined,
location,
requestDescription,
periodStartDate: periodStartDate ? new Date(periodStartDate) : undefined,
periodEndDate: periodEndDate ? new Date(periodEndDate) : undefined,
estimatedBudget: estimatedBudget ? parseFloat(estimatedBudget) : undefined,
approvers: approvers || [], // Pass approvers array for all 8 steps
});
return ResponseHandler.success(res, {
request: claimRequest,
message: 'Claim request created successfully'
}, 'Claim request created');
} catch (error: any) {
// Handle approver validation errors
if (error.message && error.message.includes('Approver')) {
logger.warn('[DealerClaimController] Approver validation error:', { message: error.message });
return ResponseHandler.error(res, error.message, 400);
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error creating claim request:', error);
return ResponseHandler.error(res, 'Failed to create claim request', 500, errorMessage);
}
}
/**
* Get claim details
* GET /api/v1/dealer-claims/:requestId
* Accepts either UUID or requestNumber
*/
async getClaimDetails(req: Request, res: Response): Promise<void> {
try {
const identifier = req.params.requestId; // Can be UUID or requestNumber
// Find workflow to get actual UUID
const workflow = await this.findWorkflowByIdentifier(identifier);
if (!workflow) {
return ResponseHandler.error(res, 'Workflow request not found', 404);
}
const requestId = (workflow as any).requestId || (workflow as any).request_id;
if (!requestId) {
return ResponseHandler.error(res, 'Invalid workflow request', 400);
}
const claimDetails = await this.dealerClaimService.getClaimDetails(requestId);
return ResponseHandler.success(res, claimDetails, 'Claim details fetched');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error getting claim details:', error);
return ResponseHandler.error(res, 'Failed to fetch claim details', 500, errorMessage);
}
}
/**
* Helper to find workflow by either requestId (UUID) or requestNumber
*/
private async findWorkflowByIdentifier(identifier: string): Promise<any> {
const isUuid = (id: string): boolean => {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(id);
};
const { WorkflowRequest } = await import('../models/WorkflowRequest');
if (isUuid(identifier)) {
return await WorkflowRequest.findByPk(identifier);
} else {
return await WorkflowRequest.findOne({ where: { requestNumber: identifier } });
}
}
/**
* Submit dealer proposal (Step 1)
* POST /api/v1/dealer-claims/:requestId/proposal
* Accepts either UUID or requestNumber
*/
async submitProposal(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const identifier = req.params.requestId; // Can be UUID or requestNumber
const userId = req.user?.userId;
const {
costBreakup,
totalEstimatedBudget,
timelineMode,
expectedCompletionDate,
expectedCompletionDays,
dealerComments,
} = req.body;
// Find workflow by identifier (UUID or requestNumber)
const workflow = await this.findWorkflowByIdentifier(identifier);
if (!workflow) {
return ResponseHandler.error(res, 'Workflow request not found', 404);
}
// Get actual UUID and requestNumber
const requestId = (workflow as any).requestId || (workflow as any).request_id;
const requestNumber = (workflow as any).requestNumber || (workflow as any).request_number;
if (!requestId) {
return ResponseHandler.error(res, 'Invalid workflow request', 400);
}
// Parse costBreakup - it comes as JSON string from FormData
let parsedCostBreakup: any[] = [];
if (costBreakup) {
if (typeof costBreakup === 'string') {
try {
parsedCostBreakup = JSON.parse(costBreakup);
} catch (parseError) {
logger.error('[DealerClaimController] Failed to parse costBreakup JSON:', parseError);
return ResponseHandler.error(res, 'Invalid costBreakup format. Expected JSON array.', 400);
}
} else if (Array.isArray(costBreakup)) {
parsedCostBreakup = costBreakup;
} else {
logger.warn('[DealerClaimController] costBreakup is not a string or array:', typeof costBreakup);
parsedCostBreakup = [];
}
}
// Validate costBreakup is an array
if (!Array.isArray(parsedCostBreakup)) {
logger.error('[DealerClaimController] costBreakup is not an array after parsing:', parsedCostBreakup);
return ResponseHandler.error(res, 'costBreakup must be an array of cost items', 400);
}
// Validate each cost item has required fields
for (const item of parsedCostBreakup) {
if (!item.description || item.amount === undefined || item.amount === null) {
return ResponseHandler.error(res, 'Each cost item must have description and amount', 400);
}
}
// Handle file upload if present
let proposalDocumentPath: string | undefined;
let proposalDocumentUrl: string | undefined;
if (req.file) {
const file = req.file;
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
const uploadResult = await gcsStorageService.uploadFileWithFallback({
buffer: fileBuffer,
originalName: file.originalname,
mimeType: file.mimetype,
requestNumber: requestNumber || 'UNKNOWN',
fileType: 'documents'
});
proposalDocumentPath = uploadResult.filePath;
proposalDocumentUrl = uploadResult.storageUrl;
// Cleanup local file if exists
if (file.path && fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
}
// Use actual UUID for service call with parsed costBreakup array
await this.dealerClaimService.submitDealerProposal(requestId, {
proposalDocumentPath,
proposalDocumentUrl,
costBreakup: parsedCostBreakup, // Use parsed array
totalEstimatedBudget: totalEstimatedBudget ? parseFloat(totalEstimatedBudget) : 0,
timelineMode: timelineMode || 'date',
expectedCompletionDate: expectedCompletionDate ? new Date(expectedCompletionDate) : undefined,
expectedCompletionDays: expectedCompletionDays ? parseInt(expectedCompletionDays) : undefined,
dealerComments: dealerComments || '',
});
return ResponseHandler.success(res, { message: 'Proposal submitted successfully' }, 'Proposal submitted');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error submitting proposal:', error);
return ResponseHandler.error(res, 'Failed to submit proposal', 500, errorMessage);
}
}
/**
* Submit completion documents (Step 5)
* POST /api/v1/dealer-claims/:requestId/completion
* Accepts either UUID or requestNumber
*/
async submitCompletion(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const identifier = req.params.requestId; // Can be UUID or requestNumber
const {
activityCompletionDate,
numberOfParticipants,
closedExpenses,
totalClosedExpenses,
completionDescription,
} = req.body;
// Parse closedExpenses if it's a JSON string
let parsedClosedExpenses: any[] = [];
if (closedExpenses) {
try {
parsedClosedExpenses = typeof closedExpenses === 'string' ? JSON.parse(closedExpenses) : closedExpenses;
} catch (e) {
logger.warn('[DealerClaimController] Failed to parse closedExpenses JSON:', e);
parsedClosedExpenses = [];
}
}
// Get files from multer
const files = req.files as { [fieldname: string]: Express.Multer.File[] } | undefined;
const completionDocumentsFiles = files?.completionDocuments || [];
const activityPhotosFiles = files?.activityPhotos || [];
const invoicesReceiptsFiles = files?.invoicesReceipts || [];
const attendanceSheetFile = files?.attendanceSheet?.[0];
// Find workflow to get actual UUID
const workflow = await this.findWorkflowByIdentifier(identifier);
if (!workflow) {
return ResponseHandler.error(res, 'Workflow request not found', 404);
}
const requestId = (workflow as any).requestId || (workflow as any).request_id;
const requestNumber = (workflow as any).requestNumber || (workflow as any).request_number || 'UNKNOWN';
if (!requestId) {
return ResponseHandler.error(res, 'Invalid workflow request', 400);
}
const userId = (req as any).user?.userId || (req as any).user?.user_id;
if (!userId) {
return ResponseHandler.error(res, 'User not authenticated', 401);
}
if (!activityCompletionDate) {
return ResponseHandler.error(res, 'Activity completion date is required', 400);
}
// Upload files to GCS and save to documents table
const completionDocuments: any[] = [];
const activityPhotos: any[] = [];
// Upload and save completion documents to documents table with COMPLETION_DOC category
for (const file of completionDocumentsFiles) {
try {
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const uploadResult = await gcsStorageService.uploadFileWithFallback({
buffer: fileBuffer,
originalName: file.originalname,
mimeType: file.mimetype,
requestNumber: requestNumber,
fileType: 'documents'
});
const extension = path.extname(file.originalname).replace('.', '').toLowerCase();
// Save to documents table
const doc = await Document.create({
requestId,
uploadedBy: userId,
fileName: path.basename(file.filename || file.originalname),
originalFileName: file.originalname,
fileType: extension,
fileExtension: extension,
fileSize: file.size,
filePath: uploadResult.filePath,
storageUrl: uploadResult.storageUrl,
mimeType: file.mimetype,
checksum,
isGoogleDoc: false,
googleDocUrl: null as any,
category: constants.DOCUMENT_CATEGORIES.COMPLETION_DOC,
version: 1,
parentDocumentId: null as any,
isDeleted: false,
downloadCount: 0,
} as any);
completionDocuments.push({
documentId: doc.documentId,
name: file.originalname,
url: uploadResult.storageUrl,
size: file.size,
type: file.mimetype,
});
// Cleanup local file if exists
if (file.path && fs.existsSync(file.path)) {
try {
fs.unlinkSync(file.path);
} catch (unlinkError) {
logger.warn(`[DealerClaimController] Failed to delete local file ${file.path}`);
}
}
} catch (error) {
logger.error(`[DealerClaimController] Error uploading completion document ${file.originalname}:`, error);
}
}
// Upload and save activity photos to documents table with ACTIVITY_PHOTO category
for (const file of activityPhotosFiles) {
try {
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const uploadResult = await gcsStorageService.uploadFileWithFallback({
buffer: fileBuffer,
originalName: file.originalname,
mimeType: file.mimetype,
requestNumber: requestNumber,
fileType: 'attachments'
});
const extension = path.extname(file.originalname).replace('.', '').toLowerCase();
// Save to documents table
const doc = await Document.create({
requestId,
uploadedBy: userId,
fileName: path.basename(file.filename || file.originalname),
originalFileName: file.originalname,
fileType: extension,
fileExtension: extension,
fileSize: file.size,
filePath: uploadResult.filePath,
storageUrl: uploadResult.storageUrl,
mimeType: file.mimetype,
checksum,
isGoogleDoc: false,
googleDocUrl: null as any,
category: constants.DOCUMENT_CATEGORIES.ACTIVITY_PHOTO,
version: 1,
parentDocumentId: null as any,
isDeleted: false,
downloadCount: 0,
} as any);
activityPhotos.push({
documentId: doc.documentId,
name: file.originalname,
url: uploadResult.storageUrl,
size: file.size,
type: file.mimetype,
});
// Cleanup local file if exists
if (file.path && fs.existsSync(file.path)) {
try {
fs.unlinkSync(file.path);
} catch (unlinkError) {
logger.warn(`[DealerClaimController] Failed to delete local file ${file.path}`);
}
}
} catch (error) {
logger.error(`[DealerClaimController] Error uploading activity photo ${file.originalname}:`, error);
}
}
// Upload and save invoices/receipts to documents table with SUPPORTING category
const invoicesReceipts: any[] = [];
for (const file of invoicesReceiptsFiles) {
try {
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const uploadResult = await gcsStorageService.uploadFileWithFallback({
buffer: fileBuffer,
originalName: file.originalname,
mimeType: file.mimetype,
requestNumber: requestNumber,
fileType: 'attachments'
});
const extension = path.extname(file.originalname).replace('.', '').toLowerCase();
// Save to documents table
const doc = await Document.create({
requestId,
uploadedBy: userId,
fileName: path.basename(file.filename || file.originalname),
originalFileName: file.originalname,
fileType: extension,
fileExtension: extension,
fileSize: file.size,
filePath: uploadResult.filePath,
storageUrl: uploadResult.storageUrl,
mimeType: file.mimetype,
checksum,
isGoogleDoc: false,
googleDocUrl: null as any,
category: constants.DOCUMENT_CATEGORIES.SUPPORTING,
version: 1,
parentDocumentId: null as any,
isDeleted: false,
downloadCount: 0,
} as any);
invoicesReceipts.push({
documentId: doc.documentId,
name: file.originalname,
url: uploadResult.storageUrl,
size: file.size,
type: file.mimetype,
});
// Cleanup local file if exists
if (file.path && fs.existsSync(file.path)) {
try {
fs.unlinkSync(file.path);
} catch (unlinkError) {
logger.warn(`[DealerClaimController] Failed to delete local file ${file.path}`);
}
}
} catch (error) {
logger.error(`[DealerClaimController] Error uploading invoice/receipt ${file.originalname}:`, error);
}
}
// Upload and save attendance sheet to documents table with SUPPORTING category
let attendanceSheet: any = null;
if (attendanceSheetFile) {
try {
const fileBuffer = attendanceSheetFile.buffer || (attendanceSheetFile.path ? fs.readFileSync(attendanceSheetFile.path) : Buffer.from(''));
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const uploadResult = await gcsStorageService.uploadFileWithFallback({
buffer: fileBuffer,
originalName: attendanceSheetFile.originalname,
mimeType: attendanceSheetFile.mimetype,
requestNumber: requestNumber,
fileType: 'attachments'
});
const extension = path.extname(attendanceSheetFile.originalname).replace('.', '').toLowerCase();
// Save to documents table
const doc = await Document.create({
requestId,
uploadedBy: userId,
fileName: path.basename(attendanceSheetFile.filename || attendanceSheetFile.originalname),
originalFileName: attendanceSheetFile.originalname,
fileType: extension,
fileExtension: extension,
fileSize: attendanceSheetFile.size,
filePath: uploadResult.filePath,
storageUrl: uploadResult.storageUrl,
mimeType: attendanceSheetFile.mimetype,
checksum,
isGoogleDoc: false,
googleDocUrl: null as any,
category: constants.DOCUMENT_CATEGORIES.SUPPORTING,
version: 1,
parentDocumentId: null as any,
isDeleted: false,
downloadCount: 0,
} as any);
attendanceSheet = {
documentId: doc.documentId,
name: attendanceSheetFile.originalname,
url: uploadResult.storageUrl,
size: attendanceSheetFile.size,
type: attendanceSheetFile.mimetype,
};
// Cleanup local file if exists
if (attendanceSheetFile.path && fs.existsSync(attendanceSheetFile.path)) {
try {
fs.unlinkSync(attendanceSheetFile.path);
} catch (unlinkError) {
logger.warn(`[DealerClaimController] Failed to delete local file ${attendanceSheetFile.path}`);
}
}
} catch (error) {
logger.error(`[DealerClaimController] Error uploading attendance sheet:`, error);
}
}
await this.dealerClaimService.submitCompletionDocuments(requestId, {
activityCompletionDate: new Date(activityCompletionDate),
numberOfParticipants: numberOfParticipants ? parseInt(numberOfParticipants) : undefined,
closedExpenses: parsedClosedExpenses,
totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0,
invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined,
attendanceSheet: attendanceSheet || undefined,
completionDescription: completionDescription || undefined,
});
return ResponseHandler.success(res, { message: 'Completion documents submitted successfully' }, 'Completion submitted');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error submitting completion:', error);
return ResponseHandler.error(res, 'Failed to submit completion documents', 500, errorMessage);
}
}
/**
* Validate/Fetch IO details from SAP
* GET /api/v1/dealer-claims/:requestId/io/validate?ioNumber=IO1234
* This endpoint fetches IO details from SAP and returns them, does not store anything
* Flow: Fetch from SAP -> Return to frontend (no database storage)
*/
async validateIO(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { ioNumber } = req.query;
if (!ioNumber || typeof ioNumber !== 'string') {
return ResponseHandler.error(res, 'IO number is required', 400);
}
// Fetch IO details from SAP (will return mock data until SAP is integrated)
const ioValidation = await sapIntegrationService.validateIONumber(ioNumber.trim());
if (!ioValidation.isValid) {
return ResponseHandler.error(res, ioValidation.error || 'Invalid IO number', 400);
}
return ResponseHandler.success(res, {
ioNumber: ioValidation.ioNumber,
availableBalance: ioValidation.availableBalance,
blockedAmount: ioValidation.blockedAmount,
remainingBalance: ioValidation.remainingBalance,
currency: ioValidation.currency,
description: ioValidation.description,
isValid: true,
}, 'IO fetched successfully from SAP');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error validating IO:', error);
return ResponseHandler.error(res, 'Failed to fetch IO from SAP', 500, errorMessage);
}
}
/**
* Update IO details and block amount in SAP
* PUT /api/v1/dealer-claims/:requestId/io
* Only stores data when blocking amount > 0
* Accepts either UUID or requestNumber
*/
async updateIODetails(req: AuthenticatedRequest, res: Response): Promise<void> {
const userId = (req as any).user?.userId || (req as any).user?.user_id;
try {
const identifier = req.params.requestId; // Can be UUID or requestNumber
const {
ioNumber,
ioRemark,
availableBalance,
blockedAmount,
remainingBalance,
} = req.body;
// Find workflow to get actual UUID
const workflow = await this.findWorkflowByIdentifier(identifier);
if (!workflow) {
return ResponseHandler.error(res, 'Workflow request not found', 404);
}
const requestId = (workflow as any).requestId || (workflow as any).request_id;
if (!requestId) {
return ResponseHandler.error(res, 'Invalid workflow request', 400);
}
if (!ioNumber) {
return ResponseHandler.error(res, 'IO number is required', 400);
}
const blockAmount = blockedAmount ? parseFloat(blockedAmount) : 0;
// Log received data for debugging
logger.info('[DealerClaimController] updateIODetails received:', {
requestId,
ioNumber,
availableBalance,
blockedAmount: blockAmount,
receivedBlockedAmount: blockedAmount, // Original value from request
userId,
});
// Store in database when blocking amount > 0 OR when ioNumber and ioRemark are provided (for Step 3 approval)
if (blockAmount > 0) {
if (availableBalance === undefined) {
return ResponseHandler.error(res, 'Available balance is required when blocking amount', 400);
}
// Don't pass remainingBalance - let the service calculate it from SAP's response
// This ensures we always use the actual remaining balance from SAP after blocking
const ioData = {
ioNumber,
ioRemark: ioRemark || '',
availableBalance: parseFloat(availableBalance),
blockedAmount: blockAmount,
// remainingBalance will be calculated by the service from SAP's response
};
logger.info('[DealerClaimController] Calling updateIODetails service with:', ioData);
await this.dealerClaimService.updateIODetails(
requestId,
ioData,
userId
);
// Fetch and return the updated IO details from database
const updatedIO = await InternalOrder.findOne({ where: { requestId } });
if (updatedIO) {
return ResponseHandler.success(res, {
message: 'IO blocked successfully in SAP',
ioDetails: {
ioNumber: updatedIO.ioNumber,
ioAvailableBalance: updatedIO.ioAvailableBalance,
ioBlockedAmount: updatedIO.ioBlockedAmount,
ioRemainingBalance: updatedIO.ioRemainingBalance,
ioRemark: updatedIO.ioRemark,
status: updatedIO.status,
}
}, 'IO blocked');
}
return ResponseHandler.success(res, { message: 'IO blocked successfully in SAP' }, 'IO blocked');
} else if (ioNumber && ioRemark !== undefined) {
// Save IO details (ioNumber, ioRemark) even without blocking amount
// This is useful when Step 3 is approved but amount hasn't been blocked yet
// IMPORTANT: Don't pass balance fields to preserve existing values from previous blocking
await this.dealerClaimService.updateIODetails(
requestId,
{
ioNumber,
ioRemark: ioRemark || '',
// Don't pass balance fields - preserve existing values from previous blocking
// Only pass if explicitly provided and > 0 (for new records)
...(availableBalance && parseFloat(availableBalance) > 0 && { availableBalance: parseFloat(availableBalance) }),
blockedAmount: 0,
// Don't pass remainingBalance - preserve existing value from previous blocking
},
userId
);
return ResponseHandler.success(res, { message: 'IO details saved successfully' }, 'IO details saved');
} else {
// Just validate IO number without storing
// This is for validation only (fetch amount scenario)
return ResponseHandler.success(res, { message: 'IO validated successfully' }, 'IO validated');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error updating IO details:', error);
return ResponseHandler.error(res, 'Failed to update IO details', 500, errorMessage);
}
}
/**
* Update e-invoice details (Step 7)
* PUT /api/v1/dealer-claims/:requestId/e-invoice
* If eInvoiceNumber is not provided, will auto-generate via DMS
* Accepts either UUID or requestNumber
*/
async updateEInvoice(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const identifier = req.params.requestId; // Can be UUID or requestNumber
const {
eInvoiceNumber,
eInvoiceDate,
dmsNumber,
amount,
description,
} = req.body;
// Find workflow to get actual UUID
const workflow = await this.findWorkflowByIdentifier(identifier);
if (!workflow) {
return ResponseHandler.error(res, 'Workflow request not found', 404);
}
const requestId = (workflow as any).requestId || (workflow as any).request_id;
if (!requestId) {
return ResponseHandler.error(res, 'Invalid workflow request', 400);
}
// If eInvoiceNumber provided, use manual entry; otherwise auto-generate
const invoiceData = eInvoiceNumber ? {
eInvoiceNumber,
eInvoiceDate: eInvoiceDate ? new Date(eInvoiceDate) : new Date(),
dmsNumber,
} : {
amount: amount ? parseFloat(amount) : undefined,
description,
};
await this.dealerClaimService.updateEInvoiceDetails(requestId, invoiceData);
return ResponseHandler.success(res, { message: 'E-Invoice details updated successfully' }, 'E-Invoice updated');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error updating e-invoice:', error);
// Translate technical PWC/IRP error codes to user-friendly messages
const userFacingMessage = translateEInvoiceError(errorMessage);
return ResponseHandler.error(res, userFacingMessage, 500, errorMessage);
}
}
/**
* Download E-Invoice PDF
* GET /api/v1/dealer-claims/:requestId/e-invoice/pdf
*/
async downloadInvoicePdf(req: Request, res: Response): Promise<void> {
try {
const identifier = req.params.requestId; // Can be UUID or requestNumber
// Find workflow to get actual UUID
const workflow = await this.findWorkflowByIdentifier(identifier);
if (!workflow) {
return ResponseHandler.error(res, 'Workflow request not found', 404);
}
const requestId = (workflow as any).requestId || (workflow as any).request_id;
if (!requestId) {
return ResponseHandler.error(res, 'Invalid workflow request', 400);
}
const { ClaimInvoice } = await import('../models/ClaimInvoice');
let invoice = await ClaimInvoice.findOne({ where: { requestId } });
if (!invoice) {
return ResponseHandler.error(res, 'Invoice record not found', 404);
}
// Generate PDF on the fly
try {
const { pdfService } = await import('../services/pdf.service');
const pdfBuffer = await pdfService.generateInvoicePdf(requestId);
const requestNumber = workflow.requestNumber || 'invoice';
const fileName = `Invoice_${requestNumber}.pdf`;
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
res.setHeader('Content-Length', pdfBuffer.length);
// Convert Buffer to stream
const { Readable } = await import('stream');
const stream = new Readable();
stream.push(pdfBuffer);
stream.push(null);
stream.pipe(res);
} catch (pdfError) {
logger.error(`[DealerClaimController] Failed to generate PDF:`, pdfError);
return ResponseHandler.error(res, 'Failed to generate invoice PDF', 500);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error downloading invoice PDF:', error);
return ResponseHandler.error(res, 'Failed to download invoice PDF', 500, errorMessage);
}
}
/**
* Update credit note details (Step 8)
* PUT /api/v1/dealer-claims/:requestId/credit-note
* If creditNoteNumber is not provided, will auto-generate via DMS
* Accepts either UUID or requestNumber
*/
async updateCreditNote(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const identifier = req.params.requestId; // Can be UUID or requestNumber
const {
creditNoteNumber,
creditNoteDate,
creditNoteAmount,
reason,
description,
} = req.body;
// Find workflow to get actual UUID
const workflow = await this.findWorkflowByIdentifier(identifier);
if (!workflow) {
return ResponseHandler.error(res, 'Workflow request not found', 404);
}
const requestId = (workflow as any).requestId || (workflow as any).request_id;
if (!requestId) {
return ResponseHandler.error(res, 'Invalid workflow request', 400);
}
// If creditNoteNumber provided, use manual entry; otherwise auto-generate
const creditNoteData = creditNoteNumber ? {
creditNoteNumber,
creditNoteDate: creditNoteDate ? new Date(creditNoteDate) : new Date(),
creditNoteAmount: creditNoteAmount ? parseFloat(creditNoteAmount) : undefined,
} : {
creditNoteAmount: creditNoteAmount ? parseFloat(creditNoteAmount) : undefined,
reason,
description,
};
await this.dealerClaimService.updateCreditNoteDetails(requestId, creditNoteData);
return ResponseHandler.success(res, { message: 'Credit note details updated successfully' }, 'Credit note updated');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error updating credit note:', error);
return ResponseHandler.error(res, 'Failed to update credit note details', 500, errorMessage);
}
}
/**
* Send credit note to dealer and auto-approve Step 8
* POST /api/v1/dealer-claims/:requestId/credit-note/send
* Accepts either UUID or requestNumber
*/
async sendCreditNoteToDealer(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const identifier = req.params.requestId; // Can be UUID or requestNumber
const userId = req.user?.userId;
if (!userId) {
return ResponseHandler.error(res, 'Unauthorized', 401);
}
// Find workflow to get actual UUID
const workflow = await this.findWorkflowByIdentifier(identifier);
if (!workflow) {
return ResponseHandler.error(res, 'Workflow request not found', 404);
}
const requestId = (workflow as any).requestId || (workflow as any).request_id;
if (!requestId) {
return ResponseHandler.error(res, 'Invalid workflow request', 400);
}
await this.dealerClaimService.sendCreditNoteToDealer(requestId, userId);
return ResponseHandler.success(res, { message: 'Credit note sent to dealer and Step 8 approved successfully' }, 'Credit note sent');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error sending credit note to dealer:', error);
return ResponseHandler.error(res, 'Failed to send credit note to dealer', 500, errorMessage);
}
}
/**
* Test SAP Budget Blocking (for testing/debugging)
* POST /api/v1/dealer-claims/test/sap-block
*
* This endpoint allows direct testing of SAP budget blocking without creating a full request
*/
async testSapBudgetBlock(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
return ResponseHandler.error(res, 'Unauthorized', 401);
}
const { ioNumber, amount, requestNumber } = req.body;
// Validation
if (!ioNumber || !amount) {
return ResponseHandler.error(res, 'Missing required fields: ioNumber and amount are required', 400);
}
const blockAmount = parseFloat(amount);
if (isNaN(blockAmount) || blockAmount <= 0) {
return ResponseHandler.error(res, 'Amount must be a positive number', 400);
}
logger.info(`[DealerClaimController] Testing SAP budget block:`, {
ioNumber,
amount: blockAmount,
requestNumber: requestNumber || 'TEST-REQUEST',
userId
});
// First validate IO number
const ioValidation = await sapIntegrationService.validateIONumber(ioNumber);
if (!ioValidation.isValid) {
return ResponseHandler.error(res, `Invalid IO number: ${ioValidation.error || 'IO number not found in SAP'}`, 400);
}
logger.info(`[DealerClaimController] IO validation successful:`, {
ioNumber,
availableBalance: ioValidation.availableBalance
});
// Block budget in SAP
const testRequestNumber = requestNumber || `TEST-${Date.now()}`;
const blockResult = await sapIntegrationService.blockBudget(
ioNumber,
blockAmount,
testRequestNumber,
`Test budget block for ${testRequestNumber}`
);
if (!blockResult.success) {
return ResponseHandler.error(res, `Failed to block budget in SAP: ${blockResult.error}`, 500);
}
// Return detailed response
return ResponseHandler.success(res, {
message: 'SAP budget block test successful',
ioNumber,
requestedAmount: blockAmount,
availableBalance: ioValidation.availableBalance,
sapResponse: {
success: blockResult.success,
blockedAmount: blockResult.blockedAmount,
remainingBalance: blockResult.remainingBalance,
sapDocumentNumber: blockResult.blockId || null,
error: blockResult.error || null
},
calculatedRemainingBalance: ioValidation.availableBalance - blockResult.blockedAmount,
validation: {
isValid: ioValidation.isValid,
availableBalance: ioValidation.availableBalance,
error: ioValidation.error || null
}
}, 'SAP budget block test completed');
} catch (error: any) {
logger.error('[DealerClaimController] Error testing SAP budget block:', error);
return ResponseHandler.error(res, error.message || 'Failed to test SAP budget block', 500);
}
}
/**
* Download Invoice CSV
* GET /api/v1/dealer-claims/:requestId/e-invoice/csv
*/
async downloadInvoiceCsv(req: Request, res: Response): Promise<void> {
try {
const identifier = req.params.requestId;
// Use helper to find workflow
const workflow = await this.findWorkflowByIdentifier(identifier);
if (!workflow) {
return ResponseHandler.error(res, 'Workflow request not found', 404);
}
const requestId = (workflow as any).requestId || (workflow as any).request_id;
const requestNumber = (workflow as any).requestNumber || (workflow as any).request_number;
// Fetch related data
logger.info(`[DealerClaimController] Preparing CSV for requestId: ${requestId}`);
const [invoice, items, claimDetails, internalOrder] = await Promise.all([
ClaimInvoice.findOne({ where: { requestId } }),
ClaimInvoiceItem.findAll({ where: { requestId }, order: [['slNo', 'ASC']] }),
DealerClaimDetails.findOne({ where: { requestId } }),
InternalOrder.findOne({ where: { requestId } })
]);
logger.info(`[DealerClaimController] Found ${items.length} items to export for request ${requestNumber}`);
let sapRefNo = '';
let taxationType = 'GST';
if (claimDetails?.activityType) {
const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } });
sapRefNo = activity?.sapRefNo || '';
taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST');
}
// Construct CSV
const headers = [
'TRNS_UNIQ_NO',
'CLAIM_NUMBER',
'INV_NUMBER',
'DEALER_CODE',
'IO_NUMBER',
'CLAIM_DOC_TYP',
'CLAIM_DATE',
'CLAIM_AMT',
'GST_AMT',
'GST_PERCENTAG'
];
const rows = items.map(item => {
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
// For Non-GST, we hide HSN (often stored in transactionCode) and GST details
const trnsUniqNo = isNonGst ? '' : (item.transactionCode || '');
const claimNumber = requestNumber;
const invNumber = invoice?.invoiceNumber || '';
const dealerCode = claimDetails?.dealerCode || '';
const ioNumber = internalOrder?.ioNumber || '';
const claimDocTyp = sapRefNo;
const claimDate = invoice?.createdAt ? new Date(invoice.createdAt).toISOString().split('T')[0] : '';
const claimAmt = item.assAmt;
// Zero out tax for Non-GST
const totalTax = isNonGst ? 0 : (Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0));
const gstPercentag = isNonGst ? 0 : (item.gstRt || 0);
return [
trnsUniqNo,
claimNumber,
invNumber,
dealerCode,
ioNumber,
claimDocTyp,
claimDate,
claimAmt,
totalTax.toFixed(2),
gstPercentag
].join(',');
});
const csvContent = [headers.join(','), ...rows].join('\n');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="Invoice_${requestNumber}.csv"`);
res.status(200).send(csvContent);
return;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error downloading invoice CSV:', error);
return ResponseHandler.error(res, 'Failed to download invoice CSV', 500, errorMessage);
}
}
}