1086 lines
42 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|