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",
|
"setup": "ts-node -r tsconfig-paths/register src/scripts/auto-setup.ts",
|
||||||
"migrate": "ts-node -r tsconfig-paths/register src/scripts/migrate.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: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": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.68.0",
|
"@anthropic-ai/sdk": "^0.68.0",
|
||||||
|
|||||||
@ -4,8 +4,11 @@ import { DealerClaimService } from '../services/dealerClaim.service';
|
|||||||
import { ResponseHandler } from '../utils/responseHandler';
|
import { ResponseHandler } from '../utils/responseHandler';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import { gcsStorageService } from '../services/gcsStorage.service';
|
import { gcsStorageService } from '../services/gcsStorage.service';
|
||||||
|
import { Document } from '../models/Document';
|
||||||
|
import { User } from '../models/User';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
export class DealerClaimController {
|
export class DealerClaimController {
|
||||||
private dealerClaimService = new DealerClaimService();
|
private dealerClaimService = new DealerClaimService();
|
||||||
@ -198,6 +201,38 @@ export class DealerClaimController {
|
|||||||
proposalDocumentPath = uploadResult.filePath;
|
proposalDocumentPath = uploadResult.filePath;
|
||||||
proposalDocumentUrl = uploadResult.storageUrl;
|
proposalDocumentUrl = uploadResult.storageUrl;
|
||||||
|
|
||||||
|
// Save to documents table with category APPROVAL (proposal document)
|
||||||
|
try {
|
||||||
|
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||||
|
const extension = path.extname(file.originalname).replace('.', '').toLowerCase();
|
||||||
|
|
||||||
|
await Document.create({
|
||||||
|
requestId,
|
||||||
|
uploadedBy: userId,
|
||||||
|
fileName: path.basename(file.filename || file.originalname),
|
||||||
|
originalFileName: file.originalname,
|
||||||
|
fileType: extension,
|
||||||
|
fileExtension: extension,
|
||||||
|
fileSize: file.size,
|
||||||
|
filePath: uploadResult.filePath,
|
||||||
|
storageUrl: uploadResult.storageUrl,
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
checksum,
|
||||||
|
isGoogleDoc: false,
|
||||||
|
googleDocUrl: null as any,
|
||||||
|
category: 'APPROVAL', // Proposal document is an approval document
|
||||||
|
version: 1,
|
||||||
|
parentDocumentId: null as any,
|
||||||
|
isDeleted: false,
|
||||||
|
downloadCount: 0,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimController] Created document entry for proposal document ${file.originalname}`);
|
||||||
|
} catch (docError) {
|
||||||
|
logger.error(`[DealerClaimController] Error creating document entry for proposal document:`, docError);
|
||||||
|
// Don't fail the entire request if document entry creation fails
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup local file if exists
|
// Cleanup local file if exists
|
||||||
if (file.path && fs.existsSync(file.path)) {
|
if (file.path && fs.existsSync(file.path)) {
|
||||||
fs.unlinkSync(file.path);
|
fs.unlinkSync(file.path);
|
||||||
@ -237,10 +272,26 @@ export class DealerClaimController {
|
|||||||
numberOfParticipants,
|
numberOfParticipants,
|
||||||
closedExpenses,
|
closedExpenses,
|
||||||
totalClosedExpenses,
|
totalClosedExpenses,
|
||||||
completionDocuments,
|
|
||||||
activityPhotos,
|
|
||||||
} = req.body;
|
} = 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
|
// Find workflow to get actual UUID
|
||||||
const workflow = await this.findWorkflowByIdentifier(identifier);
|
const workflow = await this.findWorkflowByIdentifier(identifier);
|
||||||
if (!workflow) {
|
if (!workflow) {
|
||||||
@ -248,6 +299,7 @@ export class DealerClaimController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requestId = (workflow as any).requestId || (workflow as any).request_id;
|
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) {
|
if (!requestId) {
|
||||||
return ResponseHandler.error(res, 'Invalid workflow request', 400);
|
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);
|
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, {
|
await this.dealerClaimService.submitCompletionDocuments(requestId, {
|
||||||
activityCompletionDate: new Date(activityCompletionDate),
|
activityCompletionDate: new Date(activityCompletionDate),
|
||||||
numberOfParticipants: numberOfParticipants ? parseInt(numberOfParticipants) : undefined,
|
numberOfParticipants: numberOfParticipants ? parseInt(numberOfParticipants) : undefined,
|
||||||
closedExpenses: closedExpenses || [],
|
closedExpenses: parsedClosedExpenses,
|
||||||
totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0,
|
totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0,
|
||||||
completionDocuments: completionDocuments || [],
|
completionDocuments,
|
||||||
activityPhotos: activityPhotos || [],
|
activityPhotos,
|
||||||
|
invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined,
|
||||||
|
attendanceSheet: attendanceSheet || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return ResponseHandler.success(res, { message: 'Completion documents submitted successfully' }, 'Completion submitted');
|
return ResponseHandler.success(res, { message: 'Completion documents submitted successfully' }, 'Completion submitted');
|
||||||
@ -279,10 +475,12 @@ export class DealerClaimController {
|
|||||||
* Accepts either UUID or requestNumber
|
* Accepts either UUID or requestNumber
|
||||||
*/
|
*/
|
||||||
async updateIODetails(req: AuthenticatedRequest, res: Response): Promise<void> {
|
async updateIODetails(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
const userId = (req as any).user?.userId || (req as any).user?.user_id;
|
||||||
try {
|
try {
|
||||||
const identifier = req.params.requestId; // Can be UUID or requestNumber
|
const identifier = req.params.requestId; // Can be UUID or requestNumber
|
||||||
const {
|
const {
|
||||||
ioNumber,
|
ioNumber,
|
||||||
|
ioRemark,
|
||||||
availableBalance,
|
availableBalance,
|
||||||
blockedAmount,
|
blockedAmount,
|
||||||
remainingBalance,
|
remainingBalance,
|
||||||
@ -303,12 +501,17 @@ export class DealerClaimController {
|
|||||||
return ResponseHandler.error(res, 'Missing required IO fields', 400);
|
return ResponseHandler.error(res, 'Missing required IO fields', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dealerClaimService.updateIODetails(requestId, {
|
await this.dealerClaimService.updateIODetails(
|
||||||
|
requestId,
|
||||||
|
{
|
||||||
ioNumber,
|
ioNumber,
|
||||||
|
ioRemark: ioRemark || '',
|
||||||
availableBalance: parseFloat(availableBalance),
|
availableBalance: parseFloat(availableBalance),
|
||||||
blockedAmount: parseFloat(blockedAmount),
|
blockedAmount: parseFloat(blockedAmount),
|
||||||
remainingBalance: remainingBalance ? parseFloat(remainingBalance) : parseFloat(availableBalance) - parseFloat(blockedAmount),
|
remainingBalance: remainingBalance ? parseFloat(remainingBalance) : parseFloat(availableBalance) - parseFloat(blockedAmount),
|
||||||
});
|
},
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
return ResponseHandler.success(res, { message: 'IO details updated successfully' }, 'IO details updated');
|
return ResponseHandler.success(res, { message: 'IO details updated successfully' }, 'IO details updated');
|
||||||
} catch (error) {
|
} 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;
|
estimatedBudget?: number;
|
||||||
closedExpenses?: number;
|
closedExpenses?: number;
|
||||||
ioNumber?: string;
|
ioNumber?: string;
|
||||||
|
// Note: ioRemark moved to internal_orders table
|
||||||
ioAvailableBalance?: number;
|
ioAvailableBalance?: number;
|
||||||
ioBlockedAmount?: number;
|
ioBlockedAmount?: number;
|
||||||
ioRemainingBalance?: number;
|
ioRemainingBalance?: number;
|
||||||
@ -52,6 +53,7 @@ class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaim
|
|||||||
public estimatedBudget?: number;
|
public estimatedBudget?: number;
|
||||||
public closedExpenses?: number;
|
public closedExpenses?: number;
|
||||||
public ioNumber?: string;
|
public ioNumber?: string;
|
||||||
|
// Note: ioRemark moved to internal_orders table
|
||||||
public ioAvailableBalance?: number;
|
public ioAvailableBalance?: number;
|
||||||
public ioBlockedAmount?: number;
|
public ioBlockedAmount?: number;
|
||||||
public ioRemainingBalance?: number;
|
public ioRemainingBalance?: number;
|
||||||
@ -156,6 +158,7 @@ DealerClaimDetails.init(
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
field: 'io_number'
|
field: 'io_number'
|
||||||
},
|
},
|
||||||
|
// Note: ioRemark moved to internal_orders table - removed from here
|
||||||
ioAvailableBalance: {
|
ioAvailableBalance: {
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
allowNull: true,
|
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 { DealerCompletionDetails } from './DealerCompletionDetails';
|
||||||
import { DealerProposalCostItem } from './DealerProposalCostItem';
|
import { DealerProposalCostItem } from './DealerProposalCostItem';
|
||||||
import { WorkflowTemplate } from './WorkflowTemplate';
|
import { WorkflowTemplate } from './WorkflowTemplate';
|
||||||
|
import { InternalOrder } from './InternalOrder';
|
||||||
|
import { ClaimBudgetTracking } from './ClaimBudgetTracking';
|
||||||
|
|
||||||
// Define associations
|
// Define associations
|
||||||
const defineAssociations = () => {
|
const defineAssociations = () => {
|
||||||
@ -119,6 +121,20 @@ const defineAssociations = () => {
|
|||||||
sourceKey: 'userId'
|
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
|
// 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
|
// Only hasMany associations from WorkflowRequest are defined here since they're one-way
|
||||||
};
|
};
|
||||||
@ -148,7 +164,9 @@ export {
|
|||||||
DealerProposalDetails,
|
DealerProposalDetails,
|
||||||
DealerCompletionDetails,
|
DealerCompletionDetails,
|
||||||
DealerProposalCostItem,
|
DealerProposalCostItem,
|
||||||
WorkflowTemplate
|
WorkflowTemplate,
|
||||||
|
InternalOrder,
|
||||||
|
ClaimBudgetTracking
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export default sequelize instance
|
// Export default sequelize instance
|
||||||
|
|||||||
@ -51,7 +51,12 @@ router.post('/:requestId/proposal', authenticateToken, upload.single('proposalDo
|
|||||||
* @desc Submit completion documents (Step 5)
|
* @desc Submit completion documents (Step 5)
|
||||||
* @access Private
|
* @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
|
* @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 m26 = require('../migrations/20250126-add-pause-fields-to-approval-levels');
|
||||||
const m27 = require('../migrations/20250127-migrate-in-progress-to-pending');
|
const m27 = require('../migrations/20250127-migrate-in-progress-to-pending');
|
||||||
const m28 = require('../migrations/20251203-add-user-notification-preferences');
|
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 = [
|
const migrations = [
|
||||||
{ name: '2025103000-create-users', module: m0 },
|
{ 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: '20250126-add-pause-fields-to-approval-levels', module: m26 },
|
||||||
{ name: '20250127-migrate-in-progress-to-pending', module: m27 },
|
{ name: '20250127-migrate-in-progress-to-pending', module: m27 },
|
||||||
{ name: '20251203-add-user-notification-preferences', module: m28 },
|
{ 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();
|
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 m24 from '../migrations/20251210-add-template-id-foreign-key';
|
||||||
import * as m25 from '../migrations/20251210-create-dealer-claim-tables';
|
import * as m25 from '../migrations/20251210-create-dealer-claim-tables';
|
||||||
import * as m26 from '../migrations/20251210-create-proposal-cost-items-table';
|
import * as m26 from '../migrations/20251210-create-proposal-cost-items-table';
|
||||||
|
import * as m27 from '../migrations/20251211-create-internal-orders-table';
|
||||||
|
import * as m28 from '../migrations/20251211-create-claim-budget-tracking-table';
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@ -68,6 +70,8 @@ const migrations: Migration[] = [
|
|||||||
{ name: '20251210-add-template-id-foreign-key', module: m24 },
|
{ name: '20251210-add-template-id-foreign-key', module: m24 },
|
||||||
{ name: '20251210-create-dealer-claim-tables', module: m25 },
|
{ name: '20251210-create-dealer-claim-tables', module: m25 },
|
||||||
{ name: '20251210-create-proposal-cost-items-table', module: m26 },
|
{ name: '20251210-create-proposal-cost-items-table', module: m26 },
|
||||||
|
{ name: '20251211-create-internal-orders-table', module: m27 },
|
||||||
|
{ name: '20251211-create-claim-budget-tracking-table', module: m28 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { notificationService } from './notification.service';
|
|||||||
import { activityService } from './activity.service';
|
import { activityService } from './activity.service';
|
||||||
import { tatSchedulerService } from './tatScheduler.service';
|
import { tatSchedulerService } from './tatScheduler.service';
|
||||||
import { emitToRequestRoom } from '../realtime/socket';
|
import { emitToRequestRoom } from '../realtime/socket';
|
||||||
|
import { DealerClaimService } from './dealerClaim.service';
|
||||||
|
|
||||||
export class ApprovalService {
|
export class ApprovalService {
|
||||||
async approveLevel(levelId: string, action: ApprovalAction, _userId: string, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): Promise<ApprovalLevel | null> {
|
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}`);
|
logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`);
|
||||||
// Notify next approver
|
|
||||||
if (wf && nextLevel) {
|
// 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 ], {
|
await notificationService.sendToUsers([ (nextLevel as any).approverId ], {
|
||||||
title: `Action required: ${(wf as any).requestNumber}`,
|
title: `Action required: ${(wf as any).requestNumber}`,
|
||||||
body: `${(wf as any).title}`,
|
body: `${(wf as any).title}`,
|
||||||
requestNumber: (wf as any).requestNumber,
|
requestNumber: (wf as any).requestNumber,
|
||||||
url: `/request/${(wf as any).requestNumber}`
|
url: `/request/${(wf as any).requestNumber}`
|
||||||
});
|
});
|
||||||
|
}
|
||||||
activityService.log({
|
activityService.log({
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
type: 'approval',
|
type: 'approval',
|
||||||
|
|||||||
@ -3,15 +3,18 @@ import { DealerClaimDetails } from '../models/DealerClaimDetails';
|
|||||||
import { DealerProposalDetails } from '../models/DealerProposalDetails';
|
import { DealerProposalDetails } from '../models/DealerProposalDetails';
|
||||||
import { DealerCompletionDetails } from '../models/DealerCompletionDetails';
|
import { DealerCompletionDetails } from '../models/DealerCompletionDetails';
|
||||||
import { DealerProposalCostItem } from '../models/DealerProposalCostItem';
|
import { DealerProposalCostItem } from '../models/DealerProposalCostItem';
|
||||||
|
import { InternalOrder, IOStatus } from '../models/InternalOrder';
|
||||||
import { ApprovalLevel } from '../models/ApprovalLevel';
|
import { ApprovalLevel } from '../models/ApprovalLevel';
|
||||||
import { Participant } from '../models/Participant';
|
import { Participant } from '../models/Participant';
|
||||||
import { User } from '../models/User';
|
import { User } from '../models/User';
|
||||||
import { WorkflowService } from './workflow.service';
|
import { WorkflowService } from './workflow.service';
|
||||||
import { ApprovalService } from './approval.service';
|
import { ApprovalService } from './approval.service';
|
||||||
import { generateRequestNumber } from '../utils/helpers';
|
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 { sapIntegrationService } from './sapIntegration.service';
|
||||||
import { dmsIntegrationService } from './dmsIntegration.service';
|
import { dmsIntegrationService } from './dmsIntegration.service';
|
||||||
|
import { notificationService } from './notification.service';
|
||||||
|
import { activityService } from './activity.service';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -86,6 +89,9 @@ export class DealerClaimService {
|
|||||||
// Create 8 approval levels for claim management workflow
|
// Create 8 approval levels for claim management workflow
|
||||||
await this.createClaimApprovalLevels(workflowRequest.requestId, userId, claimData.dealerEmail);
|
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}`);
|
logger.info(`[DealerClaimService] Created claim request: ${workflowRequest.requestNumber}`);
|
||||||
return workflowRequest;
|
return workflowRequest;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -277,6 +283,9 @@ export class DealerClaimService {
|
|||||||
tatHours: step.tatHours,
|
tatHours: step.tatHours,
|
||||||
// tatDays is calculated later when needed: 1 day = 8 working hours
|
// tatDays is calculated later when needed: 1 day = 8 working hours
|
||||||
// Formula: Math.ceil(tatHours / 8)
|
// 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,
|
status: step.level === 1 ? ApprovalStatus.PENDING : ApprovalStatus.PENDING,
|
||||||
isFinalApprover: step.level === 8,
|
isFinalApprover: step.level === 8,
|
||||||
elapsedHours: 0,
|
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
|
* Resolve Department Lead based on initiator's department/manager
|
||||||
* If multiple users found with same department, uses the first one
|
* If multiple users found with same department, uses the first one
|
||||||
@ -398,7 +539,41 @@ export class DealerClaimService {
|
|||||||
return anyDeptLead;
|
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;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[DealerClaimService] Error resolving department lead:', error);
|
logger.error('[DealerClaimService] Error resolving department lead:', error);
|
||||||
@ -508,6 +683,14 @@ export class DealerClaimService {
|
|||||||
where: { requestId }
|
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
|
// Serialize claim details to ensure proper field names
|
||||||
let serializedClaimDetails = null;
|
let serializedClaimDetails = null;
|
||||||
if (claimDetails) {
|
if (claimDetails) {
|
||||||
@ -553,11 +736,18 @@ export class DealerClaimService {
|
|||||||
serializedCompletionDetails = (completionDetails as any).toJSON ? (completionDetails as any).toJSON() : completionDetails;
|
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 {
|
return {
|
||||||
request: (request as any).toJSON ? (request as any).toJSON() : request,
|
request: (request as any).toJSON ? (request as any).toJSON() : request,
|
||||||
claimDetails: serializedClaimDetails,
|
claimDetails: serializedClaimDetails,
|
||||||
proposalDetails: transformedProposalDetails,
|
proposalDetails: transformedProposalDetails,
|
||||||
completionDetails: serializedCompletionDetails,
|
completionDetails: serializedCompletionDetails,
|
||||||
|
internalOrder: serializedInternalOrder,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[DealerClaimService] Error getting claim details:', error);
|
logger.error('[DealerClaimService] Error getting claim details:', error);
|
||||||
@ -692,6 +882,8 @@ export class DealerClaimService {
|
|||||||
totalClosedExpenses: number;
|
totalClosedExpenses: number;
|
||||||
completionDocuments: any[];
|
completionDocuments: any[];
|
||||||
activityPhotos: any[];
|
activityPhotos: any[];
|
||||||
|
invoicesReceipts?: any[];
|
||||||
|
attendanceSheet?: any;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@ -753,10 +945,12 @@ export class DealerClaimService {
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
ioData: {
|
ioData: {
|
||||||
ioNumber: string;
|
ioNumber: string;
|
||||||
|
ioRemark?: string;
|
||||||
availableBalance?: number;
|
availableBalance?: number;
|
||||||
blockedAmount?: number;
|
blockedAmount?: number;
|
||||||
remainingBalance?: number;
|
remainingBalance?: number;
|
||||||
}
|
},
|
||||||
|
organizedByUserId?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Validate IO number with SAP
|
// Validate IO number with SAP
|
||||||
@ -789,16 +983,38 @@ export class DealerClaimService {
|
|||||||
remainingBalance = blockResult.remainingBalance;
|
remainingBalance = blockResult.remainingBalance;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update claim details with IO information
|
// Get the user who is organizing the IO
|
||||||
await DealerClaimDetails.update(
|
const organizedBy = organizedByUserId || null;
|
||||||
{
|
|
||||||
|
// Create or update Internal Order record
|
||||||
|
const [internalOrder, created] = await InternalOrder.findOrCreate({
|
||||||
|
where: { requestId },
|
||||||
|
defaults: {
|
||||||
|
requestId,
|
||||||
ioNumber: ioData.ioNumber,
|
ioNumber: ioData.ioNumber,
|
||||||
|
ioRemark: ioData.ioRemark || '',
|
||||||
ioAvailableBalance: ioValidation.availableBalance,
|
ioAvailableBalance: ioValidation.availableBalance,
|
||||||
ioBlockedAmount: blockedAmount,
|
ioBlockedAmount: blockedAmount,
|
||||||
ioRemainingBalance: remainingBalance,
|
ioRemainingBalance: remainingBalance,
|
||||||
},
|
organizedBy: organizedBy || undefined,
|
||||||
{ where: { requestId } }
|
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}`, {
|
logger.info(`[DealerClaimService] IO details updated for request: ${requestId}`, {
|
||||||
ioNumber: ioData.ioNumber,
|
ioNumber: ioData.ioNumber,
|
||||||
@ -832,6 +1048,15 @@ export class DealerClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const request = await WorkflowRequest.findByPk(requestId);
|
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';
|
const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN';
|
||||||
|
|
||||||
// If invoice data not provided, generate via DMS
|
// 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}`);
|
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) {
|
} catch (error) {
|
||||||
logger.error('[DealerClaimService] Error updating e-invoice details:', error);
|
logger.error('[DealerClaimService] Error updating e-invoice details:', error);
|
||||||
throw 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)
|
* Update credit note details (Step 8)
|
||||||
* Generates credit note via DMS integration
|
* Generates credit note via DMS integration
|
||||||
@ -960,5 +1265,137 @@ export class DealerClaimService {
|
|||||||
throw error;
|
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