delaer claim related extra tables added like internalorder budget locking migration done.

This commit is contained in:
laxmanhalaki 2025-12-10 20:43:56 +05:30
parent 5d90e58bf9
commit ad18ec54e9
17 changed files with 2237 additions and 31 deletions

View File

@ -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

View File

@ -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!

216
docs/NEW_TABLES_SUMMARY.md Normal file
View File

@ -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

View File

@ -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",

View File

@ -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<void> => {
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<void> {
const userId = (req as any).user?.userId || (req as any).user?.user_id;
try {
const identifier = req.params.requestId; // Can be UUID or requestNumber
const {
ioNumber,
ioRemark,
availableBalance,
blockedAmount,
remainingBalance,
@ -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) {

View File

@ -0,0 +1,197 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
// 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<void> {
await queryInterface.dropTable('claim_budget_tracking');
}

View File

@ -0,0 +1,95 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
// 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<void> {
await queryInterface.dropTable('internal_orders');
}

View File

@ -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<ClaimBudgetTrackingAttributes, 'budgetId' | 'initialEstimatedBudget' | 'proposalEstimatedBudget' | 'proposalSubmittedAt' | 'approvedBudget' | 'approvedAt' | 'approvedBy' | 'ioBlockedAmount' | 'ioBlockedAt' | 'closedExpenses' | 'closedExpensesSubmittedAt' | 'finalClaimAmount' | 'finalClaimAmountApprovedAt' | 'finalClaimAmountApprovedBy' | 'creditNoteAmount' | 'creditNoteIssuedAt' | 'varianceAmount' | 'variancePercentage' | 'lastModifiedBy' | 'lastModifiedAt' | 'modificationReason' | 'budgetStatus' | 'currency' | 'createdAt' | 'updatedAt'> {}
class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudgetTrackingCreationAttributes> 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 };

View File

@ -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<DealerClaimDetailsAttributes, DealerClaim
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;
@ -156,6 +158,7 @@ DealerClaimDetails.init(
allowNull: true,
field: 'io_number'
},
// Note: ioRemark moved to internal_orders table - removed from here
ioAvailableBalance: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,

166
src/models/InternalOrder.ts Normal file
View File

@ -0,0 +1,166 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { WorkflowRequest } from './WorkflowRequest';
import { User } from './User';
export enum IOStatus {
PENDING = 'PENDING',
BLOCKED = 'BLOCKED',
RELEASED = 'RELEASED',
CANCELLED = 'CANCELLED'
}
interface InternalOrderAttributes {
ioId: string;
requestId: string;
ioNumber: string;
ioRemark?: string;
ioAvailableBalance?: number;
ioBlockedAmount?: number;
ioRemainingBalance?: number;
organizedBy?: string;
organizedAt?: Date;
sapDocumentNumber?: string;
status: IOStatus;
createdAt: Date;
updatedAt: Date;
}
interface InternalOrderCreationAttributes extends Optional<InternalOrderAttributes, 'ioId' | 'ioRemark' | 'ioAvailableBalance' | 'ioBlockedAmount' | 'ioRemainingBalance' | 'organizedBy' | 'organizedAt' | 'sapDocumentNumber' | 'status' | 'createdAt' | 'updatedAt'> {}
class InternalOrder extends Model<InternalOrderAttributes, InternalOrderCreationAttributes> 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 };

View File

@ -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

View File

@ -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

View File

@ -119,6 +119,13 @@ async function runMigrations(): Promise<void> {
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<void> {
{ 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();

View File

@ -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<void> {
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 };

View File

@ -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 },
];
/**

View File

@ -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<ApprovalLevel | null> {
@ -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',

View File

@ -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<void> {
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<string>([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<string, typeof participantsToAdd[0]>();
const rolePriority: Record<string, number> = {
'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<void> {
try {
@ -753,10 +945,12 @@ export class DealerClaimService {
requestId: string,
ioData: {
ioNumber: string;
ioRemark?: string;
availableBalance?: number;
blockedAmount?: number;
remainingBalance?: number;
}
},
organizedByUserId?: string
): Promise<void> {
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<void> {
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<void> {
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;
}
}
}