Re_Backend/src/controllers/dealerClaim.controller.ts

693 lines
26 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 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<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,
} = 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<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,
} = 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<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 || 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<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);
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<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);
}
}
}