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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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); } } }