dealer claim tab added some information mapped on overview tab and also fist approve can take action
This commit is contained in:
parent
e642e39a0a
commit
5d90e58bf9
134
docs/CLAIM_MANAGEMENT_APPROVER_MAPPING.md
Normal file
134
docs/CLAIM_MANAGEMENT_APPROVER_MAPPING.md
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
# Claim Management - Approver Mapping Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Claim Management workflow has **8 fixed steps** with specific approvers and action types. This document explains how approvers are mapped when a claim request is created.
|
||||||
|
|
||||||
|
## 8-Step Workflow Structure
|
||||||
|
|
||||||
|
### Step 1: Dealer Proposal Submission
|
||||||
|
- **Approver Type**: Dealer (External)
|
||||||
|
- **Action Type**: **SUBMIT** (Dealer submits proposal documents)
|
||||||
|
- **TAT**: 72 hours
|
||||||
|
- **Mapping**: Uses `dealerEmail` from claim data
|
||||||
|
- **Status**: PENDING (waiting for dealer to submit)
|
||||||
|
|
||||||
|
### Step 2: Requestor Evaluation
|
||||||
|
- **Approver Type**: Initiator (Internal RE Employee)
|
||||||
|
- **Action Type**: **APPROVE/REJECT** (Requestor reviews dealer proposal)
|
||||||
|
- **TAT**: 48 hours
|
||||||
|
- **Mapping**: Uses `initiatorId` (the person who created the request)
|
||||||
|
- **Status**: PENDING (waiting for requestor to evaluate)
|
||||||
|
|
||||||
|
### Step 3: Department Lead Approval
|
||||||
|
- **Approver Type**: Department Lead (Internal RE Employee)
|
||||||
|
- **Action Type**: **APPROVE/REJECT** (Department lead approves and blocks IO budget)
|
||||||
|
- **TAT**: 72 hours
|
||||||
|
- **Mapping**:
|
||||||
|
- Option 1: Find user with role `MANAGEMENT` in same department as initiator
|
||||||
|
- Option 2: Use initiator's `manager` field from User model
|
||||||
|
- Option 3: Find user with designation containing "Lead" or "Head" in same department
|
||||||
|
- **Status**: PENDING (waiting for department lead approval)
|
||||||
|
|
||||||
|
### Step 4: Activity Creation
|
||||||
|
- **Approver Type**: System (Auto-processed)
|
||||||
|
- **Action Type**: **AUTO** (System automatically creates activity)
|
||||||
|
- **TAT**: 1 hour
|
||||||
|
- **Mapping**: System user (`system@royalenfield.com`)
|
||||||
|
- **Status**: Auto-approved when triggered
|
||||||
|
|
||||||
|
### Step 5: Dealer Completion Documents
|
||||||
|
- **Approver Type**: Dealer (External)
|
||||||
|
- **Action Type**: **SUBMIT** (Dealer submits completion documents)
|
||||||
|
- **TAT**: 120 hours
|
||||||
|
- **Mapping**: Uses `dealerEmail` from claim data
|
||||||
|
- **Status**: PENDING (waiting for dealer to submit)
|
||||||
|
|
||||||
|
### Step 6: Requestor Claim Approval
|
||||||
|
- **Approver Type**: Initiator (Internal RE Employee)
|
||||||
|
- **Action Type**: **APPROVE/REJECT** (Requestor approves completion)
|
||||||
|
- **TAT**: 48 hours
|
||||||
|
- **Mapping**: Uses `initiatorId`
|
||||||
|
- **Status**: PENDING (waiting for requestor approval)
|
||||||
|
|
||||||
|
### Step 7: E-Invoice Generation
|
||||||
|
- **Approver Type**: System (Auto-processed via DMS)
|
||||||
|
- **Action Type**: **AUTO** (System generates e-invoice via DMS integration)
|
||||||
|
- **TAT**: 1 hour
|
||||||
|
- **Mapping**: System user (`system@royalenfield.com`)
|
||||||
|
- **Status**: Auto-approved when triggered
|
||||||
|
|
||||||
|
### Step 8: Credit Note Confirmation
|
||||||
|
- **Approver Type**: Finance Team (Internal RE Employee)
|
||||||
|
- **Action Type**: **APPROVE/REJECT** (Finance confirms credit note)
|
||||||
|
- **TAT**: 48 hours
|
||||||
|
- **Mapping**:
|
||||||
|
- Option 1: Find user with role `MANAGEMENT` and department contains "Finance"
|
||||||
|
- Option 2: Find user with designation containing "Finance" or "Accountant"
|
||||||
|
- Option 3: Use configured finance team email from admin settings
|
||||||
|
- **Status**: PENDING (waiting for finance confirmation)
|
||||||
|
- **Is Final Approver**: Yes (final step)
|
||||||
|
|
||||||
|
## Current Implementation Issues
|
||||||
|
|
||||||
|
### Problems:
|
||||||
|
1. **Step 1 & 5**: Dealer email not being used - using placeholder UUID
|
||||||
|
2. **Step 3**: Department Lead not resolved - using placeholder UUID
|
||||||
|
3. **Step 8**: Finance team not resolved - using placeholder UUID
|
||||||
|
4. **All steps**: Using initiator email for non-initiator steps
|
||||||
|
|
||||||
|
### Impact:
|
||||||
|
- Steps 1, 3, 5, 8 won't have correct approvers assigned
|
||||||
|
- Notifications won't be sent to correct users
|
||||||
|
- Workflow will be stuck waiting for non-existent approvers
|
||||||
|
|
||||||
|
## Action Types Summary
|
||||||
|
|
||||||
|
| Step | Action Type | Description |
|
||||||
|
|------|-------------|-------------|
|
||||||
|
| 1 | SUBMIT | Dealer submits proposal (not approve/reject) |
|
||||||
|
| 2 | APPROVE/REJECT | Requestor evaluates proposal |
|
||||||
|
| 3 | APPROVE/REJECT | Department Lead approves and blocks budget |
|
||||||
|
| 4 | AUTO | System creates activity automatically |
|
||||||
|
| 5 | SUBMIT | Dealer submits completion documents |
|
||||||
|
| 6 | APPROVE/REJECT | Requestor approves completion |
|
||||||
|
| 7 | AUTO | System generates e-invoice via DMS |
|
||||||
|
| 8 | APPROVE/REJECT | Finance confirms credit note (FINAL) |
|
||||||
|
|
||||||
|
## Approver Resolution Logic
|
||||||
|
|
||||||
|
### For Dealer Steps (1, 5):
|
||||||
|
```typescript
|
||||||
|
// Use dealer email from claim data
|
||||||
|
const dealerEmail = claimData.dealerEmail;
|
||||||
|
// Find or create dealer user (if dealer is external, may need special handling)
|
||||||
|
const dealerUser = await User.findOne({ where: { email: dealerEmail } });
|
||||||
|
// If dealer doesn't exist in system, create participant entry
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Department Lead (Step 3):
|
||||||
|
```typescript
|
||||||
|
// Priority order:
|
||||||
|
1. Find user with same department and role = 'MANAGEMENT'
|
||||||
|
2. Use initiator.manager field to find manager
|
||||||
|
3. Find user with designation containing "Lead" or "Head" in same department
|
||||||
|
4. Fallback: Use initiator's manager email from User model
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Finance Team (Step 8):
|
||||||
|
```typescript
|
||||||
|
// Priority order:
|
||||||
|
1. Find user with department containing "Finance" and role = 'MANAGEMENT'
|
||||||
|
2. Find user with designation containing "Finance" or "Accountant"
|
||||||
|
3. Use configured finance team email from admin_configurations table
|
||||||
|
4. Fallback: Use default finance email (e.g., finance@royalenfield.com)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
The `createClaimApprovalLevels()` method needs to be updated to:
|
||||||
|
1. Accept `dealerEmail` parameter
|
||||||
|
2. Resolve Department Lead dynamically
|
||||||
|
3. Resolve Finance team member dynamically
|
||||||
|
4. Handle cases where approvers don't exist in the system
|
||||||
|
|
||||||
149
docs/COST_BREAKUP_TABLE_ARCHITECTURE.md
Normal file
149
docs/COST_BREAKUP_TABLE_ARCHITECTURE.md
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
# Cost Breakup Table Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the enhanced architecture for storing cost breakups in the Dealer Claim Management system. Instead of storing cost breakups as JSONB arrays, we now use a dedicated relational table for better querying, reporting, and data integrity.
|
||||||
|
|
||||||
|
## Architecture Decision
|
||||||
|
|
||||||
|
### Previous Approach (JSONB)
|
||||||
|
- **Storage**: Cost breakups stored as JSONB array in `dealer_proposal_details.cost_breakup`
|
||||||
|
- **Limitations**:
|
||||||
|
- Difficult to query individual cost items
|
||||||
|
- Hard to update specific items
|
||||||
|
- Not ideal for reporting and analytics
|
||||||
|
- No referential integrity
|
||||||
|
|
||||||
|
### New Approach (Separate Table)
|
||||||
|
- **Storage**: Dedicated `dealer_proposal_cost_items` table
|
||||||
|
- **Benefits**:
|
||||||
|
- Better querying and filtering capabilities
|
||||||
|
- Easier to update individual cost items
|
||||||
|
- Better for analytics and reporting
|
||||||
|
- Maintains referential integrity
|
||||||
|
- Supports proper ordering of items
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Table: `dealer_proposal_cost_items`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE dealer_proposal_cost_items (
|
||||||
|
cost_item_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
proposal_id UUID NOT NULL REFERENCES dealer_proposal_details(proposal_id) ON DELETE CASCADE,
|
||||||
|
request_id UUID NOT NULL REFERENCES workflow_requests(request_id) ON DELETE CASCADE,
|
||||||
|
item_description VARCHAR(500) NOT NULL,
|
||||||
|
amount DECIMAL(15, 2) NOT NULL,
|
||||||
|
item_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Indexes**:
|
||||||
|
- `idx_proposal_cost_items_proposal_id` on `proposal_id`
|
||||||
|
- `idx_proposal_cost_items_request_id` on `request_id`
|
||||||
|
- `idx_proposal_cost_items_proposal_order` on `(proposal_id, item_order)`
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
The system maintains backward compatibility by:
|
||||||
|
1. **Dual Storage**: Still saves cost breakups to JSONB field for backward compatibility
|
||||||
|
2. **Smart Retrieval**: When fetching proposal details:
|
||||||
|
- First tries to get cost items from the new table
|
||||||
|
- Falls back to JSONB field if table is empty
|
||||||
|
3. **Migration**: Automatically migrates existing JSONB data to the new table during migration
|
||||||
|
|
||||||
|
## API Response Format
|
||||||
|
|
||||||
|
The API always returns cost breakups as an array, regardless of storage method:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"proposalDetails": {
|
||||||
|
"proposalId": "uuid",
|
||||||
|
"costBreakup": [
|
||||||
|
{
|
||||||
|
"description": "Item 1",
|
||||||
|
"amount": 10000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Item 2",
|
||||||
|
"amount": 20000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"costItems": [
|
||||||
|
{
|
||||||
|
"costItemId": "uuid",
|
||||||
|
"itemDescription": "Item 1",
|
||||||
|
"amount": 10000,
|
||||||
|
"itemOrder": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Saving Cost Items
|
||||||
|
|
||||||
|
When a proposal is submitted:
|
||||||
|
|
||||||
|
1. Save proposal details to `dealer_proposal_details` (with JSONB for backward compatibility)
|
||||||
|
2. Delete existing cost items for the proposal (if updating)
|
||||||
|
3. Insert new cost items into `dealer_proposal_cost_items` table
|
||||||
|
4. Items are ordered by `itemOrder` field
|
||||||
|
|
||||||
|
### Retrieving Cost Items
|
||||||
|
|
||||||
|
When fetching proposal details:
|
||||||
|
|
||||||
|
1. Query `dealer_proposal_details` with `include` for `costItems`
|
||||||
|
2. If cost items exist in the table, use them
|
||||||
|
3. If not, fall back to parsing JSONB `costBreakup` field
|
||||||
|
4. Always return as a normalized array format
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
The migration (`20251210-create-proposal-cost-items-table.ts`):
|
||||||
|
1. Creates the new table
|
||||||
|
2. Creates indexes for performance
|
||||||
|
3. Migrates existing JSONB data to the new table automatically
|
||||||
|
4. Handles errors gracefully (doesn't fail if migration of existing data fails)
|
||||||
|
|
||||||
|
## Model Associations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
DealerProposalDetails.hasMany(DealerProposalCostItem, {
|
||||||
|
as: 'costItems',
|
||||||
|
foreignKey: 'proposalId',
|
||||||
|
sourceKey: 'proposalId'
|
||||||
|
});
|
||||||
|
|
||||||
|
DealerProposalCostItem.belongsTo(DealerProposalDetails, {
|
||||||
|
as: 'proposal',
|
||||||
|
foreignKey: 'proposalId',
|
||||||
|
targetKey: 'proposalId'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits for Frontend
|
||||||
|
|
||||||
|
1. **Consistent Format**: Always receives cost breakups as an array
|
||||||
|
2. **No Changes Required**: Frontend code doesn't need to change
|
||||||
|
3. **Better Performance**: Can query specific cost items if needed
|
||||||
|
4. **Future Extensibility**: Easy to add features like:
|
||||||
|
- Cost item categories
|
||||||
|
- Approval status per item
|
||||||
|
- Historical tracking of cost changes
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential future improvements:
|
||||||
|
- Add `category` field to cost items
|
||||||
|
- Add `approved_amount` vs `requested_amount` for budget approval workflows
|
||||||
|
- Add `notes` field for item-level comments
|
||||||
|
- Add audit trail for cost item changes
|
||||||
|
- Add `is_approved` flag for individual item approval
|
||||||
|
|
||||||
134
docs/DEALER_USER_ARCHITECTURE.md
Normal file
134
docs/DEALER_USER_ARCHITECTURE.md
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
# Dealer User Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Dealers and regular users are stored in the SAME `users` table.** This is the correct approach because dealers ARE users in the system - they login via SSO, participate in workflows, receive notifications, etc.
|
||||||
|
|
||||||
|
## Why Single Table?
|
||||||
|
|
||||||
|
### ✅ Advantages:
|
||||||
|
1. **Unified Authentication**: Dealers login via the same Okta SSO as regular users
|
||||||
|
2. **Shared Functionality**: Dealers need all user features (notifications, workflow participation, etc.)
|
||||||
|
3. **Simpler Architecture**: No need for joins or complex queries
|
||||||
|
4. **Data Consistency**: Single source of truth for all users
|
||||||
|
5. **Workflow Integration**: Dealers can be approvers, participants, or action takers seamlessly
|
||||||
|
|
||||||
|
### ❌ Why NOT Separate Table:
|
||||||
|
- Would require complex joins for every query
|
||||||
|
- Data duplication (email, name, etc. in both tables)
|
||||||
|
- Dealers still need user authentication and permissions
|
||||||
|
- More complex to maintain
|
||||||
|
|
||||||
|
## How Dealers Are Identified
|
||||||
|
|
||||||
|
Dealers are identified using **three criteria** (any one matches):
|
||||||
|
|
||||||
|
1. **`employeeId` field starts with `'RE-'`** (e.g., `RE-MH-001`, `RE-DL-002`)
|
||||||
|
- This is the **primary identifier** for dealers
|
||||||
|
- Dealer code is stored in `employeeId` field
|
||||||
|
|
||||||
|
2. **`designation` contains `'dealer'`** (case-insensitive)
|
||||||
|
- Example: `"Dealer"`, `"Senior Dealer"`, etc.
|
||||||
|
|
||||||
|
3. **`department` contains `'dealer'`** (case-insensitive)
|
||||||
|
- Example: `"Dealer Operations"`, `"Dealer Management"`, etc.
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
users {
|
||||||
|
user_id UUID PK
|
||||||
|
email VARCHAR(255) UNIQUE
|
||||||
|
okta_sub VARCHAR(100) UNIQUE -- From Okta SSO
|
||||||
|
employee_id VARCHAR(50) -- For dealers: stores dealer code (RE-MH-001)
|
||||||
|
display_name VARCHAR(255)
|
||||||
|
designation VARCHAR(255) -- For dealers: "Dealer"
|
||||||
|
department VARCHAR(255) -- For dealers: "Dealer Operations"
|
||||||
|
role ENUM('USER', 'MANAGEMENT', 'ADMIN')
|
||||||
|
is_active BOOLEAN
|
||||||
|
-- ... other user fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Data
|
||||||
|
|
||||||
|
### Regular User:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": "uuid-1",
|
||||||
|
"email": "john.doe@royalenfield.com",
|
||||||
|
"employeeId": "E12345", // Regular employee ID
|
||||||
|
"designation": "Software Engineer",
|
||||||
|
"department": "IT",
|
||||||
|
"role": "USER"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dealer User:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": "uuid-2",
|
||||||
|
"email": "test.2@royalenfield.com",
|
||||||
|
"employeeId": "RE-MH-001", // Dealer code stored here
|
||||||
|
"designation": "Dealer",
|
||||||
|
"department": "Dealer Operations",
|
||||||
|
"role": "USER"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Querying Dealers
|
||||||
|
|
||||||
|
The `dealer.service.ts` uses these filters to find dealers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
User.findAll({
|
||||||
|
where: {
|
||||||
|
[Op.or]: [
|
||||||
|
{ designation: { [Op.iLike]: '%dealer%' } },
|
||||||
|
{ employeeId: { [Op.like]: 'RE-%' } },
|
||||||
|
{ department: { [Op.iLike]: '%dealer%' } },
|
||||||
|
],
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Seed Script Behavior
|
||||||
|
|
||||||
|
When running `npm run seed:dealers`:
|
||||||
|
|
||||||
|
1. **If user exists (from Okta SSO)**:
|
||||||
|
- ✅ Preserves `oktaSub` (real Okta subject ID)
|
||||||
|
- ✅ Preserves `role` (from Okta)
|
||||||
|
- ✅ Updates `employeeId` with dealer code
|
||||||
|
- ✅ Updates `designation` to "Dealer" (if not already)
|
||||||
|
- ✅ Updates `department` to "Dealer Operations" (if not already)
|
||||||
|
|
||||||
|
2. **If user doesn't exist**:
|
||||||
|
- Creates placeholder user
|
||||||
|
- Sets `oktaSub` to `dealer-{code}-pending-sso`
|
||||||
|
- When dealer logs in via SSO, `oktaSub` gets updated automatically
|
||||||
|
|
||||||
|
## Workflow Integration
|
||||||
|
|
||||||
|
Dealers participate in workflows just like regular users:
|
||||||
|
|
||||||
|
- **As Approvers**: In Steps 1 & 5 of claim management workflow
|
||||||
|
- **As Participants**: Can be added to any workflow
|
||||||
|
- **As Action Takers**: Can submit proposals, completion documents, etc.
|
||||||
|
|
||||||
|
The system identifies them as dealers by checking `employeeId` starting with `'RE-'` or `designation` containing `'dealer'`.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `GET /api/v1/dealers` - Get all dealers (filters users table)
|
||||||
|
- `GET /api/v1/dealers/code/:dealerCode` - Get dealer by code
|
||||||
|
- `GET /api/v1/dealers/email/:email` - Get dealer by email
|
||||||
|
- `GET /api/v1/dealers/search?q=term` - Search dealers
|
||||||
|
|
||||||
|
All endpoints query the same `users` table with dealer-specific filters.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
**✅ Single `users` table is the correct approach.** No separate dealer table needed. Dealers are users with special identification markers (dealer code in `employeeId`, dealer designation, etc.).
|
||||||
|
|
||||||
1197
docs/DYNAMIC_TEMPLATE_SYSTEM.md
Normal file
1197
docs/DYNAMIC_TEMPLATE_SYSTEM.md
Normal file
File diff suppressed because it is too large
Load Diff
583
docs/EXTENSIBLE_WORKFLOW_ARCHITECTURE.md
Normal file
583
docs/EXTENSIBLE_WORKFLOW_ARCHITECTURE.md
Normal file
@ -0,0 +1,583 @@
|
|||||||
|
# Extensible Workflow Architecture Plan
|
||||||
|
## Supporting Multiple Template Types (Claim Management, Non-Templatized, Future Templates)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document outlines how to design the backend architecture to support:
|
||||||
|
1. **Unified Request System**: All requests (templatized, non-templatized, claim management) use the same `workflow_requests` table
|
||||||
|
2. **Template Identification**: Distinguish between different workflow types
|
||||||
|
3. **Extensibility**: Easy addition of new templates by admins without code changes
|
||||||
|
4. **Unified Views**: All requests appear in "My Requests", "Open Requests", etc. automatically
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Principles
|
||||||
|
|
||||||
|
### 1. **Single Source of Truth: `workflow_requests` Table**
|
||||||
|
|
||||||
|
All requests, regardless of type, are stored in the same table:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
workflow_requests {
|
||||||
|
request_id UUID PK
|
||||||
|
request_number VARCHAR(20) UK
|
||||||
|
initiator_id UUID FK
|
||||||
|
template_type VARCHAR(20) -- 'CUSTOM' | 'TEMPLATE' (high-level)
|
||||||
|
workflow_type VARCHAR(50) -- 'NON_TEMPLATIZED' | 'CLAIM_MANAGEMENT' | 'DEALER_ONBOARDING' | etc.
|
||||||
|
template_id UUID FK (nullable) -- Reference to workflow_templates if using admin template
|
||||||
|
title VARCHAR(500)
|
||||||
|
description TEXT
|
||||||
|
status workflow_status
|
||||||
|
current_level INTEGER
|
||||||
|
total_levels INTEGER
|
||||||
|
-- ... common fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Fields:**
|
||||||
|
- `template_type`: High-level classification ('CUSTOM' for user-created, 'TEMPLATE' for admin templates)
|
||||||
|
- `workflow_type`: Specific workflow identifier (e.g., 'CLAIM_MANAGEMENT', 'NON_TEMPLATIZED')
|
||||||
|
- `template_id`: Optional reference to `workflow_templates` table if using an admin-created template
|
||||||
|
|
||||||
|
### 2. **Template-Specific Data Storage**
|
||||||
|
|
||||||
|
Each workflow type can have its own extension table for type-specific data:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- For Claim Management
|
||||||
|
dealer_claim_details {
|
||||||
|
claim_id UUID PK
|
||||||
|
request_id UUID FK -> workflow_requests(request_id)
|
||||||
|
activity_name VARCHAR(500)
|
||||||
|
activity_type VARCHAR(100)
|
||||||
|
dealer_code VARCHAR(50)
|
||||||
|
dealer_name VARCHAR(200)
|
||||||
|
dealer_email VARCHAR(255)
|
||||||
|
dealer_phone VARCHAR(20)
|
||||||
|
dealer_address TEXT
|
||||||
|
activity_date DATE
|
||||||
|
location VARCHAR(255)
|
||||||
|
period_start_date DATE
|
||||||
|
period_end_date DATE
|
||||||
|
estimated_budget DECIMAL(15,2)
|
||||||
|
closed_expenses DECIMAL(15,2)
|
||||||
|
io_number VARCHAR(50)
|
||||||
|
io_blocked_amount DECIMAL(15,2)
|
||||||
|
sap_document_number VARCHAR(100)
|
||||||
|
dms_number VARCHAR(100)
|
||||||
|
e_invoice_number VARCHAR(100)
|
||||||
|
credit_note_number VARCHAR(100)
|
||||||
|
-- ... claim-specific fields
|
||||||
|
}
|
||||||
|
|
||||||
|
-- For Non-Templatized (if needed)
|
||||||
|
non_templatized_details {
|
||||||
|
detail_id UUID PK
|
||||||
|
request_id UUID FK -> workflow_requests(request_id)
|
||||||
|
custom_fields JSONB -- Flexible storage for any custom data
|
||||||
|
-- ... any specific fields
|
||||||
|
}
|
||||||
|
|
||||||
|
-- For Future Templates
|
||||||
|
-- Each new template can have its own extension table
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **Workflow Templates Table (Admin-Created Templates)**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
workflow_templates {
|
||||||
|
template_id UUID PK
|
||||||
|
template_name VARCHAR(200) -- Display name: "Claim Management", "Dealer Onboarding"
|
||||||
|
template_code VARCHAR(50) UK -- Unique identifier: "CLAIM_MANAGEMENT", "DEALER_ONBOARDING"
|
||||||
|
template_description TEXT
|
||||||
|
template_category VARCHAR(100) -- "Dealer Operations", "HR", "Finance", etc.
|
||||||
|
workflow_type VARCHAR(50) -- Maps to workflow_requests.workflow_type
|
||||||
|
approval_levels_config JSONB -- Step definitions, TAT, roles, etc.
|
||||||
|
default_tat_hours DECIMAL(10,2)
|
||||||
|
form_fields_config JSONB -- Form field definitions for wizard
|
||||||
|
is_active BOOLEAN
|
||||||
|
is_system_template BOOLEAN -- True for built-in (Claim Management), False for admin-created
|
||||||
|
created_by UUID FK
|
||||||
|
created_at TIMESTAMP
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema Changes
|
||||||
|
|
||||||
|
### Migration: Add Workflow Type Support
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Migration: 20251210-add-workflow-type-support.ts
|
||||||
|
|
||||||
|
-- 1. Add workflow_type column to workflow_requests
|
||||||
|
ALTER TABLE workflow_requests
|
||||||
|
ADD COLUMN IF NOT EXISTS workflow_type VARCHAR(50) DEFAULT 'NON_TEMPLATIZED';
|
||||||
|
|
||||||
|
-- 2. Add template_id column (nullable, for admin templates)
|
||||||
|
ALTER TABLE workflow_requests
|
||||||
|
ADD COLUMN IF NOT EXISTS template_id UUID REFERENCES workflow_templates(template_id);
|
||||||
|
|
||||||
|
-- 3. Create index for workflow_type
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflow_requests_workflow_type
|
||||||
|
ON workflow_requests(workflow_type);
|
||||||
|
|
||||||
|
-- 4. Create index for template_id
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflow_requests_template_id
|
||||||
|
ON workflow_requests(template_id);
|
||||||
|
|
||||||
|
-- 5. Create dealer_claim_details table
|
||||||
|
CREATE TABLE IF NOT EXISTS dealer_claim_details (
|
||||||
|
claim_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
request_id UUID NOT NULL UNIQUE REFERENCES workflow_requests(request_id) ON DELETE CASCADE,
|
||||||
|
activity_name VARCHAR(500) NOT NULL,
|
||||||
|
activity_type VARCHAR(100) NOT NULL,
|
||||||
|
dealer_code VARCHAR(50) NOT NULL,
|
||||||
|
dealer_name VARCHAR(200) NOT NULL,
|
||||||
|
dealer_email VARCHAR(255),
|
||||||
|
dealer_phone VARCHAR(20),
|
||||||
|
dealer_address TEXT,
|
||||||
|
activity_date DATE,
|
||||||
|
location VARCHAR(255),
|
||||||
|
period_start_date DATE,
|
||||||
|
period_end_date DATE,
|
||||||
|
estimated_budget DECIMAL(15,2),
|
||||||
|
closed_expenses DECIMAL(15,2),
|
||||||
|
io_number VARCHAR(50),
|
||||||
|
io_available_balance DECIMAL(15,2),
|
||||||
|
io_blocked_amount DECIMAL(15,2),
|
||||||
|
io_remaining_balance DECIMAL(15,2),
|
||||||
|
sap_document_number VARCHAR(100),
|
||||||
|
dms_number VARCHAR(100),
|
||||||
|
e_invoice_number VARCHAR(100),
|
||||||
|
e_invoice_date DATE,
|
||||||
|
credit_note_number VARCHAR(100),
|
||||||
|
credit_note_date DATE,
|
||||||
|
credit_note_amount DECIMAL(15,2),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_dealer_claim_details_request_id ON dealer_claim_details(request_id);
|
||||||
|
CREATE INDEX idx_dealer_claim_details_dealer_code ON dealer_claim_details(dealer_code);
|
||||||
|
|
||||||
|
-- 6. Create proposal_details table (Step 1: Dealer Proposal)
|
||||||
|
CREATE TABLE IF NOT EXISTS dealer_proposal_details (
|
||||||
|
proposal_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
request_id UUID NOT NULL REFERENCES workflow_requests(request_id) ON DELETE CASCADE,
|
||||||
|
proposal_document_path VARCHAR(500),
|
||||||
|
proposal_document_url VARCHAR(500),
|
||||||
|
cost_breakup JSONB, -- Array of {description, amount}
|
||||||
|
total_estimated_budget DECIMAL(15,2),
|
||||||
|
timeline_mode VARCHAR(10), -- 'date' | 'days'
|
||||||
|
expected_completion_date DATE,
|
||||||
|
expected_completion_days INTEGER,
|
||||||
|
dealer_comments TEXT,
|
||||||
|
submitted_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_dealer_proposal_details_request_id ON dealer_proposal_details(request_id);
|
||||||
|
|
||||||
|
-- 7. Create completion_documents table (Step 5: Dealer Completion)
|
||||||
|
CREATE TABLE IF NOT EXISTS dealer_completion_details (
|
||||||
|
completion_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
request_id UUID NOT NULL REFERENCES workflow_requests(request_id) ON DELETE CASCADE,
|
||||||
|
activity_completion_date DATE NOT NULL,
|
||||||
|
number_of_participants INTEGER,
|
||||||
|
closed_expenses JSONB, -- Array of {description, amount}
|
||||||
|
total_closed_expenses DECIMAL(15,2),
|
||||||
|
completion_documents JSONB, -- Array of document references
|
||||||
|
activity_photos JSONB, -- Array of photo references
|
||||||
|
submitted_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_dealer_completion_details_request_id ON dealer_completion_details(request_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Model Updates
|
||||||
|
|
||||||
|
### 1. Update WorkflowRequest Model
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Re_Backend/src/models/WorkflowRequest.ts
|
||||||
|
|
||||||
|
interface WorkflowRequestAttributes {
|
||||||
|
requestId: string;
|
||||||
|
requestNumber: string;
|
||||||
|
initiatorId: string;
|
||||||
|
templateType: 'CUSTOM' | 'TEMPLATE';
|
||||||
|
workflowType: string; // NEW: 'NON_TEMPLATIZED' | 'CLAIM_MANAGEMENT' | etc.
|
||||||
|
templateId?: string; // NEW: Reference to workflow_templates
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
// ... existing fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add association
|
||||||
|
WorkflowRequest.hasOne(DealerClaimDetails, {
|
||||||
|
as: 'claimDetails',
|
||||||
|
foreignKey: 'requestId',
|
||||||
|
sourceKey: 'requestId'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create DealerClaimDetails Model
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Re_Backend/src/models/DealerClaimDetails.ts
|
||||||
|
|
||||||
|
import { DataTypes, Model } from 'sequelize';
|
||||||
|
import { sequelize } from '@config/database';
|
||||||
|
import { WorkflowRequest } from './WorkflowRequest';
|
||||||
|
|
||||||
|
interface DealerClaimDetailsAttributes {
|
||||||
|
claimId: string;
|
||||||
|
requestId: string;
|
||||||
|
activityName: string;
|
||||||
|
activityType: string;
|
||||||
|
dealerCode: string;
|
||||||
|
dealerName: string;
|
||||||
|
// ... all claim-specific fields
|
||||||
|
}
|
||||||
|
|
||||||
|
class DealerClaimDetails extends Model<DealerClaimDetailsAttributes> {
|
||||||
|
public claimId!: string;
|
||||||
|
public requestId!: string;
|
||||||
|
// ... fields
|
||||||
|
}
|
||||||
|
|
||||||
|
DealerClaimDetails.init({
|
||||||
|
claimId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
field: 'claim_id'
|
||||||
|
},
|
||||||
|
requestId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
field: 'request_id',
|
||||||
|
references: {
|
||||||
|
model: 'workflow_requests',
|
||||||
|
key: 'request_id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// ... all other fields
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'DealerClaimDetails',
|
||||||
|
tableName: 'dealer_claim_details',
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Association
|
||||||
|
DealerClaimDetails.belongsTo(WorkflowRequest, {
|
||||||
|
as: 'workflowRequest',
|
||||||
|
foreignKey: 'requestId',
|
||||||
|
targetKey: 'requestId'
|
||||||
|
});
|
||||||
|
|
||||||
|
export { DealerClaimDetails };
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Service Layer Pattern
|
||||||
|
|
||||||
|
### 1. Template-Aware Service Factory
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Re_Backend/src/services/templateService.factory.ts
|
||||||
|
|
||||||
|
import { WorkflowRequest } from '../models/WorkflowRequest';
|
||||||
|
import { DealerClaimService } from './dealerClaim.service';
|
||||||
|
import { NonTemplatizedService } from './nonTemplatized.service';
|
||||||
|
|
||||||
|
export class TemplateServiceFactory {
|
||||||
|
static getService(workflowType: string) {
|
||||||
|
switch (workflowType) {
|
||||||
|
case 'CLAIM_MANAGEMENT':
|
||||||
|
return new DealerClaimService();
|
||||||
|
case 'NON_TEMPLATIZED':
|
||||||
|
return new NonTemplatizedService();
|
||||||
|
default:
|
||||||
|
// For future templates, use a generic service or throw error
|
||||||
|
throw new Error(`Unsupported workflow type: ${workflowType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getRequestDetails(requestId: string) {
|
||||||
|
const request = await WorkflowRequest.findByPk(requestId);
|
||||||
|
if (!request) return null;
|
||||||
|
|
||||||
|
const service = this.getService(request.workflowType);
|
||||||
|
return service.getRequestDetails(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Unified Workflow Service (No Changes Needed)
|
||||||
|
|
||||||
|
The existing `WorkflowService.listMyRequests()` and `listOpenForMe()` methods will **automatically** include all request types because they query `workflow_requests` table without filtering by `workflow_type`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Existing code works as-is - no changes needed!
|
||||||
|
async listMyRequests(userId: string, page: number, limit: number, filters?: {...}) {
|
||||||
|
// This query automatically includes ALL workflow types
|
||||||
|
const requests = await WorkflowRequest.findAll({
|
||||||
|
where: {
|
||||||
|
initiatorId: userId,
|
||||||
|
isDraft: false,
|
||||||
|
// ... filters
|
||||||
|
// NO workflow_type filter - includes everything!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return requests;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### 1. Create Claim Management Request
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Re_Backend/src/controllers/dealerClaim.controller.ts
|
||||||
|
|
||||||
|
async createClaimRequest(req: AuthenticatedRequest, res: Response) {
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
const {
|
||||||
|
activityName,
|
||||||
|
activityType,
|
||||||
|
dealerCode,
|
||||||
|
// ... claim-specific fields
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 1. Create workflow request (common)
|
||||||
|
const workflowRequest = await WorkflowRequest.create({
|
||||||
|
initiatorId: userId,
|
||||||
|
templateType: 'CUSTOM',
|
||||||
|
workflowType: 'CLAIM_MANAGEMENT', // Identify as claim
|
||||||
|
title: `${activityName} - Claim Request`,
|
||||||
|
description: req.body.requestDescription,
|
||||||
|
totalLevels: 8, // Fixed 8-step workflow
|
||||||
|
// ... other common fields
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Create claim-specific details
|
||||||
|
const claimDetails = await DealerClaimDetails.create({
|
||||||
|
requestId: workflowRequest.requestId,
|
||||||
|
activityName,
|
||||||
|
activityType,
|
||||||
|
dealerCode,
|
||||||
|
// ... claim-specific fields
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Create approval levels (8 steps)
|
||||||
|
await this.createClaimApprovalLevels(workflowRequest.requestId);
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, {
|
||||||
|
request: workflowRequest,
|
||||||
|
claimDetails
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Get Request Details (Template-Aware)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async getRequestDetails(req: Request, res: Response) {
|
||||||
|
const { requestId } = req.params;
|
||||||
|
|
||||||
|
const request = await WorkflowRequest.findByPk(requestId, {
|
||||||
|
include: [
|
||||||
|
{ model: User, as: 'initiator' },
|
||||||
|
// Conditionally include template-specific data
|
||||||
|
...(request.workflowType === 'CLAIM_MANAGEMENT'
|
||||||
|
? [{ model: DealerClaimDetails, as: 'claimDetails' }]
|
||||||
|
: [])
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use factory to get template-specific service
|
||||||
|
const templateService = TemplateServiceFactory.getService(request.workflowType);
|
||||||
|
const enrichedDetails = await templateService.enrichRequestDetails(request);
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, enrichedDetails);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Integration
|
||||||
|
|
||||||
|
### 1. Request List Views (No Changes Needed)
|
||||||
|
|
||||||
|
The existing "My Requests" and "Open Requests" pages will automatically show all request types because the backend doesn't filter by `workflow_type`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Frontend: MyRequests.tsx - No changes needed!
|
||||||
|
const fetchMyRequests = async () => {
|
||||||
|
const result = await workflowApi.listMyInitiatedWorkflows({
|
||||||
|
page,
|
||||||
|
limit: itemsPerPage
|
||||||
|
});
|
||||||
|
// Returns ALL request types automatically
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Request Detail Page (Template-Aware Rendering)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Frontend: RequestDetail.tsx
|
||||||
|
|
||||||
|
const RequestDetail = ({ requestId }) => {
|
||||||
|
const request = useRequestDetails(requestId);
|
||||||
|
|
||||||
|
// Render based on workflow type
|
||||||
|
if (request.workflowType === 'CLAIM_MANAGEMENT') {
|
||||||
|
return <ClaimManagementDetail request={request} />;
|
||||||
|
} else if (request.workflowType === 'NON_TEMPLATIZED') {
|
||||||
|
return <NonTemplatizedDetail request={request} />;
|
||||||
|
} else {
|
||||||
|
// Future templates - use generic renderer or template config
|
||||||
|
return <GenericWorkflowDetail request={request} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding New Templates (Future)
|
||||||
|
|
||||||
|
### Step 1: Admin Creates Template in UI
|
||||||
|
|
||||||
|
1. Admin goes to "Template Management" page
|
||||||
|
2. Creates new template with:
|
||||||
|
- Template name: "Vendor Payment"
|
||||||
|
- Template code: "VENDOR_PAYMENT"
|
||||||
|
- Approval levels configuration
|
||||||
|
- Form fields configuration
|
||||||
|
|
||||||
|
### Step 2: Database Entry Created
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO workflow_templates (
|
||||||
|
template_name,
|
||||||
|
template_code,
|
||||||
|
workflow_type,
|
||||||
|
approval_levels_config,
|
||||||
|
form_fields_config,
|
||||||
|
is_active,
|
||||||
|
is_system_template
|
||||||
|
) VALUES (
|
||||||
|
'Vendor Payment',
|
||||||
|
'VENDOR_PAYMENT',
|
||||||
|
'VENDOR_PAYMENT',
|
||||||
|
'{"levels": [...], "tat": {...}}'::jsonb,
|
||||||
|
'{"fields": [...]}'::jsonb,
|
||||||
|
true,
|
||||||
|
false -- Admin-created, not system template
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Create Extension Table (If Needed)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE vendor_payment_details (
|
||||||
|
payment_id UUID PRIMARY KEY,
|
||||||
|
request_id UUID UNIQUE REFERENCES workflow_requests(request_id),
|
||||||
|
vendor_code VARCHAR(50),
|
||||||
|
invoice_number VARCHAR(100),
|
||||||
|
payment_amount DECIMAL(15,2),
|
||||||
|
-- ... vendor-specific fields
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Create Service (Optional - Can Use Generic Service)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Re_Backend/src/services/vendorPayment.service.ts
|
||||||
|
|
||||||
|
export class VendorPaymentService {
|
||||||
|
async getRequestDetails(request: WorkflowRequest) {
|
||||||
|
const paymentDetails = await VendorPaymentDetails.findOne({
|
||||||
|
where: { requestId: request.requestId }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...request.toJSON(),
|
||||||
|
paymentDetails
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update factory
|
||||||
|
TemplateServiceFactory.getService(workflowType: string) {
|
||||||
|
switch (workflowType) {
|
||||||
|
case 'VENDOR_PAYMENT':
|
||||||
|
return new VendorPaymentService();
|
||||||
|
// ... existing cases
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Frontend Component (Optional)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Frontend: components/VendorPaymentDetail.tsx
|
||||||
|
|
||||||
|
export function VendorPaymentDetail({ request }) {
|
||||||
|
// Render vendor payment specific UI
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Benefits of This Architecture
|
||||||
|
|
||||||
|
1. **Unified Data Model**: All requests in one table, easy to query
|
||||||
|
2. **Automatic Inclusion**: My Requests/Open Requests show all types automatically
|
||||||
|
3. **Extensibility**: Add new templates without modifying existing code
|
||||||
|
4. **Type Safety**: Template-specific data in separate tables
|
||||||
|
5. **Flexibility**: Support both system templates and admin-created templates
|
||||||
|
6. **Backward Compatible**: Existing non-templatized requests continue to work
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
1. **Phase 1**: Add `workflow_type` column, set default to 'NON_TEMPLATIZED' for existing requests
|
||||||
|
2. **Phase 2**: Create `dealer_claim_details` table and models
|
||||||
|
3. **Phase 3**: Update claim management creation flow to use new structure
|
||||||
|
4. **Phase 4**: Update request detail endpoints to be template-aware
|
||||||
|
5. **Phase 5**: Frontend updates (if needed) for template-specific rendering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- **All requests** use `workflow_requests` table
|
||||||
|
- **Template identification** via `workflow_type` field
|
||||||
|
- **Template-specific data** in extension tables (e.g., `dealer_claim_details`)
|
||||||
|
- **Unified views** automatically include all types
|
||||||
|
- **Future templates** can be added by admins without code changes
|
||||||
|
- **Existing functionality** remains unchanged
|
||||||
|
|
||||||
|
This architecture ensures that:
|
||||||
|
- ✅ Claim Management requests appear in My Requests/Open Requests
|
||||||
|
- ✅ Non-templatized requests continue to work
|
||||||
|
- ✅ Future templates can be added easily
|
||||||
|
- ✅ No code duplication
|
||||||
|
- ✅ Single source of truth for all requests
|
||||||
|
|
||||||
78
docs/IMPLEMENTATION_PROGRESS.md
Normal file
78
docs/IMPLEMENTATION_PROGRESS.md
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
# Dealer Claim Management - Implementation Progress
|
||||||
|
|
||||||
|
## ✅ Completed
|
||||||
|
|
||||||
|
### 1. Database Migrations
|
||||||
|
- ✅ `20251210-add-workflow-type-support.ts` - Adds `workflow_type` and `template_id` to `workflow_requests`
|
||||||
|
- ✅ `20251210-enhance-workflow-templates.ts` - Enhances `workflow_templates` with form configuration fields
|
||||||
|
- ✅ `20251210-create-dealer-claim-tables.ts` - Creates dealer claim related tables:
|
||||||
|
- `dealer_claim_details` - Main claim information
|
||||||
|
- `dealer_proposal_details` - Step 1: Dealer proposal submission
|
||||||
|
- `dealer_completion_details` - Step 5: Dealer completion documents
|
||||||
|
|
||||||
|
### 2. Models
|
||||||
|
- ✅ Updated `WorkflowRequest` model with `workflowType` and `templateId` fields
|
||||||
|
- ✅ Created `DealerClaimDetails` model
|
||||||
|
- ✅ Created `DealerProposalDetails` model
|
||||||
|
- ✅ Created `DealerCompletionDetails` model
|
||||||
|
|
||||||
|
### 3. Services
|
||||||
|
- ✅ Created `TemplateFieldResolver` service for dynamic user field references
|
||||||
|
|
||||||
|
## 🚧 In Progress
|
||||||
|
|
||||||
|
### 4. Services (Next Steps)
|
||||||
|
- ⏳ Create `EnhancedTemplateService` - Main service for template operations
|
||||||
|
- ⏳ Create `DealerClaimService` - Claim-specific business logic
|
||||||
|
|
||||||
|
### 5. Controllers & Routes
|
||||||
|
- ⏳ Create `DealerClaimController` - API endpoints for claim management
|
||||||
|
- ⏳ Create routes for dealer claim operations
|
||||||
|
- ⏳ Create template management endpoints
|
||||||
|
|
||||||
|
## 📋 Next Steps
|
||||||
|
|
||||||
|
1. **Create EnhancedTemplateService**
|
||||||
|
- Get form configuration with resolved user references
|
||||||
|
- Save step data
|
||||||
|
- Validate form data
|
||||||
|
|
||||||
|
2. **Create DealerClaimService**
|
||||||
|
- Create claim request
|
||||||
|
- Handle 8-step workflow transitions
|
||||||
|
- Manage proposal and completion submissions
|
||||||
|
|
||||||
|
3. **Create Controllers**
|
||||||
|
- POST `/api/v1/dealer-claims` - Create claim request
|
||||||
|
- GET `/api/v1/dealer-claims/:requestId` - Get claim details
|
||||||
|
- POST `/api/v1/dealer-claims/:requestId/proposal` - Submit proposal (Step 1)
|
||||||
|
- POST `/api/v1/dealer-claims/:requestId/completion` - Submit completion (Step 5)
|
||||||
|
- GET `/api/v1/templates/:templateId/form-config` - Get form configuration
|
||||||
|
|
||||||
|
4. **Integration Services**
|
||||||
|
- SAP integration for IO validation and budget blocking
|
||||||
|
- DMS integration for e-invoice and credit note generation
|
||||||
|
|
||||||
|
## 📝 Notes
|
||||||
|
|
||||||
|
- All migrations are ready to run
|
||||||
|
- Models are created with proper associations
|
||||||
|
- Template field resolver supports dynamic user references
|
||||||
|
- System is designed to be extensible for future templates
|
||||||
|
|
||||||
|
## 🔄 Running Migrations
|
||||||
|
|
||||||
|
To apply the migrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Re_Backend
|
||||||
|
npm run migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Or run individually:
|
||||||
|
```bash
|
||||||
|
npx ts-node src/scripts/run-migration.ts 20251210-add-workflow-type-support
|
||||||
|
npx ts-node src/scripts/run-migration.ts 20251210-enhance-workflow-templates
|
||||||
|
npx ts-node src/scripts/run-migration.ts 20251210-create-dealer-claim-tables
|
||||||
|
```
|
||||||
|
|
||||||
159
docs/IMPLEMENTATION_SUMMARY.md
Normal file
159
docs/IMPLEMENTATION_SUMMARY.md
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
# Dealer Claim Management - Implementation Summary
|
||||||
|
|
||||||
|
## ✅ Completed Implementation
|
||||||
|
|
||||||
|
### 1. Database Migrations (4 files)
|
||||||
|
- ✅ `20251210-add-workflow-type-support.ts` - Adds `workflow_type` and `template_id` to `workflow_requests`
|
||||||
|
- ✅ `20251210-enhance-workflow-templates.ts` - Enhances `workflow_templates` with form configuration
|
||||||
|
- ✅ `20251210-add-template-id-foreign-key.ts` - Adds FK constraint for `template_id`
|
||||||
|
- ✅ `20251210-create-dealer-claim-tables.ts` - Creates dealer claim tables:
|
||||||
|
- `dealer_claim_details` - Main claim information
|
||||||
|
- `dealer_proposal_details` - Step 1: Dealer proposal
|
||||||
|
- `dealer_completion_details` - Step 5: Completion documents
|
||||||
|
|
||||||
|
### 2. Models (5 files)
|
||||||
|
- ✅ Updated `WorkflowRequest` - Added `workflowType` and `templateId` fields
|
||||||
|
- ✅ Created `DealerClaimDetails` - Main claim information model
|
||||||
|
- ✅ Created `DealerProposalDetails` - Proposal submission model
|
||||||
|
- ✅ Created `DealerCompletionDetails` - Completion documents model
|
||||||
|
- ✅ Created `WorkflowTemplate` - Template configuration model
|
||||||
|
|
||||||
|
### 3. Services (3 files)
|
||||||
|
- ✅ Created `TemplateFieldResolver` - Resolves dynamic user field references
|
||||||
|
- ✅ Created `EnhancedTemplateService` - Template form management
|
||||||
|
- ✅ Created `DealerClaimService` - Claim-specific business logic:
|
||||||
|
- `createClaimRequest()` - Create new claim with 8-step workflow
|
||||||
|
- `getClaimDetails()` - Get complete claim information
|
||||||
|
- `submitDealerProposal()` - Step 1: Dealer proposal submission
|
||||||
|
- `submitCompletionDocuments()` - Step 5: Completion submission
|
||||||
|
- `updateIODetails()` - Step 3: IO budget blocking
|
||||||
|
- `updateEInvoiceDetails()` - Step 7: E-Invoice generation
|
||||||
|
- `updateCreditNoteDetails()` - Step 8: Credit note issuance
|
||||||
|
|
||||||
|
### 4. Controllers & Routes (2 files)
|
||||||
|
- ✅ Created `DealerClaimController` - API endpoints for claim operations
|
||||||
|
- ✅ Created `dealerClaim.routes.ts` - Route definitions
|
||||||
|
- ✅ Registered routes in `routes/index.ts`
|
||||||
|
|
||||||
|
### 5. Frontend Utilities (1 file)
|
||||||
|
- ✅ Created `claimRequestUtils.ts` - Utility functions for detecting claim requests
|
||||||
|
|
||||||
|
## 📋 API Endpoints Created
|
||||||
|
|
||||||
|
### Dealer Claim Management
|
||||||
|
- `POST /api/v1/dealer-claims` - Create claim request
|
||||||
|
- `GET /api/v1/dealer-claims/:requestId` - Get claim details
|
||||||
|
- `POST /api/v1/dealer-claims/:requestId/proposal` - Submit dealer proposal (Step 1)
|
||||||
|
- `POST /api/v1/dealer-claims/:requestId/completion` - Submit completion (Step 5)
|
||||||
|
- `PUT /api/v1/dealer-claims/:requestId/io` - Update IO details (Step 3)
|
||||||
|
- `PUT /api/v1/dealer-claims/:requestId/e-invoice` - Update e-invoice (Step 7)
|
||||||
|
- `PUT /api/v1/dealer-claims/:requestId/credit-note` - Update credit note (Step 8)
|
||||||
|
|
||||||
|
## 🔄 8-Step Workflow Implementation
|
||||||
|
|
||||||
|
The system automatically creates 8 approval levels:
|
||||||
|
|
||||||
|
1. **Dealer Proposal Submission** (72h) - Dealer submits proposal
|
||||||
|
2. **Requestor Evaluation** (48h) - Initiator reviews and confirms
|
||||||
|
3. **Department Lead Approval** (72h) - Dept lead approves and blocks IO
|
||||||
|
4. **Activity Creation** (1h, Auto) - System creates activity record
|
||||||
|
5. **Dealer Completion Documents** (120h) - Dealer submits completion docs
|
||||||
|
6. **Requestor Claim Approval** (48h) - Initiator approves claim
|
||||||
|
7. **E-Invoice Generation** (1h, Auto) - System generates e-invoice via DMS
|
||||||
|
8. **Credit Note Confirmation** (48h) - Finance confirms credit note
|
||||||
|
|
||||||
|
## 🎯 Key Features
|
||||||
|
|
||||||
|
1. **Unified Request System**
|
||||||
|
- All requests use same `workflow_requests` table
|
||||||
|
- Identified by `workflowType: 'CLAIM_MANAGEMENT'`
|
||||||
|
- Automatically appears in "My Requests" and "Open Requests"
|
||||||
|
|
||||||
|
2. **Template-Specific Data Storage**
|
||||||
|
- Claim data stored in extension tables
|
||||||
|
- Linked via `request_id` foreign key
|
||||||
|
- Supports future templates with their own tables
|
||||||
|
|
||||||
|
3. **Dynamic User References**
|
||||||
|
- Auto-populate fields from initiator, dealer, approvers
|
||||||
|
- Supports team lead, department lead references
|
||||||
|
- Configurable per template
|
||||||
|
|
||||||
|
4. **File Upload Integration**
|
||||||
|
- Uses GCS with local fallback
|
||||||
|
- Organized by request number and file type
|
||||||
|
- Supports proposal documents and completion files
|
||||||
|
|
||||||
|
## 📝 Next Steps
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
1. ⏳ Add SAP integration for IO validation and budget blocking
|
||||||
|
2. ⏳ Add DMS integration for e-invoice and credit note generation
|
||||||
|
3. ⏳ Create template management API endpoints
|
||||||
|
4. ⏳ Add validation for dealer codes (SAP integration)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
1. ⏳ Create `claimDataMapper.ts` utility functions
|
||||||
|
2. ⏳ Update `RequestDetail.tsx` to conditionally render claim components
|
||||||
|
3. ⏳ Update API services to include `workflowType`
|
||||||
|
4. ⏳ Create `dealerClaimApi.ts` service
|
||||||
|
5. ⏳ Update request cards to show workflow type
|
||||||
|
|
||||||
|
## 🚀 Running the Implementation
|
||||||
|
|
||||||
|
### 1. Run Migrations
|
||||||
|
```bash
|
||||||
|
cd Re_Backend
|
||||||
|
npm run migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Test API Endpoints
|
||||||
|
```bash
|
||||||
|
# Create claim request
|
||||||
|
POST /api/v1/dealer-claims
|
||||||
|
{
|
||||||
|
"activityName": "Diwali Campaign",
|
||||||
|
"activityType": "Marketing Activity",
|
||||||
|
"dealerCode": "RE-MH-001",
|
||||||
|
"dealerName": "Royal Motors Mumbai",
|
||||||
|
"location": "Mumbai",
|
||||||
|
"requestDescription": "Marketing campaign details..."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Submit proposal
|
||||||
|
POST /api/v1/dealer-claims/:requestId/proposal
|
||||||
|
FormData with proposalDocument file and JSON data
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Database Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
workflow_requests (common)
|
||||||
|
├── workflow_type: 'CLAIM_MANAGEMENT'
|
||||||
|
└── template_id: (nullable)
|
||||||
|
|
||||||
|
dealer_claim_details (claim-specific)
|
||||||
|
└── request_id → workflow_requests
|
||||||
|
|
||||||
|
dealer_proposal_details (Step 1)
|
||||||
|
└── request_id → workflow_requests
|
||||||
|
|
||||||
|
dealer_completion_details (Step 5)
|
||||||
|
└── request_id → workflow_requests
|
||||||
|
|
||||||
|
approval_levels (8 steps)
|
||||||
|
└── request_id → workflow_requests
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Run migrations successfully
|
||||||
|
- [ ] Create claim request via API
|
||||||
|
- [ ] Submit dealer proposal
|
||||||
|
- [ ] Update IO details
|
||||||
|
- [ ] Submit completion documents
|
||||||
|
- [ ] Verify request appears in "My Requests"
|
||||||
|
- [ ] Verify request appears in "Open Requests"
|
||||||
|
- [ ] Test file uploads (GCS and local fallback)
|
||||||
|
- [ ] Test workflow progression through 8 steps
|
||||||
|
|
||||||
167
docs/OKTA_USERS_API_INTEGRATION.md
Normal file
167
docs/OKTA_USERS_API_INTEGRATION.md
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
# Okta Users API Integration
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The authentication service now uses the Okta Users API (`/api/v1/users/{userId}`) to fetch complete user profile information including manager, employeeID, designation, and other fields that may not be available in the standard OAuth2 userinfo endpoint.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Add the following environment variable to your `.env` file:
|
||||||
|
|
||||||
|
```env
|
||||||
|
OKTA_API_TOKEN=your_okta_api_token_here
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the SSWS (Server-Side Web Service) token for Okta API access. You can generate this token from your Okta Admin Console under **Security > API > Tokens**.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### 1. Primary Method: Okta Users API
|
||||||
|
|
||||||
|
When a user logs in for the first time:
|
||||||
|
|
||||||
|
1. The system exchanges the authorization code for tokens (OAuth2 flow)
|
||||||
|
2. Gets the `oktaSub` (subject identifier) from the userinfo endpoint
|
||||||
|
3. **Attempts to fetch full user profile from Users API** using:
|
||||||
|
- First: Email address (as shown in curl example)
|
||||||
|
- Fallback: oktaSub (user ID) if email lookup fails
|
||||||
|
4. Extracts complete user information including:
|
||||||
|
- `profile.employeeID` - Employee ID
|
||||||
|
- `profile.manager` - Manager name
|
||||||
|
- `profile.title` - Job title/designation
|
||||||
|
- `profile.department` - Department
|
||||||
|
- `profile.mobilePhone` - Phone number
|
||||||
|
- `profile.firstName`, `profile.lastName`, `profile.displayName`
|
||||||
|
- And other profile fields
|
||||||
|
|
||||||
|
### 2. Fallback Method: OAuth2 Userinfo Endpoint
|
||||||
|
|
||||||
|
If the Users API:
|
||||||
|
- Is not configured (missing `OKTA_API_TOKEN`)
|
||||||
|
- Returns an error (4xx/5xx)
|
||||||
|
- Fails for any reason
|
||||||
|
|
||||||
|
The system automatically falls back to the standard OAuth2 userinfo endpoint (`/oauth2/default/v1/userinfo`) which provides basic user information.
|
||||||
|
|
||||||
|
## API Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://{oktaDomain}/api/v1/users/{userId}
|
||||||
|
Authorization: SSWS {OKTA_API_TOKEN}
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Where `{userId}` can be:
|
||||||
|
- Email address (e.g., `testuser10@eichergroup.com`)
|
||||||
|
- Okta user ID (e.g., `00u1e1japegDV2DkP0h8`)
|
||||||
|
|
||||||
|
## Response Structure
|
||||||
|
|
||||||
|
The Users API returns a complete user object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "00u1e1japegDV2DkP0h8",
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"profile": {
|
||||||
|
"firstName": "Sanjay",
|
||||||
|
"lastName": "Sahu",
|
||||||
|
"manager": "Ezhilan subramanian",
|
||||||
|
"mobilePhone": "8826740087",
|
||||||
|
"displayName": "Sanjay Sahu",
|
||||||
|
"employeeID": "E09994",
|
||||||
|
"title": "Supports Business Applications (SAP) portfolio",
|
||||||
|
"department": "Deputy Manager - Digital & IT",
|
||||||
|
"login": "sanjaysahu@Royalenfield.com",
|
||||||
|
"email": "sanjaysahu@royalenfield.com"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Field Mapping
|
||||||
|
|
||||||
|
| Users API Field | Database Field | Notes |
|
||||||
|
|----------------|----------------|-------|
|
||||||
|
| `profile.employeeID` | `employeeId` | Employee ID from HR system |
|
||||||
|
| `profile.manager` | `manager` | Manager name |
|
||||||
|
| `profile.title` | `designation` | Job title/designation |
|
||||||
|
| `profile.department` | `department` | Department name |
|
||||||
|
| `profile.mobilePhone` | `phone` | Phone number |
|
||||||
|
| `profile.firstName` | `firstName` | First name |
|
||||||
|
| `profile.lastName` | `lastName` | Last name |
|
||||||
|
| `profile.displayName` | `displayName` | Display name |
|
||||||
|
| `profile.email` | `email` | Email address |
|
||||||
|
| `id` | `oktaSub` | Okta subject identifier |
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Complete User Profile**: Gets all available user information including manager, employeeID, and other custom attributes
|
||||||
|
2. **Automatic Fallback**: If Users API is unavailable, gracefully falls back to userinfo endpoint
|
||||||
|
3. **No Breaking Changes**: Existing functionality continues to work even without API token
|
||||||
|
4. **Better Data Quality**: Reduces missing user information (manager, employeeID, etc.)
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
The service logs:
|
||||||
|
- When Users API is used vs. userinfo fallback
|
||||||
|
- Which lookup method succeeded (email or oktaSub)
|
||||||
|
- Extracted fields (employeeId, manager, department, etc.)
|
||||||
|
- Any errors or warnings
|
||||||
|
|
||||||
|
Example log:
|
||||||
|
```
|
||||||
|
[AuthService] Fetching user from Okta Users API (using email)
|
||||||
|
[AuthService] Successfully fetched user from Okta Users API (using email)
|
||||||
|
[AuthService] Extracted user data from Okta Users API
|
||||||
|
- oktaSub: 00u1e1japegDV2DkP0h8
|
||||||
|
- email: testuser10@eichergroup.com
|
||||||
|
- employeeId: E09994
|
||||||
|
- hasManager: true
|
||||||
|
- hasDepartment: true
|
||||||
|
- hasDesignation: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test with curl
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --location 'https://dev-830839.oktapreview.com/api/v1/users/testuser10@eichergroup.com' \
|
||||||
|
--header 'Authorization: SSWS YOUR_OKTA_API_TOKEN' \
|
||||||
|
--header 'Accept: application/json'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test in Application
|
||||||
|
|
||||||
|
1. Set `OKTA_API_TOKEN` in `.env`
|
||||||
|
2. Log in with a user
|
||||||
|
3. Check logs to see if Users API was used
|
||||||
|
4. Verify user record in database has complete information (manager, employeeID, etc.)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Users API Not Being Used
|
||||||
|
|
||||||
|
- Check if `OKTA_API_TOKEN` is set in `.env`
|
||||||
|
- Check logs for warnings about missing API token
|
||||||
|
- Verify API token has correct permissions in Okta
|
||||||
|
|
||||||
|
### Users API Returns 404
|
||||||
|
|
||||||
|
- User may not exist in Okta
|
||||||
|
- Email format may be incorrect
|
||||||
|
- Try using oktaSub (user ID) instead
|
||||||
|
|
||||||
|
### Missing Fields in Database
|
||||||
|
|
||||||
|
- Check if fields exist in Okta user profile
|
||||||
|
- Verify field mapping in `extractUserDataFromUsersAPI` method
|
||||||
|
- Check logs to see which fields were extracted
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- **API Token Security**: Store `OKTA_API_TOKEN` securely, never commit to version control
|
||||||
|
- **Token Permissions**: Ensure API token has read access to user profiles
|
||||||
|
- **Rate Limiting**: Be aware of Okta API rate limits when fetching user data
|
||||||
|
|
||||||
@ -17,7 +17,8 @@
|
|||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"setup": "ts-node -r tsconfig-paths/register src/scripts/auto-setup.ts",
|
"setup": "ts-node -r tsconfig-paths/register src/scripts/auto-setup.ts",
|
||||||
"migrate": "ts-node -r tsconfig-paths/register src/scripts/migrate.ts",
|
"migrate": "ts-node -r tsconfig-paths/register src/scripts/migrate.ts",
|
||||||
"seed:config": "ts-node -r tsconfig-paths/register src/scripts/seed-admin-config.ts"
|
"seed:config": "ts-node -r tsconfig-paths/register src/scripts/seed-admin-config.ts",
|
||||||
|
"seed:dealers": "ts-node -r tsconfig-paths/register src/scripts/seed-dealers.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.68.0",
|
"@anthropic-ai/sdk": "^0.68.0",
|
||||||
|
|||||||
@ -11,6 +11,7 @@ const ssoConfig: SSOConfig = {
|
|||||||
oktaDomain: process.env.OKTA_DOMAIN || 'https://dev-830839.oktapreview.com',
|
oktaDomain: process.env.OKTA_DOMAIN || 'https://dev-830839.oktapreview.com',
|
||||||
oktaClientId: process.env.OKTA_CLIENT_ID || '',
|
oktaClientId: process.env.OKTA_CLIENT_ID || '',
|
||||||
oktaClientSecret: process.env.OKTA_CLIENT_SECRET || '',
|
oktaClientSecret: process.env.OKTA_CLIENT_SECRET || '',
|
||||||
|
oktaApiToken: process.env.OKTA_API_TOKEN || '', // SSWS token for Users API
|
||||||
};
|
};
|
||||||
|
|
||||||
export { ssoConfig };
|
export { ssoConfig };
|
||||||
|
|||||||
86
src/controllers/dealer.controller.ts
Normal file
86
src/controllers/dealer.controller.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import type { AuthenticatedRequest } from '../types/express';
|
||||||
|
import * as dealerService from '../services/dealer.service';
|
||||||
|
import { ResponseHandler } from '../utils/responseHandler';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
export class DealerController {
|
||||||
|
/**
|
||||||
|
* Get all dealers
|
||||||
|
* GET /api/v1/dealers
|
||||||
|
*/
|
||||||
|
async getAllDealers(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dealers = await dealerService.getAllDealers();
|
||||||
|
return ResponseHandler.success(res, dealers, 'Dealers fetched successfully');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[DealerController] Error fetching dealers:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to fetch dealers', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dealer by code
|
||||||
|
* GET /api/v1/dealers/code/:dealerCode
|
||||||
|
*/
|
||||||
|
async getDealerByCode(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { dealerCode } = req.params;
|
||||||
|
const dealer = await dealerService.getDealerByCode(dealerCode);
|
||||||
|
|
||||||
|
if (!dealer) {
|
||||||
|
return ResponseHandler.error(res, 'Dealer not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, dealer, 'Dealer fetched successfully');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[DealerController] Error fetching dealer by code:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to fetch dealer', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dealer by email
|
||||||
|
* GET /api/v1/dealers/email/:email
|
||||||
|
*/
|
||||||
|
async getDealerByEmail(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { email } = req.params;
|
||||||
|
const dealer = await dealerService.getDealerByEmail(email);
|
||||||
|
|
||||||
|
if (!dealer) {
|
||||||
|
return ResponseHandler.error(res, 'Dealer not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, dealer, 'Dealer fetched successfully');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[DealerController] Error fetching dealer by email:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to fetch dealer', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search dealers
|
||||||
|
* GET /api/v1/dealers/search?q=searchTerm
|
||||||
|
*/
|
||||||
|
async searchDealers(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { q } = req.query;
|
||||||
|
|
||||||
|
if (!q || typeof q !== 'string') {
|
||||||
|
return ResponseHandler.error(res, 'Search term is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dealers = await dealerService.searchDealers(q);
|
||||||
|
return ResponseHandler.success(res, dealers, 'Dealers searched successfully');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[DealerController] Error searching dealers:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to search dealers', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
418
src/controllers/dealerClaim.controller.ts
Normal file
418
src/controllers/dealerClaim.controller.ts
Normal file
@ -0,0 +1,418 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import type { AuthenticatedRequest } from '../types/express';
|
||||||
|
import { DealerClaimService } from '../services/dealerClaim.service';
|
||||||
|
import { ResponseHandler } from '../utils/responseHandler';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
import { gcsStorageService } from '../services/gcsStorage.service';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export class DealerClaimController {
|
||||||
|
private dealerClaimService = new DealerClaimService();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new dealer claim request
|
||||||
|
* POST /api/v1/dealer-claims
|
||||||
|
*/
|
||||||
|
async createClaimRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!userId) {
|
||||||
|
return ResponseHandler.error(res, 'Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
activityName,
|
||||||
|
activityType,
|
||||||
|
dealerCode,
|
||||||
|
dealerName,
|
||||||
|
dealerEmail,
|
||||||
|
dealerPhone,
|
||||||
|
dealerAddress,
|
||||||
|
activityDate,
|
||||||
|
location,
|
||||||
|
requestDescription,
|
||||||
|
periodStartDate,
|
||||||
|
periodEndDate,
|
||||||
|
estimatedBudget,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!activityName || !activityType || !dealerCode || !dealerName || !location || !requestDescription) {
|
||||||
|
return ResponseHandler.error(res, 'Missing required fields', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimRequest = await this.dealerClaimService.createClaimRequest(userId, {
|
||||||
|
activityName,
|
||||||
|
activityType,
|
||||||
|
dealerCode,
|
||||||
|
dealerName,
|
||||||
|
dealerEmail,
|
||||||
|
dealerPhone,
|
||||||
|
dealerAddress,
|
||||||
|
activityDate: activityDate ? new Date(activityDate) : undefined,
|
||||||
|
location,
|
||||||
|
requestDescription,
|
||||||
|
periodStartDate: periodStartDate ? new Date(periodStartDate) : undefined,
|
||||||
|
periodEndDate: periodEndDate ? new Date(periodEndDate) : undefined,
|
||||||
|
estimatedBudget: estimatedBudget ? parseFloat(estimatedBudget) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, {
|
||||||
|
request: claimRequest,
|
||||||
|
message: 'Claim request created successfully'
|
||||||
|
}, 'Claim request created');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[DealerClaimController] Error creating claim request:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to create claim request', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get claim details
|
||||||
|
* GET /api/v1/dealer-claims/:requestId
|
||||||
|
* Accepts either UUID or requestNumber
|
||||||
|
*/
|
||||||
|
async getClaimDetails(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const identifier = req.params.requestId; // Can be UUID or requestNumber
|
||||||
|
|
||||||
|
// Find workflow to get actual UUID
|
||||||
|
const workflow = await this.findWorkflowByIdentifier(identifier);
|
||||||
|
if (!workflow) {
|
||||||
|
return ResponseHandler.error(res, 'Workflow request not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = (workflow as any).requestId || (workflow as any).request_id;
|
||||||
|
if (!requestId) {
|
||||||
|
return ResponseHandler.error(res, 'Invalid workflow request', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimDetails = await this.dealerClaimService.getClaimDetails(requestId);
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, claimDetails, 'Claim details fetched');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[DealerClaimController] Error getting claim details:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to fetch claim details', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to find workflow by either requestId (UUID) or requestNumber
|
||||||
|
*/
|
||||||
|
private async findWorkflowByIdentifier(identifier: string): Promise<any> {
|
||||||
|
const isUuid = (id: string): boolean => {
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
return uuidRegex.test(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { WorkflowRequest } = await import('../models/WorkflowRequest');
|
||||||
|
if (isUuid(identifier)) {
|
||||||
|
return await WorkflowRequest.findByPk(identifier);
|
||||||
|
} else {
|
||||||
|
return await WorkflowRequest.findOne({ where: { requestNumber: identifier } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit dealer proposal (Step 1)
|
||||||
|
* POST /api/v1/dealer-claims/:requestId/proposal
|
||||||
|
* Accepts either UUID or requestNumber
|
||||||
|
*/
|
||||||
|
async submitProposal(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const identifier = req.params.requestId; // Can be UUID or requestNumber
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
const {
|
||||||
|
costBreakup,
|
||||||
|
totalEstimatedBudget,
|
||||||
|
timelineMode,
|
||||||
|
expectedCompletionDate,
|
||||||
|
expectedCompletionDays,
|
||||||
|
dealerComments,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Find workflow by identifier (UUID or requestNumber)
|
||||||
|
const workflow = await this.findWorkflowByIdentifier(identifier);
|
||||||
|
if (!workflow) {
|
||||||
|
return ResponseHandler.error(res, 'Workflow request not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get actual UUID and requestNumber
|
||||||
|
const requestId = (workflow as any).requestId || (workflow as any).request_id;
|
||||||
|
const requestNumber = (workflow as any).requestNumber || (workflow as any).request_number;
|
||||||
|
|
||||||
|
if (!requestId) {
|
||||||
|
return ResponseHandler.error(res, 'Invalid workflow request', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse costBreakup - it comes as JSON string from FormData
|
||||||
|
let parsedCostBreakup: any[] = [];
|
||||||
|
if (costBreakup) {
|
||||||
|
if (typeof costBreakup === 'string') {
|
||||||
|
try {
|
||||||
|
parsedCostBreakup = JSON.parse(costBreakup);
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.error('[DealerClaimController] Failed to parse costBreakup JSON:', parseError);
|
||||||
|
return ResponseHandler.error(res, 'Invalid costBreakup format. Expected JSON array.', 400);
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(costBreakup)) {
|
||||||
|
parsedCostBreakup = costBreakup;
|
||||||
|
} else {
|
||||||
|
logger.warn('[DealerClaimController] costBreakup is not a string or array:', typeof costBreakup);
|
||||||
|
parsedCostBreakup = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate costBreakup is an array
|
||||||
|
if (!Array.isArray(parsedCostBreakup)) {
|
||||||
|
logger.error('[DealerClaimController] costBreakup is not an array after parsing:', parsedCostBreakup);
|
||||||
|
return ResponseHandler.error(res, 'costBreakup must be an array of cost items', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each cost item has required fields
|
||||||
|
for (const item of parsedCostBreakup) {
|
||||||
|
if (!item.description || item.amount === undefined || item.amount === null) {
|
||||||
|
return ResponseHandler.error(res, 'Each cost item must have description and amount', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle file upload if present
|
||||||
|
let proposalDocumentPath: string | undefined;
|
||||||
|
let proposalDocumentUrl: string | undefined;
|
||||||
|
|
||||||
|
if (req.file) {
|
||||||
|
const file = req.file;
|
||||||
|
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
|
||||||
|
|
||||||
|
const uploadResult = await gcsStorageService.uploadFileWithFallback({
|
||||||
|
buffer: fileBuffer,
|
||||||
|
originalName: file.originalname,
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
requestNumber: requestNumber || 'UNKNOWN',
|
||||||
|
fileType: 'documents'
|
||||||
|
});
|
||||||
|
|
||||||
|
proposalDocumentPath = uploadResult.filePath;
|
||||||
|
proposalDocumentUrl = uploadResult.storageUrl;
|
||||||
|
|
||||||
|
// Cleanup local file if exists
|
||||||
|
if (file.path && fs.existsSync(file.path)) {
|
||||||
|
fs.unlinkSync(file.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use actual UUID for service call with parsed costBreakup array
|
||||||
|
await this.dealerClaimService.submitDealerProposal(requestId, {
|
||||||
|
proposalDocumentPath,
|
||||||
|
proposalDocumentUrl,
|
||||||
|
costBreakup: parsedCostBreakup, // Use parsed array
|
||||||
|
totalEstimatedBudget: totalEstimatedBudget ? parseFloat(totalEstimatedBudget) : 0,
|
||||||
|
timelineMode: timelineMode || 'date',
|
||||||
|
expectedCompletionDate: expectedCompletionDate ? new Date(expectedCompletionDate) : undefined,
|
||||||
|
expectedCompletionDays: expectedCompletionDays ? parseInt(expectedCompletionDays) : undefined,
|
||||||
|
dealerComments: dealerComments || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, { message: 'Proposal submitted successfully' }, 'Proposal submitted');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[DealerClaimController] Error submitting proposal:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to submit proposal', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit completion documents (Step 5)
|
||||||
|
* POST /api/v1/dealer-claims/:requestId/completion
|
||||||
|
* Accepts either UUID or requestNumber
|
||||||
|
*/
|
||||||
|
async submitCompletion(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const identifier = req.params.requestId; // Can be UUID or requestNumber
|
||||||
|
const {
|
||||||
|
activityCompletionDate,
|
||||||
|
numberOfParticipants,
|
||||||
|
closedExpenses,
|
||||||
|
totalClosedExpenses,
|
||||||
|
completionDocuments,
|
||||||
|
activityPhotos,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Find workflow to get actual UUID
|
||||||
|
const workflow = await this.findWorkflowByIdentifier(identifier);
|
||||||
|
if (!workflow) {
|
||||||
|
return ResponseHandler.error(res, 'Workflow request not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = (workflow as any).requestId || (workflow as any).request_id;
|
||||||
|
if (!requestId) {
|
||||||
|
return ResponseHandler.error(res, 'Invalid workflow request', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activityCompletionDate) {
|
||||||
|
return ResponseHandler.error(res, 'Activity completion date is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dealerClaimService.submitCompletionDocuments(requestId, {
|
||||||
|
activityCompletionDate: new Date(activityCompletionDate),
|
||||||
|
numberOfParticipants: numberOfParticipants ? parseInt(numberOfParticipants) : undefined,
|
||||||
|
closedExpenses: closedExpenses || [],
|
||||||
|
totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0,
|
||||||
|
completionDocuments: completionDocuments || [],
|
||||||
|
activityPhotos: activityPhotos || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, { message: 'Completion documents submitted successfully' }, 'Completion submitted');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[DealerClaimController] Error submitting completion:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to submit completion documents', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update IO details (Step 3 - Department Lead)
|
||||||
|
* PUT /api/v1/dealer-claims/:requestId/io
|
||||||
|
* Accepts either UUID or requestNumber
|
||||||
|
*/
|
||||||
|
async updateIODetails(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const identifier = req.params.requestId; // Can be UUID or requestNumber
|
||||||
|
const {
|
||||||
|
ioNumber,
|
||||||
|
availableBalance,
|
||||||
|
blockedAmount,
|
||||||
|
remainingBalance,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Find workflow to get actual UUID
|
||||||
|
const workflow = await this.findWorkflowByIdentifier(identifier);
|
||||||
|
if (!workflow) {
|
||||||
|
return ResponseHandler.error(res, 'Workflow request not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = (workflow as any).requestId || (workflow as any).request_id;
|
||||||
|
if (!requestId) {
|
||||||
|
return ResponseHandler.error(res, 'Invalid workflow request', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ioNumber || availableBalance === undefined || blockedAmount === undefined) {
|
||||||
|
return ResponseHandler.error(res, 'Missing required IO fields', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dealerClaimService.updateIODetails(requestId, {
|
||||||
|
ioNumber,
|
||||||
|
availableBalance: parseFloat(availableBalance),
|
||||||
|
blockedAmount: parseFloat(blockedAmount),
|
||||||
|
remainingBalance: remainingBalance ? parseFloat(remainingBalance) : parseFloat(availableBalance) - parseFloat(blockedAmount),
|
||||||
|
});
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, { message: 'IO details updated successfully' }, 'IO details updated');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[DealerClaimController] Error updating IO details:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to update IO details', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update e-invoice details (Step 7)
|
||||||
|
* PUT /api/v1/dealer-claims/:requestId/e-invoice
|
||||||
|
* If eInvoiceNumber is not provided, will auto-generate via DMS
|
||||||
|
* Accepts either UUID or requestNumber
|
||||||
|
*/
|
||||||
|
async updateEInvoice(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const identifier = req.params.requestId; // Can be UUID or requestNumber
|
||||||
|
const {
|
||||||
|
eInvoiceNumber,
|
||||||
|
eInvoiceDate,
|
||||||
|
dmsNumber,
|
||||||
|
amount,
|
||||||
|
description,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Find workflow to get actual UUID
|
||||||
|
const workflow = await this.findWorkflowByIdentifier(identifier);
|
||||||
|
if (!workflow) {
|
||||||
|
return ResponseHandler.error(res, 'Workflow request not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = (workflow as any).requestId || (workflow as any).request_id;
|
||||||
|
if (!requestId) {
|
||||||
|
return ResponseHandler.error(res, 'Invalid workflow request', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If eInvoiceNumber provided, use manual entry; otherwise auto-generate
|
||||||
|
const invoiceData = eInvoiceNumber ? {
|
||||||
|
eInvoiceNumber,
|
||||||
|
eInvoiceDate: eInvoiceDate ? new Date(eInvoiceDate) : new Date(),
|
||||||
|
dmsNumber,
|
||||||
|
} : {
|
||||||
|
amount: amount ? parseFloat(amount) : undefined,
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.dealerClaimService.updateEInvoiceDetails(requestId, invoiceData);
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, { message: 'E-Invoice details updated successfully' }, 'E-Invoice updated');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[DealerClaimController] Error updating e-invoice:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to update e-invoice details', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update credit note details (Step 8)
|
||||||
|
* PUT /api/v1/dealer-claims/:requestId/credit-note
|
||||||
|
* If creditNoteNumber is not provided, will auto-generate via DMS
|
||||||
|
* Accepts either UUID or requestNumber
|
||||||
|
*/
|
||||||
|
async updateCreditNote(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const identifier = req.params.requestId; // Can be UUID or requestNumber
|
||||||
|
const {
|
||||||
|
creditNoteNumber,
|
||||||
|
creditNoteDate,
|
||||||
|
creditNoteAmount,
|
||||||
|
reason,
|
||||||
|
description,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Find workflow to get actual UUID
|
||||||
|
const workflow = await this.findWorkflowByIdentifier(identifier);
|
||||||
|
if (!workflow) {
|
||||||
|
return ResponseHandler.error(res, 'Workflow request not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = (workflow as any).requestId || (workflow as any).request_id;
|
||||||
|
if (!requestId) {
|
||||||
|
return ResponseHandler.error(res, 'Invalid workflow request', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If creditNoteNumber provided, use manual entry; otherwise auto-generate
|
||||||
|
const creditNoteData = creditNoteNumber ? {
|
||||||
|
creditNoteNumber,
|
||||||
|
creditNoteDate: creditNoteDate ? new Date(creditNoteDate) : new Date(),
|
||||||
|
creditNoteAmount: creditNoteAmount ? parseFloat(creditNoteAmount) : undefined,
|
||||||
|
} : {
|
||||||
|
creditNoteAmount: creditNoteAmount ? parseFloat(creditNoteAmount) : undefined,
|
||||||
|
reason,
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.dealerClaimService.updateCreditNoteDetails(requestId, creditNoteData);
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, { message: 'Credit note details updated successfully' }, 'Credit note updated');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[DealerClaimController] Error updating credit note:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to update credit note details', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -22,20 +22,57 @@ export class DocumentController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestId = String((req.body?.requestId || '').trim());
|
// Extract requestId from body (multer should parse form fields)
|
||||||
if (!requestId) {
|
// Try both req.body and req.body.requestId for compatibility
|
||||||
|
const identifier = String((req.body?.requestId || req.body?.request_id || '').trim());
|
||||||
|
if (!identifier || identifier === 'undefined' || identifier === 'null') {
|
||||||
|
logWithContext('error', 'RequestId missing or invalid in document upload', {
|
||||||
|
body: req.body,
|
||||||
|
bodyKeys: Object.keys(req.body || {}),
|
||||||
|
userId: req.user?.userId
|
||||||
|
});
|
||||||
ResponseHandler.error(res, 'requestId is required', 400);
|
ResponseHandler.error(res, 'requestId is required', 400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get workflow request to retrieve requestNumber
|
// Helper to check if identifier is UUID
|
||||||
const workflowRequest = await WorkflowRequest.findOne({ where: { requestId } });
|
const isUuid = (id: string): boolean => {
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
return uuidRegex.test(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get workflow request - handle both UUID (requestId) and requestNumber
|
||||||
|
let workflowRequest: WorkflowRequest | null = null;
|
||||||
|
if (isUuid(identifier)) {
|
||||||
|
workflowRequest = await WorkflowRequest.findByPk(identifier);
|
||||||
|
} else {
|
||||||
|
workflowRequest = await WorkflowRequest.findOne({ where: { requestNumber: identifier } });
|
||||||
|
}
|
||||||
|
|
||||||
if (!workflowRequest) {
|
if (!workflowRequest) {
|
||||||
|
logWithContext('error', 'Workflow request not found for document upload', {
|
||||||
|
identifier,
|
||||||
|
isUuid: isUuid(identifier),
|
||||||
|
userId: req.user?.userId
|
||||||
|
});
|
||||||
ResponseHandler.error(res, 'Workflow request not found', 404);
|
ResponseHandler.error(res, 'Workflow request not found', 404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the actual requestId (UUID) and requestNumber
|
||||||
|
const requestId = (workflowRequest as any).requestId || (workflowRequest as any).request_id;
|
||||||
const requestNumber = (workflowRequest as any).requestNumber || (workflowRequest as any).request_number;
|
const requestNumber = (workflowRequest as any).requestNumber || (workflowRequest as any).request_number;
|
||||||
|
|
||||||
|
if (!requestNumber) {
|
||||||
|
logWithContext('error', 'Request number not found for workflow', {
|
||||||
|
requestId,
|
||||||
|
workflowRequest: JSON.stringify(workflowRequest.toJSON()),
|
||||||
|
userId: req.user?.userId
|
||||||
|
});
|
||||||
|
ResponseHandler.error(res, 'Request number not found for workflow', 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const file = (req as any).file as Express.Multer.File | undefined;
|
const file = (req as any).file as Express.Multer.File | undefined;
|
||||||
if (!file) {
|
if (!file) {
|
||||||
ResponseHandler.error(res, 'No file uploaded', 400);
|
ResponseHandler.error(res, 'No file uploaded', 400);
|
||||||
@ -153,10 +190,21 @@ export class DocumentController {
|
|||||||
ResponseHandler.success(res, doc, 'File uploaded', 201);
|
ResponseHandler.success(res, doc, 'File uploaded', 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||||
logWithContext('error', 'Document upload failed', {
|
logWithContext('error', 'Document upload failed', {
|
||||||
userId: req.user?.userId,
|
userId: req.user?.userId,
|
||||||
requestId: req.body?.requestId,
|
requestId: req.body?.requestId || req.body?.request_id,
|
||||||
error,
|
body: req.body,
|
||||||
|
bodyKeys: Object.keys(req.body || {}),
|
||||||
|
file: req.file ? {
|
||||||
|
originalname: req.file.originalname,
|
||||||
|
size: req.file.size,
|
||||||
|
mimetype: req.file.mimetype,
|
||||||
|
hasBuffer: !!req.file.buffer,
|
||||||
|
hasPath: !!req.file.path
|
||||||
|
} : 'No file',
|
||||||
|
error: message,
|
||||||
|
stack: errorStack
|
||||||
});
|
});
|
||||||
ResponseHandler.error(res, 'Upload failed', 500, message);
|
ResponseHandler.error(res, 'Upload failed', 500, message);
|
||||||
}
|
}
|
||||||
|
|||||||
192
src/controllers/template.controller.ts
Normal file
192
src/controllers/template.controller.ts
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import type { AuthenticatedRequest } from '../types/express';
|
||||||
|
import { TemplateService } from '../services/template.service';
|
||||||
|
import { ResponseHandler } from '../utils/responseHandler';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
export class TemplateController {
|
||||||
|
private templateService = new TemplateService();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new template
|
||||||
|
* POST /api/v1/templates
|
||||||
|
*/
|
||||||
|
async createTemplate(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!userId) {
|
||||||
|
return ResponseHandler.error(res, 'Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
templateName,
|
||||||
|
templateCode,
|
||||||
|
templateDescription,
|
||||||
|
templateCategory,
|
||||||
|
workflowType,
|
||||||
|
approvalLevelsConfig,
|
||||||
|
defaultTatHours,
|
||||||
|
formStepsConfig,
|
||||||
|
userFieldMappings,
|
||||||
|
dynamicApproverConfig,
|
||||||
|
isActive,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!templateName) {
|
||||||
|
return ResponseHandler.error(res, 'Template name is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await this.templateService.createTemplate(userId, {
|
||||||
|
templateName,
|
||||||
|
templateCode,
|
||||||
|
templateDescription,
|
||||||
|
templateCategory,
|
||||||
|
workflowType,
|
||||||
|
approvalLevelsConfig,
|
||||||
|
defaultTatHours: defaultTatHours ? parseFloat(defaultTatHours) : undefined,
|
||||||
|
formStepsConfig,
|
||||||
|
userFieldMappings,
|
||||||
|
dynamicApproverConfig,
|
||||||
|
isActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, template, 'Template created successfully', 201);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[TemplateController] Error creating template:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to create template', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get template by ID
|
||||||
|
* GET /api/v1/templates/:templateId
|
||||||
|
*/
|
||||||
|
async getTemplate(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { templateId } = req.params;
|
||||||
|
|
||||||
|
const template = await this.templateService.getTemplate(templateId);
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
return ResponseHandler.error(res, 'Template not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, template, 'Template fetched');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[TemplateController] Error getting template:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to fetch template', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List templates
|
||||||
|
* GET /api/v1/templates
|
||||||
|
*/
|
||||||
|
async listTemplates(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
category,
|
||||||
|
workflowType,
|
||||||
|
isActive,
|
||||||
|
isSystemTemplate,
|
||||||
|
search,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const filters: any = {};
|
||||||
|
if (category) filters.category = category as string;
|
||||||
|
if (workflowType) filters.workflowType = workflowType as string;
|
||||||
|
if (isActive !== undefined) filters.isActive = isActive === 'true';
|
||||||
|
if (isSystemTemplate !== undefined) filters.isSystemTemplate = isSystemTemplate === 'true';
|
||||||
|
if (search) filters.search = search as string;
|
||||||
|
|
||||||
|
const templates = await this.templateService.listTemplates(filters);
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, templates, 'Templates fetched');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[TemplateController] Error listing templates:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to fetch templates', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active templates (for workflow creation)
|
||||||
|
* GET /api/v1/templates/active
|
||||||
|
*/
|
||||||
|
async getActiveTemplates(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const templates = await this.templateService.getActiveTemplates();
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, templates, 'Active templates fetched');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[TemplateController] Error getting active templates:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to fetch active templates', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update template
|
||||||
|
* PUT /api/v1/templates/:templateId
|
||||||
|
*/
|
||||||
|
async updateTemplate(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { templateId } = req.params;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!userId) {
|
||||||
|
return ResponseHandler.error(res, 'Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
templateName,
|
||||||
|
templateDescription,
|
||||||
|
templateCategory,
|
||||||
|
approvalLevelsConfig,
|
||||||
|
defaultTatHours,
|
||||||
|
formStepsConfig,
|
||||||
|
userFieldMappings,
|
||||||
|
dynamicApproverConfig,
|
||||||
|
isActive,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const template = await this.templateService.updateTemplate(templateId, userId, {
|
||||||
|
templateName,
|
||||||
|
templateDescription,
|
||||||
|
templateCategory,
|
||||||
|
approvalLevelsConfig,
|
||||||
|
defaultTatHours: defaultTatHours ? parseFloat(defaultTatHours) : undefined,
|
||||||
|
formStepsConfig,
|
||||||
|
userFieldMappings,
|
||||||
|
dynamicApproverConfig,
|
||||||
|
isActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, template, 'Template updated successfully');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[TemplateController] Error updating template:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to update template', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete template
|
||||||
|
* DELETE /api/v1/templates/:templateId
|
||||||
|
*/
|
||||||
|
async deleteTemplate(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { templateId } = req.params;
|
||||||
|
|
||||||
|
await this.templateService.deleteTemplate(templateId);
|
||||||
|
|
||||||
|
return ResponseHandler.success(res, { message: 'Template deleted successfully' }, 'Template deleted');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[TemplateController] Error deleting template:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to delete template', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
54
src/migrations/20251210-add-template-id-foreign-key.ts
Normal file
54
src/migrations/20251210-add-template-id-foreign-key.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { QueryInterface } from 'sequelize';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add foreign key constraint for template_id after workflow_templates table exists
|
||||||
|
* This should run after both:
|
||||||
|
* - 20251210-enhance-workflow-templates (creates workflow_templates table)
|
||||||
|
* - 20251210-add-workflow-type-support (adds template_id column)
|
||||||
|
*/
|
||||||
|
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||||
|
// Check if workflow_templates table exists
|
||||||
|
const [tables] = await queryInterface.sequelize.query(`
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'workflow_templates';
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (tables.length > 0) {
|
||||||
|
// Check if foreign key already exists
|
||||||
|
const [constraints] = await queryInterface.sequelize.query(`
|
||||||
|
SELECT constraint_name
|
||||||
|
FROM information_schema.table_constraints
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'workflow_requests'
|
||||||
|
AND constraint_name = 'workflow_requests_template_id_fkey';
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (constraints.length === 0) {
|
||||||
|
// Add foreign key constraint
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE workflow_requests
|
||||||
|
ADD CONSTRAINT workflow_requests_template_id_fkey
|
||||||
|
FOREIGN KEY (template_id)
|
||||||
|
REFERENCES workflow_templates(template_id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||||
|
// Remove foreign key constraint if it exists
|
||||||
|
try {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE workflow_requests
|
||||||
|
DROP CONSTRAINT IF EXISTS workflow_requests_template_id_fkey;
|
||||||
|
`);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore if constraint doesn't exist
|
||||||
|
console.log('Note: Foreign key constraint may not exist');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
116
src/migrations/20251210-add-workflow-type-support.ts
Normal file
116
src/migrations/20251210-add-workflow-type-support.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { QueryInterface, DataTypes } from 'sequelize';
|
||||||
|
|
||||||
|
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Check if columns already exist (for idempotency and backward compatibility)
|
||||||
|
const tableDescription = await queryInterface.describeTable('workflow_requests');
|
||||||
|
|
||||||
|
// 1. Add workflow_type column to workflow_requests (only if it doesn't exist)
|
||||||
|
if (!tableDescription.workflow_type) {
|
||||||
|
try {
|
||||||
|
await queryInterface.addColumn('workflow_requests', 'workflow_type', {
|
||||||
|
type: DataTypes.STRING(50),
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 'NON_TEMPLATIZED'
|
||||||
|
});
|
||||||
|
console.log('✅ Added workflow_type column');
|
||||||
|
} catch (error: any) {
|
||||||
|
// Column might have been added manually, check if it exists now
|
||||||
|
const updatedDescription = await queryInterface.describeTable('workflow_requests');
|
||||||
|
if (!updatedDescription.workflow_type) {
|
||||||
|
throw error; // Re-throw if column still doesn't exist
|
||||||
|
}
|
||||||
|
console.log('Note: workflow_type column already exists (may have been added manually)');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Note: workflow_type column already exists, skipping');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Add template_id column (nullable, for admin templates)
|
||||||
|
// Note: Foreign key constraint will be added later if workflow_templates table exists
|
||||||
|
if (!tableDescription.template_id) {
|
||||||
|
try {
|
||||||
|
await queryInterface.addColumn('workflow_requests', 'template_id', {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true
|
||||||
|
});
|
||||||
|
console.log('✅ Added template_id column');
|
||||||
|
} catch (error: any) {
|
||||||
|
// Column might have been added manually, check if it exists now
|
||||||
|
const updatedDescription = await queryInterface.describeTable('workflow_requests');
|
||||||
|
if (!updatedDescription.template_id) {
|
||||||
|
throw error; // Re-throw if column still doesn't exist
|
||||||
|
}
|
||||||
|
console.log('Note: template_id column already exists (may have been added manually)');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Note: template_id column already exists, skipping');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get updated table description for index creation
|
||||||
|
const finalTableDescription = await queryInterface.describeTable('workflow_requests');
|
||||||
|
|
||||||
|
// 3. Create index for workflow_type (only if column exists)
|
||||||
|
if (finalTableDescription.workflow_type) {
|
||||||
|
try {
|
||||||
|
await queryInterface.addIndex('workflow_requests', ['workflow_type'], {
|
||||||
|
name: 'idx_workflow_requests_workflow_type'
|
||||||
|
});
|
||||||
|
console.log('✅ Created workflow_type index');
|
||||||
|
} catch (error: any) {
|
||||||
|
// Index might already exist, ignore error
|
||||||
|
if (error.message?.includes('already exists') || error.message?.includes('duplicate')) {
|
||||||
|
console.log('Note: workflow_type index already exists');
|
||||||
|
} else {
|
||||||
|
console.log('Note: Could not create workflow_type index:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create index for template_id (only if column exists)
|
||||||
|
if (finalTableDescription.template_id) {
|
||||||
|
try {
|
||||||
|
await queryInterface.addIndex('workflow_requests', ['template_id'], {
|
||||||
|
name: 'idx_workflow_requests_template_id'
|
||||||
|
});
|
||||||
|
console.log('✅ Created template_id index');
|
||||||
|
} catch (error: any) {
|
||||||
|
// Index might already exist, ignore error
|
||||||
|
if (error.message?.includes('already exists') || error.message?.includes('duplicate')) {
|
||||||
|
console.log('Note: template_id index already exists');
|
||||||
|
} else {
|
||||||
|
console.log('Note: Could not create template_id index:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Update existing records to have workflow_type (if any exist and column exists)
|
||||||
|
if (finalTableDescription.workflow_type) {
|
||||||
|
try {
|
||||||
|
const [result] = await queryInterface.sequelize.query(`
|
||||||
|
UPDATE workflow_requests
|
||||||
|
SET workflow_type = 'NON_TEMPLATIZED'
|
||||||
|
WHERE workflow_type IS NULL;
|
||||||
|
`);
|
||||||
|
console.log('✅ Updated existing records with workflow_type');
|
||||||
|
} catch (error: any) {
|
||||||
|
// Ignore if table is empty or other error
|
||||||
|
console.log('Note: Could not update existing records:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Migration error:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||||
|
// Remove indexes
|
||||||
|
await queryInterface.removeIndex('workflow_requests', 'idx_workflow_requests_template_id');
|
||||||
|
await queryInterface.removeIndex('workflow_requests', 'idx_workflow_requests_workflow_type');
|
||||||
|
|
||||||
|
// Remove columns
|
||||||
|
await queryInterface.removeColumn('workflow_requests', 'template_id');
|
||||||
|
await queryInterface.removeColumn('workflow_requests', 'workflow_type');
|
||||||
|
}
|
||||||
|
|
||||||
282
src/migrations/20251210-create-dealer-claim-tables.ts
Normal file
282
src/migrations/20251210-create-dealer-claim-tables.ts
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
import { QueryInterface, DataTypes } from 'sequelize';
|
||||||
|
|
||||||
|
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||||
|
// 1. Create dealer_claim_details table
|
||||||
|
await queryInterface.createTable('dealer_claim_details', {
|
||||||
|
claim_id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
primaryKey: true,
|
||||||
|
defaultValue: DataTypes.UUIDV4
|
||||||
|
},
|
||||||
|
request_id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
references: {
|
||||||
|
model: 'workflow_requests',
|
||||||
|
key: 'request_id'
|
||||||
|
},
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
onUpdate: 'CASCADE'
|
||||||
|
},
|
||||||
|
activity_name: {
|
||||||
|
type: DataTypes.STRING(500),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
activity_type: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
dealer_code: {
|
||||||
|
type: DataTypes.STRING(50),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
dealer_name: {
|
||||||
|
type: DataTypes.STRING(200),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
dealer_email: {
|
||||||
|
type: DataTypes.STRING(255),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
dealer_phone: {
|
||||||
|
type: DataTypes.STRING(20),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
dealer_address: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
activity_date: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
type: DataTypes.STRING(255),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
period_start_date: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
period_end_date: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
estimated_budget: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
closed_expenses: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
io_number: {
|
||||||
|
type: DataTypes.STRING(50),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
io_available_balance: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
io_blocked_amount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
io_remaining_balance: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
sap_document_number: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
dms_number: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
e_invoice_number: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
e_invoice_date: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
credit_note_number: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
credit_note_date: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
credit_note_amount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
await queryInterface.addIndex('dealer_claim_details', ['request_id'], {
|
||||||
|
name: 'idx_dealer_claim_details_request_id',
|
||||||
|
unique: true
|
||||||
|
});
|
||||||
|
await queryInterface.addIndex('dealer_claim_details', ['dealer_code'], {
|
||||||
|
name: 'idx_dealer_claim_details_dealer_code'
|
||||||
|
});
|
||||||
|
await queryInterface.addIndex('dealer_claim_details', ['activity_type'], {
|
||||||
|
name: 'idx_dealer_claim_details_activity_type'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Create dealer_proposal_details table (Step 1: Dealer Proposal)
|
||||||
|
await queryInterface.createTable('dealer_proposal_details', {
|
||||||
|
proposal_id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
primaryKey: true,
|
||||||
|
defaultValue: DataTypes.UUIDV4
|
||||||
|
},
|
||||||
|
request_id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
references: {
|
||||||
|
model: 'workflow_requests',
|
||||||
|
key: 'request_id'
|
||||||
|
},
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
onUpdate: 'CASCADE'
|
||||||
|
},
|
||||||
|
proposal_document_path: {
|
||||||
|
type: DataTypes.STRING(500),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
proposal_document_url: {
|
||||||
|
type: DataTypes.STRING(500),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
cost_breakup: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
total_estimated_budget: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
timeline_mode: {
|
||||||
|
type: DataTypes.STRING(10),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
expected_completion_date: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
expected_completion_days: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
dealer_comments: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
submitted_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addIndex('dealer_proposal_details', ['request_id'], {
|
||||||
|
name: 'idx_dealer_proposal_details_request_id',
|
||||||
|
unique: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Create dealer_completion_details table (Step 5: Dealer Completion)
|
||||||
|
await queryInterface.createTable('dealer_completion_details', {
|
||||||
|
completion_id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
primaryKey: true,
|
||||||
|
defaultValue: DataTypes.UUIDV4
|
||||||
|
},
|
||||||
|
request_id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
references: {
|
||||||
|
model: 'workflow_requests',
|
||||||
|
key: 'request_id'
|
||||||
|
},
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
onUpdate: 'CASCADE'
|
||||||
|
},
|
||||||
|
activity_completion_date: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
number_of_participants: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
closed_expenses: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
total_closed_expenses: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
completion_documents: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
activity_photos: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
submitted_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addIndex('dealer_completion_details', ['request_id'], {
|
||||||
|
name: 'idx_dealer_completion_details_request_id',
|
||||||
|
unique: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||||
|
await queryInterface.dropTable('dealer_completion_details');
|
||||||
|
await queryInterface.dropTable('dealer_proposal_details');
|
||||||
|
await queryInterface.dropTable('dealer_claim_details');
|
||||||
|
}
|
||||||
|
|
||||||
194
src/migrations/20251210-create-proposal-cost-items-table.ts
Normal file
194
src/migrations/20251210-create-proposal-cost-items-table.ts
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
import { QueryInterface, DataTypes } from 'sequelize';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration: Create dealer_proposal_cost_items table
|
||||||
|
*
|
||||||
|
* Purpose: Separate table for cost breakups to enable better querying, reporting, and data integrity
|
||||||
|
* This replaces the JSONB costBreakup field in dealer_proposal_details
|
||||||
|
*
|
||||||
|
* Benefits:
|
||||||
|
* - Better querying and filtering
|
||||||
|
* - Easier to update individual cost items
|
||||||
|
* - Better for analytics and reporting
|
||||||
|
* - Maintains referential integrity
|
||||||
|
*/
|
||||||
|
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||||
|
// Check if table already exists
|
||||||
|
const [tables] = await queryInterface.sequelize.query(`
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'dealer_proposal_cost_items';
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (tables.length === 0) {
|
||||||
|
// Create dealer_proposal_cost_items table
|
||||||
|
await queryInterface.createTable('dealer_proposal_cost_items', {
|
||||||
|
cost_item_id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
primaryKey: true,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
field: 'cost_item_id'
|
||||||
|
},
|
||||||
|
proposal_id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'proposal_id',
|
||||||
|
references: {
|
||||||
|
model: 'dealer_proposal_details',
|
||||||
|
key: 'proposal_id'
|
||||||
|
},
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
onUpdate: 'CASCADE'
|
||||||
|
},
|
||||||
|
request_id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'request_id',
|
||||||
|
references: {
|
||||||
|
model: 'workflow_requests',
|
||||||
|
key: 'request_id'
|
||||||
|
},
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
comment: 'Denormalized for easier querying without joins'
|
||||||
|
},
|
||||||
|
item_description: {
|
||||||
|
type: DataTypes.STRING(500),
|
||||||
|
allowNull: false,
|
||||||
|
field: 'item_description'
|
||||||
|
},
|
||||||
|
amount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false,
|
||||||
|
field: 'amount',
|
||||||
|
comment: 'Cost amount in INR'
|
||||||
|
},
|
||||||
|
item_order: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
field: 'item_order',
|
||||||
|
comment: 'Order of item in the cost breakdown list'
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'created_at'
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'updated_at'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create indexes for better query performance
|
||||||
|
await queryInterface.addIndex('dealer_proposal_cost_items', ['proposal_id'], {
|
||||||
|
name: 'idx_proposal_cost_items_proposal_id'
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addIndex('dealer_proposal_cost_items', ['request_id'], {
|
||||||
|
name: 'idx_proposal_cost_items_request_id'
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addIndex('dealer_proposal_cost_items', ['proposal_id', 'item_order'], {
|
||||||
|
name: 'idx_proposal_cost_items_proposal_order'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Created dealer_proposal_cost_items table');
|
||||||
|
} else {
|
||||||
|
console.log('Note: dealer_proposal_cost_items table already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate existing JSONB costBreakup data to the new table
|
||||||
|
try {
|
||||||
|
const [existingProposals] = await queryInterface.sequelize.query(`
|
||||||
|
SELECT proposal_id, request_id, cost_breakup
|
||||||
|
FROM dealer_proposal_details
|
||||||
|
WHERE cost_breakup IS NOT NULL
|
||||||
|
AND cost_breakup::text != 'null'
|
||||||
|
AND cost_breakup::text != '[]';
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (Array.isArray(existingProposals) && existingProposals.length > 0) {
|
||||||
|
console.log(`📦 Migrating ${existingProposals.length} existing proposal(s) with cost breakups...`);
|
||||||
|
|
||||||
|
for (const proposal of existingProposals as any[]) {
|
||||||
|
const proposalId = proposal.proposal_id;
|
||||||
|
const requestId = proposal.request_id;
|
||||||
|
let costBreakup = proposal.cost_breakup;
|
||||||
|
|
||||||
|
// Parse JSONB if it's a string
|
||||||
|
if (typeof costBreakup === 'string') {
|
||||||
|
try {
|
||||||
|
costBreakup = JSON.parse(costBreakup);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`⚠️ Failed to parse costBreakup for proposal ${proposalId}:`, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure it's an array
|
||||||
|
if (!Array.isArray(costBreakup)) {
|
||||||
|
console.warn(`⚠️ costBreakup is not an array for proposal ${proposalId}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert cost items
|
||||||
|
for (let i = 0; i < costBreakup.length; i++) {
|
||||||
|
const item = costBreakup[i];
|
||||||
|
if (item && item.description && item.amount !== undefined) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
INSERT INTO dealer_proposal_cost_items
|
||||||
|
(proposal_id, request_id, item_description, amount, item_order, created_at, updated_at)
|
||||||
|
VALUES (:proposalId, :requestId, :description, :amount, :order, NOW(), NOW())
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
`, {
|
||||||
|
replacements: {
|
||||||
|
proposalId,
|
||||||
|
requestId,
|
||||||
|
description: item.description,
|
||||||
|
amount: item.amount,
|
||||||
|
order: i
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Migrated existing cost breakups to new table');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.warn('⚠️ Could not migrate existing cost breakups:', error.message);
|
||||||
|
// Don't fail the migration if migration of existing data fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||||
|
// Drop indexes first
|
||||||
|
try {
|
||||||
|
await queryInterface.removeIndex('dealer_proposal_cost_items', 'idx_proposal_cost_items_proposal_order');
|
||||||
|
} catch (e) {
|
||||||
|
// Index might not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await queryInterface.removeIndex('dealer_proposal_cost_items', 'idx_proposal_cost_items_request_id');
|
||||||
|
} catch (e) {
|
||||||
|
// Index might not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await queryInterface.removeIndex('dealer_proposal_cost_items', 'idx_proposal_cost_items_proposal_id');
|
||||||
|
} catch (e) {
|
||||||
|
// Index might not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop table
|
||||||
|
await queryInterface.dropTable('dealer_proposal_cost_items');
|
||||||
|
console.log('✅ Dropped dealer_proposal_cost_items table');
|
||||||
|
}
|
||||||
|
|
||||||
174
src/migrations/20251210-enhance-workflow-templates.ts
Normal file
174
src/migrations/20251210-enhance-workflow-templates.ts
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import { QueryInterface, DataTypes } from 'sequelize';
|
||||||
|
|
||||||
|
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||||
|
// Check if workflow_templates table exists, if not create it
|
||||||
|
const [tables] = await queryInterface.sequelize.query(`
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'workflow_templates';
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (tables.length === 0) {
|
||||||
|
// Create workflow_templates table if it doesn't exist
|
||||||
|
await queryInterface.createTable('workflow_templates', {
|
||||||
|
template_id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
primaryKey: true,
|
||||||
|
defaultValue: DataTypes.UUIDV4
|
||||||
|
},
|
||||||
|
template_name: {
|
||||||
|
type: DataTypes.STRING(200),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
template_code: {
|
||||||
|
type: DataTypes.STRING(50),
|
||||||
|
allowNull: true,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
template_description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
template_category: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
workflow_type: {
|
||||||
|
type: DataTypes.STRING(50),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
approval_levels_config: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
default_tat_hours: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 24
|
||||||
|
},
|
||||||
|
form_steps_config: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
user_field_mappings: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
dynamic_approver_config: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
is_active: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: true
|
||||||
|
},
|
||||||
|
is_system_template: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
usage_count: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
created_by: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'user_id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
await queryInterface.addIndex('workflow_templates', ['template_code'], {
|
||||||
|
name: 'idx_workflow_templates_template_code',
|
||||||
|
unique: true
|
||||||
|
});
|
||||||
|
await queryInterface.addIndex('workflow_templates', ['workflow_type'], {
|
||||||
|
name: 'idx_workflow_templates_workflow_type'
|
||||||
|
});
|
||||||
|
await queryInterface.addIndex('workflow_templates', ['is_active'], {
|
||||||
|
name: 'idx_workflow_templates_is_active'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Table exists, add new columns if they don't exist
|
||||||
|
const tableDescription = await queryInterface.describeTable('workflow_templates');
|
||||||
|
|
||||||
|
if (!tableDescription.form_steps_config) {
|
||||||
|
await queryInterface.addColumn('workflow_templates', 'form_steps_config', {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableDescription.user_field_mappings) {
|
||||||
|
await queryInterface.addColumn('workflow_templates', 'user_field_mappings', {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableDescription.dynamic_approver_config) {
|
||||||
|
await queryInterface.addColumn('workflow_templates', 'dynamic_approver_config', {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableDescription.workflow_type) {
|
||||||
|
await queryInterface.addColumn('workflow_templates', 'workflow_type', {
|
||||||
|
type: DataTypes.STRING(50),
|
||||||
|
allowNull: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableDescription.is_system_template) {
|
||||||
|
await queryInterface.addColumn('workflow_templates', 'is_system_template', {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||||
|
// Remove columns if they exist
|
||||||
|
const tableDescription = await queryInterface.describeTable('workflow_templates');
|
||||||
|
|
||||||
|
if (tableDescription.dynamic_approver_config) {
|
||||||
|
await queryInterface.removeColumn('workflow_templates', 'dynamic_approver_config');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tableDescription.user_field_mappings) {
|
||||||
|
await queryInterface.removeColumn('workflow_templates', 'user_field_mappings');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tableDescription.form_steps_config) {
|
||||||
|
await queryInterface.removeColumn('workflow_templates', 'form_steps_config');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tableDescription.workflow_type) {
|
||||||
|
await queryInterface.removeColumn('workflow_templates', 'workflow_type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tableDescription.is_system_template) {
|
||||||
|
await queryInterface.removeColumn('workflow_templates', 'is_system_template');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
258
src/models/DealerClaimDetails.ts
Normal file
258
src/models/DealerClaimDetails.ts
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
import { DataTypes, Model, Optional } from 'sequelize';
|
||||||
|
import { sequelize } from '@config/database';
|
||||||
|
import { WorkflowRequest } from './WorkflowRequest';
|
||||||
|
|
||||||
|
interface DealerClaimDetailsAttributes {
|
||||||
|
claimId: string;
|
||||||
|
requestId: string;
|
||||||
|
activityName: string;
|
||||||
|
activityType: string;
|
||||||
|
dealerCode: string;
|
||||||
|
dealerName: string;
|
||||||
|
dealerEmail?: string;
|
||||||
|
dealerPhone?: string;
|
||||||
|
dealerAddress?: string;
|
||||||
|
activityDate?: Date;
|
||||||
|
location?: string;
|
||||||
|
periodStartDate?: Date;
|
||||||
|
periodEndDate?: Date;
|
||||||
|
estimatedBudget?: number;
|
||||||
|
closedExpenses?: number;
|
||||||
|
ioNumber?: string;
|
||||||
|
ioAvailableBalance?: number;
|
||||||
|
ioBlockedAmount?: number;
|
||||||
|
ioRemainingBalance?: number;
|
||||||
|
sapDocumentNumber?: string;
|
||||||
|
dmsNumber?: string;
|
||||||
|
eInvoiceNumber?: string;
|
||||||
|
eInvoiceDate?: Date;
|
||||||
|
creditNoteNumber?: string;
|
||||||
|
creditNoteDate?: Date;
|
||||||
|
creditNoteAmount?: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DealerClaimDetailsCreationAttributes extends Optional<DealerClaimDetailsAttributes, 'claimId' | 'dealerEmail' | 'dealerPhone' | 'dealerAddress' | 'activityDate' | 'location' | 'periodStartDate' | 'periodEndDate' | 'estimatedBudget' | 'closedExpenses' | 'ioNumber' | 'ioAvailableBalance' | 'ioBlockedAmount' | 'ioRemainingBalance' | 'sapDocumentNumber' | 'dmsNumber' | 'eInvoiceNumber' | 'eInvoiceDate' | 'creditNoteNumber' | 'creditNoteDate' | 'creditNoteAmount' | 'createdAt' | 'updatedAt'> {}
|
||||||
|
|
||||||
|
class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaimDetailsCreationAttributes> implements DealerClaimDetailsAttributes {
|
||||||
|
public claimId!: string;
|
||||||
|
public requestId!: string;
|
||||||
|
public activityName!: string;
|
||||||
|
public activityType!: string;
|
||||||
|
public dealerCode!: string;
|
||||||
|
public dealerName!: string;
|
||||||
|
public dealerEmail?: string;
|
||||||
|
public dealerPhone?: string;
|
||||||
|
public dealerAddress?: string;
|
||||||
|
public activityDate?: Date;
|
||||||
|
public location?: string;
|
||||||
|
public periodStartDate?: Date;
|
||||||
|
public periodEndDate?: Date;
|
||||||
|
public estimatedBudget?: number;
|
||||||
|
public closedExpenses?: number;
|
||||||
|
public ioNumber?: string;
|
||||||
|
public ioAvailableBalance?: number;
|
||||||
|
public ioBlockedAmount?: number;
|
||||||
|
public ioRemainingBalance?: number;
|
||||||
|
public sapDocumentNumber?: string;
|
||||||
|
public dmsNumber?: string;
|
||||||
|
public eInvoiceNumber?: string;
|
||||||
|
public eInvoiceDate?: Date;
|
||||||
|
public creditNoteNumber?: string;
|
||||||
|
public creditNoteDate?: Date;
|
||||||
|
public creditNoteAmount?: number;
|
||||||
|
public createdAt!: Date;
|
||||||
|
public updatedAt!: Date;
|
||||||
|
|
||||||
|
// Associations
|
||||||
|
public workflowRequest?: WorkflowRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
DealerClaimDetails.init(
|
||||||
|
{
|
||||||
|
claimId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
field: 'claim_id'
|
||||||
|
},
|
||||||
|
requestId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
field: 'request_id',
|
||||||
|
references: {
|
||||||
|
model: 'workflow_requests',
|
||||||
|
key: 'request_id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
activityName: {
|
||||||
|
type: DataTypes.STRING(500),
|
||||||
|
allowNull: false,
|
||||||
|
field: 'activity_name'
|
||||||
|
},
|
||||||
|
activityType: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
allowNull: false,
|
||||||
|
field: 'activity_type'
|
||||||
|
},
|
||||||
|
dealerCode: {
|
||||||
|
type: DataTypes.STRING(50),
|
||||||
|
allowNull: false,
|
||||||
|
field: 'dealer_code'
|
||||||
|
},
|
||||||
|
dealerName: {
|
||||||
|
type: DataTypes.STRING(200),
|
||||||
|
allowNull: false,
|
||||||
|
field: 'dealer_name'
|
||||||
|
},
|
||||||
|
dealerEmail: {
|
||||||
|
type: DataTypes.STRING(255),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'dealer_email'
|
||||||
|
},
|
||||||
|
dealerPhone: {
|
||||||
|
type: DataTypes.STRING(20),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'dealer_phone'
|
||||||
|
},
|
||||||
|
dealerAddress: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'dealer_address'
|
||||||
|
},
|
||||||
|
activityDate: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'activity_date'
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
type: DataTypes.STRING(255),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
periodStartDate: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'period_start_date'
|
||||||
|
},
|
||||||
|
periodEndDate: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'period_end_date'
|
||||||
|
},
|
||||||
|
estimatedBudget: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'estimated_budget'
|
||||||
|
},
|
||||||
|
closedExpenses: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'closed_expenses'
|
||||||
|
},
|
||||||
|
ioNumber: {
|
||||||
|
type: DataTypes.STRING(50),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'io_number'
|
||||||
|
},
|
||||||
|
ioAvailableBalance: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'io_available_balance'
|
||||||
|
},
|
||||||
|
ioBlockedAmount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'io_blocked_amount'
|
||||||
|
},
|
||||||
|
ioRemainingBalance: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'io_remaining_balance'
|
||||||
|
},
|
||||||
|
sapDocumentNumber: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'sap_document_number'
|
||||||
|
},
|
||||||
|
dmsNumber: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'dms_number'
|
||||||
|
},
|
||||||
|
eInvoiceNumber: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'e_invoice_number'
|
||||||
|
},
|
||||||
|
eInvoiceDate: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'e_invoice_date'
|
||||||
|
},
|
||||||
|
creditNoteNumber: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'credit_note_number'
|
||||||
|
},
|
||||||
|
creditNoteDate: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'credit_note_date'
|
||||||
|
},
|
||||||
|
creditNoteAmount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'credit_note_amount'
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'created_at'
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'updated_at'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'DealerClaimDetails',
|
||||||
|
tableName: 'dealer_claim_details',
|
||||||
|
timestamps: true,
|
||||||
|
createdAt: 'created_at',
|
||||||
|
updatedAt: 'updated_at',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['request_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['dealer_code']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['activity_type']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Associations
|
||||||
|
DealerClaimDetails.belongsTo(WorkflowRequest, {
|
||||||
|
as: 'workflowRequest',
|
||||||
|
foreignKey: 'requestId',
|
||||||
|
targetKey: 'requestId'
|
||||||
|
});
|
||||||
|
|
||||||
|
WorkflowRequest.hasOne(DealerClaimDetails, {
|
||||||
|
as: 'claimDetails',
|
||||||
|
foreignKey: 'requestId',
|
||||||
|
sourceKey: 'requestId'
|
||||||
|
});
|
||||||
|
|
||||||
|
export { DealerClaimDetails };
|
||||||
|
|
||||||
132
src/models/DealerCompletionDetails.ts
Normal file
132
src/models/DealerCompletionDetails.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { DataTypes, Model, Optional } from 'sequelize';
|
||||||
|
import { sequelize } from '@config/database';
|
||||||
|
import { WorkflowRequest } from './WorkflowRequest';
|
||||||
|
|
||||||
|
interface DealerCompletionDetailsAttributes {
|
||||||
|
completionId: string;
|
||||||
|
requestId: string;
|
||||||
|
activityCompletionDate: Date;
|
||||||
|
numberOfParticipants?: number;
|
||||||
|
closedExpenses?: any; // JSONB array of {description, amount}
|
||||||
|
totalClosedExpenses?: number;
|
||||||
|
completionDocuments?: any; // JSONB array of document references
|
||||||
|
activityPhotos?: any; // JSONB array of photo references
|
||||||
|
submittedAt?: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DealerCompletionDetailsCreationAttributes extends Optional<DealerCompletionDetailsAttributes, 'completionId' | 'numberOfParticipants' | 'closedExpenses' | 'totalClosedExpenses' | 'completionDocuments' | 'activityPhotos' | 'submittedAt' | 'createdAt' | 'updatedAt'> {}
|
||||||
|
|
||||||
|
class DealerCompletionDetails extends Model<DealerCompletionDetailsAttributes, DealerCompletionDetailsCreationAttributes> implements DealerCompletionDetailsAttributes {
|
||||||
|
public completionId!: string;
|
||||||
|
public requestId!: string;
|
||||||
|
public activityCompletionDate!: Date;
|
||||||
|
public numberOfParticipants?: number;
|
||||||
|
public closedExpenses?: any;
|
||||||
|
public totalClosedExpenses?: number;
|
||||||
|
public completionDocuments?: any;
|
||||||
|
public activityPhotos?: any;
|
||||||
|
public submittedAt?: Date;
|
||||||
|
public createdAt!: Date;
|
||||||
|
public updatedAt!: Date;
|
||||||
|
|
||||||
|
public workflowRequest?: WorkflowRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
DealerCompletionDetails.init(
|
||||||
|
{
|
||||||
|
completionId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
field: 'completion_id'
|
||||||
|
},
|
||||||
|
requestId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
field: 'request_id',
|
||||||
|
references: {
|
||||||
|
model: 'workflow_requests',
|
||||||
|
key: 'request_id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
activityCompletionDate: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'activity_completion_date'
|
||||||
|
},
|
||||||
|
numberOfParticipants: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'number_of_participants'
|
||||||
|
},
|
||||||
|
closedExpenses: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'closed_expenses'
|
||||||
|
},
|
||||||
|
totalClosedExpenses: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'total_closed_expenses'
|
||||||
|
},
|
||||||
|
completionDocuments: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'completion_documents'
|
||||||
|
},
|
||||||
|
activityPhotos: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'activity_photos'
|
||||||
|
},
|
||||||
|
submittedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'submitted_at'
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'created_at'
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'updated_at'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'DealerCompletionDetails',
|
||||||
|
tableName: 'dealer_completion_details',
|
||||||
|
timestamps: true,
|
||||||
|
createdAt: 'created_at',
|
||||||
|
updatedAt: 'updated_at',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['request_id']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
DealerCompletionDetails.belongsTo(WorkflowRequest, {
|
||||||
|
as: 'workflowRequest',
|
||||||
|
foreignKey: 'requestId',
|
||||||
|
targetKey: 'requestId'
|
||||||
|
});
|
||||||
|
|
||||||
|
WorkflowRequest.hasOne(DealerCompletionDetails, {
|
||||||
|
as: 'completionDetails',
|
||||||
|
foreignKey: 'requestId',
|
||||||
|
sourceKey: 'requestId'
|
||||||
|
});
|
||||||
|
|
||||||
|
export { DealerCompletionDetails };
|
||||||
|
|
||||||
123
src/models/DealerProposalCostItem.ts
Normal file
123
src/models/DealerProposalCostItem.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { DataTypes, Model, Optional } from 'sequelize';
|
||||||
|
import { sequelize } from '@config/database';
|
||||||
|
import { DealerProposalDetails } from './DealerProposalDetails';
|
||||||
|
import { WorkflowRequest } from './WorkflowRequest';
|
||||||
|
|
||||||
|
interface DealerProposalCostItemAttributes {
|
||||||
|
costItemId: string;
|
||||||
|
proposalId: string;
|
||||||
|
requestId: string;
|
||||||
|
itemDescription: string;
|
||||||
|
amount: number;
|
||||||
|
itemOrder: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DealerProposalCostItemCreationAttributes extends Optional<DealerProposalCostItemAttributes, 'costItemId' | 'itemOrder' | 'createdAt' | 'updatedAt'> {}
|
||||||
|
|
||||||
|
class DealerProposalCostItem extends Model<DealerProposalCostItemAttributes, DealerProposalCostItemCreationAttributes> implements DealerProposalCostItemAttributes {
|
||||||
|
public costItemId!: string;
|
||||||
|
public proposalId!: string;
|
||||||
|
public requestId!: string;
|
||||||
|
public itemDescription!: string;
|
||||||
|
public amount!: number;
|
||||||
|
public itemOrder!: number;
|
||||||
|
public createdAt!: Date;
|
||||||
|
public updatedAt!: Date;
|
||||||
|
|
||||||
|
// Associations
|
||||||
|
public proposal?: DealerProposalDetails;
|
||||||
|
public workflowRequest?: WorkflowRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
DealerProposalCostItem.init(
|
||||||
|
{
|
||||||
|
costItemId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
field: 'cost_item_id'
|
||||||
|
},
|
||||||
|
proposalId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'proposal_id',
|
||||||
|
references: {
|
||||||
|
model: 'dealer_proposal_details',
|
||||||
|
key: 'proposal_id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requestId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'request_id',
|
||||||
|
references: {
|
||||||
|
model: 'workflow_requests',
|
||||||
|
key: 'request_id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemDescription: {
|
||||||
|
type: DataTypes.STRING(500),
|
||||||
|
allowNull: false,
|
||||||
|
field: 'item_description'
|
||||||
|
},
|
||||||
|
amount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
itemOrder: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
field: 'item_order'
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'created_at'
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'updated_at'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'DealerProposalCostItem',
|
||||||
|
tableName: 'dealer_proposal_cost_items',
|
||||||
|
timestamps: true,
|
||||||
|
createdAt: 'created_at',
|
||||||
|
updatedAt: 'updated_at',
|
||||||
|
indexes: [
|
||||||
|
{ fields: ['proposal_id'], name: 'idx_proposal_cost_items_proposal_id' },
|
||||||
|
{ fields: ['request_id'], name: 'idx_proposal_cost_items_request_id' },
|
||||||
|
{ fields: ['proposal_id', 'item_order'], name: 'idx_proposal_cost_items_proposal_order' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Associations
|
||||||
|
DealerProposalCostItem.belongsTo(DealerProposalDetails, {
|
||||||
|
as: 'proposal',
|
||||||
|
foreignKey: 'proposalId',
|
||||||
|
targetKey: 'proposalId'
|
||||||
|
});
|
||||||
|
|
||||||
|
DealerProposalCostItem.belongsTo(WorkflowRequest, {
|
||||||
|
as: 'workflowRequest',
|
||||||
|
foreignKey: 'requestId',
|
||||||
|
targetKey: 'requestId'
|
||||||
|
});
|
||||||
|
|
||||||
|
DealerProposalDetails.hasMany(DealerProposalCostItem, {
|
||||||
|
as: 'costItems',
|
||||||
|
foreignKey: 'proposalId',
|
||||||
|
sourceKey: 'proposalId'
|
||||||
|
});
|
||||||
|
|
||||||
|
export { DealerProposalCostItem };
|
||||||
|
|
||||||
146
src/models/DealerProposalDetails.ts
Normal file
146
src/models/DealerProposalDetails.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import { DataTypes, Model, Optional } from 'sequelize';
|
||||||
|
import { sequelize } from '@config/database';
|
||||||
|
import { WorkflowRequest } from './WorkflowRequest';
|
||||||
|
|
||||||
|
interface DealerProposalDetailsAttributes {
|
||||||
|
proposalId: string;
|
||||||
|
requestId: string;
|
||||||
|
proposalDocumentPath?: string;
|
||||||
|
proposalDocumentUrl?: string;
|
||||||
|
costBreakup?: any; // JSONB array of {description, amount}
|
||||||
|
totalEstimatedBudget?: number;
|
||||||
|
timelineMode?: 'date' | 'days';
|
||||||
|
expectedCompletionDate?: Date;
|
||||||
|
expectedCompletionDays?: number;
|
||||||
|
dealerComments?: string;
|
||||||
|
submittedAt?: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DealerProposalDetailsCreationAttributes extends Optional<DealerProposalDetailsAttributes, 'proposalId' | 'proposalDocumentPath' | 'proposalDocumentUrl' | 'costBreakup' | 'totalEstimatedBudget' | 'timelineMode' | 'expectedCompletionDate' | 'expectedCompletionDays' | 'dealerComments' | 'submittedAt' | 'createdAt' | 'updatedAt'> {}
|
||||||
|
|
||||||
|
class DealerProposalDetails extends Model<DealerProposalDetailsAttributes, DealerProposalDetailsCreationAttributes> implements DealerProposalDetailsAttributes {
|
||||||
|
public proposalId!: string;
|
||||||
|
public requestId!: string;
|
||||||
|
public proposalDocumentPath?: string;
|
||||||
|
public proposalDocumentUrl?: string;
|
||||||
|
public costBreakup?: any;
|
||||||
|
public totalEstimatedBudget?: number;
|
||||||
|
public timelineMode?: 'date' | 'days';
|
||||||
|
public expectedCompletionDate?: Date;
|
||||||
|
public expectedCompletionDays?: number;
|
||||||
|
public dealerComments?: string;
|
||||||
|
public submittedAt?: Date;
|
||||||
|
public createdAt!: Date;
|
||||||
|
public updatedAt!: Date;
|
||||||
|
|
||||||
|
public workflowRequest?: WorkflowRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
DealerProposalDetails.init(
|
||||||
|
{
|
||||||
|
proposalId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
field: 'proposal_id'
|
||||||
|
},
|
||||||
|
requestId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
field: 'request_id',
|
||||||
|
references: {
|
||||||
|
model: 'workflow_requests',
|
||||||
|
key: 'request_id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
proposalDocumentPath: {
|
||||||
|
type: DataTypes.STRING(500),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'proposal_document_path'
|
||||||
|
},
|
||||||
|
proposalDocumentUrl: {
|
||||||
|
type: DataTypes.STRING(500),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'proposal_document_url'
|
||||||
|
},
|
||||||
|
costBreakup: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'cost_breakup'
|
||||||
|
},
|
||||||
|
totalEstimatedBudget: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'total_estimated_budget'
|
||||||
|
},
|
||||||
|
timelineMode: {
|
||||||
|
type: DataTypes.STRING(10),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'timeline_mode'
|
||||||
|
},
|
||||||
|
expectedCompletionDate: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'expected_completion_date'
|
||||||
|
},
|
||||||
|
expectedCompletionDays: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'expected_completion_days'
|
||||||
|
},
|
||||||
|
dealerComments: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'dealer_comments'
|
||||||
|
},
|
||||||
|
submittedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'submitted_at'
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'created_at'
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'updated_at'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'DealerProposalDetails',
|
||||||
|
tableName: 'dealer_proposal_details',
|
||||||
|
timestamps: true,
|
||||||
|
createdAt: 'created_at',
|
||||||
|
updatedAt: 'updated_at',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['request_id']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
DealerProposalDetails.belongsTo(WorkflowRequest, {
|
||||||
|
as: 'workflowRequest',
|
||||||
|
foreignKey: 'requestId',
|
||||||
|
targetKey: 'requestId'
|
||||||
|
});
|
||||||
|
|
||||||
|
WorkflowRequest.hasOne(DealerProposalDetails, {
|
||||||
|
as: 'proposalDetails',
|
||||||
|
foreignKey: 'requestId',
|
||||||
|
sourceKey: 'requestId'
|
||||||
|
});
|
||||||
|
|
||||||
|
export { DealerProposalDetails };
|
||||||
|
|
||||||
@ -8,6 +8,8 @@ interface WorkflowRequestAttributes {
|
|||||||
requestNumber: string;
|
requestNumber: string;
|
||||||
initiatorId: string;
|
initiatorId: string;
|
||||||
templateType: 'CUSTOM' | 'TEMPLATE';
|
templateType: 'CUSTOM' | 'TEMPLATE';
|
||||||
|
workflowType?: string; // 'NON_TEMPLATIZED' | 'CLAIM_MANAGEMENT' | etc.
|
||||||
|
templateId?: string; // Reference to workflow_templates if using admin template
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
priority: Priority;
|
priority: Priority;
|
||||||
@ -38,6 +40,8 @@ class WorkflowRequest extends Model<WorkflowRequestAttributes, WorkflowRequestCr
|
|||||||
public requestNumber!: string;
|
public requestNumber!: string;
|
||||||
public initiatorId!: string;
|
public initiatorId!: string;
|
||||||
public templateType!: 'CUSTOM' | 'TEMPLATE';
|
public templateType!: 'CUSTOM' | 'TEMPLATE';
|
||||||
|
public workflowType?: string;
|
||||||
|
public templateId?: string;
|
||||||
public title!: string;
|
public title!: string;
|
||||||
public description!: string;
|
public description!: string;
|
||||||
public priority!: Priority;
|
public priority!: Priority;
|
||||||
@ -92,6 +96,23 @@ WorkflowRequest.init(
|
|||||||
defaultValue: 'CUSTOM',
|
defaultValue: 'CUSTOM',
|
||||||
field: 'template_type'
|
field: 'template_type'
|
||||||
},
|
},
|
||||||
|
workflowType: {
|
||||||
|
type: DataTypes.STRING(50),
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 'NON_TEMPLATIZED',
|
||||||
|
field: 'workflow_type',
|
||||||
|
// Don't fail if column doesn't exist (for backward compatibility with old environments)
|
||||||
|
// Sequelize will handle this gracefully if the column is missing
|
||||||
|
},
|
||||||
|
templateId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'template_id',
|
||||||
|
references: {
|
||||||
|
model: 'workflow_templates',
|
||||||
|
key: 'template_id'
|
||||||
|
}
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
type: DataTypes.STRING(500),
|
type: DataTypes.STRING(500),
|
||||||
allowNull: false
|
allowNull: false
|
||||||
@ -223,6 +244,12 @@ WorkflowRequest.init(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
fields: ['created_at']
|
fields: ['created_at']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['workflow_type']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['template_id']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
180
src/models/WorkflowTemplate.ts
Normal file
180
src/models/WorkflowTemplate.ts
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import { DataTypes, Model, Optional } from 'sequelize';
|
||||||
|
import { sequelize } from '@config/database';
|
||||||
|
import { User } from './User';
|
||||||
|
|
||||||
|
interface WorkflowTemplateAttributes {
|
||||||
|
templateId: string;
|
||||||
|
templateName: string;
|
||||||
|
templateCode?: string;
|
||||||
|
templateDescription?: string;
|
||||||
|
templateCategory?: string;
|
||||||
|
workflowType?: string;
|
||||||
|
approvalLevelsConfig?: any;
|
||||||
|
defaultTatHours?: number;
|
||||||
|
formStepsConfig?: any;
|
||||||
|
userFieldMappings?: any;
|
||||||
|
dynamicApproverConfig?: any;
|
||||||
|
isActive: boolean;
|
||||||
|
isSystemTemplate: boolean;
|
||||||
|
usageCount: number;
|
||||||
|
createdBy?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkflowTemplateCreationAttributes extends Optional<WorkflowTemplateAttributes, 'templateId' | 'templateCode' | 'templateDescription' | 'templateCategory' | 'workflowType' | 'approvalLevelsConfig' | 'defaultTatHours' | 'formStepsConfig' | 'userFieldMappings' | 'dynamicApproverConfig' | 'createdBy' | 'createdAt' | 'updatedAt'> {}
|
||||||
|
|
||||||
|
class WorkflowTemplate extends Model<WorkflowTemplateAttributes, WorkflowTemplateCreationAttributes> implements WorkflowTemplateAttributes {
|
||||||
|
public templateId!: string;
|
||||||
|
public templateName!: string;
|
||||||
|
public templateCode?: string;
|
||||||
|
public templateDescription?: string;
|
||||||
|
public templateCategory?: string;
|
||||||
|
public workflowType?: string;
|
||||||
|
public approvalLevelsConfig?: any;
|
||||||
|
public defaultTatHours?: number;
|
||||||
|
public formStepsConfig?: any;
|
||||||
|
public userFieldMappings?: any;
|
||||||
|
public dynamicApproverConfig?: any;
|
||||||
|
public isActive!: boolean;
|
||||||
|
public isSystemTemplate!: boolean;
|
||||||
|
public usageCount!: number;
|
||||||
|
public createdBy?: string;
|
||||||
|
public createdAt!: Date;
|
||||||
|
public updatedAt!: Date;
|
||||||
|
|
||||||
|
// Associations
|
||||||
|
public creator?: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkflowTemplate.init(
|
||||||
|
{
|
||||||
|
templateId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
field: 'template_id'
|
||||||
|
},
|
||||||
|
templateName: {
|
||||||
|
type: DataTypes.STRING(200),
|
||||||
|
allowNull: false,
|
||||||
|
field: 'template_name'
|
||||||
|
},
|
||||||
|
templateCode: {
|
||||||
|
type: DataTypes.STRING(50),
|
||||||
|
allowNull: true,
|
||||||
|
unique: true,
|
||||||
|
field: 'template_code'
|
||||||
|
},
|
||||||
|
templateDescription: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'template_description'
|
||||||
|
},
|
||||||
|
templateCategory: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'template_category'
|
||||||
|
},
|
||||||
|
workflowType: {
|
||||||
|
type: DataTypes.STRING(50),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'workflow_type'
|
||||||
|
},
|
||||||
|
approvalLevelsConfig: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'approval_levels_config'
|
||||||
|
},
|
||||||
|
defaultTatHours: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 24,
|
||||||
|
field: 'default_tat_hours'
|
||||||
|
},
|
||||||
|
formStepsConfig: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'form_steps_config'
|
||||||
|
},
|
||||||
|
userFieldMappings: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'user_field_mappings'
|
||||||
|
},
|
||||||
|
dynamicApproverConfig: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'dynamic_approver_config'
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: true,
|
||||||
|
field: 'is_active'
|
||||||
|
},
|
||||||
|
isSystemTemplate: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
field: 'is_system_template'
|
||||||
|
},
|
||||||
|
usageCount: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
field: 'usage_count'
|
||||||
|
},
|
||||||
|
createdBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'created_by',
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'user_id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'created_at'
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'updated_at'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'WorkflowTemplate',
|
||||||
|
tableName: 'workflow_templates',
|
||||||
|
timestamps: true,
|
||||||
|
createdAt: 'created_at',
|
||||||
|
updatedAt: 'updated_at',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['template_code']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['workflow_type']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['is_active']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Associations
|
||||||
|
WorkflowTemplate.belongsTo(User, {
|
||||||
|
as: 'creator',
|
||||||
|
foreignKey: 'createdBy',
|
||||||
|
targetKey: 'userId'
|
||||||
|
});
|
||||||
|
|
||||||
|
export { WorkflowTemplate };
|
||||||
|
|
||||||
@ -16,6 +16,11 @@ import { Notification } from './Notification';
|
|||||||
import ConclusionRemark from './ConclusionRemark';
|
import ConclusionRemark from './ConclusionRemark';
|
||||||
import RequestSummary from './RequestSummary';
|
import RequestSummary from './RequestSummary';
|
||||||
import SharedSummary from './SharedSummary';
|
import SharedSummary from './SharedSummary';
|
||||||
|
import { DealerClaimDetails } from './DealerClaimDetails';
|
||||||
|
import { DealerProposalDetails } from './DealerProposalDetails';
|
||||||
|
import { DealerCompletionDetails } from './DealerCompletionDetails';
|
||||||
|
import { DealerProposalCostItem } from './DealerProposalCostItem';
|
||||||
|
import { WorkflowTemplate } from './WorkflowTemplate';
|
||||||
|
|
||||||
// Define associations
|
// Define associations
|
||||||
const defineAssociations = () => {
|
const defineAssociations = () => {
|
||||||
@ -138,7 +143,12 @@ export {
|
|||||||
Notification,
|
Notification,
|
||||||
ConclusionRemark,
|
ConclusionRemark,
|
||||||
RequestSummary,
|
RequestSummary,
|
||||||
SharedSummary
|
SharedSummary,
|
||||||
|
DealerClaimDetails,
|
||||||
|
DealerProposalDetails,
|
||||||
|
DealerCompletionDetails,
|
||||||
|
DealerProposalCostItem,
|
||||||
|
WorkflowTemplate
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export default sequelize instance
|
// Export default sequelize instance
|
||||||
|
|||||||
38
src/routes/dealer.routes.ts
Normal file
38
src/routes/dealer.routes.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { DealerController } from '../controllers/dealer.controller';
|
||||||
|
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||||
|
import { asyncHandler } from '../middlewares/errorHandler.middleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const dealerController = new DealerController();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/v1/dealers
|
||||||
|
* @desc Get all dealers
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.get('/', authenticateToken, asyncHandler(dealerController.getAllDealers.bind(dealerController)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/v1/dealers/search
|
||||||
|
* @desc Search dealers by name, code, or email
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.get('/search', authenticateToken, asyncHandler(dealerController.searchDealers.bind(dealerController)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/v1/dealers/code/:dealerCode
|
||||||
|
* @desc Get dealer by code
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.get('/code/:dealerCode', authenticateToken, asyncHandler(dealerController.getDealerByCode.bind(dealerController)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/v1/dealers/email/:email
|
||||||
|
* @desc Get dealer by email
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.get('/email/:email', authenticateToken, asyncHandler(dealerController.getDealerByEmail.bind(dealerController)));
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
78
src/routes/dealerClaim.routes.ts
Normal file
78
src/routes/dealerClaim.routes.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { DealerClaimController } from '../controllers/dealerClaim.controller';
|
||||||
|
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||||
|
import { asyncHandler } from '../middlewares/errorHandler.middleware';
|
||||||
|
import multer from 'multer';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const dealerClaimController = new DealerClaimController();
|
||||||
|
|
||||||
|
// Configure multer for file uploads (memory storage for direct GCS upload)
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: {
|
||||||
|
fileSize: 10 * 1024 * 1024, // 10MB
|
||||||
|
},
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
const allowedExtensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.jpg', '.jpeg', '.png', '.zip'];
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
if (allowedExtensions.includes(ext)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error(`File type ${ext} not allowed. Allowed types: ${allowedExtensions.join(', ')}`));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/v1/dealer-claims
|
||||||
|
* @desc Create a new dealer claim request
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.post('/', authenticateToken, asyncHandler(dealerClaimController.createClaimRequest.bind(dealerClaimController)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/v1/dealer-claims/:requestId
|
||||||
|
* @desc Get claim details
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.get('/:requestId', authenticateToken, asyncHandler(dealerClaimController.getClaimDetails.bind(dealerClaimController)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/v1/dealer-claims/:requestId/proposal
|
||||||
|
* @desc Submit dealer proposal (Step 1)
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.post('/:requestId/proposal', authenticateToken, upload.single('proposalDocument'), asyncHandler(dealerClaimController.submitProposal.bind(dealerClaimController)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/v1/dealer-claims/:requestId/completion
|
||||||
|
* @desc Submit completion documents (Step 5)
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.post('/:requestId/completion', authenticateToken, asyncHandler(dealerClaimController.submitCompletion.bind(dealerClaimController)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route PUT /api/v1/dealer-claims/:requestId/io
|
||||||
|
* @desc Update IO details (Step 3)
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.put('/:requestId/io', authenticateToken, asyncHandler(dealerClaimController.updateIODetails.bind(dealerClaimController)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route PUT /api/v1/dealer-claims/:requestId/e-invoice
|
||||||
|
* @desc Update e-invoice details (Step 7)
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.put('/:requestId/e-invoice', authenticateToken, asyncHandler(dealerClaimController.updateEInvoice.bind(dealerClaimController)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route PUT /api/v1/dealer-claims/:requestId/credit-note
|
||||||
|
* @desc Update credit note details (Step 8)
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.put('/:requestId/credit-note', authenticateToken, asyncHandler(dealerClaimController.updateCreditNote.bind(dealerClaimController)));
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
@ -13,6 +13,9 @@ import dashboardRoutes from './dashboard.routes';
|
|||||||
import notificationRoutes from './notification.routes';
|
import notificationRoutes from './notification.routes';
|
||||||
import conclusionRoutes from './conclusion.routes';
|
import conclusionRoutes from './conclusion.routes';
|
||||||
import aiRoutes from './ai.routes';
|
import aiRoutes from './ai.routes';
|
||||||
|
import dealerClaimRoutes from './dealerClaim.routes';
|
||||||
|
import templateRoutes from './template.routes';
|
||||||
|
import dealerRoutes from './dealer.routes';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -40,6 +43,9 @@ router.use('/notifications', notificationRoutes);
|
|||||||
router.use('/conclusions', conclusionRoutes);
|
router.use('/conclusions', conclusionRoutes);
|
||||||
router.use('/ai', aiRoutes);
|
router.use('/ai', aiRoutes);
|
||||||
router.use('/summaries', summaryRoutes);
|
router.use('/summaries', summaryRoutes);
|
||||||
|
router.use('/dealer-claims', dealerClaimRoutes);
|
||||||
|
router.use('/templates', templateRoutes);
|
||||||
|
router.use('/dealers', dealerRoutes);
|
||||||
|
|
||||||
// TODO: Add other route modules as they are implemented
|
// TODO: Add other route modules as they are implemented
|
||||||
// router.use('/approvals', approvalRoutes);
|
// router.use('/approvals', approvalRoutes);
|
||||||
|
|||||||
53
src/routes/template.routes.ts
Normal file
53
src/routes/template.routes.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { TemplateController } from '../controllers/template.controller';
|
||||||
|
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||||
|
import { asyncHandler } from '../middlewares/errorHandler.middleware';
|
||||||
|
import { requireAdmin } from '../middlewares/auth.middleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const templateController = new TemplateController();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/v1/templates
|
||||||
|
* @desc List all templates (with optional filters)
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.get('/', authenticateToken, asyncHandler(templateController.listTemplates.bind(templateController)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/v1/templates/active
|
||||||
|
* @desc Get active templates for workflow creation
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.get('/active', authenticateToken, asyncHandler(templateController.getActiveTemplates.bind(templateController)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/v1/templates/:templateId
|
||||||
|
* @desc Get template by ID
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.get('/:templateId', authenticateToken, asyncHandler(templateController.getTemplate.bind(templateController)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/v1/templates
|
||||||
|
* @desc Create a new template
|
||||||
|
* @access Private (Admin only)
|
||||||
|
*/
|
||||||
|
router.post('/', authenticateToken, requireAdmin, asyncHandler(templateController.createTemplate.bind(templateController)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route PUT /api/v1/templates/:templateId
|
||||||
|
* @desc Update template
|
||||||
|
* @access Private (Admin only)
|
||||||
|
*/
|
||||||
|
router.put('/:templateId', authenticateToken, requireAdmin, asyncHandler(templateController.updateTemplate.bind(templateController)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route DELETE /api/v1/templates/:templateId
|
||||||
|
* @desc Delete template (soft delete)
|
||||||
|
* @access Private (Admin only)
|
||||||
|
*/
|
||||||
|
router.delete('/:templateId', authenticateToken, requireAdmin, asyncHandler(templateController.deleteTemplate.bind(templateController)));
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
@ -22,6 +22,11 @@ import * as m18 from '../migrations/20251118-add-breach-reason-to-approval-level
|
|||||||
import * as m19 from '../migrations/20251121-add-ai-model-configs';
|
import * as m19 from '../migrations/20251121-add-ai-model-configs';
|
||||||
import * as m20 from '../migrations/20250122-create-request-summaries';
|
import * as m20 from '../migrations/20250122-create-request-summaries';
|
||||||
import * as m21 from '../migrations/20250122-create-shared-summaries';
|
import * as m21 from '../migrations/20250122-create-shared-summaries';
|
||||||
|
import * as m22 from '../migrations/20251210-add-workflow-type-support';
|
||||||
|
import * as m23 from '../migrations/20251210-enhance-workflow-templates';
|
||||||
|
import * as m24 from '../migrations/20251210-add-template-id-foreign-key';
|
||||||
|
import * as m25 from '../migrations/20251210-create-dealer-claim-tables';
|
||||||
|
import * as m26 from '../migrations/20251210-create-proposal-cost-items-table';
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@ -58,6 +63,11 @@ const migrations: Migration[] = [
|
|||||||
{ name: '20251121-add-ai-model-configs', module: m19 },
|
{ name: '20251121-add-ai-model-configs', module: m19 },
|
||||||
{ name: '20250122-create-request-summaries', module: m20 },
|
{ name: '20250122-create-request-summaries', module: m20 },
|
||||||
{ name: '20250122-create-shared-summaries', module: m21 },
|
{ name: '20250122-create-shared-summaries', module: m21 },
|
||||||
|
{ name: '20251210-add-workflow-type-support', module: m22 },
|
||||||
|
{ name: '20251210-enhance-workflow-templates', module: m23 },
|
||||||
|
{ name: '20251210-add-template-id-foreign-key', module: m24 },
|
||||||
|
{ name: '20251210-create-dealer-claim-tables', module: m25 },
|
||||||
|
{ name: '20251210-create-proposal-cost-items-table', module: m26 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
182
src/scripts/seed-dealers.ts
Normal file
182
src/scripts/seed-dealers.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* Seed Dealer Users
|
||||||
|
* Creates dealer users for claim management workflow
|
||||||
|
* These users will act as action takers in the workflow
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sequelize } from '../config/database';
|
||||||
|
import { User } from '../models/User';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
interface DealerData {
|
||||||
|
email: string;
|
||||||
|
dealerCode: string;
|
||||||
|
dealerName: string;
|
||||||
|
displayName: string;
|
||||||
|
department?: string;
|
||||||
|
designation?: string;
|
||||||
|
phone?: string;
|
||||||
|
role?: 'USER' | 'MANAGEMENT' | 'ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
const dealers: DealerData[] = [
|
||||||
|
{
|
||||||
|
email: 'test.2@royalenfield.com',
|
||||||
|
dealerCode: 'RE-MH-001',
|
||||||
|
dealerName: 'Royal Motors Mumbai',
|
||||||
|
displayName: 'Royal Motors Mumbai',
|
||||||
|
department: 'Dealer Operations',
|
||||||
|
designation: 'Dealer',
|
||||||
|
phone: '+91-9876543210',
|
||||||
|
role: 'USER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: 'test.4@royalenfield.com',
|
||||||
|
dealerCode: 'RE-DL-002',
|
||||||
|
dealerName: 'Delhi enfield center',
|
||||||
|
displayName: 'Delhi Enfield Center',
|
||||||
|
department: 'Dealer Operations',
|
||||||
|
designation: 'Dealer',
|
||||||
|
phone: '+91-9876543211',
|
||||||
|
role: 'USER',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seedDealers(): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.info('[Seed Dealers] Starting dealer user seeding...');
|
||||||
|
|
||||||
|
for (const dealer of dealers) {
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await User.findOne({
|
||||||
|
where: { email: dealer.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
// User already exists (likely from Okta SSO login)
|
||||||
|
const isOktaUser = existingUser.oktaSub && !existingUser.oktaSub.startsWith('dealer-');
|
||||||
|
|
||||||
|
if (isOktaUser) {
|
||||||
|
logger.info(`[Seed Dealers] User ${dealer.email} already exists as Okta user (oktaSub: ${existingUser.oktaSub}), updating dealer-specific fields only...`);
|
||||||
|
} else {
|
||||||
|
logger.info(`[Seed Dealers] User ${dealer.email} already exists, updating dealer information...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing user with dealer information
|
||||||
|
// IMPORTANT: Preserve Okta data (oktaSub, role from Okta, etc.) and only update dealer-specific fields
|
||||||
|
const nameParts = dealer.dealerName.split(' ');
|
||||||
|
const firstName = nameParts[0] || dealer.dealerName;
|
||||||
|
const lastName = nameParts.slice(1).join(' ') || '';
|
||||||
|
|
||||||
|
// Build update object - only update fields that don't conflict with Okta data
|
||||||
|
const updateData: any = {
|
||||||
|
// Always update dealer code in employeeId (this is dealer-specific, safe to update)
|
||||||
|
employeeId: dealer.dealerCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only update displayName if it's different or if current one is empty
|
||||||
|
if (!existingUser.displayName || existingUser.displayName !== dealer.displayName) {
|
||||||
|
updateData.displayName = dealer.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update designation if current one doesn't indicate dealer role
|
||||||
|
if (!existingUser.designation || !existingUser.designation.toLowerCase().includes('dealer')) {
|
||||||
|
updateData.designation = dealer.designation || existingUser.designation;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update department if it's not set or if we want to ensure "Dealer Operations"
|
||||||
|
if (!existingUser.department || existingUser.department !== 'Dealer Operations') {
|
||||||
|
updateData.department = dealer.department || existingUser.department;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update phone if not set
|
||||||
|
if (!existingUser.phone && dealer.phone) {
|
||||||
|
updateData.phone = dealer.phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update name parts if not set
|
||||||
|
if (!existingUser.firstName && firstName) {
|
||||||
|
updateData.firstName = firstName;
|
||||||
|
}
|
||||||
|
if (!existingUser.lastName && lastName) {
|
||||||
|
updateData.lastName = lastName;
|
||||||
|
}
|
||||||
|
|
||||||
|
await existingUser.update(updateData);
|
||||||
|
|
||||||
|
if (isOktaUser) {
|
||||||
|
logger.info(`[Seed Dealers] ✅ Updated existing Okta user ${dealer.email} with dealer code: ${dealer.dealerCode}`);
|
||||||
|
logger.info(`[Seed Dealers] Preserved Okta data: oktaSub=${existingUser.oktaSub}, role=${existingUser.role}`);
|
||||||
|
} else {
|
||||||
|
logger.info(`[Seed Dealers] ✅ Updated user ${dealer.email} with dealer code: ${dealer.dealerCode}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// User doesn't exist - create new dealer user
|
||||||
|
// NOTE: If dealer is an Okta user, they should login via SSO first to be created automatically
|
||||||
|
// This creates a placeholder user that will be updated when they login via SSO
|
||||||
|
logger.warn(`[Seed Dealers] User ${dealer.email} not found in database. Creating placeholder user...`);
|
||||||
|
logger.warn(`[Seed Dealers] ⚠️ If this user is an Okta user, they should login via SSO first to be created automatically.`);
|
||||||
|
logger.warn(`[Seed Dealers] ⚠️ The oktaSub will be updated when they login via SSO.`);
|
||||||
|
|
||||||
|
// Generate a UUID for userId
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const userId = uuidv4();
|
||||||
|
|
||||||
|
const nameParts = dealer.dealerName.split(' ');
|
||||||
|
const firstName = nameParts[0] || dealer.dealerName;
|
||||||
|
const lastName = nameParts.slice(1).join(' ') || '';
|
||||||
|
|
||||||
|
await User.create({
|
||||||
|
userId,
|
||||||
|
email: dealer.email.toLowerCase(),
|
||||||
|
displayName: dealer.displayName,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
department: dealer.department || 'Dealer Operations',
|
||||||
|
designation: dealer.designation || 'Dealer',
|
||||||
|
phone: dealer.phone,
|
||||||
|
role: dealer.role || 'USER',
|
||||||
|
employeeId: dealer.dealerCode, // Store dealer code in employeeId field
|
||||||
|
isActive: true,
|
||||||
|
// Set placeholder oktaSub - will be updated when user logs in via SSO
|
||||||
|
// Using a recognizable pattern so we know it's a placeholder
|
||||||
|
oktaSub: `dealer-${dealer.dealerCode}-pending-sso`,
|
||||||
|
emailNotificationsEnabled: true,
|
||||||
|
pushNotificationsEnabled: false,
|
||||||
|
inAppNotificationsEnabled: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
logger.info(`[Seed Dealers] ⚠️ Created placeholder dealer user: ${dealer.email} (${dealer.dealerCode})`);
|
||||||
|
logger.info(`[Seed Dealers] ⚠️ User should login via SSO to update oktaSub field with real Okta subject ID`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[Seed Dealers] ✅ Dealer seeding completed successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Seed Dealers] ❌ Error seeding dealers:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run if called directly
|
||||||
|
if (require.main === module) {
|
||||||
|
sequelize
|
||||||
|
.authenticate()
|
||||||
|
.then(() => {
|
||||||
|
logger.info('[Seed Dealers] Database connection established');
|
||||||
|
return seedDealers();
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
logger.info('[Seed Dealers] Seeding completed');
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error('[Seed Dealers] Seeding failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { seedDealers, dealers };
|
||||||
|
|
||||||
@ -7,6 +7,201 @@ import logger, { logAuthEvent } from '../utils/logger';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
|
/**
|
||||||
|
* Fetch user details from Okta Users API (full profile with manager, employeeID, etc.)
|
||||||
|
* Falls back to userinfo endpoint if Users API fails or token is not configured
|
||||||
|
*/
|
||||||
|
private async fetchUserFromOktaUsersAPI(oktaSub: string, email: string, accessToken: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
// Check if API token is configured
|
||||||
|
if (!ssoConfig.oktaApiToken || ssoConfig.oktaApiToken.trim() === '') {
|
||||||
|
logger.info('OKTA_API_TOKEN not configured, will use userinfo endpoint as fallback');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to fetch from Users API using email first (as shown in curl example)
|
||||||
|
// If email lookup fails, try with oktaSub (user ID)
|
||||||
|
let usersApiResponse: any = null;
|
||||||
|
|
||||||
|
// First attempt: Use email (preferred method as shown in curl example)
|
||||||
|
if (email) {
|
||||||
|
const usersApiEndpoint = `${ssoConfig.oktaDomain}/api/v1/users/${encodeURIComponent(email)}`;
|
||||||
|
|
||||||
|
logger.info('Fetching user from Okta Users API (using email)', {
|
||||||
|
endpoint: usersApiEndpoint.replace(email, email.substring(0, 5) + '...'),
|
||||||
|
hasApiToken: !!ssoConfig.oktaApiToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(usersApiEndpoint, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `SSWS ${ssoConfig.oktaApiToken}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
validateStatus: (status) => status < 500, // Don't throw on 4xx errors
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 200 && response.data) {
|
||||||
|
logger.info('Successfully fetched user from Okta Users API (using email)', {
|
||||||
|
userId: response.data.id,
|
||||||
|
hasProfile: !!response.data.profile,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
} catch (emailError: any) {
|
||||||
|
logger.warn('Users API lookup with email failed, will try with oktaSub', {
|
||||||
|
status: emailError.response?.status,
|
||||||
|
error: emailError.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second attempt: Use oktaSub (user ID) if email lookup failed
|
||||||
|
if (oktaSub) {
|
||||||
|
const usersApiEndpoint = `${ssoConfig.oktaDomain}/api/v1/users/${encodeURIComponent(oktaSub)}`;
|
||||||
|
|
||||||
|
logger.info('Fetching user from Okta Users API (using oktaSub)', {
|
||||||
|
endpoint: usersApiEndpoint.replace(oktaSub, oktaSub.substring(0, 10) + '...'),
|
||||||
|
hasApiToken: !!ssoConfig.oktaApiToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(usersApiEndpoint, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `SSWS ${ssoConfig.oktaApiToken}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
validateStatus: (status) => status < 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 200 && response.data) {
|
||||||
|
logger.info('Successfully fetched user from Okta Users API (using oktaSub)', {
|
||||||
|
userId: response.data.id,
|
||||||
|
hasProfile: !!response.data.profile,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
logger.warn('Okta Users API returned non-200 status (oktaSub lookup)', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (oktaSubError: any) {
|
||||||
|
logger.warn('Users API lookup with oktaSub also failed', {
|
||||||
|
status: oktaSubError.response?.status,
|
||||||
|
error: oktaSubError.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.warn('Failed to fetch from Okta Users API, will use userinfo fallback', {
|
||||||
|
error: error.message,
|
||||||
|
status: error.response?.status,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract user data from Okta Users API response
|
||||||
|
*/
|
||||||
|
private extractUserDataFromUsersAPI(oktaUserResponse: any, oktaSub: string): SSOUserData | null {
|
||||||
|
try {
|
||||||
|
const profile = oktaUserResponse.profile || {};
|
||||||
|
|
||||||
|
const userData: SSOUserData = {
|
||||||
|
oktaSub: oktaSub || oktaUserResponse.id || '',
|
||||||
|
email: profile.email || profile.login || '',
|
||||||
|
employeeId: profile.employeeID || profile.employeeId || profile.employee_id || undefined,
|
||||||
|
firstName: profile.firstName || undefined,
|
||||||
|
lastName: profile.lastName || undefined,
|
||||||
|
displayName: profile.displayName || undefined,
|
||||||
|
department: profile.department || undefined,
|
||||||
|
designation: profile.title || profile.designation || undefined,
|
||||||
|
phone: profile.mobilePhone || profile.phone || profile.phoneNumber || undefined,
|
||||||
|
manager: profile.manager || undefined, // Store manager name if available
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!userData.oktaSub || !userData.email) {
|
||||||
|
logger.warn('Users API response missing required fields (oktaSub or email)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Extracted user data from Okta Users API', {
|
||||||
|
oktaSub: userData.oktaSub,
|
||||||
|
email: userData.email,
|
||||||
|
employeeId: userData.employeeId || 'not provided',
|
||||||
|
hasManager: !!userData.manager,
|
||||||
|
hasDepartment: !!userData.department,
|
||||||
|
hasDesignation: !!userData.designation,
|
||||||
|
});
|
||||||
|
|
||||||
|
return userData;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error extracting user data from Users API response', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract user data from Okta userinfo endpoint (fallback)
|
||||||
|
*/
|
||||||
|
private extractUserDataFromUserInfo(oktaUser: any, oktaSub: string): SSOUserData {
|
||||||
|
// Extract oktaSub (required)
|
||||||
|
const sub = oktaSub || oktaUser.sub || '';
|
||||||
|
if (!sub) {
|
||||||
|
throw new Error('Okta sub (subject identifier) is required but not found in response');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract employeeId (optional)
|
||||||
|
const employeeId =
|
||||||
|
oktaUser.employeeId ||
|
||||||
|
oktaUser.employee_id ||
|
||||||
|
oktaUser.empId ||
|
||||||
|
oktaUser.employeeNumber ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
const userData: SSOUserData = {
|
||||||
|
oktaSub: sub,
|
||||||
|
email: oktaUser.email || '',
|
||||||
|
employeeId: employeeId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate: Ensure we're not accidentally using oktaSub as employeeId
|
||||||
|
if (employeeId === sub) {
|
||||||
|
logger.warn('Warning: employeeId matches oktaSub - this should not happen unless explicitly set in Okta', {
|
||||||
|
oktaSub: sub,
|
||||||
|
employeeId,
|
||||||
|
});
|
||||||
|
userData.employeeId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only set optional fields if they have values
|
||||||
|
if (oktaUser.given_name || oktaUser.firstName) {
|
||||||
|
userData.firstName = oktaUser.given_name || oktaUser.firstName;
|
||||||
|
}
|
||||||
|
if (oktaUser.family_name || oktaUser.lastName) {
|
||||||
|
userData.lastName = oktaUser.family_name || oktaUser.lastName;
|
||||||
|
}
|
||||||
|
if (oktaUser.name) {
|
||||||
|
userData.displayName = oktaUser.name;
|
||||||
|
}
|
||||||
|
if (oktaUser.department) {
|
||||||
|
userData.department = oktaUser.department;
|
||||||
|
}
|
||||||
|
if (oktaUser.title || oktaUser.designation) {
|
||||||
|
userData.designation = oktaUser.title || oktaUser.designation;
|
||||||
|
}
|
||||||
|
if (oktaUser.phone_number || oktaUser.phone) {
|
||||||
|
userData.phone = oktaUser.phone_number || oktaUser.phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
return userData;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle SSO callback from frontend
|
* Handle SSO callback from frontend
|
||||||
* Creates new user or updates existing user based on employeeId
|
* Creates new user or updates existing user based on employeeId
|
||||||
@ -59,6 +254,7 @@ export class AuthService {
|
|||||||
if (userData.department) userUpdateData.department = userData.department;
|
if (userData.department) userUpdateData.department = userData.department;
|
||||||
if (userData.designation) userUpdateData.designation = userData.designation;
|
if (userData.designation) userUpdateData.designation = userData.designation;
|
||||||
if (userData.phone) userUpdateData.phone = userData.phone;
|
if (userData.phone) userUpdateData.phone = userData.phone;
|
||||||
|
if (userData.manager) userUpdateData.manager = userData.manager; // Manager name from Okta
|
||||||
|
|
||||||
// Check if user exists by email (primary identifier)
|
// Check if user exists by email (primary identifier)
|
||||||
let user = await User.findOne({
|
let user = await User.findOne({
|
||||||
@ -88,6 +284,7 @@ export class AuthService {
|
|||||||
department: userData.department || null,
|
department: userData.department || null,
|
||||||
designation: userData.designation || null,
|
designation: userData.designation || null,
|
||||||
phone: userData.phone || null,
|
phone: userData.phone || null,
|
||||||
|
manager: userData.manager || null, // Manager name from Okta
|
||||||
isActive: true,
|
isActive: true,
|
||||||
role: 'USER',
|
role: 'USER',
|
||||||
lastLogin: new Date()
|
lastLogin: new Date()
|
||||||
@ -306,45 +503,29 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const oktaUser = userInfoResponse.data;
|
const oktaUserInfo = userInfoResponse.data;
|
||||||
|
const oktaSub = oktaUserInfo.sub || '';
|
||||||
|
|
||||||
// Step 3: Extract user data from Okta response
|
|
||||||
const oktaSub = oktaUser.sub || '';
|
|
||||||
if (!oktaSub) {
|
if (!oktaSub) {
|
||||||
throw new Error('Okta sub (subject identifier) not found in response');
|
throw new Error('Okta sub (subject identifier) not found in response');
|
||||||
}
|
}
|
||||||
|
|
||||||
const employeeId =
|
// Step 3: Try Users API first (provides full profile including manager, employeeID, etc.)
|
||||||
oktaUser.employeeId ||
|
let userData: SSOUserData | null = null;
|
||||||
oktaUser.employee_id ||
|
const usersApiResponse = await this.fetchUserFromOktaUsersAPI(oktaSub, oktaUserInfo.email || username, access_token);
|
||||||
oktaUser.empId ||
|
|
||||||
oktaUser.employeeNumber ||
|
|
||||||
undefined;
|
|
||||||
|
|
||||||
const userData: SSOUserData = {
|
if (usersApiResponse) {
|
||||||
oktaSub: oktaSub,
|
userData = this.extractUserDataFromUsersAPI(usersApiResponse, oktaSub);
|
||||||
email: oktaUser.email || username,
|
}
|
||||||
employeeId: employeeId,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add optional fields
|
// Fallback to userinfo endpoint if Users API failed or returned null
|
||||||
if (oktaUser.given_name || oktaUser.firstName) {
|
if (!userData) {
|
||||||
userData.firstName = oktaUser.given_name || oktaUser.firstName;
|
logger.info('Using userinfo endpoint as fallback (Users API unavailable or failed)');
|
||||||
}
|
userData = this.extractUserDataFromUserInfo(oktaUserInfo, oktaSub);
|
||||||
if (oktaUser.family_name || oktaUser.lastName) {
|
// Override email with username if needed
|
||||||
userData.lastName = oktaUser.family_name || oktaUser.lastName;
|
if (!userData.email && username) {
|
||||||
}
|
userData.email = username;
|
||||||
if (oktaUser.name) {
|
}
|
||||||
userData.displayName = oktaUser.name;
|
|
||||||
}
|
|
||||||
if (oktaUser.department) {
|
|
||||||
userData.department = oktaUser.department;
|
|
||||||
}
|
|
||||||
if (oktaUser.title || oktaUser.designation) {
|
|
||||||
userData.designation = oktaUser.title || oktaUser.designation;
|
|
||||||
}
|
|
||||||
if (oktaUser.phone_number || oktaUser.phone) {
|
|
||||||
userData.phone = oktaUser.phone_number || oktaUser.phone;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('User data extracted from Okta', {
|
logger.info('User data extracted from Okta', {
|
||||||
@ -483,7 +664,8 @@ export class AuthService {
|
|||||||
hasIdToken: !!id_token,
|
hasIdToken: !!id_token,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get user info from Okta using access token
|
// Step 1: Try to get user info from Okta Users API (full profile with manager, employeeID, etc.)
|
||||||
|
// First, get oktaSub from userinfo to use as user ID
|
||||||
const userInfoEndpoint = `${ssoConfig.oktaDomain}/oauth2/default/v1/userinfo`;
|
const userInfoEndpoint = `${ssoConfig.oktaDomain}/oauth2/default/v1/userinfo`;
|
||||||
const userInfoResponse = await axios.get(userInfoEndpoint, {
|
const userInfoResponse = await axios.get(userInfoEndpoint, {
|
||||||
headers: {
|
headers: {
|
||||||
@ -491,98 +673,35 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const oktaUser = userInfoResponse.data;
|
const oktaUserInfo = userInfoResponse.data;
|
||||||
|
const oktaSub = oktaUserInfo.sub || '';
|
||||||
|
|
||||||
// Log the full Okta response to see what attributes are available
|
|
||||||
logger.info('Okta userinfo response received', {
|
|
||||||
availableKeys: Object.keys(oktaUser || {}),
|
|
||||||
sub: oktaUser.sub,
|
|
||||||
email: oktaUser.email,
|
|
||||||
// Log specific fields that might be employeeId
|
|
||||||
employeeId: oktaUser.employeeId || oktaUser.employee_id || oktaUser.empId || 'NOT_FOUND',
|
|
||||||
// Log other common custom attributes
|
|
||||||
customAttributes: Object.keys(oktaUser || {}).filter(key =>
|
|
||||||
key.includes('employee') || key.includes('emp') || key.includes('id')
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract oktaSub (required) - this is the Okta subject identifier
|
|
||||||
// IMPORTANT: Do NOT use oktaSub for employeeId - they are separate fields
|
|
||||||
const oktaSub = oktaUser.sub || '';
|
|
||||||
if (!oktaSub) {
|
if (!oktaSub) {
|
||||||
throw new Error('Okta sub (subject identifier) is required but not found in response');
|
throw new Error('Okta sub (subject identifier) is required but not found in response');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract employeeId (optional) - ONLY from custom Okta attributes, NOT from sub
|
// Try Users API first (provides full profile including manager, employeeID, etc.)
|
||||||
// Check multiple possible sources for actual employee ID attribute:
|
let userData: SSOUserData | null = null;
|
||||||
// 1. Custom Okta attribute: employeeId, employee_id, empId, employeeNumber
|
const usersApiResponse = await this.fetchUserFromOktaUsersAPI(oktaSub, oktaUserInfo.email || '', access_token);
|
||||||
// 2. Leave undefined if not found - DO NOT use oktaSub/sub as fallback
|
|
||||||
const employeeId =
|
|
||||||
oktaUser.employeeId ||
|
|
||||||
oktaUser.employee_id ||
|
|
||||||
oktaUser.empId ||
|
|
||||||
oktaUser.employeeNumber ||
|
|
||||||
undefined; // Explicitly undefined if not found - oktaSub is stored separately
|
|
||||||
|
|
||||||
// Extract user data from Okta response
|
if (usersApiResponse) {
|
||||||
// Adjust these mappings based on your Okta user profile attributes
|
userData = this.extractUserDataFromUsersAPI(usersApiResponse, oktaSub);
|
||||||
// Only include fields that have values, leave others undefined for optional handling
|
|
||||||
const userData: SSOUserData = {
|
|
||||||
oktaSub: oktaSub, // Required - Okta subject identifier (stored in okta_sub column)
|
|
||||||
email: oktaUser.email || '',
|
|
||||||
employeeId: employeeId, // Optional - Only if provided as custom attribute, NOT oktaSub
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate: Ensure we're not accidentally using oktaSub as employeeId
|
|
||||||
if (employeeId === oktaSub) {
|
|
||||||
logger.warn('Warning: employeeId matches oktaSub - this should not happen unless explicitly set in Okta', {
|
|
||||||
oktaSub,
|
|
||||||
employeeId,
|
|
||||||
});
|
|
||||||
// Clear employeeId to avoid confusion - user can update it later if needed
|
|
||||||
userData.employeeId = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('User data extracted from Okta', {
|
// Fallback to userinfo endpoint if Users API failed or returned null
|
||||||
oktaSub: oktaSub,
|
if (!userData) {
|
||||||
email: oktaUser.email,
|
logger.info('Using userinfo endpoint as fallback (Users API unavailable or failed)');
|
||||||
employeeId: employeeId || 'not provided (optional)',
|
userData = this.extractUserDataFromUserInfo(oktaUserInfo, oktaSub);
|
||||||
employeeIdSource: oktaUser.employeeId ? 'employeeId attribute' :
|
|
||||||
oktaUser.employee_id ? 'employee_id attribute' :
|
|
||||||
oktaUser.empId ? 'empId attribute' :
|
|
||||||
'not found',
|
|
||||||
note: 'Using email as primary identifier, oktaSub for uniqueness',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only set optional fields if they have values
|
|
||||||
if (oktaUser.given_name || oktaUser.firstName) {
|
|
||||||
userData.firstName = oktaUser.given_name || oktaUser.firstName;
|
|
||||||
}
|
|
||||||
if (oktaUser.family_name || oktaUser.lastName) {
|
|
||||||
userData.lastName = oktaUser.family_name || oktaUser.lastName;
|
|
||||||
}
|
|
||||||
if (oktaUser.name) {
|
|
||||||
userData.displayName = oktaUser.name;
|
|
||||||
}
|
|
||||||
if (oktaUser.department) {
|
|
||||||
userData.department = oktaUser.department;
|
|
||||||
}
|
|
||||||
if (oktaUser.title || oktaUser.designation) {
|
|
||||||
userData.designation = oktaUser.title || oktaUser.designation;
|
|
||||||
}
|
|
||||||
if (oktaUser.phone_number || oktaUser.phone) {
|
|
||||||
userData.phone = oktaUser.phone_number || oktaUser.phone;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Extracted user data from Okta', {
|
logger.info('Final extracted user data', {
|
||||||
employeeId: userData.employeeId,
|
oktaSub: userData.oktaSub,
|
||||||
email: userData.email,
|
email: userData.email,
|
||||||
hasFirstName: !!userData.firstName,
|
employeeId: userData.employeeId || 'not provided',
|
||||||
hasLastName: !!userData.lastName,
|
hasManager: !!(userData as any).manager,
|
||||||
hasDisplayName: !!userData.displayName,
|
|
||||||
hasDepartment: !!userData.department,
|
hasDepartment: !!userData.department,
|
||||||
hasDesignation: !!userData.designation,
|
hasDesignation: !!userData.designation,
|
||||||
hasPhone: !!userData.phone,
|
source: usersApiResponse ? 'Users API' : 'userinfo endpoint',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle SSO callback to create/update user and generate our tokens
|
// Handle SSO callback to create/update user and generate our tokens
|
||||||
|
|||||||
166
src/services/dealer.service.ts
Normal file
166
src/services/dealer.service.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Dealer Service
|
||||||
|
* Handles dealer-related operations for claim management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { User } from '../models/User';
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
export interface DealerInfo {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
dealerCode: string;
|
||||||
|
dealerName: string;
|
||||||
|
displayName: string;
|
||||||
|
phone?: string;
|
||||||
|
department?: string;
|
||||||
|
designation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all dealers (users with dealer designation or employeeId starting with 'RE-')
|
||||||
|
*/
|
||||||
|
export async function getAllDealers(): Promise<DealerInfo[]> {
|
||||||
|
try {
|
||||||
|
const dealers = await User.findAll({
|
||||||
|
where: {
|
||||||
|
[Op.or]: [
|
||||||
|
{ designation: { [Op.iLike]: '%dealer%' } as any },
|
||||||
|
{ employeeId: { [Op.like]: 'RE-%' } as any },
|
||||||
|
{ department: { [Op.iLike]: '%dealer%' } as any },
|
||||||
|
],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
order: [['displayName', 'ASC']],
|
||||||
|
});
|
||||||
|
|
||||||
|
return dealers.map((dealer) => ({
|
||||||
|
userId: dealer.userId,
|
||||||
|
email: dealer.email,
|
||||||
|
dealerCode: dealer.employeeId || '', // Dealer code stored in employeeId
|
||||||
|
dealerName: dealer.displayName || dealer.email,
|
||||||
|
displayName: dealer.displayName || dealer.email,
|
||||||
|
phone: dealer.phone || undefined,
|
||||||
|
department: dealer.department || undefined,
|
||||||
|
designation: dealer.designation || undefined,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[DealerService] Error fetching dealers:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dealer by code
|
||||||
|
*/
|
||||||
|
export async function getDealerByCode(dealerCode: string): Promise<DealerInfo | null> {
|
||||||
|
try {
|
||||||
|
const dealer = await User.findOne({
|
||||||
|
where: {
|
||||||
|
employeeId: dealerCode,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dealer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: dealer.userId,
|
||||||
|
email: dealer.email,
|
||||||
|
dealerCode: dealer.employeeId || '',
|
||||||
|
dealerName: dealer.displayName || dealer.email,
|
||||||
|
displayName: dealer.displayName || dealer.email,
|
||||||
|
phone: dealer.phone || undefined,
|
||||||
|
department: dealer.department || undefined,
|
||||||
|
designation: dealer.designation || undefined,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[DealerService] Error fetching dealer by code:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dealer by email
|
||||||
|
*/
|
||||||
|
export async function getDealerByEmail(email: string): Promise<DealerInfo | null> {
|
||||||
|
try {
|
||||||
|
const dealer = await User.findOne({
|
||||||
|
where: {
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
isActive: true,
|
||||||
|
[Op.or]: [
|
||||||
|
{ designation: { [Op.iLike]: '%dealer%' } as any },
|
||||||
|
{ employeeId: { [Op.like]: 'RE-%' } as any },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dealer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: dealer.userId,
|
||||||
|
email: dealer.email,
|
||||||
|
dealerCode: dealer.employeeId || '',
|
||||||
|
dealerName: dealer.displayName || dealer.email,
|
||||||
|
displayName: dealer.displayName || dealer.email,
|
||||||
|
phone: dealer.phone || undefined,
|
||||||
|
department: dealer.department || undefined,
|
||||||
|
designation: dealer.designation || undefined,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[DealerService] Error fetching dealer by email:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search dealers by name or code
|
||||||
|
*/
|
||||||
|
export async function searchDealers(searchTerm: string): Promise<DealerInfo[]> {
|
||||||
|
try {
|
||||||
|
const dealers = await User.findAll({
|
||||||
|
where: {
|
||||||
|
[Op.and]: [
|
||||||
|
{
|
||||||
|
[Op.or]: [
|
||||||
|
{ designation: { [Op.iLike]: '%dealer%' } as any },
|
||||||
|
{ employeeId: { [Op.like]: 'RE-%' } as any },
|
||||||
|
{ department: { [Op.iLike]: '%dealer%' } as any },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[Op.or]: [
|
||||||
|
{ displayName: { [Op.iLike]: `%${searchTerm}%` } as any },
|
||||||
|
{ email: { [Op.iLike]: `%${searchTerm}%` } as any },
|
||||||
|
{ employeeId: { [Op.iLike]: `%${searchTerm}%` } as any },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ isActive: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
order: [['displayName', 'ASC']],
|
||||||
|
limit: 50, // Limit results
|
||||||
|
});
|
||||||
|
|
||||||
|
return dealers.map((dealer) => ({
|
||||||
|
userId: dealer.userId,
|
||||||
|
email: dealer.email,
|
||||||
|
dealerCode: dealer.employeeId || '',
|
||||||
|
dealerName: dealer.displayName || dealer.email,
|
||||||
|
displayName: dealer.displayName || dealer.email,
|
||||||
|
phone: dealer.phone || undefined,
|
||||||
|
department: dealer.department || undefined,
|
||||||
|
designation: dealer.designation || undefined,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[DealerService] Error searching dealers:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
964
src/services/dealerClaim.service.ts
Normal file
964
src/services/dealerClaim.service.ts
Normal file
@ -0,0 +1,964 @@
|
|||||||
|
import { WorkflowRequest } from '../models/WorkflowRequest';
|
||||||
|
import { DealerClaimDetails } from '../models/DealerClaimDetails';
|
||||||
|
import { DealerProposalDetails } from '../models/DealerProposalDetails';
|
||||||
|
import { DealerCompletionDetails } from '../models/DealerCompletionDetails';
|
||||||
|
import { DealerProposalCostItem } from '../models/DealerProposalCostItem';
|
||||||
|
import { ApprovalLevel } from '../models/ApprovalLevel';
|
||||||
|
import { Participant } from '../models/Participant';
|
||||||
|
import { User } from '../models/User';
|
||||||
|
import { WorkflowService } from './workflow.service';
|
||||||
|
import { ApprovalService } from './approval.service';
|
||||||
|
import { generateRequestNumber } from '../utils/helpers';
|
||||||
|
import { Priority, WorkflowStatus, ApprovalStatus } from '../types/common.types';
|
||||||
|
import { sapIntegrationService } from './sapIntegration.service';
|
||||||
|
import { dmsIntegrationService } from './dmsIntegration.service';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dealer Claim Service
|
||||||
|
* Handles business logic specific to dealer claim management workflow
|
||||||
|
*/
|
||||||
|
export class DealerClaimService {
|
||||||
|
private workflowService = new WorkflowService();
|
||||||
|
private approvalService = new ApprovalService();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new dealer claim request
|
||||||
|
*/
|
||||||
|
async createClaimRequest(
|
||||||
|
userId: string,
|
||||||
|
claimData: {
|
||||||
|
activityName: string;
|
||||||
|
activityType: string;
|
||||||
|
dealerCode: string;
|
||||||
|
dealerName: string;
|
||||||
|
dealerEmail?: string;
|
||||||
|
dealerPhone?: string;
|
||||||
|
dealerAddress?: string;
|
||||||
|
activityDate?: Date;
|
||||||
|
location: string;
|
||||||
|
requestDescription: string;
|
||||||
|
periodStartDate?: Date;
|
||||||
|
periodEndDate?: Date;
|
||||||
|
estimatedBudget?: number;
|
||||||
|
}
|
||||||
|
): Promise<WorkflowRequest> {
|
||||||
|
try {
|
||||||
|
// Generate request number
|
||||||
|
const requestNumber = await generateRequestNumber();
|
||||||
|
|
||||||
|
// Create workflow request
|
||||||
|
// For claim management, requests are submitted immediately (not drafts)
|
||||||
|
// Step 1 will be active for dealer to submit proposal
|
||||||
|
const workflowRequest = await WorkflowRequest.create({
|
||||||
|
initiatorId: userId,
|
||||||
|
requestNumber,
|
||||||
|
templateType: 'CUSTOM',
|
||||||
|
workflowType: 'CLAIM_MANAGEMENT',
|
||||||
|
title: `${claimData.activityName} - Claim Request`,
|
||||||
|
description: claimData.requestDescription,
|
||||||
|
priority: Priority.STANDARD,
|
||||||
|
status: WorkflowStatus.PENDING, // Submitted, not draft
|
||||||
|
totalLevels: 8, // Fixed 8-step workflow for claim management
|
||||||
|
currentLevel: 1, // Step 1: Dealer Proposal Submission
|
||||||
|
totalTatHours: 0, // Will be calculated from approval levels
|
||||||
|
isDraft: false, // Not a draft - submitted and ready for workflow
|
||||||
|
isDeleted: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create claim details
|
||||||
|
await DealerClaimDetails.create({
|
||||||
|
requestId: workflowRequest.requestId,
|
||||||
|
activityName: claimData.activityName,
|
||||||
|
activityType: claimData.activityType,
|
||||||
|
dealerCode: claimData.dealerCode,
|
||||||
|
dealerName: claimData.dealerName,
|
||||||
|
dealerEmail: claimData.dealerEmail,
|
||||||
|
dealerPhone: claimData.dealerPhone,
|
||||||
|
dealerAddress: claimData.dealerAddress,
|
||||||
|
activityDate: claimData.activityDate,
|
||||||
|
location: claimData.location,
|
||||||
|
periodStartDate: claimData.periodStartDate,
|
||||||
|
periodEndDate: claimData.periodEndDate,
|
||||||
|
estimatedBudget: claimData.estimatedBudget,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 8 approval levels for claim management workflow
|
||||||
|
await this.createClaimApprovalLevels(workflowRequest.requestId, userId, claimData.dealerEmail);
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimService] Created claim request: ${workflowRequest.requestNumber}`);
|
||||||
|
return workflowRequest;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[DealerClaimService] Error creating claim request:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create 8-step approval levels for claim management
|
||||||
|
* Maps approvers based on step requirements:
|
||||||
|
* - Step 1 & 5: Dealer (external user via email)
|
||||||
|
* - Step 2 & 6: Initiator (requestor)
|
||||||
|
* - Step 3: Department Lead (resolved from initiator's department/manager)
|
||||||
|
* - Step 4 & 7: System (auto-processed)
|
||||||
|
* - Step 8: Finance Team (resolved from department/role)
|
||||||
|
*/
|
||||||
|
private async createClaimApprovalLevels(
|
||||||
|
requestId: string,
|
||||||
|
initiatorId: string,
|
||||||
|
dealerEmail?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const initiator = await User.findByPk(initiatorId);
|
||||||
|
if (!initiator) {
|
||||||
|
throw new Error('Initiator not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve Department Lead for Step 3
|
||||||
|
const departmentLead = await this.resolveDepartmentLead(initiator);
|
||||||
|
|
||||||
|
// Resolve Finance Team for Step 8
|
||||||
|
const financeApprover = await this.resolveFinanceApprover();
|
||||||
|
|
||||||
|
// 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 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) - Finance confirms credit note (FINAL)
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
level: 1,
|
||||||
|
name: 'Dealer Proposal Submission',
|
||||||
|
tatHours: 72,
|
||||||
|
isAuto: false,
|
||||||
|
approverType: 'dealer' as const,
|
||||||
|
approverEmail: dealerEmail,
|
||||||
|
approverId: null, // Dealer may not exist in system, will use email
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: 'finance' as const,
|
||||||
|
approverId: financeApprover?.userId || null,
|
||||||
|
approverEmail: financeApprover?.email || 'finance@royalenfield.com',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// Try to find dealer user - must exist in system
|
||||||
|
const dealerUser = await User.findOne({ where: { email: step.approverEmail } });
|
||||||
|
if (dealerUser) {
|
||||||
|
approverId = dealerUser.userId;
|
||||||
|
approverName = dealerUser.displayName || dealerUser.email || 'Dealer';
|
||||||
|
approverEmail = dealerUser.email;
|
||||||
|
} else {
|
||||||
|
// Dealer not found - this should not happen if dealers are seeded
|
||||||
|
// Use initiator as fallback for now, but log a warning
|
||||||
|
logger.warn(`[DealerClaimService] Dealer ${step.approverEmail} not found in system, using initiator as fallback`);
|
||||||
|
approverId = initiatorId;
|
||||||
|
approverName = `Dealer (${step.approverEmail})`;
|
||||||
|
approverEmail = step.approverEmail;
|
||||||
|
}
|
||||||
|
} else if (step.approverType === 'initiator') {
|
||||||
|
approverId = initiatorId;
|
||||||
|
approverName = initiator.displayName || initiator.email || 'Requestor';
|
||||||
|
approverEmail = initiator.email;
|
||||||
|
} else if (step.approverType === 'department_lead') {
|
||||||
|
if (departmentLead) {
|
||||||
|
approverId = departmentLead.userId;
|
||||||
|
approverName = departmentLead.displayName || departmentLead.email || 'Department Lead';
|
||||||
|
approverEmail = departmentLead.email;
|
||||||
|
} else {
|
||||||
|
// Department lead not found - use initiator as fallback
|
||||||
|
logger.warn(`[DealerClaimService] Department lead not found for department ${initiator.department}, using initiator as fallback`);
|
||||||
|
approverId = initiatorId;
|
||||||
|
approverName = 'Department Lead (Not Found)';
|
||||||
|
approverEmail = initiator.email;
|
||||||
|
}
|
||||||
|
} else if (step.approverType === 'finance') {
|
||||||
|
if (financeApprover) {
|
||||||
|
approverId = financeApprover.userId;
|
||||||
|
approverName = financeApprover.displayName || financeApprover.email || 'Finance Team';
|
||||||
|
approverEmail = financeApprover.email;
|
||||||
|
} else {
|
||||||
|
// Finance approver not found - use initiator as fallback
|
||||||
|
logger.warn(`[DealerClaimService] Finance approver not found, using initiator as fallback`);
|
||||||
|
approverId = initiatorId;
|
||||||
|
approverName = 'Finance Team (Not Found)';
|
||||||
|
approverEmail = initiator.email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we always have a valid approverId (should never be null at this point)
|
||||||
|
if (!approverId) {
|
||||||
|
logger.error(`[DealerClaimService] No approverId resolved for step ${step.level}, using initiator as fallback`);
|
||||||
|
approverId = initiatorId;
|
||||||
|
approverEmail = approverEmail || initiator.email;
|
||||||
|
approverName = approverName || 'Unknown Approver';
|
||||||
|
}
|
||||||
|
|
||||||
|
await ApprovalLevel.create({
|
||||||
|
requestId,
|
||||||
|
levelNumber: step.level,
|
||||||
|
levelName: step.name,
|
||||||
|
approverId: approverId, // Always a valid user ID now
|
||||||
|
approverEmail,
|
||||||
|
approverName,
|
||||||
|
tatHours: step.tatHours,
|
||||||
|
// tatDays is calculated later when needed: 1 day = 8 working hours
|
||||||
|
// Formula: Math.ceil(tatHours / 8)
|
||||||
|
status: step.level === 1 ? ApprovalStatus.PENDING : ApprovalStatus.PENDING,
|
||||||
|
isFinalApprover: step.level === 8,
|
||||||
|
elapsedHours: 0,
|
||||||
|
remainingHours: step.tatHours,
|
||||||
|
tatPercentageUsed: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve Department Lead based on initiator's department/manager
|
||||||
|
* If multiple users found with same department, uses the first one
|
||||||
|
*/
|
||||||
|
private async resolveDepartmentLead(initiator: User): Promise<User | null> {
|
||||||
|
try {
|
||||||
|
const { Op } = await import('sequelize');
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimService] Resolving department lead for initiator: ${initiator.email}, department: ${initiator.department}, manager: ${initiator.manager}`);
|
||||||
|
|
||||||
|
// Priority 1: Find user with MANAGEMENT role in same department
|
||||||
|
if (initiator.department) {
|
||||||
|
const deptLeads = await User.findAll({
|
||||||
|
where: {
|
||||||
|
department: initiator.department,
|
||||||
|
role: 'MANAGEMENT' as any,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
order: [['createdAt', 'ASC']], // Get first one if multiple
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
if (deptLeads.length > 0) {
|
||||||
|
logger.info(`[DealerClaimService] Found department lead by MANAGEMENT role: ${deptLeads[0].email} for department: ${initiator.department}`);
|
||||||
|
return deptLeads[0];
|
||||||
|
} else {
|
||||||
|
logger.debug(`[DealerClaimService] No MANAGEMENT role user found in department: ${initiator.department}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.debug(`[DealerClaimService] Initiator has no department set`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Find users with "Department Lead", "Team Lead", "Team Manager", "Group Manager", "Assistant Manager", "Deputy Manager" in designation, same department
|
||||||
|
if (initiator.department) {
|
||||||
|
const leads = await User.findAll({
|
||||||
|
where: {
|
||||||
|
department: initiator.department,
|
||||||
|
designation: {
|
||||||
|
[Op.or]: [
|
||||||
|
{ [Op.iLike]: '%department lead%' },
|
||||||
|
{ [Op.iLike]: '%departmentlead%' },
|
||||||
|
{ [Op.iLike]: '%dept lead%' },
|
||||||
|
{ [Op.iLike]: '%deptlead%' },
|
||||||
|
{ [Op.iLike]: '%team lead%' },
|
||||||
|
{ [Op.iLike]: '%team manager%' },
|
||||||
|
{ [Op.iLike]: '%group manager%' },
|
||||||
|
{ [Op.iLike]: '%assistant manager%' },
|
||||||
|
{ [Op.iLike]: '%deputy manager%' },
|
||||||
|
{ [Op.iLike]: '%lead%' },
|
||||||
|
{ [Op.iLike]: '%head%' },
|
||||||
|
{ [Op.iLike]: '%manager%' },
|
||||||
|
],
|
||||||
|
} as any,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
order: [['createdAt', 'ASC']], // Get first one if multiple
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
if (leads.length > 0) {
|
||||||
|
logger.info(`[DealerClaimService] Found lead by designation: ${leads[0].email} (designation: ${leads[0].designation})`);
|
||||||
|
return leads[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: Use initiator's manager field
|
||||||
|
if (initiator.manager) {
|
||||||
|
const manager = await User.findOne({
|
||||||
|
where: {
|
||||||
|
email: initiator.manager,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (manager) {
|
||||||
|
logger.info(`[DealerClaimService] Using initiator's manager as department lead: ${manager.email}`);
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 4: Find any user in same department (fallback - use first one)
|
||||||
|
if (initiator.department) {
|
||||||
|
const anyDeptUser = await User.findOne({
|
||||||
|
where: {
|
||||||
|
department: initiator.department,
|
||||||
|
isActive: true,
|
||||||
|
userId: { [Op.ne]: initiator.userId }, // Exclude initiator
|
||||||
|
},
|
||||||
|
order: [['createdAt', 'ASC']],
|
||||||
|
});
|
||||||
|
if (anyDeptUser) {
|
||||||
|
logger.warn(`[DealerClaimService] Using first available user in department as fallback: ${anyDeptUser.email} (designation: ${anyDeptUser.designation}, role: ${anyDeptUser.role})`);
|
||||||
|
return anyDeptUser;
|
||||||
|
} else {
|
||||||
|
logger.debug(`[DealerClaimService] No other users found in department: ${initiator.department}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 5: Search across all departments for users with "Department Lead" designation
|
||||||
|
logger.debug(`[DealerClaimService] Trying to find any user with "Department Lead" designation...`);
|
||||||
|
const anyDeptLead = await User.findOne({
|
||||||
|
where: {
|
||||||
|
designation: {
|
||||||
|
[Op.iLike]: '%department lead%',
|
||||||
|
} as any,
|
||||||
|
isActive: true,
|
||||||
|
userId: { [Op.ne]: initiator.userId }, // Exclude initiator
|
||||||
|
},
|
||||||
|
order: [['createdAt', 'ASC']],
|
||||||
|
});
|
||||||
|
if (anyDeptLead) {
|
||||||
|
logger.warn(`[DealerClaimService] Found user with "Department Lead" designation across all departments: ${anyDeptLead.email} (department: ${anyDeptLead.department})`);
|
||||||
|
return anyDeptLead;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(`[DealerClaimService] Could not resolve department lead for initiator: ${initiator.email} (department: ${initiator.department}, manager: ${initiator.manager})`);
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[DealerClaimService] Error resolving department lead:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve Finance Team approver for Step 8
|
||||||
|
*/
|
||||||
|
private async resolveFinanceApprover(): Promise<User | null> {
|
||||||
|
try {
|
||||||
|
const { Op } = await import('sequelize');
|
||||||
|
|
||||||
|
// Priority 1: Find user with department containing "Finance" and MANAGEMENT role
|
||||||
|
const financeManager = await User.findOne({
|
||||||
|
where: {
|
||||||
|
department: {
|
||||||
|
[Op.iLike]: '%finance%',
|
||||||
|
} as any,
|
||||||
|
role: 'MANAGEMENT' as any,
|
||||||
|
},
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
});
|
||||||
|
if (financeManager) {
|
||||||
|
logger.info(`[DealerClaimService] Found finance manager: ${financeManager.email}`);
|
||||||
|
return financeManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Find user with designation containing "Finance" or "Accountant"
|
||||||
|
const financeUser = await User.findOne({
|
||||||
|
where: {
|
||||||
|
[Op.or]: [
|
||||||
|
{ designation: { [Op.iLike]: '%finance%' } as any },
|
||||||
|
{ designation: { [Op.iLike]: '%accountant%' } as any },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
});
|
||||||
|
if (financeUser) {
|
||||||
|
logger.info(`[DealerClaimService] Found finance user by designation: ${financeUser.email}`);
|
||||||
|
return financeUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: Check admin configurations for finance team email
|
||||||
|
const { getConfigValue } = await import('./configReader.service');
|
||||||
|
const financeEmail = await getConfigValue('FINANCE_TEAM_EMAIL');
|
||||||
|
if (financeEmail) {
|
||||||
|
const financeUserByEmail = await User.findOne({
|
||||||
|
where: { email: financeEmail },
|
||||||
|
});
|
||||||
|
if (financeUserByEmail) {
|
||||||
|
logger.info(`[DealerClaimService] Found finance user from config: ${financeEmail}`);
|
||||||
|
return financeUserByEmail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('[DealerClaimService] Could not resolve finance approver, will use default email');
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[DealerClaimService] Error resolving finance approver:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get claim details with all related data
|
||||||
|
*/
|
||||||
|
async getClaimDetails(requestId: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const request = await WorkflowRequest.findByPk(requestId, {
|
||||||
|
include: [
|
||||||
|
{ model: User, as: 'initiator' },
|
||||||
|
{ model: ApprovalLevel, as: 'approvalLevels' },
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
throw new Error('Request not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle backward compatibility: workflowType may be undefined in old environments
|
||||||
|
const workflowType = request.workflowType || 'NON_TEMPLATIZED';
|
||||||
|
if (workflowType !== 'CLAIM_MANAGEMENT') {
|
||||||
|
throw new Error('Request is not a claim management request');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch related claim data separately
|
||||||
|
const claimDetails = await DealerClaimDetails.findOne({
|
||||||
|
where: { requestId }
|
||||||
|
});
|
||||||
|
|
||||||
|
const proposalDetails = await DealerProposalDetails.findOne({
|
||||||
|
where: { requestId },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: DealerProposalCostItem,
|
||||||
|
as: 'costItems',
|
||||||
|
required: false,
|
||||||
|
separate: true, // Use separate query for ordering
|
||||||
|
order: [['itemOrder', 'ASC']]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const completionDetails = await DealerCompletionDetails.findOne({
|
||||||
|
where: { requestId }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serialize claim details to ensure proper field names
|
||||||
|
let serializedClaimDetails = null;
|
||||||
|
if (claimDetails) {
|
||||||
|
serializedClaimDetails = (claimDetails as any).toJSON ? (claimDetails as any).toJSON() : claimDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform proposal details to include cost items as array
|
||||||
|
let transformedProposalDetails = null;
|
||||||
|
if (proposalDetails) {
|
||||||
|
const proposalData = (proposalDetails as any).toJSON ? (proposalDetails as any).toJSON() : proposalDetails;
|
||||||
|
|
||||||
|
// Get cost items from separate table (preferred) or fallback to JSONB
|
||||||
|
let costBreakup: any[] = [];
|
||||||
|
if (proposalData.costItems && Array.isArray(proposalData.costItems) && proposalData.costItems.length > 0) {
|
||||||
|
// Use cost items from separate table
|
||||||
|
costBreakup = proposalData.costItems.map((item: any) => ({
|
||||||
|
description: item.itemDescription || item.description,
|
||||||
|
amount: Number(item.amount) || 0
|
||||||
|
}));
|
||||||
|
} else if (proposalData.costBreakup) {
|
||||||
|
// Fallback to JSONB field for backward compatibility
|
||||||
|
if (Array.isArray(proposalData.costBreakup)) {
|
||||||
|
costBreakup = proposalData.costBreakup;
|
||||||
|
} else if (typeof proposalData.costBreakup === 'string') {
|
||||||
|
try {
|
||||||
|
costBreakup = JSON.parse(proposalData.costBreakup);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('[DealerClaimService] Failed to parse costBreakup JSON:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transformedProposalDetails = {
|
||||||
|
...proposalData,
|
||||||
|
costBreakup, // Always return as array for frontend compatibility
|
||||||
|
costItems: proposalData.costItems || [] // Also include raw cost items
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize completion details
|
||||||
|
let serializedCompletionDetails = null;
|
||||||
|
if (completionDetails) {
|
||||||
|
serializedCompletionDetails = (completionDetails as any).toJSON ? (completionDetails as any).toJSON() : completionDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
request: (request as any).toJSON ? (request as any).toJSON() : request,
|
||||||
|
claimDetails: serializedClaimDetails,
|
||||||
|
proposalDetails: transformedProposalDetails,
|
||||||
|
completionDetails: serializedCompletionDetails,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[DealerClaimService] Error getting claim details:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit dealer proposal (Step 1)
|
||||||
|
*/
|
||||||
|
async submitDealerProposal(
|
||||||
|
requestId: string,
|
||||||
|
proposalData: {
|
||||||
|
proposalDocumentPath?: string;
|
||||||
|
proposalDocumentUrl?: string;
|
||||||
|
costBreakup: any[];
|
||||||
|
totalEstimatedBudget: number;
|
||||||
|
timelineMode: 'date' | 'days';
|
||||||
|
expectedCompletionDate?: Date;
|
||||||
|
expectedCompletionDays?: number;
|
||||||
|
dealerComments: string;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const request = await WorkflowRequest.findByPk(requestId);
|
||||||
|
if (!request || request.workflowType !== 'CLAIM_MANAGEMENT') {
|
||||||
|
throw new Error('Invalid claim request');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.currentLevel !== 1) {
|
||||||
|
throw new Error('Proposal can only be submitted at step 1');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save proposal details (keep costBreakup in JSONB for backward compatibility)
|
||||||
|
const [proposal] = await DealerProposalDetails.upsert({
|
||||||
|
requestId,
|
||||||
|
proposalDocumentPath: proposalData.proposalDocumentPath,
|
||||||
|
proposalDocumentUrl: proposalData.proposalDocumentUrl,
|
||||||
|
// Keep costBreakup in JSONB for backward compatibility
|
||||||
|
costBreakup: proposalData.costBreakup.length > 0 ? proposalData.costBreakup : null,
|
||||||
|
totalEstimatedBudget: proposalData.totalEstimatedBudget,
|
||||||
|
timelineMode: proposalData.timelineMode,
|
||||||
|
expectedCompletionDate: proposalData.expectedCompletionDate,
|
||||||
|
expectedCompletionDays: proposalData.expectedCompletionDays,
|
||||||
|
dealerComments: proposalData.dealerComments,
|
||||||
|
submittedAt: new Date(),
|
||||||
|
}, {
|
||||||
|
returning: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get proposalId - handle both Sequelize instance and plain object
|
||||||
|
let proposalId = (proposal as any).proposalId
|
||||||
|
|| (proposal as any).proposal_id;
|
||||||
|
|
||||||
|
// If not found, try getDataValue method
|
||||||
|
if (!proposalId && (proposal as any).getDataValue) {
|
||||||
|
proposalId = (proposal as any).getDataValue('proposalId');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still not found, fetch the proposal by requestId
|
||||||
|
if (!proposalId) {
|
||||||
|
const existingProposal = await DealerProposalDetails.findOne({
|
||||||
|
where: { requestId }
|
||||||
|
});
|
||||||
|
if (existingProposal) {
|
||||||
|
proposalId = (existingProposal as any).proposalId
|
||||||
|
|| (existingProposal as any).proposal_id
|
||||||
|
|| ((existingProposal as any).getDataValue ? (existingProposal as any).getDataValue('proposalId') : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!proposalId) {
|
||||||
|
throw new Error('Failed to get proposal ID after saving proposal details');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save cost items to separate table (preferred approach)
|
||||||
|
if (proposalData.costBreakup && proposalData.costBreakup.length > 0) {
|
||||||
|
// Delete existing cost items for this proposal (in case of update)
|
||||||
|
await DealerProposalCostItem.destroy({
|
||||||
|
where: { proposalId }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert new cost items
|
||||||
|
const costItems = proposalData.costBreakup.map((item: any, index: number) => ({
|
||||||
|
proposalId,
|
||||||
|
requestId,
|
||||||
|
itemDescription: item.description || item.itemDescription || '',
|
||||||
|
amount: Number(item.amount) || 0,
|
||||||
|
itemOrder: index
|
||||||
|
}));
|
||||||
|
|
||||||
|
await DealerProposalCostItem.bulkCreate(costItems);
|
||||||
|
logger.info(`[DealerClaimService] Saved ${costItems.length} cost items for proposal ${proposalId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update estimated budget in claim details
|
||||||
|
await DealerClaimDetails.update(
|
||||||
|
{ estimatedBudget: proposalData.totalEstimatedBudget },
|
||||||
|
{ where: { requestId } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Approve step 1 and move to step 2
|
||||||
|
const level1 = await ApprovalLevel.findOne({
|
||||||
|
where: { requestId, levelNumber: 1 }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (level1) {
|
||||||
|
await this.approvalService.approveLevel(
|
||||||
|
level1.levelId,
|
||||||
|
{ action: 'APPROVE', comments: 'Dealer proposal submitted' },
|
||||||
|
'system', // System approval
|
||||||
|
{ ipAddress: null, userAgent: null }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimService] Dealer proposal submitted for request: ${requestId}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[DealerClaimService] Error submitting dealer proposal:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit dealer completion documents (Step 5)
|
||||||
|
*/
|
||||||
|
async submitCompletionDocuments(
|
||||||
|
requestId: string,
|
||||||
|
completionData: {
|
||||||
|
activityCompletionDate: Date;
|
||||||
|
numberOfParticipants?: number;
|
||||||
|
closedExpenses: any[];
|
||||||
|
totalClosedExpenses: number;
|
||||||
|
completionDocuments: any[];
|
||||||
|
activityPhotos: any[];
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const request = await WorkflowRequest.findByPk(requestId);
|
||||||
|
// Handle backward compatibility: workflowType may be undefined in old environments
|
||||||
|
const workflowType = request?.workflowType || 'NON_TEMPLATIZED';
|
||||||
|
if (!request || workflowType !== 'CLAIM_MANAGEMENT') {
|
||||||
|
throw new Error('Invalid claim request');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.currentLevel !== 5) {
|
||||||
|
throw new Error('Completion documents can only be submitted at step 5');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save completion details
|
||||||
|
await DealerCompletionDetails.upsert({
|
||||||
|
requestId,
|
||||||
|
activityCompletionDate: completionData.activityCompletionDate,
|
||||||
|
numberOfParticipants: completionData.numberOfParticipants,
|
||||||
|
closedExpenses: completionData.closedExpenses,
|
||||||
|
totalClosedExpenses: completionData.totalClosedExpenses,
|
||||||
|
completionDocuments: completionData.completionDocuments,
|
||||||
|
activityPhotos: completionData.activityPhotos,
|
||||||
|
submittedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update closed expenses in claim details
|
||||||
|
await DealerClaimDetails.update(
|
||||||
|
{ closedExpenses: completionData.totalClosedExpenses },
|
||||||
|
{ where: { requestId } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Approve step 5 and move to step 6
|
||||||
|
const level5 = await ApprovalLevel.findOne({
|
||||||
|
where: { requestId, levelNumber: 5 }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (level5) {
|
||||||
|
await this.approvalService.approveLevel(
|
||||||
|
level5.levelId,
|
||||||
|
{ action: 'APPROVE', comments: 'Completion documents submitted' },
|
||||||
|
'system',
|
||||||
|
{ ipAddress: null, userAgent: null }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimService] Completion documents submitted for request: ${requestId}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[DealerClaimService] Error submitting completion documents:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update IO details (Step 3 - Department Lead)
|
||||||
|
* Validates IO number with SAP and blocks budget
|
||||||
|
*/
|
||||||
|
async updateIODetails(
|
||||||
|
requestId: string,
|
||||||
|
ioData: {
|
||||||
|
ioNumber: string;
|
||||||
|
availableBalance?: number;
|
||||||
|
blockedAmount?: number;
|
||||||
|
remainingBalance?: number;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Validate IO number with SAP
|
||||||
|
const ioValidation = await sapIntegrationService.validateIONumber(ioData.ioNumber);
|
||||||
|
|
||||||
|
if (!ioValidation.isValid) {
|
||||||
|
throw new Error(`Invalid IO number: ${ioValidation.error || 'IO number not found in SAP'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If amount is provided, block budget in SAP
|
||||||
|
let blockedAmount = ioData.blockedAmount || 0;
|
||||||
|
let remainingBalance = ioValidation.remainingBalance;
|
||||||
|
|
||||||
|
if (ioData.blockedAmount && ioData.blockedAmount > 0) {
|
||||||
|
const request = await WorkflowRequest.findByPk(requestId);
|
||||||
|
const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN';
|
||||||
|
|
||||||
|
const blockResult = await sapIntegrationService.blockBudget(
|
||||||
|
ioData.ioNumber,
|
||||||
|
ioData.blockedAmount,
|
||||||
|
requestNumber,
|
||||||
|
`Budget block for claim request ${requestNumber}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!blockResult.success) {
|
||||||
|
throw new Error(`Failed to block budget in SAP: ${blockResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
blockedAmount = blockResult.blockedAmount;
|
||||||
|
remainingBalance = blockResult.remainingBalance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update claim details with IO information
|
||||||
|
await DealerClaimDetails.update(
|
||||||
|
{
|
||||||
|
ioNumber: ioData.ioNumber,
|
||||||
|
ioAvailableBalance: ioValidation.availableBalance,
|
||||||
|
ioBlockedAmount: blockedAmount,
|
||||||
|
ioRemainingBalance: remainingBalance,
|
||||||
|
},
|
||||||
|
{ where: { requestId } }
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimService] IO details updated for request: ${requestId}`, {
|
||||||
|
ioNumber: ioData.ioNumber,
|
||||||
|
blockedAmount,
|
||||||
|
remainingBalance
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[DealerClaimService] Error updating IO details:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update e-invoice details (Step 7)
|
||||||
|
* Generates e-invoice via DMS integration
|
||||||
|
*/
|
||||||
|
async updateEInvoiceDetails(
|
||||||
|
requestId: string,
|
||||||
|
invoiceData?: {
|
||||||
|
eInvoiceNumber?: string;
|
||||||
|
eInvoiceDate?: Date;
|
||||||
|
dmsNumber?: string;
|
||||||
|
amount?: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
|
||||||
|
if (!claimDetails) {
|
||||||
|
throw new Error('Claim details not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = await WorkflowRequest.findByPk(requestId);
|
||||||
|
const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN';
|
||||||
|
|
||||||
|
// If invoice data not provided, generate via DMS
|
||||||
|
if (!invoiceData?.eInvoiceNumber) {
|
||||||
|
const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } });
|
||||||
|
const invoiceAmount = invoiceData?.amount || proposalDetails?.totalEstimatedBudget || claimDetails.estimatedBudget || 0;
|
||||||
|
|
||||||
|
const invoiceResult = await dmsIntegrationService.generateEInvoice({
|
||||||
|
requestNumber,
|
||||||
|
dealerCode: claimDetails.dealerCode,
|
||||||
|
dealerName: claimDetails.dealerName,
|
||||||
|
amount: invoiceAmount,
|
||||||
|
description: invoiceData?.description || `E-Invoice for claim request ${requestNumber}`,
|
||||||
|
ioNumber: claimDetails.ioNumber || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invoiceResult.success) {
|
||||||
|
throw new Error(`Failed to generate e-invoice: ${invoiceResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await DealerClaimDetails.update(
|
||||||
|
{
|
||||||
|
eInvoiceNumber: invoiceResult.eInvoiceNumber,
|
||||||
|
eInvoiceDate: invoiceResult.invoiceDate || new Date(),
|
||||||
|
dmsNumber: invoiceResult.dmsNumber,
|
||||||
|
},
|
||||||
|
{ where: { requestId } }
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimService] E-Invoice generated via DMS for request: ${requestId}`, {
|
||||||
|
eInvoiceNumber: invoiceResult.eInvoiceNumber,
|
||||||
|
dmsNumber: invoiceResult.dmsNumber
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Manual entry - just update the fields
|
||||||
|
await DealerClaimDetails.update(
|
||||||
|
{
|
||||||
|
eInvoiceNumber: invoiceData.eInvoiceNumber,
|
||||||
|
eInvoiceDate: invoiceData.eInvoiceDate || new Date(),
|
||||||
|
dmsNumber: invoiceData.dmsNumber,
|
||||||
|
},
|
||||||
|
{ where: { requestId } }
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimService] E-Invoice details manually updated for request: ${requestId}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[DealerClaimService] Error updating e-invoice details:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update credit note details (Step 8)
|
||||||
|
* Generates credit note via DMS integration
|
||||||
|
*/
|
||||||
|
async updateCreditNoteDetails(
|
||||||
|
requestId: string,
|
||||||
|
creditNoteData?: {
|
||||||
|
creditNoteNumber?: string;
|
||||||
|
creditNoteDate?: Date;
|
||||||
|
creditNoteAmount?: number;
|
||||||
|
reason?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
|
||||||
|
if (!claimDetails) {
|
||||||
|
throw new Error('Claim details not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!claimDetails.eInvoiceNumber) {
|
||||||
|
throw new Error('E-Invoice must be generated before creating credit note');
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = await WorkflowRequest.findByPk(requestId);
|
||||||
|
const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN';
|
||||||
|
|
||||||
|
// If credit note data not provided, generate via DMS
|
||||||
|
if (!creditNoteData?.creditNoteNumber) {
|
||||||
|
const creditNoteAmount = creditNoteData?.creditNoteAmount || claimDetails.closedExpenses || 0;
|
||||||
|
|
||||||
|
const creditNoteResult = await dmsIntegrationService.generateCreditNote({
|
||||||
|
requestNumber,
|
||||||
|
eInvoiceNumber: claimDetails.eInvoiceNumber,
|
||||||
|
dealerCode: claimDetails.dealerCode,
|
||||||
|
dealerName: claimDetails.dealerName,
|
||||||
|
amount: creditNoteAmount,
|
||||||
|
reason: creditNoteData?.reason || 'Claim settlement',
|
||||||
|
description: creditNoteData?.description || `Credit note for claim request ${requestNumber}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!creditNoteResult.success) {
|
||||||
|
throw new Error(`Failed to generate credit note: ${creditNoteResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await DealerClaimDetails.update(
|
||||||
|
{
|
||||||
|
creditNoteNumber: creditNoteResult.creditNoteNumber,
|
||||||
|
creditNoteDate: creditNoteResult.creditNoteDate || new Date(),
|
||||||
|
creditNoteAmount: creditNoteResult.creditNoteAmount,
|
||||||
|
},
|
||||||
|
{ where: { requestId } }
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimService] Credit note generated via DMS for request: ${requestId}`, {
|
||||||
|
creditNoteNumber: creditNoteResult.creditNoteNumber,
|
||||||
|
creditNoteAmount: creditNoteResult.creditNoteAmount
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Manual entry - just update the fields
|
||||||
|
await DealerClaimDetails.update(
|
||||||
|
{
|
||||||
|
creditNoteNumber: creditNoteData.creditNoteNumber,
|
||||||
|
creditNoteDate: creditNoteData.creditNoteDate || new Date(),
|
||||||
|
creditNoteAmount: creditNoteData.creditNoteAmount,
|
||||||
|
},
|
||||||
|
{ where: { requestId } }
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimService] Credit note details manually updated for request: ${requestId}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[DealerClaimService] Error updating credit note details:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
313
src/services/dmsIntegration.service.ts
Normal file
313
src/services/dmsIntegration.service.ts
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DMS (Document Management System) Integration Service
|
||||||
|
* Handles integration with DMS for e-invoice and credit note generation
|
||||||
|
*
|
||||||
|
* NOTE: This is a placeholder/stub implementation.
|
||||||
|
* Replace with actual DMS API integration based on your DMS system.
|
||||||
|
*/
|
||||||
|
export class DMSIntegrationService {
|
||||||
|
private dmsBaseUrl: string;
|
||||||
|
private dmsApiKey?: string;
|
||||||
|
private dmsUsername?: string;
|
||||||
|
private dmsPassword?: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.dmsBaseUrl = process.env.DMS_BASE_URL || '';
|
||||||
|
this.dmsApiKey = process.env.DMS_API_KEY;
|
||||||
|
this.dmsUsername = process.env.DMS_USERNAME;
|
||||||
|
this.dmsPassword = process.env.DMS_PASSWORD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if DMS integration is configured
|
||||||
|
*/
|
||||||
|
private isConfigured(): boolean {
|
||||||
|
return !!this.dmsBaseUrl && (!!this.dmsApiKey || (!!this.dmsUsername && !!this.dmsPassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate e-invoice in DMS
|
||||||
|
* @param invoiceData - Invoice data
|
||||||
|
* @returns E-invoice details including invoice number, DMS number, etc.
|
||||||
|
*/
|
||||||
|
async generateEInvoice(invoiceData: {
|
||||||
|
requestNumber: string;
|
||||||
|
dealerCode: string;
|
||||||
|
dealerName: string;
|
||||||
|
amount: number;
|
||||||
|
description: string;
|
||||||
|
ioNumber?: string;
|
||||||
|
taxDetails?: any;
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
eInvoiceNumber?: string;
|
||||||
|
dmsNumber?: string;
|
||||||
|
invoiceDate?: Date;
|
||||||
|
invoiceUrl?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
logger.warn('[DMS] DMS integration not configured, generating mock e-invoice');
|
||||||
|
// Return mock data for development/testing
|
||||||
|
const mockInvoiceNumber = `EINV-${Date.now()}`;
|
||||||
|
const mockDmsNumber = `DMS-${Date.now()}`;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
eInvoiceNumber: mockInvoiceNumber,
|
||||||
|
dmsNumber: mockDmsNumber,
|
||||||
|
invoiceDate: new Date(),
|
||||||
|
invoiceUrl: `https://dms.example.com/invoices/${mockInvoiceNumber}`,
|
||||||
|
error: 'DMS not configured - e-invoice generation simulated'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement actual DMS API call
|
||||||
|
// Example:
|
||||||
|
// const response = await axios.post(`${this.dmsBaseUrl}/api/invoices/generate`, {
|
||||||
|
// request_number: invoiceData.requestNumber,
|
||||||
|
// dealer_code: invoiceData.dealerCode,
|
||||||
|
// dealer_name: invoiceData.dealerName,
|
||||||
|
// amount: invoiceData.amount,
|
||||||
|
// description: invoiceData.description,
|
||||||
|
// io_number: invoiceData.ioNumber,
|
||||||
|
// tax_details: invoiceData.taxDetails
|
||||||
|
// }, {
|
||||||
|
// headers: {
|
||||||
|
// 'Authorization': `Bearer ${this.dmsApiKey}`,
|
||||||
|
// 'Content-Type': 'application/json'
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// return {
|
||||||
|
// success: response.data.success,
|
||||||
|
// eInvoiceNumber: response.data.e_invoice_number,
|
||||||
|
// dmsNumber: response.data.dms_number,
|
||||||
|
// invoiceDate: new Date(response.data.invoice_date),
|
||||||
|
// invoiceUrl: response.data.invoice_url
|
||||||
|
// };
|
||||||
|
|
||||||
|
logger.warn('[DMS] DMS e-invoice generation not implemented, generating mock invoice');
|
||||||
|
const mockInvoiceNumber = `EINV-${Date.now()}`;
|
||||||
|
const mockDmsNumber = `DMS-${Date.now()}`;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
eInvoiceNumber: mockInvoiceNumber,
|
||||||
|
dmsNumber: mockDmsNumber,
|
||||||
|
invoiceDate: new Date(),
|
||||||
|
invoiceUrl: `https://dms.example.com/invoices/${mockInvoiceNumber}`,
|
||||||
|
error: 'DMS API not implemented - e-invoice generation simulated'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[DMS] Error generating e-invoice:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate credit note in DMS
|
||||||
|
* @param creditNoteData - Credit note data
|
||||||
|
* @returns Credit note details including credit note number, amount, etc.
|
||||||
|
*/
|
||||||
|
async generateCreditNote(creditNoteData: {
|
||||||
|
requestNumber: string;
|
||||||
|
eInvoiceNumber: string;
|
||||||
|
dealerCode: string;
|
||||||
|
dealerName: string;
|
||||||
|
amount: number;
|
||||||
|
reason: string;
|
||||||
|
description?: string;
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
creditNoteNumber?: string;
|
||||||
|
creditNoteDate?: Date;
|
||||||
|
creditNoteAmount?: number;
|
||||||
|
creditNoteUrl?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
logger.warn('[DMS] DMS integration not configured, generating mock credit note');
|
||||||
|
// Return mock data for development/testing
|
||||||
|
const mockCreditNoteNumber = `CN-${Date.now()}`;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
creditNoteNumber: mockCreditNoteNumber,
|
||||||
|
creditNoteDate: new Date(),
|
||||||
|
creditNoteAmount: creditNoteData.amount,
|
||||||
|
creditNoteUrl: `https://dms.example.com/credit-notes/${mockCreditNoteNumber}`,
|
||||||
|
error: 'DMS not configured - credit note generation simulated'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement actual DMS API call
|
||||||
|
// Example:
|
||||||
|
// const response = await axios.post(`${this.dmsBaseUrl}/api/credit-notes/generate`, {
|
||||||
|
// request_number: creditNoteData.requestNumber,
|
||||||
|
// e_invoice_number: creditNoteData.eInvoiceNumber,
|
||||||
|
// dealer_code: creditNoteData.dealerCode,
|
||||||
|
// dealer_name: creditNoteData.dealerName,
|
||||||
|
// amount: creditNoteData.amount,
|
||||||
|
// reason: creditNoteData.reason,
|
||||||
|
// description: creditNoteData.description
|
||||||
|
// }, {
|
||||||
|
// headers: {
|
||||||
|
// 'Authorization': `Bearer ${this.dmsApiKey}`,
|
||||||
|
// 'Content-Type': 'application/json'
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// return {
|
||||||
|
// success: response.data.success,
|
||||||
|
// creditNoteNumber: response.data.credit_note_number,
|
||||||
|
// creditNoteDate: new Date(response.data.credit_note_date),
|
||||||
|
// creditNoteAmount: response.data.credit_note_amount,
|
||||||
|
// creditNoteUrl: response.data.credit_note_url
|
||||||
|
// };
|
||||||
|
|
||||||
|
logger.warn('[DMS] DMS credit note generation not implemented, generating mock credit note');
|
||||||
|
const mockCreditNoteNumber = `CN-${Date.now()}`;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
creditNoteNumber: mockCreditNoteNumber,
|
||||||
|
creditNoteDate: new Date(),
|
||||||
|
creditNoteAmount: creditNoteData.amount,
|
||||||
|
creditNoteUrl: `https://dms.example.com/credit-notes/${mockCreditNoteNumber}`,
|
||||||
|
error: 'DMS API not implemented - credit note generation simulated'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[DMS] Error generating credit note:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get invoice status from DMS
|
||||||
|
* @param eInvoiceNumber - E-invoice number
|
||||||
|
* @returns Invoice status and details
|
||||||
|
*/
|
||||||
|
async getInvoiceStatus(eInvoiceNumber: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
status?: string;
|
||||||
|
invoiceNumber?: string;
|
||||||
|
dmsNumber?: string;
|
||||||
|
invoiceDate?: Date;
|
||||||
|
amount?: number;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
logger.warn('[DMS] DMS integration not configured, returning mock invoice status');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: 'GENERATED',
|
||||||
|
invoiceNumber: eInvoiceNumber,
|
||||||
|
dmsNumber: `DMS-${Date.now()}`,
|
||||||
|
invoiceDate: new Date(),
|
||||||
|
amount: 0,
|
||||||
|
error: 'DMS not configured - invoice status simulated'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement actual DMS API call
|
||||||
|
// Example:
|
||||||
|
// const response = await axios.get(`${this.dmsBaseUrl}/api/invoices/${eInvoiceNumber}/status`, {
|
||||||
|
// headers: {
|
||||||
|
// 'Authorization': `Bearer ${this.dmsApiKey}`,
|
||||||
|
// 'Content-Type': 'application/json'
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// return {
|
||||||
|
// success: true,
|
||||||
|
// status: response.data.status,
|
||||||
|
// invoiceNumber: response.data.invoice_number,
|
||||||
|
// dmsNumber: response.data.dms_number,
|
||||||
|
// invoiceDate: new Date(response.data.invoice_date),
|
||||||
|
// amount: response.data.amount
|
||||||
|
// };
|
||||||
|
|
||||||
|
logger.warn('[DMS] DMS invoice status check not implemented, returning mock status');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: 'GENERATED',
|
||||||
|
invoiceNumber: eInvoiceNumber,
|
||||||
|
dmsNumber: `DMS-${Date.now()}`,
|
||||||
|
invoiceDate: new Date(),
|
||||||
|
amount: 0,
|
||||||
|
error: 'DMS API not implemented - invoice status simulated'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[DMS] Error getting invoice status:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download invoice document from DMS
|
||||||
|
* @param eInvoiceNumber - E-invoice number
|
||||||
|
* @returns Invoice document URL or file buffer
|
||||||
|
*/
|
||||||
|
async downloadInvoice(eInvoiceNumber: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
documentUrl?: string;
|
||||||
|
documentBuffer?: Buffer;
|
||||||
|
mimeType?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
logger.warn('[DMS] DMS integration not configured, returning mock download URL');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
documentUrl: `https://dms.example.com/invoices/${eInvoiceNumber}/download`,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
error: 'DMS not configured - download URL simulated'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement actual DMS API call
|
||||||
|
// Example:
|
||||||
|
// const response = await axios.get(`${this.dmsBaseUrl}/api/invoices/${eInvoiceNumber}/download`, {
|
||||||
|
// headers: {
|
||||||
|
// 'Authorization': `Bearer ${this.dmsApiKey}`
|
||||||
|
// },
|
||||||
|
// responseType: 'arraybuffer'
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// return {
|
||||||
|
// success: true,
|
||||||
|
// documentBuffer: Buffer.from(response.data),
|
||||||
|
// mimeType: response.headers['content-type'] || 'application/pdf'
|
||||||
|
// };
|
||||||
|
|
||||||
|
logger.warn('[DMS] DMS invoice download not implemented, returning mock URL');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
documentUrl: `https://dms.example.com/invoices/${eInvoiceNumber}/download`,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
error: 'DMS API not implemented - download URL simulated'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[DMS] Error downloading invoice:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dmsIntegrationService = new DMSIntegrationService();
|
||||||
|
|
||||||
241
src/services/enhancedTemplate.service.ts
Normal file
241
src/services/enhancedTemplate.service.ts
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import { WorkflowTemplate } from '../models/WorkflowTemplate';
|
||||||
|
import { WorkflowRequest } from '../models/WorkflowRequest';
|
||||||
|
import { ApprovalLevel } from '../models/ApprovalLevel';
|
||||||
|
import { TemplateFieldResolver, FormStepConfig } from './templateFieldResolver.service';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced Template Service
|
||||||
|
* Handles template-based workflow operations with dynamic form configuration
|
||||||
|
*/
|
||||||
|
export class EnhancedTemplateService {
|
||||||
|
private fieldResolver = new TemplateFieldResolver();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get form configuration for a template with resolved user references
|
||||||
|
*/
|
||||||
|
async getFormConfig(
|
||||||
|
templateId: string,
|
||||||
|
requestId?: string,
|
||||||
|
currentUserId?: string
|
||||||
|
): Promise<FormStepConfig[]> {
|
||||||
|
try {
|
||||||
|
const template = await WorkflowTemplate.findByPk(templateId);
|
||||||
|
if (!template) {
|
||||||
|
throw new Error('Template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepsConfig = (template.formStepsConfig || []) as FormStepConfig[];
|
||||||
|
|
||||||
|
// If request exists, resolve user references
|
||||||
|
if (requestId && currentUserId) {
|
||||||
|
const request = await WorkflowRequest.findByPk(requestId, {
|
||||||
|
include: [{ model: ApprovalLevel, as: 'approvalLevels' }]
|
||||||
|
});
|
||||||
|
if (request) {
|
||||||
|
return await this.resolveStepsWithUserData(stepsConfig, request, currentUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stepsConfig;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[EnhancedTemplateService] Error getting form config:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve user references in all steps
|
||||||
|
*/
|
||||||
|
private async resolveStepsWithUserData(
|
||||||
|
steps: FormStepConfig[],
|
||||||
|
request: WorkflowRequest,
|
||||||
|
currentUserId: string
|
||||||
|
): Promise<FormStepConfig[]> {
|
||||||
|
try {
|
||||||
|
// Get all approvers for context
|
||||||
|
const approvers = await ApprovalLevel.findAll({
|
||||||
|
where: { requestId: request.requestId }
|
||||||
|
});
|
||||||
|
const approverMap = new Map(
|
||||||
|
approvers.map(a => [a.levelNumber, a])
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolvedSteps = await Promise.all(
|
||||||
|
steps.map(async (step) => {
|
||||||
|
const resolvedFields = await this.fieldResolver.resolveUserReferences(
|
||||||
|
step,
|
||||||
|
request,
|
||||||
|
currentUserId,
|
||||||
|
{
|
||||||
|
currentLevel: request.currentLevel,
|
||||||
|
approvers: approverMap
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Merge resolved values into field defaults
|
||||||
|
const enrichedFields = step.fields.map(field => ({
|
||||||
|
...field,
|
||||||
|
defaultValue: resolvedFields[field.fieldId] || field.defaultValue
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...step,
|
||||||
|
fields: enrichedFields
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return resolvedSteps;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[EnhancedTemplateService] Error resolving steps:', error);
|
||||||
|
return steps; // Return original steps on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and save form data for a step
|
||||||
|
*/
|
||||||
|
async saveStepData(
|
||||||
|
templateId: string,
|
||||||
|
requestId: string,
|
||||||
|
stepNumber: number,
|
||||||
|
formData: Record<string, any>,
|
||||||
|
userId: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const template = await WorkflowTemplate.findByPk(templateId);
|
||||||
|
if (!template) {
|
||||||
|
throw new Error('Template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepsConfig = (template.formStepsConfig || []) as FormStepConfig[];
|
||||||
|
const stepConfig = stepsConfig.find(s => s.stepNumber === stepNumber);
|
||||||
|
|
||||||
|
if (!stepConfig) {
|
||||||
|
throw new Error(`Step ${stepNumber} not found in template`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
this.validateStepData(stepConfig, formData);
|
||||||
|
|
||||||
|
// Save to template-specific storage
|
||||||
|
await this.saveToTemplateStorage(template.workflowType, requestId, stepNumber, formData);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[EnhancedTemplateService] Error saving step data:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate step data against configuration
|
||||||
|
*/
|
||||||
|
private validateStepData(stepConfig: FormStepConfig, formData: Record<string, any>): void {
|
||||||
|
for (const field of stepConfig.fields) {
|
||||||
|
if (field.required && !formData[field.fieldId]) {
|
||||||
|
throw new Error(`Field ${field.label} is required`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply validation rules
|
||||||
|
if (field.validation && formData[field.fieldId]) {
|
||||||
|
const value = formData[field.fieldId];
|
||||||
|
if (field.validation.min !== undefined && value < field.validation.min) {
|
||||||
|
throw new Error(`${field.label} must be at least ${field.validation.min}`);
|
||||||
|
}
|
||||||
|
if (field.validation.max !== undefined && value > field.validation.max) {
|
||||||
|
throw new Error(`${field.label} must be at most ${field.validation.max}`);
|
||||||
|
}
|
||||||
|
if (field.validation.pattern) {
|
||||||
|
const regex = new RegExp(field.validation.pattern);
|
||||||
|
if (!regex.test(String(value))) {
|
||||||
|
throw new Error(`${field.label} format is invalid`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save to template-specific storage based on workflow type
|
||||||
|
*/
|
||||||
|
private async saveToTemplateStorage(
|
||||||
|
workflowType: string,
|
||||||
|
requestId: string,
|
||||||
|
stepNumber: number,
|
||||||
|
formData: Record<string, any>
|
||||||
|
): Promise<void> {
|
||||||
|
switch (workflowType) {
|
||||||
|
case 'CLAIM_MANAGEMENT':
|
||||||
|
await this.saveClaimManagementStepData(requestId, stepNumber, formData);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Generic storage for custom templates
|
||||||
|
logger.warn(`[EnhancedTemplateService] No specific storage handler for workflow type: ${workflowType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save claim management step data
|
||||||
|
*/
|
||||||
|
private async saveClaimManagementStepData(
|
||||||
|
requestId: string,
|
||||||
|
stepNumber: number,
|
||||||
|
formData: Record<string, any>
|
||||||
|
): Promise<void> {
|
||||||
|
const { DealerClaimDetails } = await import('../models/DealerClaimDetails');
|
||||||
|
const { DealerProposalDetails } = await import('../models/DealerProposalDetails');
|
||||||
|
const { DealerCompletionDetails } = await import('../models/DealerCompletionDetails');
|
||||||
|
|
||||||
|
switch (stepNumber) {
|
||||||
|
case 1:
|
||||||
|
// Save to dealer_claim_details
|
||||||
|
await DealerClaimDetails.upsert({
|
||||||
|
requestId,
|
||||||
|
activityName: formData.activity_name,
|
||||||
|
activityType: formData.activity_type,
|
||||||
|
dealerCode: formData.dealer_code,
|
||||||
|
dealerName: formData.dealer_name,
|
||||||
|
dealerEmail: formData.dealer_email,
|
||||||
|
dealerPhone: formData.dealer_phone,
|
||||||
|
dealerAddress: formData.dealer_address,
|
||||||
|
activityDate: formData.activity_date,
|
||||||
|
location: formData.location,
|
||||||
|
periodStartDate: formData.period_start_date,
|
||||||
|
periodEndDate: formData.period_end_date,
|
||||||
|
estimatedBudget: formData.estimated_budget,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
// Save to dealer_proposal_details
|
||||||
|
await DealerProposalDetails.upsert({
|
||||||
|
requestId,
|
||||||
|
costBreakup: formData.cost_breakup,
|
||||||
|
totalEstimatedBudget: formData.total_estimated_budget,
|
||||||
|
timelineMode: formData.timeline_mode,
|
||||||
|
expectedCompletionDate: formData.expected_completion_date,
|
||||||
|
expectedCompletionDays: formData.expected_completion_days,
|
||||||
|
dealerComments: formData.dealer_comments,
|
||||||
|
proposalDocumentPath: formData.proposal_document_path,
|
||||||
|
proposalDocumentUrl: formData.proposal_document_url,
|
||||||
|
submittedAt: new Date(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
// Save to dealer_completion_details
|
||||||
|
await DealerCompletionDetails.upsert({
|
||||||
|
requestId,
|
||||||
|
activityCompletionDate: formData.activity_completion_date,
|
||||||
|
numberOfParticipants: formData.number_of_participants,
|
||||||
|
closedExpenses: formData.closed_expenses,
|
||||||
|
totalClosedExpenses: formData.total_closed_expenses,
|
||||||
|
completionDocuments: formData.completion_documents,
|
||||||
|
activityPhotos: formData.activity_photos,
|
||||||
|
submittedAt: new Date(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logger.warn(`[EnhancedTemplateService] No storage handler for claim management step ${stepNumber}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -657,14 +657,44 @@ export class PauseService {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// Find all paused workflows where resume date has passed
|
// Find all paused workflows where resume date has passed
|
||||||
const pausedWorkflows = await WorkflowRequest.findAll({
|
// Handle backward compatibility: workflow_type column may not exist in old environments
|
||||||
where: {
|
let pausedWorkflows: WorkflowRequest[];
|
||||||
isPaused: true,
|
try {
|
||||||
pauseResumeDate: {
|
pausedWorkflows = await WorkflowRequest.findAll({
|
||||||
[Op.lte]: now
|
where: {
|
||||||
|
isPaused: true,
|
||||||
|
pauseResumeDate: {
|
||||||
|
[Op.lte]: now
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
// If error is due to missing workflow_type column, use raw query
|
||||||
|
if (error.message?.includes('workflow_type') || (error.message?.includes('column') && error.message?.includes('does not exist'))) {
|
||||||
|
logger.warn('[Pause] workflow_type column not found, using raw query for backward compatibility');
|
||||||
|
const { sequelize } = await import('../config/database');
|
||||||
|
const { QueryTypes } = await import('sequelize');
|
||||||
|
const results = await sequelize.query(`
|
||||||
|
SELECT request_id, is_paused, pause_resume_date
|
||||||
|
FROM workflow_requests
|
||||||
|
WHERE is_paused = true
|
||||||
|
AND pause_resume_date <= :now
|
||||||
|
`, {
|
||||||
|
replacements: { now },
|
||||||
|
type: QueryTypes.SELECT
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to WorkflowRequest-like objects
|
||||||
|
// results is an array of objects from SELECT query
|
||||||
|
pausedWorkflows = (results as any[]).map((r: any) => ({
|
||||||
|
requestId: r.request_id,
|
||||||
|
isPaused: r.is_paused,
|
||||||
|
pauseResumeDate: r.pause_resume_date
|
||||||
|
})) as any;
|
||||||
|
} else {
|
||||||
|
throw error; // Re-throw if it's a different error
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
let resumedCount = 0;
|
let resumedCount = 0;
|
||||||
for (const workflow of pausedWorkflows) {
|
for (const workflow of pausedWorkflows) {
|
||||||
|
|||||||
298
src/services/sapIntegration.service.ts
Normal file
298
src/services/sapIntegration.service.ts
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SAP Integration Service
|
||||||
|
* Handles integration with SAP for IO validation and budget blocking
|
||||||
|
*
|
||||||
|
* NOTE: This is a placeholder/stub implementation.
|
||||||
|
* Replace with actual SAP API integration based on your SAP system.
|
||||||
|
*/
|
||||||
|
export class SAPIntegrationService {
|
||||||
|
private sapBaseUrl: string;
|
||||||
|
private sapApiKey?: string;
|
||||||
|
private sapUsername?: string;
|
||||||
|
private sapPassword?: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.sapBaseUrl = process.env.SAP_BASE_URL || '';
|
||||||
|
this.sapApiKey = process.env.SAP_API_KEY;
|
||||||
|
this.sapUsername = process.env.SAP_USERNAME;
|
||||||
|
this.sapPassword = process.env.SAP_PASSWORD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if SAP integration is configured
|
||||||
|
*/
|
||||||
|
private isConfigured(): boolean {
|
||||||
|
return !!this.sapBaseUrl && (!!this.sapApiKey || (!!this.sapUsername && !!this.sapPassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate IO number and get IO details
|
||||||
|
* @param ioNumber - IO (Internal Order) number from SAP
|
||||||
|
* @returns IO details including available balance, blocked amount, etc.
|
||||||
|
*/
|
||||||
|
async validateIONumber(ioNumber: string): Promise<{
|
||||||
|
isValid: boolean;
|
||||||
|
ioNumber: string;
|
||||||
|
availableBalance: number;
|
||||||
|
blockedAmount: number;
|
||||||
|
remainingBalance: number;
|
||||||
|
currency: string;
|
||||||
|
description?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
logger.warn('[SAP] SAP integration not configured, returning mock data');
|
||||||
|
// Return mock data for development/testing
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
ioNumber,
|
||||||
|
availableBalance: 1000000,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 1000000,
|
||||||
|
currency: 'INR',
|
||||||
|
description: 'Mock IO Data (SAP not configured)'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement actual SAP API call
|
||||||
|
// Example:
|
||||||
|
// const response = await axios.get(`${this.sapBaseUrl}/api/io/${ioNumber}`, {
|
||||||
|
// headers: {
|
||||||
|
// 'Authorization': `Bearer ${this.sapApiKey}`,
|
||||||
|
// 'Content-Type': 'application/json'
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// return {
|
||||||
|
// isValid: response.data.valid,
|
||||||
|
// ioNumber: response.data.io_number,
|
||||||
|
// availableBalance: response.data.available_balance,
|
||||||
|
// blockedAmount: response.data.blocked_amount,
|
||||||
|
// remainingBalance: response.data.remaining_balance,
|
||||||
|
// currency: response.data.currency,
|
||||||
|
// description: response.data.description
|
||||||
|
// };
|
||||||
|
|
||||||
|
logger.warn('[SAP] SAP API integration not implemented, returning mock data');
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
ioNumber,
|
||||||
|
availableBalance: 1000000,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 1000000,
|
||||||
|
currency: 'INR',
|
||||||
|
description: 'Mock IO Data (SAP API not implemented)'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[SAP] Error validating IO number:', error);
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
ioNumber,
|
||||||
|
availableBalance: 0,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 0,
|
||||||
|
currency: 'INR',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block budget in SAP for a claim request
|
||||||
|
* @param ioNumber - IO number
|
||||||
|
* @param amount - Amount to block
|
||||||
|
* @param requestNumber - Request number for reference
|
||||||
|
* @param description - Description of the block
|
||||||
|
* @returns Blocking confirmation details
|
||||||
|
*/
|
||||||
|
async blockBudget(
|
||||||
|
ioNumber: string,
|
||||||
|
amount: number,
|
||||||
|
requestNumber: string,
|
||||||
|
description?: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
blockId?: string;
|
||||||
|
blockedAmount: number;
|
||||||
|
remainingBalance: number;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
logger.warn('[SAP] SAP integration not configured, budget blocking skipped');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
blockedAmount: amount,
|
||||||
|
remainingBalance: 1000000 - amount,
|
||||||
|
error: 'SAP not configured - budget blocking simulated'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement actual SAP API call to block budget
|
||||||
|
// Example:
|
||||||
|
// const response = await axios.post(`${this.sapBaseUrl}/api/io/${ioNumber}/block`, {
|
||||||
|
// amount,
|
||||||
|
// reference: requestNumber,
|
||||||
|
// description: description || `Budget block for request ${requestNumber}`
|
||||||
|
// }, {
|
||||||
|
// headers: {
|
||||||
|
// 'Authorization': `Bearer ${this.sapApiKey}`,
|
||||||
|
// 'Content-Type': 'application/json'
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// return {
|
||||||
|
// success: response.data.success,
|
||||||
|
// blockId: response.data.block_id,
|
||||||
|
// blockedAmount: response.data.blocked_amount,
|
||||||
|
// remainingBalance: response.data.remaining_balance
|
||||||
|
// };
|
||||||
|
|
||||||
|
logger.warn('[SAP] SAP budget blocking not implemented, simulating block');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
blockId: `BLOCK-${Date.now()}`,
|
||||||
|
blockedAmount: amount,
|
||||||
|
remainingBalance: 1000000 - amount,
|
||||||
|
error: 'SAP API not implemented - budget blocking simulated'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[SAP] Error blocking budget:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 0,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release blocked budget in SAP
|
||||||
|
* @param ioNumber - IO number
|
||||||
|
* @param blockId - Block ID from previous block operation
|
||||||
|
* @param requestNumber - Request number for reference
|
||||||
|
* @returns Release confirmation
|
||||||
|
*/
|
||||||
|
async releaseBudget(
|
||||||
|
ioNumber: string,
|
||||||
|
blockId: string,
|
||||||
|
requestNumber: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
releasedAmount: number;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
logger.warn('[SAP] SAP integration not configured, budget release skipped');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
releasedAmount: 0,
|
||||||
|
error: 'SAP not configured - budget release simulated'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement actual SAP API call to release budget
|
||||||
|
// Example:
|
||||||
|
// const response = await axios.post(`${this.sapBaseUrl}/api/io/${ioNumber}/release`, {
|
||||||
|
// block_id: blockId,
|
||||||
|
// reference: requestNumber
|
||||||
|
// }, {
|
||||||
|
// headers: {
|
||||||
|
// 'Authorization': `Bearer ${this.sapApiKey}`,
|
||||||
|
// 'Content-Type': 'application/json'
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// return {
|
||||||
|
// success: response.data.success,
|
||||||
|
// releasedAmount: response.data.released_amount
|
||||||
|
// };
|
||||||
|
|
||||||
|
logger.warn('[SAP] SAP budget release not implemented, simulating release');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
releasedAmount: 0,
|
||||||
|
error: 'SAP API not implemented - budget release simulated'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[SAP] Error releasing budget:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
releasedAmount: 0,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dealer information from SAP
|
||||||
|
* @param dealerCode - Dealer code
|
||||||
|
* @returns Dealer details from SAP
|
||||||
|
*/
|
||||||
|
async getDealerInfo(dealerCode: string): Promise<{
|
||||||
|
isValid: boolean;
|
||||||
|
dealerCode: string;
|
||||||
|
dealerName?: string;
|
||||||
|
dealerEmail?: string;
|
||||||
|
dealerPhone?: string;
|
||||||
|
dealerAddress?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
logger.warn('[SAP] SAP integration not configured, returning mock dealer data');
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
dealerCode,
|
||||||
|
dealerName: `Dealer ${dealerCode}`,
|
||||||
|
dealerEmail: `dealer${dealerCode}@example.com`,
|
||||||
|
dealerPhone: '+91-XXXXXXXXXX',
|
||||||
|
dealerAddress: 'Mock Address'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement actual SAP API call to get dealer info
|
||||||
|
// Example:
|
||||||
|
// const response = await axios.get(`${this.sapBaseUrl}/api/dealers/${dealerCode}`, {
|
||||||
|
// headers: {
|
||||||
|
// 'Authorization': `Bearer ${this.sapApiKey}`,
|
||||||
|
// 'Content-Type': 'application/json'
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// return {
|
||||||
|
// isValid: response.data.valid,
|
||||||
|
// dealerCode: response.data.dealer_code,
|
||||||
|
// dealerName: response.data.dealer_name,
|
||||||
|
// dealerEmail: response.data.dealer_email,
|
||||||
|
// dealerPhone: response.data.dealer_phone,
|
||||||
|
// dealerAddress: response.data.dealer_address
|
||||||
|
// };
|
||||||
|
|
||||||
|
logger.warn('[SAP] SAP dealer lookup not implemented, returning mock data');
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
dealerCode,
|
||||||
|
dealerName: `Dealer ${dealerCode}`,
|
||||||
|
dealerEmail: `dealer${dealerCode}@example.com`,
|
||||||
|
dealerPhone: '+91-XXXXXXXXXX',
|
||||||
|
dealerAddress: 'Mock Address'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[SAP] Error getting dealer info:', error);
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
dealerCode,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sapIntegrationService = new SAPIntegrationService();
|
||||||
|
|
||||||
246
src/services/template.service.ts
Normal file
246
src/services/template.service.ts
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import { WorkflowTemplate } from '../models/WorkflowTemplate';
|
||||||
|
import { WorkflowRequest } from '../models/WorkflowRequest';
|
||||||
|
import { User } from '../models/User';
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template Service
|
||||||
|
* Handles CRUD operations for workflow templates
|
||||||
|
*/
|
||||||
|
export class TemplateService {
|
||||||
|
/**
|
||||||
|
* Create a new workflow template
|
||||||
|
*/
|
||||||
|
async createTemplate(
|
||||||
|
userId: string,
|
||||||
|
templateData: {
|
||||||
|
templateName: string;
|
||||||
|
templateCode?: string;
|
||||||
|
templateDescription?: string;
|
||||||
|
templateCategory?: string;
|
||||||
|
workflowType?: string;
|
||||||
|
approvalLevelsConfig?: any;
|
||||||
|
defaultTatHours?: number;
|
||||||
|
formStepsConfig?: any;
|
||||||
|
userFieldMappings?: any;
|
||||||
|
dynamicApproverConfig?: any;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<WorkflowTemplate> {
|
||||||
|
try {
|
||||||
|
// Validate template code uniqueness if provided
|
||||||
|
if (templateData.templateCode) {
|
||||||
|
const existing = await WorkflowTemplate.findOne({
|
||||||
|
where: { templateCode: templateData.templateCode }
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Template code '${templateData.templateCode}' already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await WorkflowTemplate.create({
|
||||||
|
templateName: templateData.templateName,
|
||||||
|
templateCode: templateData.templateCode,
|
||||||
|
templateDescription: templateData.templateDescription,
|
||||||
|
templateCategory: templateData.templateCategory,
|
||||||
|
workflowType: templateData.workflowType || templateData.templateCode?.toUpperCase(),
|
||||||
|
approvalLevelsConfig: templateData.approvalLevelsConfig,
|
||||||
|
defaultTatHours: templateData.defaultTatHours || 24,
|
||||||
|
formStepsConfig: templateData.formStepsConfig,
|
||||||
|
userFieldMappings: templateData.userFieldMappings,
|
||||||
|
dynamicApproverConfig: templateData.dynamicApproverConfig,
|
||||||
|
isActive: templateData.isActive !== undefined ? templateData.isActive : true,
|
||||||
|
isSystemTemplate: false, // Admin-created templates are not system templates
|
||||||
|
usageCount: 0,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`[TemplateService] Created template: ${template.templateId}`);
|
||||||
|
return template;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TemplateService] Error creating template:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get template by ID
|
||||||
|
*/
|
||||||
|
async getTemplate(templateId: string): Promise<WorkflowTemplate | null> {
|
||||||
|
try {
|
||||||
|
return await WorkflowTemplate.findByPk(templateId, {
|
||||||
|
include: [{ model: User, as: 'creator' }]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TemplateService] Error getting template:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get template by code
|
||||||
|
*/
|
||||||
|
async getTemplateByCode(templateCode: string): Promise<WorkflowTemplate | null> {
|
||||||
|
try {
|
||||||
|
return await WorkflowTemplate.findOne({
|
||||||
|
where: { templateCode },
|
||||||
|
include: [{ model: User, as: 'creator' }]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TemplateService] Error getting template by code:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all templates with filters
|
||||||
|
*/
|
||||||
|
async listTemplates(filters?: {
|
||||||
|
category?: string;
|
||||||
|
workflowType?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
isSystemTemplate?: boolean;
|
||||||
|
search?: string;
|
||||||
|
}): Promise<WorkflowTemplate[]> {
|
||||||
|
try {
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (filters?.category) {
|
||||||
|
where.templateCategory = filters.category;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters?.workflowType) {
|
||||||
|
where.workflowType = filters.workflowType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters?.isActive !== undefined) {
|
||||||
|
where.isActive = filters.isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters?.isSystemTemplate !== undefined) {
|
||||||
|
where.isSystemTemplate = filters.isSystemTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters?.search) {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ templateName: { [Op.iLike]: `%${filters.search}%` } },
|
||||||
|
{ templateCode: { [Op.iLike]: `%${filters.search}%` } },
|
||||||
|
{ templateDescription: { [Op.iLike]: `%${filters.search}%` } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await WorkflowTemplate.findAll({
|
||||||
|
where,
|
||||||
|
include: [{ model: User, as: 'creator' }],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TemplateService] Error listing templates:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update template
|
||||||
|
*/
|
||||||
|
async updateTemplate(
|
||||||
|
templateId: string,
|
||||||
|
userId: string,
|
||||||
|
updateData: {
|
||||||
|
templateName?: string;
|
||||||
|
templateDescription?: string;
|
||||||
|
templateCategory?: string;
|
||||||
|
approvalLevelsConfig?: any;
|
||||||
|
defaultTatHours?: number;
|
||||||
|
formStepsConfig?: any;
|
||||||
|
userFieldMappings?: any;
|
||||||
|
dynamicApproverConfig?: any;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<WorkflowTemplate> {
|
||||||
|
try {
|
||||||
|
const template = await WorkflowTemplate.findByPk(templateId);
|
||||||
|
if (!template) {
|
||||||
|
throw new Error('Template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if template is system template (system templates should not be modified)
|
||||||
|
if (template.isSystemTemplate && updateData.approvalLevelsConfig) {
|
||||||
|
throw new Error('Cannot modify approval levels of system templates');
|
||||||
|
}
|
||||||
|
|
||||||
|
await template.update(updateData);
|
||||||
|
|
||||||
|
logger.info(`[TemplateService] Updated template: ${templateId}`);
|
||||||
|
return template;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TemplateService] Error updating template:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete template (soft delete by setting isActive to false)
|
||||||
|
*/
|
||||||
|
async deleteTemplate(templateId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const template = await WorkflowTemplate.findByPk(templateId);
|
||||||
|
if (!template) {
|
||||||
|
throw new Error('Template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if template is in use
|
||||||
|
const usageCount = await WorkflowRequest.count({
|
||||||
|
where: { templateId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (usageCount > 0) {
|
||||||
|
throw new Error(`Cannot delete template: ${usageCount} request(s) are using this template`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// System templates cannot be deleted
|
||||||
|
if (template.isSystemTemplate) {
|
||||||
|
throw new Error('Cannot delete system templates');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete by deactivating
|
||||||
|
await template.update({ isActive: false });
|
||||||
|
|
||||||
|
logger.info(`[TemplateService] Deleted (deactivated) template: ${templateId}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TemplateService] Error deleting template:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active templates for workflow creation
|
||||||
|
*/
|
||||||
|
async getActiveTemplates(): Promise<WorkflowTemplate[]> {
|
||||||
|
try {
|
||||||
|
return await WorkflowTemplate.findAll({
|
||||||
|
where: { isActive: true },
|
||||||
|
order: [['templateName', 'ASC']]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TemplateService] Error getting active templates:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment usage count when template is used
|
||||||
|
*/
|
||||||
|
async incrementUsageCount(templateId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await WorkflowTemplate.increment('usageCount', {
|
||||||
|
where: { templateId }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TemplateService] Error incrementing usage count:', error);
|
||||||
|
// Don't throw - this is not critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
287
src/services/templateFieldResolver.service.ts
Normal file
287
src/services/templateFieldResolver.service.ts
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
import { WorkflowRequest } from '../models/WorkflowRequest';
|
||||||
|
import { ApprovalLevel } from '../models/ApprovalLevel';
|
||||||
|
import { User } from '../models/User';
|
||||||
|
import { Participant } from '../models/Participant';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for user reference configuration in form fields
|
||||||
|
*/
|
||||||
|
export interface UserReference {
|
||||||
|
role: 'initiator' | 'dealer' | 'approver' | 'team_lead' | 'department_lead' | 'current_approver' | 'previous_approver';
|
||||||
|
level?: number; // For approver: which approval level
|
||||||
|
field: 'name' | 'email' | 'phone' | 'department' | 'employee_id' | 'all'; // Which user field to reference
|
||||||
|
autoPopulate: boolean; // Auto-fill from user data
|
||||||
|
editable: boolean; // Can user edit the auto-populated value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for form step configuration
|
||||||
|
*/
|
||||||
|
export interface FormStepConfig {
|
||||||
|
stepNumber: number;
|
||||||
|
stepName: string;
|
||||||
|
stepDescription?: string;
|
||||||
|
fields: FormFieldConfig[];
|
||||||
|
userReferences?: UserReferenceConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormFieldConfig {
|
||||||
|
fieldId: string;
|
||||||
|
fieldType: string;
|
||||||
|
label: string;
|
||||||
|
required: boolean;
|
||||||
|
defaultValue?: any;
|
||||||
|
userReference?: UserReference;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserReferenceConfig {
|
||||||
|
role: string;
|
||||||
|
captureFields: string[];
|
||||||
|
autoPopulateFrom: 'workflow' | 'user_profile' | 'approval_level';
|
||||||
|
allowOverride: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service to resolve user references in template forms
|
||||||
|
*/
|
||||||
|
export class TemplateFieldResolver {
|
||||||
|
/**
|
||||||
|
* Resolve user reference fields in a step
|
||||||
|
*/
|
||||||
|
async resolveUserReferences(
|
||||||
|
stepConfig: FormStepConfig,
|
||||||
|
request: WorkflowRequest,
|
||||||
|
currentUserId: string,
|
||||||
|
context?: {
|
||||||
|
currentLevel?: number;
|
||||||
|
approvers?: Map<number, ApprovalLevel>;
|
||||||
|
}
|
||||||
|
): Promise<Record<string, any>> {
|
||||||
|
const resolvedFields: Record<string, any> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const field of stepConfig.fields) {
|
||||||
|
if (field.userReference) {
|
||||||
|
const userData = await this.getUserDataForReference(
|
||||||
|
field.userReference,
|
||||||
|
request,
|
||||||
|
currentUserId,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
|
||||||
|
if (field.userReference.autoPopulate && userData) {
|
||||||
|
resolvedFields[field.fieldId] = this.extractUserField(
|
||||||
|
userData,
|
||||||
|
field.userReference.field
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TemplateFieldResolver] Error resolving user references:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user data based on reference configuration
|
||||||
|
*/
|
||||||
|
private async getUserDataForReference(
|
||||||
|
userRef: UserReference,
|
||||||
|
request: WorkflowRequest,
|
||||||
|
currentUserId: string,
|
||||||
|
context?: any
|
||||||
|
): Promise<User | null> {
|
||||||
|
try {
|
||||||
|
switch (userRef.role) {
|
||||||
|
case 'initiator':
|
||||||
|
return await User.findByPk(request.initiatorId);
|
||||||
|
|
||||||
|
case 'dealer':
|
||||||
|
// Get dealer from participants
|
||||||
|
const dealerParticipant = await Participant.findOne({
|
||||||
|
where: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
participantType: 'DEALER' as any,
|
||||||
|
isActive: true
|
||||||
|
},
|
||||||
|
include: [{ model: User, as: 'user' }]
|
||||||
|
});
|
||||||
|
return dealerParticipant?.user || null;
|
||||||
|
|
||||||
|
case 'approver':
|
||||||
|
if (userRef.level && context?.approvers) {
|
||||||
|
const approverLevel = context.approvers.get(userRef.level);
|
||||||
|
if (approverLevel?.approverId) {
|
||||||
|
return await User.findByPk(approverLevel.approverId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to current approver
|
||||||
|
const currentLevel = await ApprovalLevel.findOne({
|
||||||
|
where: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
levelNumber: context?.currentLevel || request.currentLevel,
|
||||||
|
status: 'PENDING' as any
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (currentLevel?.approverId) {
|
||||||
|
return await User.findByPk(currentLevel.approverId);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case 'team_lead':
|
||||||
|
// Find team lead based on initiator's manager
|
||||||
|
const initiator = await User.findByPk(request.initiatorId);
|
||||||
|
if (initiator?.manager) {
|
||||||
|
return await User.findOne({
|
||||||
|
where: {
|
||||||
|
email: initiator.manager,
|
||||||
|
role: 'MANAGEMENT' as any
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case 'department_lead':
|
||||||
|
const initiatorUser = await User.findByPk(request.initiatorId);
|
||||||
|
if (initiatorUser?.department) {
|
||||||
|
return await User.findOne({
|
||||||
|
where: {
|
||||||
|
department: initiatorUser.department,
|
||||||
|
role: 'MANAGEMENT' as any
|
||||||
|
},
|
||||||
|
order: [['created_at', 'DESC']]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case 'current_approver':
|
||||||
|
const currentApprovalLevel = await ApprovalLevel.findOne({
|
||||||
|
where: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
status: 'PENDING' as any
|
||||||
|
},
|
||||||
|
order: [['level_number', 'ASC']]
|
||||||
|
});
|
||||||
|
if (currentApprovalLevel?.approverId) {
|
||||||
|
return await User.findByPk(currentApprovalLevel.approverId);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case 'previous_approver':
|
||||||
|
const previousLevel = request.currentLevel - 1;
|
||||||
|
if (previousLevel > 0) {
|
||||||
|
const previousApprovalLevel = await ApprovalLevel.findOne({
|
||||||
|
where: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
levelNumber: previousLevel
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (previousApprovalLevel?.approverId) {
|
||||||
|
return await User.findByPk(previousApprovalLevel.approverId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[TemplateFieldResolver] Error getting user data for role ${userRef.role}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract specific field from user data
|
||||||
|
*/
|
||||||
|
private extractUserField(user: User, field: string): any {
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
switch (field) {
|
||||||
|
case 'name':
|
||||||
|
return user.displayName || `${user.firstName || ''} ${user.lastName || ''}`.trim();
|
||||||
|
case 'email':
|
||||||
|
return user.email;
|
||||||
|
case 'phone':
|
||||||
|
return user.phone || user.mobilePhone;
|
||||||
|
case 'department':
|
||||||
|
return user.department;
|
||||||
|
case 'employee_id':
|
||||||
|
return user.employeeId;
|
||||||
|
case 'all':
|
||||||
|
return {
|
||||||
|
name: user.displayName || `${user.firstName || ''} ${user.lastName || ''}`.trim(),
|
||||||
|
email: user.email,
|
||||||
|
phone: user.phone || user.mobilePhone,
|
||||||
|
department: user.department,
|
||||||
|
employeeId: user.employeeId
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve dynamic approver based on configuration
|
||||||
|
*/
|
||||||
|
async resolveDynamicApprover(
|
||||||
|
level: number,
|
||||||
|
config: any, // DynamicApproverConfig
|
||||||
|
request: WorkflowRequest
|
||||||
|
): Promise<User | null> {
|
||||||
|
if (!config?.enabled || !config?.approverSelection?.dynamicRules) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rule = config.approverSelection.dynamicRules.find((r: any) => r.level === level);
|
||||||
|
if (!rule) return null;
|
||||||
|
|
||||||
|
const criteria = rule.selectionCriteria;
|
||||||
|
|
||||||
|
switch (criteria.type) {
|
||||||
|
case 'role':
|
||||||
|
return await User.findOne({
|
||||||
|
where: {
|
||||||
|
role: criteria.value as any
|
||||||
|
},
|
||||||
|
order: [['created_at', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
case 'department':
|
||||||
|
const initiator = await User.findByPk(request.initiatorId);
|
||||||
|
const deptValue = criteria.value?.replace('${initiator.department}', initiator?.department || '') || initiator?.department;
|
||||||
|
if (deptValue) {
|
||||||
|
return await User.findOne({
|
||||||
|
where: {
|
||||||
|
department: deptValue,
|
||||||
|
role: 'MANAGEMENT' as any
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case 'manager':
|
||||||
|
const initiatorUser = await User.findByPk(request.initiatorId);
|
||||||
|
if (initiatorUser?.manager) {
|
||||||
|
return await User.findOne({
|
||||||
|
where: {
|
||||||
|
email: initiatorUser.manager
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TemplateFieldResolver] Error resolving dynamic approver:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -9,6 +9,7 @@ export interface SSOUserData {
|
|||||||
designation?: string;
|
designation?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
reportingManagerId?: string;
|
reportingManagerId?: string;
|
||||||
|
manager?: string; // Optional - Manager name from Okta profile
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSOConfig {
|
export interface SSOConfig {
|
||||||
@ -20,6 +21,7 @@ export interface SSOConfig {
|
|||||||
oktaDomain: string;
|
oktaDomain: string;
|
||||||
oktaClientId: string;
|
oktaClientId: string;
|
||||||
oktaClientSecret: string;
|
oktaClientSecret: string;
|
||||||
|
oktaApiToken?: string; // Optional - SSWS token for Okta Users API
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthTokens {
|
export interface AuthTokens {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user