main code pulled and merged

This commit is contained in:
laxmanhalaki 2025-12-13 13:33:59 +05:30
commit ce652d260c
75 changed files with 13048 additions and 300 deletions

View File

@ -0,0 +1,7 @@
<<<<<<<< HEAD:build/assets/conclusionApi-uNxtglEr.js
import{a as t}from"./index-9cOIFSn9.js";import"./radix-vendor-C2EbRL2a.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-BmvKDhMD.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-CRr9x_Jp.js";async function m(n){return(await t.post(`/conclusions/${n}/generate`)).data.data}async function d(n,o){return(await t.post(`/conclusions/${n}/finalize`,{finalRemark:o})).data.data}async function f(n){return(await t.get(`/conclusions/${n}`)).data.data}export{d as finalizeConclusion,m as generateConclusion,f as getConclusion};
//# sourceMappingURL=conclusionApi-uNxtglEr.js.map
========
import{a as t}from"./index-CogACwP9.js";import"./radix-vendor-C2EbRL2a.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-BpFwwBOf.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-CRr9x_Jp.js";async function m(n){return(await t.post(`/conclusions/${n}/generate`)).data.data}async function d(n,o){return(await t.post(`/conclusions/${n}/finalize`,{finalRemark:o})).data.data}async function f(n){return(await t.get(`/conclusions/${n}`)).data.data}export{d as finalizeConclusion,m as generateConclusion,f as getConclusion};
//# sourceMappingURL=conclusionApi-CGl-93sb.js.map
>>>>>>>> ee361a0c4ba611c87efe7f97d044a7c711024d1b:build/assets/conclusionApi-CGl-93sb.js

View File

@ -0,0 +1,5 @@
<<<<<<<< HEAD:build/assets/conclusionApi-uNxtglEr.js.map
{"version":3,"file":"conclusionApi-uNxtglEr.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAKA,eAAsBC,EAAcJ,EAA8C,CAEhF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB"}
========
{"version":3,"file":"conclusionApi-CGl-93sb.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAKA,eAAsBC,EAAcJ,EAA8C,CAEhF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB"}
>>>>>>>> ee361a0c4ba611c87efe7f97d044a7c711024d1b:build/assets/conclusionApi-CGl-93sb.js.map

View File

@ -1,2 +1,7 @@
<<<<<<<< HEAD:build/assets/conclusionApi-uNxtglEr.js
import{a as t}from"./index-9cOIFSn9.js";import"./radix-vendor-C2EbRL2a.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-BmvKDhMD.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-CRr9x_Jp.js";async function m(n){return(await t.post(`/conclusions/${n}/generate`)).data.data}async function d(n,o){return(await t.post(`/conclusions/${n}/finalize`,{finalRemark:o})).data.data}async function f(n){return(await t.get(`/conclusions/${n}`)).data.data}export{d as finalizeConclusion,m as generateConclusion,f as getConclusion}; import{a as t}from"./index-9cOIFSn9.js";import"./radix-vendor-C2EbRL2a.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-BmvKDhMD.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-CRr9x_Jp.js";async function m(n){return(await t.post(`/conclusions/${n}/generate`)).data.data}async function d(n,o){return(await t.post(`/conclusions/${n}/finalize`,{finalRemark:o})).data.data}async function f(n){return(await t.get(`/conclusions/${n}`)).data.data}export{d as finalizeConclusion,m as generateConclusion,f as getConclusion};
//# sourceMappingURL=conclusionApi-uNxtglEr.js.map //# sourceMappingURL=conclusionApi-uNxtglEr.js.map
========
import{a as t}from"./index-CogACwP9.js";import"./radix-vendor-C2EbRL2a.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-BpFwwBOf.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-CRr9x_Jp.js";async function m(n){return(await t.post(`/conclusions/${n}/generate`)).data.data}async function d(n,o){return(await t.post(`/conclusions/${n}/finalize`,{finalRemark:o})).data.data}async function f(n){return(await t.get(`/conclusions/${n}`)).data.data}export{d as finalizeConclusion,m as generateConclusion,f as getConclusion};
//# sourceMappingURL=conclusionApi-CGl-93sb.js.map
>>>>>>>> ee361a0c4ba611c87efe7f97d044a7c711024d1b:build/assets/conclusionApi-CGl-93sb.js

View File

@ -1 +1,5 @@
<<<<<<<< HEAD:build/assets/conclusionApi-uNxtglEr.js.map
{"version":3,"file":"conclusionApi-uNxtglEr.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAKA,eAAsBC,EAAcJ,EAA8C,CAEhF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB"} {"version":3,"file":"conclusionApi-uNxtglEr.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAKA,eAAsBC,EAAcJ,EAA8C,CAEhF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB"}
========
{"version":3,"file":"conclusionApi-CGl-93sb.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAKA,eAAsBC,EAAcJ,EAA8C,CAEhF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB"}
>>>>>>>> ee361a0c4ba611c87efe7f97d044a7c711024d1b:build/assets/conclusionApi-CGl-93sb.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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,181 @@
# Dealer Claim Management - Fresh Start Guide
## Overview
This guide helps you start fresh with the dealer claim management system by cleaning up all existing data and ensuring the database structure is ready for new requests.
## Prerequisites
1. **Database Migrations**: Ensure all migrations are up to date, including the new tables:
- `internal_orders` (for IO details)
- `claim_budget_tracking` (for comprehensive budget tracking)
2. **Backup** (Optional but Recommended):
- If you have important data, backup your database before running cleanup
## Fresh Start Steps
### Step 1: Run Database Migrations
Ensure all new tables are created:
```bash
cd Re_Backend
npm run migrate
```
This will create:
- ✅ `internal_orders` table (for IO details with `ioRemark`)
- ✅ `claim_budget_tracking` table (for comprehensive budget tracking)
- ✅ All other dealer claim related tables
### Step 2: Clean Up All Existing Dealer Claims
Run the cleanup script to remove all existing CLAIM_MANAGEMENT requests:
```bash
npm run cleanup:dealer-claims
```
**What this script does:**
- Finds all workflow requests with `workflow_type = 'CLAIM_MANAGEMENT'`
- Deletes all related data from:
- `claim_budget_tracking`
- `internal_orders`
- `dealer_proposal_cost_items`
- `dealer_completion_details`
- `dealer_proposal_details`
- `dealer_claim_details`
- `activities`
- `work_notes`
- `documents`
- `participants`
- `approval_levels`
- `subscriptions`
- `notifications`
- `request_summaries`
- `shared_summaries`
- `conclusion_remarks`
- `tat_alerts`
- `workflow_requests` (finally)
**Note:** This script uses a database transaction, so if any step fails, all changes will be rolled back.
### Step 3: Verify Cleanup
After running the cleanup script, verify that no CLAIM_MANAGEMENT requests remain:
```sql
SELECT COUNT(*) FROM workflow_requests WHERE workflow_type = 'CLAIM_MANAGEMENT';
-- Should return 0
```
### Step 4: Seed Dealers (If Needed)
If you need to seed dealer users:
```bash
npm run seed:dealers
```
## Database Structure Summary
### New Tables Created
1. **`internal_orders`** - Dedicated table for IO (Internal Order) details
- `io_id` (PK)
- `request_id` (FK, unique)
- `io_number`
- `io_remark` ✅ (dedicated field, not in comments)
- `io_available_balance`
- `io_blocked_amount`
- `io_remaining_balance`
- `organized_by` (FK to users)
- `organized_at`
- `status` (PENDING, BLOCKED, RELEASED, CANCELLED)
2. **`claim_budget_tracking`** - Comprehensive budget tracking
- `budget_id` (PK)
- `request_id` (FK, unique)
- `initial_estimated_budget`
- `proposal_estimated_budget`
- `approved_budget`
- `io_blocked_amount`
- `closed_expenses`
- `final_claim_amount`
- `credit_note_amount`
- `budget_status` (DRAFT, PROPOSED, APPROVED, BLOCKED, CLOSED, SETTLED)
- `variance_amount` & `variance_percentage`
- Audit fields (last_modified_by, last_modified_at, modification_reason)
### Existing Tables (Enhanced)
- `dealer_claim_details` - Main claim information
- `dealer_proposal_details` - Step 1: Dealer proposal
- `dealer_proposal_cost_items` - Cost breakdown items
- `dealer_completion_details` - Step 5: Completion documents
## What's New
### 1. IO Details in Separate Table
- ✅ IO remark is now stored in `internal_orders.io_remark` (not parsed from comments)
- ✅ Tracks who organized the IO (`organized_by`, `organized_at`)
- ✅ Better data integrity and querying
### 2. Comprehensive Budget Tracking
- ✅ All budget-related values in one place
- ✅ Tracks budget lifecycle (DRAFT → PROPOSED → APPROVED → BLOCKED → CLOSED → SETTLED)
- ✅ Calculates variance automatically
- ✅ Audit trail for budget modifications
### 3. Proper Data Structure
- ✅ Estimated budget: `claimDetails.estimatedBudget` or `proposalDetails.totalEstimatedBudget`
- ✅ Claim amount: `completionDetails.totalClosedExpenses` or `budgetTracking.finalClaimAmount`
- ✅ IO details: `internalOrder` table (separate, dedicated)
- ✅ E-Invoice: `claimDetails.eInvoiceNumber`, `claimDetails.eInvoiceDate`
- ✅ Credit Note: `claimDetails.creditNoteNumber`, `claimDetails.creditNoteAmount`
## Next Steps After Cleanup
1. **Create New Claim Requests**: Use the API or frontend to create fresh dealer claim requests
2. **Test Workflow**: Go through the 8-step workflow to ensure everything works correctly
3. **Verify Data Storage**: Check that IO details and budget tracking are properly stored
## Troubleshooting
### If Cleanup Fails
1. Check database connection
2. Verify foreign key constraints are not blocking deletion
3. Check logs for specific error messages
4. The script uses transactions, so partial deletions won't occur
### If Tables Don't Exist
Run migrations again:
```bash
npm run migrate
```
### If You Need to Restore Data
If you backed up before cleanup, restore from your backup. The cleanup script does not create backups automatically.
## API Endpoints Ready
After cleanup, you can use these endpoints:
- `POST /api/v1/dealer-claims` - Create new claim request
- `POST /api/v1/dealer-claims/:requestId/proposal` - Submit proposal (Step 1)
- `PUT /api/v1/dealer-claims/:requestId/io` - Update IO details (Step 3)
- `POST /api/v1/dealer-claims/:requestId/completion` - Submit completion (Step 5)
- `PUT /api/v1/dealer-claims/:requestId/e-invoice` - Update e-invoice (Step 7)
- `PUT /api/v1/dealer-claims/:requestId/credit-note` - Update credit note (Step 8)
## Summary
**Cleanup Script**: `npm run cleanup:dealer-claims`
**Migrations**: `npm run migrate`
**Fresh Start**: Database is ready for new dealer claim requests
**Proper Structure**: IO details and budget tracking in dedicated tables

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

507
docs/ERD.mermaid Normal file
View File

