From ad18ec54e9fcf08c3a17c3db84d5270325ccb3f5 Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Wed, 10 Dec 2025 20:43:56 +0530 Subject: [PATCH] delaer claim related extra tables added like internalorder budget locking migration done. --- docs/DEALER_CLAIM_FRESH_START.md | 181 +++++++ docs/MIGRATION_SETUP_SUMMARY.md | 164 +++++++ docs/NEW_TABLES_SUMMARY.md | 216 +++++++++ package.json | 3 +- src/controllers/dealerClaim.controller.ts | 225 ++++++++- ...1211-create-claim-budget-tracking-table.ts | 197 ++++++++ .../20251211-create-internal-orders-table.ts | 95 ++++ src/models/ClaimBudgetTracking.ts | 295 ++++++++++++ src/models/DealerClaimDetails.ts | 3 + src/models/InternalOrder.ts | 166 +++++++ src/models/index.ts | 20 +- src/routes/dealerClaim.routes.ts | 7 +- src/scripts/auto-setup.ts | 14 + src/scripts/cleanup-dealer-claims.ts | 167 +++++++ src/scripts/migrate.ts | 4 + src/services/approval.service.ts | 56 ++- src/services/dealerClaim.service.ts | 455 +++++++++++++++++- 17 files changed, 2237 insertions(+), 31 deletions(-) create mode 100644 docs/DEALER_CLAIM_FRESH_START.md create mode 100644 docs/MIGRATION_SETUP_SUMMARY.md create mode 100644 docs/NEW_TABLES_SUMMARY.md create mode 100644 src/migrations/20251211-create-claim-budget-tracking-table.ts create mode 100644 src/migrations/20251211-create-internal-orders-table.ts create mode 100644 src/models/ClaimBudgetTracking.ts create mode 100644 src/models/InternalOrder.ts create mode 100644 src/scripts/cleanup-dealer-claims.ts diff --git a/docs/DEALER_CLAIM_FRESH_START.md b/docs/DEALER_CLAIM_FRESH_START.md new file mode 100644 index 0000000..f5ef5e3 --- /dev/null +++ b/docs/DEALER_CLAIM_FRESH_START.md @@ -0,0 +1,181 @@ +# Dealer Claim Management - Fresh Start Guide + +## Overview + +This guide helps you start fresh with the dealer claim management system by cleaning up all existing data and ensuring the database structure is ready for new requests. + +## Prerequisites + +1. **Database Migrations**: Ensure all migrations are up to date, including the new tables: + - `internal_orders` (for IO details) + - `claim_budget_tracking` (for comprehensive budget tracking) + +2. **Backup** (Optional but Recommended): + - If you have important data, backup your database before running cleanup + +## Fresh Start Steps + +### Step 1: Run Database Migrations + +Ensure all new tables are created: + +```bash +cd Re_Backend +npm run migrate +``` + +This will create: +- ✅ `internal_orders` table (for IO details with `ioRemark`) +- ✅ `claim_budget_tracking` table (for comprehensive budget tracking) +- ✅ All other dealer claim related tables + +### Step 2: Clean Up All Existing Dealer Claims + +Run the cleanup script to remove all existing CLAIM_MANAGEMENT requests: + +```bash +npm run cleanup:dealer-claims +``` + +**What this script does:** +- Finds all workflow requests with `workflow_type = 'CLAIM_MANAGEMENT'` +- Deletes all related data from: + - `claim_budget_tracking` + - `internal_orders` + - `dealer_proposal_cost_items` + - `dealer_completion_details` + - `dealer_proposal_details` + - `dealer_claim_details` + - `activities` + - `work_notes` + - `documents` + - `participants` + - `approval_levels` + - `subscriptions` + - `notifications` + - `request_summaries` + - `shared_summaries` + - `conclusion_remarks` + - `tat_alerts` + - `workflow_requests` (finally) + +**Note:** This script uses a database transaction, so if any step fails, all changes will be rolled back. + +### Step 3: Verify Cleanup + +After running the cleanup script, verify that no CLAIM_MANAGEMENT requests remain: + +```sql +SELECT COUNT(*) FROM workflow_requests WHERE workflow_type = 'CLAIM_MANAGEMENT'; +-- Should return 0 +``` + +### Step 4: Seed Dealers (If Needed) + +If you need to seed dealer users: + +```bash +npm run seed:dealers +``` + +## Database Structure Summary + +### New Tables Created + +1. **`internal_orders`** - Dedicated table for IO (Internal Order) details + - `io_id` (PK) + - `request_id` (FK, unique) + - `io_number` + - `io_remark` ✅ (dedicated field, not in comments) + - `io_available_balance` + - `io_blocked_amount` + - `io_remaining_balance` + - `organized_by` (FK to users) + - `organized_at` + - `status` (PENDING, BLOCKED, RELEASED, CANCELLED) + +2. **`claim_budget_tracking`** - Comprehensive budget tracking + - `budget_id` (PK) + - `request_id` (FK, unique) + - `initial_estimated_budget` + - `proposal_estimated_budget` + - `approved_budget` + - `io_blocked_amount` + - `closed_expenses` + - `final_claim_amount` + - `credit_note_amount` + - `budget_status` (DRAFT, PROPOSED, APPROVED, BLOCKED, CLOSED, SETTLED) + - `variance_amount` & `variance_percentage` + - Audit fields (last_modified_by, last_modified_at, modification_reason) + +### Existing Tables (Enhanced) + +- `dealer_claim_details` - Main claim information +- `dealer_proposal_details` - Step 1: Dealer proposal +- `dealer_proposal_cost_items` - Cost breakdown items +- `dealer_completion_details` - Step 5: Completion documents + +## What's New + +### 1. IO Details in Separate Table +- ✅ IO remark is now stored in `internal_orders.io_remark` (not parsed from comments) +- ✅ Tracks who organized the IO (`organized_by`, `organized_at`) +- ✅ Better data integrity and querying + +### 2. Comprehensive Budget Tracking +- ✅ All budget-related values in one place +- ✅ Tracks budget lifecycle (DRAFT → PROPOSED → APPROVED → BLOCKED → CLOSED → SETTLED) +- ✅ Calculates variance automatically +- ✅ Audit trail for budget modifications + +### 3. Proper Data Structure +- ✅ Estimated budget: `claimDetails.estimatedBudget` or `proposalDetails.totalEstimatedBudget` +- ✅ Claim amount: `completionDetails.totalClosedExpenses` or `budgetTracking.finalClaimAmount` +- ✅ IO details: `internalOrder` table (separate, dedicated) +- ✅ E-Invoice: `claimDetails.eInvoiceNumber`, `claimDetails.eInvoiceDate` +- ✅ Credit Note: `claimDetails.creditNoteNumber`, `claimDetails.creditNoteAmount` + +## Next Steps After Cleanup + +1. **Create New Claim Requests**: Use the API or frontend to create fresh dealer claim requests +2. **Test Workflow**: Go through the 8-step workflow to ensure everything works correctly +3. **Verify Data Storage**: Check that IO details and budget tracking are properly stored + +## Troubleshooting + +### If Cleanup Fails + +1. Check database connection +2. Verify foreign key constraints are not blocking deletion +3. Check logs for specific error messages +4. The script uses transactions, so partial deletions won't occur + +### If Tables Don't Exist + +Run migrations again: +```bash +npm run migrate +``` + +### If You Need to Restore Data + +If you backed up before cleanup, restore from your backup. The cleanup script does not create backups automatically. + +## API Endpoints Ready + +After cleanup, you can use these endpoints: + +- `POST /api/v1/dealer-claims` - Create new claim request +- `POST /api/v1/dealer-claims/:requestId/proposal` - Submit proposal (Step 1) +- `PUT /api/v1/dealer-claims/:requestId/io` - Update IO details (Step 3) +- `POST /api/v1/dealer-claims/:requestId/completion` - Submit completion (Step 5) +- `PUT /api/v1/dealer-claims/:requestId/e-invoice` - Update e-invoice (Step 7) +- `PUT /api/v1/dealer-claims/:requestId/credit-note` - Update credit note (Step 8) + +## Summary + +✅ **Cleanup Script**: `npm run cleanup:dealer-claims` +✅ **Migrations**: `npm run migrate` +✅ **Fresh Start**: Database is ready for new dealer claim requests +✅ **Proper Structure**: IO details and budget tracking in dedicated tables + diff --git a/docs/MIGRATION_SETUP_SUMMARY.md b/docs/MIGRATION_SETUP_SUMMARY.md new file mode 100644 index 0000000..ff8a8ec --- /dev/null +++ b/docs/MIGRATION_SETUP_SUMMARY.md @@ -0,0 +1,164 @@ +# Migration and Setup Summary + +## ✅ Current Status + +### Tables Created by Migrations + +All **6 new dealer claim tables** are included in the migration system: + +1. ✅ `dealer_claim_details` - Main claim information +2. ✅ `dealer_proposal_details` - Step 1: Dealer proposal +3. ✅ `dealer_completion_details` - Step 5: Completion documents +4. ✅ `dealer_proposal_cost_items` - Cost breakdown items +5. ✅ `internal_orders` ⭐ - IO details with dedicated fields +6. ✅ `claim_budget_tracking` ⭐ - Comprehensive budget tracking + +## Migration Commands + +### 1. **`npm run migrate`** ✅ +**Status:** ✅ **Fully configured** + +This command runs `src/scripts/migrate.ts` which includes **ALL** migrations including: +- ✅ All dealer claim tables (m25-m28) +- ✅ New tables: `internal_orders` (m27) and `claim_budget_tracking` (m28) + +**Usage:** +```bash +npm run migrate +``` + +**What it does:** +- Checks which migrations have already run (via `migrations` table) +- Runs only pending migrations +- Marks them as executed +- Creates all new tables automatically + +--- + +### 2. **`npm run dev`** ✅ +**Status:** ✅ **Now fixed and configured** + +This command runs: +```bash +npm run setup && nodemon --exec ts-node ... +``` + +Which calls `npm run setup` → `src/scripts/auto-setup.ts` + +**What `auto-setup.ts` does:** +1. ✅ Checks if database exists, creates if missing +2. ✅ Installs PostgreSQL extensions (uuid-ossp) +3. ✅ **Runs all pending migrations** (including dealer claim tables) +4. ✅ Tests database connection + +**Fixed:** ✅ Now includes all dealer claim migrations (m29-m35) + +**Usage:** +```bash +npm run dev +``` + +This will automatically: +- Create database if needed +- Run all migrations (including new tables) +- Start the development server + +--- + +### 3. **`npm run setup`** ✅ +**Status:** ✅ **Now fixed and configured** + +Same as what `npm run dev` calls - runs `auto-setup.ts` + +**Usage:** +```bash +npm run setup +``` + +--- + +## Migration Files Included + +### In `migrate.ts` (for `npm run migrate`): +- ✅ `20251210-add-workflow-type-support` (m22) +- ✅ `20251210-enhance-workflow-templates` (m23) +- ✅ `20251210-add-template-id-foreign-key` (m24) +- ✅ `20251210-create-dealer-claim-tables` (m25) - Creates 3 tables +- ✅ `20251210-create-proposal-cost-items-table` (m26) +- ✅ `20251211-create-internal-orders-table` (m27) ⭐ NEW +- ✅ `20251211-create-claim-budget-tracking-table` (m28) ⭐ NEW + +### In `auto-setup.ts` (for `npm run dev` / `npm run setup`): +- ✅ All migrations from `migrate.ts` are now included (m29-m35) + +--- + +## What Gets Created + +When you run either `npm run migrate` or `npm run dev`, these tables will be created: + +### Dealer Claim Tables (from `20251210-create-dealer-claim-tables.ts`): +1. `dealer_claim_details` +2. `dealer_proposal_details` +3. `dealer_completion_details` + +### Additional Tables: +4. `dealer_proposal_cost_items` (from `20251210-create-proposal-cost-items-table.ts`) +5. `internal_orders` ⭐ (from `20251211-create-internal-orders-table.ts`) +6. `claim_budget_tracking` ⭐ (from `20251211-create-claim-budget-tracking-table.ts`) + +--- + +## Verification + +After running migrations, verify tables exist: + +```sql +-- Check if new tables exist +SELECT table_name +FROM information_schema.tables +WHERE table_schema = 'public' +AND table_name IN ( + 'dealer_claim_details', + 'dealer_proposal_details', + 'dealer_completion_details', + 'dealer_proposal_cost_items', + 'internal_orders', + 'claim_budget_tracking' +) +ORDER BY table_name; +``` + +Should return 6 rows. + +--- + +## Summary + +| Command | Runs Migrations? | Includes New Tables? | Status | +|---------|------------------|---------------------|--------| +| `npm run migrate` | ✅ Yes | ✅ Yes | ✅ Working | +| `npm run dev` | ✅ Yes | ✅ Yes | ✅ Fixed | +| `npm run setup` | ✅ Yes | ✅ Yes | ✅ Fixed | + +**All commands now create the new tables automatically!** 🎉 + +--- + +## Next Steps + +1. **Run migrations:** + ```bash + npm run migrate + ``` + OR + ```bash + npm run dev # This will also run migrations via setup + ``` + +2. **Verify tables created:** + Check the database to confirm all 6 tables exist. + +3. **Start using:** + The tables are ready for dealer claim management! + diff --git a/docs/NEW_TABLES_SUMMARY.md b/docs/NEW_TABLES_SUMMARY.md new file mode 100644 index 0000000..23f7987 --- /dev/null +++ b/docs/NEW_TABLES_SUMMARY.md @@ -0,0 +1,216 @@ +# New Tables Created for Dealer Claim Management + +## Overview + +This document lists all the new database tables created specifically for the Dealer Claim Management system. + +## Tables Created + +### 1. **`dealer_claim_details`** +**Migration:** `20251210-create-dealer-claim-tables.ts` + +**Purpose:** Main table storing claim-specific information + +**Key Fields:** +- `claim_id` (PK) +- `request_id` (FK to `workflow_requests`, unique) +- `activity_name`, `activity_type` +- `dealer_code`, `dealer_name`, `dealer_email`, `dealer_phone`, `dealer_address` +- `activity_date`, `location` +- `period_start_date`, `period_end_date` +- `estimated_budget`, `closed_expenses` +- `io_number`, `io_available_balance`, `io_blocked_amount`, `io_remaining_balance` (legacy - now in `internal_orders`) +- `sap_document_number`, `dms_number` +- `e_invoice_number`, `e_invoice_date` +- `credit_note_number`, `credit_note_date`, `credit_note_amount` + +**Created:** December 10, 2025 + +--- + +### 2. **`dealer_proposal_details`** +**Migration:** `20251210-create-dealer-claim-tables.ts` + +**Purpose:** Stores dealer proposal submission data (Step 1 of workflow) + +**Key Fields:** +- `proposal_id` (PK) +- `request_id` (FK to `workflow_requests`, unique) +- `proposal_document_path`, `proposal_document_url` +- `cost_breakup` (JSONB - legacy, now use `dealer_proposal_cost_items`) +- `total_estimated_budget` +- `timeline_mode` ('date' | 'days') +- `expected_completion_date`, `expected_completion_days` +- `dealer_comments` +- `submitted_at` + +**Created:** December 10, 2025 + +--- + +### 3. **`dealer_completion_details`** +**Migration:** `20251210-create-dealer-claim-tables.ts` + +**Purpose:** Stores dealer completion documents and expenses (Step 5 of workflow) + +**Key Fields:** +- `completion_id` (PK) +- `request_id` (FK to `workflow_requests`, unique) +- `activity_completion_date` +- `number_of_participants` +- `closed_expenses` (JSONB array) +- `total_closed_expenses` +- `completion_documents` (JSONB array) +- `activity_photos` (JSONB array) +- `submitted_at` + +**Created:** December 10, 2025 + +--- + +### 4. **`dealer_proposal_cost_items`** +**Migration:** `20251210-create-proposal-cost-items-table.ts` + +**Purpose:** Separate table for cost breakdown items (replaces JSONB in `dealer_proposal_details`) + +**Key Fields:** +- `cost_item_id` (PK) +- `proposal_id` (FK to `dealer_proposal_details`) +- `request_id` (FK to `workflow_requests` - denormalized for easier querying) +- `item_description` +- `amount` (DECIMAL 15,2) +- `item_order` (for maintaining order in cost breakdown) + +**Benefits:** +- Better querying and filtering +- Easier to update individual cost items +- Better for analytics and reporting +- Maintains referential integrity + +**Created:** December 10, 2025 + +--- + +### 5. **`internal_orders`** ⭐ NEW +**Migration:** `20251211-create-internal-orders-table.ts` + +**Purpose:** Dedicated table for IO (Internal Order) details with proper structure + +**Key Fields:** +- `io_id` (PK) +- `request_id` (FK to `workflow_requests`, unique - one IO per request) +- `io_number` (STRING 50) +- `io_remark` (TEXT) ⭐ - Dedicated field for IO remarks (not in comments) +- `io_available_balance` (DECIMAL 15,2) +- `io_blocked_amount` (DECIMAL 15,2) +- `io_remaining_balance` (DECIMAL 15,2) +- `organized_by` (FK to `users`) ⭐ - Tracks who organized the IO +- `organized_at` (DATE) ⭐ - When IO was organized +- `sap_document_number` (STRING 100) +- `status` (ENUM: 'PENDING', 'BLOCKED', 'RELEASED', 'CANCELLED') + +**Why This Table:** +- Previously IO details were stored in `dealer_claim_details` table +- IO remark was being parsed from comments +- Now dedicated table with proper fields and relationships +- Better data integrity and querying + +**Created:** December 11, 2025 + +--- + +### 6. **`claim_budget_tracking`** ⭐ NEW +**Migration:** `20251211-create-claim-budget-tracking-table.ts` + +**Purpose:** Comprehensive budget tracking throughout the claim lifecycle + +**Key Fields:** +- `budget_id` (PK) +- `request_id` (FK to `workflow_requests`, unique - one budget record per request) + +**Budget Values:** +- `initial_estimated_budget` - From claim creation +- `proposal_estimated_budget` - From Step 1 (Dealer Proposal) +- `approved_budget` - From Step 2 (Requestor Evaluation) +- `io_blocked_amount` - From Step 3 (Department Lead - IO blocking) +- `closed_expenses` - From Step 5 (Dealer Completion) +- `final_claim_amount` - From Step 6 (Requestor Claim Approval) +- `credit_note_amount` - From Step 8 (Finance) + +**Tracking Fields:** +- `proposal_submitted_at` +- `approved_at`, `approved_by` (FK to `users`) +- `io_blocked_at` +- `closed_expenses_submitted_at` +- `final_claim_amount_approved_at`, `final_claim_amount_approved_by` (FK to `users`) +- `credit_note_issued_at` + +**Status & Analysis:** +- `budget_status` (ENUM: 'DRAFT', 'PROPOSED', 'APPROVED', 'BLOCKED', 'CLOSED', 'SETTLED') +- `currency` (STRING 3, default: 'INR') +- `variance_amount` - Difference between approved and closed expenses +- `variance_percentage` - Variance as percentage + +**Audit Fields:** +- `last_modified_by` (FK to `users`) +- `last_modified_at` +- `modification_reason` (TEXT) + +**Why This Table:** +- Previously budget data was scattered across multiple tables +- No single source of truth for budget lifecycle +- No audit trail for budget modifications +- Now comprehensive tracking with status and variance calculation + +**Created:** December 11, 2025 + +--- + +## Summary + +### Total New Tables: **6** + +1. ✅ `dealer_claim_details` - Main claim information +2. ✅ `dealer_proposal_details` - Step 1: Dealer proposal +3. ✅ `dealer_completion_details` - Step 5: Completion documents +4. ✅ `dealer_proposal_cost_items` - Cost breakdown items +5. ✅ `internal_orders` ⭐ - IO details with dedicated fields +6. ✅ `claim_budget_tracking` ⭐ - Comprehensive budget tracking + +### Most Recent Additions (December 11, 2025): +- **`internal_orders`** - Proper IO data structure with `ioRemark` field +- **`claim_budget_tracking`** - Complete budget lifecycle tracking + +## Migration Order + +Run migrations in this order: +```bash +npm run migrate +``` + +The migrations will run in chronological order: +1. `20251210-create-dealer-claim-tables.ts` (creates tables 1-3) +2. `20251210-create-proposal-cost-items-table.ts` (creates table 4) +3. `20251211-create-internal-orders-table.ts` (creates table 5) +4. `20251211-create-claim-budget-tracking-table.ts` (creates table 6) + +## Relationships + +``` +workflow_requests (1) + ├── dealer_claim_details (1:1) + ├── dealer_proposal_details (1:1) + │ └── dealer_proposal_cost_items (1:many) + ├── dealer_completion_details (1:1) + ├── internal_orders (1:1) ⭐ NEW + └── claim_budget_tracking (1:1) ⭐ NEW +``` + +## Notes + +- All tables have `request_id` foreign key to `workflow_requests` +- Most tables have unique constraint on `request_id` (one record per request) +- `dealer_proposal_cost_items` can have multiple items per proposal +- All tables use UUID primary keys +- All tables have `created_at` and `updated_at` timestamps + diff --git a/package.json b/package.json index bd375f1..4f5923d 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "setup": "ts-node -r tsconfig-paths/register src/scripts/auto-setup.ts", "migrate": "ts-node -r tsconfig-paths/register src/scripts/migrate.ts", "seed:config": "ts-node -r tsconfig-paths/register src/scripts/seed-admin-config.ts", - "seed:dealers": "ts-node -r tsconfig-paths/register src/scripts/seed-dealers.ts" + "seed:dealers": "ts-node -r tsconfig-paths/register src/scripts/seed-dealers.ts", + "cleanup:dealer-claims": "ts-node -r tsconfig-paths/register src/scripts/cleanup-dealer-claims.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.68.0", diff --git a/src/controllers/dealerClaim.controller.ts b/src/controllers/dealerClaim.controller.ts index d7a19d3..8f56842 100644 --- a/src/controllers/dealerClaim.controller.ts +++ b/src/controllers/dealerClaim.controller.ts @@ -4,8 +4,11 @@ import { DealerClaimService } from '../services/dealerClaim.service'; import { ResponseHandler } from '../utils/responseHandler'; import logger from '../utils/logger'; import { gcsStorageService } from '../services/gcsStorage.service'; +import { Document } from '../models/Document'; +import { User } from '../models/User'; import fs from 'fs'; import path from 'path'; +import crypto from 'crypto'; export class DealerClaimController { private dealerClaimService = new DealerClaimService(); @@ -198,6 +201,38 @@ export class DealerClaimController { proposalDocumentPath = uploadResult.filePath; proposalDocumentUrl = uploadResult.storageUrl; + // Save to documents table with category APPROVAL (proposal document) + try { + const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex'); + const extension = path.extname(file.originalname).replace('.', '').toLowerCase(); + + await Document.create({ + requestId, + uploadedBy: userId, + fileName: path.basename(file.filename || file.originalname), + originalFileName: file.originalname, + fileType: extension, + fileExtension: extension, + fileSize: file.size, + filePath: uploadResult.filePath, + storageUrl: uploadResult.storageUrl, + mimeType: file.mimetype, + checksum, + isGoogleDoc: false, + googleDocUrl: null as any, + category: 'APPROVAL', // Proposal document is an approval document + version: 1, + parentDocumentId: null as any, + isDeleted: false, + downloadCount: 0, + } as any); + + logger.info(`[DealerClaimController] Created document entry for proposal document ${file.originalname}`); + } catch (docError) { + logger.error(`[DealerClaimController] Error creating document entry for proposal document:`, docError); + // Don't fail the entire request if document entry creation fails + } + // Cleanup local file if exists if (file.path && fs.existsSync(file.path)) { fs.unlinkSync(file.path); @@ -237,10 +272,26 @@ export class DealerClaimController { numberOfParticipants, closedExpenses, totalClosedExpenses, - completionDocuments, - activityPhotos, } = req.body; + // Parse closedExpenses if it's a JSON string + let parsedClosedExpenses: any[] = []; + if (closedExpenses) { + try { + parsedClosedExpenses = typeof closedExpenses === 'string' ? JSON.parse(closedExpenses) : closedExpenses; + } catch (e) { + logger.warn('[DealerClaimController] Failed to parse closedExpenses JSON:', e); + parsedClosedExpenses = []; + } + } + + // Get files from multer + const files = req.files as { [fieldname: string]: Express.Multer.File[] } | undefined; + const completionDocumentsFiles = files?.completionDocuments || []; + const activityPhotosFiles = files?.activityPhotos || []; + const invoicesReceiptsFiles = files?.invoicesReceipts || []; + const attendanceSheetFile = files?.attendanceSheet?.[0]; + // Find workflow to get actual UUID const workflow = await this.findWorkflowByIdentifier(identifier); if (!workflow) { @@ -248,6 +299,7 @@ export class DealerClaimController { } const requestId = (workflow as any).requestId || (workflow as any).request_id; + const requestNumber = (workflow as any).requestNumber || (workflow as any).request_number || 'UNKNOWN'; if (!requestId) { return ResponseHandler.error(res, 'Invalid workflow request', 400); } @@ -256,13 +308,157 @@ export class DealerClaimController { return ResponseHandler.error(res, 'Activity completion date is required', 400); } + const userId = req.user?.userId; + if (!userId) { + return ResponseHandler.error(res, 'Unauthorized', 401); + } + + // Upload files to GCS and get URLs, and save to documents table + const completionDocuments: any[] = []; + const activityPhotos: any[] = []; + + // Helper function to create document entry + const createDocumentEntry = async ( + file: Express.Multer.File, + uploadResult: { storageUrl: string; filePath: string }, + category: 'APPROVAL' | 'SUPPORTING' | 'REFERENCE' | 'FINAL' | 'OTHER' + ): Promise => { + try { + const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from('')); + 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, + version: 1, + parentDocumentId: null as any, + isDeleted: false, + downloadCount: 0, + } as any); + + logger.info(`[DealerClaimController] Created document entry for ${file.originalname} with category ${category}`); + } catch (docError) { + logger.error(`[DealerClaimController] Error creating document entry for ${file.originalname}:`, docError); + // Don't fail the entire request if document entry creation fails + } + }; + + // Upload completion documents (category: APPROVAL - these are proof of completion) + for (const file of completionDocumentsFiles) { + try { + const uploadResult = await gcsStorageService.uploadFileWithFallback({ + buffer: file.buffer, + originalName: file.originalname, + mimeType: file.mimetype, + requestNumber: requestNumber, + fileType: 'documents' + }); + completionDocuments.push({ + name: file.originalname, + url: uploadResult.storageUrl, + size: file.size, + type: file.mimetype, + }); + // Save to documents table + await createDocumentEntry(file, uploadResult, 'APPROVAL'); + } catch (error) { + logger.error(`[DealerClaimController] Error uploading completion document ${file.originalname}:`, error); + } + } + + // Upload activity photos (category: SUPPORTING - supporting evidence) + for (const file of activityPhotosFiles) { + try { + const uploadResult = await gcsStorageService.uploadFileWithFallback({ + buffer: file.buffer, + originalName: file.originalname, + mimeType: file.mimetype, + requestNumber: requestNumber, + fileType: 'attachments' + }); + activityPhotos.push({ + name: file.originalname, + url: uploadResult.storageUrl, + size: file.size, + type: file.mimetype, + }); + // Save to documents table + await createDocumentEntry(file, uploadResult, 'SUPPORTING'); + } catch (error) { + logger.error(`[DealerClaimController] Error uploading activity photo ${file.originalname}:`, error); + } + } + + // Upload invoices/receipts if provided (category: SUPPORTING - supporting financial documents) + const invoicesReceipts: any[] = []; + for (const file of invoicesReceiptsFiles) { + try { + const uploadResult = await gcsStorageService.uploadFileWithFallback({ + buffer: file.buffer, + originalName: file.originalname, + mimeType: file.mimetype, + requestNumber: requestNumber, + fileType: 'attachments' + }); + invoicesReceipts.push({ + name: file.originalname, + url: uploadResult.storageUrl, + size: file.size, + type: file.mimetype, + }); + // Save to documents table + await createDocumentEntry(file, uploadResult, 'SUPPORTING'); + } catch (error) { + logger.error(`[DealerClaimController] Error uploading invoice/receipt ${file.originalname}:`, error); + } + } + + // Upload attendance sheet if provided (category: SUPPORTING - supporting evidence) + let attendanceSheet: any = null; + if (attendanceSheetFile) { + try { + const uploadResult = await gcsStorageService.uploadFileWithFallback({ + buffer: attendanceSheetFile.buffer, + originalName: attendanceSheetFile.originalname, + mimeType: attendanceSheetFile.mimetype, + requestNumber: requestNumber, + fileType: 'attachments' + }); + attendanceSheet = { + name: attendanceSheetFile.originalname, + url: uploadResult.storageUrl, + size: attendanceSheetFile.size, + type: attendanceSheetFile.mimetype, + }; + // Save to documents table + await createDocumentEntry(attendanceSheetFile, uploadResult, 'SUPPORTING'); + } catch (error) { + logger.error(`[DealerClaimController] Error uploading attendance sheet:`, error); + } + } + await this.dealerClaimService.submitCompletionDocuments(requestId, { activityCompletionDate: new Date(activityCompletionDate), numberOfParticipants: numberOfParticipants ? parseInt(numberOfParticipants) : undefined, - closedExpenses: closedExpenses || [], + closedExpenses: parsedClosedExpenses, totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0, - completionDocuments: completionDocuments || [], - activityPhotos: activityPhotos || [], + completionDocuments, + activityPhotos, + invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined, + attendanceSheet: attendanceSheet || undefined, }); return ResponseHandler.success(res, { message: 'Completion documents submitted successfully' }, 'Completion submitted'); @@ -279,10 +475,12 @@ export class DealerClaimController { * Accepts either UUID or requestNumber */ async updateIODetails(req: AuthenticatedRequest, res: Response): Promise { + const userId = (req as any).user?.userId || (req as any).user?.user_id; try { const identifier = req.params.requestId; // Can be UUID or requestNumber const { ioNumber, + ioRemark, availableBalance, blockedAmount, remainingBalance, @@ -303,12 +501,17 @@ export class DealerClaimController { return ResponseHandler.error(res, 'Missing required IO fields', 400); } - await this.dealerClaimService.updateIODetails(requestId, { - ioNumber, - availableBalance: parseFloat(availableBalance), - blockedAmount: parseFloat(blockedAmount), - remainingBalance: remainingBalance ? parseFloat(remainingBalance) : parseFloat(availableBalance) - parseFloat(blockedAmount), - }); + await this.dealerClaimService.updateIODetails( + requestId, + { + ioNumber, + ioRemark: ioRemark || '', + availableBalance: parseFloat(availableBalance), + blockedAmount: parseFloat(blockedAmount), + remainingBalance: remainingBalance ? parseFloat(remainingBalance) : parseFloat(availableBalance) - parseFloat(blockedAmount), + }, + userId + ); return ResponseHandler.success(res, { message: 'IO details updated successfully' }, 'IO details updated'); } catch (error) { diff --git a/src/migrations/20251211-create-claim-budget-tracking-table.ts b/src/migrations/20251211-create-claim-budget-tracking-table.ts new file mode 100644 index 0000000..17cbc17 --- /dev/null +++ b/src/migrations/20251211-create-claim-budget-tracking-table.ts @@ -0,0 +1,197 @@ +import { QueryInterface, DataTypes } from 'sequelize'; + +export async function up(queryInterface: QueryInterface): Promise { + // Create claim_budget_tracking table for comprehensive budget management + await queryInterface.createTable('claim_budget_tracking', { + budget_id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4 + }, + request_id: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + references: { + model: 'workflow_requests', + key: 'request_id' + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE' + }, + // Initial Budget (from claim creation) + initial_estimated_budget: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + comment: 'Initial estimated budget when claim was created' + }, + // Proposal Budget (from Step 1 - Dealer Proposal) + proposal_estimated_budget: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + comment: 'Total estimated budget from dealer proposal' + }, + proposal_submitted_at: { + type: DataTypes.DATE, + allowNull: true, + comment: 'When dealer submitted proposal' + }, + // Approved Budget (from Step 2 - Requestor Evaluation) + approved_budget: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + comment: 'Budget approved by requestor in Step 2' + }, + approved_at: { + type: DataTypes.DATE, + allowNull: true, + comment: 'When budget was approved by requestor' + }, + approved_by: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'users', + key: 'user_id' + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + comment: 'User who approved the budget' + }, + // IO Blocked Budget (from Step 3 - Department Lead) + io_blocked_amount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + comment: 'Amount blocked in IO (from internal_orders table)' + }, + io_blocked_at: { + type: DataTypes.DATE, + allowNull: true, + comment: 'When budget was blocked in IO' + }, + // Closed Expenses (from Step 5 - Dealer Completion) + closed_expenses: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + comment: 'Total closed expenses from completion documents' + }, + closed_expenses_submitted_at: { + type: DataTypes.DATE, + allowNull: true, + comment: 'When completion expenses were submitted' + }, + // Final Claim Amount (from Step 6 - Requestor Claim Approval) + final_claim_amount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + comment: 'Final claim amount approved/modified by requestor in Step 6' + }, + final_claim_amount_approved_at: { + type: DataTypes.DATE, + allowNull: true, + comment: 'When final claim amount was approved' + }, + final_claim_amount_approved_by: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'users', + key: 'user_id' + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + comment: 'User who approved final claim amount' + }, + // Credit Note (from Step 8 - Finance) + credit_note_amount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + comment: 'Credit note amount issued by finance' + }, + credit_note_issued_at: { + type: DataTypes.DATE, + allowNull: true, + comment: 'When credit note was issued' + }, + // Budget Status + budget_status: { + type: DataTypes.ENUM('DRAFT', 'PROPOSED', 'APPROVED', 'BLOCKED', 'CLOSED', 'SETTLED'), + defaultValue: 'DRAFT', + allowNull: false, + comment: 'Current status of budget lifecycle' + }, + // Currency + currency: { + type: DataTypes.STRING(3), + defaultValue: 'INR', + allowNull: false, + comment: 'Currency code (INR, USD, etc.)' + }, + // Budget Variance + variance_amount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + comment: 'Difference between approved and closed expenses (closed - approved)' + }, + variance_percentage: { + type: DataTypes.DECIMAL(5, 2), + allowNull: true, + comment: 'Variance as percentage of approved budget' + }, + // Audit fields + last_modified_by: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'users', + key: 'user_id' + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + comment: 'Last user who modified budget' + }, + last_modified_at: { + type: DataTypes.DATE, + allowNull: true, + comment: 'When budget was last modified' + }, + modification_reason: { + type: DataTypes.TEXT, + allowNull: true, + comment: 'Reason for budget modification' + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + } + }); + + // Create indexes + await queryInterface.addIndex('claim_budget_tracking', ['request_id'], { + name: 'idx_claim_budget_tracking_request_id', + unique: true + }); + + await queryInterface.addIndex('claim_budget_tracking', ['budget_status'], { + name: 'idx_claim_budget_tracking_status' + }); + + await queryInterface.addIndex('claim_budget_tracking', ['approved_by'], { + name: 'idx_claim_budget_tracking_approved_by' + }); + + await queryInterface.addIndex('claim_budget_tracking', ['final_claim_amount_approved_by'], { + name: 'idx_claim_budget_tracking_final_approved_by' + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable('claim_budget_tracking'); +} + diff --git a/src/migrations/20251211-create-internal-orders-table.ts b/src/migrations/20251211-create-internal-orders-table.ts new file mode 100644 index 0000000..8933121 --- /dev/null +++ b/src/migrations/20251211-create-internal-orders-table.ts @@ -0,0 +1,95 @@ +import { QueryInterface, DataTypes } from 'sequelize'; + +export async function up(queryInterface: QueryInterface): Promise { + // Create internal_orders table for storing IO (Internal Order) details + await queryInterface.createTable('internal_orders', { + io_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' + }, + io_number: { + type: DataTypes.STRING(50), + allowNull: false + }, + io_remark: { + type: DataTypes.TEXT, + 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 + }, + organized_by: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'users', + key: 'user_id' + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE' + }, + organized_at: { + type: DataTypes.DATE, + allowNull: true + }, + sap_document_number: { + type: DataTypes.STRING(100), + allowNull: true + }, + status: { + type: DataTypes.ENUM('PENDING', 'BLOCKED', 'RELEASED', 'CANCELLED'), + defaultValue: 'PENDING', + allowNull: false + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + } + }); + + // Create indexes + await queryInterface.addIndex('internal_orders', ['io_number'], { + name: 'idx_internal_orders_io_number' + }); + + await queryInterface.addIndex('internal_orders', ['organized_by'], { + name: 'idx_internal_orders_organized_by' + }); + + // Create unique constraint: one IO per request (unique index on request_id) + await queryInterface.addIndex('internal_orders', ['request_id'], { + name: 'idx_internal_orders_request_id_unique', + unique: true + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable('internal_orders'); +} + diff --git a/src/models/ClaimBudgetTracking.ts b/src/models/ClaimBudgetTracking.ts new file mode 100644 index 0000000..0999f7b --- /dev/null +++ b/src/models/ClaimBudgetTracking.ts @@ -0,0 +1,295 @@ +import { DataTypes, Model, Optional } from 'sequelize'; +import { sequelize } from '@config/database'; +import { WorkflowRequest } from './WorkflowRequest'; +import { User } from './User'; + +export enum BudgetStatus { + DRAFT = 'DRAFT', + PROPOSED = 'PROPOSED', + APPROVED = 'APPROVED', + BLOCKED = 'BLOCKED', + CLOSED = 'CLOSED', + SETTLED = 'SETTLED' +} + +interface ClaimBudgetTrackingAttributes { + budgetId: string; + requestId: string; + // Initial Budget + initialEstimatedBudget?: number; + // Proposal Budget + proposalEstimatedBudget?: number; + proposalSubmittedAt?: Date; + // Approved Budget + approvedBudget?: number; + approvedAt?: Date; + approvedBy?: string; + // IO Blocked Budget + ioBlockedAmount?: number; + ioBlockedAt?: Date; + // Closed Expenses + closedExpenses?: number; + closedExpensesSubmittedAt?: Date; + // Final Claim Amount + finalClaimAmount?: number; + finalClaimAmountApprovedAt?: Date; + finalClaimAmountApprovedBy?: string; + // Credit Note + creditNoteAmount?: number; + creditNoteIssuedAt?: Date; + // Status & Metadata + budgetStatus: BudgetStatus; + currency: string; + varianceAmount?: number; + variancePercentage?: number; + // Audit + lastModifiedBy?: string; + lastModifiedAt?: Date; + modificationReason?: string; + createdAt: Date; + updatedAt: Date; +} + +interface ClaimBudgetTrackingCreationAttributes extends Optional {} + +class ClaimBudgetTracking extends Model implements ClaimBudgetTrackingAttributes { + public budgetId!: string; + public requestId!: string; + public initialEstimatedBudget?: number; + public proposalEstimatedBudget?: number; + public proposalSubmittedAt?: Date; + public approvedBudget?: number; + public approvedAt?: Date; + public approvedBy?: string; + public ioBlockedAmount?: number; + public ioBlockedAt?: Date; + public closedExpenses?: number; + public closedExpensesSubmittedAt?: Date; + public finalClaimAmount?: number; + public finalClaimAmountApprovedAt?: Date; + public finalClaimAmountApprovedBy?: string; + public creditNoteAmount?: number; + public creditNoteIssuedAt?: Date; + public budgetStatus!: BudgetStatus; + public currency!: string; + public varianceAmount?: number; + public variancePercentage?: number; + public lastModifiedBy?: string; + public lastModifiedAt?: Date; + public modificationReason?: string; + public createdAt!: Date; + public updatedAt!: Date; + + // Associations + public request?: WorkflowRequest; + public approver?: User; + public finalApprover?: User; + public lastModifier?: User; +} + +ClaimBudgetTracking.init( + { + budgetId: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + field: 'budget_id' + }, + requestId: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + field: 'request_id', + references: { + model: 'workflow_requests', + key: 'request_id' + } + }, + initialEstimatedBudget: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'initial_estimated_budget' + }, + proposalEstimatedBudget: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'proposal_estimated_budget' + }, + proposalSubmittedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'proposal_submitted_at' + }, + approvedBudget: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'approved_budget' + }, + approvedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'approved_at' + }, + approvedBy: { + type: DataTypes.UUID, + allowNull: true, + field: 'approved_by', + references: { + model: 'users', + key: 'user_id' + } + }, + ioBlockedAmount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'io_blocked_amount' + }, + ioBlockedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'io_blocked_at' + }, + closedExpenses: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'closed_expenses' + }, + closedExpensesSubmittedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'closed_expenses_submitted_at' + }, + finalClaimAmount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'final_claim_amount' + }, + finalClaimAmountApprovedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'final_claim_amount_approved_at' + }, + finalClaimAmountApprovedBy: { + type: DataTypes.UUID, + allowNull: true, + field: 'final_claim_amount_approved_by', + references: { + model: 'users', + key: 'user_id' + } + }, + creditNoteAmount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'credit_note_amount' + }, + creditNoteIssuedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'credit_note_issued_at' + }, + budgetStatus: { + type: DataTypes.ENUM('DRAFT', 'PROPOSED', 'APPROVED', 'BLOCKED', 'CLOSED', 'SETTLED'), + defaultValue: 'DRAFT', + allowNull: false, + field: 'budget_status' + }, + currency: { + type: DataTypes.STRING(3), + defaultValue: 'INR', + allowNull: false + }, + varianceAmount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'variance_amount' + }, + variancePercentage: { + type: DataTypes.DECIMAL(5, 2), + allowNull: true, + field: 'variance_percentage' + }, + lastModifiedBy: { + type: DataTypes.UUID, + allowNull: true, + field: 'last_modified_by', + references: { + model: 'users', + key: 'user_id' + } + }, + lastModifiedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'last_modified_at' + }, + modificationReason: { + type: DataTypes.TEXT, + allowNull: true, + field: 'modification_reason' + }, + 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: 'ClaimBudgetTracking', + tableName: 'claim_budget_tracking', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { + fields: ['request_id'], + unique: true + }, + { + fields: ['budget_status'] + }, + { + fields: ['approved_by'] + }, + { + fields: ['final_claim_amount_approved_by'] + } + ] + } +); + +// Associations +ClaimBudgetTracking.belongsTo(WorkflowRequest, { + as: 'request', + foreignKey: 'requestId', + targetKey: 'requestId' +}); + +ClaimBudgetTracking.belongsTo(User, { + as: 'approver', + foreignKey: 'approvedBy', + targetKey: 'userId' +}); + +ClaimBudgetTracking.belongsTo(User, { + as: 'finalApprover', + foreignKey: 'finalClaimAmountApprovedBy', + targetKey: 'userId' +}); + +ClaimBudgetTracking.belongsTo(User, { + as: 'lastModifier', + foreignKey: 'lastModifiedBy', + targetKey: 'userId' +}); + +export { ClaimBudgetTracking }; + diff --git a/src/models/DealerClaimDetails.ts b/src/models/DealerClaimDetails.ts index ff6464a..8fb82be 100644 --- a/src/models/DealerClaimDetails.ts +++ b/src/models/DealerClaimDetails.ts @@ -19,6 +19,7 @@ interface DealerClaimDetailsAttributes { estimatedBudget?: number; closedExpenses?: number; ioNumber?: string; + // Note: ioRemark moved to internal_orders table ioAvailableBalance?: number; ioBlockedAmount?: number; ioRemainingBalance?: number; @@ -52,6 +53,7 @@ class DealerClaimDetails extends Model {} + +class InternalOrder extends Model implements InternalOrderAttributes { + public ioId!: string; + public requestId!: string; + public ioNumber!: string; + public ioRemark?: string; + public ioAvailableBalance?: number; + public ioBlockedAmount?: number; + public ioRemainingBalance?: number; + public organizedBy?: string; + public organizedAt?: Date; + public sapDocumentNumber?: string; + public status!: IOStatus; + public createdAt!: Date; + public updatedAt!: Date; + + // Associations + public request?: WorkflowRequest; + public organizer?: User; +} + +InternalOrder.init( + { + ioId: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + field: 'io_id' + }, + requestId: { + type: DataTypes.UUID, + allowNull: false, + field: 'request_id', + references: { + model: 'workflow_requests', + key: 'request_id' + } + }, + ioNumber: { + type: DataTypes.STRING(50), + allowNull: false, + field: 'io_number' + }, + ioRemark: { + type: DataTypes.TEXT, + allowNull: true, + field: 'io_remark' + }, + 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' + }, + organizedBy: { + type: DataTypes.UUID, + allowNull: true, + field: 'organized_by', + references: { + model: 'users', + key: 'user_id' + } + }, + organizedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'organized_at' + }, + sapDocumentNumber: { + type: DataTypes.STRING(100), + allowNull: true, + field: 'sap_document_number' + }, + status: { + type: DataTypes.ENUM('PENDING', 'BLOCKED', 'RELEASED', 'CANCELLED'), + defaultValue: 'PENDING', + allowNull: false + }, + 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: 'InternalOrder', + tableName: 'internal_orders', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { + fields: ['request_id'], + unique: true + }, + { + fields: ['io_number'] + }, + { + fields: ['organized_by'] + } + ] + } +); + +// Associations +InternalOrder.belongsTo(WorkflowRequest, { + as: 'request', + foreignKey: 'requestId', + targetKey: 'requestId' +}); + +InternalOrder.belongsTo(User, { + as: 'organizer', + foreignKey: 'organizedBy', + targetKey: 'userId' +}); + +export { InternalOrder }; + diff --git a/src/models/index.ts b/src/models/index.ts index 98bf946..37130ce 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -21,6 +21,8 @@ import { DealerProposalDetails } from './DealerProposalDetails'; import { DealerCompletionDetails } from './DealerCompletionDetails'; import { DealerProposalCostItem } from './DealerProposalCostItem'; import { WorkflowTemplate } from './WorkflowTemplate'; +import { InternalOrder } from './InternalOrder'; +import { ClaimBudgetTracking } from './ClaimBudgetTracking'; // Define associations const defineAssociations = () => { @@ -119,6 +121,20 @@ const defineAssociations = () => { sourceKey: 'userId' }); + // InternalOrder associations + WorkflowRequest.hasOne(InternalOrder, { + as: 'internalOrder', + foreignKey: 'requestId', + sourceKey: 'requestId' + }); + + // ClaimBudgetTracking associations + WorkflowRequest.hasOne(ClaimBudgetTracking, { + as: 'budgetTracking', + foreignKey: 'requestId', + sourceKey: 'requestId' + }); + // Note: belongsTo associations are defined in individual model files to avoid duplicate alias conflicts // Only hasMany associations from WorkflowRequest are defined here since they're one-way }; @@ -148,7 +164,9 @@ export { DealerProposalDetails, DealerCompletionDetails, DealerProposalCostItem, - WorkflowTemplate + WorkflowTemplate, + InternalOrder, + ClaimBudgetTracking }; // Export default sequelize instance diff --git a/src/routes/dealerClaim.routes.ts b/src/routes/dealerClaim.routes.ts index e9a82f5..ed351d1 100644 --- a/src/routes/dealerClaim.routes.ts +++ b/src/routes/dealerClaim.routes.ts @@ -51,7 +51,12 @@ router.post('/:requestId/proposal', authenticateToken, upload.single('proposalDo * @desc Submit completion documents (Step 5) * @access Private */ -router.post('/:requestId/completion', authenticateToken, asyncHandler(dealerClaimController.submitCompletion.bind(dealerClaimController))); +router.post('/:requestId/completion', authenticateToken, upload.fields([ + { name: 'completionDocuments', maxCount: 10 }, + { name: 'activityPhotos', maxCount: 10 }, + { name: 'invoicesReceipts', maxCount: 10 }, + { name: 'attendanceSheet', maxCount: 1 }, +]), asyncHandler(dealerClaimController.submitCompletion.bind(dealerClaimController))); /** * @route PUT /api/v1/dealer-claims/:requestId/io diff --git a/src/scripts/auto-setup.ts b/src/scripts/auto-setup.ts index 14707c9..33a9301 100644 --- a/src/scripts/auto-setup.ts +++ b/src/scripts/auto-setup.ts @@ -119,6 +119,13 @@ async function runMigrations(): Promise { const m26 = require('../migrations/20250126-add-pause-fields-to-approval-levels'); const m27 = require('../migrations/20250127-migrate-in-progress-to-pending'); const m28 = require('../migrations/20251203-add-user-notification-preferences'); + const m29 = require('../migrations/20251210-add-workflow-type-support'); + const m30 = require('../migrations/20251210-enhance-workflow-templates'); + const m31 = require('../migrations/20251210-add-template-id-foreign-key'); + const m32 = require('../migrations/20251210-create-dealer-claim-tables'); + const m33 = require('../migrations/20251210-create-proposal-cost-items-table'); + const m34 = require('../migrations/20251211-create-internal-orders-table'); + const m35 = require('../migrations/20251211-create-claim-budget-tracking-table'); const migrations = [ { name: '2025103000-create-users', module: m0 }, @@ -150,6 +157,13 @@ async function runMigrations(): Promise { { name: '20250126-add-pause-fields-to-approval-levels', module: m26 }, { name: '20250127-migrate-in-progress-to-pending', module: m27 }, { name: '20251203-add-user-notification-preferences', module: m28 }, + { name: '20251210-add-workflow-type-support', module: m29 }, + { name: '20251210-enhance-workflow-templates', module: m30 }, + { name: '20251210-add-template-id-foreign-key', module: m31 }, + { name: '20251210-create-dealer-claim-tables', module: m32 }, + { name: '20251210-create-proposal-cost-items-table', module: m33 }, + { name: '20251211-create-internal-orders-table', module: m34 }, + { name: '20251211-create-claim-budget-tracking-table', module: m35 }, ]; const queryInterface = sequelize.getQueryInterface(); diff --git a/src/scripts/cleanup-dealer-claims.ts b/src/scripts/cleanup-dealer-claims.ts new file mode 100644 index 0000000..2e92deb --- /dev/null +++ b/src/scripts/cleanup-dealer-claims.ts @@ -0,0 +1,167 @@ +/** + * Cleanup Dealer Claims Script + * Removes all dealer claim related data for a fresh start + * + * Usage: npm run cleanup:dealer-claims + * + * WARNING: This will permanently delete all CLAIM_MANAGEMENT requests and related data! + */ + +import { sequelize } from '../config/database'; +import { QueryTypes } from 'sequelize'; +import logger from '../utils/logger'; + +async function cleanupDealerClaims(): Promise { + const transaction = await sequelize.transaction(); + + try { + logger.info('[Cleanup] Starting dealer claim cleanup...'); + + // Step 1: Find all CLAIM_MANAGEMENT request IDs + logger.info('[Cleanup] Finding all CLAIM_MANAGEMENT requests...'); + const claimRequests = await sequelize.query<{ request_id: string }>( + `SELECT request_id FROM workflow_requests WHERE workflow_type = 'CLAIM_MANAGEMENT'`, + { type: QueryTypes.SELECT, transaction } + ); + + const requestIds = claimRequests.map(r => r.request_id); + const count = requestIds.length; + + if (count === 0) { + logger.info('[Cleanup] No CLAIM_MANAGEMENT requests found. Nothing to clean up.'); + await transaction.commit(); + return; + } + + logger.info(`[Cleanup] Found ${count} CLAIM_MANAGEMENT request(s) to delete`); + + // Step 2: Delete in order (respecting foreign key constraints) + // Start with child tables, then parent tables + + // Convert UUID array to PostgreSQL array format + const requestIdsArray = `{${requestIds.map(id => `'${id}'`).join(',')}}`; + + // Delete from claim_budget_tracking (new table) + logger.info('[Cleanup] Deleting from claim_budget_tracking...'); + await sequelize.query( + `DELETE FROM claim_budget_tracking WHERE request_id = ANY(ARRAY[${requestIds.map(() => '?').join(',')}]::uuid[])`, + { + replacements: requestIds, + type: QueryTypes.DELETE, + transaction + } + ); + + // Step 2: Delete in order (respecting foreign key constraints) + // Start with child tables, then parent tables + + // Helper function to delete with array + const deleteWithArray = async (tableName: string, columnName: string = 'request_id') => { + await sequelize.query( + `DELETE FROM ${tableName} WHERE ${columnName} = ANY(ARRAY[${requestIds.map(() => '?').join(',')}]::uuid[])`, + { + replacements: requestIds, + type: QueryTypes.DELETE, + transaction + } + ); + }; + + // Delete from claim_budget_tracking (new table) + logger.info('[Cleanup] Deleting from claim_budget_tracking...'); + await deleteWithArray('claim_budget_tracking'); + + // Delete from internal_orders (new table) + logger.info('[Cleanup] Deleting from internal_orders...'); + await deleteWithArray('internal_orders'); + + // Delete from dealer_proposal_cost_items + logger.info('[Cleanup] Deleting from dealer_proposal_cost_items...'); + await deleteWithArray('dealer_proposal_cost_items'); + + // Delete from dealer_completion_details + logger.info('[Cleanup] Deleting from dealer_completion_details...'); + await deleteWithArray('dealer_completion_details'); + + // Delete from dealer_proposal_details + logger.info('[Cleanup] Deleting from dealer_proposal_details...'); + await deleteWithArray('dealer_proposal_details'); + + // Delete from dealer_claim_details + logger.info('[Cleanup] Deleting from dealer_claim_details...'); + await deleteWithArray('dealer_claim_details'); + + // Delete from activities (workflow activities) + logger.info('[Cleanup] Deleting from activities...'); + await deleteWithArray('activities'); + + // Delete from work_notes + logger.info('[Cleanup] Deleting from work_notes...'); + await deleteWithArray('work_notes'); + + // Delete from documents + logger.info('[Cleanup] Deleting from documents...'); + await deleteWithArray('documents'); + + // Delete from participants + logger.info('[Cleanup] Deleting from participants...'); + await deleteWithArray('participants'); + + // Delete from approval_levels + logger.info('[Cleanup] Deleting from approval_levels...'); + await deleteWithArray('approval_levels'); + + // Note: subscriptions table doesn't have request_id - it's for push notification subscriptions + // Skip subscriptions as it's not related to workflow requests + + // Delete from notifications + logger.info('[Cleanup] Deleting from notifications...'); + await deleteWithArray('notifications'); + + // Delete from request_summaries + logger.info('[Cleanup] Deleting from request_summaries...'); + await deleteWithArray('request_summaries'); + + // Delete from shared_summaries + logger.info('[Cleanup] Deleting from shared_summaries...'); + await deleteWithArray('shared_summaries'); + + // Delete from conclusion_remarks + logger.info('[Cleanup] Deleting from conclusion_remarks...'); + await deleteWithArray('conclusion_remarks'); + + // Delete from tat_alerts + logger.info('[Cleanup] Deleting from tat_alerts...'); + await deleteWithArray('tat_alerts'); + + // Finally, delete from workflow_requests + logger.info('[Cleanup] Deleting from workflow_requests...'); + await deleteWithArray('workflow_requests'); + + await transaction.commit(); + + logger.info(`[Cleanup] ✅ Successfully deleted ${count} CLAIM_MANAGEMENT request(s) and all related data!`); + logger.info('[Cleanup] Database is now clean and ready for fresh dealer claim requests.'); + + } catch (error) { + await transaction.rollback(); + logger.error('[Cleanup] ❌ Error during cleanup:', error); + throw error; + } +} + +// Run cleanup if called directly +if (require.main === module) { + cleanupDealerClaims() + .then(() => { + logger.info('[Cleanup] Cleanup completed successfully'); + process.exit(0); + }) + .catch((error) => { + logger.error('[Cleanup] Cleanup failed:', error); + process.exit(1); + }); +} + +export { cleanupDealerClaims }; + diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts index 07634ec..aea6b3b 100644 --- a/src/scripts/migrate.ts +++ b/src/scripts/migrate.ts @@ -27,6 +27,8 @@ import * as m23 from '../migrations/20251210-enhance-workflow-templates'; import * as m24 from '../migrations/20251210-add-template-id-foreign-key'; import * as m25 from '../migrations/20251210-create-dealer-claim-tables'; import * as m26 from '../migrations/20251210-create-proposal-cost-items-table'; +import * as m27 from '../migrations/20251211-create-internal-orders-table'; +import * as m28 from '../migrations/20251211-create-claim-budget-tracking-table'; interface Migration { name: string; @@ -68,6 +70,8 @@ const migrations: Migration[] = [ { name: '20251210-add-template-id-foreign-key', module: m24 }, { name: '20251210-create-dealer-claim-tables', module: m25 }, { name: '20251210-create-proposal-cost-items-table', module: m26 }, + { name: '20251211-create-internal-orders-table', module: m27 }, + { name: '20251211-create-claim-budget-tracking-table', module: m28 }, ]; /** diff --git a/src/services/approval.service.ts b/src/services/approval.service.ts index db61d98..0f1c76d 100644 --- a/src/services/approval.service.ts +++ b/src/services/approval.service.ts @@ -12,6 +12,7 @@ import { notificationService } from './notification.service'; import { activityService } from './activity.service'; import { tatSchedulerService } from './tatScheduler.service'; import { emitToRequestRoom } from '../realtime/socket'; +import { DealerClaimService } from './dealerClaim.service'; export class ApprovalService { async approveLevel(levelId: string, action: ApprovalAction, _userId: string, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): Promise { @@ -425,14 +426,53 @@ export class ApprovalService { ); logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`); - // Notify next approver - if (wf && nextLevel) { - await notificationService.sendToUsers([ (nextLevel as any).approverId ], { - title: `Action required: ${(wf as any).requestNumber}`, - body: `${(wf as any).title}`, - requestNumber: (wf as any).requestNumber, - url: `/request/${(wf as any).requestNumber}` - }); + + // Check if this is Step 3 approval in a claim management workflow, and next level is Step 4 (auto-step) + const workflowType = (wf as any)?.workflowType; + const isClaimManagement = workflowType === 'CLAIM_MANAGEMENT'; + const isStep3Approval = level.levelNumber === 3; + const isStep4Next = nextLevelNumber === 4; + const isStep6Approval = level.levelNumber === 6; + const isStep7Next = nextLevelNumber === 7; + + if (isClaimManagement && isStep3Approval && isStep4Next && nextLevel) { + // Step 4 is an auto-step - process it automatically + logger.info(`[Approval] Step 3 approved for claim management workflow. Auto-processing Step 4: Activity Creation`); + try { + const dealerClaimService = new DealerClaimService(); + await dealerClaimService.processActivityCreation(level.requestId); + logger.info(`[Approval] Step 4 auto-processing completed for request ${level.requestId}`); + } catch (step4Error) { + logger.error(`[Approval] Error auto-processing Step 4 for request ${level.requestId}:`, step4Error); + // Don't fail the Step 3 approval if Step 4 processing fails - log and continue + } + } else if (isClaimManagement && isStep6Approval && isStep7Next && nextLevel && nextLevel.approverEmail === 'system@royalenfield.com') { + // Step 7 is an auto-step - process it automatically after Step 6 approval + logger.info(`[Approval] Step 6 approved for claim management workflow. Auto-processing Step 7: E-Invoice Generation`); + try { + const dealerClaimService = new DealerClaimService(); + await dealerClaimService.processEInvoiceGeneration(level.requestId); + logger.info(`[Approval] Step 7 auto-processing completed for request ${level.requestId}`); + // Skip notification for system auto-processed step - continue to return updatedLevel at end + } catch (step7Error) { + logger.error(`[Approval] Error auto-processing Step 7 for request ${level.requestId}:`, step7Error); + // Don't fail the Step 6 approval if Step 7 processing fails - log and continue + } + } else if (wf && nextLevel) { + // Normal flow - notify next approver (skip for auto-steps) + // Check if it's an auto-step by checking approverEmail or levelName + const isAutoStep = (nextLevel as any).approverEmail === 'system@royalenfield.com' + || (nextLevel as any).approverName === 'System Auto-Process' + || (nextLevel as any).levelName === 'Activity Creation' + || (nextLevel as any).levelName === 'E-Invoice Generation'; + if (!isAutoStep && (nextLevel as any).approverId) { + await notificationService.sendToUsers([ (nextLevel as any).approverId ], { + title: `Action required: ${(wf as any).requestNumber}`, + body: `${(wf as any).title}`, + requestNumber: (wf as any).requestNumber, + url: `/request/${(wf as any).requestNumber}` + }); + } activityService.log({ requestId: level.requestId, type: 'approval', diff --git a/src/services/dealerClaim.service.ts b/src/services/dealerClaim.service.ts index 9312716..646631f 100644 --- a/src/services/dealerClaim.service.ts +++ b/src/services/dealerClaim.service.ts @@ -3,15 +3,18 @@ import { DealerClaimDetails } from '../models/DealerClaimDetails'; import { DealerProposalDetails } from '../models/DealerProposalDetails'; import { DealerCompletionDetails } from '../models/DealerCompletionDetails'; import { DealerProposalCostItem } from '../models/DealerProposalCostItem'; +import { InternalOrder, IOStatus } from '../models/InternalOrder'; import { ApprovalLevel } from '../models/ApprovalLevel'; import { Participant } from '../models/Participant'; import { User } from '../models/User'; import { WorkflowService } from './workflow.service'; import { ApprovalService } from './approval.service'; import { generateRequestNumber } from '../utils/helpers'; -import { Priority, WorkflowStatus, ApprovalStatus } from '../types/common.types'; +import { Priority, WorkflowStatus, ApprovalStatus, ParticipantType } from '../types/common.types'; import { sapIntegrationService } from './sapIntegration.service'; import { dmsIntegrationService } from './dmsIntegration.service'; +import { notificationService } from './notification.service'; +import { activityService } from './activity.service'; import logger from '../utils/logger'; /** @@ -86,6 +89,9 @@ export class DealerClaimService { // Create 8 approval levels for claim management workflow await this.createClaimApprovalLevels(workflowRequest.requestId, userId, claimData.dealerEmail); + // Create participants (initiator, dealer, department lead, finance - exclude system) + await this.createClaimParticipants(workflowRequest.requestId, userId, claimData.dealerEmail); + logger.info(`[DealerClaimService] Created claim request: ${workflowRequest.requestNumber}`); return workflowRequest; } catch (error) { @@ -277,6 +283,9 @@ export class DealerClaimService { tatHours: step.tatHours, // tatDays is calculated later when needed: 1 day = 8 working hours // Formula: Math.ceil(tatHours / 8) + // Only step 1 should be PENDING initially, others should wait + // But ApprovalStatus doesn't have WAITING, so we'll use PENDING for all + // and the approval service will properly activate the next level status: step.level === 1 ? ApprovalStatus.PENDING : ApprovalStatus.PENDING, isFinalApprover: step.level === 8, elapsedHours: 0, @@ -286,6 +295,138 @@ export class DealerClaimService { } } + /** + * Create participants for claim management workflow + * Includes: Initiator, Dealer, Department Lead, Finance Approver + * Excludes: System users + */ + private async createClaimParticipants( + requestId: string, + initiatorId: string, + dealerEmail?: string + ): Promise { + try { + const initiator = await User.findByPk(initiatorId); + if (!initiator) { + throw new Error('Initiator not found'); + } + + // Get all approval levels to extract approvers + const approvalLevels = await ApprovalLevel.findAll({ + where: { requestId }, + order: [['levelNumber', 'ASC']], + }); + + const participantsToAdd: Array<{ + userId: string; + userEmail: string; + userName: string; + participantType: ParticipantType; + }> = []; + + // 1. Add Initiator + participantsToAdd.push({ + userId: initiatorId, + userEmail: initiator.email, + userName: initiator.displayName || initiator.email || 'Initiator', + participantType: ParticipantType.INITIATOR, + }); + + // 2. Add Dealer (if exists and not system) + if (dealerEmail && dealerEmail.toLowerCase() !== 'system@royalenfield.com') { + const dealerUser = await User.findOne({ + where: { email: dealerEmail.toLowerCase() }, + }); + if (dealerUser) { + participantsToAdd.push({ + userId: dealerUser.userId, + userEmail: dealerUser.email, + userName: dealerUser.displayName || dealerUser.email || 'Dealer', + participantType: ParticipantType.APPROVER, + }); + } + } + + // 3. Add all approvers from approval levels (excluding system and duplicates) + const addedUserIds = new Set([initiatorId]); + const systemEmails = ['system@royalenfield.com']; + + for (const level of approvalLevels) { + const approverEmail = (level as any).approverEmail?.toLowerCase(); + const approverId = (level as any).approverId; + + // Skip if system user or already added + if ( + !approverId || + systemEmails.includes(approverEmail || '') || + addedUserIds.has(approverId) + ) { + continue; + } + + // Skip if email is system email + if (approverEmail && systemEmails.includes(approverEmail)) { + continue; + } + + const approverUser = await User.findByPk(approverId); + if (approverUser) { + participantsToAdd.push({ + userId: approverId, + userEmail: approverUser.email, + userName: approverUser.displayName || approverUser.email || 'Approver', + participantType: ParticipantType.APPROVER, + }); + addedUserIds.add(approverId); + } + } + + // Create participants (deduplicate by userId) + const participantMap = new Map(); + const rolePriority: Record = { + 'INITIATOR': 3, + 'APPROVER': 2, + 'SPECTATOR': 1, + }; + + for (const participantData of participantsToAdd) { + const existing = participantMap.get(participantData.userId); + if (existing) { + // Keep higher priority role + const existingPriority = rolePriority[existing.participantType] || 0; + const newPriority = rolePriority[participantData.participantType] || 0; + if (newPriority > existingPriority) { + participantMap.set(participantData.userId, participantData); + } + } else { + participantMap.set(participantData.userId, participantData); + } + } + + // Create participant records + for (const participantData of participantMap.values()) { + await Participant.create({ + requestId, + userId: participantData.userId, + userEmail: participantData.userEmail, + userName: participantData.userName, + participantType: participantData.participantType, + canComment: true, + canViewDocuments: true, + canDownloadDocuments: true, + notificationEnabled: true, + addedBy: initiatorId, + isActive: true, + } as any); + } + + logger.info(`[DealerClaimService] Created ${participantMap.size} participants for claim request ${requestId}`); + } catch (error) { + logger.error('[DealerClaimService] Error creating participants:', error); + // Don't throw - participants are not critical for request creation + } + } + /** * Resolve Department Lead based on initiator's department/manager * If multiple users found with same department, uses the first one @@ -398,7 +539,41 @@ export class DealerClaimService { return anyDeptLead; } - logger.warn(`[DealerClaimService] Could not resolve department lead for initiator: ${initiator.email} (department: ${initiator.department}, manager: ${initiator.manager})`); + // Priority 6: Find any user with MANAGEMENT role (across all departments) + logger.debug(`[DealerClaimService] Trying to find any user with MANAGEMENT role...`); + const anyManagementUser = await User.findOne({ + where: { + role: 'MANAGEMENT' as any, + isActive: true, + userId: { [Op.ne]: initiator.userId }, // Exclude initiator + }, + order: [['createdAt', 'ASC']], + }); + if (anyManagementUser) { + logger.warn(`[DealerClaimService] Found user with MANAGEMENT role across all departments: ${anyManagementUser.email} (department: ${anyManagementUser.department})`); + return anyManagementUser; + } + + // Priority 7: Find any user with ADMIN role (across all departments) + logger.debug(`[DealerClaimService] Trying to find any user with ADMIN role...`); + const anyAdminUser = await User.findOne({ + where: { + role: 'ADMIN' as any, + isActive: true, + userId: { [Op.ne]: initiator.userId }, // Exclude initiator + }, + order: [['createdAt', 'ASC']], + }); + if (anyAdminUser) { + logger.warn(`[DealerClaimService] Found user with ADMIN role as fallback: ${anyAdminUser.email} (department: ${anyAdminUser.department})`); + return anyAdminUser; + } + + logger.warn(`[DealerClaimService] Could not resolve department lead for initiator: ${initiator.email} (department: ${initiator.department || 'NOT SET'}, manager: ${initiator.manager || 'NOT SET'})`); + logger.warn(`[DealerClaimService] No suitable department lead found. Please ensure:`); + logger.warn(`[DealerClaimService] 1. Initiator has a department set: ${initiator.department || 'MISSING'}`); + logger.warn(`[DealerClaimService] 2. There is at least one user with MANAGEMENT role in the system`); + logger.warn(`[DealerClaimService] 3. Initiator's manager field is set: ${initiator.manager || 'MISSING'}`); return null; } catch (error) { logger.error('[DealerClaimService] Error resolving department lead:', error); @@ -508,6 +683,14 @@ export class DealerClaimService { where: { requestId } }); + // Fetch Internal Order details + const internalOrder = await InternalOrder.findOne({ + where: { requestId }, + include: [ + { model: User, as: 'organizer', required: false } + ] + }); + // Serialize claim details to ensure proper field names let serializedClaimDetails = null; if (claimDetails) { @@ -553,11 +736,18 @@ export class DealerClaimService { serializedCompletionDetails = (completionDetails as any).toJSON ? (completionDetails as any).toJSON() : completionDetails; } + // Serialize internal order details + let serializedInternalOrder = null; + if (internalOrder) { + serializedInternalOrder = (internalOrder as any).toJSON ? (internalOrder as any).toJSON() : internalOrder; + } + return { request: (request as any).toJSON ? (request as any).toJSON() : request, claimDetails: serializedClaimDetails, proposalDetails: transformedProposalDetails, completionDetails: serializedCompletionDetails, + internalOrder: serializedInternalOrder, }; } catch (error) { logger.error('[DealerClaimService] Error getting claim details:', error); @@ -692,6 +882,8 @@ export class DealerClaimService { totalClosedExpenses: number; completionDocuments: any[]; activityPhotos: any[]; + invoicesReceipts?: any[]; + attendanceSheet?: any; } ): Promise { try { @@ -753,10 +945,12 @@ export class DealerClaimService { requestId: string, ioData: { ioNumber: string; + ioRemark?: string; availableBalance?: number; blockedAmount?: number; remainingBalance?: number; - } + }, + organizedByUserId?: string ): Promise { try { // Validate IO number with SAP @@ -789,16 +983,38 @@ export class DealerClaimService { remainingBalance = blockResult.remainingBalance; } - // Update claim details with IO information - await DealerClaimDetails.update( - { + // Get the user who is organizing the IO + const organizedBy = organizedByUserId || null; + + // Create or update Internal Order record + const [internalOrder, created] = await InternalOrder.findOrCreate({ + where: { requestId }, + defaults: { + requestId, ioNumber: ioData.ioNumber, + ioRemark: ioData.ioRemark || '', ioAvailableBalance: ioValidation.availableBalance, ioBlockedAmount: blockedAmount, ioRemainingBalance: remainingBalance, - }, - { where: { requestId } } - ); + organizedBy: organizedBy || undefined, + organizedAt: new Date(), + status: IOStatus.BLOCKED, + } + }); + + if (!created) { + // Update existing IO record + await internalOrder.update({ + ioNumber: ioData.ioNumber, + ioRemark: ioData.ioRemark || '', + ioAvailableBalance: ioValidation.availableBalance, + ioBlockedAmount: blockedAmount, + ioRemainingBalance: remainingBalance, + organizedBy: organizedBy || internalOrder.organizedBy, + organizedAt: internalOrder.organizedAt || new Date(), + status: IOStatus.BLOCKED, + }); + } logger.info(`[DealerClaimService] IO details updated for request: ${requestId}`, { ioNumber: ioData.ioNumber, @@ -832,6 +1048,15 @@ export class DealerClaimService { } const request = await WorkflowRequest.findByPk(requestId); + if (!request) { + throw new Error('Workflow request not found'); + } + + const workflowType = (request as any).workflowType; + if (workflowType !== 'CLAIM_MANAGEMENT') { + throw new Error('This endpoint is only for claim management workflows'); + } + const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN'; // If invoice data not provided, generate via DMS @@ -878,12 +1103,92 @@ export class DealerClaimService { logger.info(`[DealerClaimService] E-Invoice details manually updated for request: ${requestId}`); } + + // Check if Step 6 is approved - if not, approve it first + const step6Level = await ApprovalLevel.findOne({ + where: { requestId, levelNumber: 6 } + }); + + if (step6Level && step6Level.status !== ApprovalStatus.APPROVED) { + logger.info(`[DealerClaimService] Step 6 not approved yet. Auto-approving Step 6 for request ${requestId}`); + // Auto-approve Step 6 - this will trigger Step 7 auto-processing in approval service + await this.approvalService.approveLevel( + step6Level.levelId, + { action: 'APPROVE', comments: 'Auto-approved when pushing to DMS. E-Invoice generated.' }, + 'system', + { ipAddress: null, userAgent: 'System Auto-Process' } + ); + // Note: Step 7 auto-processing will be handled by approval service when Step 6 is approved + // But we also call it directly here to ensure it runs (in case approval service logic doesn't trigger) + await this.processEInvoiceGeneration(requestId); + } else { + // Step 6 already approved - directly process Step 7 + await this.processEInvoiceGeneration(requestId); + } } catch (error) { logger.error('[DealerClaimService] Error updating e-invoice details:', error); throw error; } } + /** + * Process Step 7: E-Invoice Generation (Auto-processed after Step 6 approval or when pushing to DMS) + * Generates e-invoice via DMS and auto-approves Step 7 + */ + async processEInvoiceGeneration(requestId: string): Promise { + try { + logger.info(`[DealerClaimService] Processing Step 7: E-Invoice Generation for request ${requestId}`); + + const request = await WorkflowRequest.findByPk(requestId); + if (!request) { + throw new Error(`Workflow request ${requestId} not found`); + } + + const workflowType = (request as any).workflowType; + if (workflowType !== 'CLAIM_MANAGEMENT') { + logger.warn(`[DealerClaimService] Skipping Step 7 auto-processing - not a claim management workflow (type: ${workflowType})`); + return; + } + + const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); + if (!claimDetails) { + throw new Error(`Claim details not found for request ${requestId}`); + } + + // Get Step 7 approval level + const step7Level = await ApprovalLevel.findOne({ + where: { + requestId, + levelNumber: 7 + } + }); + + if (!step7Level) { + throw new Error(`Step 7 approval level not found for request ${requestId}`); + } + + // If e-invoice already generated, just auto-approve Step 7 + if (claimDetails.eInvoiceNumber) { + logger.info(`[DealerClaimService] E-Invoice already generated (${claimDetails.eInvoiceNumber}). Auto-approving Step 7 for request ${requestId}`); + + // Auto-approve Step 7 + await this.approvalService.approveLevel( + step7Level.levelId, + { action: 'APPROVE', comments: 'E-Invoice generated via DMS. Step 7 auto-approved.' }, + 'system', + { ipAddress: null, userAgent: 'System Auto-Process' } + ); + + logger.info(`[DealerClaimService] Successfully auto-processed and approved Step 7 for request ${requestId}`); + } else { + logger.warn(`[DealerClaimService] E-Invoice not generated yet for request ${requestId}. Step 7 will be processed when e-invoice is generated.`); + } + } catch (error) { + logger.error(`[DealerClaimService] Error processing Step 7 (E-Invoice Generation) for request ${requestId}:`, error); + throw error; + } + } + /** * Update credit note details (Step 8) * Generates credit note via DMS integration @@ -960,5 +1265,137 @@ export class DealerClaimService { throw error; } } + + /** + * Process Step 4: Activity Creation (Auto-processed after Step 3 approval) + * Creates activity confirmation and sends emails to dealer, requestor, and department lead + */ + async processActivityCreation(requestId: string): Promise { + try { + logger.info(`[DealerClaimService] Processing Step 4: Activity Creation for request ${requestId}`); + + // Get workflow request + const request = await WorkflowRequest.findByPk(requestId); + if (!request) { + throw new Error(`Workflow request ${requestId} not found`); + } + + // Verify this is a claim management workflow + const workflowType = (request as any).workflowType; + if (workflowType !== 'CLAIM_MANAGEMENT') { + logger.warn(`[DealerClaimService] Skipping Step 4 auto-processing - not a claim management workflow (type: ${workflowType})`); + return; + } + + // Get claim details + const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); + if (!claimDetails) { + throw new Error(`Claim details not found for request ${requestId}`); + } + + // Get Step 4 approval level + const step4Level = await ApprovalLevel.findOne({ + where: { + requestId, + levelNumber: 4 + } + }); + + if (!step4Level) { + throw new Error(`Step 4 approval level not found for request ${requestId}`); + } + + // Get participants for email notifications + const initiator = await User.findByPk((request as any).initiatorId); + const dealerUser = claimDetails.dealerEmail + ? await User.findOne({ where: { email: claimDetails.dealerEmail } }) + : null; + + // Get department lead from Step 3 + const step3Level = await ApprovalLevel.findOne({ + where: { + requestId, + levelNumber: 3 + } + }); + const departmentLead = step3Level?.approverId + ? await User.findByPk(step3Level.approverId) + : null; + + const requestNumber = (request as any).requestNumber || (request as any).request_number || 'UNKNOWN'; + const activityName = claimDetails.activityName || 'Activity'; + const activityType = claimDetails.activityType || 'N/A'; + + // Prepare email recipients + const emailRecipients: string[] = []; + const userIdsForNotification: string[] = []; + + // Add initiator + if (initiator) { + emailRecipients.push(initiator.email); + userIdsForNotification.push(initiator.userId); + } + + // Add dealer + if (dealerUser) { + emailRecipients.push(dealerUser.email); + userIdsForNotification.push(dealerUser.userId); + } else if (claimDetails.dealerEmail) { + emailRecipients.push(claimDetails.dealerEmail); + } + + // Add department lead + if (departmentLead) { + emailRecipients.push(departmentLead.email); + userIdsForNotification.push(departmentLead.userId); + } + + // Send activity confirmation emails + const emailSubject = `Activity Created: ${activityName} - ${requestNumber}`; + const emailBody = `Activity "${activityName}" (${activityType}) has been created successfully for request ${requestNumber}. IO confirmation to be made.`; + + // Send notifications to users in the system + if (userIdsForNotification.length > 0) { + await notificationService.sendToUsers(userIdsForNotification, { + title: emailSubject, + body: emailBody, + requestId, + requestNumber, + url: `/request/${requestNumber}`, + type: 'activity_created', + priority: 'MEDIUM', + actionRequired: false + }); + } + + // Log activity creation + await activityService.log({ + requestId, + type: 'status_change', + user: { userId: 'system', name: 'System Auto-Process' }, + timestamp: new Date().toISOString(), + action: 'Activity Created', + details: `Activity "${activityName}" created. Activity confirmation email auto-triggered to dealer, requestor, and department lead. IO confirmation to be made.`, + }); + + // Step 4 is already activated (IN_PROGRESS) by the approval service + // Now auto-approve Step 4 immediately (since it's an auto-step) + const step4LevelId = step4Level.levelId; + await this.approvalService.approveLevel( + step4LevelId, + { action: 'APPROVE', comments: 'Activity created automatically. Activity confirmation email sent to dealer, requestor, and department lead.' }, + 'system', + { + ipAddress: null, + userAgent: 'System Auto-Process' + } + ); + + logger.info(`[DealerClaimService] Step 4 auto-approved for request ${requestId}. Activity creation completed.`); + } catch (error) { + logger.error(`[DealerClaimService] Error processing Step 4 activity creation for request ${requestId}:`, error); + throw error; + } + } }