backednd db enhanced table for audit point dedicated table for buget tracking and io

This commit is contained in:
laxmanhalaki 2025-12-11 20:21:36 +05:30
parent ad18ec54e9
commit 031c490de1
22 changed files with 1639 additions and 411 deletions

507
docs/ERD.mermaid Normal file
View File

@ -0,0 +1,507 @@
erDiagram
users ||--o{ workflow_requests : initiates
users ||--o{ approval_levels : approves
users ||--o{ participants : participates
users ||--o{ work_notes : posts
users ||--o{ documents : uploads
users ||--o{ activities : performs
users ||--o{ notifications : receives
users ||--o{ user_sessions : has
workflow_requests ||--|{ approval_levels : has
workflow_requests ||--o{ participants : involves
workflow_requests ||--o{ documents : contains
workflow_requests ||--o{ work_notes : has
workflow_requests ||--o{ activities : logs
workflow_requests ||--o{ tat_tracking : monitors
workflow_requests ||--o{ notifications : triggers
workflow_requests ||--|| conclusion_remarks : concludes
workflow_requests ||--|| dealer_claim_details : claim_details
workflow_requests ||--|| dealer_proposal_details : proposal_details
dealer_proposal_details ||--o{ dealer_proposal_cost_items : cost_items
workflow_requests ||--|| dealer_completion_details : completion_details
workflow_requests ||--|| internal_orders : internal_order
workflow_requests ||--|| claim_budget_tracking : budget_tracking
workflow_requests ||--|| claim_invoices : claim_invoice
workflow_requests ||--|| claim_credit_notes : claim_credit_note
work_notes ||--o{ work_note_attachments : has
notifications ||--o{ email_logs : sends
notifications ||--o{ sms_logs : sends
workflow_requests ||--o{ report_cache : caches
workflow_requests ||--o{ audit_logs : audits
workflow_requests ||--o{ workflow_templates : templates
users ||--o{ system_settings : updates
users {
uuid user_id PK
varchar employee_id
varchar okta_sub
varchar email
varchar first_name
varchar last_name
varchar display_name
varchar department
varchar designation
varchar phone
varchar manager
varchar second_email
text job_title
varchar employee_number
varchar postal_address
varchar mobile_phone
jsonb ad_groups
jsonb location
boolean is_active
enum role
timestamp last_login
timestamp created_at
timestamp updated_at
}
workflow_requests {
uuid request_id PK
varchar request_number
uuid initiator_id FK
varchar template_type
varchar title
text description
enum priority
enum status
integer current_level
integer total_levels
decimal total_tat_hours
timestamp submission_date
timestamp closure_date
text conclusion_remark
text ai_generated_conclusion
boolean is_draft
boolean is_deleted
timestamp created_at
timestamp updated_at
}
approval_levels {
uuid level_id PK
uuid request_id FK
integer level_number
varchar level_name
uuid approver_id FK
varchar approver_email
varchar approver_name
decimal tat_hours
integer tat_days
enum status
timestamp level_start_time
timestamp level_end_time
timestamp action_date
text comments
text rejection_reason
boolean is_final_approver
decimal elapsed_hours
decimal remaining_hours
decimal tat_percentage_used
timestamp created_at
timestamp updated_at
}
participants {
uuid participant_id PK
uuid request_id FK
uuid user_id FK
varchar user_email
varchar user_name
enum participant_type
boolean can_comment
boolean can_view_documents
boolean can_download_documents
boolean notification_enabled
uuid added_by FK
timestamp added_at
boolean is_active
}
documents {
uuid document_id PK
uuid request_id FK
uuid uploaded_by FK
varchar file_name
varchar original_file_name
varchar file_type
varchar file_extension
bigint file_size
varchar file_path
varchar storage_url
varchar mime_type
varchar checksum
boolean is_google_doc
varchar google_doc_url
enum category
integer version
uuid parent_document_id
boolean is_deleted
integer download_count
timestamp uploaded_at
}
work_notes {
uuid note_id PK
uuid request_id FK
uuid user_id FK
varchar user_name
varchar user_role
text message
varchar message_type
boolean is_priority
boolean has_attachment
uuid parent_note_id
uuid[] mentioned_users
jsonb reactions
boolean is_edited
boolean is_deleted
timestamp created_at
timestamp updated_at
}
work_note_attachments {
uuid attachment_id PK
uuid note_id FK
varchar file_name
varchar file_type
bigint file_size
varchar file_path
varchar storage_url
boolean is_downloadable
integer download_count
timestamp uploaded_at
}
activities {
uuid activity_id PK
uuid request_id FK
uuid user_id FK
varchar user_name
varchar activity_type
text activity_description
varchar activity_category
varchar severity
jsonb metadata
boolean is_system_event
varchar ip_address
text user_agent
timestamp created_at
}
notifications {
uuid notification_id PK
uuid user_id FK
uuid request_id FK
varchar notification_type
varchar title
text message
boolean is_read
enum priority
varchar action_url
boolean action_required
jsonb metadata
varchar[] sent_via
boolean email_sent
boolean sms_sent
boolean push_sent
timestamp read_at
timestamp expires_at
timestamp created_at
}
tat_tracking {
uuid tracking_id PK
uuid request_id FK
uuid level_id FK
varchar tracking_type
enum tat_status
decimal total_tat_hours
decimal elapsed_hours
decimal remaining_hours
decimal percentage_used
boolean threshold_50_breached
timestamp threshold_50_alerted_at
boolean threshold_80_breached
timestamp threshold_80_alerted_at
boolean threshold_100_breached
timestamp threshold_100_alerted_at
integer alert_count
timestamp last_calculated_at
}
conclusion_remarks {
uuid conclusion_id PK
uuid request_id FK
text ai_generated_remark
varchar ai_model_used
decimal ai_confidence_score
text final_remark
uuid edited_by FK
boolean is_edited
integer edit_count
jsonb approval_summary
jsonb document_summary
text[] key_discussion_points
timestamp generated_at
timestamp finalized_at
}
audit_logs {
uuid audit_id PK
uuid user_id FK
varchar entity_type
uuid entity_id
varchar action
varchar action_category
jsonb old_values
jsonb new_values
text changes_summary
varchar ip_address
text user_agent
varchar session_id
varchar request_method
varchar request_url
integer response_status
integer execution_time_ms
timestamp created_at
}
user_sessions {
uuid session_id PK
uuid user_id FK
varchar session_token
varchar refresh_token
varchar ip_address
text user_agent
varchar device_type
varchar browser
varchar os
timestamp login_at
timestamp last_activity_at
timestamp logout_at
timestamp expires_at
boolean is_active
varchar logout_reason
}
email_logs {
uuid email_log_id PK
uuid request_id FK
uuid notification_id FK
varchar recipient_email
uuid recipient_user_id FK
text[] cc_emails
text[] bcc_emails
varchar subject
text body
varchar email_type
varchar status
integer send_attempts
timestamp sent_at
timestamp failed_at
text failure_reason
timestamp opened_at
timestamp clicked_at
timestamp created_at
}
sms_logs {
uuid sms_log_id PK
uuid request_id FK
uuid notification_id FK
varchar recipient_phone
uuid recipient_user_id FK
text message
varchar sms_type
varchar status
integer send_attempts
timestamp sent_at
timestamp delivered_at
timestamp failed_at
text failure_reason
varchar sms_provider
varchar sms_provider_message_id
decimal cost
timestamp created_at
}
system_settings {
uuid setting_id PK
varchar setting_key
text setting_value
varchar setting_type
varchar setting_category
text description
boolean is_editable
boolean is_sensitive
jsonb validation_rules
text default_value
uuid updated_by FK
timestamp created_at
timestamp updated_at
}
workflow_templates {
uuid template_id PK
varchar template_name
text template_description
varchar template_category
jsonb approval_levels_config
decimal default_tat_hours
boolean is_active
integer usage_count
uuid created_by FK
timestamp created_at
timestamp updated_at
}
report_cache {
uuid cache_id PK
varchar report_type
jsonb report_params
jsonb report_data
uuid generated_by FK
timestamp generated_at
timestamp expires_at
integer access_count
timestamp last_accessed_at
}
dealer_claim_details {
uuid claim_id PK
uuid request_id
varchar activity_name
varchar activity_type
varchar dealer_code
varchar dealer_name
varchar dealer_email
varchar dealer_phone
text dealer_address
date activity_date
varchar location
date period_start_date
date period_end_date
timestamp created_at
timestamp updated_at
}
dealer_proposal_details {
uuid proposal_id PK
uuid request_id
string proposal_document_path
string proposal_document_url
decimal total_estimated_budget
string timeline_mode
date expected_completion_date
int expected_completion_days
text dealer_comments
date submitted_at
timestamp created_at
timestamp updated_at
}
dealer_proposal_cost_items {
uuid cost_item_id PK
uuid proposal_id FK
uuid request_id FK
string item_description
decimal amount
int item_order
timestamp created_at
timestamp updated_at
}
dealer_completion_details {
uuid completion_id PK
uuid request_id
date activity_completion_date
int number_of_participants
decimal total_closed_expenses
date submitted_at
timestamp created_at
timestamp updated_at
}
dealer_completion_expenses {
uuid expense_id PK
uuid request_id
uuid completion_id
string description
decimal amount
timestamp created_at
timestamp updated_at
}
internal_orders {
uuid io_id PK
uuid request_id
string io_number
text io_remark
decimal io_available_balance
decimal io_blocked_amount
decimal io_remaining_balance
uuid organized_by FK
date organized_at
string sap_document_number
enum status
timestamp created_at
timestamp updated_at
}
claim_budget_tracking {
uuid budget_id PK
uuid request_id
decimal initial_estimated_budget
decimal proposal_estimated_budget
date proposal_submitted_at
decimal approved_budget
date approved_at
uuid approved_by FK
decimal io_blocked_amount
date io_blocked_at
decimal closed_expenses
date closed_expenses_submitted_at
decimal final_claim_amount
date final_claim_amount_approved_at
uuid final_claim_amount_approved_by FK
decimal credit_note_amount
date credit_note_issued_at
enum budget_status
string currency
decimal variance_amount
decimal variance_percentage
uuid last_modified_by FK
date last_modified_at
text modification_reason
timestamp created_at
timestamp updated_at
}
claim_invoices {
uuid invoice_id PK
uuid request_id
string invoice_number
date invoice_date
string dms_number
decimal amount
string status
text description
timestamp created_at
timestamp updated_at
}
claim_credit_notes {
uuid credit_note_id PK
uuid request_id
string credit_note_number
date credit_note_date
decimal credit_note_amount
string status
text reason
text description
timestamp created_at
timestamp updated_at
}

View File

@ -5,7 +5,7 @@
"main": "dist/server.js",
"scripts": {
"start": "npm run setup && npm run build && npm run start:prod",
"dev": "npm run setup && nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
"dev": "npm run setup && npm run migrate && nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
"dev:no-setup": "nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
"build": "tsc && tsc-alias",
"build:watch": "tsc --watch",

View File

@ -66,6 +66,8 @@ export const constants = {
REFERENCE: 'REFERENCE',
FINAL: 'FINAL',
OTHER: 'OTHER',
COMPLETION_DOC: 'COMPLETION_DOC',
ACTIVITY_PHOTO: 'ACTIVITY_PHOTO',
},
// Work Note Types

View File

@ -782,15 +782,15 @@ export const assignRoleByEmail = async (req: Request, res: Response): Promise<vo
// User doesn't exist, need to fetch from Okta and create
logger.info(`[Admin] User ${email} not found in database, fetching from Okta...`);
// Import UserService to search Okta
// Import UserService to fetch full profile from Okta
const { UserService } = await import('@services/user.service');
const userService = new UserService();
try {
// Search Okta for this user
const oktaUsers = await userService.searchUsers(email, 1);
// Fetch full user profile from Okta Users API (includes manager, jobTitle, etc.)
const oktaUserData = await userService.fetchAndExtractOktaUserByEmail(email);
if (!oktaUsers || oktaUsers.length === 0) {
if (!oktaUserData) {
res.status(404).json({
success: false,
error: 'User not found in Okta. Please ensure the email is correct.'
@ -798,25 +798,15 @@ export const assignRoleByEmail = async (req: Request, res: Response): Promise<vo
return;
}
const oktaUser = oktaUsers[0];
// Create user in our database
user = await User.create({
email: oktaUser.email,
oktaSub: (oktaUser as any).userId || (oktaUser as any).oktaSub, // Okta user ID as oktaSub
employeeId: (oktaUser as any).employeeNumber || (oktaUser as any).employeeId || null,
firstName: oktaUser.firstName || null,
lastName: oktaUser.lastName || null,
displayName: oktaUser.displayName || `${oktaUser.firstName || ''} ${oktaUser.lastName || ''}`.trim() || oktaUser.email,
department: oktaUser.department || null,
designation: (oktaUser as any).designation || (oktaUser as any).title || null,
phone: (oktaUser as any).phone || (oktaUser as any).mobilePhone || null,
isActive: true,
role: role, // Assign the requested role
lastLogin: undefined // Not logged in yet
// Create user in our database via centralized userService with all fields including manager
const ensured = await userService.createOrUpdateUser({
...oktaUserData,
role, // Set the assigned role
isActive: true, // Ensure user is active
});
user = ensured;
logger.info(`[Admin] Created new user ${email} with role ${role}`);
logger.info(`[Admin] Created new user ${email} with role ${role} (manager: ${oktaUserData.manager || 'N/A'})`);
} catch (oktaError: any) {
logger.error('[Admin] Error fetching from Okta:', oktaError);
res.status(500).json({
@ -826,7 +816,7 @@ export const assignRoleByEmail = async (req: Request, res: Response): Promise<vo
return;
}
} else {
// User exists, update their role
// User exists - fetch latest data from Okta and sync all fields including role
const previousRole = user.role;
// Prevent self-demotion
@ -838,9 +828,35 @@ export const assignRoleByEmail = async (req: Request, res: Response): Promise<vo
return;
}
await user.update({ role });
// Import UserService to fetch latest data from Okta
const { UserService } = await import('@services/user.service');
const userService = new UserService();
logger.info(`[Admin] Updated user ${email} role from ${previousRole} to ${role}`);
try {
// Fetch full user profile from Okta Users API to sync manager and other fields
const oktaUserData = await userService.fetchAndExtractOktaUserByEmail(email);
if (oktaUserData) {
// Sync all fields from Okta including the new role using centralized method
const updated = await userService.createOrUpdateUser({
...oktaUserData, // Includes all fields: manager, jobTitle, postalAddress, etc.
role, // Set the new role
isActive: true, // Ensure user is active
});
user = updated;
logger.info(`[Admin] Synced user ${email} from Okta (manager: ${oktaUserData.manager || 'N/A'}) and updated role from ${previousRole} to ${role}`);
} else {
// Okta user not found, just update role
await user.update({ role });
logger.info(`[Admin] Updated user ${email} role from ${previousRole} to ${role} (Okta data not available)`);
}
} catch (oktaError: any) {
// If Okta fetch fails, just update the role
logger.warn(`[Admin] Failed to fetch Okta data for ${email}, updating role only:`, oktaError.message);
await user.update({ role });
logger.info(`[Admin] Updated user ${email} role from ${previousRole} to ${role} (Okta sync failed)`);
}
}
res.json({

View File

@ -5,7 +5,7 @@ import { ResponseHandler } from '../utils/responseHandler';
import logger from '../utils/logger';
import { gcsStorageService } from '../services/gcsStorage.service';
import { Document } from '../models/Document';
import { User } from '../models/User';
import { constants } from '../config/constants';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
@ -201,38 +201,6 @@ export class DealerClaimController {
proposalDocumentPath = uploadResult.filePath;
proposalDocumentUrl = uploadResult.storageUrl;
// Save to documents table with category APPROVAL (proposal document)
try {
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const extension = path.extname(file.originalname).replace('.', '').toLowerCase();
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: 'APPROVAL', // Proposal document is an approval document
version: 1,
parentDocumentId: null as any,
isDeleted: false,
downloadCount: 0,
} as any);
logger.info(`[DealerClaimController] Created document entry for proposal document ${file.originalname}`);
} catch (docError) {
logger.error(`[DealerClaimController] Error creating document entry for proposal document:`, docError);
// Don't fail the entire request if document entry creation fails
}
// Cleanup local file if exists
if (file.path && fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
@ -304,31 +272,37 @@ export class DealerClaimController {
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);
}
const userId = req.user?.userId;
if (!userId) {
return ResponseHandler.error(res, 'Unauthorized', 401);
}
// Upload files to GCS and get URLs, and save to documents table
// Upload files to GCS and save to documents table
const completionDocuments: any[] = [];
const activityPhotos: any[] = [];
// Helper function to create document entry
const createDocumentEntry = async (
file: Express.Multer.File,
uploadResult: { storageUrl: string; filePath: string },
category: 'APPROVAL' | 'SUPPORTING' | 'REFERENCE' | 'FINAL' | 'OTHER'
): Promise<void> => {
// 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();
await Document.create({
// Save to documents table
const doc = await Document.create({
requestId,
uploadedBy: userId,
fileName: path.basename(file.filename || file.originalname),
@ -342,109 +316,208 @@ export class DealerClaimController {
checksum,
isGoogleDoc: false,
googleDocUrl: null as any,
category,
category: constants.DOCUMENT_CATEGORIES.COMPLETION_DOC,
version: 1,
parentDocumentId: null as any,
isDeleted: false,
downloadCount: 0,
} as any);
logger.info(`[DealerClaimController] Created document entry for ${file.originalname} with category ${category}`);
} catch (docError) {
logger.error(`[DealerClaimController] Error creating document entry for ${file.originalname}:`, docError);
// Don't fail the entire request if document entry creation fails
}
};
// Upload completion documents (category: APPROVAL - these are proof of completion)
for (const file of completionDocumentsFiles) {
try {
const uploadResult = await gcsStorageService.uploadFileWithFallback({
buffer: file.buffer,
originalName: file.originalname,
mimeType: file.mimetype,
requestNumber: requestNumber,
fileType: 'documents'
});
completionDocuments.push({
documentId: doc.documentId,
name: file.originalname,
url: uploadResult.storageUrl,
size: file.size,
type: file.mimetype,
});
// Save to documents table
await createDocumentEntry(file, uploadResult, 'APPROVAL');
// 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 activity photos (category: SUPPORTING - supporting evidence)
// 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: file.buffer,
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,
});
// Save to documents table
await createDocumentEntry(file, uploadResult, 'SUPPORTING');
// 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 invoices/receipts if provided (category: SUPPORTING - supporting financial documents)
// 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: file.buffer,
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,
});
// Save to documents table
await createDocumentEntry(file, uploadResult, 'SUPPORTING');
// 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 attendance sheet if provided (category: SUPPORTING - supporting evidence)
// 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: attendanceSheetFile.buffer,
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,
};
// Save to documents table
await createDocumentEntry(attendanceSheetFile, uploadResult, 'SUPPORTING');
// 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);
}
@ -455,8 +528,6 @@ export class DealerClaimController {
numberOfParticipants: numberOfParticipants ? parseInt(numberOfParticipants) : undefined,
closedExpenses: parsedClosedExpenses,
totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0,
completionDocuments,
activityPhotos,
invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined,
attendanceSheet: attendanceSheet || undefined,
});

View File

@ -4,7 +4,7 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.sequelize.query(`DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_document_category') THEN
CREATE TYPE enum_document_category AS ENUM ('SUPPORTING','APPROVAL','REFERENCE','FINAL','OTHER');
CREATE TYPE enum_document_category AS ENUM ('SUPPORTING','APPROVAL','REFERENCE','FINAL','OTHER','COMPLETION_DOC','ACTIVITY_PHOTO');
END IF;
END$$;`);

View File

@ -63,58 +63,6 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
type: DataTypes.DATEONLY,
allowNull: true
},
estimated_budget: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true
},
closed_expenses: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true
},
io_number: {
type: DataTypes.STRING(50),
allowNull: true
},
io_available_balance: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true
},
io_blocked_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true
},
io_remaining_balance: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true
},
sap_document_number: {
type: DataTypes.STRING(100),
allowNull: true
},
dms_number: {
type: DataTypes.STRING(100),
allowNull: true
},
e_invoice_number: {
type: DataTypes.STRING(100),
allowNull: true
},
e_invoice_date: {
type: DataTypes.DATEONLY,
allowNull: true
},
credit_note_number: {
type: DataTypes.STRING(100),
allowNull: true
},
credit_note_date: {
type: DataTypes.DATEONLY,
allowNull: true
},
credit_note_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
@ -165,10 +113,6 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
type: DataTypes.STRING(500),
allowNull: true
},
cost_breakup: {
type: DataTypes.JSONB,
allowNull: true
},
total_estimated_budget: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true
@ -236,22 +180,10 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
type: DataTypes.INTEGER,
allowNull: true
},
closed_expenses: {
type: DataTypes.JSONB,
allowNull: true
},
total_closed_expenses: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true
},
completion_documents: {
type: DataTypes.JSONB,
allowNull: true
},
activity_photos: {
type: DataTypes.JSONB,
allowNull: true
},
submitted_at: {
type: DataTypes.DATE,
allowNull: true

View File

@ -0,0 +1,116 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.createTable('claim_invoices', {
invoice_id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4,
},
request_id: {
type: DataTypes.UUID,
allowNull: false,
unique: true, // one invoice per request (adjust later if multiples needed)
references: { model: 'workflow_requests', key: 'request_id' },
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
invoice_number: {
type: DataTypes.STRING(100),
allowNull: true,
},
invoice_date: {
type: DataTypes.DATEONLY,
allowNull: true,
},
dms_number: {
type: DataTypes.STRING(100),
allowNull: true,
},
amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
},
status: {
type: DataTypes.STRING(50), // e.g., PENDING, GENERATED, SENT, FAILED, CANCELLED
allowNull: true,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
await queryInterface.addIndex('claim_invoices', ['request_id'], { name: 'idx_claim_invoices_request_id', unique: true });
await queryInterface.addIndex('claim_invoices', ['invoice_number'], { name: 'idx_claim_invoices_invoice_number' });
await queryInterface.addIndex('claim_invoices', ['dms_number'], { name: 'idx_claim_invoices_dms_number' });
await queryInterface.createTable('claim_credit_notes', {
credit_note_id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4,
},
request_id: {
type: DataTypes.UUID,
allowNull: false,
unique: true, // one credit note per request (adjust later if multiples needed)
references: { model: 'workflow_requests', key: 'request_id' },
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
credit_note_number: {
type: DataTypes.STRING(100),
allowNull: true,
},
credit_note_date: {
type: DataTypes.DATEONLY,
allowNull: true,
},
credit_note_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
},
status: {
type: DataTypes.STRING(50), // e.g., PENDING, GENERATED, SENT, FAILED, CANCELLED
allowNull: true,
},
reason: {
type: DataTypes.TEXT,
allowNull: true,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
await queryInterface.addIndex('claim_credit_notes', ['request_id'], { name: 'idx_claim_credit_notes_request_id', unique: true });
await queryInterface.addIndex('claim_credit_notes', ['credit_note_number'], { name: 'idx_claim_credit_notes_number' });
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.dropTable('claim_credit_notes');
await queryInterface.dropTable('claim_invoices');
}

View File

@ -0,0 +1,38 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.removeColumn('dealer_claim_details', 'dms_number');
await queryInterface.removeColumn('dealer_claim_details', 'e_invoice_number');
await queryInterface.removeColumn('dealer_claim_details', 'e_invoice_date');
await queryInterface.removeColumn('dealer_claim_details', 'credit_note_number');
await queryInterface.removeColumn('dealer_claim_details', 'credit_note_date');
await queryInterface.removeColumn('dealer_claim_details', 'credit_note_amount');
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.addColumn('dealer_claim_details', 'dms_number', {
type: DataTypes.STRING(100),
allowNull: true,
});
await queryInterface.addColumn('dealer_claim_details', 'e_invoice_number', {
type: DataTypes.STRING(100),
allowNull: true,
});
await queryInterface.addColumn('dealer_claim_details', 'e_invoice_date', {
type: DataTypes.DATEONLY,
allowNull: true,
});
await queryInterface.addColumn('dealer_claim_details', 'credit_note_number', {
type: DataTypes.STRING(100),
allowNull: true,
});
await queryInterface.addColumn('dealer_claim_details', 'credit_note_date', {
type: DataTypes.DATEONLY,
allowNull: true,
});
await queryInterface.addColumn('dealer_claim_details', 'credit_note_amount', {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
});
}

View File

@ -0,0 +1,55 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.createTable('dealer_completion_expenses', {
expense_id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4,
},
request_id: {
type: DataTypes.UUID,
allowNull: false,
references: { model: 'workflow_requests', key: 'request_id' },
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
completion_id: {
type: DataTypes.UUID,
allowNull: true,
references: { model: 'dealer_completion_details', key: 'completion_id' },
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
description: {
type: DataTypes.STRING(500),
allowNull: false,
},
amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
await queryInterface.addIndex('dealer_completion_expenses', ['request_id'], {
name: 'idx_dealer_completion_expenses_request_id',
});
await queryInterface.addIndex('dealer_completion_expenses', ['completion_id'], {
name: 'idx_dealer_completion_expenses_completion_id',
});
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.dropTable('dealer_completion_expenses');
}

View File

@ -0,0 +1,123 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { WorkflowRequest } from './WorkflowRequest';
interface ClaimCreditNoteAttributes {
creditNoteId: string;
requestId: string;
creditNoteNumber?: string;
creditNoteDate?: Date;
creditNoteAmount?: number;
status?: string;
reason?: string;
description?: string;
createdAt: Date;
updatedAt: Date;
}
interface ClaimCreditNoteCreationAttributes extends Optional<ClaimCreditNoteAttributes, 'creditNoteId' | 'creditNoteNumber' | 'creditNoteDate' | 'creditNoteAmount' | 'status' | 'reason' | 'description' | 'createdAt' | 'updatedAt'> {}
class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCreationAttributes> implements ClaimCreditNoteAttributes {
public creditNoteId!: string;
public requestId!: string;
public creditNoteNumber?: string;
public creditNoteDate?: Date;
public creditNoteAmount?: number;
public status?: string;
public reason?: string;
public description?: string;
public createdAt!: Date;
public updatedAt!: Date;
}
ClaimCreditNote.init(
{
creditNoteId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
field: 'credit_note_id',
},
requestId: {
type: DataTypes.UUID,
allowNull: false,
unique: true,
field: 'request_id',
references: {
model: 'workflow_requests',
key: 'request_id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
creditNoteNumber: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'credit_note_number',
},
creditNoteDate: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'credit_note_date',
},
creditNoteAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'credit_note_amount',
},
status: {
type: DataTypes.STRING(50),
allowNull: true,
field: 'status',
},
reason: {
type: DataTypes.TEXT,
allowNull: true,
field: 'reason',
},
description: {
type: DataTypes.TEXT,
allowNull: true,
field: 'description',
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'created_at',
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'updated_at',
},
},
{
sequelize,
modelName: 'ClaimCreditNote',
tableName: 'claim_credit_notes',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
{ unique: true, fields: ['request_id'], name: 'idx_claim_credit_notes_request_id' },
{ fields: ['credit_note_number'], name: 'idx_claim_credit_notes_number' },
],
}
);
WorkflowRequest.hasOne(ClaimCreditNote, {
as: 'claimCreditNote',
foreignKey: 'requestId',
sourceKey: 'requestId',
});
ClaimCreditNote.belongsTo(WorkflowRequest, {
as: 'workflowRequest',
foreignKey: 'requestId',
targetKey: 'requestId',
});
export { ClaimCreditNote };

124
src/models/ClaimInvoice.ts Normal file
View File

@ -0,0 +1,124 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { WorkflowRequest } from './WorkflowRequest';
interface ClaimInvoiceAttributes {
invoiceId: string;
requestId: string;
invoiceNumber?: string;
invoiceDate?: Date;
dmsNumber?: string;
amount?: number;
status?: string;
description?: string;
createdAt: Date;
updatedAt: Date;
}
interface ClaimInvoiceCreationAttributes extends Optional<ClaimInvoiceAttributes, 'invoiceId' | 'invoiceNumber' | 'invoiceDate' | 'dmsNumber' | 'amount' | 'status' | 'description' | 'createdAt' | 'updatedAt'> {}
class ClaimInvoice extends Model<ClaimInvoiceAttributes, ClaimInvoiceCreationAttributes> implements ClaimInvoiceAttributes {
public invoiceId!: string;
public requestId!: string;
public invoiceNumber?: string;
public invoiceDate?: Date;
public dmsNumber?: string;
public amount?: number;
public status?: string;
public description?: string;
public createdAt!: Date;
public updatedAt!: Date;
}
ClaimInvoice.init(
{
invoiceId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
field: 'invoice_id',
},
requestId: {
type: DataTypes.UUID,
allowNull: false,
unique: true,
field: 'request_id',
references: {
model: 'workflow_requests',
key: 'request_id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
invoiceNumber: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'invoice_number',
},
invoiceDate: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'invoice_date',
},
dmsNumber: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'dms_number',
},
amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'amount',
},
status: {
type: DataTypes.STRING(50),
allowNull: true,
field: 'status',
},
description: {
type: DataTypes.TEXT,
allowNull: true,
field: 'description',
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'created_at',
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'updated_at',
},
},
{
sequelize,
modelName: 'ClaimInvoice',
tableName: 'claim_invoices',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
{ unique: true, fields: ['request_id'], name: 'idx_claim_invoices_request_id' },
{ fields: ['invoice_number'], name: 'idx_claim_invoices_invoice_number' },
{ fields: ['dms_number'], name: 'idx_claim_invoices_dms_number' },
],
}
);
WorkflowRequest.hasOne(ClaimInvoice, {
as: 'claimInvoice',
foreignKey: 'requestId',
sourceKey: 'requestId',
});
ClaimInvoice.belongsTo(WorkflowRequest, {
as: 'workflowRequest',
foreignKey: 'requestId',
targetKey: 'requestId',
});
export { ClaimInvoice };

View File

@ -16,25 +16,11 @@ interface DealerClaimDetailsAttributes {
location?: string;
periodStartDate?: Date;
periodEndDate?: Date;
estimatedBudget?: number;
closedExpenses?: number;
ioNumber?: string;
// Note: ioRemark moved to internal_orders table
ioAvailableBalance?: number;
ioBlockedAmount?: number;
ioRemainingBalance?: number;
sapDocumentNumber?: string;
dmsNumber?: string;
eInvoiceNumber?: string;
eInvoiceDate?: Date;
creditNoteNumber?: string;
creditNoteDate?: Date;
creditNoteAmount?: number;
createdAt: Date;
updatedAt: Date;
}
interface DealerClaimDetailsCreationAttributes extends Optional<DealerClaimDetailsAttributes, 'claimId' | 'dealerEmail' | 'dealerPhone' | 'dealerAddress' | 'activityDate' | 'location' | 'periodStartDate' | 'periodEndDate' | 'estimatedBudget' | 'closedExpenses' | 'ioNumber' | 'ioAvailableBalance' | 'ioBlockedAmount' | 'ioRemainingBalance' | 'sapDocumentNumber' | 'dmsNumber' | 'eInvoiceNumber' | 'eInvoiceDate' | 'creditNoteNumber' | 'creditNoteDate' | 'creditNoteAmount' | 'createdAt' | 'updatedAt'> {}
interface DealerClaimDetailsCreationAttributes extends Optional<DealerClaimDetailsAttributes, 'claimId' | 'dealerEmail' | 'dealerPhone' | 'dealerAddress' | 'activityDate' | 'location' | 'periodStartDate' | 'periodEndDate' | 'createdAt' | 'updatedAt'> {}
class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaimDetailsCreationAttributes> implements DealerClaimDetailsAttributes {
public claimId!: string;
@ -50,20 +36,6 @@ class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaim
public location?: string;
public periodStartDate?: Date;
public periodEndDate?: Date;
public estimatedBudget?: number;
public closedExpenses?: number;
public ioNumber?: string;
// Note: ioRemark moved to internal_orders table
public ioAvailableBalance?: number;
public ioBlockedAmount?: number;
public ioRemainingBalance?: number;
public sapDocumentNumber?: string;
public dmsNumber?: string;
public eInvoiceNumber?: string;
public eInvoiceDate?: Date;
public creditNoteNumber?: string;
public creditNoteDate?: Date;
public creditNoteAmount?: number;
public createdAt!: Date;
public updatedAt!: Date;
@ -143,72 +115,6 @@ DealerClaimDetails.init(
allowNull: true,
field: 'period_end_date'
},
estimatedBudget: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'estimated_budget'
},
closedExpenses: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'closed_expenses'
},
ioNumber: {
type: DataTypes.STRING(50),
allowNull: true,
field: 'io_number'
},
// Note: ioRemark moved to internal_orders table - removed from here
ioAvailableBalance: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'io_available_balance'
},
ioBlockedAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'io_blocked_amount'
},
ioRemainingBalance: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'io_remaining_balance'
},
sapDocumentNumber: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'sap_document_number'
},
dmsNumber: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'dms_number'
},
eInvoiceNumber: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'e_invoice_number'
},
eInvoiceDate: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'e_invoice_date'
},
creditNoteNumber: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'credit_note_number'
},
creditNoteDate: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'credit_note_date'
},
creditNoteAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'credit_note_amount'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,

View File

@ -7,26 +7,20 @@ interface DealerCompletionDetailsAttributes {
requestId: string;
activityCompletionDate: Date;
numberOfParticipants?: number;
closedExpenses?: any; // JSONB array of {description, amount}
totalClosedExpenses?: number;
completionDocuments?: any; // JSONB array of document references
activityPhotos?: any; // JSONB array of photo references
submittedAt?: Date;
createdAt: Date;
updatedAt: Date;
}
interface DealerCompletionDetailsCreationAttributes extends Optional<DealerCompletionDetailsAttributes, 'completionId' | 'numberOfParticipants' | 'closedExpenses' | 'totalClosedExpenses' | 'completionDocuments' | 'activityPhotos' | 'submittedAt' | 'createdAt' | 'updatedAt'> {}
interface DealerCompletionDetailsCreationAttributes extends Optional<DealerCompletionDetailsAttributes, 'completionId' | 'numberOfParticipants' | 'totalClosedExpenses' | 'submittedAt' | 'createdAt' | 'updatedAt'> {}
class DealerCompletionDetails extends Model<DealerCompletionDetailsAttributes, DealerCompletionDetailsCreationAttributes> implements DealerCompletionDetailsAttributes {
public completionId!: string;
public requestId!: string;
public activityCompletionDate!: Date;
public numberOfParticipants?: number;
public closedExpenses?: any;
public totalClosedExpenses?: number;
public completionDocuments?: any;
public activityPhotos?: any;
public submittedAt?: Date;
public createdAt!: Date;
public updatedAt!: Date;
@ -62,26 +56,11 @@ DealerCompletionDetails.init(
allowNull: true,
field: 'number_of_participants'
},
closedExpenses: {
type: DataTypes.JSONB,
allowNull: true,
field: 'closed_expenses'
},
totalClosedExpenses: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'total_closed_expenses'
},
completionDocuments: {
type: DataTypes.JSONB,
allowNull: true,
field: 'completion_documents'
},
activityPhotos: {
type: DataTypes.JSONB,
allowNull: true,
field: 'activity_photos'
},
submittedAt: {
type: DataTypes.DATE,
allowNull: true,

View File

@ -0,0 +1,118 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { WorkflowRequest } from './WorkflowRequest';
import { DealerCompletionDetails } from './DealerCompletionDetails';
interface DealerCompletionExpenseAttributes {
expenseId: string;
requestId: string;
completionId?: string | null;
description: string;
amount: number;
createdAt: Date;
updatedAt: Date;
}
interface DealerCompletionExpenseCreationAttributes extends Optional<DealerCompletionExpenseAttributes, 'expenseId' | 'completionId' | 'createdAt' | 'updatedAt'> {}
class DealerCompletionExpense extends Model<DealerCompletionExpenseAttributes, DealerCompletionExpenseCreationAttributes> implements DealerCompletionExpenseAttributes {
public expenseId!: string;
public requestId!: string;
public completionId?: string | null;
public description!: string;
public amount!: number;
public createdAt!: Date;
public updatedAt!: Date;
}
DealerCompletionExpense.init(
{
expenseId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
field: 'expense_id',
},
requestId: {
type: DataTypes.UUID,
allowNull: false,
field: 'request_id',
references: {
model: 'workflow_requests',
key: 'request_id',
},
},
completionId: {
type: DataTypes.UUID,
allowNull: true,
field: 'completion_id',
references: {
model: 'dealer_completion_details',
key: 'completion_id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
description: {
type: DataTypes.STRING(500),
allowNull: false,
field: 'description',
},
amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: 'amount',
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'created_at',
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'updated_at',
},
},
{
sequelize,
modelName: 'DealerCompletionExpense',
tableName: 'dealer_completion_expenses',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
{ fields: ['request_id'], name: 'idx_dealer_completion_expenses_request_id' },
{ fields: ['completion_id'], name: 'idx_dealer_completion_expenses_completion_id' },
],
}
);
WorkflowRequest.hasMany(DealerCompletionExpense, {
as: 'completionExpenses',
foreignKey: 'requestId',
sourceKey: 'requestId',
});
DealerCompletionExpense.belongsTo(WorkflowRequest, {
as: 'workflowRequest',
foreignKey: 'requestId',
targetKey: 'requestId',
});
DealerCompletionDetails.hasMany(DealerCompletionExpense, {
as: 'expenses',
foreignKey: 'completionId',
sourceKey: 'completionId',
});
DealerCompletionExpense.belongsTo(DealerCompletionDetails, {
as: 'completion',
foreignKey: 'completionId',
targetKey: 'completionId',
});
export { DealerCompletionExpense };

View File

@ -7,7 +7,7 @@ interface DealerProposalDetailsAttributes {
requestId: string;
proposalDocumentPath?: string;
proposalDocumentUrl?: string;
costBreakup?: any; // JSONB array of {description, amount}
// costBreakup removed - now using dealer_proposal_cost_items table
totalEstimatedBudget?: number;
timelineMode?: 'date' | 'days';
expectedCompletionDate?: Date;
@ -18,14 +18,14 @@ interface DealerProposalDetailsAttributes {
updatedAt: Date;
}
interface DealerProposalDetailsCreationAttributes extends Optional<DealerProposalDetailsAttributes, 'proposalId' | 'proposalDocumentPath' | 'proposalDocumentUrl' | 'costBreakup' | 'totalEstimatedBudget' | 'timelineMode' | 'expectedCompletionDate' | 'expectedCompletionDays' | 'dealerComments' | 'submittedAt' | 'createdAt' | 'updatedAt'> {}
interface DealerProposalDetailsCreationAttributes extends Optional<DealerProposalDetailsAttributes, 'proposalId' | 'proposalDocumentPath' | 'proposalDocumentUrl' | 'totalEstimatedBudget' | 'timelineMode' | 'expectedCompletionDate' | 'expectedCompletionDays' | 'dealerComments' | 'submittedAt' | 'createdAt' | 'updatedAt'> {}
class DealerProposalDetails extends Model<DealerProposalDetailsAttributes, DealerProposalDetailsCreationAttributes> implements DealerProposalDetailsAttributes {
public proposalId!: string;
public requestId!: string;
public proposalDocumentPath?: string;
public proposalDocumentUrl?: string;
public costBreakup?: any;
// costBreakup removed - now using dealer_proposal_cost_items table
public totalEstimatedBudget?: number;
public timelineMode?: 'date' | 'days';
public expectedCompletionDate?: Date;
@ -66,11 +66,7 @@ DealerProposalDetails.init(
allowNull: true,
field: 'proposal_document_url'
},
costBreakup: {
type: DataTypes.JSONB,
allowNull: true,
field: 'cost_breakup'
},
// costBreakup field removed - now using dealer_proposal_cost_items table
totalEstimatedBudget: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,

View File

@ -29,6 +29,8 @@ import * as m25 from '../migrations/20251210-create-dealer-claim-tables';
import * as m26 from '../migrations/20251210-create-proposal-cost-items-table';
import * as m27 from '../migrations/20251211-create-internal-orders-table';
import * as m28 from '../migrations/20251211-create-claim-budget-tracking-table';
import * as m29 from '../migrations/20251213-create-claim-invoice-credit-note-tables';
import * as m30 from '../migrations/20251214-create-dealer-completion-expenses';
interface Migration {
name: string;
@ -72,6 +74,8 @@ const migrations: Migration[] = [
{ name: '20251210-create-proposal-cost-items-table', module: m26 },
{ name: '20251211-create-internal-orders-table', module: m27 },
{ name: '20251211-create-claim-budget-tracking-table', module: m28 },
{ name: '20251213-create-claim-invoice-credit-note-tables', module: m29 },
{ name: '20251214-create-dealer-completion-expenses', module: m30 },
];
/**

View File

@ -453,7 +453,8 @@ export class ApprovalService {
const dealerClaimService = new DealerClaimService();
await dealerClaimService.processEInvoiceGeneration(level.requestId);
logger.info(`[Approval] Step 7 auto-processing completed for request ${level.requestId}`);
// Skip notification for system auto-processed step - continue to return updatedLevel at end
// Skip notification for system auto-processed step
return updatedLevel;
} catch (step7Error) {
logger.error(`[Approval] Error auto-processing Step 7 for request ${level.requestId}:`, step7Error);
// Don't fail the Step 6 approval if Step 7 processing fails - log and continue

View File

@ -122,6 +122,11 @@ export class AuthService {
designation: profile.title || profile.designation || undefined,
phone: profile.mobilePhone || profile.phone || profile.phoneNumber || undefined,
manager: profile.manager || undefined, // Store manager name if available
jobTitle: profile.title || undefined,
postalAddress: profile.postalAddress || undefined,
mobilePhone: profile.mobilePhone || undefined,
secondEmail: profile.secondEmail || profile.second_email || undefined,
adGroups: Array.isArray(profile.memberOf) ? profile.memberOf : undefined,
};
// Validate required fields
@ -135,8 +140,16 @@ export class AuthService {
email: userData.email,
employeeId: userData.employeeId || 'not provided',
hasManager: !!userData.manager,
manager: userData.manager || 'not provided',
hasDepartment: !!userData.department,
hasDesignation: !!userData.designation,
designation: userData.designation || 'not provided',
hasJobTitle: !!userData.jobTitle,
jobTitle: userData.jobTitle || 'not provided',
hasTitle: !!(userData.jobTitle || userData.designation),
hasAdGroups: !!userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0,
adGroupsCount: userData.adGroups && Array.isArray(userData.adGroups) ? userData.adGroups.length : 0,
adGroups: userData.adGroups && Array.isArray(userData.adGroups) ? userData.adGroups.slice(0, 5) : 'none', // Log first 5 groups
});
return userData;
@ -194,10 +207,26 @@ export class AuthService {
}
if (oktaUser.title || oktaUser.designation) {
userData.designation = oktaUser.title || oktaUser.designation;
userData.jobTitle = oktaUser.title || oktaUser.designation;
}
if (oktaUser.phone_number || oktaUser.phone) {
userData.phone = oktaUser.phone_number || oktaUser.phone;
}
if (oktaUser.manager) {
userData.manager = oktaUser.manager;
}
if (oktaUser.mobilePhone) {
userData.mobilePhone = oktaUser.mobilePhone;
}
if (oktaUser.address || oktaUser.postalAddress) {
userData.postalAddress = oktaUser.address || oktaUser.postalAddress;
}
if (oktaUser.secondEmail) {
userData.secondEmail = oktaUser.secondEmail;
}
if (Array.isArray(oktaUser.memberOf)) {
userData.adGroups = oktaUser.memberOf;
}
return userData;
}
@ -530,8 +559,15 @@ export class AuthService {
logger.info('User data extracted from Okta', {
email: userData.email,
employeeId: userData.employeeId || 'not provided',
hasEmployeeId: !!userData.employeeId,
hasName: !!userData.displayName,
hasManager: !!(userData as any).manager,
manager: (userData as any).manager || 'not provided',
hasDepartment: !!userData.department,
hasDesignation: !!userData.designation,
hasJobTitle: !!userData.jobTitle,
source: usersApiResponse ? 'Users API' : 'userinfo endpoint',
});
// Step 4: Create/update user in our database
@ -699,8 +735,14 @@ export class AuthService {
email: userData.email,
employeeId: userData.employeeId || 'not provided',
hasManager: !!(userData as any).manager,
manager: (userData as any).manager || 'not provided',
hasDepartment: !!userData.department,
hasDesignation: !!userData.designation,
hasJobTitle: !!userData.jobTitle,
hasPostalAddress: !!userData.postalAddress,
hasMobilePhone: !!userData.mobilePhone,
hasSecondEmail: !!userData.secondEmail,
hasAdGroups: !!userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0,
source: usersApiResponse ? 'Users API' : 'userinfo endpoint',
});

View File

@ -4,6 +4,10 @@ import { DealerProposalDetails } from '../models/DealerProposalDetails';
import { DealerCompletionDetails } from '../models/DealerCompletionDetails';
import { DealerProposalCostItem } from '../models/DealerProposalCostItem';
import { InternalOrder, IOStatus } from '../models/InternalOrder';
import { ClaimBudgetTracking, BudgetStatus } from '../models/ClaimBudgetTracking';
import { ClaimInvoice } from '../models/ClaimInvoice';
import { ClaimCreditNote } from '../models/ClaimCreditNote';
import { DealerCompletionExpense } from '../models/DealerCompletionExpense';
import { ApprovalLevel } from '../models/ApprovalLevel';
import { Participant } from '../models/Participant';
import { User } from '../models/User';
@ -83,7 +87,14 @@ export class DealerClaimService {
location: claimData.location,
periodStartDate: claimData.periodStartDate,
periodEndDate: claimData.periodEndDate,
estimatedBudget: claimData.estimatedBudget,
});
// Initialize budget tracking with initial estimated budget (if provided)
await ClaimBudgetTracking.upsert({
requestId: workflowRequest.requestId,
initialEstimatedBudget: claimData.estimatedBudget,
budgetStatus: BudgetStatus.DRAFT,
currency: 'INR',
});
// Create 8 approval levels for claim management workflow
@ -702,7 +713,7 @@ export class DealerClaimService {
if (proposalDetails) {
const proposalData = (proposalDetails as any).toJSON ? (proposalDetails as any).toJSON() : proposalDetails;
// Get cost items from separate table (preferred) or fallback to JSONB
// Get cost items from separate table (dealer_proposal_cost_items)
let costBreakup: any[] = [];
if (proposalData.costItems && Array.isArray(proposalData.costItems) && proposalData.costItems.length > 0) {
// Use cost items from separate table
@ -710,18 +721,8 @@ export class DealerClaimService {
description: item.itemDescription || item.description,
amount: Number(item.amount) || 0
}));
} else if (proposalData.costBreakup) {
// Fallback to JSONB field for backward compatibility
if (Array.isArray(proposalData.costBreakup)) {
costBreakup = proposalData.costBreakup;
} else if (typeof proposalData.costBreakup === 'string') {
try {
costBreakup = JSON.parse(proposalData.costBreakup);
} catch (e) {
logger.warn('[DealerClaimService] Failed to parse costBreakup JSON:', e);
}
}
}
// Note: costBreakup JSONB field has been removed - only using separate table now
transformedProposalDetails = {
...proposalData,
@ -742,12 +743,63 @@ export class DealerClaimService {
serializedInternalOrder = (internalOrder as any).toJSON ? (internalOrder as any).toJSON() : internalOrder;
}
// Fetch Budget Tracking details
const budgetTracking = await ClaimBudgetTracking.findOne({
where: { requestId }
});
// Fetch Invoice details
const claimInvoice = await ClaimInvoice.findOne({
where: { requestId }
});
// Fetch Credit Note details
const claimCreditNote = await ClaimCreditNote.findOne({
where: { requestId }
});
// Fetch Completion Expenses (individual expense items)
const completionExpenses = await DealerCompletionExpense.findAll({
where: { requestId },
order: [['createdAt', 'ASC']]
});
// Serialize new tables
let serializedBudgetTracking = null;
if (budgetTracking) {
serializedBudgetTracking = (budgetTracking as any).toJSON ? (budgetTracking as any).toJSON() : budgetTracking;
}
let serializedInvoice = null;
if (claimInvoice) {
serializedInvoice = (claimInvoice as any).toJSON ? (claimInvoice as any).toJSON() : claimInvoice;
}
let serializedCreditNote = null;
if (claimCreditNote) {
serializedCreditNote = (claimCreditNote as any).toJSON ? (claimCreditNote as any).toJSON() : claimCreditNote;
}
// Transform completion expenses to array format for frontend
const expensesBreakdown = completionExpenses.map((expense: any) => {
const expenseData = expense.toJSON ? expense.toJSON() : expense;
return {
description: expenseData.description || '',
amount: Number(expenseData.amount) || 0
};
});
return {
request: (request as any).toJSON ? (request as any).toJSON() : request,
claimDetails: serializedClaimDetails,
proposalDetails: transformedProposalDetails,
completionDetails: serializedCompletionDetails,
internalOrder: serializedInternalOrder,
// New normalized tables
budgetTracking: serializedBudgetTracking,
invoice: serializedInvoice,
creditNote: serializedCreditNote,
completionExpenses: expensesBreakdown, // Array of expense items
};
} catch (error) {
logger.error('[DealerClaimService] Error getting claim details:', error);
@ -781,13 +833,12 @@ export class DealerClaimService {
throw new Error('Proposal can only be submitted at step 1');
}
// Save proposal details (keep costBreakup in JSONB for backward compatibility)
// Save proposal details (costBreakup removed - now using separate table)
const [proposal] = await DealerProposalDetails.upsert({
requestId,
proposalDocumentPath: proposalData.proposalDocumentPath,
proposalDocumentUrl: proposalData.proposalDocumentUrl,
// Keep costBreakup in JSONB for backward compatibility
costBreakup: proposalData.costBreakup.length > 0 ? proposalData.costBreakup : null,
// costBreakup field removed - now using dealer_proposal_cost_items table
totalEstimatedBudget: proposalData.totalEstimatedBudget,
timelineMode: proposalData.timelineMode,
expectedCompletionDate: proposalData.expectedCompletionDate,
@ -843,11 +894,14 @@ export class DealerClaimService {
logger.info(`[DealerClaimService] Saved ${costItems.length} cost items for proposal ${proposalId}`);
}
// Update estimated budget in claim details
await DealerClaimDetails.update(
{ estimatedBudget: proposalData.totalEstimatedBudget },
{ where: { requestId } }
);
// Update budget tracking with proposal estimate
await ClaimBudgetTracking.upsert({
requestId,
proposalEstimatedBudget: proposalData.totalEstimatedBudget,
proposalSubmittedAt: new Date(),
budgetStatus: BudgetStatus.PROPOSED,
currency: 'INR',
});
// Approve step 1 and move to step 2
const level1 = await ApprovalLevel.findOne({
@ -880,8 +934,6 @@ export class DealerClaimService {
numberOfParticipants?: number;
closedExpenses: any[];
totalClosedExpenses: number;
completionDocuments: any[];
activityPhotos: any[];
invoicesReceipts?: any[];
attendanceSheet?: any;
}
@ -899,22 +951,36 @@ export class DealerClaimService {
}
// Save completion details
await DealerCompletionDetails.upsert({
const [completionDetails] = await DealerCompletionDetails.upsert({
requestId,
activityCompletionDate: completionData.activityCompletionDate,
numberOfParticipants: completionData.numberOfParticipants,
closedExpenses: completionData.closedExpenses,
totalClosedExpenses: completionData.totalClosedExpenses,
completionDocuments: completionData.completionDocuments,
activityPhotos: completionData.activityPhotos,
submittedAt: new Date(),
});
// Update closed expenses in claim details
await DealerClaimDetails.update(
{ closedExpenses: completionData.totalClosedExpenses },
{ where: { requestId } }
);
// Persist individual closed expenses to dealer_completion_expenses
const completionId = (completionDetails as any)?.completionId;
if (completionData.closedExpenses && completionData.closedExpenses.length > 0) {
// Clear existing expenses for this request to avoid duplicates
await DealerCompletionExpense.destroy({ where: { requestId } });
const expenseRows = completionData.closedExpenses.map((item: any) => ({
requestId,
completionId,
description: item.description,
amount: item.amount,
}));
await DealerCompletionExpense.bulkCreate(expenseRows);
}
// Update budget tracking with closed expenses
await ClaimBudgetTracking.upsert({
requestId,
closedExpenses: completionData.totalClosedExpenses,
closedExpensesSubmittedAt: new Date(),
budgetStatus: BudgetStatus.CLOSED,
currency: 'INR',
});
// Approve step 5 and move to step 6
const level5 = await ApprovalLevel.findOne({
@ -1016,6 +1082,15 @@ export class DealerClaimService {
});
}
// Update budget tracking with blocked amount
await ClaimBudgetTracking.upsert({
requestId,
ioBlockedAmount: blockedAmount,
ioBlockedAt: new Date(),
budgetStatus: BudgetStatus.BLOCKED,
currency: 'INR',
});
logger.info(`[DealerClaimService] IO details updated for request: ${requestId}`, {
ioNumber: ioData.ioNumber,
blockedAmount,
@ -1047,6 +1122,11 @@ export class DealerClaimService {
throw new Error('Claim details not found');
}
const budgetTracking = await ClaimBudgetTracking.findOne({ where: { requestId } });
const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId } });
const internalOrder = await InternalOrder.findOne({ where: { requestId } });
const claimInvoice = await ClaimInvoice.findOne({ where: { requestId } });
const request = await WorkflowRequest.findByPk(requestId);
if (!request) {
throw new Error('Workflow request not found');
@ -1062,7 +1142,11 @@ export class DealerClaimService {
// If invoice data not provided, generate via DMS
if (!invoiceData?.eInvoiceNumber) {
const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } });
const invoiceAmount = invoiceData?.amount || proposalDetails?.totalEstimatedBudget || claimDetails.estimatedBudget || 0;
const invoiceAmount = invoiceData?.amount
|| proposalDetails?.totalEstimatedBudget
|| budgetTracking?.proposalEstimatedBudget
|| budgetTracking?.initialEstimatedBudget
|| 0;
const invoiceResult = await dmsIntegrationService.generateEInvoice({
requestNumber,
@ -1070,21 +1154,22 @@ export class DealerClaimService {
dealerName: claimDetails.dealerName,
amount: invoiceAmount,
description: invoiceData?.description || `E-Invoice for claim request ${requestNumber}`,
ioNumber: claimDetails.ioNumber || undefined,
ioNumber: internalOrder?.ioNumber || undefined,
});
if (!invoiceResult.success) {
throw new Error(`Failed to generate e-invoice: ${invoiceResult.error}`);
}
await DealerClaimDetails.update(
{
eInvoiceNumber: invoiceResult.eInvoiceNumber,
eInvoiceDate: invoiceResult.invoiceDate || new Date(),
dmsNumber: invoiceResult.dmsNumber,
},
{ where: { requestId } }
);
await ClaimInvoice.upsert({
requestId,
invoiceNumber: invoiceResult.eInvoiceNumber,
invoiceDate: invoiceResult.invoiceDate || new Date(),
dmsNumber: invoiceResult.dmsNumber,
amount: invoiceAmount,
status: 'GENERATED',
description: invoiceData?.description || `E-Invoice for claim request ${requestNumber}`,
});
logger.info(`[DealerClaimService] E-Invoice generated via DMS for request: ${requestId}`, {
eInvoiceNumber: invoiceResult.eInvoiceNumber,
@ -1092,14 +1177,15 @@ export class DealerClaimService {
});
} else {
// Manual entry - just update the fields
await DealerClaimDetails.update(
{
eInvoiceNumber: invoiceData.eInvoiceNumber,
eInvoiceDate: invoiceData.eInvoiceDate || new Date(),
dmsNumber: invoiceData.dmsNumber,
},
{ where: { requestId } }
);
await ClaimInvoice.upsert({
requestId,
invoiceNumber: invoiceData.eInvoiceNumber,
invoiceDate: invoiceData.eInvoiceDate || new Date(),
dmsNumber: invoiceData.dmsNumber,
amount: invoiceData.amount,
status: 'UPDATED',
description: invoiceData.description,
});
logger.info(`[DealerClaimService] E-Invoice details manually updated for request: ${requestId}`);
}
@ -1168,8 +1254,10 @@ export class DealerClaimService {
}
// If e-invoice already generated, just auto-approve Step 7
if (claimDetails.eInvoiceNumber) {
logger.info(`[DealerClaimService] E-Invoice already generated (${claimDetails.eInvoiceNumber}). Auto-approving Step 7 for request ${requestId}`);
const claimInvoice = await ClaimInvoice.findOne({ where: { requestId } });
if (claimInvoice?.invoiceNumber) {
logger.info(`[DealerClaimService] E-Invoice already generated (${claimInvoice.invoiceNumber}). Auto-approving Step 7 for request ${requestId}`);
// Auto-approve Step 7
await this.approvalService.approveLevel(
@ -1209,7 +1297,11 @@ export class DealerClaimService {
throw new Error('Claim details not found');
}
if (!claimDetails.eInvoiceNumber) {
const budgetTracking = await ClaimBudgetTracking.findOne({ where: { requestId } });
const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId } });
const claimInvoice = await ClaimInvoice.findOne({ where: { requestId } });
if (!claimInvoice?.invoiceNumber) {
throw new Error('E-Invoice must be generated before creating credit note');
}
@ -1218,11 +1310,14 @@ export class DealerClaimService {
// If credit note data not provided, generate via DMS
if (!creditNoteData?.creditNoteNumber) {
const creditNoteAmount = creditNoteData?.creditNoteAmount || claimDetails.closedExpenses || 0;
const creditNoteAmount = creditNoteData?.creditNoteAmount
|| budgetTracking?.closedExpenses
|| completionDetails?.totalClosedExpenses
|| 0;
const creditNoteResult = await dmsIntegrationService.generateCreditNote({
requestNumber,
eInvoiceNumber: claimDetails.eInvoiceNumber,
eInvoiceNumber: claimInvoice.invoiceNumber,
dealerCode: claimDetails.dealerCode,
dealerName: claimDetails.dealerName,
amount: creditNoteAmount,
@ -1234,14 +1329,15 @@ export class DealerClaimService {
throw new Error(`Failed to generate credit note: ${creditNoteResult.error}`);
}
await DealerClaimDetails.update(
{
creditNoteNumber: creditNoteResult.creditNoteNumber,
creditNoteDate: creditNoteResult.creditNoteDate || new Date(),
creditNoteAmount: creditNoteResult.creditNoteAmount,
},
{ where: { requestId } }
);
await ClaimCreditNote.upsert({
requestId,
creditNoteNumber: creditNoteResult.creditNoteNumber,
creditNoteDate: creditNoteResult.creditNoteDate || new Date(),
creditNoteAmount: creditNoteResult.creditNoteAmount,
status: 'GENERATED',
reason: creditNoteData?.reason || 'Claim settlement',
description: creditNoteData?.description || `Credit note for claim request ${requestNumber}`,
});
logger.info(`[DealerClaimService] Credit note generated via DMS for request: ${requestId}`, {
creditNoteNumber: creditNoteResult.creditNoteNumber,
@ -1249,14 +1345,15 @@ export class DealerClaimService {
});
} else {
// Manual entry - just update the fields
await DealerClaimDetails.update(
{
creditNoteNumber: creditNoteData.creditNoteNumber,
creditNoteDate: creditNoteData.creditNoteDate || new Date(),
creditNoteAmount: creditNoteData.creditNoteAmount,
},
{ where: { requestId } }
);
await ClaimCreditNote.upsert({
requestId,
creditNoteNumber: creditNoteData.creditNoteNumber,
creditNoteDate: creditNoteData.creditNoteDate || new Date(),
creditNoteAmount: creditNoteData.creditNoteAmount,
status: 'UPDATED',
reason: creditNoteData?.reason,
description: creditNoteData?.description,
});
logger.info(`[DealerClaimService] Credit note details manually updated for request: ${requestId}`);
}

View File

@ -16,10 +16,83 @@ interface OktaUser {
login: string;
department?: string;
mobilePhone?: string;
[key: string]: any; // Allow any additional profile fields
};
}
/**
* Extract full user data from Okta Users API response (centralized extraction)
* This ensures consistent field mapping across all user creation/update operations
*/
function extractOktaUserData(oktaUserResponse: any): SSOUserData | null {
try {
const profile = oktaUserResponse.profile || {};
const userData: SSOUserData = {
oktaSub: oktaUserResponse.id || '',
email: profile.email || profile.login || '',
employeeId: profile.employeeID || profile.employeeId || profile.employee_id || undefined,
firstName: profile.firstName || undefined,
lastName: profile.lastName || undefined,
displayName: profile.displayName || undefined,
department: profile.department || undefined,
designation: profile.title || profile.designation || undefined,
phone: profile.mobilePhone || profile.phone || profile.phoneNumber || undefined,
manager: profile.manager || undefined, // Manager name from Okta
jobTitle: profile.title || undefined,
postalAddress: profile.postalAddress || undefined,
mobilePhone: profile.mobilePhone || undefined,
secondEmail: profile.secondEmail || profile.second_email || undefined,
adGroups: Array.isArray(profile.memberOf) ? profile.memberOf : undefined,
};
// Validate required fields
if (!userData.oktaSub || !userData.email) {
return null;
}
return userData;
} catch (error) {
return null;
}
}
export class UserService {
/**
* Build a consistent user payload for create/update from SSO data.
* @param isUpdate - If true, excludes email from payload (email should never be updated)
*/
private buildUserPayload(ssoData: SSOUserData, existingRole?: string, isUpdate: boolean = false) {
const now = new Date();
const payload: any = {
oktaSub: ssoData.oktaSub,
employeeId: ssoData.employeeId || null,
firstName: ssoData.firstName || null,
lastName: ssoData.lastName || null,
displayName: ssoData.displayName || null,
department: ssoData.department || null,
designation: ssoData.designation || null,
phone: ssoData.phone || null,
manager: ssoData.manager || null,
jobTitle: ssoData.designation || ssoData.jobTitle || null,
postalAddress: ssoData.postalAddress || null,
mobilePhone: ssoData.mobilePhone || null,
secondEmail: ssoData.secondEmail || null,
adGroups: ssoData.adGroups || null,
lastLogin: now,
updatedAt: now,
isActive: ssoData.isActive ?? true,
role: (ssoData.role as any) || existingRole || 'USER',
};
// Only include email for new users (never update email for existing users)
if (!isUpdate) {
payload.email = ssoData.email;
}
return payload;
}
async createOrUpdateUser(ssoData: SSOUserData): Promise<UserModel> {
// Validate required fields
if (!ssoData.email || !ssoData.oktaSub) {
@ -36,44 +109,18 @@ export class UserService {
}
});
const now = new Date();
if (existingUser) {
// Update existing user - include oktaSub to ensure it's synced
await existingUser.update({
email: ssoData.email,
oktaSub: ssoData.oktaSub,
employeeId: ssoData.employeeId || null, // Optional
firstName: ssoData.firstName || null,
lastName: ssoData.lastName || null,
displayName: ssoData.displayName || null,
department: ssoData.department || null,
designation: ssoData.designation || null,
phone: ssoData.phone || null,
// location: (ssoData as any).location || null, // Ignored for now - schema not finalized
lastLogin: now,
updatedAt: now,
isActive: true, // Ensure user is active after SSO login
});
// Update existing user - DO NOT update email (crucial identifier)
const updatePayload = this.buildUserPayload(ssoData, existingUser.role, true); // isUpdate = true
await existingUser.update(updatePayload);
return existingUser;
} else {
// Create new user - oktaSub is required
const newUser = await UserModel.create({
email: ssoData.email,
oktaSub: ssoData.oktaSub, // Required
employeeId: ssoData.employeeId || null, // Optional
firstName: ssoData.firstName || null,
lastName: ssoData.lastName || null,
displayName: ssoData.displayName || null,
department: ssoData.department || null,
designation: ssoData.designation || null,
phone: ssoData.phone || null,
// location: (ssoData as any).location || null, // Ignored for now - schema not finalized
isActive: true,
role: 'USER', // Default role for new users
lastLogin: now
});
// Create new user - oktaSub is required, email is included
const createPayload = this.buildUserPayload(ssoData, 'USER', false); // isUpdate = false
const newUser = await UserModel.create(createPayload);
return newUser;
}
@ -221,9 +268,10 @@ export class UserService {
}
/**
* Fetch user from Okta by email
* Fetch user from Okta by email and extract full profile data
* Returns SSOUserData with all fields including manager, jobTitle, etc.
*/
async fetchUserFromOktaByEmail(email: string): Promise<OktaUser | null> {
async fetchAndExtractOktaUserByEmail(email: string): Promise<SSOUserData | null> {
try {
const oktaDomain = process.env.OKTA_DOMAIN;
const oktaApiToken = process.env.OKTA_API_TOKEN;
@ -232,7 +280,25 @@ export class UserService {
return null;
}
// Search Okta users by email (exact match)
// Try to fetch by email directly first (more reliable)
try {
const directResponse = await axios.get(`${oktaDomain}/api/v1/users/${encodeURIComponent(email)}`, {
headers: {
'Authorization': `SSWS ${oktaApiToken}`,
'Accept': 'application/json'
},
timeout: 5000,
validateStatus: (status) => status < 500
});
if (directResponse.status === 200 && directResponse.data) {
return extractOktaUserData(directResponse.data);
}
} catch (directError) {
// Fall through to search method
}
// Fallback: Search Okta users by email
const response = await axios.get(`${oktaDomain}/api/v1/users`, {
params: { search: `profile.email eq "${email}"`, limit: 1 },
headers: {
@ -242,14 +308,42 @@ export class UserService {
timeout: 5000
});
const users: OktaUser[] = response.data || [];
return users.length > 0 ? users[0] : null;
const users: any[] = response.data || [];
if (users.length > 0) {
return extractOktaUserData(users[0]);
}
return null;
} catch (error: any) {
console.error(`Failed to fetch user from Okta by email ${email}:`, error.message);
return null;
}
}
/**
* Fetch user from Okta by email (legacy method, kept for backward compatibility)
* @deprecated Use fetchAndExtractOktaUserByEmail instead for full profile extraction
*/
async fetchUserFromOktaByEmail(email: string): Promise<OktaUser | null> {
const userData = await this.fetchAndExtractOktaUserByEmail(email);
if (!userData) return null;
// Return in legacy format for backward compatibility
return {
id: userData.oktaSub,
status: 'ACTIVE',
profile: {
email: userData.email,
login: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
displayName: userData.displayName,
department: userData.department,
mobilePhone: userData.mobilePhone,
}
};
}
/**
* Ensure user exists in database (create if not exists)
* Used when tagging users from Okta search results or when only email is provided

View File

@ -10,6 +10,13 @@ export interface SSOUserData {
phone?: string;
reportingManagerId?: string;
manager?: string; // Optional - Manager name from Okta profile
jobTitle?: string; // Detailed title from Okta profile.title
postalAddress?: string;
mobilePhone?: string;
secondEmail?: string;
adGroups?: string[];
role?: 'USER' | 'MANAGEMENT' | 'ADMIN';
isActive?: boolean;
}
export interface SSOConfig {