delaer claim related extra tables added like internalorder budget locking migration done.
This commit is contained in:
parent
5d90e58bf9
commit
ad18ec54e9
181
docs/DEALER_CLAIM_FRESH_START.md
Normal file
181
docs/DEALER_CLAIM_FRESH_START.md
Normal 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
|
||||
|
||||
164
docs/MIGRATION_SETUP_SUMMARY.md
Normal file
164
docs/MIGRATION_SETUP_SUMMARY.md
Normal 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
216
docs/NEW_TABLES_SUMMARY.md
Normal 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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) {
|
||||
|
||||
197
src/migrations/20251211-create-claim-budget-tracking-table.ts
Normal file
197
src/migrations/20251211-create-claim-budget-tracking-table.ts
Normal 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');
|
||||
}
|
||||
|
||||
95
src/migrations/20251211-create-internal-orders-table.ts
Normal file
95
src/migrations/20251211-create-internal-orders-table.ts
Normal 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');
|
||||
}
|
||||
|
||||
295
src/models/ClaimBudgetTracking.ts
Normal file
295
src/models/ClaimBudgetTracking.ts
Normal 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 };
|
||||
|
||||
@ -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
166
src/models/InternalOrder.ts
Normal 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 };
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
167
src/scripts/cleanup-dealer-claims.ts
Normal file
167
src/scripts/cleanup-dealer-claims.ts
Normal 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 };
|
||||
|
||||
@ -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 },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user