import { Request, Response } from 'express'; import type { AuthenticatedRequest } from '../types/express'; import { DealerClaimService } from '../services/dealerClaim.service'; import { ResponseHandler } from '../utils/responseHandler'; import logger from '../utils/logger'; import { gcsStorageService } from '../services/gcsStorage.service'; import { Document } from '../models/Document'; import { constants } from '../config/constants'; import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; 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, } = 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, }); return ResponseHandler.success(res, { request: claimRequest, message: 'Claim request created successfully' }, 'Claim request created'); } catch (error) { 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, } = 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, }); 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); } } /** * Update IO details (Step 3 - Department Lead) * PUT /api/v1/dealer-claims/:requestId/io * 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 || availableBalance === undefined || blockedAmount === undefined) { return ResponseHandler.error(res, 'Missing required IO fields', 400); } await this.dealerClaimService.updateIODetails( requestId, { ioNumber, ioRemark: ioRemark || '', availableBalance: parseFloat(availableBalance), blockedAmount: parseFloat(blockedAmount), remainingBalance: remainingBalance ? parseFloat(remainingBalance) : parseFloat(availableBalance) - parseFloat(blockedAmount), }, userId ); return ResponseHandler.success(res, { message: 'IO details updated successfully' }, 'IO details updated'); } 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); return ResponseHandler.error(res, 'Failed to update e-invoice details', 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); } } }