@ -0,0 +1,507 @@
erDiagram
users ||--o{ workflow_requests : initiates
users ||--o{ approval_levels : approves
users ||--o{ participants : participates
users ||--o{ work_notes : posts
users ||--o{ documents : uploads
users ||--o{ activities : performs
users ||--o{ notifications : receives
users ||--o{ user_sessions : has
workflow_requests ||--|{ approval_levels : has
workflow_requests ||--o{ participants : involves
workflow_requests ||--o{ documents : contains
workflow_requests ||--o{ work_notes : has
workflow_requests ||--o{ activities : logs
workflow_requests ||--o{ tat_tracking : monitors
workflow_requests ||--o{ notifications : triggers
workflow_requests ||--|| conclusion_remarks : concludes
workflow_requests ||--|| dealer_claim_details : claim_details
workflow_requests ||--|| dealer_proposal_details : proposal_details
dealer_proposal_details ||--o{ dealer_proposal_cost_items : cost_items
workflow_requests ||--|| dealer_completion_details : completion_details
workflow_requests ||--|| internal_orders : internal_order
workflow_requests ||--|| claim_budget_tracking : budget_tracking
workflow_requests ||--|| claim_invoices : claim_invoice
workflow_requests ||--|| claim_credit_notes : claim_credit_note
work_notes ||--o{ work_note_attachments : has
notifications ||--o{ email_logs : sends
notifications ||--o{ sms_logs : sends
workflow_requests ||--o{ report_cache : caches
workflow_requests ||--o{ audit_logs : audits
workflow_requests ||--o{ workflow_templates : templates
users ||--o{ system_settings : updates
users {
uuid user_id PK
varchar employee_id
varchar okta_sub
varchar email
varchar first_name
varchar last_name
varchar display_name
varchar department
varchar designation
varchar phone
varchar manager
varchar second_email
text job_title
varchar employee_number
varchar postal_address
varchar mobile_phone
jsonb ad_groups
jsonb location
boolean is_active
enum role
timestamp last_login
timestamp created_at
timestamp updated_at
}
workflow_requests {
uuid request_id PK
varchar request_number
uuid initiator_id FK
varchar template_type
varchar title
text description
enum priority
enum status
integer current_level
integer total_levels
decimal total_tat_hours
timestamp submission_date
timestamp closure_date
text conclusion_remark
text ai_generated_conclusion
boolean is_draft
boolean is_deleted
timestamp created_at
timestamp updated_at
}
approval_levels {
uuid level_id PK
uuid request_id FK
integer level_number
varchar level_name
uuid approver_id FK
varchar approver_email
varchar approver_name
decimal tat_hours
integer tat_days
enum status
timestamp level_start_time
timestamp level_end_time
timestamp action_date
text comments
text rejection_reason
boolean is_final_approver
decimal elapsed_hours
decimal remaining_hours
decimal tat_percentage_used
timestamp created_at
timestamp updated_at
}
participants {
uuid participant_id PK
uuid request_id FK
uuid user_id FK
varchar user_email
varchar user_name
enum participant_type
boolean can_comment
boolean can_view_documents
boolean can_download_documents
boolean notification_enabled
uuid added_by FK
timestamp added_at
boolean is_active
}
documents {
uuid document_id PK
uuid request_id FK
uuid uploaded_by FK
varchar file_name
varchar original_file_name
varchar file_type
varchar file_extension
bigint file_size
varchar file_path
varchar storage_url
varchar mime_type
varchar checksum
boolean is_google_doc
varchar google_doc_url
enum category
integer version
uuid parent_document_id
boolean is_deleted
integer download_count
timestamp uploaded_at
}
work_notes {
uuid note_id PK
uuid request_id FK
uuid user_id FK
varchar user_name
varchar user_role
text message
varchar message_type
boolean is_priority
boolean has_attachment
uuid parent_note_id
uuid[] mentioned_users
jsonb reactions
boolean is_edited
boolean is_deleted
timestamp created_at
timestamp updated_at
}
work_note_attachments {
uuid attachment_id PK
uuid note_id FK
varchar file_name
varchar file_type
bigint file_size
varchar file_path
varchar storage_url
boolean is_downloadable
integer download_count
timestamp uploaded_at
}
activities {
uuid activity_id PK
uuid request_id FK
uuid user_id FK
varchar user_name
varchar activity_type
text activity_description
varchar activity_category
varchar severity
jsonb metadata
boolean is_system_event
varchar ip_address
text user_agent
timestamp created_at
}
notifications {
uuid notification_id PK
uuid user_id FK
uuid request_id FK
varchar notification_type
varchar title
text message
boolean is_read
enum priority
varchar action_url
boolean action_required
jsonb metadata
varchar[] sent_via
boolean email_sent
boolean sms_sent
boolean push_sent
timestamp read_at
timestamp expires_at
timestamp created_at
}
tat_tracking {
uuid tracking_id PK
uuid request_id FK
uuid level_id FK
varchar tracking_type
enum tat_status
decimal total_tat_hours
decimal elapsed_hours
decimal remaining_hours
decimal percentage_used
boolean threshold_50_breached
timestamp threshold_50_alerted_at
boolean threshold_80_breached
timestamp threshold_80_alerted_at
boolean threshold_100_breached
timestamp threshold_100_alerted_at
integer alert_count
timestamp last_calculated_at
}
conclusion_remarks {
uuid conclusion_id PK
uuid request_id FK
text ai_generated_remark
varchar ai_model_used
decimal ai_confidence_score
text final_remark
uuid edited_by FK
boolean is_edited
integer edit_count
jsonb approval_summary
jsonb document_summary
text[] key_discussion_points
timestamp generated_at
timestamp finalized_at
}
audit_logs {
uuid audit_id PK
uuid user_id FK
varchar entity_type
uuid entity_id
varchar action
varchar action_category
jsonb old_values
jsonb new_values
text changes_summary
varchar ip_address
text user_agent
varchar session_id
varchar request_method
varchar request_url
integer response_status
integer execution_time_ms
timestamp created_at
}
user_sessions {
uuid session_id PK
uuid user_id FK
varchar session_token
varchar refresh_token
varchar ip_address
text user_agent
varchar device_type
varchar browser
varchar os
timestamp login_at
timestamp last_activity_at
timestamp logout_at
timestamp expires_at
boolean is_active
varchar logout_reason
}
email_logs {
uuid email_log_id PK
uuid request_id FK
uuid notification_id FK
varchar recipient_email
uuid recipient_user_id FK
text[] cc_emails
text[] bcc_emails
varchar subject
text body
varchar email_type
varchar status
integer send_attempts
timestamp sent_at
timestamp failed_at
text failure_reason
timestamp opened_at
timestamp clicked_at
timestamp created_at
}
sms_logs {
uuid sms_log_id PK
uuid request_id FK
uuid notification_id FK
varchar recipient_phone
uuid recipient_user_id FK
text message
varchar sms_type
varchar status
integer send_attempts
timestamp sent_at
timestamp delivered_at
timestamp failed_at
text failure_reason
varchar sms_provider
varchar sms_provider_message_id
decimal cost
timestamp created_at
}
system_settings {
uuid setting_id PK
varchar setting_key
text setting_value
varchar setting_type
varchar setting_category
text description
boolean is_editable
boolean is_sensitive
jsonb validation_rules
text default_value
uuid updated_by FK
timestamp created_at
timestamp updated_at
}
workflow_templates {
uuid template_id PK
varchar template_name
text template_description
varchar template_category
jsonb approval_levels_config
decimal default_tat_hours
boolean is_active
integer usage_count
uuid created_by FK
timestamp created_at
timestamp updated_at
}
report_cache {
uuid cache_id PK
varchar report_type
jsonb report_params
jsonb report_data
uuid generated_by FK
timestamp generated_at
timestamp expires_at
integer access_count
timestamp last_accessed_at
}
dealer_claim_details {
uuid claim_id PK
uuid request_id
varchar activity_name
varchar activity_type
varchar dealer_code
varchar dealer_name
varchar dealer_email
varchar dealer_phone
text dealer_address
date activity_date
varchar location
date period_start_date
date period_end_date
timestamp created_at
timestamp updated_at
}
dealer_proposal_details {
uuid proposal_id PK
uuid request_id
string proposal_document_path
string proposal_document_url
decimal total_estimated_budget
string timeline_mode
date expected_completion_date
int expected_completion_days
text dealer_comments
date submitted_at
timestamp created_at
timestamp updated_at
}
dealer_proposal_cost_items {
uuid cost_item_id PK
uuid proposal_id FK
uuid request_id FK
string item_description
decimal amount
int item_order
timestamp created_at
timestamp updated_at
}
dealer_completion_details {
uuid completion_id PK
uuid request_id
date activity_completion_date
int number_of_participants
decimal total_closed_expenses
date submitted_at
timestamp created_at
timestamp updated_at
}
dealer_completion_expenses {
uuid expense_id PK
uuid request_id
uuid completion_id
string description
decimal amount
timestamp created_at
timestamp updated_at
}
internal_orders {
uuid io_id PK
uuid request_id
string io_number
text io_remark
decimal io_available_balance
decimal io_blocked_amount
decimal io_remaining_balance
uuid organized_by FK
date organized_at
string sap_document_number
enum status
timestamp created_at
timestamp updated_at
}
claim_budget_tracking {
uuid budget_id PK
uuid request_id
decimal initial_estimated_budget
decimal proposal_estimated_budget
date proposal_submitted_at
decimal approved_budget
date approved_at
uuid approved_by FK
decimal io_blocked_amount
date io_blocked_at
decimal closed_expenses
date closed_expenses_submitted_at
decimal final_claim_amount
date final_claim_amount_approved_at
uuid final_claim_amount_approved_by FK
decimal credit_note_amount
date credit_note_issued_at
enum budget_status
string currency
decimal variance_amount
decimal variance_percentage
uuid last_modified_by FK
date last_modified_at
text modification_reason
timestamp created_at
timestamp updated_at
}
claim_invoices {
uuid invoice_id PK
uuid request_id
string invoice_number
date invoice_date
string dms_number
decimal amount
string status
text description
timestamp created_at
timestamp updated_at
}
claim_credit_notes {
uuid credit_note_id PK
uuid request_id
string credit_note_number
date credit_note_date
decimal credit_note_amount
string status
text reason
text description
timestamp created_at
timestamp updated_at
}

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,164 @@
# Migration and Setup Summary
## ✅ Current Status
### Tables Created by Migrations
All **6 new dealer claim tables** are included in the migration system:
1. ✅ `dealer_claim_details` - Main claim information
2. ✅ `dealer_proposal_details` - Step 1: Dealer proposal
3. ✅ `dealer_completion_details` - Step 5: Completion documents
4. ✅ `dealer_proposal_cost_items` - Cost breakdown items
5. ✅ `internal_orders` ⭐ - IO details with dedicated fields
6. ✅ `claim_budget_tracking` ⭐ - Comprehensive budget tracking
## Migration Commands
### 1. **`npm run migrate`** ✅
**Status:** ✅ **Fully configured**
This command runs `src/scripts/migrate.ts` which includes **ALL** migrations including:
- ✅ All dealer claim tables (m25-m28)
- ✅ New tables: `internal_orders` (m27) and `claim_budget_tracking` (m28)
**Usage:**
```bash
npm run migrate
```
**What it does:**
- Checks which migrations have already run (via `migrations` table)
- Runs only pending migrations
- Marks them as executed
- Creates all new tables automatically
---
### 2. **`npm run dev`** ✅
**Status:** ✅ **Now fixed and configured**
This command runs:
```bash
npm run setup && nodemon --exec ts-node ...
```
Which calls `npm run setup``src/scripts/auto-setup.ts`
**What `auto-setup.ts` does:**
1. ✅ Checks if database exists, creates if missing
2. ✅ Installs PostgreSQL extensions (uuid-ossp)
3. ✅ **Runs all pending migrations** (including dealer claim tables)
4. ✅ Tests database connection
**Fixed:** ✅ Now includes all dealer claim migrations (m29-m35)
**Usage:**
```bash
npm run dev
```
This will automatically:
- Create database if needed
- Run all migrations (including new tables)
- Start the development server
---
### 3. **`npm run setup`** ✅
**Status:** ✅ **Now fixed and configured**
Same as what `npm run dev` calls - runs `auto-setup.ts`
**Usage:**
```bash
npm run setup
```
---
## Migration Files Included
### In `migrate.ts` (for `npm run migrate`):
- ✅ `20251210-add-workflow-type-support` (m22)
- ✅ `20251210-enhance-workflow-templates` (m23)
- ✅ `20251210-add-template-id-foreign-key` (m24)
- ✅ `20251210-create-dealer-claim-tables` (m25) - Creates 3 tables
- ✅ `20251210-create-proposal-cost-items-table` (m26)
- ✅ `20251211-create-internal-orders-table` (m27) ⭐ NEW
- ✅ `20251211-create-claim-budget-tracking-table` (m28) ⭐ NEW
### In `auto-setup.ts` (for `npm run dev` / `npm run setup`):
- ✅ All migrations from `migrate.ts` are now included (m29-m35)
---
## What Gets Created
When you run either `npm run migrate` or `npm run dev`, these tables will be created:
### Dealer Claim Tables (from `20251210-create-dealer-claim-tables.ts`):
1. `dealer_claim_details`
2. `dealer_proposal_details`
3. `dealer_completion_details`
### Additional Tables:
4. `dealer_proposal_cost_items` (from `20251210-create-proposal-cost-items-table.ts`)
5. `internal_orders` ⭐ (from `20251211-create-internal-orders-table.ts`)
6. `claim_budget_tracking` ⭐ (from `20251211-create-claim-budget-tracking-table.ts`)
---
## Verification
After running migrations, verify tables exist:
```sql
-- Check if new tables exist
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN (
'dealer_claim_details',
'dealer_proposal_details',
'dealer_completion_details',
'dealer_proposal_cost_items',
'internal_orders',
'claim_budget_tracking'
)
ORDER BY table_name;
```
Should return 6 rows.
---
## Summary
| Command | Runs Migrations? | Includes New Tables? | Status |
|---------|------------------|---------------------|--------|
| `npm run migrate` | ✅ Yes | ✅ Yes | ✅ Working |
| `npm run dev` | ✅ Yes | ✅ Yes | ✅ Fixed |
| `npm run setup` | ✅ Yes | ✅ Yes | ✅ Fixed |
**All commands now create the new tables automatically!** 🎉
---
## Next Steps
1. **Run migrations:**
```bash
npm run migrate
```
OR
```bash
npm run dev # This will also run migrations via setup
```
2. **Verify tables created:**
Check the database to confirm all 6 tables exist.
3. **Start using:**
The tables are ready for dealer claim management!

216
docs/NEW_TABLES_SUMMARY.md Normal file
View File

@ -0,0 +1,216 @@
# New Tables Created for Dealer Claim Management
## Overview
This document lists all the new database tables created specifically for the Dealer Claim Management system.
## Tables Created
### 1. **`dealer_claim_details`**
**Migration:** `20251210-create-dealer-claim-tables.ts`
**Purpose:** Main table storing claim-specific information
**Key Fields:**
- `claim_id` (PK)
- `request_id` (FK to `workflow_requests`, unique)
- `activity_name`, `activity_type`
- `dealer_code`, `dealer_name`, `dealer_email`, `dealer_phone`, `dealer_address`
- `activity_date`, `location`
- `period_start_date`, `period_end_date`
- `estimated_budget`, `closed_expenses`
- `io_number`, `io_available_balance`, `io_blocked_amount`, `io_remaining_balance` (legacy - now in `internal_orders`)
- `sap_document_number`, `dms_number`
- `e_invoice_number`, `e_invoice_date`
- `credit_note_number`, `credit_note_date`, `credit_note_amount`
**Created:** December 10, 2025
---
### 2. **`dealer_proposal_details`**
**Migration:** `20251210-create-dealer-claim-tables.ts`
**Purpose:** Stores dealer proposal submission data (Step 1 of workflow)
**Key Fields:**
- `proposal_id` (PK)
- `request_id` (FK to `workflow_requests`, unique)
- `proposal_document_path`, `proposal_document_url`
- `cost_breakup` (JSONB - legacy, now use `dealer_proposal_cost_items`)
- `total_estimated_budget`
- `timeline_mode` ('date' | 'days')
- `expected_completion_date`, `expected_completion_days`
- `dealer_comments`
- `submitted_at`
**Created:** December 10, 2025
---
### 3. **`dealer_completion_details`**
**Migration:** `20251210-create-dealer-claim-tables.ts`
**Purpose:** Stores dealer completion documents and expenses (Step 5 of workflow)
**Key Fields:**
- `completion_id` (PK)
- `request_id` (FK to `workflow_requests`, unique)
- `activity_completion_date`
- `number_of_participants`
- `closed_expenses` (JSONB array)
- `total_closed_expenses`
- `completion_documents` (JSONB array)
- `activity_photos` (JSONB array)
- `submitted_at`
**Created:** December 10, 2025
---
### 4. **`dealer_proposal_cost_items`**
**Migration:** `20251210-create-proposal-cost-items-table.ts`
**Purpose:** Separate table for cost breakdown items (replaces JSONB in `dealer_proposal_details`)
**Key Fields:**
- `cost_item_id` (PK)
- `proposal_id` (FK to `dealer_proposal_details`)
- `request_id` (FK to `workflow_requests` - denormalized for easier querying)
- `item_description`
- `amount` (DECIMAL 15,2)
- `item_order` (for maintaining order in cost breakdown)
**Benefits:**
- Better querying and filtering
- Easier to update individual cost items
- Better for analytics and reporting
- Maintains referential integrity
**Created:** December 10, 2025
---
### 5. **`internal_orders`** ⭐ NEW
**Migration:** `20251211-create-internal-orders-table.ts`
**Purpose:** Dedicated table for IO (Internal Order) details with proper structure
**Key Fields:**
- `io_id` (PK)
- `request_id` (FK to `workflow_requests`, unique - one IO per request)
- `io_number` (STRING 50)
- `io_remark` (TEXT) ⭐ - Dedicated field for IO remarks (not in comments)
- `io_available_balance` (DECIMAL 15,2)
- `io_blocked_amount` (DECIMAL 15,2)
- `io_remaining_balance` (DECIMAL 15,2)
- `organized_by` (FK to `users`) ⭐ - Tracks who organized the IO
- `organized_at` (DATE) ⭐ - When IO was organized
- `sap_document_number` (STRING 100)
- `status` (ENUM: 'PENDING', 'BLOCKED', 'RELEASED', 'CANCELLED')
**Why This Table:**
- Previously IO details were stored in `dealer_claim_details` table
- IO remark was being parsed from comments
- Now dedicated table with proper fields and relationships
- Better data integrity and querying
**Created:** December 11, 2025
---
### 6. **`claim_budget_tracking`** ⭐ NEW
**Migration:** `20251211-create-claim-budget-tracking-table.ts`
**Purpose:** Comprehensive budget tracking throughout the claim lifecycle
**Key Fields:**
- `budget_id` (PK)
- `request_id` (FK to `workflow_requests`, unique - one budget record per request)
**Budget Values:**
- `initial_estimated_budget` - From claim creation
- `proposal_estimated_budget` - From Step 1 (Dealer Proposal)
- `approved_budget` - From Step 2 (Requestor Evaluation)
- `io_blocked_amount` - From Step 3 (Department Lead - IO blocking)
- `closed_expenses` - From Step 5 (Dealer Completion)
- `final_claim_amount` - From Step 6 (Requestor Claim Approval)
- `credit_note_amount` - From Step 8 (Finance)
**Tracking Fields:**
- `proposal_submitted_at`
- `approved_at`, `approved_by` (FK to `users`)
- `io_blocked_at`
- `closed_expenses_submitted_at`
- `final_claim_amount_approved_at`, `final_claim_amount_approved_by` (FK to `users`)
- `credit_note_issued_at`
**Status & Analysis:**
- `budget_status` (ENUM: 'DRAFT', 'PROPOSED', 'APPROVED', 'BLOCKED', 'CLOSED', 'SETTLED')
- `currency` (STRING 3, default: 'INR')
- `variance_amount` - Difference between approved and closed expenses
- `variance_percentage` - Variance as percentage
**Audit Fields:**
- `last_modified_by` (FK to `users`)
- `last_modified_at`
- `modification_reason` (TEXT)
**Why This Table:**
- Previously budget data was scattered across multiple tables
- No single source of truth for budget lifecycle
- No audit trail for budget modifications
- Now comprehensive tracking with status and variance calculation
**Created:** December 11, 2025
---
## Summary
### Total New Tables: **6**
1. ✅ `dealer_claim_details` - Main claim information
2. ✅ `dealer_proposal_details` - Step 1: Dealer proposal
3. ✅ `dealer_completion_details` - Step 5: Completion documents
4. ✅ `dealer_proposal_cost_items` - Cost breakdown items
5. ✅ `internal_orders` ⭐ - IO details with dedicated fields
6. ✅ `claim_budget_tracking` ⭐ - Comprehensive budget tracking
### Most Recent Additions (December 11, 2025):
- **`internal_orders`** - Proper IO data structure with `ioRemark` field
- **`claim_budget_tracking`** - Complete budget lifecycle tracking
## Migration Order
Run migrations in this order:
```bash
npm run migrate
```
The migrations will run in chronological order:
1. `20251210-create-dealer-claim-tables.ts` (creates tables 1-3)
2. `20251210-create-proposal-cost-items-table.ts` (creates table 4)
3. `20251211-create-internal-orders-table.ts` (creates table 5)
4. `20251211-create-claim-budget-tracking-table.ts` (creates table 6)
## Relationships
```
workflow_requests (1)
├── dealer_claim_details (1:1)
├── dealer_proposal_details (1:1)
│ └── dealer_proposal_cost_items (1:many)
├── dealer_completion_details (1:1)
├── internal_orders (1:1) ⭐ NEW
└── claim_budget_tracking (1:1) ⭐ NEW
```
## Notes
- All tables have `request_id` foreign key to `workflow_requests`
- Most tables have unique constraint on `request_id` (one record per request)
- `dealer_proposal_cost_items` can have multiple items per proposal
- All tables use UUID primary keys
- All tables have `created_at` and `updated_at` timestamps

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

@ -5,7 +5,7 @@
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {
"start": "npm run setup && npm run build && npm run start:prod", "start": "npm run setup && npm run build && npm run start:prod",
"dev": "npm run setup && nodemon --exec ts-node -r tsconfig-paths/register src/server.ts", "dev": "npm run setup && npm run migrate && nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
"dev:no-setup": "nodemon --exec ts-node -r tsconfig-paths/register src/server.ts", "dev:no-setup": "nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
"build": "tsc && tsc-alias", "build": "tsc && tsc-alias",
"build:watch": "tsc --watch", "build:watch": "tsc --watch",
@ -17,7 +17,9 @@
"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",
"cleanup:dealer-claims": "ts-node -r tsconfig-paths/register src/scripts/cleanup-dealer-claims.ts"
}, },
"dependencies": { "dependencies": {
"@google-cloud/storage": "^7.18.0", "@google-cloud/storage": "^7.18.0",

View File

@ -66,6 +66,8 @@ export const constants = {
REFERENCE: 'REFERENCE', REFERENCE: 'REFERENCE',
FINAL: 'FINAL', FINAL: 'FINAL',
OTHER: 'OTHER', OTHER: 'OTHER',
COMPLETION_DOC: 'COMPLETION_DOC',
ACTIVITY_PHOTO: 'ACTIVITY_PHOTO',
}, },
// Work Note Types // Work Note Types

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

@ -782,15 +782,15 @@ export const assignRoleByEmail = async (req: Request, res: Response): Promise<vo
// User doesn't exist, need to fetch from Okta and create // User doesn't exist, need to fetch from Okta and create
logger.info(`[Admin] User ${email} not found in database, fetching from Okta...`); logger.info(`[Admin] User ${email} not found in database, fetching from Okta...`);
// Import UserService to search Okta // Import UserService to fetch full profile from Okta
const { UserService } = await import('@services/user.service'); const { UserService } = await import('@services/user.service');
const userService = new UserService(); const userService = new UserService();
try { try {
// Search Okta for this user // Fetch full user profile from Okta Users API (includes manager, jobTitle, etc.)
const oktaUsers = await userService.searchUsers(email, 1); const oktaUserData = await userService.fetchAndExtractOktaUserByEmail(email);
if (!oktaUsers || oktaUsers.length === 0) { if (!oktaUserData) {
res.status(404).json({ res.status(404).json({
success: false, success: false,
error: 'User not found in Okta. Please ensure the email is correct.' error: 'User not found in Okta. Please ensure the email is correct.'
@ -798,25 +798,15 @@ export const assignRoleByEmail = async (req: Request, res: Response): Promise<vo
return; return;
} }
const oktaUser = oktaUsers[0]; // Create user in our database via centralized userService with all fields including manager
const ensured = await userService.createOrUpdateUser({
// Create user in our database ...oktaUserData,
user = await User.create({ role, // Set the assigned role
email: oktaUser.email, isActive: true, // Ensure user is active
oktaSub: (oktaUser as any).userId || (oktaUser as any).oktaSub, // Okta user ID as oktaSub
employeeId: (oktaUser as any).employeeNumber || (oktaUser as any).employeeId || null,
firstName: oktaUser.firstName || null,
lastName: oktaUser.lastName || null,
displayName: oktaUser.displayName || `${oktaUser.firstName || ''} ${oktaUser.lastName || ''}`.trim() || oktaUser.email,
department: oktaUser.department || null,
designation: (oktaUser as any).designation || (oktaUser as any).title || null,
phone: (oktaUser as any).phone || (oktaUser as any).mobilePhone || null,
isActive: true,
role: role, // Assign the requested role
lastLogin: undefined // Not logged in yet
}); });
user = ensured;
logger.info(`[Admin] Created new user ${email} with role ${role}`); logger.info(`[Admin] Created new user ${email} with role ${role} (manager: ${oktaUserData.manager || 'N/A'})`);
} catch (oktaError: any) { } catch (oktaError: any) {
logger.error('[Admin] Error fetching from Okta:', oktaError); logger.error('[Admin] Error fetching from Okta:', oktaError);
res.status(500).json({ res.status(500).json({
@ -826,7 +816,7 @@ export const assignRoleByEmail = async (req: Request, res: Response): Promise<vo
return; return;
} }
} else { } else {
// User exists, update their role // User exists - fetch latest data from Okta and sync all fields including role
const previousRole = user.role; const previousRole = user.role;
// Prevent self-demotion // Prevent self-demotion
@ -838,9 +828,35 @@ export const assignRoleByEmail = async (req: Request, res: Response): Promise<vo
return; return;
} }
await user.update({ role }); // Import UserService to fetch latest data from Okta
const { UserService } = await import('@services/user.service');
const userService = new UserService();
logger.info(`[Admin] Updated user ${email} role from ${previousRole} to ${role}`); try {
// Fetch full user profile from Okta Users API to sync manager and other fields
const oktaUserData = await userService.fetchAndExtractOktaUserByEmail(email);
if (oktaUserData) {
// Sync all fields from Okta including the new role using centralized method
const updated = await userService.createOrUpdateUser({
...oktaUserData, // Includes all fields: manager, jobTitle, postalAddress, etc.
role, // Set the new role
isActive: true, // Ensure user is active
});
user = updated;
logger.info(`[Admin] Synced user ${email} from Okta (manager: ${oktaUserData.manager || 'N/A'}) and updated role from ${previousRole} to ${role}`);
} else {
// Okta user not found, just update role
await user.update({ role });
logger.info(`[Admin] Updated user ${email} role from ${previousRole} to ${role} (Okta data not available)`);
}
} catch (oktaError: any) {
// If Okta fetch fails, just update the role
logger.warn(`[Admin] Failed to fetch Okta data for ${email}, updating role only:`, oktaError.message);
await user.update({ role });
logger.info(`[Admin] Updated user ${email} role from ${previousRole} to ${role} (Okta sync failed)`);
}
} }
res.json({ res.json({

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,778 @@
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 { Document } from '../models/Document';
import { constants } from '../config/constants';
import { sapIntegrationService } from '../services/sapIntegration.service';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
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,
selectedManagerEmail, // Optional: When multiple managers found, user selects one
} = 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,
selectedManagerEmail, // Pass selected manager email if provided
});
return ResponseHandler.success(res, {
request: claimRequest,
message: 'Claim request created successfully'
}, 'Claim request created');
} catch (error: any) {
// Handle multiple managers found error
if (error.code === 'MULTIPLE_MANAGERS_FOUND') {
const response: any = {
success: false,
message: 'Multiple reporting managers found. Please select one.',
error: {
code: 'MULTIPLE_MANAGERS_FOUND',
managers: error.managers || []
},
timestamp: new Date(),
};
logger.warn('[DealerClaimController] Multiple managers found:', { managers: error.managers });
res.status(400).json(response);
return;
}
// Handle no manager found error
if (error.code === 'NO_MANAGER_FOUND') {
const response: any = {
success: false,
message: error.message || 'No reporting manager found. Please ensure your manager is correctly configured in the system.',
error: {
code: 'NO_MANAGER_FOUND',
message: error.message
},
timestamp: new Date(),
};
logger.warn('[DealerClaimController] No manager found:', { message: error.message });
res.status(400).json(response);
return;
}
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,
} = req.body;
// Parse closedExpenses if it's a JSON string
let parsedClosedExpenses: any[] = [];
if (closedExpenses) {
try {
parsedClosedExpenses = typeof closedExpenses === 'string' ? JSON.parse(closedExpenses) : closedExpenses;
} catch (e) {
logger.warn('[DealerClaimController] Failed to parse closedExpenses JSON:', e);
parsedClosedExpenses = [];
}
}
// Get files from multer
const files = req.files as { [fieldname: string]: Express.Multer.File[] } | undefined;
const completionDocumentsFiles = files?.completionDocuments || [];
const activityPhotosFiles = files?.activityPhotos || [];
const invoicesReceiptsFiles = files?.invoicesReceipts || [];
const attendanceSheetFile = files?.attendanceSheet?.[0];
// Find workflow to get actual UUID
const workflow = await this.findWorkflowByIdentifier(identifier);
if (!workflow) {
return ResponseHandler.error(res, 'Workflow request not found', 404);
}
const requestId = (workflow as any).requestId || (workflow as any).request_id;
const requestNumber = (workflow as any).requestNumber || (workflow as any).request_number || 'UNKNOWN';
if (!requestId) {
return ResponseHandler.error(res, 'Invalid workflow request', 400);
}
const userId = (req as any).user?.userId || (req as any).user?.user_id;
if (!userId) {
return ResponseHandler.error(res, 'User not authenticated', 401);
}
if (!activityCompletionDate) {
return ResponseHandler.error(res, 'Activity completion date is required', 400);
}
// Upload files to GCS and save to documents table
const completionDocuments: any[] = [];
const activityPhotos: any[] = [];
// Upload and save completion documents to documents table with COMPLETION_DOC category
for (const file of completionDocumentsFiles) {
try {
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const uploadResult = await gcsStorageService.uploadFileWithFallback({
buffer: fileBuffer,
originalName: file.originalname,
mimeType: file.mimetype,
requestNumber: requestNumber,
fileType: 'documents'
});
const extension = path.extname(file.originalname).replace('.', '').toLowerCase();
// Save to documents table
const doc = await Document.create({
requestId,
uploadedBy: userId,
fileName: path.basename(file.filename || file.originalname),
originalFileName: file.originalname,
fileType: extension,
fileExtension: extension,
fileSize: file.size,
filePath: uploadResult.filePath,
storageUrl: uploadResult.storageUrl,
mimeType: file.mimetype,
checksum,
isGoogleDoc: false,
googleDocUrl: null as any,
category: constants.DOCUMENT_CATEGORIES.COMPLETION_DOC,
version: 1,
parentDocumentId: null as any,
isDeleted: false,
downloadCount: 0,
} as any);
completionDocuments.push({
documentId: doc.documentId,
name: file.originalname,
url: uploadResult.storageUrl,
size: file.size,
type: file.mimetype,
});
// Cleanup local file if exists
if (file.path && fs.existsSync(file.path)) {
try {
fs.unlinkSync(file.path);
} catch (unlinkError) {
logger.warn(`[DealerClaimController] Failed to delete local file ${file.path}`);
}
}
} catch (error) {
logger.error(`[DealerClaimController] Error uploading completion document ${file.originalname}:`, error);
}
}
// Upload and save activity photos to documents table with ACTIVITY_PHOTO category
for (const file of activityPhotosFiles) {
try {
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const uploadResult = await gcsStorageService.uploadFileWithFallback({
buffer: fileBuffer,
originalName: file.originalname,
mimeType: file.mimetype,
requestNumber: requestNumber,
fileType: 'attachments'
});
const extension = path.extname(file.originalname).replace('.', '').toLowerCase();
// Save to documents table
const doc = await Document.create({
requestId,
uploadedBy: userId,
fileName: path.basename(file.filename || file.originalname),
originalFileName: file.originalname,
fileType: extension,
fileExtension: extension,
fileSize: file.size,
filePath: uploadResult.filePath,
storageUrl: uploadResult.storageUrl,
mimeType: file.mimetype,
checksum,
isGoogleDoc: false,
googleDocUrl: null as any,
category: constants.DOCUMENT_CATEGORIES.ACTIVITY_PHOTO,
version: 1,
parentDocumentId: null as any,
isDeleted: false,
downloadCount: 0,
} as any);
activityPhotos.push({
documentId: doc.documentId,
name: file.originalname,
url: uploadResult.storageUrl,
size: file.size,
type: file.mimetype,
});
// Cleanup local file if exists
if (file.path && fs.existsSync(file.path)) {
try {
fs.unlinkSync(file.path);
} catch (unlinkError) {
logger.warn(`[DealerClaimController] Failed to delete local file ${file.path}`);
}
}
} catch (error) {
logger.error(`[DealerClaimController] Error uploading activity photo ${file.originalname}:`, error);
}
}
// Upload and save invoices/receipts to documents table with SUPPORTING category
const invoicesReceipts: any[] = [];
for (const file of invoicesReceiptsFiles) {
try {
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const uploadResult = await gcsStorageService.uploadFileWithFallback({
buffer: fileBuffer,
originalName: file.originalname,
mimeType: file.mimetype,
requestNumber: requestNumber,
fileType: 'attachments'
});
const extension = path.extname(file.originalname).replace('.', '').toLowerCase();
// Save to documents table
const doc = await Document.create({
requestId,
uploadedBy: userId,
fileName: path.basename(file.filename || file.originalname),
originalFileName: file.originalname,
fileType: extension,
fileExtension: extension,
fileSize: file.size,
filePath: uploadResult.filePath,
storageUrl: uploadResult.storageUrl,
mimeType: file.mimetype,
checksum,
isGoogleDoc: false,
googleDocUrl: null as any,
category: constants.DOCUMENT_CATEGORIES.SUPPORTING,
version: 1,
parentDocumentId: null as any,
isDeleted: false,
downloadCount: 0,
} as any);
invoicesReceipts.push({
documentId: doc.documentId,
name: file.originalname,
url: uploadResult.storageUrl,
size: file.size,
type: file.mimetype,
});
// Cleanup local file if exists
if (file.path && fs.existsSync(file.path)) {
try {
fs.unlinkSync(file.path);
} catch (unlinkError) {
logger.warn(`[DealerClaimController] Failed to delete local file ${file.path}`);
}
}
} catch (error) {
logger.error(`[DealerClaimController] Error uploading invoice/receipt ${file.originalname}:`, error);
}
}
// Upload and save attendance sheet to documents table with SUPPORTING category
let attendanceSheet: any = null;
if (attendanceSheetFile) {
try {
const fileBuffer = attendanceSheetFile.buffer || (attendanceSheetFile.path ? fs.readFileSync(attendanceSheetFile.path) : Buffer.from(''));
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const uploadResult = await gcsStorageService.uploadFileWithFallback({
buffer: fileBuffer,
originalName: attendanceSheetFile.originalname,
mimeType: attendanceSheetFile.mimetype,
requestNumber: requestNumber,
fileType: 'attachments'
});
const extension = path.extname(attendanceSheetFile.originalname).replace('.', '').toLowerCase();
// Save to documents table
const doc = await Document.create({
requestId,
uploadedBy: userId,
fileName: path.basename(attendanceSheetFile.filename || attendanceSheetFile.originalname),
originalFileName: attendanceSheetFile.originalname,
fileType: extension,
fileExtension: extension,
fileSize: attendanceSheetFile.size,
filePath: uploadResult.filePath,
storageUrl: uploadResult.storageUrl,
mimeType: attendanceSheetFile.mimetype,
checksum,
isGoogleDoc: false,
googleDocUrl: null as any,
category: constants.DOCUMENT_CATEGORIES.SUPPORTING,
version: 1,
parentDocumentId: null as any,
isDeleted: false,
downloadCount: 0,
} as any);
attendanceSheet = {
documentId: doc.documentId,
name: attendanceSheetFile.originalname,
url: uploadResult.storageUrl,
size: attendanceSheetFile.size,
type: attendanceSheetFile.mimetype,
};
// Cleanup local file if exists
if (attendanceSheetFile.path && fs.existsSync(attendanceSheetFile.path)) {
try {
fs.unlinkSync(attendanceSheetFile.path);
} catch (unlinkError) {
logger.warn(`[DealerClaimController] Failed to delete local file ${attendanceSheetFile.path}`);
}
}
} catch (error) {
logger.error(`[DealerClaimController] Error uploading attendance sheet:`, error);
}
}
await this.dealerClaimService.submitCompletionDocuments(requestId, {
activityCompletionDate: new Date(activityCompletionDate),
numberOfParticipants: numberOfParticipants ? parseInt(numberOfParticipants) : undefined,
closedExpenses: parsedClosedExpenses,
totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0,
invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined,
attendanceSheet: attendanceSheet || undefined,
});
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);
}
}
/**
* Validate/Fetch IO details from SAP
* GET /api/v1/dealer-claims/:requestId/io/validate?ioNumber=IO1234
* This endpoint fetches IO details from SAP and returns them, does not store anything
* Flow: Fetch from SAP -> Return to frontend (no database storage)
*/
async validateIO(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { ioNumber } = req.query;
if (!ioNumber || typeof ioNumber !== 'string') {
return ResponseHandler.error(res, 'IO number is required', 400);
}
// Fetch IO details from SAP (will return mock data until SAP is integrated)
const ioValidation = await sapIntegrationService.validateIONumber(ioNumber.trim());
if (!ioValidation.isValid) {
return ResponseHandler.error(res, ioValidation.error || 'Invalid IO number', 400);
}
return ResponseHandler.success(res, {
ioNumber: ioValidation.ioNumber,
availableBalance: ioValidation.availableBalance,
blockedAmount: ioValidation.blockedAmount,
remainingBalance: ioValidation.remainingBalance,
currency: ioValidation.currency,
description: ioValidation.description,
isValid: true,
}, 'IO fetched successfully from SAP');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error validating IO:', error);
return ResponseHandler.error(res, 'Failed to fetch IO from SAP', 500, errorMessage);
}
}
/**
* Update IO details and block amount in SAP
* PUT /api/v1/dealer-claims/:requestId/io
* Only stores data when blocking amount > 0
* Accepts either UUID or requestNumber
*/
async updateIODetails(req: AuthenticatedRequest, res: Response): Promise<void> {
const userId = (req as any).user?.userId || (req as any).user?.user_id;
try {
const identifier = req.params.requestId; // Can be UUID or requestNumber
const {
ioNumber,
ioRemark,
availableBalance,
blockedAmount,
remainingBalance,
} = 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) {
return ResponseHandler.error(res, 'IO number is required', 400);
}
const blockAmount = blockedAmount ? parseFloat(blockedAmount) : 0;
// Only store in database when blocking amount > 0
if (blockAmount > 0) {
if (availableBalance === undefined) {
return ResponseHandler.error(res, 'Available balance is required when blocking amount', 400);
}
await this.dealerClaimService.updateIODetails(
requestId,
{
ioNumber,
ioRemark: ioRemark || '',
availableBalance: parseFloat(availableBalance),
blockedAmount: blockAmount,
remainingBalance: remainingBalance ? parseFloat(remainingBalance) : parseFloat(availableBalance) - blockAmount,
},
userId
);
return ResponseHandler.success(res, { message: 'IO blocked successfully in SAP' }, 'IO blocked');
} else {
// Just validate IO number without storing
// This is for validation only (fetch amount scenario)
return ResponseHandler.success(res, { message: 'IO validated successfully' }, 'IO validated');
}
} 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

@ -36,6 +36,39 @@ export class UserController {
} }
} }
/**
* Search users in Okta by displayName
* GET /api/v1/users/search-by-displayname?displayName=John Doe
* Used when creating claim requests to find manager by displayName
*/
async searchByDisplayName(req: Request, res: Response): Promise<void> {
try {
const displayName = String(req.query.displayName || '').trim();
if (!displayName) {
ResponseHandler.error(res, 'displayName query parameter is required', 400);
return;
}
const oktaUsers = await this.userService.searchOktaByDisplayName(displayName);
const result = oktaUsers.map(u => ({
userId: u.id,
email: u.profile.email || u.profile.login,
displayName: u.profile.displayName || `${u.profile.firstName || ''} ${u.profile.lastName || ''}`.trim(),
firstName: u.profile.firstName,
lastName: u.profile.lastName,
department: u.profile.department,
status: u.status,
}));
ResponseHandler.success(res, result, 'Users found by displayName');
} catch (error: any) {
logger.error('Search by displayName failed', { error });
ResponseHandler.error(res, error.message || 'Search by displayName failed', 500);
}
}
/** /**
* Ensure user exists in database (create if not exists) * Ensure user exists in database (create if not exists)
* Called when user is selected/tagged in the frontend * Called when user is selected/tagged in the frontend

View File

@ -4,7 +4,7 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.sequelize.query(`DO $$ await queryInterface.sequelize.query(`DO $$
BEGIN BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_document_category') THEN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_document_category') THEN
CREATE TYPE enum_document_category AS ENUM ('SUPPORTING','APPROVAL','REFERENCE','FINAL','OTHER'); CREATE TYPE enum_document_category AS ENUM ('SUPPORTING','APPROVAL','REFERENCE','FINAL','OTHER','COMPLETION_DOC','ACTIVITY_PHOTO');
END IF; END IF;
END$$;`); END$$;`);

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,214 @@
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
},
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
},
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
},
total_closed_expenses: {
type: DataTypes.DECIMAL(15, 2),
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,197 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
// Create claim_budget_tracking table for comprehensive budget management
await queryInterface.createTable('claim_budget_tracking', {
budget_id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4
},
request_id: {
type: DataTypes.UUID,
allowNull: false,
unique: true,
references: {
model: 'workflow_requests',
key: 'request_id'
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE'
},
// Initial Budget (from claim creation)
initial_estimated_budget: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
comment: 'Initial estimated budget when claim was created'
},
// Proposal Budget (from Step 1 - Dealer Proposal)
proposal_estimated_budget: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
comment: 'Total estimated budget from dealer proposal'
},
proposal_submitted_at: {
type: DataTypes.DATE,
allowNull: true,
comment: 'When dealer submitted proposal'
},
// Approved Budget (from Step 2 - Requestor Evaluation)
approved_budget: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
comment: 'Budget approved by requestor in Step 2'
},
approved_at: {
type: DataTypes.DATE,
allowNull: true,
comment: 'When budget was approved by requestor'
},
approved_by: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'users',
key: 'user_id'
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
comment: 'User who approved the budget'
},
// IO Blocked Budget (from Step 3 - Department Lead)
io_blocked_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
comment: 'Amount blocked in IO (from internal_orders table)'
},
io_blocked_at: {
type: DataTypes.DATE,
allowNull: true,
comment: 'When budget was blocked in IO'
},
// Closed Expenses (from Step 5 - Dealer Completion)
closed_expenses: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
comment: 'Total closed expenses from completion documents'
},
closed_expenses_submitted_at: {
type: DataTypes.DATE,
allowNull: true,
comment: 'When completion expenses were submitted'
},
// Final Claim Amount (from Step 6 - Requestor Claim Approval)
final_claim_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
comment: 'Final claim amount approved/modified by requestor in Step 6'
},
final_claim_amount_approved_at: {
type: DataTypes.DATE,
allowNull: true,
comment: 'When final claim amount was approved'
},
final_claim_amount_approved_by: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'users',
key: 'user_id'
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
comment: 'User who approved final claim amount'
},
// Credit Note (from Step 8 - Finance)
credit_note_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
comment: 'Credit note amount issued by finance'
},
credit_note_issued_at: {
type: DataTypes.DATE,
allowNull: true,
comment: 'When credit note was issued'
},
// Budget Status
budget_status: {
type: DataTypes.ENUM('DRAFT', 'PROPOSED', 'APPROVED', 'BLOCKED', 'CLOSED', 'SETTLED'),
defaultValue: 'DRAFT',
allowNull: false,
comment: 'Current status of budget lifecycle'
},
// Currency
currency: {
type: DataTypes.STRING(3),
defaultValue: 'INR',
allowNull: false,
comment: 'Currency code (INR, USD, etc.)'
},
// Budget Variance
variance_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
comment: 'Difference between approved and closed expenses (closed - approved)'
},
variance_percentage: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
comment: 'Variance as percentage of approved budget'
},
// Audit fields
last_modified_by: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'users',
key: 'user_id'
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
comment: 'Last user who modified budget'
},
last_modified_at: {
type: DataTypes.DATE,
allowNull: true,
comment: 'When budget was last modified'
},
modification_reason: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Reason for budget modification'
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
}
});
// Create indexes
await queryInterface.addIndex('claim_budget_tracking', ['request_id'], {
name: 'idx_claim_budget_tracking_request_id',
unique: true
});
await queryInterface.addIndex('claim_budget_tracking', ['budget_status'], {
name: 'idx_claim_budget_tracking_status'
});
await queryInterface.addIndex('claim_budget_tracking', ['approved_by'], {
name: 'idx_claim_budget_tracking_approved_by'
});
await queryInterface.addIndex('claim_budget_tracking', ['final_claim_amount_approved_by'], {
name: 'idx_claim_budget_tracking_final_approved_by'
});
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.dropTable('claim_budget_tracking');
}

View File

@ -0,0 +1,95 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
// Create internal_orders table for storing IO (Internal Order) details
await queryInterface.createTable('internal_orders', {
io_id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4
},
request_id: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'workflow_requests',
key: 'request_id'
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE'
},
io_number: {
type: DataTypes.STRING(50),
allowNull: false
},
io_remark: {
type: DataTypes.TEXT,
allowNull: true
},
io_available_balance: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true
},
io_blocked_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true
},
io_remaining_balance: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true
},
organized_by: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'users',
key: 'user_id'
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE'
},
organized_at: {
type: DataTypes.DATE,
allowNull: true
},
sap_document_number: {
type: DataTypes.STRING(100),
allowNull: true
},
status: {
type: DataTypes.ENUM('PENDING', 'BLOCKED', 'RELEASED', 'CANCELLED'),
defaultValue: 'PENDING',
allowNull: false
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
}
});
// Create indexes
await queryInterface.addIndex('internal_orders', ['io_number'], {
name: 'idx_internal_orders_io_number'
});
await queryInterface.addIndex('internal_orders', ['organized_by'], {
name: 'idx_internal_orders_organized_by'
});
// Create unique constraint: one IO per request (unique index on request_id)
await queryInterface.addIndex('internal_orders', ['request_id'], {
name: 'idx_internal_orders_request_id_unique',
unique: true
});
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.dropTable('internal_orders');
}

View File

@ -0,0 +1,116 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.createTable('claim_invoices', {
invoice_id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4,
},
request_id: {
type: DataTypes.UUID,
allowNull: false,
unique: true, // one invoice per request (adjust later if multiples needed)
references: { model: 'workflow_requests', key: 'request_id' },
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
invoice_number: {
type: DataTypes.STRING(100),
allowNull: true,
},
invoice_date: {
type: DataTypes.DATEONLY,
allowNull: true,
},
dms_number: {
type: DataTypes.STRING(100),
allowNull: true,
},
amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
},
status: {
type: DataTypes.STRING(50), // e.g., PENDING, GENERATED, SENT, FAILED, CANCELLED
allowNull: true,
},
description: {
type: DataTypes.TEXT,
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('claim_invoices', ['request_id'], { name: 'idx_claim_invoices_request_id', unique: true });
await queryInterface.addIndex('claim_invoices', ['invoice_number'], { name: 'idx_claim_invoices_invoice_number' });
await queryInterface.addIndex('claim_invoices', ['dms_number'], { name: 'idx_claim_invoices_dms_number' });
await queryInterface.createTable('claim_credit_notes', {
credit_note_id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4,
},
request_id: {
type: DataTypes.UUID,
allowNull: false,
unique: true, // one credit note per request (adjust later if multiples needed)
references: { model: 'workflow_requests', key: 'request_id' },
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
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,
},
status: {
type: DataTypes.STRING(50), // e.g., PENDING, GENERATED, SENT, FAILED, CANCELLED
allowNull: true,
},
reason: {
type: DataTypes.TEXT,
allowNull: true,
},
description: {
type: DataTypes.TEXT,
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('claim_credit_notes', ['request_id'], { name: 'idx_claim_credit_notes_request_id', unique: true });
await queryInterface.addIndex('claim_credit_notes', ['credit_note_number'], { name: 'idx_claim_credit_notes_number' });
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.dropTable('claim_credit_notes');
await queryInterface.dropTable('claim_invoices');
}

View File

@ -0,0 +1,38 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.removeColumn('dealer_claim_details', 'dms_number');
await queryInterface.removeColumn('dealer_claim_details', 'e_invoice_number');
await queryInterface.removeColumn('dealer_claim_details', 'e_invoice_date');
await queryInterface.removeColumn('dealer_claim_details', 'credit_note_number');
await queryInterface.removeColumn('dealer_claim_details', 'credit_note_date');
await queryInterface.removeColumn('dealer_claim_details', 'credit_note_amount');
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.addColumn('dealer_claim_details', 'dms_number', {
type: DataTypes.STRING(100),
allowNull: true,
});
await queryInterface.addColumn('dealer_claim_details', 'e_invoice_number', {
type: DataTypes.STRING(100),
allowNull: true,
});
await queryInterface.addColumn('dealer_claim_details', 'e_invoice_date', {
type: DataTypes.DATEONLY,
allowNull: true,
});
await queryInterface.addColumn('dealer_claim_details', 'credit_note_number', {
type: DataTypes.STRING(100),
allowNull: true,
});
await queryInterface.addColumn('dealer_claim_details', 'credit_note_date', {
type: DataTypes.DATEONLY,
allowNull: true,
});
await queryInterface.addColumn('dealer_claim_details', 'credit_note_amount', {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
});
}

View File

@ -0,0 +1,55 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.createTable('dealer_completion_expenses', {
expense_id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4,
},
request_id: {
type: DataTypes.UUID,
allowNull: false,
references: { model: 'workflow_requests', key: 'request_id' },
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
completion_id: {
type: DataTypes.UUID,
allowNull: true,
references: { model: 'dealer_completion_details', key: 'completion_id' },
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
description: {
type: DataTypes.STRING(500),
allowNull: false,
},
amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
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_expenses', ['request_id'], {
name: 'idx_dealer_completion_expenses_request_id',
});
await queryInterface.addIndex('dealer_completion_expenses', ['completion_id'], {
name: 'idx_dealer_completion_expenses_completion_id',
});
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.dropTable('dealer_completion_expenses');
}

View File

@ -0,0 +1,295 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { WorkflowRequest } from './WorkflowRequest';
import { User } from './User';
export enum BudgetStatus {
DRAFT = 'DRAFT',
PROPOSED = 'PROPOSED',
APPROVED = 'APPROVED',
BLOCKED = 'BLOCKED',
CLOSED = 'CLOSED',
SETTLED = 'SETTLED'
}
interface ClaimBudgetTrackingAttributes {
budgetId: string;
requestId: string;
// Initial Budget
initialEstimatedBudget?: number;
// Proposal Budget
proposalEstimatedBudget?: number;
proposalSubmittedAt?: Date;
// Approved Budget
approvedBudget?: number;
approvedAt?: Date;
approvedBy?: string;
// IO Blocked Budget
ioBlockedAmount?: number;
ioBlockedAt?: Date;
// Closed Expenses
closedExpenses?: number;
closedExpensesSubmittedAt?: Date;
// Final Claim Amount
finalClaimAmount?: number;
finalClaimAmountApprovedAt?: Date;
finalClaimAmountApprovedBy?: string;
// Credit Note
creditNoteAmount?: number;
creditNoteIssuedAt?: Date;
// Status & Metadata
budgetStatus: BudgetStatus;
currency: string;
varianceAmount?: number;
variancePercentage?: number;
// Audit
lastModifiedBy?: string;
lastModifiedAt?: Date;
modificationReason?: string;
createdAt: Date;
updatedAt: Date;
}
interface ClaimBudgetTrackingCreationAttributes extends Optional<ClaimBudgetTrackingAttributes, 'budgetId' | 'initialEstimatedBudget' | 'proposalEstimatedBudget' | 'proposalSubmittedAt' | 'approvedBudget' | 'approvedAt' | 'approvedBy' | 'ioBlockedAmount' | 'ioBlockedAt' | 'closedExpenses' | 'closedExpensesSubmittedAt' | 'finalClaimAmount' | 'finalClaimAmountApprovedAt' | 'finalClaimAmountApprovedBy' | 'creditNoteAmount' | 'creditNoteIssuedAt' | 'varianceAmount' | 'variancePercentage' | 'lastModifiedBy' | 'lastModifiedAt' | 'modificationReason' | 'budgetStatus' | 'currency' | 'createdAt' | 'updatedAt'> {}
class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudgetTrackingCreationAttributes> implements ClaimBudgetTrackingAttributes {
public budgetId!: string;
public requestId!: string;
public initialEstimatedBudget?: number;
public proposalEstimatedBudget?: number;
public proposalSubmittedAt?: Date;
public approvedBudget?: number;
public approvedAt?: Date;
public approvedBy?: string;
public ioBlockedAmount?: number;
public ioBlockedAt?: Date;
public closedExpenses?: number;
public closedExpensesSubmittedAt?: Date;
public finalClaimAmount?: number;
public finalClaimAmountApprovedAt?: Date;
public finalClaimAmountApprovedBy?: string;
public creditNoteAmount?: number;
public creditNoteIssuedAt?: Date;
public budgetStatus!: BudgetStatus;
public currency!: string;
public varianceAmount?: number;
public variancePercentage?: number;
public lastModifiedBy?: string;
public lastModifiedAt?: Date;
public modificationReason?: string;
public createdAt!: Date;
public updatedAt!: Date;
// Associations
public request?: WorkflowRequest;
public approver?: User;
public finalApprover?: User;
public lastModifier?: User;
}
ClaimBudgetTracking.init(
{
budgetId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
field: 'budget_id'
},
requestId: {
type: DataTypes.UUID,
allowNull: false,
unique: true,
field: 'request_id',
references: {
model: 'workflow_requests',
key: 'request_id'
}
},
initialEstimatedBudget: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'initial_estimated_budget'
},
proposalEstimatedBudget: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'proposal_estimated_budget'
},
proposalSubmittedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'proposal_submitted_at'
},
approvedBudget: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'approved_budget'
},
approvedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'approved_at'
},
approvedBy: {
type: DataTypes.UUID,
allowNull: true,
field: 'approved_by',
references: {
model: 'users',
key: 'user_id'
}
},
ioBlockedAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'io_blocked_amount'
},
ioBlockedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'io_blocked_at'
},
closedExpenses: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'closed_expenses'
},
closedExpensesSubmittedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'closed_expenses_submitted_at'
},
finalClaimAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'final_claim_amount'
},
finalClaimAmountApprovedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'final_claim_amount_approved_at'
},
finalClaimAmountApprovedBy: {
type: DataTypes.UUID,
allowNull: true,
field: 'final_claim_amount_approved_by',
references: {
model: 'users',
key: 'user_id'
}
},
creditNoteAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'credit_note_amount'
},
creditNoteIssuedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'credit_note_issued_at'
},
budgetStatus: {
type: DataTypes.ENUM('DRAFT', 'PROPOSED', 'APPROVED', 'BLOCKED', 'CLOSED', 'SETTLED'),
defaultValue: 'DRAFT',
allowNull: false,
field: 'budget_status'
},
currency: {
type: DataTypes.STRING(3),
defaultValue: 'INR',
allowNull: false
},
varianceAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'variance_amount'
},
variancePercentage: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'variance_percentage'
},
lastModifiedBy: {
type: DataTypes.UUID,
allowNull: true,
field: 'last_modified_by',
references: {
model: 'users',
key: 'user_id'
}
},
lastModifiedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'last_modified_at'
},
modificationReason: {
type: DataTypes.TEXT,
allowNull: true,
field: 'modification_reason'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'created_at'
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'updated_at'
}
},
{
sequelize,
modelName: 'ClaimBudgetTracking',
tableName: 'claim_budget_tracking',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
{
fields: ['request_id'],
unique: true
},
{
fields: ['budget_status']
},
{
fields: ['approved_by']
},
{
fields: ['final_claim_amount_approved_by']
}
]
}
);
// Associations
ClaimBudgetTracking.belongsTo(WorkflowRequest, {
as: 'request',
foreignKey: 'requestId',
targetKey: 'requestId'
});
ClaimBudgetTracking.belongsTo(User, {
as: 'approver',
foreignKey: 'approvedBy',
targetKey: 'userId'
});
ClaimBudgetTracking.belongsTo(User, {
as: 'finalApprover',
foreignKey: 'finalClaimAmountApprovedBy',
targetKey: 'userId'
});
ClaimBudgetTracking.belongsTo(User, {
as: 'lastModifier',
foreignKey: 'lastModifiedBy',
targetKey: 'userId'
});
export { ClaimBudgetTracking };

View File

@ -0,0 +1,123 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { WorkflowRequest } from './WorkflowRequest';
interface ClaimCreditNoteAttributes {
creditNoteId: string;
requestId: string;
creditNoteNumber?: string;
creditNoteDate?: Date;
creditNoteAmount?: number;
status?: string;
reason?: string;
description?: string;
createdAt: Date;
updatedAt: Date;
}
interface ClaimCreditNoteCreationAttributes extends Optional<ClaimCreditNoteAttributes, 'creditNoteId' | 'creditNoteNumber' | 'creditNoteDate' | 'creditNoteAmount' | 'status' | 'reason' | 'description' | 'createdAt' | 'updatedAt'> {}
class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCreationAttributes> implements ClaimCreditNoteAttributes {
public creditNoteId!: string;
public requestId!: string;
public creditNoteNumber?: string;
public creditNoteDate?: Date;
public creditNoteAmount?: number;
public status?: string;
public reason?: string;
public description?: string;
public createdAt!: Date;
public updatedAt!: Date;
}
ClaimCreditNote.init(
{
creditNoteId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
field: 'credit_note_id',
},
requestId: {
type: DataTypes.UUID,
allowNull: false,
unique: true,
field: 'request_id',
references: {
model: 'workflow_requests',
key: 'request_id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
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',
},
status: {
type: DataTypes.STRING(50),
allowNull: true,
field: 'status',
},
reason: {
type: DataTypes.TEXT,
allowNull: true,
field: 'reason',
},
description: {
type: DataTypes.TEXT,
allowNull: true,
field: 'description',
},
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: 'ClaimCreditNote',
tableName: 'claim_credit_notes',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
{ unique: true, fields: ['request_id'], name: 'idx_claim_credit_notes_request_id' },
{ fields: ['credit_note_number'], name: 'idx_claim_credit_notes_number' },
],
}
);
WorkflowRequest.hasOne(ClaimCreditNote, {
as: 'claimCreditNote',
foreignKey: 'requestId',
sourceKey: 'requestId',
});
ClaimCreditNote.belongsTo(WorkflowRequest, {
as: 'workflowRequest',
foreignKey: 'requestId',
targetKey: 'requestId',
});
export { ClaimCreditNote };

124
src/models/ClaimInvoice.ts Normal file
View File

@ -0,0 +1,124 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { WorkflowRequest } from './WorkflowRequest';
interface ClaimInvoiceAttributes {
invoiceId: string;
requestId: string;
invoiceNumber?: string;
invoiceDate?: Date;
dmsNumber?: string;
amount?: number;
status?: string;
description?: string;
createdAt: Date;
updatedAt: Date;
}
interface ClaimInvoiceCreationAttributes extends Optional<ClaimInvoiceAttributes, 'invoiceId' | 'invoiceNumber' | 'invoiceDate' | 'dmsNumber' | 'amount' | 'status' | 'description' | 'createdAt' | 'updatedAt'> {}
class ClaimInvoice extends Model<ClaimInvoiceAttributes, ClaimInvoiceCreationAttributes> implements ClaimInvoiceAttributes {
public invoiceId!: string;
public requestId!: string;
public invoiceNumber?: string;
public invoiceDate?: Date;
public dmsNumber?: string;
public amount?: number;
public status?: string;
public description?: string;
public createdAt!: Date;
public updatedAt!: Date;
}
ClaimInvoice.init(
{
invoiceId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
field: 'invoice_id',
},
requestId: {
type: DataTypes.UUID,
allowNull: false,
unique: true,
field: 'request_id',
references: {
model: 'workflow_requests',
key: 'request_id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
invoiceNumber: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'invoice_number',
},
invoiceDate: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'invoice_date',
},
dmsNumber: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'dms_number',
},
amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'amount',
},
status: {
type: DataTypes.STRING(50),
allowNull: true,
field: 'status',
},
description: {
type: DataTypes.TEXT,
allowNull: true,
field: 'description',
},
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: 'ClaimInvoice',
tableName: 'claim_invoices',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
{ unique: true, fields: ['request_id'], name: 'idx_claim_invoices_request_id' },
{ fields: ['invoice_number'], name: 'idx_claim_invoices_invoice_number' },
{ fields: ['dms_number'], name: 'idx_claim_invoices_dms_number' },
],
}
);
WorkflowRequest.hasOne(ClaimInvoice, {
as: 'claimInvoice',
foreignKey: 'requestId',
sourceKey: 'requestId',
});
ClaimInvoice.belongsTo(WorkflowRequest, {
as: 'workflowRequest',
foreignKey: 'requestId',
targetKey: 'requestId',
});
export { ClaimInvoice };

View File

@ -0,0 +1,167 @@
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;
createdAt: Date;
updatedAt: Date;
}
interface DealerClaimDetailsCreationAttributes extends Optional<DealerClaimDetailsAttributes, 'claimId' | 'dealerEmail' | 'dealerPhone' | 'dealerAddress' | 'activityDate' | 'location' | 'periodStartDate' | 'periodEndDate' | '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 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'
},
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,111 @@
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;
totalClosedExpenses?: number;
submittedAt?: Date;
createdAt: Date;
updatedAt: Date;
}
interface DealerCompletionDetailsCreationAttributes extends Optional<DealerCompletionDetailsAttributes, 'completionId' | 'numberOfParticipants' | 'totalClosedExpenses' | 'submittedAt' | 'createdAt' | 'updatedAt'> {}
class DealerCompletionDetails extends Model<DealerCompletionDetailsAttributes, DealerCompletionDetailsCreationAttributes> implements DealerCompletionDetailsAttributes {
public completionId!: string;
public requestId!: string;
public activityCompletionDate!: Date;
public numberOfParticipants?: number;
public totalClosedExpenses?: number;
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'
},
totalClosedExpenses: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'total_closed_expenses'
},
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,118 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { WorkflowRequest } from './WorkflowRequest';
import { DealerCompletionDetails } from './DealerCompletionDetails';
interface DealerCompletionExpenseAttributes {
expenseId: string;
requestId: string;
completionId?: string | null;
description: string;
amount: number;
createdAt: Date;
updatedAt: Date;
}
interface DealerCompletionExpenseCreationAttributes extends Optional<DealerCompletionExpenseAttributes, 'expenseId' | 'completionId' | 'createdAt' | 'updatedAt'> {}
class DealerCompletionExpense extends Model<DealerCompletionExpenseAttributes, DealerCompletionExpenseCreationAttributes> implements DealerCompletionExpenseAttributes {
public expenseId!: string;
public requestId!: string;
public completionId?: string | null;
public description!: string;
public amount!: number;
public createdAt!: Date;
public updatedAt!: Date;
}
DealerCompletionExpense.init(
{
expenseId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
field: 'expense_id',
},
requestId: {
type: DataTypes.UUID,
allowNull: false,
field: 'request_id',
references: {
model: 'workflow_requests',
key: 'request_id',
},
},
completionId: {
type: DataTypes.UUID,
allowNull: true,
field: 'completion_id',
references: {
model: 'dealer_completion_details',
key: 'completion_id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
description: {
type: DataTypes.STRING(500),
allowNull: false,
field: 'description',
},
amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: '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: 'DealerCompletionExpense',
tableName: 'dealer_completion_expenses',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
{ fields: ['request_id'], name: 'idx_dealer_completion_expenses_request_id' },
{ fields: ['completion_id'], name: 'idx_dealer_completion_expenses_completion_id' },
],
}
);
WorkflowRequest.hasMany(DealerCompletionExpense, {
as: 'completionExpenses',
foreignKey: 'requestId',
sourceKey: 'requestId',
});
DealerCompletionExpense.belongsTo(WorkflowRequest, {
as: 'workflowRequest',
foreignKey: 'requestId',
targetKey: 'requestId',
});
DealerCompletionDetails.hasMany(DealerCompletionExpense, {
as: 'expenses',
foreignKey: 'completionId',
sourceKey: 'completionId',
});
DealerCompletionExpense.belongsTo(DealerCompletionDetails, {
as: 'completion',
foreignKey: 'completionId',
targetKey: 'completionId',
});
export { DealerCompletionExpense };

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,142 @@
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 removed - now using dealer_proposal_cost_items table
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' | '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;
// costBreakup removed - now using dealer_proposal_cost_items table
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 field removed - now using dealer_proposal_cost_items table
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 };

166
src/models/InternalOrder.ts Normal file
View File

@ -0,0 +1,166 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { WorkflowRequest } from './WorkflowRequest';
import { User } from './User';
export enum IOStatus {
PENDING = 'PENDING',
BLOCKED = 'BLOCKED',
RELEASED = 'RELEASED',
CANCELLED = 'CANCELLED'
}
interface InternalOrderAttributes {
ioId: string;
requestId: string;
ioNumber: string;
ioRemark?: string;
ioAvailableBalance?: number;
ioBlockedAmount?: number;
ioRemainingBalance?: number;
organizedBy?: string;
organizedAt?: Date;
sapDocumentNumber?: string;
status: IOStatus;
createdAt: Date;
updatedAt: Date;
}
interface InternalOrderCreationAttributes extends Optional<InternalOrderAttributes, 'ioId' | 'ioRemark' | 'ioAvailableBalance' | 'ioBlockedAmount' | 'ioRemainingBalance' | 'organizedBy' | 'organizedAt' | 'sapDocumentNumber' | 'status' | 'createdAt' | 'updatedAt'> {}
class InternalOrder extends Model<InternalOrderAttributes, InternalOrderCreationAttributes> implements InternalOrderAttributes {
public ioId!: string;
public requestId!: string;
public ioNumber!: string;
public ioRemark?: string;
public ioAvailableBalance?: number;
public ioBlockedAmount?: number;
public ioRemainingBalance?: number;
public organizedBy?: string;
public organizedAt?: Date;
public sapDocumentNumber?: string;
public status!: IOStatus;
public createdAt!: Date;
public updatedAt!: Date;
// Associations
public request?: WorkflowRequest;
public organizer?: User;
}
InternalOrder.init(
{
ioId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
field: 'io_id'
},
requestId: {
type: DataTypes.UUID,
allowNull: false,
field: 'request_id',
references: {
model: 'workflow_requests',
key: 'request_id'
}
},
ioNumber: {
type: DataTypes.STRING(50),
allowNull: false,
field: 'io_number'
},
ioRemark: {
type: DataTypes.TEXT,
allowNull: true,
field: 'io_remark'
},
ioAvailableBalance: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'io_available_balance'
},
ioBlockedAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'io_blocked_amount'
},
ioRemainingBalance: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'io_remaining_balance'
},
organizedBy: {
type: DataTypes.UUID,
allowNull: true,
field: 'organized_by',
references: {
model: 'users',
key: 'user_id'
}
},
organizedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'organized_at'
},
sapDocumentNumber: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'sap_document_number'
},
status: {
type: DataTypes.ENUM('PENDING', 'BLOCKED', 'RELEASED', 'CANCELLED'),
defaultValue: 'PENDING',
allowNull: false
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'created_at'
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'updated_at'
}
},
{
sequelize,
modelName: 'InternalOrder',
tableName: 'internal_orders',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
{
fields: ['request_id'],
unique: true
},
{
fields: ['io_number']
},
{
fields: ['organized_by']
}
]
}
);
// Associations
InternalOrder.belongsTo(WorkflowRequest, {
as: 'request',
foreignKey: 'requestId',
targetKey: 'requestId'
});
InternalOrder.belongsTo(User, {
as: 'organizer',
foreignKey: 'organizedBy',
targetKey: 'userId'
});
export { InternalOrder };

View File

@ -7,7 +7,9 @@ interface WorkflowRequestAttributes {
requestId: string; requestId: string;
requestNumber: string; requestNumber: string;
initiatorId: string; initiatorId: string;
templateType: 'CUSTOM' | 'TEMPLATE'; templateType: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM';
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;
@ -37,7 +39,9 @@ class WorkflowRequest extends Model<WorkflowRequestAttributes, WorkflowRequestCr
public requestId!: string; public requestId!: string;
public requestNumber!: string; public requestNumber!: string;
public initiatorId!: string; public initiatorId!: string;
public templateType!: 'CUSTOM' | 'TEMPLATE'; public templateType!: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM';
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,13 @@ 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';
import { InternalOrder } from './InternalOrder';
import { ClaimBudgetTracking } from './ClaimBudgetTracking';
// Define associations // Define associations
const defineAssociations = () => { const defineAssociations = () => {
@ -114,6 +121,20 @@ const defineAssociations = () => {
sourceKey: 'userId' sourceKey: 'userId'
}); });
// InternalOrder associations
WorkflowRequest.hasOne(InternalOrder, {
as: 'internalOrder',
foreignKey: 'requestId',
sourceKey: 'requestId'
});
// ClaimBudgetTracking associations
WorkflowRequest.hasOne(ClaimBudgetTracking, {
as: 'budgetTracking',
foreignKey: 'requestId',
sourceKey: 'requestId'
});
// Note: belongsTo associations are defined in individual model files to avoid duplicate alias conflicts // Note: belongsTo associations are defined in individual model files to avoid duplicate alias conflicts
// Only hasMany associations from WorkflowRequest are defined here since they're one-way // Only hasMany associations from WorkflowRequest are defined here since they're one-way
}; };
@ -138,7 +159,14 @@ export {
Notification, Notification,
ConclusionRemark, ConclusionRemark,
RequestSummary, RequestSummary,
SharedSummary SharedSummary,
DealerClaimDetails,
DealerProposalDetails,
DealerCompletionDetails,
DealerProposalCostItem,
WorkflowTemplate,
InternalOrder,
ClaimBudgetTracking
}; };
// 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,90 @@
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, upload.fields([
{ name: 'completionDocuments', maxCount: 10 },
{ name: 'activityPhotos', maxCount: 10 },
{ name: 'invoicesReceipts', maxCount: 10 },
{ name: 'attendanceSheet', maxCount: 1 },
]), asyncHandler(dealerClaimController.submitCompletion.bind(dealerClaimController)));
/**
* @route GET /api/v1/dealer-claims/:requestId/io/validate
* @desc Validate/Fetch IO details from SAP (returns dummy data for now)
* @access Private
*/
router.get('/:requestId/io/validate', authenticateToken, asyncHandler(dealerClaimController.validateIO.bind(dealerClaimController)));
/**
* @route PUT /api/v1/dealer-claims/:requestId/io
* @desc Block IO amount in SAP and store in database
* @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

@ -10,6 +10,9 @@ const userController = new UserController();
// GET /api/v1/users/search?q=<email or name> // GET /api/v1/users/search?q=<email or name>
router.get('/search', authenticateToken, asyncHandler(userController.searchUsers.bind(userController))); router.get('/search', authenticateToken, asyncHandler(userController.searchUsers.bind(userController)));
// GET /api/v1/users/search-by-displayname?displayName=John Doe
router.get('/search-by-displayname', authenticateToken, asyncHandler(userController.searchByDisplayName.bind(userController)));
// GET /api/v1/users/configurations - Get public configurations (document policy, workflow sharing, TAT settings) // GET /api/v1/users/configurations - Get public configurations (document policy, workflow sharing, TAT settings)
router.get('/configurations', authenticateToken, asyncHandler(getPublicConfigurations)); router.get('/configurations', authenticateToken, asyncHandler(getPublicConfigurations));

View File

@ -118,8 +118,20 @@ async function runMigrations(): Promise<void> {
const m25 = require('../migrations/20250126-add-pause-fields-to-workflow-requests'); const m25 = require('../migrations/20250126-add-pause-fields-to-workflow-requests');
const m26 = require('../migrations/20250126-add-pause-fields-to-approval-levels'); const m26 = require('../migrations/20250126-add-pause-fields-to-approval-levels');
const m27 = require('../migrations/20250127-migrate-in-progress-to-pending'); const m27 = require('../migrations/20250127-migrate-in-progress-to-pending');
// Base branch migrations (m28-m29)
const m28 = require('../migrations/20250130-migrate-to-vertex-ai'); const m28 = require('../migrations/20250130-migrate-to-vertex-ai');
const m29 = require('../migrations/20251203-add-user-notification-preferences'); const m29 = require('../migrations/20251203-add-user-notification-preferences');
// Dealer claim branch migrations (m30-m39)
const m30 = require('../migrations/20251210-add-workflow-type-support');
const m31 = require('../migrations/20251210-enhance-workflow-templates');
const m32 = require('../migrations/20251210-add-template-id-foreign-key');
const m33 = require('../migrations/20251210-create-dealer-claim-tables');
const m34 = require('../migrations/20251210-create-proposal-cost-items-table');
const m35 = require('../migrations/20251211-create-internal-orders-table');
const m36 = require('../migrations/20251211-create-claim-budget-tracking-table');
const m37 = require('../migrations/20251213-drop-claim-details-invoice-columns');
const m38 = require('../migrations/20251213-create-claim-invoice-credit-note-tables');
const m39 = require('../migrations/20251214-create-dealer-completion-expenses');
const migrations = [ const migrations = [
{ name: '2025103000-create-users', module: m0 }, { name: '2025103000-create-users', module: m0 },
@ -150,8 +162,20 @@ async function runMigrations(): Promise<void> {
{ name: '20250126-add-pause-fields-to-workflow-requests', module: m25 }, { name: '20250126-add-pause-fields-to-workflow-requests', module: m25 },
{ name: '20250126-add-pause-fields-to-approval-levels', module: m26 }, { name: '20250126-add-pause-fields-to-approval-levels', module: m26 },
{ name: '20250127-migrate-in-progress-to-pending', module: m27 }, { name: '20250127-migrate-in-progress-to-pending', module: m27 },
// Base branch migrations (m28-m29)
{ name: '20250130-migrate-to-vertex-ai', module: m28 }, { name: '20250130-migrate-to-vertex-ai', module: m28 },
{ name: '20251203-add-user-notification-preferences', module: m29 }, { name: '20251203-add-user-notification-preferences', module: m29 },
// Dealer claim branch migrations (m30-m39)
{ name: '20251210-add-workflow-type-support', module: m30 },
{ name: '20251210-enhance-workflow-templates', module: m31 },
{ name: '20251210-add-template-id-foreign-key', module: m32 },
{ name: '20251210-create-dealer-claim-tables', module: m33 },
{ name: '20251210-create-proposal-cost-items-table', module: m34 },
{ name: '20251211-create-internal-orders-table', module: m35 },
{ name: '20251211-create-claim-budget-tracking-table', module: m36 },
{ name: '20251213-drop-claim-details-invoice-columns', module: m37 },
{ name: '20251213-create-claim-invoice-credit-note-tables', module: m38 },
{ name: '20251214-create-dealer-completion-expenses', module: m39 },
]; ];
const queryInterface = sequelize.getQueryInterface(); const queryInterface = sequelize.getQueryInterface();

View File

@ -0,0 +1,167 @@
/**
* Cleanup Dealer Claims Script
* Removes all dealer claim related data for a fresh start
*
* Usage: npm run cleanup:dealer-claims
*
* WARNING: This will permanently delete all CLAIM_MANAGEMENT requests and related data!
*/
import { sequelize } from '../config/database';
import { QueryTypes } from 'sequelize';
import logger from '../utils/logger';
async function cleanupDealerClaims(): Promise<void> {
const transaction = await sequelize.transaction();
try {
logger.info('[Cleanup] Starting dealer claim cleanup...');
// Step 1: Find all CLAIM_MANAGEMENT request IDs
logger.info('[Cleanup] Finding all CLAIM_MANAGEMENT requests...');
const claimRequests = await sequelize.query<{ request_id: string }>(
`SELECT request_id FROM workflow_requests WHERE workflow_type = 'CLAIM_MANAGEMENT'`,
{ type: QueryTypes.SELECT, transaction }
);
const requestIds = claimRequests.map(r => r.request_id);
const count = requestIds.length;
if (count === 0) {
logger.info('[Cleanup] No CLAIM_MANAGEMENT requests found. Nothing to clean up.');
await transaction.commit();
return;
}
logger.info(`[Cleanup] Found ${count} CLAIM_MANAGEMENT request(s) to delete`);
// Step 2: Delete in order (respecting foreign key constraints)
// Start with child tables, then parent tables
// Convert UUID array to PostgreSQL array format
const requestIdsArray = `{${requestIds.map(id => `'${id}'`).join(',')}}`;
// Delete from claim_budget_tracking (new table)
logger.info('[Cleanup] Deleting from claim_budget_tracking...');
await sequelize.query(
`DELETE FROM claim_budget_tracking WHERE request_id = ANY(ARRAY[${requestIds.map(() => '?').join(',')}]::uuid[])`,
{
replacements: requestIds,
type: QueryTypes.DELETE,
transaction
}
);
// Step 2: Delete in order (respecting foreign key constraints)
// Start with child tables, then parent tables
// Helper function to delete with array
const deleteWithArray = async (tableName: string, columnName: string = 'request_id') => {
await sequelize.query(
`DELETE FROM ${tableName} WHERE ${columnName} = ANY(ARRAY[${requestIds.map(() => '?').join(',')}]::uuid[])`,
{
replacements: requestIds,
type: QueryTypes.DELETE,
transaction
}
);
};
// Delete from claim_budget_tracking (new table)
logger.info('[Cleanup] Deleting from claim_budget_tracking...');
await deleteWithArray('claim_budget_tracking');
// Delete from internal_orders (new table)
logger.info('[Cleanup] Deleting from internal_orders...');
await deleteWithArray('internal_orders');
// Delete from dealer_proposal_cost_items
logger.info('[Cleanup] Deleting from dealer_proposal_cost_items...');
await deleteWithArray('dealer_proposal_cost_items');
// Delete from dealer_completion_details
logger.info('[Cleanup] Deleting from dealer_completion_details...');
await deleteWithArray('dealer_completion_details');
// Delete from dealer_proposal_details
logger.info('[Cleanup] Deleting from dealer_proposal_details...');
await deleteWithArray('dealer_proposal_details');
// Delete from dealer_claim_details
logger.info('[Cleanup] Deleting from dealer_claim_details...');
await deleteWithArray('dealer_claim_details');
// Delete from activities (workflow activities)
logger.info('[Cleanup] Deleting from activities...');
await deleteWithArray('activities');
// Delete from work_notes
logger.info('[Cleanup] Deleting from work_notes...');
await deleteWithArray('work_notes');
// Delete from documents
logger.info('[Cleanup] Deleting from documents...');
await deleteWithArray('documents');
// Delete from participants
logger.info('[Cleanup] Deleting from participants...');
await deleteWithArray('participants');
// Delete from approval_levels
logger.info('[Cleanup] Deleting from approval_levels...');
await deleteWithArray('approval_levels');
// Note: subscriptions table doesn't have request_id - it's for push notification subscriptions
// Skip subscriptions as it's not related to workflow requests
// Delete from notifications
logger.info('[Cleanup] Deleting from notifications...');
await deleteWithArray('notifications');
// Delete from request_summaries
logger.info('[Cleanup] Deleting from request_summaries...');
await deleteWithArray('request_summaries');
// Delete from shared_summaries
logger.info('[Cleanup] Deleting from shared_summaries...');
await deleteWithArray('shared_summaries');
// Delete from conclusion_remarks
logger.info('[Cleanup] Deleting from conclusion_remarks...');
await deleteWithArray('conclusion_remarks');
// Delete from tat_alerts
logger.info('[Cleanup] Deleting from tat_alerts...');
await deleteWithArray('tat_alerts');
// Finally, delete from workflow_requests
logger.info('[Cleanup] Deleting from workflow_requests...');
await deleteWithArray('workflow_requests');
await transaction.commit();
logger.info(`[Cleanup] ✅ Successfully deleted ${count} CLAIM_MANAGEMENT request(s) and all related data!`);
logger.info('[Cleanup] Database is now clean and ready for fresh dealer claim requests.');
} catch (error) {
await transaction.rollback();
logger.error('[Cleanup] ❌ Error during cleanup:', error);
throw error;
}
}
// Run cleanup if called directly
if (require.main === module) {
cleanupDealerClaims()
.then(() => {
logger.info('[Cleanup] Cleanup completed successfully');
process.exit(0);
})
.catch((error) => {
logger.error('[Cleanup] Cleanup failed:', error);
process.exit(1);
});
}
export { cleanupDealerClaims };

View File

@ -28,8 +28,20 @@ import * as m24 from '../migrations/20250126-add-paused-to-workflow-status-enum'
import * as m25 from '../migrations/20250126-add-pause-fields-to-workflow-requests'; import * as m25 from '../migrations/20250126-add-pause-fields-to-workflow-requests';
import * as m26 from '../migrations/20250126-add-pause-fields-to-approval-levels'; import * as m26 from '../migrations/20250126-add-pause-fields-to-approval-levels';
import * as m27 from '../migrations/20250127-migrate-in-progress-to-pending'; import * as m27 from '../migrations/20250127-migrate-in-progress-to-pending';
// Base branch migrations (m28-m29)
import * as m28 from '../migrations/20250130-migrate-to-vertex-ai'; import * as m28 from '../migrations/20250130-migrate-to-vertex-ai';
import * as m29 from '../migrations/20251203-add-user-notification-preferences'; import * as m29 from '../migrations/20251203-add-user-notification-preferences';
// Dealer claim branch migrations (m30-m39)
import * as m30 from '../migrations/20251210-add-workflow-type-support';
import * as m31 from '../migrations/20251210-enhance-workflow-templates';
import * as m32 from '../migrations/20251210-add-template-id-foreign-key';
import * as m33 from '../migrations/20251210-create-dealer-claim-tables';
import * as m34 from '../migrations/20251210-create-proposal-cost-items-table';
import * as m35 from '../migrations/20251211-create-internal-orders-table';
import * as m36 from '../migrations/20251211-create-claim-budget-tracking-table';
import * as m37 from '../migrations/20251213-drop-claim-details-invoice-columns';
import * as m38 from '../migrations/20251213-create-claim-invoice-credit-note-tables';
import * as m39 from '../migrations/20251214-create-dealer-completion-expenses';
interface Migration { interface Migration {
name: string; name: string;
@ -72,8 +84,20 @@ const migrations: Migration[] = [
{ name: '20250126-add-pause-fields-to-workflow-requests', module: m25 }, { name: '20250126-add-pause-fields-to-workflow-requests', module: m25 },
{ name: '20250126-add-pause-fields-to-approval-levels', module: m26 }, { name: '20250126-add-pause-fields-to-approval-levels', module: m26 },
{ name: '20250127-migrate-in-progress-to-pending', module: m27 }, { name: '20250127-migrate-in-progress-to-pending', module: m27 },
// Base branch migrations (m28-m29)
{ name: '20250130-migrate-to-vertex-ai', module: m28 }, { name: '20250130-migrate-to-vertex-ai', module: m28 },
{ name: '20251203-add-user-notification-preferences', module: m29 }, { name: '20251203-add-user-notification-preferences', module: m29 },
// Dealer claim branch migrations (m30-m39)
{ name: '20251210-add-workflow-type-support', module: m30 },
{ name: '20251210-enhance-workflow-templates', module: m31 },
{ name: '20251210-add-template-id-foreign-key', module: m32 },
{ name: '20251210-create-dealer-claim-tables', module: m33 },
{ name: '20251210-create-proposal-cost-items-table', module: m34 },
{ name: '20251211-create-internal-orders-table', module: m35 },
{ name: '20251211-create-claim-budget-tracking-table', module: m36 },
{ name: '20251213-drop-claim-details-invoice-columns', module: m37 },
{ name: '20251213-create-claim-invoice-credit-note-tables', module: m38 },
{ name: '20251214-create-dealer-completion-expenses', module: m39 },
]; ];
/** /**

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

@ -12,6 +12,7 @@ import { notificationService } from './notification.service';
import { activityService } from './activity.service'; import { activityService } from './activity.service';
import { tatSchedulerService } from './tatScheduler.service'; import { tatSchedulerService } from './tatScheduler.service';
import { emitToRequestRoom } from '../realtime/socket'; import { emitToRequestRoom } from '../realtime/socket';
import { DealerClaimService } from './dealerClaim.service';
export class ApprovalService { export class ApprovalService {
async approveLevel(levelId: string, action: ApprovalAction, _userId: string, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): Promise<ApprovalLevel | null> { async approveLevel(levelId: string, action: ApprovalAction, _userId: string, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): Promise<ApprovalLevel | null> {
@ -425,14 +426,54 @@ export class ApprovalService {
); );
logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`); logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`);
// Notify next approver
if (wf && nextLevel) { // Check if this is Step 3 approval in a claim management workflow, and next level is Step 4 (auto-step)
const workflowType = (wf as any)?.workflowType;
const isClaimManagement = workflowType === 'CLAIM_MANAGEMENT';
const isStep3Approval = level.levelNumber === 3;
const isStep4Next = nextLevelNumber === 4;
const isStep6Approval = level.levelNumber === 6;
const isStep7Next = nextLevelNumber === 7;
if (isClaimManagement && isStep3Approval && isStep4Next && nextLevel) {
// Step 4 is an auto-step - process it automatically
logger.info(`[Approval] Step 3 approved for claim management workflow. Auto-processing Step 4: Activity Creation`);
try {
const dealerClaimService = new DealerClaimService();
await dealerClaimService.processActivityCreation(level.requestId);
logger.info(`[Approval] Step 4 auto-processing completed for request ${level.requestId}`);
} catch (step4Error) {
logger.error(`[Approval] Error auto-processing Step 4 for request ${level.requestId}:`, step4Error);
// Don't fail the Step 3 approval if Step 4 processing fails - log and continue
}
} else if (isClaimManagement && isStep6Approval && isStep7Next && nextLevel && nextLevel.approverEmail === 'system@royalenfield.com') {
// Step 7 is an auto-step - process it automatically after Step 6 approval
logger.info(`[Approval] Step 6 approved for claim management workflow. Auto-processing Step 7: E-Invoice Generation`);
try {
const dealerClaimService = new DealerClaimService();
await dealerClaimService.processEInvoiceGeneration(level.requestId);
logger.info(`[Approval] Step 7 auto-processing completed for request ${level.requestId}`);
// Skip notification for system auto-processed step
return updatedLevel;
} catch (step7Error) {
logger.error(`[Approval] Error auto-processing Step 7 for request ${level.requestId}:`, step7Error);
// Don't fail the Step 6 approval if Step 7 processing fails - log and continue
}
} else if (wf && nextLevel) {
// Normal flow - notify next approver (skip for auto-steps)
// Check if it's an auto-step by checking approverEmail or levelName
const isAutoStep = (nextLevel as any).approverEmail === 'system@royalenfield.com'
|| (nextLevel as any).approverName === 'System Auto-Process'
|| (nextLevel as any).levelName === 'Activity Creation'
|| (nextLevel as any).levelName === 'E-Invoice Generation';
if (!isAutoStep && (nextLevel as any).approverId) {
await notificationService.sendToUsers([ (nextLevel as any).approverId ], { await notificationService.sendToUsers([ (nextLevel as any).approverId ], {
title: `Action required: ${(wf as any).requestNumber}`, title: `Action required: ${(wf as any).requestNumber}`,
body: `${(wf as any).title}`, body: `${(wf as any).title}`,
requestNumber: (wf as any).requestNumber, requestNumber: (wf as any).requestNumber,
url: `/request/${(wf as any).requestNumber}` url: `/request/${(wf as any).requestNumber}`
}); });
}
activityService.log({ activityService.log({
requestId: level.requestId, requestId: level.requestId,
type: 'approval', type: 'approval',

View File

@ -7,6 +7,230 @@ 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
jobTitle: profile.title || undefined,
postalAddress: profile.postalAddress || undefined,
mobilePhone: profile.mobilePhone || undefined,
secondEmail: profile.secondEmail || profile.second_email || undefined,
adGroups: Array.isArray(profile.memberOf) ? profile.memberOf : undefined,
};
// 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,
manager: userData.manager || 'not provided',
hasDepartment: !!userData.department,
hasDesignation: !!userData.designation,
designation: userData.designation || 'not provided',
hasJobTitle: !!userData.jobTitle,
jobTitle: userData.jobTitle || 'not provided',
hasTitle: !!(userData.jobTitle || userData.designation),
hasAdGroups: !!userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0,
adGroupsCount: userData.adGroups && Array.isArray(userData.adGroups) ? userData.adGroups.length : 0,
adGroups: userData.adGroups && Array.isArray(userData.adGroups) ? userData.adGroups.slice(0, 5) : 'none', // Log first 5 groups
});
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;
userData.jobTitle = oktaUser.title || oktaUser.designation;
}
if (oktaUser.phone_number || oktaUser.phone) {
userData.phone = oktaUser.phone_number || oktaUser.phone;
}
if (oktaUser.manager) {
userData.manager = oktaUser.manager;
}
if (oktaUser.mobilePhone) {
userData.mobilePhone = oktaUser.mobilePhone;
}
if (oktaUser.address || oktaUser.postalAddress) {
userData.postalAddress = oktaUser.address || oktaUser.postalAddress;
}
if (oktaUser.secondEmail) {
userData.secondEmail = oktaUser.secondEmail;
}
if (Array.isArray(oktaUser.memberOf)) {
userData.adGroups = oktaUser.memberOf;
}
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 +283,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 +313,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,51 +532,42 @@ 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', {
email: userData.email, email: userData.email,
employeeId: userData.employeeId || 'not provided',
hasEmployeeId: !!userData.employeeId, hasEmployeeId: !!userData.employeeId,
hasName: !!userData.displayName, hasName: !!userData.displayName,
hasManager: !!(userData as any).manager,
manager: (userData as any).manager || 'not provided',
hasDepartment: !!userData.department,
hasDesignation: !!userData.designation,
hasJobTitle: !!userData.jobTitle,
source: usersApiResponse ? 'Users API' : 'userinfo endpoint',
}); });
// Step 4: Create/update user in our database // Step 4: Create/update user in our database
@ -483,7 +700,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 +709,41 @@ 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, manager: (userData as any).manager || 'not provided',
hasDepartment: !!userData.department, hasDepartment: !!userData.department,
hasDesignation: !!userData.designation, hasDesignation: !!userData.designation,
hasPhone: !!userData.phone, hasJobTitle: !!userData.jobTitle,
hasPostalAddress: !!userData.postalAddress,
hasMobilePhone: !!userData.mobilePhone,
hasSecondEmail: !!userData.secondEmail,
hasAdGroups: !!userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0,
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;
}
}

File diff suppressed because it is too large Load Diff

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

@ -2,6 +2,7 @@ import { User as UserModel } from '../models/User';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import { SSOUserData } from '../types/auth.types'; // Use shared type import { SSOUserData } from '../types/auth.types'; // Use shared type
import axios from 'axios'; import axios from 'axios';
import logger from '../utils/logger';
// Using UserModel type directly - interface removed to avoid duplication // Using UserModel type directly - interface removed to avoid duplication
@ -16,10 +17,83 @@ interface OktaUser {
login: string; login: string;
department?: string; department?: string;
mobilePhone?: string; mobilePhone?: string;
[key: string]: any; // Allow any additional profile fields
}; };
} }
/**
* Extract full user data from Okta Users API response (centralized extraction)
* This ensures consistent field mapping across all user creation/update operations
*/
function extractOktaUserData(oktaUserResponse: any): SSOUserData | null {
try {
const profile = oktaUserResponse.profile || {};
const userData: SSOUserData = {
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, // Manager name from Okta
jobTitle: profile.title || undefined,
postalAddress: profile.postalAddress || undefined,
mobilePhone: profile.mobilePhone || undefined,
secondEmail: profile.secondEmail || profile.second_email || undefined,
adGroups: Array.isArray(profile.memberOf) ? profile.memberOf : undefined,
};
// Validate required fields
if (!userData.oktaSub || !userData.email) {
return null;
}
return userData;
} catch (error) {
return null;
}
}
export class UserService { export class UserService {
/**
* Build a consistent user payload for create/update from SSO data.
* @param isUpdate - If true, excludes email from payload (email should never be updated)
*/
private buildUserPayload(ssoData: SSOUserData, existingRole?: string, isUpdate: boolean = false) {
const now = new Date();
const payload: any = {
oktaSub: ssoData.oktaSub,
employeeId: ssoData.employeeId || null,
firstName: ssoData.firstName || null,
lastName: ssoData.lastName || null,
displayName: ssoData.displayName || null,
department: ssoData.department || null,
designation: ssoData.designation || null,
phone: ssoData.phone || null,
manager: ssoData.manager || null,
jobTitle: ssoData.designation || ssoData.jobTitle || null,
postalAddress: ssoData.postalAddress || null,
mobilePhone: ssoData.mobilePhone || null,
secondEmail: ssoData.secondEmail || null,
adGroups: ssoData.adGroups || null,
lastLogin: now,
updatedAt: now,
isActive: ssoData.isActive ?? true,
role: (ssoData.role as any) || existingRole || 'USER',
};
// Only include email for new users (never update email for existing users)
if (!isUpdate) {
payload.email = ssoData.email;
}
return payload;
}
async createOrUpdateUser(ssoData: SSOUserData): Promise<UserModel> { async createOrUpdateUser(ssoData: SSOUserData): Promise<UserModel> {
// Validate required fields // Validate required fields
if (!ssoData.email || !ssoData.oktaSub) { if (!ssoData.email || !ssoData.oktaSub) {
@ -36,44 +110,18 @@ export class UserService {
} }
}); });
const now = new Date();
if (existingUser) { if (existingUser) {
// Update existing user - include oktaSub to ensure it's synced // Update existing user - DO NOT update email (crucial identifier)
await existingUser.update({ const updatePayload = this.buildUserPayload(ssoData, existingUser.role, true); // isUpdate = true
email: ssoData.email,
oktaSub: ssoData.oktaSub, await existingUser.update(updatePayload);
employeeId: ssoData.employeeId || null, // Optional
firstName: ssoData.firstName || null,
lastName: ssoData.lastName || null,
displayName: ssoData.displayName || null,
department: ssoData.department || null,
designation: ssoData.designation || null,
phone: ssoData.phone || null,
// location: (ssoData as any).location || null, // Ignored for now - schema not finalized
lastLogin: now,
updatedAt: now,
isActive: true, // Ensure user is active after SSO login
});
return existingUser; return existingUser;
} else { } else {
// Create new user - oktaSub is required // Create new user - oktaSub is required, email is included
const newUser = await UserModel.create({ const createPayload = this.buildUserPayload(ssoData, 'USER', false); // isUpdate = false
email: ssoData.email,
oktaSub: ssoData.oktaSub, // Required const newUser = await UserModel.create(createPayload);
employeeId: ssoData.employeeId || null, // Optional
firstName: ssoData.firstName || null,
lastName: ssoData.lastName || null,
displayName: ssoData.displayName || null,
department: ssoData.department || null,
designation: ssoData.designation || null,
phone: ssoData.phone || null,
// location: (ssoData as any).location || null, // Ignored for now - schema not finalized
isActive: true,
role: 'USER', // Default role for new users
lastLogin: now
});
return newUser; return newUser;
} }
@ -221,9 +269,10 @@ export class UserService {
} }
/** /**
* Fetch user from Okta by email * Fetch user from Okta by email and extract full profile data
* Returns SSOUserData with all fields including manager, jobTitle, etc.
*/ */
async fetchUserFromOktaByEmail(email: string): Promise<OktaUser | null> { async fetchAndExtractOktaUserByEmail(email: string): Promise<SSOUserData | null> {
try { try {
const oktaDomain = process.env.OKTA_DOMAIN; const oktaDomain = process.env.OKTA_DOMAIN;
const oktaApiToken = process.env.OKTA_API_TOKEN; const oktaApiToken = process.env.OKTA_API_TOKEN;
@ -232,7 +281,25 @@ export class UserService {
return null; return null;
} }
// Search Okta users by email (exact match) // Try to fetch by email directly first (more reliable)
try {
const directResponse = await axios.get(`${oktaDomain}/api/v1/users/${encodeURIComponent(email)}`, {
headers: {
'Authorization': `SSWS ${oktaApiToken}`,
'Accept': 'application/json'
},
timeout: 5000,
validateStatus: (status) => status < 500
});
if (directResponse.status === 200 && directResponse.data) {
return extractOktaUserData(directResponse.data);
}
} catch (directError) {
// Fall through to search method
}
// Fallback: Search Okta users by email
const response = await axios.get(`${oktaDomain}/api/v1/users`, { const response = await axios.get(`${oktaDomain}/api/v1/users`, {
params: { search: `profile.email eq "${email}"`, limit: 1 }, params: { search: `profile.email eq "${email}"`, limit: 1 },
headers: { headers: {
@ -242,14 +309,81 @@ export class UserService {
timeout: 5000 timeout: 5000
}); });
const users: OktaUser[] = response.data || []; const users: any[] = response.data || [];
return users.length > 0 ? users[0] : null; if (users.length > 0) {
return extractOktaUserData(users[0]);
}
return null;
} catch (error: any) { } catch (error: any) {
console.error(`Failed to fetch user from Okta by email ${email}:`, error.message); console.error(`Failed to fetch user from Okta by email ${email}:`, error.message);
return null; return null;
} }
} }
/**
* Search users in Okta by displayName
* Uses Okta search API: /api/v1/users?search=profile.displayName eq "displayName"
* @param displayName - Display name to search for
* @returns Array of matching users from Okta
*/
async searchOktaByDisplayName(displayName: string): Promise<OktaUser[]> {
try {
const oktaDomain = process.env.OKTA_DOMAIN;
const oktaApiToken = process.env.OKTA_API_TOKEN;
if (!oktaDomain || !oktaApiToken) {
logger.warn('[UserService] Okta not configured, returning empty array for displayName search');
return [];
}
// Search Okta users by displayName
const response = await axios.get(`${oktaDomain}/api/v1/users`, {
params: {
search: `profile.displayName eq "${displayName}"`,
limit: 50
},
headers: {
'Authorization': `SSWS ${oktaApiToken}`,
'Accept': 'application/json'
},
timeout: 5000
});
const oktaUsers: OktaUser[] = response.data || [];
// Filter only active users
return oktaUsers.filter(u => u.status === 'ACTIVE');
} catch (error: any) {
logger.error(`[UserService] Error searching Okta by displayName "${displayName}":`, error.message);
return [];
}
}
/**
* Fetch user from Okta by email (legacy method, kept for backward compatibility)
* @deprecated Use fetchAndExtractOktaUserByEmail instead for full profile extraction
*/
async fetchUserFromOktaByEmail(email: string): Promise<OktaUser | null> {
const userData = await this.fetchAndExtractOktaUserByEmail(email);
if (!userData) return null;
// Return in legacy format for backward compatibility
return {
id: userData.oktaSub,
status: 'ACTIVE',
profile: {
email: userData.email,
login: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
displayName: userData.displayName,
department: userData.department,
mobilePhone: userData.mobilePhone,
}
};
}
/** /**
* Ensure user exists in database (create if not exists) * Ensure user exists in database (create if not exists)
* Used when tagging users from Okta search results or when only email is provided * Used when tagging users from Okta search results or when only email is provided

View File

@ -9,6 +9,14 @@ export interface SSOUserData {
designation?: string; designation?: string;
phone?: string; phone?: string;
reportingManagerId?: string; reportingManagerId?: string;
manager?: string; // Optional - Manager name from Okta profile
jobTitle?: string; // Detailed title from Okta profile.title
postalAddress?: string;
mobilePhone?: string;
secondEmail?: string;
adGroups?: string[];
role?: 'USER' | 'MANAGEMENT' | 'ADMIN';
isActive?: boolean;
} }
export interface SSOConfig { export interface SSOConfig {
@ -20,6 +28,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 {