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",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "npm run setup && npm run build && npm run start:prod",
|
"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",
|
"dev:no-setup": "nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
|
||||||
"build": "tsc && tsc-alias",
|
"build": "tsc && tsc-alias",
|
||||||
"build:watch": "tsc --watch",
|
"build:watch": "tsc --watch",
|
||||||
|
|||||||
@ -66,6 +66,8 @@ export const constants = {
|
|||||||
REFERENCE: 'REFERENCE',
|
REFERENCE: 'REFERENCE',
|
||||||
FINAL: 'FINAL',
|
FINAL: 'FINAL',
|
||||||
OTHER: 'OTHER',
|
OTHER: 'OTHER',
|
||||||
|
COMPLETION_DOC: 'COMPLETION_DOC',
|
||||||
|
ACTIVITY_PHOTO: 'ACTIVITY_PHOTO',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Work Note Types
|
// 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
|
// User doesn't exist, need to fetch from Okta and create
|
||||||
logger.info(`[Admin] User ${email} not found in database, fetching from Okta...`);
|
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 } = await import('@services/user.service');
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Search Okta for this user
|
// Fetch full user profile from Okta Users API (includes manager, jobTitle, etc.)
|
||||||
const oktaUsers = await userService.searchUsers(email, 1);
|
const oktaUserData = await userService.fetchAndExtractOktaUserByEmail(email);
|
||||||
|
|
||||||
if (!oktaUsers || oktaUsers.length === 0) {
|
if (!oktaUserData) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'User not found in Okta. Please ensure the email is correct.'
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oktaUser = oktaUsers[0];
|
// Create user in our database via centralized userService with all fields including manager
|
||||||
|
const ensured = await userService.createOrUpdateUser({
|
||||||
// Create user in our database
|
...oktaUserData,
|
||||||
user = await User.create({
|
role, // Set the assigned role
|
||||||
email: oktaUser.email,
|
isActive: true, // Ensure user is active
|
||||||
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
|
|
||||||
});
|
});
|
||||||
|
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) {
|
} catch (oktaError: any) {
|
||||||
logger.error('[Admin] Error fetching from Okta:', oktaError);
|
logger.error('[Admin] Error fetching from Okta:', oktaError);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@ -826,7 +816,7 @@ export const assignRoleByEmail = async (req: Request, res: Response): Promise<vo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// User exists, update their role
|
// User exists - fetch latest data from Okta and sync all fields including role
|
||||||
const previousRole = user.role;
|
const previousRole = user.role;
|
||||||
|
|
||||||
// Prevent self-demotion
|
// Prevent self-demotion
|
||||||
@ -838,9 +828,35 @@ export const assignRoleByEmail = async (req: Request, res: Response): Promise<vo
|
|||||||
return;
|
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({
|
res.json({
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { ResponseHandler } from '../utils/responseHandler';
|
|||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import { gcsStorageService } from '../services/gcsStorage.service';
|
import { gcsStorageService } from '../services/gcsStorage.service';
|
||||||
import { Document } from '../models/Document';
|
import { Document } from '../models/Document';
|
||||||
import { User } from '../models/User';
|
import { constants } from '../config/constants';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
@ -201,38 +201,6 @@ export class DealerClaimController {
|
|||||||
proposalDocumentPath = uploadResult.filePath;
|
proposalDocumentPath = uploadResult.filePath;
|
||||||
proposalDocumentUrl = uploadResult.storageUrl;
|
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
|
// Cleanup local file if exists
|
||||||
if (file.path && fs.existsSync(file.path)) {
|
if (file.path && fs.existsSync(file.path)) {
|
||||||
fs.unlinkSync(file.path);
|
fs.unlinkSync(file.path);
|
||||||
@ -304,31 +272,37 @@ export class DealerClaimController {
|
|||||||
return ResponseHandler.error(res, 'Invalid workflow request', 400);
|
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) {
|
if (!activityCompletionDate) {
|
||||||
return ResponseHandler.error(res, 'Activity completion date is required', 400);
|
return ResponseHandler.error(res, 'Activity completion date is required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = req.user?.userId;
|
// Upload files to GCS and save to documents table
|
||||||
if (!userId) {
|
|
||||||
return ResponseHandler.error(res, 'Unauthorized', 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload files to GCS and get URLs, and save to documents table
|
|
||||||
const completionDocuments: any[] = [];
|
const completionDocuments: any[] = [];
|
||||||
const activityPhotos: any[] = [];
|
const activityPhotos: any[] = [];
|
||||||
|
|
||||||
// Helper function to create document entry
|
// Upload and save completion documents to documents table with COMPLETION_DOC category
|
||||||
const createDocumentEntry = async (
|
for (const file of completionDocumentsFiles) {
|
||||||
file: Express.Multer.File,
|
|
||||||
uploadResult: { storageUrl: string; filePath: string },
|
|
||||||
category: 'APPROVAL' | 'SUPPORTING' | 'REFERENCE' | 'FINAL' | 'OTHER'
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
try {
|
||||||
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
|
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
|
||||||
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
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();
|
const extension = path.extname(file.originalname).replace('.', '').toLowerCase();
|
||||||
|
|
||||||
await Document.create({
|
// Save to documents table
|
||||||
|
const doc = await Document.create({
|
||||||
requestId,
|
requestId,
|
||||||
uploadedBy: userId,
|
uploadedBy: userId,
|
||||||
fileName: path.basename(file.filename || file.originalname),
|
fileName: path.basename(file.filename || file.originalname),
|
||||||
@ -342,109 +316,208 @@ export class DealerClaimController {
|
|||||||
checksum,
|
checksum,
|
||||||
isGoogleDoc: false,
|
isGoogleDoc: false,
|
||||||
googleDocUrl: null as any,
|
googleDocUrl: null as any,
|
||||||
category,
|
category: constants.DOCUMENT_CATEGORIES.COMPLETION_DOC,
|
||||||
version: 1,
|
version: 1,
|
||||||
parentDocumentId: null as any,
|
parentDocumentId: null as any,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
downloadCount: 0,
|
downloadCount: 0,
|
||||||
} as any);
|
} 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({
|
completionDocuments.push({
|
||||||
|
documentId: doc.documentId,
|
||||||
name: file.originalname,
|
name: file.originalname,
|
||||||
url: uploadResult.storageUrl,
|
url: uploadResult.storageUrl,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
type: file.mimetype,
|
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) {
|
} catch (error) {
|
||||||
logger.error(`[DealerClaimController] Error uploading completion document ${file.originalname}:`, 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) {
|
for (const file of activityPhotosFiles) {
|
||||||
try {
|
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({
|
const uploadResult = await gcsStorageService.uploadFileWithFallback({
|
||||||
buffer: file.buffer,
|
buffer: fileBuffer,
|
||||||
originalName: file.originalname,
|
originalName: file.originalname,
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
requestNumber: requestNumber,
|
requestNumber: requestNumber,
|
||||||
fileType: 'attachments'
|
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({
|
activityPhotos.push({
|
||||||
|
documentId: doc.documentId,
|
||||||
name: file.originalname,
|
name: file.originalname,
|
||||||
url: uploadResult.storageUrl,
|
url: uploadResult.storageUrl,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
type: file.mimetype,
|
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) {
|
} catch (error) {
|
||||||
logger.error(`[DealerClaimController] Error uploading activity photo ${file.originalname}:`, 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[] = [];
|
const invoicesReceipts: any[] = [];
|
||||||
for (const file of invoicesReceiptsFiles) {
|
for (const file of invoicesReceiptsFiles) {
|
||||||
try {
|
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({
|
const uploadResult = await gcsStorageService.uploadFileWithFallback({
|
||||||
buffer: file.buffer,
|
buffer: fileBuffer,
|
||||||
originalName: file.originalname,
|
originalName: file.originalname,
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
requestNumber: requestNumber,
|
requestNumber: requestNumber,
|
||||||
fileType: 'attachments'
|
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({
|
invoicesReceipts.push({
|
||||||
|
documentId: doc.documentId,
|
||||||
name: file.originalname,
|
name: file.originalname,
|
||||||
url: uploadResult.storageUrl,
|
url: uploadResult.storageUrl,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
type: file.mimetype,
|
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) {
|
} catch (error) {
|
||||||
logger.error(`[DealerClaimController] Error uploading invoice/receipt ${file.originalname}:`, 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;
|
let attendanceSheet: any = null;
|
||||||
if (attendanceSheetFile) {
|
if (attendanceSheetFile) {
|
||||||
try {
|
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({
|
const uploadResult = await gcsStorageService.uploadFileWithFallback({
|
||||||
buffer: attendanceSheetFile.buffer,
|
buffer: fileBuffer,
|
||||||
originalName: attendanceSheetFile.originalname,
|
originalName: attendanceSheetFile.originalname,
|
||||||
mimeType: attendanceSheetFile.mimetype,
|
mimeType: attendanceSheetFile.mimetype,
|
||||||
requestNumber: requestNumber,
|
requestNumber: requestNumber,
|
||||||
fileType: 'attachments'
|
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 = {
|
attendanceSheet = {
|
||||||
|
documentId: doc.documentId,
|
||||||
name: attendanceSheetFile.originalname,
|
name: attendanceSheetFile.originalname,
|
||||||
url: uploadResult.storageUrl,
|
url: uploadResult.storageUrl,
|
||||||
size: attendanceSheetFile.size,
|
size: attendanceSheetFile.size,
|
||||||
type: attendanceSheetFile.mimetype,
|
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) {
|
} catch (error) {
|
||||||
logger.error(`[DealerClaimController] Error uploading attendance sheet:`, error);
|
logger.error(`[DealerClaimController] Error uploading attendance sheet:`, error);
|
||||||
}
|
}
|
||||||
@ -455,8 +528,6 @@ export class DealerClaimController {
|
|||||||
numberOfParticipants: numberOfParticipants ? parseInt(numberOfParticipants) : undefined,
|
numberOfParticipants: numberOfParticipants ? parseInt(numberOfParticipants) : undefined,
|
||||||
closedExpenses: parsedClosedExpenses,
|
closedExpenses: parsedClosedExpenses,
|
||||||
totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0,
|
totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0,
|
||||||
completionDocuments,
|
|
||||||
activityPhotos,
|
|
||||||
invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined,
|
invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined,
|
||||||
attendanceSheet: attendanceSheet || undefined,
|
attendanceSheet: attendanceSheet || undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,7 +4,7 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
|
|||||||
await queryInterface.sequelize.query(`DO $$
|
await queryInterface.sequelize.query(`DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_document_category') THEN
|
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 IF;
|
||||||
END$$;`);
|
END$$;`);
|
||||||
|
|
||||||
|
|||||||
@ -63,58 +63,6 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
|
|||||||
type: DataTypes.DATEONLY,
|
type: DataTypes.DATEONLY,
|
||||||
allowNull: true
|
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: {
|
created_at: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
@ -165,10 +113,6 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
|
|||||||
type: DataTypes.STRING(500),
|
type: DataTypes.STRING(500),
|
||||||
allowNull: true
|
allowNull: true
|
||||||
},
|
},
|
||||||
cost_breakup: {
|
|
||||||
type: DataTypes.JSONB,
|
|
||||||
allowNull: true
|
|
||||||
},
|
|
||||||
total_estimated_budget: {
|
total_estimated_budget: {
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
allowNull: true
|
allowNull: true
|
||||||
@ -236,22 +180,10 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
|
|||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: true
|
allowNull: true
|
||||||
},
|
},
|
||||||
closed_expenses: {
|
|
||||||
type: DataTypes.JSONB,
|
|
||||||
allowNull: true
|
|
||||||
},
|
|
||||||
total_closed_expenses: {
|
total_closed_expenses: {
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
allowNull: true
|
allowNull: true
|
||||||
},
|
},
|
||||||
completion_documents: {
|
|
||||||
type: DataTypes.JSONB,
|
|
||||||
allowNull: true
|
|
||||||
},
|
|
||||||
activity_photos: {
|
|
||||||
type: DataTypes.JSONB,
|
|
||||||
allowNull: true
|
|
||||||
},
|
|
||||||
submitted_at: {
|
submitted_at: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: true
|
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;
|
location?: string;
|
||||||
periodStartDate?: Date;
|
periodStartDate?: Date;
|
||||||
periodEndDate?: 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;
|
createdAt: Date;
|
||||||
updatedAt: 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 {
|
class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaimDetailsCreationAttributes> implements DealerClaimDetailsAttributes {
|
||||||
public claimId!: string;
|
public claimId!: string;
|
||||||
@ -50,20 +36,6 @@ class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaim
|
|||||||
public location?: string;
|
public location?: string;
|
||||||
public periodStartDate?: Date;
|
public periodStartDate?: Date;
|
||||||
public periodEndDate?: 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 createdAt!: Date;
|
||||||
public updatedAt!: Date;
|
public updatedAt!: Date;
|
||||||
|
|
||||||
@ -143,72 +115,6 @@ DealerClaimDetails.init(
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
field: 'period_end_date'
|
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: {
|
createdAt: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@ -7,26 +7,20 @@ interface DealerCompletionDetailsAttributes {
|
|||||||
requestId: string;
|
requestId: string;
|
||||||
activityCompletionDate: Date;
|
activityCompletionDate: Date;
|
||||||
numberOfParticipants?: number;
|
numberOfParticipants?: number;
|
||||||
closedExpenses?: any; // JSONB array of {description, amount}
|
|
||||||
totalClosedExpenses?: number;
|
totalClosedExpenses?: number;
|
||||||
completionDocuments?: any; // JSONB array of document references
|
|
||||||
activityPhotos?: any; // JSONB array of photo references
|
|
||||||
submittedAt?: Date;
|
submittedAt?: Date;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: 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 {
|
class DealerCompletionDetails extends Model<DealerCompletionDetailsAttributes, DealerCompletionDetailsCreationAttributes> implements DealerCompletionDetailsAttributes {
|
||||||
public completionId!: string;
|
public completionId!: string;
|
||||||
public requestId!: string;
|
public requestId!: string;
|
||||||
public activityCompletionDate!: Date;
|
public activityCompletionDate!: Date;
|
||||||
public numberOfParticipants?: number;
|
public numberOfParticipants?: number;
|
||||||
public closedExpenses?: any;
|
|
||||||
public totalClosedExpenses?: number;
|
public totalClosedExpenses?: number;
|
||||||
public completionDocuments?: any;
|
|
||||||
public activityPhotos?: any;
|
|
||||||
public submittedAt?: Date;
|
public submittedAt?: Date;
|
||||||
public createdAt!: Date;
|
public createdAt!: Date;
|
||||||
public updatedAt!: Date;
|
public updatedAt!: Date;
|
||||||
@ -62,26 +56,11 @@ DealerCompletionDetails.init(
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
field: 'number_of_participants'
|
field: 'number_of_participants'
|
||||||
},
|
},
|
||||||
closedExpenses: {
|
|
||||||
type: DataTypes.JSONB,
|
|
||||||
allowNull: true,
|
|
||||||
field: 'closed_expenses'
|
|
||||||
},
|
|
||||||
totalClosedExpenses: {
|
totalClosedExpenses: {
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
field: 'total_closed_expenses'
|
field: 'total_closed_expenses'
|
||||||
},
|
},
|
||||||
completionDocuments: {
|
|
||||||
type: DataTypes.JSONB,
|
|
||||||
allowNull: true,
|
|
||||||
field: 'completion_documents'
|
|
||||||
},
|
|
||||||
activityPhotos: {
|
|
||||||
type: DataTypes.JSONB,
|
|
||||||
allowNull: true,
|
|
||||||
field: 'activity_photos'
|
|
||||||
},
|
|
||||||
submittedAt: {
|
submittedAt: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: true,
|
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;
|
requestId: string;
|
||||||
proposalDocumentPath?: string;
|
proposalDocumentPath?: string;
|
||||||
proposalDocumentUrl?: string;
|
proposalDocumentUrl?: string;
|
||||||
costBreakup?: any; // JSONB array of {description, amount}
|
// costBreakup removed - now using dealer_proposal_cost_items table
|
||||||
totalEstimatedBudget?: number;
|
totalEstimatedBudget?: number;
|
||||||
timelineMode?: 'date' | 'days';
|
timelineMode?: 'date' | 'days';
|
||||||
expectedCompletionDate?: Date;
|
expectedCompletionDate?: Date;
|
||||||
@ -18,14 +18,14 @@ interface DealerProposalDetailsAttributes {
|
|||||||
updatedAt: Date;
|
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 {
|
class DealerProposalDetails extends Model<DealerProposalDetailsAttributes, DealerProposalDetailsCreationAttributes> implements DealerProposalDetailsAttributes {
|
||||||
public proposalId!: string;
|
public proposalId!: string;
|
||||||
public requestId!: string;
|
public requestId!: string;
|
||||||
public proposalDocumentPath?: string;
|
public proposalDocumentPath?: string;
|
||||||
public proposalDocumentUrl?: string;
|
public proposalDocumentUrl?: string;
|
||||||
public costBreakup?: any;
|
// costBreakup removed - now using dealer_proposal_cost_items table
|
||||||
public totalEstimatedBudget?: number;
|
public totalEstimatedBudget?: number;
|
||||||
public timelineMode?: 'date' | 'days';
|
public timelineMode?: 'date' | 'days';
|
||||||
public expectedCompletionDate?: Date;
|
public expectedCompletionDate?: Date;
|
||||||
@ -66,11 +66,7 @@ DealerProposalDetails.init(
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
field: 'proposal_document_url'
|
field: 'proposal_document_url'
|
||||||
},
|
},
|
||||||
costBreakup: {
|
// costBreakup field removed - now using dealer_proposal_cost_items table
|
||||||
type: DataTypes.JSONB,
|
|
||||||
allowNull: true,
|
|
||||||
field: 'cost_breakup'
|
|
||||||
},
|
|
||||||
totalEstimatedBudget: {
|
totalEstimatedBudget: {
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
allowNull: true,
|
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 m26 from '../migrations/20251210-create-proposal-cost-items-table';
|
||||||
import * as m27 from '../migrations/20251211-create-internal-orders-table';
|
import * as m27 from '../migrations/20251211-create-internal-orders-table';
|
||||||
import * as m28 from '../migrations/20251211-create-claim-budget-tracking-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 {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@ -72,6 +74,8 @@ const migrations: Migration[] = [
|
|||||||
{ name: '20251210-create-proposal-cost-items-table', module: m26 },
|
{ name: '20251210-create-proposal-cost-items-table', module: m26 },
|
||||||
{ name: '20251211-create-internal-orders-table', module: m27 },
|
{ name: '20251211-create-internal-orders-table', module: m27 },
|
||||||
{ name: '20251211-create-claim-budget-tracking-table', module: m28 },
|
{ 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();
|
const dealerClaimService = new DealerClaimService();
|
||||||
await dealerClaimService.processEInvoiceGeneration(level.requestId);
|
await dealerClaimService.processEInvoiceGeneration(level.requestId);
|
||||||
logger.info(`[Approval] Step 7 auto-processing completed for request ${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) {
|
} catch (step7Error) {
|
||||||
logger.error(`[Approval] Error auto-processing Step 7 for request ${level.requestId}:`, 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
|
// 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,
|
designation: profile.title || profile.designation || undefined,
|
||||||
phone: profile.mobilePhone || profile.phone || profile.phoneNumber || undefined,
|
phone: profile.mobilePhone || profile.phone || profile.phoneNumber || undefined,
|
||||||
manager: profile.manager || undefined, // Store manager name if available
|
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
|
// Validate required fields
|
||||||
@ -135,8 +140,16 @@ export class AuthService {
|
|||||||
email: userData.email,
|
email: userData.email,
|
||||||
employeeId: userData.employeeId || 'not provided',
|
employeeId: userData.employeeId || 'not provided',
|
||||||
hasManager: !!userData.manager,
|
hasManager: !!userData.manager,
|
||||||
|
manager: userData.manager || 'not provided',
|
||||||
hasDepartment: !!userData.department,
|
hasDepartment: !!userData.department,
|
||||||
hasDesignation: !!userData.designation,
|
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;
|
return userData;
|
||||||
@ -194,10 +207,26 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
if (oktaUser.title || oktaUser.designation) {
|
if (oktaUser.title || oktaUser.designation) {
|
||||||
userData.designation = oktaUser.title || oktaUser.designation;
|
userData.designation = oktaUser.title || oktaUser.designation;
|
||||||
|
userData.jobTitle = oktaUser.title || oktaUser.designation;
|
||||||
}
|
}
|
||||||
if (oktaUser.phone_number || oktaUser.phone) {
|
if (oktaUser.phone_number || oktaUser.phone) {
|
||||||
userData.phone = 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;
|
return userData;
|
||||||
}
|
}
|
||||||
@ -530,8 +559,15 @@ export class AuthService {
|
|||||||
|
|
||||||
logger.info('User data extracted from Okta', {
|
logger.info('User data extracted from Okta', {
|
||||||
email: userData.email,
|
email: userData.email,
|
||||||
|
employeeId: userData.employeeId || 'not provided',
|
||||||
hasEmployeeId: !!userData.employeeId,
|
hasEmployeeId: !!userData.employeeId,
|
||||||
hasName: !!userData.displayName,
|
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
|
// Step 4: Create/update user in our database
|
||||||
@ -699,8 +735,14 @@ export class AuthService {
|
|||||||
email: userData.email,
|
email: userData.email,
|
||||||
employeeId: userData.employeeId || 'not provided',
|
employeeId: userData.employeeId || 'not provided',
|
||||||
hasManager: !!(userData as any).manager,
|
hasManager: !!(userData as any).manager,
|
||||||
|
manager: (userData as any).manager || 'not provided',
|
||||||
hasDepartment: !!userData.department,
|
hasDepartment: !!userData.department,
|
||||||
hasDesignation: !!userData.designation,
|
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',
|
source: usersApiResponse ? 'Users API' : 'userinfo endpoint',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,10 @@ import { DealerProposalDetails } from '../models/DealerProposalDetails';
|
|||||||
import { DealerCompletionDetails } from '../models/DealerCompletionDetails';
|
import { DealerCompletionDetails } from '../models/DealerCompletionDetails';
|
||||||
import { DealerProposalCostItem } from '../models/DealerProposalCostItem';
|
import { DealerProposalCostItem } from '../models/DealerProposalCostItem';
|
||||||
import { InternalOrder, IOStatus } from '../models/InternalOrder';
|
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 { ApprovalLevel } from '../models/ApprovalLevel';
|
||||||
import { Participant } from '../models/Participant';
|
import { Participant } from '../models/Participant';
|
||||||
import { User } from '../models/User';
|
import { User } from '../models/User';
|
||||||
@ -83,7 +87,14 @@ export class DealerClaimService {
|
|||||||
location: claimData.location,
|
location: claimData.location,
|
||||||
periodStartDate: claimData.periodStartDate,
|
periodStartDate: claimData.periodStartDate,
|
||||||
periodEndDate: claimData.periodEndDate,
|
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
|
// Create 8 approval levels for claim management workflow
|
||||||
@ -702,7 +713,7 @@ export class DealerClaimService {
|
|||||||
if (proposalDetails) {
|
if (proposalDetails) {
|
||||||
const proposalData = (proposalDetails as any).toJSON ? (proposalDetails as any).toJSON() : 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[] = [];
|
let costBreakup: any[] = [];
|
||||||
if (proposalData.costItems && Array.isArray(proposalData.costItems) && proposalData.costItems.length > 0) {
|
if (proposalData.costItems && Array.isArray(proposalData.costItems) && proposalData.costItems.length > 0) {
|
||||||
// Use cost items from separate table
|
// Use cost items from separate table
|
||||||
@ -710,18 +721,8 @@ export class DealerClaimService {
|
|||||||
description: item.itemDescription || item.description,
|
description: item.itemDescription || item.description,
|
||||||
amount: Number(item.amount) || 0
|
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 = {
|
transformedProposalDetails = {
|
||||||
...proposalData,
|
...proposalData,
|
||||||
@ -742,12 +743,63 @@ export class DealerClaimService {
|
|||||||
serializedInternalOrder = (internalOrder as any).toJSON ? (internalOrder as any).toJSON() : internalOrder;
|
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 {
|
return {
|
||||||
request: (request as any).toJSON ? (request as any).toJSON() : request,
|
request: (request as any).toJSON ? (request as any).toJSON() : request,
|
||||||
claimDetails: serializedClaimDetails,
|
claimDetails: serializedClaimDetails,
|
||||||
proposalDetails: transformedProposalDetails,
|
proposalDetails: transformedProposalDetails,
|
||||||
completionDetails: serializedCompletionDetails,
|
completionDetails: serializedCompletionDetails,
|
||||||
internalOrder: serializedInternalOrder,
|
internalOrder: serializedInternalOrder,
|
||||||
|
// New normalized tables
|
||||||
|
budgetTracking: serializedBudgetTracking,
|
||||||
|
invoice: serializedInvoice,
|
||||||
|
creditNote: serializedCreditNote,
|
||||||
|
completionExpenses: expensesBreakdown, // Array of expense items
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[DealerClaimService] Error getting claim details:', 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');
|
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({
|
const [proposal] = await DealerProposalDetails.upsert({
|
||||||
requestId,
|
requestId,
|
||||||
proposalDocumentPath: proposalData.proposalDocumentPath,
|
proposalDocumentPath: proposalData.proposalDocumentPath,
|
||||||
proposalDocumentUrl: proposalData.proposalDocumentUrl,
|
proposalDocumentUrl: proposalData.proposalDocumentUrl,
|
||||||
// Keep costBreakup in JSONB for backward compatibility
|
// costBreakup field removed - now using dealer_proposal_cost_items table
|
||||||
costBreakup: proposalData.costBreakup.length > 0 ? proposalData.costBreakup : null,
|
|
||||||
totalEstimatedBudget: proposalData.totalEstimatedBudget,
|
totalEstimatedBudget: proposalData.totalEstimatedBudget,
|
||||||
timelineMode: proposalData.timelineMode,
|
timelineMode: proposalData.timelineMode,
|
||||||
expectedCompletionDate: proposalData.expectedCompletionDate,
|
expectedCompletionDate: proposalData.expectedCompletionDate,
|
||||||
@ -843,11 +894,14 @@ export class DealerClaimService {
|
|||||||
logger.info(`[DealerClaimService] Saved ${costItems.length} cost items for proposal ${proposalId}`);
|
logger.info(`[DealerClaimService] Saved ${costItems.length} cost items for proposal ${proposalId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update estimated budget in claim details
|
// Update budget tracking with proposal estimate
|
||||||
await DealerClaimDetails.update(
|
await ClaimBudgetTracking.upsert({
|
||||||
{ estimatedBudget: proposalData.totalEstimatedBudget },
|
requestId,
|
||||||
{ where: { requestId } }
|
proposalEstimatedBudget: proposalData.totalEstimatedBudget,
|
||||||
);
|
proposalSubmittedAt: new Date(),
|
||||||
|
budgetStatus: BudgetStatus.PROPOSED,
|
||||||
|
currency: 'INR',
|
||||||
|
});
|
||||||
|
|
||||||
// Approve step 1 and move to step 2
|
// Approve step 1 and move to step 2
|
||||||
const level1 = await ApprovalLevel.findOne({
|
const level1 = await ApprovalLevel.findOne({
|
||||||
@ -880,8 +934,6 @@ export class DealerClaimService {
|
|||||||
numberOfParticipants?: number;
|
numberOfParticipants?: number;
|
||||||
closedExpenses: any[];
|
closedExpenses: any[];
|
||||||
totalClosedExpenses: number;
|
totalClosedExpenses: number;
|
||||||
completionDocuments: any[];
|
|
||||||
activityPhotos: any[];
|
|
||||||
invoicesReceipts?: any[];
|
invoicesReceipts?: any[];
|
||||||
attendanceSheet?: any;
|
attendanceSheet?: any;
|
||||||
}
|
}
|
||||||
@ -899,22 +951,36 @@ export class DealerClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save completion details
|
// Save completion details
|
||||||
await DealerCompletionDetails.upsert({
|
const [completionDetails] = await DealerCompletionDetails.upsert({
|
||||||
requestId,
|
requestId,
|
||||||
activityCompletionDate: completionData.activityCompletionDate,
|
activityCompletionDate: completionData.activityCompletionDate,
|
||||||
numberOfParticipants: completionData.numberOfParticipants,
|
numberOfParticipants: completionData.numberOfParticipants,
|
||||||
closedExpenses: completionData.closedExpenses,
|
|
||||||
totalClosedExpenses: completionData.totalClosedExpenses,
|
totalClosedExpenses: completionData.totalClosedExpenses,
|
||||||
completionDocuments: completionData.completionDocuments,
|
|
||||||
activityPhotos: completionData.activityPhotos,
|
|
||||||
submittedAt: new Date(),
|
submittedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update closed expenses in claim details
|
// Persist individual closed expenses to dealer_completion_expenses
|
||||||
await DealerClaimDetails.update(
|
const completionId = (completionDetails as any)?.completionId;
|
||||||
{ closedExpenses: completionData.totalClosedExpenses },
|
if (completionData.closedExpenses && completionData.closedExpenses.length > 0) {
|
||||||
{ where: { requestId } }
|
// 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
|
// Approve step 5 and move to step 6
|
||||||
const level5 = await ApprovalLevel.findOne({
|
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}`, {
|
logger.info(`[DealerClaimService] IO details updated for request: ${requestId}`, {
|
||||||
ioNumber: ioData.ioNumber,
|
ioNumber: ioData.ioNumber,
|
||||||
blockedAmount,
|
blockedAmount,
|
||||||
@ -1047,6 +1122,11 @@ export class DealerClaimService {
|
|||||||
throw new Error('Claim details not found');
|
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);
|
const request = await WorkflowRequest.findByPk(requestId);
|
||||||
if (!request) {
|
if (!request) {
|
||||||
throw new Error('Workflow request not found');
|
throw new Error('Workflow request not found');
|
||||||
@ -1062,7 +1142,11 @@ export class DealerClaimService {
|
|||||||
// If invoice data not provided, generate via DMS
|
// If invoice data not provided, generate via DMS
|
||||||
if (!invoiceData?.eInvoiceNumber) {
|
if (!invoiceData?.eInvoiceNumber) {
|
||||||
const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } });
|
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({
|
const invoiceResult = await dmsIntegrationService.generateEInvoice({
|
||||||
requestNumber,
|
requestNumber,
|
||||||
@ -1070,21 +1154,22 @@ export class DealerClaimService {
|
|||||||
dealerName: claimDetails.dealerName,
|
dealerName: claimDetails.dealerName,
|
||||||
amount: invoiceAmount,
|
amount: invoiceAmount,
|
||||||
description: invoiceData?.description || `E-Invoice for claim request ${requestNumber}`,
|
description: invoiceData?.description || `E-Invoice for claim request ${requestNumber}`,
|
||||||
ioNumber: claimDetails.ioNumber || undefined,
|
ioNumber: internalOrder?.ioNumber || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!invoiceResult.success) {
|
if (!invoiceResult.success) {
|
||||||
throw new Error(`Failed to generate e-invoice: ${invoiceResult.error}`);
|
throw new Error(`Failed to generate e-invoice: ${invoiceResult.error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await DealerClaimDetails.update(
|
await ClaimInvoice.upsert({
|
||||||
{
|
requestId,
|
||||||
eInvoiceNumber: invoiceResult.eInvoiceNumber,
|
invoiceNumber: invoiceResult.eInvoiceNumber,
|
||||||
eInvoiceDate: invoiceResult.invoiceDate || new Date(),
|
invoiceDate: invoiceResult.invoiceDate || new Date(),
|
||||||
dmsNumber: invoiceResult.dmsNumber,
|
dmsNumber: invoiceResult.dmsNumber,
|
||||||
},
|
amount: invoiceAmount,
|
||||||
{ where: { requestId } }
|
status: 'GENERATED',
|
||||||
);
|
description: invoiceData?.description || `E-Invoice for claim request ${requestNumber}`,
|
||||||
|
});
|
||||||
|
|
||||||
logger.info(`[DealerClaimService] E-Invoice generated via DMS for request: ${requestId}`, {
|
logger.info(`[DealerClaimService] E-Invoice generated via DMS for request: ${requestId}`, {
|
||||||
eInvoiceNumber: invoiceResult.eInvoiceNumber,
|
eInvoiceNumber: invoiceResult.eInvoiceNumber,
|
||||||
@ -1092,14 +1177,15 @@ export class DealerClaimService {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Manual entry - just update the fields
|
// Manual entry - just update the fields
|
||||||
await DealerClaimDetails.update(
|
await ClaimInvoice.upsert({
|
||||||
{
|
requestId,
|
||||||
eInvoiceNumber: invoiceData.eInvoiceNumber,
|
invoiceNumber: invoiceData.eInvoiceNumber,
|
||||||
eInvoiceDate: invoiceData.eInvoiceDate || new Date(),
|
invoiceDate: invoiceData.eInvoiceDate || new Date(),
|
||||||
dmsNumber: invoiceData.dmsNumber,
|
dmsNumber: invoiceData.dmsNumber,
|
||||||
},
|
amount: invoiceData.amount,
|
||||||
{ where: { requestId } }
|
status: 'UPDATED',
|
||||||
);
|
description: invoiceData.description,
|
||||||
|
});
|
||||||
|
|
||||||
logger.info(`[DealerClaimService] E-Invoice details manually updated for request: ${requestId}`);
|
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 e-invoice already generated, just auto-approve Step 7
|
||||||
if (claimDetails.eInvoiceNumber) {
|
const claimInvoice = await ClaimInvoice.findOne({ where: { requestId } });
|
||||||
logger.info(`[DealerClaimService] E-Invoice already generated (${claimDetails.eInvoiceNumber}). Auto-approving Step 7 for request ${requestId}`);
|
|
||||||
|
if (claimInvoice?.invoiceNumber) {
|
||||||
|
logger.info(`[DealerClaimService] E-Invoice already generated (${claimInvoice.invoiceNumber}). Auto-approving Step 7 for request ${requestId}`);
|
||||||
|
|
||||||
// Auto-approve Step 7
|
// Auto-approve Step 7
|
||||||
await this.approvalService.approveLevel(
|
await this.approvalService.approveLevel(
|
||||||
@ -1209,7 +1297,11 @@ export class DealerClaimService {
|
|||||||
throw new Error('Claim details not found');
|
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');
|
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 credit note data not provided, generate via DMS
|
||||||
if (!creditNoteData?.creditNoteNumber) {
|
if (!creditNoteData?.creditNoteNumber) {
|
||||||
const creditNoteAmount = creditNoteData?.creditNoteAmount || claimDetails.closedExpenses || 0;
|
const creditNoteAmount = creditNoteData?.creditNoteAmount
|
||||||
|
|| budgetTracking?.closedExpenses
|
||||||
|
|| completionDetails?.totalClosedExpenses
|
||||||
|
|| 0;
|
||||||
|
|
||||||
const creditNoteResult = await dmsIntegrationService.generateCreditNote({
|
const creditNoteResult = await dmsIntegrationService.generateCreditNote({
|
||||||
requestNumber,
|
requestNumber,
|
||||||
eInvoiceNumber: claimDetails.eInvoiceNumber,
|
eInvoiceNumber: claimInvoice.invoiceNumber,
|
||||||
dealerCode: claimDetails.dealerCode,
|
dealerCode: claimDetails.dealerCode,
|
||||||
dealerName: claimDetails.dealerName,
|
dealerName: claimDetails.dealerName,
|
||||||
amount: creditNoteAmount,
|
amount: creditNoteAmount,
|
||||||
@ -1234,14 +1329,15 @@ export class DealerClaimService {
|
|||||||
throw new Error(`Failed to generate credit note: ${creditNoteResult.error}`);
|
throw new Error(`Failed to generate credit note: ${creditNoteResult.error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await DealerClaimDetails.update(
|
await ClaimCreditNote.upsert({
|
||||||
{
|
requestId,
|
||||||
creditNoteNumber: creditNoteResult.creditNoteNumber,
|
creditNoteNumber: creditNoteResult.creditNoteNumber,
|
||||||
creditNoteDate: creditNoteResult.creditNoteDate || new Date(),
|
creditNoteDate: creditNoteResult.creditNoteDate || new Date(),
|
||||||
creditNoteAmount: creditNoteResult.creditNoteAmount,
|
creditNoteAmount: creditNoteResult.creditNoteAmount,
|
||||||
},
|
status: 'GENERATED',
|
||||||
{ where: { requestId } }
|
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}`, {
|
logger.info(`[DealerClaimService] Credit note generated via DMS for request: ${requestId}`, {
|
||||||
creditNoteNumber: creditNoteResult.creditNoteNumber,
|
creditNoteNumber: creditNoteResult.creditNoteNumber,
|
||||||
@ -1249,14 +1345,15 @@ export class DealerClaimService {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Manual entry - just update the fields
|
// Manual entry - just update the fields
|
||||||
await DealerClaimDetails.update(
|
await ClaimCreditNote.upsert({
|
||||||
{
|
requestId,
|
||||||
creditNoteNumber: creditNoteData.creditNoteNumber,
|
creditNoteNumber: creditNoteData.creditNoteNumber,
|
||||||
creditNoteDate: creditNoteData.creditNoteDate || new Date(),
|
creditNoteDate: creditNoteData.creditNoteDate || new Date(),
|
||||||
creditNoteAmount: creditNoteData.creditNoteAmount,
|
creditNoteAmount: creditNoteData.creditNoteAmount,
|
||||||
},
|
status: 'UPDATED',
|
||||||
{ where: { requestId } }
|
reason: creditNoteData?.reason,
|
||||||
);
|
description: creditNoteData?.description,
|
||||||
|
});
|
||||||
|
|
||||||
logger.info(`[DealerClaimService] Credit note details manually updated for request: ${requestId}`);
|
logger.info(`[DealerClaimService] Credit note details manually updated for request: ${requestId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,10 +16,83 @@ interface OktaUser {
|
|||||||
login: string;
|
login: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
mobilePhone?: 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 {
|
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> {
|
async createOrUpdateUser(ssoData: SSOUserData): Promise<UserModel> {
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!ssoData.email || !ssoData.oktaSub) {
|
if (!ssoData.email || !ssoData.oktaSub) {
|
||||||
@ -36,44 +109,18 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
// Update existing user - include oktaSub to ensure it's synced
|
// Update existing user - DO NOT update email (crucial identifier)
|
||||||
await existingUser.update({
|
const updatePayload = this.buildUserPayload(ssoData, existingUser.role, true); // isUpdate = true
|
||||||
email: ssoData.email,
|
|
||||||
oktaSub: ssoData.oktaSub,
|
await existingUser.update(updatePayload);
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
return existingUser;
|
return existingUser;
|
||||||
} else {
|
} else {
|
||||||
// Create new user - oktaSub is required
|
// Create new user - oktaSub is required, email is included
|
||||||
const newUser = await UserModel.create({
|
const createPayload = this.buildUserPayload(ssoData, 'USER', false); // isUpdate = false
|
||||||
email: ssoData.email,
|
|
||||||
oktaSub: ssoData.oktaSub, // Required
|
const newUser = await UserModel.create(createPayload);
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
return newUser;
|
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 {
|
try {
|
||||||
const oktaDomain = process.env.OKTA_DOMAIN;
|
const oktaDomain = process.env.OKTA_DOMAIN;
|
||||||
const oktaApiToken = process.env.OKTA_API_TOKEN;
|
const oktaApiToken = process.env.OKTA_API_TOKEN;
|
||||||
@ -232,7 +280,25 @@ export class UserService {
|
|||||||
return null;
|
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`, {
|
const response = await axios.get(`${oktaDomain}/api/v1/users`, {
|
||||||
params: { search: `profile.email eq "${email}"`, limit: 1 },
|
params: { search: `profile.email eq "${email}"`, limit: 1 },
|
||||||
headers: {
|
headers: {
|
||||||
@ -242,14 +308,42 @@ export class UserService {
|
|||||||
timeout: 5000
|
timeout: 5000
|
||||||
});
|
});
|
||||||
|
|
||||||
const users: OktaUser[] = response.data || [];
|
const users: any[] = response.data || [];
|
||||||
return users.length > 0 ? users[0] : null;
|
if (users.length > 0) {
|
||||||
|
return extractOktaUserData(users[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`Failed to fetch user from Okta by email ${email}:`, error.message);
|
console.error(`Failed to fetch user from Okta by email ${email}:`, error.message);
|
||||||
return null;
|
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)
|
* Ensure user exists in database (create if not exists)
|
||||||
* Used when tagging users from Okta search results or when only email is provided
|
* Used when tagging users from Okta search results or when only email is provided
|
||||||
|
|||||||
@ -10,6 +10,13 @@ export interface SSOUserData {
|
|||||||
phone?: string;
|
phone?: string;
|
||||||
reportingManagerId?: string;
|
reportingManagerId?: string;
|
||||||
manager?: string; // Optional - Manager name from Okta profile
|
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 {
|
export interface SSOConfig {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user