dealer claim tab added some information mapped on overview tab and also fist approve can take action

This commit is contained in:
laxmanhalaki 2025-12-09 20:44:54 +05:30
parent e642e39a0a
commit 5d90e58bf9
42 changed files with 8206 additions and 130 deletions

View 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

View 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

View 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.).

File diff suppressed because it is too large Load Diff

View 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

View 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
```

View 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

View 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

View File

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

View File

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

View 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);
}
}
}

View 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);
}
}
}

View File

@ -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);
} }

View 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);
}
}
}

View 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');
}
}

View 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');
}

View 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');
}

View 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');
}

View 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');
}
}

View 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 };

View 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 };

View 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 };

View 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 };

View File

@ -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']
} }
] ]
} }

View 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 };

View File

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

View 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;

View 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;

View File

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

View 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;

View File

@ -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
View 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 };

View File

@ -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);
// Override email with username if needed
if (!userData.email && username) {
userData.email = username;
} }
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('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

View 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;
}
}

View 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;
}
}
}

View 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();

View 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}`);
}
}
}

View File

@ -657,7 +657,10 @@ 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
let pausedWorkflows: WorkflowRequest[];
try {
pausedWorkflows = await WorkflowRequest.findAll({
where: { where: {
isPaused: true, isPaused: true,
pauseResumeDate: { pauseResumeDate: {
@ -665,6 +668,33 @@ export class PauseService {
} }
} }
}); });
} 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) {

View 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();

View 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
}
}
}

View 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;
}
}
}

View File

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