we have aded the apad approvar in dealer claim
This commit is contained in:
parent
1ac169dc7f
commit
fbbde44d46
@ -2,10 +2,70 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the data exchange between the Royal Enfield Workflow System and the DMS (Document Management System) for:
|
||||
This document describes the data exchange between the Royal Enfield Workflow System (RE-Flow) and the DMS (Document Management System) for:
|
||||
1. **E-Invoice Generation** - Submitting claim data to DMS for e-invoice creation
|
||||
2. **Credit Note Generation** - Fetching/Generating credit note from DMS
|
||||
|
||||
## Data Flow Overview
|
||||
|
||||
### Inputs from RE-Flow System
|
||||
|
||||
The following data is sent **FROM** RE-Flow System **TO** DMS:
|
||||
|
||||
1. **Dealer Code** - Unique dealer identifier
|
||||
2. **Dealer Name** - Dealer business name
|
||||
3. **Activity Name** - Name of the activity/claim type (see Activity Types below)
|
||||
4. **Activity Description** - Detailed description of the activity
|
||||
5. **Claim Amount** - Total claim amount (before taxes)
|
||||
6. **Request Number** - Unique request identifier from RE-Flow (e.g., "REQ-2025-12-0001")
|
||||
7. **IO Number** - Internal Order number (if available)
|
||||
|
||||
### Inputs from DMS Team
|
||||
|
||||
The following data is **PROVIDED BY** DMS Team **TO** RE-Flow System (via webhook):
|
||||
|
||||
3. **Document No** - Generated invoice/credit note number
|
||||
4. **Document Type** - Type of document ("E-INVOICE", "INVOICE", or "CREDIT_NOTE")
|
||||
10. **Item Code No** - Item code number (same as provided in request, used for GST calculation)
|
||||
11. **HSN/SAC Code** - HSN/SAC code for tax calculation (determined by DMS based on Item Code No)
|
||||
12. **CGST %** - CGST percentage (e.g., 9.0 for 9%) - calculated by DMS based on Item Code No and dealer location
|
||||
13. **SGST %** - SGST percentage (e.g., 9.0 for 9%) - calculated by DMS based on Item Code No and dealer location
|
||||
14. **IGST %** - IGST percentage (0.0 for intra-state, >0 for inter-state) - calculated by DMS based on Item Code No and dealer location
|
||||
15. **CGST Amount** - CGST amount in INR - calculated by DMS
|
||||
16. **SGST Amount** - SGST amount in INR - calculated by DMS
|
||||
17. **IGST Amount** - IGST amount in INR - calculated by DMS
|
||||
18. **Credit Type** - Type of credit: "GST" or "Commercial Credit" (for credit notes only)
|
||||
19. **IRN No** - Invoice Reference Number from GST portal (response from GST system)
|
||||
20. **SAP Credit Note No** - SAP Credit Note Number (response from SAP system, for credit notes only)
|
||||
|
||||
**Important:** Item Code No is used by DMS for GST calculation. DMS determines HSN/SAC code, tax percentages, and tax amounts based on the Item Code No and dealer location.
|
||||
|
||||
### Predefined Activity Types
|
||||
|
||||
The following is the complete list of predefined Activity Types that RE-Flow System uses. DMS Team must provide **Item Code No** mapping for each Activity Type:
|
||||
|
||||
- **Riders Mania Claims**
|
||||
- **Marketing Cost – Bike to Vendor**
|
||||
- **Media Bike Service**
|
||||
- **ARAI Motorcycle Liquidation**
|
||||
- **ARAI Certification – STA Approval CNR**
|
||||
- **Procurement of Spares/Apparel/GMA for Events**
|
||||
- **Fuel for Media Bike Used for Event**
|
||||
- **Motorcycle Buyback and Goodwill Support**
|
||||
- **Liquidation of Used Motorcycle**
|
||||
- **Motorcycle Registration CNR (Owned or Gifted by RE)**
|
||||
- **Legal Claims Reimbursement**
|
||||
- **Service Camp Claims**
|
||||
- **Corporate Claims – Institutional Sales PDI**
|
||||
|
||||
**Item Code No Lookup Process:**
|
||||
1. RE-Flow sends `activity_name` to DMS
|
||||
2. DMS responds with corresponding `item_code_no` based on activity type mapping
|
||||
3. RE-Flow includes the `item_code_no` in invoice/credit note generation payload
|
||||
4. DMS uses `item_code_no` to determine HSN/SAC code and calculate GST (CGST/SGST/IGST percentages and amounts)
|
||||
|
||||
**Note:** DMS Team must configure the Activity Type → Item Code No mapping in their system. This mapping is used for GST calculation.
|
||||
|
||||
---
|
||||
|
||||
## 1. E-Invoice Generation (DMS Push)
|
||||
@ -27,46 +87,84 @@ Authorization: Bearer {DMS_API_KEY}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
**Request Body (Complete Payload):**
|
||||
```json
|
||||
{
|
||||
"request_number": "REQ-2025-001234",
|
||||
"request_number": "REQ-2025-12-0001",
|
||||
"dealer_code": "DLR001",
|
||||
"dealer_name": "ABC Motors",
|
||||
"amount": 150000.00,
|
||||
"description": "E-Invoice for claim request REQ-2025-001234",
|
||||
"activity_name": "Marketing Cost – Bike to Vendor",
|
||||
"activity_description": "Q4 Marketing Campaign for Royal Enfield",
|
||||
"claim_amount": 150000.00,
|
||||
"io_number": "IO-2025-001",
|
||||
"tax_details": {
|
||||
"cgst": 0,
|
||||
"sgst": 0,
|
||||
"igst": 0,
|
||||
"total_tax": 0
|
||||
}
|
||||
"item_code_no": "ITEM-001"
|
||||
}
|
||||
```
|
||||
|
||||
**Complete Webhook Response Payload (from DMS to RE-Flow):**
|
||||
|
||||
After processing, DMS will send the following complete payload to RE-Flow webhook endpoint `POST /api/v1/webhooks/dms/invoice`:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_number": "REQ-2025-12-0001",
|
||||
"document_no": "EINV-2025-001234",
|
||||
"document_type": "E-INVOICE",
|
||||
"document_date": "2025-12-17T10:30:00.000Z",
|
||||
"dealer_code": "DLR001",
|
||||
"dealer_name": "ABC Motors",
|
||||
"activity_name": "Marketing Cost – Bike to Vendor",
|
||||
"activity_description": "Q4 Marketing Campaign for Royal Enfield",
|
||||
"claim_amount": 150000.00,
|
||||
"io_number": "IO-2025-001",
|
||||
"item_code_no": "ITEM-001",
|
||||
"hsn_sac_code": "998314",
|
||||
"cgst_percentage": 9.0,
|
||||
"sgst_percentage": 9.0,
|
||||
"igst_percentage": 0.0,
|
||||
"cgst_amount": 13500.00,
|
||||
"sgst_amount": 13500.00,
|
||||
"igst_amount": 0.00,
|
||||
"total_amount": 177000.00,
|
||||
"irn_no": "IRN123456789012345678901234567890123456789012345678901234567890",
|
||||
"invoice_file_path": "https://dms.example.com/invoices/EINV-2025-001234.pdf",
|
||||
"error_message": null,
|
||||
"timestamp": "2025-12-17T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- RE-Flow sends all required details including `item_code_no` (determined by DMS based on `activity_name` mapping)
|
||||
- DMS processes the invoice generation **asynchronously**
|
||||
- DMS responds with acknowledgment only
|
||||
- **Status Verification (Primary Method):** DMS sends webhook to RE-Flow webhook URL `POST /api/v1/webhooks/dms/invoice` (see DMS_WEBHOOK_API.md) to notify when invoice is generated with complete details
|
||||
- `item_code_no` is used by DMS for GST calculation (HSN/SAC code, tax percentages, tax amounts)
|
||||
- **Status Verification (Backup Method):** If webhook fails, RE-Flow can use backup status check API (see section "Backup: Status Check API" below)
|
||||
|
||||
### Request Field Descriptions
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `request_number` | string | Yes | Unique request number from RE Workflow System (e.g., "REQ-2025-001234") |
|
||||
| `dealer_code` | string | Yes | Dealer's unique code/identifier |
|
||||
| `dealer_name` | string | Yes | Dealer's business name |
|
||||
| `amount` | number | Yes | Total invoice amount (in INR, decimal format) |
|
||||
| `description` | string | Yes | Description of the invoice/claim |
|
||||
| `request_number` | string | ✅ Yes | Unique request number from RE-Flow System (e.g., "REQ-2025-12-0001") |
|
||||
| `dealer_code` | string | ✅ Yes | Dealer's unique code/identifier |
|
||||
| `dealer_name` | string | ✅ Yes | Dealer's business name |
|
||||
| `activity_name` | string | ✅ Yes | Activity type name (must match one of the predefined Activity Types) |
|
||||
| `activity_description` | string | ✅ Yes | Detailed description of the activity/claim |
|
||||
| `claim_amount` | number | ✅ Yes | Total claim amount before taxes (in INR, decimal format) |
|
||||
| `io_number` | string | No | Internal Order (IO) number if available |
|
||||
| `tax_details` | object | No | Tax breakdown (CGST, SGST, IGST, Total Tax) |
|
||||
| `item_code_no` | string | ✅ Yes | Item code number determined by DMS based on `activity_name` mapping. RE-Flow includes this in the request. Used by DMS for GST calculation. |
|
||||
|
||||
### Expected Response
|
||||
|
||||
**Success Response (200 OK):**
|
||||
|
||||
**Note:** DMS should respond with a simple acknowledgment. The actual invoice details (document number, tax calculations, IRN, etc.) will be sent back to RE-Flow via **webhook** (see DMS_WEBHOOK_API.md).
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"e_invoice_number": "EINV-2025-001234",
|
||||
"dms_number": "DMS-2025-001234",
|
||||
"invoice_date": "2025-12-17T10:30:00.000Z",
|
||||
"invoice_url": "https://dms.example.com/invoices/EINV-2025-001234"
|
||||
"message": "Invoice generation request received and queued for processing",
|
||||
"request_number": "REQ-2025-12-0001"
|
||||
}
|
||||
```
|
||||
|
||||
@ -74,11 +172,15 @@ Content-Type: application/json
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `success` | boolean | Indicates if the request was successful |
|
||||
| `e_invoice_number` | string | Generated e-invoice number (unique identifier) |
|
||||
| `dms_number` | string | DMS internal tracking number |
|
||||
| `invoice_date` | string (ISO 8601) | Date when the invoice was generated |
|
||||
| `invoice_url` | string | URL to view/download the invoice document |
|
||||
| `success` | boolean | Indicates if the request was accepted |
|
||||
| `message` | string | Status message |
|
||||
| `request_number` | string | Echo of the request number for reference |
|
||||
|
||||
**Important:**
|
||||
- The actual invoice generation happens **asynchronously**
|
||||
- DMS will send the complete invoice details (including document number, tax calculations, IRN, file path, `item_code_no`, etc.) via **webhook** to RE-Flow System once processing is complete
|
||||
- Webhook endpoint: `POST /api/v1/webhooks/dms/invoice` (see DMS_WEBHOOK_API.md for details)
|
||||
- If webhook delivery fails, RE-Flow can use the backup status check API (see section "Backup: Status Check API" below)
|
||||
|
||||
### Error Response
|
||||
|
||||
@ -107,12 +209,14 @@ curl -X POST "https://dms.example.com/api/invoices/generate" \
|
||||
-H "Authorization: Bearer YOUR_DMS_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"request_number": "REQ-2025-001234",
|
||||
"request_number": "REQ-2025-12-0001",
|
||||
"dealer_code": "DLR001",
|
||||
"dealer_name": "ABC Motors",
|
||||
"amount": 150000.00,
|
||||
"description": "E-Invoice for claim request REQ-2025-001234",
|
||||
"io_number": "IO-2025-001"
|
||||
"activity_name": "Marketing Cost – Bike to Vendor",
|
||||
"activity_description": "Q4 Marketing Campaign for Royal Enfield",
|
||||
"claim_amount": 150000.00,
|
||||
"io_number": "IO-2025-001",
|
||||
"item_code_no": "ITEM-001"
|
||||
}'
|
||||
```
|
||||
|
||||
@ -137,41 +241,86 @@ Authorization: Bearer {DMS_API_KEY}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
**Request Body (Complete Payload):**
|
||||
```json
|
||||
{
|
||||
"request_number": "REQ-2025-001234",
|
||||
"request_number": "REQ-2025-12-0001",
|
||||
"e_invoice_number": "EINV-2025-001234",
|
||||
"dealer_code": "DLR001",
|
||||
"dealer_name": "ABC Motors",
|
||||
"amount": 150000.00,
|
||||
"reason": "Claim settlement",
|
||||
"description": "Credit note for claim request REQ-2025-001234"
|
||||
"activity_name": "Marketing Cost – Bike to Vendor",
|
||||
"activity_description": "Q4 Marketing Campaign for Royal Enfield",
|
||||
"claim_amount": 150000.00,
|
||||
"io_number": "IO-2025-001",
|
||||
"item_code_no": "ITEM-001"
|
||||
}
|
||||
```
|
||||
|
||||
**Complete Webhook Response Payload (from DMS to RE-Flow):**
|
||||
|
||||
After processing, DMS will send the following complete payload to RE-Flow webhook endpoint `POST /api/v1/webhooks/dms/credit-note`:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_number": "REQ-2025-12-0001",
|
||||
"document_no": "CN-2025-001234",
|
||||
"document_type": "CREDIT_NOTE",
|
||||
"document_date": "2025-12-17T11:00:00.000Z",
|
||||
"dealer_code": "DLR001",
|
||||
"dealer_name": "ABC Motors",
|
||||
"activity_name": "Marketing Cost – Bike to Vendor",
|
||||
"activity_description": "Q4 Marketing Campaign for Royal Enfield",
|
||||
"claim_amount": 150000.00,
|
||||
"io_number": "IO-2025-001",
|
||||
"item_code_no": "ITEM-001",
|
||||
"hsn_sac_code": "998314",
|
||||
"cgst_percentage": 9.0,
|
||||
"sgst_percentage": 9.0,
|
||||
"igst_percentage": 0.0,
|
||||
"cgst_amount": 13500.00,
|
||||
"sgst_amount": 13500.00,
|
||||
"igst_amount": 0.00,
|
||||
"total_amount": 177000.00,
|
||||
"credit_type": "GST",
|
||||
"irn_no": "IRN987654321098765432109876543210987654321098765432109876543210",
|
||||
"sap_credit_note_no": "SAP-CN-2025-001234",
|
||||
"credit_note_file_path": "https://dms.example.com/credit-notes/CN-2025-001234.pdf",
|
||||
"error_message": null,
|
||||
"timestamp": "2025-12-17T11:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- RE-Flow sends `activity_name` in the request
|
||||
- DMS should use the same Item Code No from the original invoice (determined by `activity_name`)
|
||||
- DMS returns `item_code_no` in the webhook response (see DMS_WEBHOOK_API.md)
|
||||
- `item_code_no` is used by DMS for GST calculation (HSN/SAC code, tax percentages, tax amounts)
|
||||
|
||||
### Request Field Descriptions
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `request_number` | string | Yes | Original request number from RE Workflow System |
|
||||
| `e_invoice_number` | string | Yes | E-invoice number that was generated earlier (must exist in DMS) |
|
||||
| `dealer_code` | string | Yes | Dealer's unique code/identifier (must match invoice) |
|
||||
| `dealer_name` | string | Yes | Dealer's business name |
|
||||
| `amount` | number | Yes | Credit note amount (in INR, decimal format) - typically matches invoice amount |
|
||||
| `reason` | string | Yes | Reason for credit note (e.g., "Claim settlement", "Return", "Adjustment") |
|
||||
| `description` | string | No | Additional description/details about the credit note |
|
||||
| `request_number` | string | ✅ Yes | Original request number from RE-Flow System |
|
||||
| `e_invoice_number` | string | ✅ Yes | E-invoice number that was generated earlier (must exist in DMS) |
|
||||
| `dealer_code` | string | ✅ Yes | Dealer's unique code/identifier (must match invoice) |
|
||||
| `dealer_name` | string | ✅ Yes | Dealer's business name |
|
||||
| `activity_name` | string | ✅ Yes | Activity type name (must match original invoice) |
|
||||
| `activity_description` | string | ✅ Yes | Activity description (must match original invoice) |
|
||||
| `claim_amount` | number | ✅ Yes | Credit note amount (in INR, decimal format) - typically matches invoice amount |
|
||||
| `io_number` | string | No | Internal Order (IO) number if available |
|
||||
| `item_code_no` | string | ✅ Yes | Item code number (same as original invoice, determined by `activity_name` mapping). RE-Flow includes this in the request. Used by DMS for GST calculation. |
|
||||
|
||||
### Expected Response
|
||||
|
||||
**Success Response (200 OK):**
|
||||
|
||||
**Note:** DMS should respond with a simple acknowledgment. The actual credit note details (document number, tax calculations, SAP credit note number, IRN, etc.) will be sent back to RE-Flow via **webhook** (see DMS_WEBHOOK_API.md).
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"credit_note_number": "CN-2025-001234",
|
||||
"credit_note_date": "2025-12-17T10:30:00.000Z",
|
||||
"credit_note_amount": 150000.00,
|
||||
"credit_note_url": "https://dms.example.com/credit-notes/CN-2025-001234"
|
||||
"message": "Credit note generation request received and queued for processing",
|
||||
"request_number": "REQ-2025-12-0001"
|
||||
}
|
||||
```
|
||||
|
||||
@ -179,11 +328,11 @@ Content-Type: application/json
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `success` | boolean | Indicates if the request was successful |
|
||||
| `credit_note_number` | string | Generated credit note number (unique identifier) |
|
||||
| `credit_note_date` | string (ISO 8601) | Date when the credit note was generated |
|
||||
| `credit_note_amount` | number | Credit note amount (should match request amount) |
|
||||
| `credit_note_url` | string | URL to view/download the credit note document |
|
||||
| `success` | boolean | Indicates if the request was accepted |
|
||||
| `message` | string | Status message |
|
||||
| `request_number` | string | Echo of the request number for reference |
|
||||
|
||||
**Important:** The actual credit note generation happens asynchronously. DMS will send the complete credit note details (including document number, tax calculations, SAP credit note number, IRN, file path, etc.) via webhook to RE-Flow System once processing is complete.
|
||||
|
||||
### Error Response
|
||||
|
||||
@ -213,13 +362,15 @@ curl -X POST "https://dms.example.com/api/credit-notes/generate" \
|
||||
-H "Authorization: Bearer YOUR_DMS_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"request_number": "REQ-2025-001234",
|
||||
"request_number": "REQ-2025-12-0001",
|
||||
"e_invoice_number": "EINV-2025-001234",
|
||||
"dealer_code": "DLR001",
|
||||
"dealer_name": "ABC Motors",
|
||||
"amount": 150000.00,
|
||||
"reason": "Claim settlement",
|
||||
"description": "Credit note for claim request REQ-2025-001234"
|
||||
"activity_name": "Marketing Cost – Bike to Vendor",
|
||||
"activity_description": "Q4 Marketing Campaign for Royal Enfield",
|
||||
"claim_amount": 150000.00,
|
||||
"io_number": "IO-2025-001",
|
||||
"item_code_no": "ITEM-001"
|
||||
}'
|
||||
```
|
||||
|
||||
@ -261,34 +412,65 @@ DMS supports two authentication methods:
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ RE Workflow │
|
||||
│ System │
|
||||
│ RE-Flow System │
|
||||
│ (Step 6) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
│ POST /api/invoices/generate
|
||||
│ { request_number, dealer_code, amount, ... }
|
||||
│ { request_number, dealer_code, activity_name,
|
||||
│ claim_amount, item_code_no, ... }
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ DMS System │
|
||||
│ │
|
||||
│ - Validates │
|
||||
│ - Generates │
|
||||
│ E-Invoice │
|
||||
│ - Queues for │
|
||||
│ processing │
|
||||
│ │
|
||||
│ Response: │
|
||||
│ { success: true }│
|
||||
└────────┬────────┘
|
||||
│
|
||||
│ Response: { e_invoice_number, dms_number, ... }
|
||||
│ (Asynchronous Processing)
|
||||
│
|
||||
│ - Determines Item Code No
|
||||
│ - Calculates GST
|
||||
│ - Generates E-Invoice
|
||||
│ - Gets IRN from GST
|
||||
│
|
||||
│ POST /api/v1/webhooks/dms/invoice
|
||||
│ { document_no, item_code_no,
|
||||
│ hsn_sac_code, tax details,
|
||||
│ irn_no, invoice_file_path, ... }
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ RE Workflow │
|
||||
│ System │
|
||||
│ RE-Flow System │
|
||||
│ │
|
||||
│ - Receives │
|
||||
│ webhook │
|
||||
│ - Stores │
|
||||
│ invoice data │
|
||||
│ - Updates │
|
||||
│ workflow │
|
||||
│ - Moves to │
|
||||
│ Step 8 │
|
||||
└─────────────────┘
|
||||
|
||||
Backup (if webhook fails):
|
||||
┌─────────────────┐
|
||||
│ RE-Flow System │
|
||||
│ │
|
||||
│ GET /api/invoices/status/{request_number}
|
||||
│ │
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ DMS System │
|
||||
│ │
|
||||
│ Returns current │
|
||||
│ invoice status │
|
||||
│ and details │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
@ -296,13 +478,14 @@ DMS supports two authentication methods:
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ RE Workflow │
|
||||
│ System │
|
||||
│ RE-Flow System │
|
||||
│ (Step 8) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
│ POST /api/credit-notes/generate
|
||||
│ { e_invoice_number, request_number, amount, ... }
|
||||
│ { e_invoice_number, request_number,
|
||||
│ activity_name, claim_amount,
|
||||
│ item_code_no, ... }
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
@ -310,21 +493,55 @@ DMS supports two authentication methods:
|
||||
│ │
|
||||
│ - Validates │
|
||||
│ invoice │
|
||||
│ - Generates │
|
||||
│ Credit Note │
|
||||
│ - Queues for │
|
||||
│ processing │
|
||||
│ │
|
||||
│ Response: │
|
||||
│ { success: true }│
|
||||
└────────┬────────┘
|
||||
│
|
||||
│ Response: { credit_note_number, credit_note_date, ... }
|
||||
│ (Asynchronous Processing)
|
||||
│
|
||||
│ - Uses Item Code No from invoice
|
||||
│ - Calculates GST
|
||||
│ - Generates Credit Note
|
||||
│ - Gets IRN from GST
|
||||
│ - Gets SAP Credit Note No
|
||||
│
|
||||
│ POST /api/v1/webhooks/dms/credit-note
|
||||
│ { document_no, item_code_no,
|
||||
│ hsn_sac_code, tax details,
|
||||
│ irn_no, sap_credit_note_no,
|
||||
│ credit_note_file_path, ... }
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ RE Workflow │
|
||||
│ System │
|
||||
│ RE-Flow System │
|
||||
│ │
|
||||
│ - Receives │
|
||||
│ webhook │
|
||||
│ - Stores │
|
||||
│ credit note │
|
||||
│ - Updates │
|
||||
│ workflow │
|
||||
│ - Completes │
|
||||
│ request │
|
||||
└─────────────────┘
|
||||
|
||||
Backup (if webhook fails):
|
||||
┌─────────────────┐
|
||||
│ RE-Flow System │
|
||||
│ │
|
||||
│ GET /api/credit-notes/status/{request_number}
|
||||
│ │
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ DMS System │
|
||||
│ │
|
||||
│ Returns current │
|
||||
│ credit note │
|
||||
│ status and │
|
||||
│ details │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
@ -332,27 +549,37 @@ DMS supports two authentication methods:
|
||||
|
||||
## Data Mapping
|
||||
|
||||
### RE Workflow System → DMS
|
||||
### RE-Flow System → DMS (API Request)
|
||||
|
||||
| RE Workflow Field | DMS Request Field | Notes |
|
||||
|-------------------|-------------------|-------|
|
||||
| RE-Flow Field | DMS Request Field | Notes |
|
||||
|----------------|-------------------|-------|
|
||||
| `request.requestNumber` | `request_number` | Direct mapping |
|
||||
| `claimDetails.dealerCode` | `dealer_code` | Direct mapping |
|
||||
| `claimDetails.dealerName` | `dealer_name` | Direct mapping |
|
||||
| `budgetTracking.closedExpenses` | `amount` | Total claim amount |
|
||||
| `claimDetails.activityName` | `activity_name` | Must match predefined Activity Types |
|
||||
| `claimDetails.activityDescription` | `activity_description` | Direct mapping |
|
||||
| `budgetTracking.closedExpenses` | `claim_amount` | Total claim amount (before taxes) |
|
||||
| `internalOrder.ioNumber` | `io_number` | Optional, if available |
|
||||
| `claimInvoice.invoiceNumber` | `e_invoice_number` | For credit note only |
|
||||
| `itemCodeNo` (determined by DMS) | `item_code_no` | Included in payload. DMS determines this based on `activity_name` mapping. Used by DMS for GST calculation. |
|
||||
| `claimInvoice.invoiceNumber` | `e_invoice_number` | For credit note request only |
|
||||
|
||||
### DMS → RE Workflow System
|
||||
### DMS → RE-Flow System (Webhook Response)
|
||||
|
||||
| DMS Response Field | RE Workflow Field | Notes |
|
||||
|-------------------|-------------------|-------|
|
||||
| `e_invoice_number` | `ClaimInvoice.invoiceNumber` | Stored in database |
|
||||
| `dms_number` | `ClaimInvoice.dmsNumber` | Stored in database |
|
||||
| `invoice_date` | `ClaimInvoice.invoiceDate` | Converted to Date object |
|
||||
| `credit_note_number` | `ClaimCreditNote.creditNoteNumber` | Stored in database |
|
||||
| `credit_note_date` | `ClaimCreditNote.creditNoteDate` | Converted to Date object |
|
||||
| `credit_note_amount` | `ClaimCreditNote.creditNoteAmount` | Stored in database |
|
||||
**Note:** All invoice and credit note details are sent via webhook (see DMS_WEBHOOK_API.md), not in the API response.
|
||||
|
||||
| DMS Webhook Field | RE-Flow Database Field | Table | Notes |
|
||||
|-------------------|------------------------|-------|-------|
|
||||
| `document_no` | `invoice_number` / `credit_note_number` | `claim_invoices` / `claim_credit_notes` | Generated by DMS |
|
||||
| `document_date` | `invoice_date` / `credit_note_date` | `claim_invoices` / `claim_credit_notes` | Converted to Date object |
|
||||
| `total_amount` | `invoice_amount` / `credit_amount` | `claim_invoices` / `claim_credit_notes` | Includes taxes |
|
||||
| `invoice_file_path` | `invoice_file_path` | `claim_invoices` | URL/path to PDF |
|
||||
| `credit_note_file_path` | `credit_note_file_path` | `claim_credit_notes` | URL/path to PDF |
|
||||
| `irn_no` | Stored in `description` field | Both tables | From GST portal |
|
||||
| `sap_credit_note_no` | `sap_document_number` | `claim_credit_notes` | From SAP system |
|
||||
| `item_code_no` | Stored in `description` field | Both tables | Provided by DMS based on activity |
|
||||
| `hsn_sac_code` | Stored in `description` field | Both tables | Provided by DMS |
|
||||
| `cgst_amount`, `sgst_amount`, `igst_amount` | Stored in `description` field | Both tables | Tax breakdown |
|
||||
| `credit_type` | Stored in `description` field | `claim_credit_notes` | "GST" or "Commercial Credit" |
|
||||
|
||||
---
|
||||
|
||||
@ -373,8 +600,11 @@ When DMS is not configured, the system operates in **mock mode**:
|
||||
"request_number": "REQ-TEST-001",
|
||||
"dealer_code": "TEST-DLR-001",
|
||||
"dealer_name": "Test Dealer",
|
||||
"amount": 10000.00,
|
||||
"description": "Test invoice generation"
|
||||
"activity_name": "Marketing Cost – Bike to Vendor",
|
||||
"activity_description": "Test invoice generation for marketing activity",
|
||||
"claim_amount": 10000.00,
|
||||
"io_number": "IO-TEST-001",
|
||||
"item_code_no": "ITEM-001"
|
||||
}
|
||||
```
|
||||
|
||||
@ -385,9 +615,11 @@ When DMS is not configured, the system operates in **mock mode**:
|
||||
"e_invoice_number": "EINV-TEST-001",
|
||||
"dealer_code": "TEST-DLR-001",
|
||||
"dealer_name": "Test Dealer",
|
||||
"amount": 10000.00,
|
||||
"reason": "Test credit note",
|
||||
"description": "Test credit note generation"
|
||||
"activity_name": "Marketing Cost – Bike to Vendor",
|
||||
"activity_description": "Test credit note generation for marketing activity",
|
||||
"claim_amount": 10000.00,
|
||||
"io_number": "IO-TEST-001",
|
||||
"item_code_no": "ITEM-001"
|
||||
}
|
||||
```
|
||||
|
||||
@ -395,17 +627,43 @@ When DMS is not configured, the system operates in **mock mode**:
|
||||
|
||||
## Notes
|
||||
|
||||
1. **Idempotency**: Both APIs should be idempotent - calling them multiple times with the same data should not create duplicates.
|
||||
1. **Asynchronous Processing**: Invoice and credit note generation happens asynchronously. DMS should:
|
||||
- Accept the request immediately and return a success acknowledgment
|
||||
- Process the invoice/credit note in the background
|
||||
- Send complete details via webhook once processing is complete
|
||||
|
||||
2. **Amount Validation**: DMS should validate that credit note amount matches or is less than the original invoice amount.
|
||||
2. **Activity Type to Item Code No Mapping**:
|
||||
- DMS Team must provide **Item Code No** mapping for each predefined Activity Type
|
||||
- This mapping should be configured in DMS system
|
||||
- RE-Flow includes `item_code_no` in the request payload (determined by DMS based on `activity_name` mapping)
|
||||
- DMS uses Item Code No to determine HSN/SAC code and calculate GST (CGST/SGST/IGST percentages and amounts)
|
||||
- DMS returns `item_code_no` in the webhook response for verification
|
||||
|
||||
3. **Invoice Dependency**: Credit note generation requires a valid e-invoice to exist in DMS first.
|
||||
3. **Tax Calculation**: DMS is responsible for:
|
||||
- Determining CGST/SGST/IGST percentages based on dealer location and activity type
|
||||
- Calculating tax amounts
|
||||
- Providing HSN/SAC codes
|
||||
|
||||
4. **Error Handling**: RE Workflow System handles DMS errors gracefully and allows manual entry if DMS is unavailable.
|
||||
4. **Amount Validation**: DMS should validate that credit note amount matches or is less than the original invoice amount.
|
||||
|
||||
5. **Retry Logic**: Consider implementing retry logic for transient DMS failures.
|
||||
5. **Invoice Dependency**: Credit note generation requires a valid e-invoice to exist in DMS first.
|
||||
|
||||
6. **Webhooks** (Optional): DMS can send webhooks to notify RE Workflow System when invoice/credit note status changes.
|
||||
6. **Error Handling**: RE-Flow System handles DMS errors gracefully and allows manual entry if DMS is unavailable.
|
||||
|
||||
7. **Retry Logic**: Consider implementing retry logic for transient DMS failures.
|
||||
|
||||
8. **Webhooks (Primary Method)**: DMS **MUST** send webhooks to notify RE-Flow System when invoice/credit note processing is complete. See DMS_WEBHOOK_API.md for webhook specifications. This is the **primary method** for status verification.
|
||||
|
||||
9. **Status Check API (Backup Method)**: If webhook delivery fails, RE-Flow can use the backup status check API to verify invoice/credit note generation status. See section "Backup: Status Check API" above.
|
||||
|
||||
10. **IRN Generation**: DMS should generate IRN (Invoice Reference Number) from GST portal and include it in the webhook response.
|
||||
|
||||
11. **SAP Integration**: For credit notes, DMS should generate SAP Credit Note Number and include it in the webhook response.
|
||||
|
||||
12. **Webhook URL Configuration**: DMS must be configured with RE-Flow webhook URLs:
|
||||
- Invoice Webhook: `POST /api/v1/webhooks/dms/invoice`
|
||||
- Credit Note Webhook: `POST /api/v1/webhooks/dms/credit-note`
|
||||
- See DMS_WEBHOOK_API.md for complete webhook specifications
|
||||
|
||||
---
|
||||
|
||||
@ -418,6 +676,20 @@ For issues or questions regarding DMS integration:
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** December 17, 2025
|
||||
**Version:** 1.0
|
||||
**Last Updated:** December 19, 2025
|
||||
**Version:** 2.0
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 2.0 (December 19, 2025)
|
||||
- Added clear breakdown of inputs from RE-Flow vs DMS Team
|
||||
- Added predefined Activity Types list
|
||||
- Updated request/response structure to reflect asynchronous processing
|
||||
- Clarified that detailed responses come via webhook, not API response
|
||||
- Updated field names to match actual implementation (`claim_amount` instead of `amount`, `activity_name`, `activity_description`)
|
||||
- Added notes about Item Code No mapping requirement for DMS Team
|
||||
- Updated data mapping section with webhook fields
|
||||
|
||||
### Version 1.0 (December 17, 2025)
|
||||
- Initial documentation
|
||||
|
||||
|
||||
299
docs/STEP3_APPROVER_ANALYSIS.md
Normal file
299
docs/STEP3_APPROVER_ANALYSIS.md
Normal file
@ -0,0 +1,299 @@
|
||||
# Step 3 (Department Lead Approval) - User Addition Flow Analysis
|
||||
|
||||
## Overview
|
||||
This document analyzes how Step 3 approvers (Department Lead) are added to the dealer claim workflow, covering both frontend and backend implementation.
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
### 1. Request Creation Flow (`dealerClaim.service.ts`)
|
||||
|
||||
#### Entry Point: `createClaimRequest()`
|
||||
- **Location**: `Re_Backend/src/services/dealerClaim.service.ts:37`
|
||||
- **Parameters**:
|
||||
- `userId`: Initiator's user ID
|
||||
- `claimData`: Includes optional `selectedManagerEmail` for user selection
|
||||
|
||||
#### Step 3 Approver Resolution Process:
|
||||
|
||||
**Phase 1: Pre-Validation (Before Creating Records)**
|
||||
```typescript
|
||||
// Lines 67-87: Resolve Department Lead BEFORE creating workflow
|
||||
let departmentLead: User | null = null;
|
||||
|
||||
if (claimData.selectedManagerEmail) {
|
||||
// User selected a manager from multiple options
|
||||
departmentLead = await this.userService.ensureUserExists({
|
||||
email: claimData.selectedManagerEmail,
|
||||
});
|
||||
} else {
|
||||
// Search Okta using manager displayName from initiator's user record
|
||||
departmentLead = await this.resolveDepartmentLeadFromManager(initiator);
|
||||
|
||||
// If no manager found, throw error BEFORE creating any records
|
||||
if (!departmentLead) {
|
||||
throw new Error(`No reporting manager found...`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Phase 2: Approval Level Creation**
|
||||
```typescript
|
||||
// Line 136: Create approval levels with pre-resolved department lead
|
||||
await this.createClaimApprovalLevels(
|
||||
workflowRequest.requestId,
|
||||
userId,
|
||||
claimData.dealerEmail,
|
||||
claimData.selectedManagerEmail,
|
||||
departmentLead // Pre-resolved to avoid re-searching
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Approval Level Creation (`createClaimApprovalLevels()`)
|
||||
|
||||
#### Location: `Re_Backend/src/services/dealerClaim.service.ts:253`
|
||||
|
||||
#### Step 3 Configuration:
|
||||
```typescript
|
||||
// Lines 310-318: Step 3 definition
|
||||
{
|
||||
level: 3,
|
||||
name: 'Department Lead Approval',
|
||||
tatHours: 72,
|
||||
isAuto: false,
|
||||
approverType: 'department_lead' as const,
|
||||
approverId: departmentLead?.userId || null,
|
||||
approverEmail: departmentLead?.email || initiator.manager || 'deptlead@royalenfield.com',
|
||||
}
|
||||
```
|
||||
|
||||
#### Approver Resolution Logic:
|
||||
```typescript
|
||||
// Lines 405-417: Department Lead resolution
|
||||
else if (step.approverType === 'department_lead') {
|
||||
if (finalDepartmentLead) {
|
||||
approverId = finalDepartmentLead.userId;
|
||||
approverName = finalDepartmentLead.displayName || finalDepartmentLead.email || 'Department Lead';
|
||||
approverEmail = finalDepartmentLead.email;
|
||||
} else {
|
||||
// This should never happen as we validate manager before creating records
|
||||
throw new Error('Department lead not found...');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Database Record Creation:
|
||||
```typescript
|
||||
// Lines 432-454: Create ApprovalLevel record
|
||||
await ApprovalLevel.create({
|
||||
requestId,
|
||||
levelNumber: 3,
|
||||
levelName: 'Department Lead Approval',
|
||||
approverId: approverId, // Department Lead's userId
|
||||
approverEmail,
|
||||
approverName,
|
||||
tatHours: 72,
|
||||
status: ApprovalStatus.PENDING, // Will be activated when Step 2 is approved
|
||||
isFinalApprover: false,
|
||||
// ... other fields
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Department Lead Resolution Methods
|
||||
|
||||
#### Method 1: `resolveDepartmentLeadFromManager()` (Primary)
|
||||
- **Location**: `Re_Backend/src/services/dealerClaim.service.ts:622`
|
||||
- **Flow**:
|
||||
1. Get `manager` displayName from initiator's User record
|
||||
2. Search Okta directory by displayName using `userService.searchOktaByDisplayName()`
|
||||
3. **If 0 matches**: Return `null` (fallback to legacy method)
|
||||
4. **If 1 match**: Create user in DB if needed, return User object
|
||||
5. **If multiple matches**: Throw error with `MULTIPLE_MANAGERS_FOUND` code and list of managers
|
||||
|
||||
#### Method 2: `resolveDepartmentLead()` (Fallback/Legacy)
|
||||
- **Location**: `Re_Backend/src/services/dealerClaim.service.ts:699`
|
||||
- **Priority Order**:
|
||||
1. User with `MANAGEMENT` role in same department
|
||||
2. User with designation containing "Lead"/"Head"/"Manager" in same department
|
||||
3. User matching `initiator.manager` email field
|
||||
4. Any user in same department (excluding initiator)
|
||||
5. Any user with "Department Lead" designation (across all departments)
|
||||
6. Any user with `MANAGEMENT` role (across all departments)
|
||||
7. Any user with `ADMIN` role (across all departments)
|
||||
|
||||
### 4. Participant Creation
|
||||
|
||||
#### Location: `Re_Backend/src/services/dealerClaim.service.ts:463`
|
||||
- Department Lead is automatically added as a participant when approval levels are created
|
||||
- Participant type: `APPROVER`
|
||||
- Allows department lead to view, comment, and approve the request
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### 1. Request Creation (`ClaimManagementWizard.tsx`)
|
||||
|
||||
#### Location: `Re_Figma_Code/src/dealer-claim/components/request-creation/ClaimManagementWizard.tsx`
|
||||
|
||||
#### Current Implementation:
|
||||
- **No UI for selecting Step 3 approver during creation**
|
||||
- Step 3 approver is automatically resolved by backend based on:
|
||||
- Initiator's manager field
|
||||
- Department hierarchy
|
||||
- Role-based lookup
|
||||
|
||||
#### Form Data Structure:
|
||||
```typescript
|
||||
// Lines 61-75: Form data structure
|
||||
const [formData, setFormData] = useState({
|
||||
activityName: '',
|
||||
activityType: '',
|
||||
dealerCode: '',
|
||||
// ... other fields
|
||||
// Note: No selectedManagerEmail field in wizard
|
||||
});
|
||||
```
|
||||
|
||||
#### Submission:
|
||||
```typescript
|
||||
// Lines 152-216: handleSubmit()
|
||||
const claimData = {
|
||||
...formData,
|
||||
templateType: 'claim-management',
|
||||
// selectedManagerEmail is NOT included in current wizard
|
||||
// Backend will auto-resolve department lead
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Request Detail View (`RequestDetail.tsx`)
|
||||
|
||||
#### Location: `Re_Figma_Code/src/dealer-claim/pages/RequestDetail.tsx`
|
||||
|
||||
#### Step 3 Approver Detection:
|
||||
```typescript
|
||||
// Lines 147-173: Finding Step 3 approver
|
||||
const step3Level = approvalFlow.find((level: any) =>
|
||||
(level.step || level.levelNumber || level.level_number) === 3
|
||||
) || approvals.find((level: any) =>
|
||||
(level.levelNumber || level.level_number) === 3
|
||||
);
|
||||
|
||||
const deptLeadUserId = step3Level?.approverId || step3Level?.approver_id || step3Level?.approver?.userId;
|
||||
const deptLeadEmail = (step3Level?.approverEmail || '').toLowerCase().trim();
|
||||
|
||||
// User is department lead if they match Step 3 approver
|
||||
const isDeptLead = (deptLeadUserId && deptLeadUserId === currentUserId) ||
|
||||
(deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail);
|
||||
```
|
||||
|
||||
#### Add Approver Functionality:
|
||||
- **Lines 203-217, 609, 621, 688, 701, 711**: References to `handleAddApprover` and `AddApproverModal`
|
||||
- **Note**: This appears to be generic approver addition (for other workflow types), not specifically for Step 3
|
||||
- Step 3 approver is **fixed** and cannot be changed after request creation
|
||||
|
||||
### 3. Workflow Tab (`WorkflowTab.tsx`)
|
||||
|
||||
#### Location: `Re_Figma_Code/src/dealer-claim/components/request-detail/WorkflowTab.tsx`
|
||||
|
||||
#### Step 3 Action Button Visibility:
|
||||
```typescript
|
||||
// Lines 1109-1126: Step 3 approval button
|
||||
{step.step === 3 && (() => {
|
||||
// Find step 3 from approvalFlow to get approverEmail
|
||||
const step3Level = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === 3);
|
||||
const step3ApproverEmail = (step3Level?.approverEmail || '').toLowerCase();
|
||||
const isStep3ApproverByEmail = step3ApproverEmail && userEmail === step3ApproverEmail;
|
||||
return isStep3ApproverByEmail || isStep3Approver || isCurrentApprover;
|
||||
})() && (
|
||||
<Button onClick={() => setShowIOApprovalModal(true)}>
|
||||
Approve and Organise IO
|
||||
</Button>
|
||||
)}
|
||||
```
|
||||
|
||||
#### Step 3 Approval Handler:
|
||||
```typescript
|
||||
// Lines 535-583: handleIOApproval()
|
||||
// 1. Finds Step 3 levelId from approval levels
|
||||
// 2. Updates IO details (ioNumber, ioRemark)
|
||||
// 3. Approves Step 3 using approveLevel() API
|
||||
// 4. Moves workflow to Step 4 (auto-processed)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Findings
|
||||
|
||||
### Current Flow Summary:
|
||||
|
||||
1. **Request Creation**:
|
||||
- User creates claim request via `ClaimManagementWizard`
|
||||
- **No UI for selecting Step 3 approver**
|
||||
- Backend automatically resolves department lead using:
|
||||
- Initiator's `manager` displayName → Okta search
|
||||
- Fallback to legacy resolution methods
|
||||
|
||||
2. **Multiple Managers Scenario**:
|
||||
- If Okta search returns multiple managers:
|
||||
- Backend throws `MULTIPLE_MANAGERS_FOUND` error
|
||||
- Error includes list of manager options
|
||||
- **Frontend needs to handle this** (currently not implemented in wizard)
|
||||
|
||||
3. **Approval Level Creation**:
|
||||
- Step 3 approver is **fixed** at request creation
|
||||
- Stored in `ApprovalLevel` table with:
|
||||
- `levelNumber: 3`
|
||||
- `approverId`: Department Lead's userId
|
||||
- `approverEmail`: Department Lead's email
|
||||
- `status: PENDING` (activated when Step 2 is approved)
|
||||
|
||||
4. **After Request Creation**:
|
||||
- Step 3 approver **cannot be changed** via UI
|
||||
- Generic `AddApproverModal` exists but is not used for Step 3
|
||||
- Step 3 approver is determined by backend logic only
|
||||
|
||||
### Limitations:
|
||||
|
||||
1. **No User Selection During Creation**:
|
||||
- Wizard doesn't allow user to select/override Step 3 approver
|
||||
- If multiple managers found, error handling not implemented in frontend
|
||||
|
||||
2. **No Post-Creation Modification**:
|
||||
- No UI to change Step 3 approver after request is created
|
||||
- Would require backend API to update `ApprovalLevel.approverId`
|
||||
|
||||
3. **Fixed Resolution Logic**:
|
||||
- Department lead resolution is hardcoded in backend
|
||||
- No configuration or override mechanism
|
||||
|
||||
---
|
||||
|
||||
## Potential Enhancement Areas
|
||||
|
||||
1. **Frontend**: Add manager selection UI in wizard when multiple managers found
|
||||
2. **Frontend**: Add "Change Approver" option for Step 3 (if allowed by business rules)
|
||||
3. **Backend**: Add API endpoint to update Step 3 approver after request creation
|
||||
4. **Backend**: Add configuration for department lead resolution rules
|
||||
5. **Both**: Handle `MULTIPLE_MANAGERS_FOUND` error gracefully in frontend
|
||||
|
||||
---
|
||||
|
||||
## Related Files
|
||||
|
||||
### Backend:
|
||||
- `Re_Backend/src/services/dealerClaim.service.ts` - Main service
|
||||
- `Re_Backend/src/controllers/dealerClaim.controller.ts` - API endpoints
|
||||
- `Re_Backend/src/services/user.service.ts` - User/Okta integration
|
||||
- `Re_Backend/src/models/ApprovalLevel.ts` - Database model
|
||||
|
||||
### Frontend:
|
||||
- `Re_Figma_Code/src/dealer-claim/components/request-creation/ClaimManagementWizard.tsx` - Request creation
|
||||
- `Re_Figma_Code/src/dealer-claim/pages/RequestDetail.tsx` - Request detail view
|
||||
- `Re_Figma_Code/src/dealer-claim/components/request-detail/WorkflowTab.tsx` - Workflow display
|
||||
- `Re_Figma_Code/src/dealer-claim/components/request-detail/modals/DeptLeadIOApprovalModal.tsx` - Step 3 approval modal
|
||||
|
||||
### Documentation:
|
||||
- `Re_Backend/docs/CLAIM_MANAGEMENT_APPROVER_MAPPING.md` - Approver mapping rules
|
||||
|
||||
@ -40,7 +40,7 @@ export class DealerClaimController {
|
||||
periodStartDate,
|
||||
periodEndDate,
|
||||
estimatedBudget,
|
||||
selectedManagerEmail, // Optional: When multiple managers found, user selects one
|
||||
approvers, // Array of approvers for all 8 steps
|
||||
} = req.body;
|
||||
|
||||
// Validation
|
||||
@ -62,7 +62,7 @@ export class DealerClaimController {
|
||||
periodStartDate: periodStartDate ? new Date(periodStartDate) : undefined,
|
||||
periodEndDate: periodEndDate ? new Date(periodEndDate) : undefined,
|
||||
estimatedBudget: estimatedBudget ? parseFloat(estimatedBudget) : undefined,
|
||||
selectedManagerEmail, // Pass selected manager email if provided
|
||||
approvers: approvers || [], // Pass approvers array for all 8 steps
|
||||
});
|
||||
|
||||
return ResponseHandler.success(res, {
|
||||
@ -70,36 +70,10 @@ export class DealerClaimController {
|
||||
message: 'Claim request created successfully'
|
||||
}, 'Claim request created');
|
||||
} catch (error: any) {
|
||||
// Handle multiple managers found error
|
||||
if (error.code === 'MULTIPLE_MANAGERS_FOUND') {
|
||||
const response: any = {
|
||||
success: false,
|
||||
message: 'Multiple reporting managers found. Please select one.',
|
||||
error: {
|
||||
code: 'MULTIPLE_MANAGERS_FOUND',
|
||||
managers: error.managers || []
|
||||
},
|
||||
timestamp: new Date(),
|
||||
};
|
||||
logger.warn('[DealerClaimController] Multiple managers found:', { managers: error.managers });
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle no manager found error
|
||||
if (error.code === 'NO_MANAGER_FOUND') {
|
||||
const response: any = {
|
||||
success: false,
|
||||
message: error.message || 'No reporting manager found. Please ensure your manager is correctly configured in the system.',
|
||||
error: {
|
||||
code: 'NO_MANAGER_FOUND',
|
||||
message: error.message
|
||||
},
|
||||
timestamp: new Date(),
|
||||
};
|
||||
logger.warn('[DealerClaimController] No manager found:', { message: error.message });
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
// Handle approver validation errors
|
||||
if (error.message && error.message.includes('Approver')) {
|
||||
logger.warn('[DealerClaimController] Approver validation error:', { message: error.message });
|
||||
return ResponseHandler.error(res, error.message, 400);
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@ -648,6 +622,16 @@ export class DealerClaimController {
|
||||
|
||||
const blockAmount = blockedAmount ? parseFloat(blockedAmount) : 0;
|
||||
|
||||
// Log received data for debugging
|
||||
logger.info('[DealerClaimController] updateIODetails received:', {
|
||||
requestId,
|
||||
ioNumber,
|
||||
availableBalance,
|
||||
blockedAmount: blockAmount,
|
||||
receivedBlockedAmount: blockedAmount, // Original value from request
|
||||
userId,
|
||||
});
|
||||
|
||||
// Store in database when blocking amount > 0 OR when ioNumber and ioRemark are provided (for Step 3 approval)
|
||||
if (blockAmount > 0) {
|
||||
if (availableBalance === undefined) {
|
||||
@ -656,15 +640,19 @@ export class DealerClaimController {
|
||||
|
||||
// Don't pass remainingBalance - let the service calculate it from SAP's response
|
||||
// This ensures we always use the actual remaining balance from SAP after blocking
|
||||
const ioData = {
|
||||
ioNumber,
|
||||
ioRemark: ioRemark || '',
|
||||
availableBalance: parseFloat(availableBalance),
|
||||
blockedAmount: blockAmount,
|
||||
// remainingBalance will be calculated by the service from SAP's response
|
||||
};
|
||||
|
||||
logger.info('[DealerClaimController] Calling updateIODetails service with:', ioData);
|
||||
|
||||
await this.dealerClaimService.updateIODetails(
|
||||
requestId,
|
||||
{
|
||||
ioNumber,
|
||||
ioRemark: ioRemark || '',
|
||||
availableBalance: parseFloat(availableBalance),
|
||||
blockedAmount: blockAmount,
|
||||
// remainingBalance will be calculated by the service from SAP's response
|
||||
},
|
||||
ioData,
|
||||
userId
|
||||
);
|
||||
|
||||
|
||||
@ -427,31 +427,49 @@ export class ApprovalService {
|
||||
|
||||
logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`);
|
||||
|
||||
// Check if this is Step 3 approval in a claim management workflow, and next level is Step 4 (auto-step)
|
||||
// Check if this is Department Lead approval in a claim management workflow, and next level is Activity Creation (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`);
|
||||
// Check if current level is Department Lead (by levelName, not hardcoded step number)
|
||||
const currentLevelName = (level.levelName || '').toLowerCase();
|
||||
const isDeptLeadApproval = currentLevelName.includes('department lead') || level.levelNumber === 3;
|
||||
|
||||
// Check if next level is Activity Creation (by levelName or approverEmail, not hardcoded step number)
|
||||
const nextLevelName = (nextLevel?.levelName || '').toLowerCase();
|
||||
const nextLevelEmail = (nextLevel?.approverEmail || '').toLowerCase();
|
||||
const isActivityCreationNext = nextLevelName.includes('activity creation') ||
|
||||
(nextLevelEmail === 'system@royalenfield.com' && nextLevelNumber > level.levelNumber);
|
||||
|
||||
// Check if current level is Requestor Claim Approval (Step 6) and next is E-Invoice Generation (Step 7)
|
||||
const currentLevelNameForStep6 = (level.levelName || '').toLowerCase();
|
||||
const isRequestorClaimApproval = currentLevelNameForStep6.includes('requestor') &&
|
||||
(currentLevelNameForStep6.includes('claim') || currentLevelNameForStep6.includes('approval')) ||
|
||||
level.levelNumber === 6;
|
||||
|
||||
const nextLevelNameForStep7 = (nextLevel?.levelName || '').toLowerCase();
|
||||
const nextLevelEmailForStep7 = (nextLevel?.approverEmail || '').toLowerCase();
|
||||
const isEInvoiceGenerationNext = nextLevelNameForStep7.includes('e-invoice') ||
|
||||
nextLevelNameForStep7.includes('invoice generation') ||
|
||||
(nextLevelEmailForStep7 === 'system@royalenfield.com' && nextLevelNumber > level.levelNumber);
|
||||
|
||||
if (isClaimManagement && isDeptLeadApproval && isActivityCreationNext && nextLevel) {
|
||||
// Activity Creation is an auto-step - process it automatically
|
||||
logger.info(`[Approval] Department Lead approved for claim management workflow. Auto-processing Activity Creation (Level ${nextLevelNumber})`);
|
||||
try {
|
||||
const dealerClaimService = new DealerClaimService();
|
||||
await dealerClaimService.processActivityCreation(level.requestId);
|
||||
logger.info(`[Approval] Step 4 auto-processing completed for request ${level.requestId}`);
|
||||
logger.info(`[Approval] Activity Creation 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
|
||||
logger.error(`[Approval] Error auto-processing Activity Creation for request ${level.requestId}:`, step4Error);
|
||||
// Don't fail the Department Lead approval if Activity Creation processing fails - log and continue
|
||||
}
|
||||
} else if (isClaimManagement && isStep6Approval && isStep7Next && nextLevel && nextLevel.approverEmail === 'system@royalenfield.com') {
|
||||
// Step 7 is an auto-step - activate it but don't process invoice generation
|
||||
} else if (isClaimManagement && isRequestorClaimApproval && isEInvoiceGenerationNext && nextLevel && nextLevel.approverEmail === 'system@royalenfield.com') {
|
||||
// E-Invoice Generation is an auto-step - activate it but don't process invoice generation
|
||||
// Invoice generation will be handled by DMS webhook when invoice is created
|
||||
logger.info(`[Approval] Step 6 approved for claim management workflow. Step 7 activated. Waiting for DMS webhook to generate invoice.`);
|
||||
// Step 7 will remain in IN_PROGRESS until webhook creates invoice and auto-approves it
|
||||
// Continue with normal flow to activate Step 7
|
||||
logger.info(`[Approval] Requestor Claim Approval approved for claim management workflow. E-Invoice Generation (Level ${nextLevelNumber}) activated. Waiting for DMS webhook to generate invoice.`);
|
||||
// E-Invoice Generation will remain in IN_PROGRESS until webhook creates invoice and auto-approves it
|
||||
// Continue with normal flow to activate E-Invoice Generation
|
||||
}
|
||||
|
||||
if (wf && nextLevel) {
|
||||
@ -529,19 +547,20 @@ export class ApprovalService {
|
||||
logger.info(`[Approval] Skipping notification for auto-step at level ${nextLevelNumber}`);
|
||||
}
|
||||
|
||||
// Notify initiator when dealer submits documents (Step 1 or Step 5 approval in claim management)
|
||||
// Notify initiator when dealer submits documents (Dealer Proposal or Dealer Completion Documents approval in claim management)
|
||||
const workflowType = (wf as any)?.workflowType;
|
||||
const isClaimManagement = workflowType === 'CLAIM_MANAGEMENT';
|
||||
const isStep1Approval = level.levelNumber === 1;
|
||||
const isStep5Approval = level.levelNumber === 5;
|
||||
const levelName = (level.levelName || '').toLowerCase();
|
||||
const isDealerProposalApproval = levelName.includes('dealer') && levelName.includes('proposal') || level.levelNumber === 1;
|
||||
const isDealerCompletionApproval = levelName.includes('dealer') && (levelName.includes('completion') || levelName.includes('documents')) || level.levelNumber === 5;
|
||||
|
||||
if (isClaimManagement && (isStep1Approval || isStep5Approval) && (wf as any).initiatorId) {
|
||||
const stepMessage = isStep1Approval
|
||||
if (isClaimManagement && (isDealerProposalApproval || isDealerCompletionApproval) && (wf as any).initiatorId) {
|
||||
const stepMessage = isDealerProposalApproval
|
||||
? 'Dealer proposal has been submitted and is now under review.'
|
||||
: 'Dealer completion documents have been submitted and are now under review.';
|
||||
|
||||
await notificationService.sendToUsers([(wf as any).initiatorId], {
|
||||
title: isStep1Approval ? 'Proposal Submitted' : 'Completion Documents Submitted',
|
||||
title: isDealerProposalApproval ? 'Proposal Submitted' : 'Completion Documents Submitted',
|
||||
body: `Your claim request "${(wf as any).title}" - ${stepMessage}`,
|
||||
requestNumber: (wf as any).requestNumber,
|
||||
requestId: (wf as any).requestId,
|
||||
@ -551,7 +570,7 @@ export class ApprovalService {
|
||||
actionRequired: false
|
||||
});
|
||||
|
||||
logger.info(`[Approval] Sent notification to initiator for ${isStep1Approval ? 'Step 1' : 'Step 5'} approval in claim management workflow`);
|
||||
logger.info(`[Approval] Sent notification to initiator for ${isDealerProposalApproval ? 'Dealer Proposal Submission' : 'Dealer Completion Documents'} approval in claim management workflow`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -50,40 +50,29 @@ export class DealerClaimService {
|
||||
periodStartDate?: Date;
|
||||
periodEndDate?: Date;
|
||||
estimatedBudget?: number;
|
||||
selectedManagerEmail?: string; // Optional: When multiple managers found, user selects one
|
||||
approvers?: Array<{
|
||||
email: string;
|
||||
name?: string;
|
||||
userId?: string;
|
||||
level: number;
|
||||
tat?: number | string;
|
||||
tatType?: 'hours' | 'days';
|
||||
}>;
|
||||
}
|
||||
): Promise<WorkflowRequest> {
|
||||
try {
|
||||
// Generate request number
|
||||
const requestNumber = await generateRequestNumber();
|
||||
|
||||
// First, validate that manager can be resolved BEFORE creating any records
|
||||
// This ensures no partial records are created if manager is not found
|
||||
// Validate initiator
|
||||
const initiator = await User.findByPk(userId);
|
||||
if (!initiator) {
|
||||
throw new Error('Initiator not found');
|
||||
}
|
||||
|
||||
// Resolve Department Lead/Manager BEFORE creating workflow
|
||||
let departmentLead: User | null = null;
|
||||
|
||||
if (claimData.selectedManagerEmail) {
|
||||
// User selected a manager from multiple options
|
||||
logger.info(`[DealerClaimService] Using selected manager email: ${claimData.selectedManagerEmail}`);
|
||||
departmentLead = await this.userService.ensureUserExists({
|
||||
email: claimData.selectedManagerEmail,
|
||||
});
|
||||
} else {
|
||||
// Search Okta using manager displayName from initiator's user record
|
||||
departmentLead = await this.resolveDepartmentLeadFromManager(initiator);
|
||||
|
||||
// If no manager found, throw error BEFORE creating any records
|
||||
if (!departmentLead) {
|
||||
const managerDisplayName = initiator.manager || 'Unknown';
|
||||
const error: any = new Error(`No reporting manager found for displayName: "${managerDisplayName}". Please ensure your manager is correctly configured in the system.`);
|
||||
error.code = 'NO_MANAGER_FOUND';
|
||||
throw error;
|
||||
}
|
||||
// Validate approvers array is provided
|
||||
if (!claimData.approvers || !Array.isArray(claimData.approvers) || claimData.approvers.length === 0) {
|
||||
throw new Error('Approvers array is required. Please assign approvers for all workflow steps.');
|
||||
}
|
||||
|
||||
// Now create workflow request (manager is validated)
|
||||
@ -131,9 +120,8 @@ export class DealerClaimService {
|
||||
currency: 'INR',
|
||||
});
|
||||
|
||||
// Create 8 approval levels for claim management workflow
|
||||
// Pass the already-resolved departmentLead to avoid re-searching
|
||||
await this.createClaimApprovalLevels(workflowRequest.requestId, userId, claimData.dealerEmail, claimData.selectedManagerEmail, departmentLead);
|
||||
// Create 8 approval levels for claim management workflow from approvers array
|
||||
await this.createClaimApprovalLevelsFromApprovers(workflowRequest.requestId, userId, claimData.dealerEmail, claimData.approvers || []);
|
||||
|
||||
// Schedule TAT jobs for Step 1 (Dealer Proposal Submission) - first active step
|
||||
// This ensures SLA tracking starts immediately from request creation
|
||||
@ -243,215 +231,144 @@ export class DealerClaimService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create 8-step approval levels for claim management
|
||||
* Maps approvers based on step requirements:
|
||||
* - Step 1 & 5: Dealer (treated as Okta/internal user - synced from Okta)
|
||||
* - Step 2, 6 & 8: Initiator (requestor) - Step 8 is credit note action taken by initiator
|
||||
* - Step 3: Department Lead/Manager (resolved from initiator's manager displayName via Okta search)
|
||||
* - Step 4 & 7: System (auto-processed)
|
||||
* Create 8-step approval levels for claim management from approvers array
|
||||
* Validates and creates approval levels based on user-provided approvers
|
||||
* Similar to custom request flow - all approvers are manually assigned
|
||||
*/
|
||||
private async createClaimApprovalLevels(
|
||||
private async createClaimApprovalLevelsFromApprovers(
|
||||
requestId: string,
|
||||
initiatorId: string,
|
||||
dealerEmail?: string,
|
||||
selectedManagerEmail?: string,
|
||||
departmentLead?: User | null // Pre-resolved department lead (to avoid re-searching)
|
||||
approvers: Array<{
|
||||
email: string;
|
||||
name?: string;
|
||||
userId?: string;
|
||||
level: number;
|
||||
tat?: number | string;
|
||||
tatType?: 'hours' | 'days';
|
||||
}> = []
|
||||
): Promise<void> {
|
||||
const initiator = await User.findByPk(initiatorId);
|
||||
if (!initiator) {
|
||||
throw new Error('Initiator not found');
|
||||
}
|
||||
|
||||
// Use pre-resolved department lead if provided, otherwise resolve it
|
||||
let finalDepartmentLead: User | null = departmentLead || null;
|
||||
|
||||
if (!finalDepartmentLead) {
|
||||
if (selectedManagerEmail) {
|
||||
// User selected a manager from multiple options
|
||||
logger.info(`[DealerClaimService] Using selected manager email: ${selectedManagerEmail}`);
|
||||
const managerUser = await this.userService.ensureUserExists({
|
||||
email: selectedManagerEmail,
|
||||
});
|
||||
finalDepartmentLead = managerUser;
|
||||
} else {
|
||||
// Search Okta using manager displayName from initiator's user record
|
||||
finalDepartmentLead = await this.resolveDepartmentLeadFromManager(initiator);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Dealer Proposal Submission (72 hours) - Dealer submits proposal
|
||||
// Step 2: Requestor Evaluation (48 hours) - Initiator evaluates
|
||||
// Step 3: Department Lead Approval (72 hours) - Department Lead/Manager approves and blocks IO
|
||||
// Step 4: Activity Creation (Auto - 1 hour) - System auto-processes
|
||||
// Step 5: Dealer Completion Documents (120 hours) - Dealer submits completion docs
|
||||
// Step 6: Requestor Claim Approval (48 hours) - Initiator approves completion
|
||||
// Step 7: E-Invoice Generation (Auto - 1 hour) - System generates via DMS
|
||||
// Step 8: Credit Note Confirmation (48 hours) - Initiator confirms credit note (FINAL)
|
||||
|
||||
const steps = [
|
||||
{
|
||||
level: 1,
|
||||
name: 'Dealer Proposal Submission',
|
||||
tatHours: 72,
|
||||
isAuto: false,
|
||||
approverType: 'dealer' as const,
|
||||
approverEmail: dealerEmail,
|
||||
approverId: null, // Will be resolved by syncing dealer from Okta
|
||||
},
|
||||
{
|
||||
level: 2,
|
||||
name: 'Requestor Evaluation',
|
||||
tatHours: 48,
|
||||
isAuto: false,
|
||||
approverType: 'initiator' as const,
|
||||
approverId: initiatorId,
|
||||
approverEmail: initiator.email,
|
||||
},
|
||||
{
|
||||
level: 3,
|
||||
name: 'Department Lead Approval',
|
||||
tatHours: 72,
|
||||
isAuto: false,
|
||||
approverType: 'department_lead' as const,
|
||||
approverId: departmentLead?.userId || null,
|
||||
approverEmail: departmentLead?.email || initiator.manager || 'deptlead@royalenfield.com',
|
||||
},
|
||||
{
|
||||
level: 4,
|
||||
name: 'Activity Creation',
|
||||
tatHours: 1,
|
||||
isAuto: true,
|
||||
approverType: 'system' as const,
|
||||
approverId: null,
|
||||
approverEmail: 'system@royalenfield.com',
|
||||
},
|
||||
{
|
||||
level: 5,
|
||||
name: 'Dealer Completion Documents',
|
||||
tatHours: 120,
|
||||
isAuto: false,
|
||||
approverType: 'dealer' as const,
|
||||
approverEmail: dealerEmail,
|
||||
approverId: null, // Will be resolved by syncing dealer from Okta
|
||||
},
|
||||
{
|
||||
level: 6,
|
||||
name: 'Requestor Claim Approval',
|
||||
tatHours: 48,
|
||||
isAuto: false,
|
||||
approverType: 'initiator' as const,
|
||||
approverId: initiatorId,
|
||||
approverEmail: initiator.email,
|
||||
},
|
||||
{
|
||||
level: 7,
|
||||
name: 'E-Invoice Generation',
|
||||
tatHours: 1,
|
||||
isAuto: true,
|
||||
approverType: 'system' as const,
|
||||
approverId: null,
|
||||
approverEmail: 'system@royalenfield.com',
|
||||
},
|
||||
{
|
||||
level: 8,
|
||||
name: 'Credit Note Confirmation',
|
||||
tatHours: 48,
|
||||
isAuto: false,
|
||||
approverType: 'initiator' as const,
|
||||
approverId: initiatorId,
|
||||
approverEmail: initiator.email,
|
||||
},
|
||||
// Step definitions with default TAT
|
||||
const stepDefinitions = [
|
||||
{ level: 1, name: 'Dealer Proposal Submission', defaultTat: 72, isAuto: false },
|
||||
{ level: 2, name: 'Requestor Evaluation', defaultTat: 48, isAuto: false },
|
||||
{ level: 3, name: 'Department Lead Approval', defaultTat: 72, isAuto: false },
|
||||
{ level: 4, name: 'Activity Creation', defaultTat: 1, isAuto: true },
|
||||
{ level: 5, name: 'Dealer Completion Documents', defaultTat: 120, isAuto: false },
|
||||
{ level: 6, name: 'Requestor Claim Approval', defaultTat: 48, isAuto: false },
|
||||
{ level: 7, name: 'E-Invoice Generation', defaultTat: 1, isAuto: true },
|
||||
{ level: 8, name: 'Credit Note Confirmation', defaultTat: 48, isAuto: true }, // System/Finance step handled by webhook
|
||||
];
|
||||
|
||||
for (const step of steps) {
|
||||
// For dealer steps, try to find dealer user, but allow email-only if not found
|
||||
let approverId = step.approverId;
|
||||
let approverEmail = step.approverEmail || '';
|
||||
let approverName = 'System';
|
||||
// Process each step
|
||||
for (const stepDef of stepDefinitions) {
|
||||
const approver = approvers.find((a) => a.level === stepDef.level);
|
||||
|
||||
if (step.isAuto) {
|
||||
// For system/auto steps, use initiator's ID (system steps don't need real approver)
|
||||
approverId = initiatorId;
|
||||
approverName = 'System Auto-Process';
|
||||
approverEmail = approverEmail || 'system@royalenfield.com';
|
||||
} else if (step.approverType === 'dealer' && step.approverEmail) {
|
||||
// Treat dealer as Okta user - sync from Okta if not in database
|
||||
let dealerUser = await User.findOne({ where: { email: step.approverEmail.toLowerCase() } });
|
||||
if (!dealerUser) {
|
||||
logger.info(`[DealerClaimService] Dealer ${step.approverEmail} not found in DB, syncing from Okta`);
|
||||
try {
|
||||
dealerUser = await this.userService.ensureUserExists({
|
||||
email: step.approverEmail.toLowerCase(),
|
||||
}) as any;
|
||||
logger.info(`[DealerClaimService] Successfully synced dealer ${step.approverEmail} from Okta`);
|
||||
} catch (oktaError: any) {
|
||||
logger.error(`[DealerClaimService] Failed to sync dealer from Okta: ${step.approverEmail}`, oktaError);
|
||||
throw new Error(`Dealer email '${step.approverEmail}' not found in organization directory. Please verify the email address.`);
|
||||
let approverId: string | null = null;
|
||||
let approverEmail = '';
|
||||
let approverName = 'System';
|
||||
let tatHours = stepDef.defaultTat;
|
||||
|
||||
if (stepDef.isAuto) {
|
||||
// System steps - handled by webhooks, no user validation needed
|
||||
// Step 8 is System/Finance, others are System
|
||||
if (stepDef.level === 8) {
|
||||
approverId = initiatorId; // Use initiator ID as placeholder (no actual user needed)
|
||||
approverName = 'System/Finance';
|
||||
approverEmail = 'finance@royalenfield.com';
|
||||
} else {
|
||||
approverId = initiatorId; // System steps use initiator ID as placeholder
|
||||
approverName = 'System Auto-Process';
|
||||
approverEmail = 'system@royalenfield.com';
|
||||
}
|
||||
tatHours = stepDef.defaultTat;
|
||||
} else if (approver) {
|
||||
// User-provided approver
|
||||
if (!approver.email) {
|
||||
throw new Error(`Approver email is required for Step ${stepDef.level}: ${stepDef.name}`);
|
||||
}
|
||||
|
||||
// Calculate TAT in hours
|
||||
if (approver.tat) {
|
||||
const tat = Number(approver.tat);
|
||||
if (isNaN(tat) || tat <= 0) {
|
||||
throw new Error(`Invalid TAT for Step ${stepDef.level}. TAT must be a positive number.`);
|
||||
}
|
||||
tatHours = approver.tatType === 'days' ? tat * 24 : tat;
|
||||
}
|
||||
|
||||
// Ensure user exists in database (create from Okta if needed)
|
||||
let user: User | null = null;
|
||||
|
||||
if (approver.userId) {
|
||||
// User ID provided - use it
|
||||
user = await User.findByPk(approver.userId);
|
||||
}
|
||||
|
||||
if (!user && approver.email) {
|
||||
// User not found by ID, try to find or create by email
|
||||
user = await User.findOne({ where: { email: approver.email.toLowerCase() } });
|
||||
|
||||
if (!user) {
|
||||
// User doesn't exist - create from Okta
|
||||
logger.info(`[DealerClaimService] User ${approver.email} not found in DB, syncing from Okta`);
|
||||
try {
|
||||
user = await this.userService.ensureUserExists({
|
||||
email: approver.email.toLowerCase(),
|
||||
}) as any;
|
||||
logger.info(`[DealerClaimService] Successfully synced user ${approver.email} from Okta`);
|
||||
} catch (oktaError: any) {
|
||||
logger.error(`[DealerClaimService] Failed to sync user from Okta: ${approver.email}`, oktaError);
|
||||
throw new Error(`User email '${approver.email}' not found in organization directory. Please verify the email address.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure dealerUser is not null (TypeScript check)
|
||||
if (!dealerUser) {
|
||||
throw new Error(`Dealer user could not be resolved for email: ${step.approverEmail}`);
|
||||
if (!user) {
|
||||
throw new Error(`Could not resolve user for Step ${stepDef.level}: ${approver.email}`);
|
||||
}
|
||||
|
||||
approverId = dealerUser.userId;
|
||||
approverName = dealerUser.displayName || dealerUser.email || 'Dealer';
|
||||
approverEmail = dealerUser.email;
|
||||
} else if (step.approverType === 'initiator') {
|
||||
approverId = initiatorId;
|
||||
approverName = initiator.displayName || initiator.email || 'Requestor';
|
||||
approverEmail = initiator.email;
|
||||
} else if (step.approverType === 'department_lead') {
|
||||
if (finalDepartmentLead) {
|
||||
approverId = finalDepartmentLead.userId;
|
||||
approverName = finalDepartmentLead.displayName || finalDepartmentLead.email || 'Department Lead';
|
||||
approverEmail = finalDepartmentLead.email;
|
||||
} else {
|
||||
// This should never happen as we validate manager before creating records
|
||||
// But keeping as safety check
|
||||
const error: any = new Error('Department lead not found. This should have been validated before creating the request.');
|
||||
error.code = 'DEPARTMENT_LEAD_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
approverId = user.userId;
|
||||
approverEmail = user.email;
|
||||
approverName = approver.name || user.displayName || user.email || 'Approver';
|
||||
} else {
|
||||
// No approver provided for required step
|
||||
throw new Error(`Approver is required for Step ${stepDef.level}: ${stepDef.name}`);
|
||||
}
|
||||
|
||||
// Ensure we always have a valid approverId (should never be null at this point)
|
||||
// Ensure we have a valid approverId
|
||||
if (!approverId) {
|
||||
logger.error(`[DealerClaimService] No approverId resolved for step ${step.level}, using initiator as fallback`);
|
||||
logger.error(`[DealerClaimService] No approverId resolved for step ${stepDef.level}, using initiator as fallback`);
|
||||
approverId = initiatorId;
|
||||
approverEmail = approverEmail || initiator.email;
|
||||
approverName = approverName || 'Unknown Approver';
|
||||
}
|
||||
|
||||
// Step 1 is the first active step - set levelStartTime immediately so SLA tracking starts
|
||||
// For other steps, levelStartTime will be set when they become active
|
||||
// Create approval level
|
||||
const now = new Date();
|
||||
const isStep1 = step.level === 1;
|
||||
const isStep1 = stepDef.level === 1;
|
||||
|
||||
await ApprovalLevel.create({
|
||||
requestId,
|
||||
levelNumber: step.level,
|
||||
levelName: step.name,
|
||||
approverId: approverId, // Always a valid user ID now
|
||||
levelNumber: stepDef.level,
|
||||
levelName: stepDef.name,
|
||||
approverId: approverId,
|
||||
approverEmail,
|
||||
approverName,
|
||||
tatHours: step.tatHours,
|
||||
// tatDays is calculated later when needed: 1 day = 8 working hours
|
||||
// Formula: Math.ceil(tatHours / 8)
|
||||
// Only step 1 should be PENDING initially, others should wait
|
||||
// But ApprovalStatus doesn't have WAITING, so we'll use PENDING for all
|
||||
// and the approval service will properly activate the next level
|
||||
status: step.level === 1 ? ApprovalStatus.PENDING : ApprovalStatus.PENDING,
|
||||
isFinalApprover: step.level === 8,
|
||||
tatHours: tatHours,
|
||||
status: stepDef.level === 1 ? ApprovalStatus.PENDING : ApprovalStatus.PENDING,
|
||||
isFinalApprover: stepDef.level === 8,
|
||||
elapsedHours: 0,
|
||||
remainingHours: step.tatHours,
|
||||
remainingHours: tatHours,
|
||||
tatPercentageUsed: 0,
|
||||
// CRITICAL: Set levelStartTime for Step 1 immediately so SLA tracking starts from request creation
|
||||
// This ensures dealers see SLA tracker in Open Requests even before they take any action
|
||||
levelStartTime: isStep1 ? now : undefined,
|
||||
tatStartTime: isStep1 ? now : undefined,
|
||||
});
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1157,14 +1074,24 @@ export class DealerClaimService {
|
||||
currency: 'INR',
|
||||
});
|
||||
|
||||
// Approve step 1 and move to step 2
|
||||
const level1 = await ApprovalLevel.findOne({
|
||||
where: { requestId, levelNumber: 1 }
|
||||
// Approve Dealer Proposal Submission step dynamically (by levelName, not hardcoded step number)
|
||||
let dealerProposalLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId,
|
||||
levelName: 'Dealer Proposal Submission'
|
||||
}
|
||||
});
|
||||
|
||||
if (level1) {
|
||||
// Fallback: try to find by levelNumber 1 (for backwards compatibility)
|
||||
if (!dealerProposalLevel) {
|
||||
dealerProposalLevel = await ApprovalLevel.findOne({
|
||||
where: { requestId, levelNumber: 1 }
|
||||
});
|
||||
}
|
||||
|
||||
if (dealerProposalLevel) {
|
||||
await this.approvalService.approveLevel(
|
||||
level1.levelId,
|
||||
dealerProposalLevel.levelId,
|
||||
{ action: 'APPROVE', comments: 'Dealer proposal submitted' },
|
||||
'system', // System approval
|
||||
{ ipAddress: null, userAgent: null }
|
||||
@ -1236,14 +1163,24 @@ export class DealerClaimService {
|
||||
currency: 'INR',
|
||||
});
|
||||
|
||||
// Approve step 5 and move to step 6
|
||||
const level5 = await ApprovalLevel.findOne({
|
||||
where: { requestId, levelNumber: 5 }
|
||||
// Approve Dealer Completion Documents step dynamically (by levelName, not hardcoded step number)
|
||||
let dealerCompletionLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId,
|
||||
levelName: 'Dealer Completion Documents'
|
||||
}
|
||||
});
|
||||
|
||||
if (level5) {
|
||||
// Fallback: try to find by levelNumber 5 (for backwards compatibility)
|
||||
if (!dealerCompletionLevel) {
|
||||
dealerCompletionLevel = await ApprovalLevel.findOne({
|
||||
where: { requestId, levelNumber: 5 }
|
||||
});
|
||||
}
|
||||
|
||||
if (dealerCompletionLevel) {
|
||||
await this.approvalService.approveLevel(
|
||||
level5.levelId,
|
||||
dealerCompletionLevel.levelId,
|
||||
{ action: 'APPROVE', comments: 'Completion documents submitted' },
|
||||
'system',
|
||||
{ ipAddress: null, userAgent: null }
|
||||
@ -1348,6 +1285,14 @@ export class DealerClaimService {
|
||||
const request = await WorkflowRequest.findByPk(requestId);
|
||||
const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN';
|
||||
|
||||
logger.info(`[DealerClaimService] Blocking budget in SAP:`, {
|
||||
requestId,
|
||||
requestNumber,
|
||||
ioNumber: ioData.ioNumber,
|
||||
amountToBlock: blockedAmount,
|
||||
availableBalance: ioData.availableBalance || ioValidation.availableBalance,
|
||||
});
|
||||
|
||||
const blockResult = await sapIntegrationService.blockBudget(
|
||||
ioData.ioNumber,
|
||||
blockedAmount,
|
||||
@ -1359,21 +1304,60 @@ export class DealerClaimService {
|
||||
throw new Error(`Failed to block budget in SAP: ${blockResult.error}`);
|
||||
}
|
||||
|
||||
const finalBlockedAmount = blockResult.blockedAmount;
|
||||
const sapReturnedBlockedAmount = blockResult.blockedAmount;
|
||||
const availableBalance = ioData.availableBalance || ioValidation.availableBalance;
|
||||
|
||||
// Calculate remaining balance: availableBalance - blockedAmount
|
||||
// Use SAP's returned value if available and valid (> 0), otherwise calculate it
|
||||
// This ensures we always have a valid remaining balance value
|
||||
const sapRemainingBalance = blockResult.remainingBalance;
|
||||
// Use the amount we REQUESTED for calculation, not what SAP returned
|
||||
// SAP might return a slightly different amount due to rounding, but we calculate based on what we requested
|
||||
// Only use SAP's returned amount if it's significantly different (more than 1 rupee), which would indicate an actual issue
|
||||
const amountDifference = Math.abs(sapReturnedBlockedAmount - blockedAmount);
|
||||
const useSapAmount = amountDifference > 1.0; // Only use SAP's amount if difference is more than 1 rupee
|
||||
const finalBlockedAmount = useSapAmount ? sapReturnedBlockedAmount : blockedAmount;
|
||||
|
||||
// Log SAP response vs what we sent
|
||||
logger.info(`[DealerClaimService] SAP block result:`, {
|
||||
requestedAmount: blockedAmount,
|
||||
sapReturnedBlockedAmount: sapReturnedBlockedAmount,
|
||||
sapReturnedRemainingBalance: blockResult.remainingBalance,
|
||||
availableBalance,
|
||||
amountDifference,
|
||||
usingSapAmount: useSapAmount,
|
||||
finalBlockedAmountUsed: finalBlockedAmount,
|
||||
});
|
||||
|
||||
// Warn if SAP blocked a significantly different amount than requested
|
||||
if (amountDifference > 0.01) {
|
||||
if (amountDifference > 1.0) {
|
||||
logger.warn(`[DealerClaimService] ⚠️ Significant amount mismatch! Requested: ${blockedAmount}, SAP blocked: ${sapReturnedBlockedAmount}, Difference: ${amountDifference}`);
|
||||
} else {
|
||||
logger.info(`[DealerClaimService] Minor amount difference (likely rounding): Requested: ${blockedAmount}, SAP returned: ${sapReturnedBlockedAmount}, Using requested amount for calculation`);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate remaining balance: availableBalance - requestedAmount
|
||||
// IMPORTANT: Use the amount we REQUESTED, not SAP's returned amount (unless SAP blocked significantly different amount)
|
||||
// This ensures accuracy: remaining = available - requested
|
||||
const calculatedRemainingBalance = availableBalance - finalBlockedAmount;
|
||||
const remainingBalance = (sapRemainingBalance > 0 && sapRemainingBalance <= availableBalance)
|
||||
|
||||
// Only use SAP's value if it's valid AND matches our calculation (within 1 rupee tolerance)
|
||||
// This is a safety check - if SAP's value is way off, use our calculation
|
||||
const sapRemainingBalance = blockResult.remainingBalance;
|
||||
const sapValueIsValid = sapRemainingBalance > 0 &&
|
||||
sapRemainingBalance <= availableBalance &&
|
||||
Math.abs(sapRemainingBalance - calculatedRemainingBalance) < 1;
|
||||
|
||||
const remainingBalance = sapValueIsValid
|
||||
? sapRemainingBalance
|
||||
: calculatedRemainingBalance;
|
||||
|
||||
// Ensure remaining balance is not negative
|
||||
const finalRemainingBalance = Math.max(0, remainingBalance);
|
||||
|
||||
// Warn if SAP's value doesn't match our calculation
|
||||
if (!sapValueIsValid && sapRemainingBalance !== calculatedRemainingBalance) {
|
||||
logger.warn(`[DealerClaimService] ⚠️ SAP returned invalid remaining balance (${sapRemainingBalance}), using calculated value (${calculatedRemainingBalance})`);
|
||||
}
|
||||
|
||||
logger.info(`[DealerClaimService] Budget blocking calculation:`, {
|
||||
availableBalance,
|
||||
blockedAmount: finalBlockedAmount,
|
||||
@ -1385,14 +1369,19 @@ export class DealerClaimService {
|
||||
// Get the user who is blocking the IO (current user)
|
||||
const organizedBy = organizedByUserId || null;
|
||||
|
||||
// Round amounts to 2 decimal places for database storage (avoid floating point precision issues)
|
||||
const roundedAvailableBalance = Math.round(availableBalance * 100) / 100;
|
||||
const roundedBlockedAmount = Math.round(finalBlockedAmount * 100) / 100;
|
||||
const roundedRemainingBalance = Math.round(finalRemainingBalance * 100) / 100;
|
||||
|
||||
// Create or update Internal Order record (only when blocking)
|
||||
const ioRecordData = {
|
||||
requestId,
|
||||
ioNumber: ioData.ioNumber,
|
||||
ioRemark: ioData.ioRemark || '',
|
||||
ioAvailableBalance: availableBalance,
|
||||
ioBlockedAmount: finalBlockedAmount,
|
||||
ioRemainingBalance: finalRemainingBalance,
|
||||
ioAvailableBalance: roundedAvailableBalance,
|
||||
ioBlockedAmount: roundedBlockedAmount,
|
||||
ioRemainingBalance: roundedRemainingBalance,
|
||||
organizedBy: organizedBy || undefined,
|
||||
organizedAt: new Date(),
|
||||
status: IOStatus.BLOCKED,
|
||||
@ -1568,25 +1557,36 @@ export class DealerClaimService {
|
||||
logger.info(`[DealerClaimService] E-Invoice details manually updated for request: ${requestId}`);
|
||||
}
|
||||
|
||||
// Check if Step 6 is approved - if not, approve it first
|
||||
const step6Level = await ApprovalLevel.findOne({
|
||||
where: { requestId, levelNumber: 6 }
|
||||
// Check if Requestor Claim Approval (Step 6) is approved - if not, approve it first
|
||||
// Find dynamically by levelName, not hardcoded step number
|
||||
let requestorClaimLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId,
|
||||
levelName: 'Requestor Claim Approval'
|
||||
}
|
||||
});
|
||||
|
||||
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 - Step 7 will be activated but invoice generation will happen via webhook
|
||||
// Fallback: try to find by levelNumber 6 (for backwards compatibility)
|
||||
if (!requestorClaimLevel) {
|
||||
requestorClaimLevel = await ApprovalLevel.findOne({
|
||||
where: { requestId, levelNumber: 6 }
|
||||
});
|
||||
}
|
||||
|
||||
if (requestorClaimLevel && requestorClaimLevel.status !== ApprovalStatus.APPROVED) {
|
||||
logger.info(`[DealerClaimService] Requestor Claim Approval not approved yet. Auto-approving for request ${requestId}`);
|
||||
// Auto-approve Requestor Claim Approval - E-Invoice Generation will be activated but invoice generation will happen via webhook
|
||||
await this.approvalService.approveLevel(
|
||||
step6Level.levelId,
|
||||
requestorClaimLevel.levelId,
|
||||
{ action: 'APPROVE', comments: 'Auto-approved when pushing to DMS. Waiting for DMS webhook to generate invoice.' },
|
||||
'system',
|
||||
{ ipAddress: null, userAgent: 'System Auto-Process' }
|
||||
);
|
||||
// Note: Step 7 invoice generation will be handled by DMS webhook, not here
|
||||
logger.info(`[DealerClaimService] Step 6 approved. Step 7 activated. Waiting for DMS webhook to generate invoice.`);
|
||||
// Note: E-Invoice Generation will be handled by DMS webhook, not here
|
||||
logger.info(`[DealerClaimService] Requestor Claim Approval approved. E-Invoice Generation activated. Waiting for DMS webhook to generate invoice.`);
|
||||
} else {
|
||||
// Step 6 already approved - Step 7 should be active, waiting for webhook
|
||||
logger.info(`[DealerClaimService] Step 6 already approved. Step 7 should be active. Invoice generation will happen via DMS webhook.`);
|
||||
// Requestor Claim Approval already approved - E-Invoice Generation should be active, waiting for webhook
|
||||
logger.info(`[DealerClaimService] Requestor Claim Approval already approved. E-Invoice Generation should be active. Invoice generation will happen via DMS webhook.`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[DealerClaimService] Error updating e-invoice details:', error);
|
||||
@ -1620,34 +1620,45 @@ export class DealerClaimService {
|
||||
}
|
||||
|
||||
// Get Step 7 approval level
|
||||
const step7Level = await ApprovalLevel.findOne({
|
||||
// Get E-Invoice Generation approval level dynamically (by levelName, not hardcoded step number)
|
||||
let eInvoiceLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId,
|
||||
levelNumber: 7
|
||||
levelName: 'E-Invoice Generation'
|
||||
}
|
||||
});
|
||||
|
||||
if (!step7Level) {
|
||||
throw new Error(`Step 7 approval level not found for request ${requestId}`);
|
||||
// Fallback: try to find by levelNumber 7 (for backwards compatibility)
|
||||
if (!eInvoiceLevel) {
|
||||
eInvoiceLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId,
|
||||
levelNumber: 7
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If e-invoice already generated, just auto-approve Step 7
|
||||
if (!eInvoiceLevel) {
|
||||
throw new Error(`E-Invoice Generation approval level not found for request ${requestId}`);
|
||||
}
|
||||
|
||||
// If e-invoice already generated, just auto-approve E-Invoice Generation
|
||||
const claimInvoice = await ClaimInvoice.findOne({ where: { requestId } });
|
||||
|
||||
if (claimInvoice?.invoiceNumber) {
|
||||
logger.info(`[DealerClaimService] E-Invoice already generated (${claimInvoice.invoiceNumber}). Auto-approving Step 7 for request ${requestId}`);
|
||||
logger.info(`[DealerClaimService] E-Invoice already generated (${claimInvoice.invoiceNumber}). Auto-approving E-Invoice Generation for request ${requestId}`);
|
||||
|
||||
// Auto-approve Step 7
|
||||
// Auto-approve E-Invoice Generation
|
||||
await this.approvalService.approveLevel(
|
||||
step7Level.levelId,
|
||||
{ action: 'APPROVE', comments: 'E-Invoice generated via DMS. Step 7 auto-approved.' },
|
||||
eInvoiceLevel.levelId,
|
||||
{ action: 'APPROVE', comments: 'E-Invoice generated via DMS. E-Invoice Generation auto-approved.' },
|
||||
'system',
|
||||
{ ipAddress: null, userAgent: 'System Auto-Process' }
|
||||
);
|
||||
|
||||
logger.info(`[DealerClaimService] Successfully auto-processed and approved Step 7 for request ${requestId}`);
|
||||
logger.info(`[DealerClaimService] Successfully auto-processed and approved E-Invoice Generation (Level ${eInvoiceLevel.levelNumber}) 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.`);
|
||||
logger.warn(`[DealerClaimService] E-Invoice not generated yet for request ${requestId}. E-Invoice Generation will be processed when e-invoice is generated.`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[DealerClaimService] Error processing Step 7 (E-Invoice Generation) for request ${requestId}:`, error);
|
||||
@ -1780,30 +1791,41 @@ export class DealerClaimService {
|
||||
}
|
||||
|
||||
// Get Step 8 approval level
|
||||
const step8Level = await ApprovalLevel.findOne({
|
||||
// Get Credit Note Confirmation approval level dynamically (by levelName, not hardcoded step number)
|
||||
let creditNoteLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId,
|
||||
levelNumber: 8
|
||||
levelName: 'Credit Note Confirmation'
|
||||
}
|
||||
});
|
||||
|
||||
if (!step8Level) {
|
||||
throw new Error(`Step 8 approval level not found for request ${requestId}`);
|
||||
// Fallback: try to find by levelNumber 8 (for backwards compatibility)
|
||||
if (!creditNoteLevel) {
|
||||
creditNoteLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId,
|
||||
levelNumber: 8
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check if Step 8 is already approved
|
||||
if (step8Level.status === 'APPROVED') {
|
||||
logger.info(`[DealerClaimService] Step 8 already approved for request ${requestId}`);
|
||||
if (!creditNoteLevel) {
|
||||
throw new Error(`Credit Note Confirmation approval level not found for request ${requestId}`);
|
||||
}
|
||||
|
||||
// Check if Credit Note Confirmation is already approved
|
||||
if (creditNoteLevel.status === 'APPROVED') {
|
||||
logger.info(`[DealerClaimService] Credit Note Confirmation already approved for request ${requestId}`);
|
||||
// Still send notification to dealer if credit note wasn't sent before
|
||||
// You can add a flag to track if credit note was already sent
|
||||
} else {
|
||||
// Auto-approve Step 8
|
||||
logger.info(`[DealerClaimService] Auto-approving Step 8 for request ${requestId}`);
|
||||
// Auto-approve Credit Note Confirmation
|
||||
logger.info(`[DealerClaimService] Auto-approving Credit Note Confirmation (Level ${creditNoteLevel.levelNumber}) for request ${requestId}`);
|
||||
await this.approvalService.approveLevel(
|
||||
step8Level.levelId,
|
||||
creditNoteLevel.levelId,
|
||||
{
|
||||
action: 'APPROVE',
|
||||
comments: `Credit note sent to dealer. Credit Note Number: ${creditNote.creditNoteNumber || 'N/A'}. Step 8 auto-approved.`,
|
||||
comments: `Credit note sent to dealer. Credit Note Number: ${creditNote.creditNoteNumber || 'N/A'}. Credit Note Confirmation auto-approved.`,
|
||||
},
|
||||
userId,
|
||||
{
|
||||
@ -1812,7 +1834,7 @@ export class DealerClaimService {
|
||||
}
|
||||
);
|
||||
|
||||
logger.info(`[DealerClaimService] Step 8 auto-approved successfully for request ${requestId}`);
|
||||
logger.info(`[DealerClaimService] Credit Note Confirmation (Level ${creditNoteLevel.levelNumber}) auto-approved successfully for request ${requestId}`);
|
||||
}
|
||||
|
||||
// Update credit note status to SENT
|
||||
@ -1873,16 +1895,27 @@ export class DealerClaimService {
|
||||
throw new Error(`Claim details not found for request ${requestId}`);
|
||||
}
|
||||
|
||||
// Get Step 4 approval level
|
||||
const step4Level = await ApprovalLevel.findOne({
|
||||
// Get Activity Creation approval level dynamically (by levelName, not hardcoded step number)
|
||||
// This handles cases where approvers are added between steps, causing Activity Creation to shift
|
||||
let activityCreationLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId,
|
||||
levelNumber: 4
|
||||
levelName: 'Activity Creation'
|
||||
}
|
||||
});
|
||||
|
||||
if (!step4Level) {
|
||||
throw new Error(`Step 4 approval level not found for request ${requestId}`);
|
||||
if (!activityCreationLevel) {
|
||||
// Fallback: try to find by levelNumber 4 (for backwards compatibility)
|
||||
activityCreationLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId,
|
||||
levelNumber: 4
|
||||
}
|
||||
});
|
||||
|
||||
if (!activityCreationLevel) {
|
||||
throw new Error(`Activity Creation approval level not found for request ${requestId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get participants for email notifications
|
||||
@ -1891,15 +1924,25 @@ export class DealerClaimService {
|
||||
? await User.findOne({ where: { email: claimDetails.dealerEmail } })
|
||||
: null;
|
||||
|
||||
// Get department lead from Step 3
|
||||
const step3Level = await ApprovalLevel.findOne({
|
||||
// Get department lead dynamically (by levelName, not hardcoded step number)
|
||||
let deptLeadLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId,
|
||||
levelNumber: 3
|
||||
levelName: 'Department Lead Approval'
|
||||
}
|
||||
});
|
||||
const departmentLead = step3Level?.approverId
|
||||
? await User.findByPk(step3Level.approverId)
|
||||
|
||||
// Fallback: try to find by levelNumber 3 (for backwards compatibility)
|
||||
if (!deptLeadLevel) {
|
||||
deptLeadLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId,
|
||||
levelNumber: 3
|
||||
}
|
||||
});
|
||||
}
|
||||
const departmentLead = deptLeadLevel?.approverId
|
||||
? await User.findByPk(deptLeadLevel.approverId)
|
||||
: null;
|
||||
|
||||
const requestNumber = (request as any).requestNumber || (request as any).request_number || 'UNKNOWN';
|
||||
@ -1958,11 +2001,11 @@ export class DealerClaimService {
|
||||
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;
|
||||
// Activity Creation level is already activated (IN_PROGRESS) by the approval service
|
||||
// Now auto-approve Activity Creation immediately (since it's an auto-step)
|
||||
const activityCreationLevelId = activityCreationLevel.levelId;
|
||||
await this.approvalService.approveLevel(
|
||||
step4LevelId,
|
||||
activityCreationLevelId,
|
||||
{ action: 'APPROVE', comments: 'Activity created automatically. Activity confirmation email sent to dealer, requestor, and department lead.' },
|
||||
'system',
|
||||
{
|
||||
@ -1971,7 +2014,7 @@ export class DealerClaimService {
|
||||
}
|
||||
);
|
||||
|
||||
logger.info(`[DealerClaimService] Step 4 auto-approved for request ${requestId}. Activity creation completed.`);
|
||||
logger.info(`[DealerClaimService] Activity Creation (Level ${activityCreationLevel.levelNumber}) 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;
|
||||
|
||||
@ -347,40 +347,51 @@ export class DMSWebhookService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get Step 7 approval level
|
||||
const step7Level = await ApprovalLevel.findOne({
|
||||
// Get E-Invoice Generation approval level dynamically (by levelName, not hardcoded step number)
|
||||
let eInvoiceLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId,
|
||||
levelNumber: 7,
|
||||
levelName: 'E-Invoice Generation',
|
||||
},
|
||||
});
|
||||
|
||||
if (!step7Level) {
|
||||
logger.warn('[DMSWebhook] Step 7 approval level not found', { requestId, requestNumber });
|
||||
// Fallback: try to find by levelNumber 7 (for backwards compatibility)
|
||||
if (!eInvoiceLevel) {
|
||||
eInvoiceLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId,
|
||||
levelNumber: 7,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!eInvoiceLevel) {
|
||||
logger.warn('[DMSWebhook] E-Invoice Generation approval level not found', { requestId, requestNumber });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Step 7 is already approved
|
||||
if (step7Level.status === 'APPROVED') {
|
||||
logger.info('[DMSWebhook] Step 7 already approved, skipping auto-approval', {
|
||||
// Check if E-Invoice Generation is already approved
|
||||
if (eInvoiceLevel.status === 'APPROVED') {
|
||||
logger.info('[DMSWebhook] E-Invoice Generation already approved, skipping auto-approval', {
|
||||
requestId,
|
||||
requestNumber,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-approve Step 7
|
||||
logger.info('[DMSWebhook] Auto-approving Step 7 (E-Invoice Generation)', {
|
||||
// Auto-approve E-Invoice Generation
|
||||
logger.info(`[DMSWebhook] Auto-approving E-Invoice Generation (Level ${eInvoiceLevel.levelNumber})`, {
|
||||
requestId,
|
||||
requestNumber,
|
||||
levelId: step7Level.levelId,
|
||||
levelId: eInvoiceLevel.levelId,
|
||||
levelNumber: eInvoiceLevel.levelNumber,
|
||||
});
|
||||
|
||||
await this.approvalService.approveLevel(
|
||||
step7Level.levelId,
|
||||
eInvoiceLevel.levelId,
|
||||
{
|
||||
action: 'APPROVE',
|
||||
comments: `E-Invoice generated via DMS webhook. Invoice Number: ${(await ClaimInvoice.findOne({ where: { requestId } }))?.invoiceNumber || 'N/A'}. Step 7 auto-approved.`,
|
||||
comments: `E-Invoice generated via DMS webhook. Invoice Number: ${(await ClaimInvoice.findOne({ where: { requestId } }))?.invoiceNumber || 'N/A'}. E-Invoice Generation auto-approved.`,
|
||||
},
|
||||
'system', // System user for auto-approval
|
||||
{
|
||||
@ -389,9 +400,10 @@ export class DMSWebhookService {
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('[DMSWebhook] Step 7 auto-approved successfully. Workflow moved to Step 8', {
|
||||
logger.info('[DMSWebhook] E-Invoice Generation auto-approved successfully. Workflow moved to next step', {
|
||||
requestId,
|
||||
requestNumber,
|
||||
levelNumber: eInvoiceLevel.levelNumber,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
@ -612,6 +612,8 @@ export class SAPIntegrationService {
|
||||
textNodeName: '#text',
|
||||
parseAttributeValue: true,
|
||||
trimValues: true
|
||||
// Note: fast-xml-parser preserves namespace prefixes by default
|
||||
// So 'd:Available_Amount' should remain as 'd:Available_Amount' in the parsed object
|
||||
});
|
||||
responseData = parser.parse(response.data);
|
||||
logger.info(`[SAP] XML parsed successfully`);
|
||||
@ -648,23 +650,65 @@ export class SAPIntegrationService {
|
||||
// For XML: Available_Amount in lt_io_output[0] (may be prefixed with 'd:' namespace)
|
||||
// For JSON: RemainingBalance, Remaining, Available_Amount, etc.
|
||||
const extractRemainingBalance = (obj: any): number => {
|
||||
if (!obj) return 0;
|
||||
if (!obj) {
|
||||
logger.debug(`[SAP] extractRemainingBalance: obj is null/undefined`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Helper to extract value from field (handles both direct values and nested #text nodes)
|
||||
const getFieldValue = (fieldName: string): any => {
|
||||
const field = obj[fieldName];
|
||||
if (field === undefined || field === null) return null;
|
||||
|
||||
// If it's an object with #text property (XML parser sometimes does this)
|
||||
if (typeof field === 'object' && field['#text'] !== undefined) {
|
||||
return field['#text'];
|
||||
}
|
||||
|
||||
// Direct value
|
||||
return field;
|
||||
};
|
||||
|
||||
// Try various field name variations (both JSON and XML formats)
|
||||
// XML namespace prefixes: 'd:Available_Amount', 'd:RemainingBalance', etc.
|
||||
const value = obj['d:Available_Amount'] || // XML format with namespace prefix
|
||||
obj.Available_Amount || // XML format without prefix
|
||||
obj.RemainingBalance ||
|
||||
obj['d:RemainingBalance'] ||
|
||||
obj.Remaining ||
|
||||
obj.RemainingAmount ||
|
||||
obj.AvailableBalance ||
|
||||
obj.Balance ||
|
||||
obj.Available ||
|
||||
0;
|
||||
// IMPORTANT: Check 'd:Available_Amount' first as that's what SAP returns in XML
|
||||
const value = getFieldValue('d:Available_Amount') ?? // XML format with namespace prefix (PRIORITY)
|
||||
getFieldValue('Available_Amount') ?? // XML format without prefix
|
||||
getFieldValue('d:RemainingBalance') ??
|
||||
getFieldValue('RemainingBalance') ??
|
||||
getFieldValue('RemainingAmount') ??
|
||||
getFieldValue('Remaining') ??
|
||||
getFieldValue('AvailableBalance') ??
|
||||
getFieldValue('Balance') ??
|
||||
getFieldValue('Available') ??
|
||||
null;
|
||||
|
||||
const parsed = parseFloat(value?.toString() || '0');
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
if (value === null || value === undefined) {
|
||||
logger.debug(`[SAP] extractRemainingBalance: No value found. Object keys:`, Object.keys(obj));
|
||||
// Log all keys that might be relevant
|
||||
const relevantKeys = Object.keys(obj).filter(k =>
|
||||
k.toLowerCase().includes('available') ||
|
||||
k.toLowerCase().includes('amount') ||
|
||||
k.toLowerCase().includes('remaining') ||
|
||||
k.toLowerCase().includes('balance')
|
||||
);
|
||||
if (relevantKeys.length > 0) {
|
||||
logger.debug(`[SAP] extractRemainingBalance: Found relevant keys but no value:`, relevantKeys.map(k => ({ key: k, value: obj[k], type: typeof obj[k] })));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Convert to string first, then parse (handles both string "14291525.00" and number)
|
||||
const valueStr = value?.toString().trim() || '0';
|
||||
const parsed = parseFloat(valueStr);
|
||||
|
||||
if (isNaN(parsed)) {
|
||||
logger.warn(`[SAP] extractRemainingBalance: Failed to parse value "${valueStr}" as number`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
logger.debug(`[SAP] extractRemainingBalance: Extracted value "${valueStr}" -> ${parsed}`);
|
||||
return parsed;
|
||||
};
|
||||
|
||||
// Helper function to extract blocked amount
|
||||
@ -692,36 +736,141 @@ export class SAPIntegrationService {
|
||||
// Check for XML structure first (parsed XML from fast-xml-parser)
|
||||
let ioOutputData: any = null;
|
||||
let message = '';
|
||||
let mainEntryProperties: any = null;
|
||||
|
||||
// XML structure: entry.link (array) -> find link with @rel='lt_io_output' -> inline.feed.entry
|
||||
if (responseData.entry) {
|
||||
const entry = responseData.entry;
|
||||
|
||||
// Also check main entry properties (sometimes Available_Amount is here)
|
||||
const mainContent = entry.content || {};
|
||||
mainEntryProperties = mainContent['m:properties'] || mainContent.properties || (mainContent['@_type'] === 'application/xml' ? mainContent : null);
|
||||
|
||||
if (mainEntryProperties) {
|
||||
logger.info(`[SAP] Found main entry properties, keys:`, Object.keys(mainEntryProperties));
|
||||
}
|
||||
|
||||
// Find lt_io_output link
|
||||
// The rel attribute can be a full URL like "http://schemas.microsoft.com/ado/2007/08/dataservices/related/lt_io_output"
|
||||
// or just "lt_io_output", so we check if it includes "lt_io_output"
|
||||
const links = Array.isArray(entry.link) ? entry.link : (entry.link ? [entry.link] : []);
|
||||
const ioOutputLink = links.find((link: any) =>
|
||||
link['@_rel']?.includes('lt_io_output') ||
|
||||
link['@_title'] === 'IOOutputSet' ||
|
||||
link.title === 'IOOutputSet'
|
||||
);
|
||||
|
||||
if (ioOutputLink?.inline?.feed?.entry) {
|
||||
const ioEntry = Array.isArray(ioOutputLink.inline.feed.entry)
|
||||
? ioOutputLink.inline.feed.entry[0]
|
||||
: ioOutputLink.inline.feed.entry;
|
||||
logger.debug(`[SAP] All links in entry:`, links.map((l: any) => ({
|
||||
rel: l['@_rel'] || l.rel,
|
||||
title: l['@_title'] || l.title,
|
||||
href: l['@_href'] || l.href
|
||||
})));
|
||||
|
||||
// Handle both namespace-prefixed and non-prefixed property names
|
||||
const content = ioEntry.content || {};
|
||||
const properties = content['m:properties'] || content.properties || content['@_type'] === 'application/xml' ? content : null;
|
||||
const ioOutputLink = links.find((link: any) => {
|
||||
const rel = link['@_rel'] || link.rel || '';
|
||||
const title = link['@_title'] || link.title || '';
|
||||
return rel.includes('lt_io_output') || title === 'IOOutputSet' || title === 'lt_io_output';
|
||||
});
|
||||
|
||||
if (properties) {
|
||||
ioOutputData = properties;
|
||||
// Try both namespace-prefixed and non-prefixed field names
|
||||
message = ioOutputData['d:Message'] || ioOutputData.Message || ioOutputData['#text'] || '';
|
||||
if (ioOutputLink) {
|
||||
logger.info(`[SAP] Found lt_io_output link:`, {
|
||||
rel: ioOutputLink['@_rel'] || ioOutputLink.rel,
|
||||
title: ioOutputLink['@_title'] || ioOutputLink.title,
|
||||
hasInline: !!ioOutputLink.inline,
|
||||
hasFeed: !!ioOutputLink.inline?.feed,
|
||||
hasEntry: !!ioOutputLink.inline?.feed?.entry
|
||||
});
|
||||
|
||||
logger.info(`[SAP] Found XML structure - lt_io_output data extracted`);
|
||||
logger.debug(`[SAP] XML properties keys:`, Object.keys(ioOutputData));
|
||||
if (ioOutputLink.inline?.feed?.entry) {
|
||||
const ioEntry = Array.isArray(ioOutputLink.inline.feed.entry)
|
||||
? ioOutputLink.inline.feed.entry[0]
|
||||
: ioOutputLink.inline.feed.entry;
|
||||
|
||||
logger.debug(`[SAP] IO Entry structure:`, {
|
||||
hasContent: !!ioEntry.content,
|
||||
contentType: ioEntry.content?.['@_type'] || ioEntry.content?.type,
|
||||
hasMProperties: !!ioEntry.content?.['m:properties'],
|
||||
hasProperties: !!ioEntry.content?.properties
|
||||
});
|
||||
|
||||
// Handle both namespace-prefixed and non-prefixed property names
|
||||
const content = ioEntry.content || {};
|
||||
const properties = content['m:properties'] || content.properties || (content['@_type'] === 'application/xml' ? content : null);
|
||||
|
||||
if (properties) {
|
||||
ioOutputData = properties;
|
||||
// Try both namespace-prefixed and non-prefixed field names
|
||||
message = ioOutputData['d:Message'] || ioOutputData.Message || ioOutputData['#text'] || '';
|
||||
|
||||
logger.info(`[SAP] ✅ Found XML structure - lt_io_output data extracted`);
|
||||
logger.info(`[SAP] XML properties keys (${Object.keys(ioOutputData).length} keys):`, Object.keys(ioOutputData));
|
||||
|
||||
// Log the raw properties object to see exact structure
|
||||
logger.info(`[SAP] XML properties full object:`, JSON.stringify(ioOutputData, null, 2));
|
||||
|
||||
// Check ALL keys that might contain "Available" or "Amount"
|
||||
const allKeys = Object.keys(ioOutputData);
|
||||
const availableKeys = allKeys.filter(key =>
|
||||
key.toLowerCase().includes('available') ||
|
||||
key.toLowerCase().includes('amount') ||
|
||||
key.toLowerCase().includes('remaining') ||
|
||||
key.toLowerCase().includes('balance')
|
||||
);
|
||||
logger.info(`[SAP] Keys containing 'available', 'amount', 'remaining', or 'balance':`, availableKeys);
|
||||
|
||||
// Log all possible Available_Amount field variations with their actual values
|
||||
const availableAmountVariations: Record<string, any> = {};
|
||||
const variationsToCheck = [
|
||||
'd:Available_Amount',
|
||||
'Available_Amount',
|
||||
'd:AvailableAmount',
|
||||
'AvailableAmount',
|
||||
'RemainingBalance',
|
||||
'd:RemainingBalance',
|
||||
'Remaining',
|
||||
'RemainingAmount',
|
||||
'AvailableBalance',
|
||||
'Balance',
|
||||
'Available'
|
||||
];
|
||||
|
||||
variationsToCheck.forEach(key => {
|
||||
if (ioOutputData[key] !== undefined) {
|
||||
availableAmountVariations[key] = {
|
||||
value: ioOutputData[key],
|
||||
type: typeof ioOutputData[key],
|
||||
stringValue: String(ioOutputData[key])
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[SAP] Available Amount field variations found:`, availableAmountVariations);
|
||||
|
||||
// Also check if the XML parser might have stripped the namespace prefix
|
||||
// Sometimes XML parsers convert 'd:Available_Amount' to just 'Available_Amount'
|
||||
if (allKeys.includes('Available_Amount') && !allKeys.includes('d:Available_Amount')) {
|
||||
logger.info(`[SAP] ℹ️ Found 'Available_Amount' without 'd:' prefix (parser may have stripped namespace)`);
|
||||
}
|
||||
|
||||
// Explicitly check for d:Available_Amount and log its value
|
||||
if (ioOutputData['d:Available_Amount'] !== undefined) {
|
||||
logger.info(`[SAP] ✅ Found d:Available_Amount: ${ioOutputData['d:Available_Amount']} (type: ${typeof ioOutputData['d:Available_Amount']})`);
|
||||
} else if (ioOutputData.Available_Amount !== undefined) {
|
||||
logger.info(`[SAP] ✅ Found Available_Amount (without d: prefix): ${ioOutputData.Available_Amount} (type: ${typeof ioOutputData.Available_Amount})`);
|
||||
} else {
|
||||
logger.warn(`[SAP] ⚠️ d:Available_Amount not found in properties. All property keys:`, allKeys);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`[SAP] ⚠️ Properties not found in ioEntry.content. Content structure:`, JSON.stringify(content, null, 2));
|
||||
}
|
||||
} else {
|
||||
logger.warn(`[SAP] ⚠️ No entry found in ioOutputLink.inline.feed. Structure:`, {
|
||||
hasInline: !!ioOutputLink.inline,
|
||||
hasFeed: !!ioOutputLink.inline?.feed,
|
||||
hasEntry: !!ioOutputLink.inline?.feed?.entry,
|
||||
inlineKeys: ioOutputLink.inline ? Object.keys(ioOutputLink.inline) : []
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.warn(`[SAP] ⚠️ No lt_io_output link found in entry.links. Available links:`, links.map((l: any) => ({
|
||||
rel: l['@_rel'] || l.rel || 'unknown',
|
||||
title: l['@_title'] || l.title || 'unknown'
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
@ -739,6 +888,16 @@ export class SAPIntegrationService {
|
||||
success = message.includes('Successful') || message.includes('Success') || !message.includes('Error');
|
||||
blockedAmount = amount; // Use the amount we sent (from lt_io_input)
|
||||
remainingBalance = extractRemainingBalance(ioOutputData); // Available_Amount from XML
|
||||
|
||||
// If not found in lt_io_output, try main entry properties
|
||||
if (remainingBalance === 0 && mainEntryProperties) {
|
||||
logger.info(`[SAP] Available_Amount not found in lt_io_output, trying main entry properties`);
|
||||
remainingBalance = extractRemainingBalance(mainEntryProperties);
|
||||
if (remainingBalance > 0) {
|
||||
logger.info(`[SAP] Found Available_Amount in main entry properties: ${remainingBalance}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Try both namespace-prefixed and non-prefixed field names
|
||||
blockId = ioOutputData['d:Sap_Reference_no'] ||
|
||||
ioOutputData.Sap_Reference_no ||
|
||||
@ -752,7 +911,8 @@ export class SAPIntegrationService {
|
||||
message,
|
||||
availableAmount: remainingBalance,
|
||||
sapReference: blockId,
|
||||
allKeys: Object.keys(ioOutputData)
|
||||
allKeys: Object.keys(ioOutputData),
|
||||
foundInMainEntry: remainingBalance > 0 && mainEntryProperties ? true : false
|
||||
});
|
||||
} else if (responseData.ls_response && Array.isArray(responseData.ls_response) && responseData.ls_response.length > 0) {
|
||||
// Response in ls_response array
|
||||
@ -789,9 +949,15 @@ export class SAPIntegrationService {
|
||||
blockedAmount,
|
||||
remainingBalance,
|
||||
blockId,
|
||||
note: remainingBalance === 0 ? 'Remaining balance will be calculated from availableBalance - blockedAmount' : 'Remaining balance from SAP response'
|
||||
responseStructure: ioOutputData ? 'XML lt_io_output' : responseData.d ? 'JSON d' : responseData.ls_response ? 'ls_response' : responseData.lt_io_output ? 'lt_io_output' : 'unknown',
|
||||
note: remainingBalance === 0 ? '⚠️ Remaining balance is 0 - will be calculated from availableBalance - blockedAmount' : '✅ Remaining balance from SAP response'
|
||||
});
|
||||
|
||||
// If remaining balance is 0, log the full response structure for debugging
|
||||
if (remainingBalance === 0 && response.status === 200 || response.status === 201) {
|
||||
logger.warn(`[SAP] ⚠️ Remaining balance is 0, but request was successful. Full response structure:`, JSON.stringify(responseData, null, 2));
|
||||
}
|
||||
|
||||
if (success) {
|
||||
logger.info(`[SAP] Budget blocked successfully for IO ${ioNumber}. Blocked: ${blockedAmount}, Remaining: ${remainingBalance}`);
|
||||
return {
|
||||
|
||||
@ -332,6 +332,9 @@ export class WorkflowService {
|
||||
|
||||
// Shift existing levels at and after target level
|
||||
// IMPORTANT: Shift in REVERSE order to avoid unique constraint violations
|
||||
// IMPORTANT: Preserve original level names when shifting (don't overwrite them)
|
||||
// IMPORTANT: Update status of shifted levels - if they were IN_PROGRESS, set to PENDING
|
||||
// because they're no longer the current active step (new approver is being added before them)
|
||||
const levelsToShift = allLevels
|
||||
.filter(l => (l as any).levelNumber >= targetLevel)
|
||||
.sort((a, b) => (b as any).levelNumber - (a as any).levelNumber); // Sort descending
|
||||
@ -339,24 +342,47 @@ export class WorkflowService {
|
||||
for (const levelToShift of levelsToShift) {
|
||||
const oldLevelNumber = (levelToShift as any).levelNumber;
|
||||
const newLevelNumber = oldLevelNumber + 1;
|
||||
const existingLevelName = (levelToShift as any).levelName;
|
||||
const currentStatus = (levelToShift as any).status;
|
||||
|
||||
// If the level being shifted was IN_PROGRESS or PENDING, set it to PENDING
|
||||
// because it's no longer the current active step (a new approver is being added before it)
|
||||
const newStatus = (currentStatus === ApprovalStatus.IN_PROGRESS || currentStatus === ApprovalStatus.PENDING)
|
||||
? ApprovalStatus.PENDING
|
||||
: currentStatus; // Keep APPROVED, REJECTED, SKIPPED as-is
|
||||
|
||||
// Preserve the original level name - don't overwrite it
|
||||
await levelToShift.update({
|
||||
levelNumber: newLevelNumber,
|
||||
levelName: `Level ${newLevelNumber}`
|
||||
});
|
||||
logger.info(`[Workflow] Shifted level ${oldLevelNumber} → ${newLevelNumber}`);
|
||||
// Keep existing levelName if it exists, otherwise use generic
|
||||
levelName: existingLevelName || `Level ${newLevelNumber}`,
|
||||
status: newStatus,
|
||||
// Clear levelStartTime and tatStartTime since this is no longer the active step
|
||||
levelStartTime: undefined,
|
||||
tatStartTime: undefined,
|
||||
} as any);
|
||||
logger.info(`[Workflow] Shifted level ${oldLevelNumber} → ${newLevelNumber}, preserved levelName: ${existingLevelName || 'N/A'}, updated status: ${currentStatus} → ${newStatus}`);
|
||||
}
|
||||
|
||||
// Update total levels in workflow
|
||||
await workflow.update({ totalLevels: allLevels.length + 1 });
|
||||
|
||||
// Auto-generate smart level name
|
||||
let levelName = `Level ${targetLevel}`;
|
||||
// Auto-generate smart level name for newly added approver
|
||||
// Use "Additional Approver" to identify dynamically added approvers
|
||||
let levelName = `Additional Approver`;
|
||||
if (designation) {
|
||||
levelName = `${designation} Approval`;
|
||||
levelName = `Additional Approver - ${designation}`;
|
||||
} else if (department) {
|
||||
levelName = `${department} Approval`;
|
||||
levelName = `Additional Approver - ${department}`;
|
||||
} else if (userName) {
|
||||
levelName = `Additional Approver - ${userName}`;
|
||||
}
|
||||
|
||||
// Determine if the new level should be IN_PROGRESS
|
||||
// If we're adding at the current level, the new approver becomes the active approver
|
||||
const workflowCurrentLevel = (workflow as any).currentLevel;
|
||||
const isAddingAtCurrentLevel = targetLevel === workflowCurrentLevel;
|
||||
|
||||
// Create new approval level at target position
|
||||
const newLevel = await ApprovalLevel.create({
|
||||
requestId,
|
||||
@ -367,12 +393,17 @@ export class WorkflowService {
|
||||
approverName: userName,
|
||||
tatHours,
|
||||
// tatDays is auto-calculated by database as a generated column
|
||||
status: targetLevel === (workflow as any).currentLevel ? ApprovalStatus.IN_PROGRESS : ApprovalStatus.PENDING,
|
||||
status: isAddingAtCurrentLevel ? ApprovalStatus.IN_PROGRESS : ApprovalStatus.PENDING,
|
||||
isFinalApprover: targetLevel === allLevels.length + 1,
|
||||
levelStartTime: targetLevel === (workflow as any).currentLevel ? new Date() : null,
|
||||
tatStartTime: targetLevel === (workflow as any).currentLevel ? new Date() : null
|
||||
levelStartTime: isAddingAtCurrentLevel ? new Date() : null,
|
||||
tatStartTime: isAddingAtCurrentLevel ? new Date() : null
|
||||
} as any);
|
||||
|
||||
// IMPORTANT: If we're adding at the current level, the workflow's currentLevel stays the same
|
||||
// (it's still the same level number, just with a new approver)
|
||||
// The status update we did above ensures the shifted level becomes PENDING
|
||||
// No need to update workflow.currentLevel - it's already correct
|
||||
|
||||
// Update isFinalApprover for previous final approver (now it's not final anymore)
|
||||
if (allLevels.length > 0) {
|
||||
const previousFinal = allLevels.find(l => (l as any).isFinalApprover);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user