backednd db enhanced table for audit point dedicated table for buget tracking and io
This commit is contained in:
parent
ad18ec54e9
commit
031c490de1
507
docs/ERD.mermaid
Normal file
507
docs/ERD.mermaid
Normal 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
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -66,6 +66,8 @@ export const constants = {
|
||||
REFERENCE: 'REFERENCE',
|
||||
FINAL: 'FINAL',
|
||||
OTHER: 'OTHER',
|
||||
COMPLETION_DOC: 'COMPLETION_DOC',
|
||||
ACTIVITY_PHOTO: 'ACTIVITY_PHOTO',
|
||||
},
|
||||
|
||||
// Work Note Types
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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$$;`);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
55
src/migrations/20251214-create-dealer-completion-expenses.ts
Normal file
55
src/migrations/20251214-create-dealer-completion-expenses.ts
Normal 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');
|
||||
}
|
||||
|
||||
123
src/models/ClaimCreditNote.ts
Normal file
123
src/models/ClaimCreditNote.ts
Normal 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
124
src/models/ClaimInvoice.ts
Normal 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 };
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
118
src/models/DealerCompletionExpense.ts
Normal file
118
src/models/DealerCompletionExpense.ts
Normal 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 };
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
});
|
||||
|
||||
|
||||
@ -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(),
|
||||
await ClaimInvoice.upsert({
|
||||
requestId,
|
||||
invoiceNumber: invoiceResult.eInvoiceNumber,
|
||||
invoiceDate: invoiceResult.invoiceDate || new Date(),
|
||||
dmsNumber: invoiceResult.dmsNumber,
|
||||
},
|
||||
{ where: { requestId } }
|
||||
);
|
||||
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(),
|
||||
await ClaimInvoice.upsert({
|
||||
requestId,
|
||||
invoiceNumber: invoiceData.eInvoiceNumber,
|
||||
invoiceDate: invoiceData.eInvoiceDate || new Date(),
|
||||
dmsNumber: invoiceData.dmsNumber,
|
||||
},
|
||||
{ where: { requestId } }
|
||||
);
|
||||
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(
|
||||
{
|
||||
await ClaimCreditNote.upsert({
|
||||
requestId,
|
||||
creditNoteNumber: creditNoteResult.creditNoteNumber,
|
||||
creditNoteDate: creditNoteResult.creditNoteDate || new Date(),
|
||||
creditNoteAmount: creditNoteResult.creditNoteAmount,
|
||||
},
|
||||
{ where: { requestId } }
|
||||
);
|
||||
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(
|
||||
{
|
||||
await ClaimCreditNote.upsert({
|
||||
requestId,
|
||||
creditNoteNumber: creditNoteData.creditNoteNumber,
|
||||
creditNoteDate: creditNoteData.creditNoteDate || new Date(),
|
||||
creditNoteAmount: creditNoteData.creditNoteAmount,
|
||||
},
|
||||
{ where: { requestId } }
|
||||
);
|
||||
status: 'UPDATED',
|
||||
reason: creditNoteData?.reason,
|
||||
description: creditNoteData?.description,
|
||||
});
|
||||
|
||||
logger.info(`[DealerClaimService] Credit note details manually updated for request: ${requestId}`);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user