main code pulled and merged
This commit is contained in:
commit
ce652d260c
7
build/assets/conclusionApi-CGl-93sb.js
Normal file
7
build/assets/conclusionApi-CGl-93sb.js
Normal 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
|
||||
5
build/assets/conclusionApi-CGl-93sb.js.map
Normal file
5
build/assets/conclusionApi-CGl-93sb.js.map
Normal 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
|
||||
@ -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};
|
||||
//# 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
|
||||
|
||||
@ -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-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
|
||||
|
||||
1
build/assets/index-BDQMGM0H.css
Normal file
1
build/assets/index-BDQMGM0H.css
Normal file
File diff suppressed because one or more lines are too long
64
build/assets/index-CogACwP9.js
Normal file
64
build/assets/index-CogACwP9.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/index-CogACwP9.js.map
Normal file
1
build/assets/index-CogACwP9.js.map
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
718
build/assets/ui-vendor-BpFwwBOf.js
Normal file
718
build/assets/ui-vendor-BpFwwBOf.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/ui-vendor-BpFwwBOf.js.map
Normal file
1
build/assets/ui-vendor-BpFwwBOf.js.map
Normal file
File diff suppressed because one or more lines are too long
134
docs/CLAIM_MANAGEMENT_APPROVER_MAPPING.md
Normal file
134
docs/CLAIM_MANAGEMENT_APPROVER_MAPPING.md
Normal file
@ -0,0 +1,134 @@
|
||||
# Claim Management - Approver Mapping Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The Claim Management workflow has **8 fixed steps** with specific approvers and action types. This document explains how approvers are mapped when a claim request is created.
|
||||
|
||||
## 8-Step Workflow Structure
|
||||
|
||||
### Step 1: Dealer Proposal Submission
|
||||
- **Approver Type**: Dealer (External)
|
||||
- **Action Type**: **SUBMIT** (Dealer submits proposal documents)
|
||||
- **TAT**: 72 hours
|
||||
- **Mapping**: Uses `dealerEmail` from claim data
|
||||
- **Status**: PENDING (waiting for dealer to submit)
|
||||
|
||||
### Step 2: Requestor Evaluation
|
||||
- **Approver Type**: Initiator (Internal RE Employee)
|
||||
- **Action Type**: **APPROVE/REJECT** (Requestor reviews dealer proposal)
|
||||
- **TAT**: 48 hours
|
||||
- **Mapping**: Uses `initiatorId` (the person who created the request)
|
||||
- **Status**: PENDING (waiting for requestor to evaluate)
|
||||
|
||||
### Step 3: Department Lead Approval
|
||||
- **Approver Type**: Department Lead (Internal RE Employee)
|
||||
- **Action Type**: **APPROVE/REJECT** (Department lead approves and blocks IO budget)
|
||||
- **TAT**: 72 hours
|
||||
- **Mapping**:
|
||||
- Option 1: Find user with role `MANAGEMENT` in same department as initiator
|
||||
- Option 2: Use initiator's `manager` field from User model
|
||||
- Option 3: Find user with designation containing "Lead" or "Head" in same department
|
||||
- **Status**: PENDING (waiting for department lead approval)
|
||||
|
||||
### Step 4: Activity Creation
|
||||
- **Approver Type**: System (Auto-processed)
|
||||
- **Action Type**: **AUTO** (System automatically creates activity)
|
||||
- **TAT**: 1 hour
|
||||
- **Mapping**: System user (`system@royalenfield.com`)
|
||||
- **Status**: Auto-approved when triggered
|
||||
|
||||
### Step 5: Dealer Completion Documents
|
||||
- **Approver Type**: Dealer (External)
|
||||
- **Action Type**: **SUBMIT** (Dealer submits completion documents)
|
||||
- **TAT**: 120 hours
|
||||
- **Mapping**: Uses `dealerEmail` from claim data
|
||||
- **Status**: PENDING (waiting for dealer to submit)
|
||||
|
||||
### Step 6: Requestor Claim Approval
|
||||
- **Approver Type**: Initiator (Internal RE Employee)
|
||||
- **Action Type**: **APPROVE/REJECT** (Requestor approves completion)
|
||||
- **TAT**: 48 hours
|
||||
- **Mapping**: Uses `initiatorId`
|
||||
- **Status**: PENDING (waiting for requestor approval)
|
||||
|
||||
### Step 7: E-Invoice Generation
|
||||
- **Approver Type**: System (Auto-processed via DMS)
|
||||
- **Action Type**: **AUTO** (System generates e-invoice via DMS integration)
|
||||
- **TAT**: 1 hour
|
||||
- **Mapping**: System user (`system@royalenfield.com`)
|
||||
- **Status**: Auto-approved when triggered
|
||||
|
||||
### Step 8: Credit Note Confirmation
|
||||
- **Approver Type**: Finance Team (Internal RE Employee)
|
||||
- **Action Type**: **APPROVE/REJECT** (Finance confirms credit note)
|
||||
- **TAT**: 48 hours
|
||||
- **Mapping**:
|
||||
- Option 1: Find user with role `MANAGEMENT` and department contains "Finance"
|
||||
- Option 2: Find user with designation containing "Finance" or "Accountant"
|
||||
- Option 3: Use configured finance team email from admin settings
|
||||
- **Status**: PENDING (waiting for finance confirmation)
|
||||
- **Is Final Approver**: Yes (final step)
|
||||
|
||||
## Current Implementation Issues
|
||||
|
||||
### Problems:
|
||||
1. **Step 1 & 5**: Dealer email not being used - using placeholder UUID
|
||||
2. **Step 3**: Department Lead not resolved - using placeholder UUID
|
||||
3. **Step 8**: Finance team not resolved - using placeholder UUID
|
||||
4. **All steps**: Using initiator email for non-initiator steps
|
||||
|
||||
### Impact:
|
||||
- Steps 1, 3, 5, 8 won't have correct approvers assigned
|
||||
- Notifications won't be sent to correct users
|
||||
- Workflow will be stuck waiting for non-existent approvers
|
||||
|
||||
## Action Types Summary
|
||||
|
||||
| Step | Action Type | Description |
|
||||
|------|-------------|-------------|
|
||||
| 1 | SUBMIT | Dealer submits proposal (not approve/reject) |
|
||||
| 2 | APPROVE/REJECT | Requestor evaluates proposal |
|
||||
| 3 | APPROVE/REJECT | Department Lead approves and blocks budget |
|
||||
| 4 | AUTO | System creates activity automatically |
|
||||
| 5 | SUBMIT | Dealer submits completion documents |
|
||||
| 6 | APPROVE/REJECT | Requestor approves completion |
|
||||
| 7 | AUTO | System generates e-invoice via DMS |
|
||||
| 8 | APPROVE/REJECT | Finance confirms credit note (FINAL) |
|
||||
|
||||
## Approver Resolution Logic
|
||||
|
||||
### For Dealer Steps (1, 5):
|
||||
```typescript
|
||||
// Use dealer email from claim data
|
||||
const dealerEmail = claimData.dealerEmail;
|
||||
// Find or create dealer user (if dealer is external, may need special handling)
|
||||
const dealerUser = await User.findOne({ where: { email: dealerEmail } });
|
||||
// If dealer doesn't exist in system, create participant entry
|
||||
```
|
||||
|
||||
### For Department Lead (Step 3):
|
||||
```typescript
|
||||
// Priority order:
|
||||
1. Find user with same department and role = 'MANAGEMENT'
|
||||
2. Use initiator.manager field to find manager
|
||||
3. Find user with designation containing "Lead" or "Head" in same department
|
||||
4. Fallback: Use initiator's manager email from User model
|
||||
```
|
||||
|
||||
### For Finance Team (Step 8):
|
||||
```typescript
|
||||
// Priority order:
|
||||
1. Find user with department containing "Finance" and role = 'MANAGEMENT'
|
||||
2. Find user with designation containing "Finance" or "Accountant"
|
||||
3. Use configured finance team email from admin_configurations table
|
||||
4. Fallback: Use default finance email (e.g., finance@royalenfield.com)
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
The `createClaimApprovalLevels()` method needs to be updated to:
|
||||
1. Accept `dealerEmail` parameter
|
||||
2. Resolve Department Lead dynamically
|
||||
3. Resolve Finance team member dynamically
|
||||
4. Handle cases where approvers don't exist in the system
|
||||
|
||||
149
docs/COST_BREAKUP_TABLE_ARCHITECTURE.md
Normal file
149
docs/COST_BREAKUP_TABLE_ARCHITECTURE.md
Normal file
@ -0,0 +1,149 @@
|
||||
# Cost Breakup Table Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the enhanced architecture for storing cost breakups in the Dealer Claim Management system. Instead of storing cost breakups as JSONB arrays, we now use a dedicated relational table for better querying, reporting, and data integrity.
|
||||
|
||||
## Architecture Decision
|
||||
|
||||
### Previous Approach (JSONB)
|
||||
- **Storage**: Cost breakups stored as JSONB array in `dealer_proposal_details.cost_breakup`
|
||||
- **Limitations**:
|
||||
- Difficult to query individual cost items
|
||||
- Hard to update specific items
|
||||
- Not ideal for reporting and analytics
|
||||
- No referential integrity
|
||||
|
||||
### New Approach (Separate Table)
|
||||
- **Storage**: Dedicated `dealer_proposal_cost_items` table
|
||||
- **Benefits**:
|
||||
- Better querying and filtering capabilities
|
||||
- Easier to update individual cost items
|
||||
- Better for analytics and reporting
|
||||
- Maintains referential integrity
|
||||
- Supports proper ordering of items
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Table: `dealer_proposal_cost_items`
|
||||
|
||||
```sql
|
||||
CREATE TABLE dealer_proposal_cost_items (
|
||||
cost_item_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
proposal_id UUID NOT NULL REFERENCES dealer_proposal_details(proposal_id) ON DELETE CASCADE,
|
||||
request_id UUID NOT NULL REFERENCES workflow_requests(request_id) ON DELETE CASCADE,
|
||||
item_description VARCHAR(500) NOT NULL,
|
||||
amount DECIMAL(15, 2) NOT NULL,
|
||||
item_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**Indexes**:
|
||||
- `idx_proposal_cost_items_proposal_id` on `proposal_id`
|
||||
- `idx_proposal_cost_items_request_id` on `request_id`
|
||||
- `idx_proposal_cost_items_proposal_order` on `(proposal_id, item_order)`
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
The system maintains backward compatibility by:
|
||||
1. **Dual Storage**: Still saves cost breakups to JSONB field for backward compatibility
|
||||
2. **Smart Retrieval**: When fetching proposal details:
|
||||
- First tries to get cost items from the new table
|
||||
- Falls back to JSONB field if table is empty
|
||||
3. **Migration**: Automatically migrates existing JSONB data to the new table during migration
|
||||
|
||||
## API Response Format
|
||||
|
||||
The API always returns cost breakups as an array, regardless of storage method:
|
||||
|
||||
```json
|
||||
{
|
||||
"proposalDetails": {
|
||||
"proposalId": "uuid",
|
||||
"costBreakup": [
|
||||
{
|
||||
"description": "Item 1",
|
||||
"amount": 10000
|
||||
},
|
||||
{
|
||||
"description": "Item 2",
|
||||
"amount": 20000
|
||||
}
|
||||
],
|
||||
"costItems": [
|
||||
{
|
||||
"costItemId": "uuid",
|
||||
"itemDescription": "Item 1",
|
||||
"amount": 10000,
|
||||
"itemOrder": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Saving Cost Items
|
||||
|
||||
When a proposal is submitted:
|
||||
|
||||
1. Save proposal details to `dealer_proposal_details` (with JSONB for backward compatibility)
|
||||
2. Delete existing cost items for the proposal (if updating)
|
||||
3. Insert new cost items into `dealer_proposal_cost_items` table
|
||||
4. Items are ordered by `itemOrder` field
|
||||
|
||||
### Retrieving Cost Items
|
||||
|
||||
When fetching proposal details:
|
||||
|
||||
1. Query `dealer_proposal_details` with `include` for `costItems`
|
||||
2. If cost items exist in the table, use them
|
||||
3. If not, fall back to parsing JSONB `costBreakup` field
|
||||
4. Always return as a normalized array format
|
||||
|
||||
## Migration
|
||||
|
||||
The migration (`20251210-create-proposal-cost-items-table.ts`):
|
||||
1. Creates the new table
|
||||
2. Creates indexes for performance
|
||||
3. Migrates existing JSONB data to the new table automatically
|
||||
4. Handles errors gracefully (doesn't fail if migration of existing data fails)
|
||||
|
||||
## Model Associations
|
||||
|
||||
```typescript
|
||||
DealerProposalDetails.hasMany(DealerProposalCostItem, {
|
||||
as: 'costItems',
|
||||
foreignKey: 'proposalId',
|
||||
sourceKey: 'proposalId'
|
||||
});
|
||||
|
||||
DealerProposalCostItem.belongsTo(DealerProposalDetails, {
|
||||
as: 'proposal',
|
||||
foreignKey: 'proposalId',
|
||||
targetKey: 'proposalId'
|
||||
});
|
||||
```
|
||||
|
||||
## Benefits for Frontend
|
||||
|
||||
1. **Consistent Format**: Always receives cost breakups as an array
|
||||
2. **No Changes Required**: Frontend code doesn't need to change
|
||||
3. **Better Performance**: Can query specific cost items if needed
|
||||
4. **Future Extensibility**: Easy to add features like:
|
||||
- Cost item categories
|
||||
- Approval status per item
|
||||
- Historical tracking of cost changes
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential future improvements:
|
||||
- Add `category` field to cost items
|
||||
- Add `approved_amount` vs `requested_amount` for budget approval workflows
|
||||
- Add `notes` field for item-level comments
|
||||
- Add audit trail for cost item changes
|
||||
- Add `is_approved` flag for individual item approval
|
||||
|
||||
181
docs/DEALER_CLAIM_FRESH_START.md
Normal file
181
docs/DEALER_CLAIM_FRESH_START.md
Normal file
@ -0,0 +1,181 @@
|
||||
# Dealer Claim Management - Fresh Start Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide helps you start fresh with the dealer claim management system by cleaning up all existing data and ensuring the database structure is ready for new requests.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Database Migrations**: Ensure all migrations are up to date, including the new tables:
|
||||
- `internal_orders` (for IO details)
|
||||
- `claim_budget_tracking` (for comprehensive budget tracking)
|
||||
|
||||
2. **Backup** (Optional but Recommended):
|
||||
- If you have important data, backup your database before running cleanup
|
||||
|
||||
## Fresh Start Steps
|
||||
|
||||
### Step 1: Run Database Migrations
|
||||
|
||||
Ensure all new tables are created:
|
||||
|
||||
```bash
|
||||
cd Re_Backend
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
This will create:
|
||||
- ✅ `internal_orders` table (for IO details with `ioRemark`)
|
||||
- ✅ `claim_budget_tracking` table (for comprehensive budget tracking)
|
||||
- ✅ All other dealer claim related tables
|
||||
|
||||
### Step 2: Clean Up All Existing Dealer Claims
|
||||
|
||||
Run the cleanup script to remove all existing CLAIM_MANAGEMENT requests:
|
||||
|
||||
```bash
|
||||
npm run cleanup:dealer-claims
|
||||
```
|
||||
|
||||
**What this script does:**
|
||||
- Finds all workflow requests with `workflow_type = 'CLAIM_MANAGEMENT'`
|
||||
- Deletes all related data from:
|
||||
- `claim_budget_tracking`
|
||||
- `internal_orders`
|
||||
- `dealer_proposal_cost_items`
|
||||
- `dealer_completion_details`
|
||||
- `dealer_proposal_details`
|
||||
- `dealer_claim_details`
|
||||
- `activities`
|
||||
- `work_notes`
|
||||
- `documents`
|
||||
- `participants`
|
||||
- `approval_levels`
|
||||
- `subscriptions`
|
||||
- `notifications`
|
||||
- `request_summaries`
|
||||
- `shared_summaries`
|
||||
- `conclusion_remarks`
|
||||
- `tat_alerts`
|
||||
- `workflow_requests` (finally)
|
||||
|
||||
**Note:** This script uses a database transaction, so if any step fails, all changes will be rolled back.
|
||||
|
||||
### Step 3: Verify Cleanup
|
||||
|
||||
After running the cleanup script, verify that no CLAIM_MANAGEMENT requests remain:
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM workflow_requests WHERE workflow_type = 'CLAIM_MANAGEMENT';
|
||||
-- Should return 0
|
||||
```
|
||||
|
||||
### Step 4: Seed Dealers (If Needed)
|
||||
|
||||
If you need to seed dealer users:
|
||||
|
||||
```bash
|
||||
npm run seed:dealers
|
||||
```
|
||||
|
||||
## Database Structure Summary
|
||||
|
||||
### New Tables Created
|
||||
|
||||
1. **`internal_orders`** - Dedicated table for IO (Internal Order) details
|
||||
- `io_id` (PK)
|
||||
- `request_id` (FK, unique)
|
||||
- `io_number`
|
||||
- `io_remark` ✅ (dedicated field, not in comments)
|
||||
- `io_available_balance`
|
||||
- `io_blocked_amount`
|
||||
- `io_remaining_balance`
|
||||
- `organized_by` (FK to users)
|
||||
- `organized_at`
|
||||
- `status` (PENDING, BLOCKED, RELEASED, CANCELLED)
|
||||
|
||||
2. **`claim_budget_tracking`** - Comprehensive budget tracking
|
||||
- `budget_id` (PK)
|
||||
- `request_id` (FK, unique)
|
||||
- `initial_estimated_budget`
|
||||
- `proposal_estimated_budget`
|
||||
- `approved_budget`
|
||||
- `io_blocked_amount`
|
||||
- `closed_expenses`
|
||||
- `final_claim_amount`
|
||||
- `credit_note_amount`
|
||||
- `budget_status` (DRAFT, PROPOSED, APPROVED, BLOCKED, CLOSED, SETTLED)
|
||||
- `variance_amount` & `variance_percentage`
|
||||
- Audit fields (last_modified_by, last_modified_at, modification_reason)
|
||||
|
||||
### Existing Tables (Enhanced)
|
||||
|
||||
- `dealer_claim_details` - Main claim information
|
||||
- `dealer_proposal_details` - Step 1: Dealer proposal
|
||||
- `dealer_proposal_cost_items` - Cost breakdown items
|
||||
- `dealer_completion_details` - Step 5: Completion documents
|
||||
|
||||
## What's New
|
||||
|
||||
### 1. IO Details in Separate Table
|
||||
- ✅ IO remark is now stored in `internal_orders.io_remark` (not parsed from comments)
|
||||
- ✅ Tracks who organized the IO (`organized_by`, `organized_at`)
|
||||
- ✅ Better data integrity and querying
|
||||
|
||||
### 2. Comprehensive Budget Tracking
|
||||
- ✅ All budget-related values in one place
|
||||
- ✅ Tracks budget lifecycle (DRAFT → PROPOSED → APPROVED → BLOCKED → CLOSED → SETTLED)
|
||||
- ✅ Calculates variance automatically
|
||||
- ✅ Audit trail for budget modifications
|
||||
|
||||
### 3. Proper Data Structure
|
||||
- ✅ Estimated budget: `claimDetails.estimatedBudget` or `proposalDetails.totalEstimatedBudget`
|
||||
- ✅ Claim amount: `completionDetails.totalClosedExpenses` or `budgetTracking.finalClaimAmount`
|
||||
- ✅ IO details: `internalOrder` table (separate, dedicated)
|
||||
- ✅ E-Invoice: `claimDetails.eInvoiceNumber`, `claimDetails.eInvoiceDate`
|
||||
- ✅ Credit Note: `claimDetails.creditNoteNumber`, `claimDetails.creditNoteAmount`
|
||||
|
||||
## Next Steps After Cleanup
|
||||
|
||||
1. **Create New Claim Requests**: Use the API or frontend to create fresh dealer claim requests
|
||||
2. **Test Workflow**: Go through the 8-step workflow to ensure everything works correctly
|
||||
3. **Verify Data Storage**: Check that IO details and budget tracking are properly stored
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### If Cleanup Fails
|
||||
|
||||
1. Check database connection
|
||||
2. Verify foreign key constraints are not blocking deletion
|
||||
3. Check logs for specific error messages
|
||||
4. The script uses transactions, so partial deletions won't occur
|
||||
|
||||
### If Tables Don't Exist
|
||||
|
||||
Run migrations again:
|
||||
```bash
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
### If You Need to Restore Data
|
||||
|
||||
If you backed up before cleanup, restore from your backup. The cleanup script does not create backups automatically.
|
||||
|
||||
## API Endpoints Ready
|
||||
|
||||
After cleanup, you can use these endpoints:
|
||||
|
||||
- `POST /api/v1/dealer-claims` - Create new claim request
|
||||
- `POST /api/v1/dealer-claims/:requestId/proposal` - Submit proposal (Step 1)
|
||||
- `PUT /api/v1/dealer-claims/:requestId/io` - Update IO details (Step 3)
|
||||
- `POST /api/v1/dealer-claims/:requestId/completion` - Submit completion (Step 5)
|
||||
- `PUT /api/v1/dealer-claims/:requestId/e-invoice` - Update e-invoice (Step 7)
|
||||
- `PUT /api/v1/dealer-claims/:requestId/credit-note` - Update credit note (Step 8)
|
||||
|
||||
## Summary
|
||||
|
||||
✅ **Cleanup Script**: `npm run cleanup:dealer-claims`
|
||||
✅ **Migrations**: `npm run migrate`
|
||||
✅ **Fresh Start**: Database is ready for new dealer claim requests
|
||||
✅ **Proper Structure**: IO details and budget tracking in dedicated tables
|
||||
|
||||
134
docs/DEALER_USER_ARCHITECTURE.md
Normal file
134
docs/DEALER_USER_ARCHITECTURE.md
Normal file
@ -0,0 +1,134 @@
|
||||
# Dealer User Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
**Dealers and regular users are stored in the SAME `users` table.** This is the correct approach because dealers ARE users in the system - they login via SSO, participate in workflows, receive notifications, etc.
|
||||
|
||||
## Why Single Table?
|
||||
|
||||
### ✅ Advantages:
|
||||
1. **Unified Authentication**: Dealers login via the same Okta SSO as regular users
|
||||
2. **Shared Functionality**: Dealers need all user features (notifications, workflow participation, etc.)
|
||||
3. **Simpler Architecture**: No need for joins or complex queries
|
||||
4. **Data Consistency**: Single source of truth for all users
|
||||
5. **Workflow Integration**: Dealers can be approvers, participants, or action takers seamlessly
|
||||
|
||||
### ❌ Why NOT Separate Table:
|
||||
- Would require complex joins for every query
|
||||
- Data duplication (email, name, etc. in both tables)
|
||||
- Dealers still need user authentication and permissions
|
||||
- More complex to maintain
|
||||
|
||||
## How Dealers Are Identified
|
||||
|
||||
Dealers are identified using **three criteria** (any one matches):
|
||||
|
||||
1. **`employeeId` field starts with `'RE-'`** (e.g., `RE-MH-001`, `RE-DL-002`)
|
||||
- This is the **primary identifier** for dealers
|
||||
- Dealer code is stored in `employeeId` field
|
||||
|
||||
2. **`designation` contains `'dealer'`** (case-insensitive)
|
||||
- Example: `"Dealer"`, `"Senior Dealer"`, etc.
|
||||
|
||||
3. **`department` contains `'dealer'`** (case-insensitive)
|
||||
- Example: `"Dealer Operations"`, `"Dealer Management"`, etc.
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
users {
|
||||
user_id UUID PK
|
||||
email VARCHAR(255) UNIQUE
|
||||
okta_sub VARCHAR(100) UNIQUE -- From Okta SSO
|
||||
employee_id VARCHAR(50) -- For dealers: stores dealer code (RE-MH-001)
|
||||
display_name VARCHAR(255)
|
||||
designation VARCHAR(255) -- For dealers: "Dealer"
|
||||
department VARCHAR(255) -- For dealers: "Dealer Operations"
|
||||
role ENUM('USER', 'MANAGEMENT', 'ADMIN')
|
||||
is_active BOOLEAN
|
||||
-- ... other user fields
|
||||
}
|
||||
```
|
||||
|
||||
## Example Data
|
||||
|
||||
### Regular User:
|
||||
```json
|
||||
{
|
||||
"userId": "uuid-1",
|
||||
"email": "john.doe@royalenfield.com",
|
||||
"employeeId": "E12345", // Regular employee ID
|
||||
"designation": "Software Engineer",
|
||||
"department": "IT",
|
||||
"role": "USER"
|
||||
}
|
||||
```
|
||||
|
||||
### Dealer User:
|
||||
```json
|
||||
{
|
||||
"userId": "uuid-2",
|
||||
"email": "test.2@royalenfield.com",
|
||||
"employeeId": "RE-MH-001", // Dealer code stored here
|
||||
"designation": "Dealer",
|
||||
"department": "Dealer Operations",
|
||||
"role": "USER"
|
||||
}
|
||||
```
|
||||
|
||||
## Querying Dealers
|
||||
|
||||
The `dealer.service.ts` uses these filters to find dealers:
|
||||
|
||||
```typescript
|
||||
User.findAll({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ designation: { [Op.iLike]: '%dealer%' } },
|
||||
{ employeeId: { [Op.like]: 'RE-%' } },
|
||||
{ department: { [Op.iLike]: '%dealer%' } },
|
||||
],
|
||||
isActive: true,
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Seed Script Behavior
|
||||
|
||||
When running `npm run seed:dealers`:
|
||||
|
||||
1. **If user exists (from Okta SSO)**:
|
||||
- ✅ Preserves `oktaSub` (real Okta subject ID)
|
||||
- ✅ Preserves `role` (from Okta)
|
||||
- ✅ Updates `employeeId` with dealer code
|
||||
- ✅ Updates `designation` to "Dealer" (if not already)
|
||||
- ✅ Updates `department` to "Dealer Operations" (if not already)
|
||||
|
||||
2. **If user doesn't exist**:
|
||||
- Creates placeholder user
|
||||
- Sets `oktaSub` to `dealer-{code}-pending-sso`
|
||||
- When dealer logs in via SSO, `oktaSub` gets updated automatically
|
||||
|
||||
## Workflow Integration
|
||||
|
||||
Dealers participate in workflows just like regular users:
|
||||
|
||||
- **As Approvers**: In Steps 1 & 5 of claim management workflow
|
||||
- **As Participants**: Can be added to any workflow
|
||||
- **As Action Takers**: Can submit proposals, completion documents, etc.
|
||||
|
||||
The system identifies them as dealers by checking `employeeId` starting with `'RE-'` or `designation` containing `'dealer'`.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /api/v1/dealers` - Get all dealers (filters users table)
|
||||
- `GET /api/v1/dealers/code/:dealerCode` - Get dealer by code
|
||||
- `GET /api/v1/dealers/email/:email` - Get dealer by email
|
||||
- `GET /api/v1/dealers/search?q=term` - Search dealers
|
||||
|
||||
All endpoints query the same `users` table with dealer-specific filters.
|
||||
|
||||
## Conclusion
|
||||
|
||||
**✅ Single `users` table is the correct approach.** No separate dealer table needed. Dealers are users with special identification markers (dealer code in `employeeId`, dealer designation, etc.).
|
||||
|
||||
1197
docs/DYNAMIC_TEMPLATE_SYSTEM.md
Normal file
1197
docs/DYNAMIC_TEMPLATE_SYSTEM.md
Normal file
File diff suppressed because it is too large
Load Diff
507
docs/ERD.mermaid
Normal file
507
docs/ERD.mermaid
Normal 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
|
||||
}
|
||||
|
||||
583
docs/EXTENSIBLE_WORKFLOW_ARCHITECTURE.md
Normal file
583
docs/EXTENSIBLE_WORKFLOW_ARCHITECTURE.md
Normal file
@ -0,0 +1,583 @@
|
||||
# Extensible Workflow Architecture Plan
|
||||
## Supporting Multiple Template Types (Claim Management, Non-Templatized, Future Templates)
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines how to design the backend architecture to support:
|
||||
1. **Unified Request System**: All requests (templatized, non-templatized, claim management) use the same `workflow_requests` table
|
||||
2. **Template Identification**: Distinguish between different workflow types
|
||||
3. **Extensibility**: Easy addition of new templates by admins without code changes
|
||||
4. **Unified Views**: All requests appear in "My Requests", "Open Requests", etc. automatically
|
||||
|
||||
---
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
### 1. **Single Source of Truth: `workflow_requests` Table**
|
||||
|
||||
All requests, regardless of type, are stored in the same table:
|
||||
|
||||
```sql
|
||||
workflow_requests {
|
||||
request_id UUID PK
|
||||
request_number VARCHAR(20) UK
|
||||
initiator_id UUID FK
|
||||
template_type VARCHAR(20) -- 'CUSTOM' | 'TEMPLATE' (high-level)
|
||||
workflow_type VARCHAR(50) -- 'NON_TEMPLATIZED' | 'CLAIM_MANAGEMENT' | 'DEALER_ONBOARDING' | etc.
|
||||
template_id UUID FK (nullable) -- Reference to workflow_templates if using admin template
|
||||
title VARCHAR(500)
|
||||
description TEXT
|
||||
status workflow_status
|
||||
current_level INTEGER
|
||||
total_levels INTEGER
|
||||
-- ... common fields
|
||||
}
|
||||
```
|
||||
|
||||
**Key Fields:**
|
||||
- `template_type`: High-level classification ('CUSTOM' for user-created, 'TEMPLATE' for admin templates)
|
||||
- `workflow_type`: Specific workflow identifier (e.g., 'CLAIM_MANAGEMENT', 'NON_TEMPLATIZED')
|
||||
- `template_id`: Optional reference to `workflow_templates` table if using an admin-created template
|
||||
|
||||
### 2. **Template-Specific Data Storage**
|
||||
|
||||
Each workflow type can have its own extension table for type-specific data:
|
||||
|
||||
```sql
|
||||
-- For Claim Management
|
||||
dealer_claim_details {
|
||||
claim_id UUID PK
|
||||
request_id UUID FK -> workflow_requests(request_id)
|
||||
activity_name VARCHAR(500)
|
||||
activity_type VARCHAR(100)
|
||||
dealer_code VARCHAR(50)
|
||||
dealer_name VARCHAR(200)
|
||||
dealer_email VARCHAR(255)
|
||||
dealer_phone VARCHAR(20)
|
||||
dealer_address TEXT
|
||||
activity_date DATE
|
||||
location VARCHAR(255)
|
||||
period_start_date DATE
|
||||
period_end_date DATE
|
||||
estimated_budget DECIMAL(15,2)
|
||||
closed_expenses DECIMAL(15,2)
|
||||
io_number VARCHAR(50)
|
||||
io_blocked_amount DECIMAL(15,2)
|
||||
sap_document_number VARCHAR(100)
|
||||
dms_number VARCHAR(100)
|
||||
e_invoice_number VARCHAR(100)
|
||||
credit_note_number VARCHAR(100)
|
||||
-- ... claim-specific fields
|
||||
}
|
||||
|
||||
-- For Non-Templatized (if needed)
|
||||
non_templatized_details {
|
||||
detail_id UUID PK
|
||||
request_id UUID FK -> workflow_requests(request_id)
|
||||
custom_fields JSONB -- Flexible storage for any custom data
|
||||
-- ... any specific fields
|
||||
}
|
||||
|
||||
-- For Future Templates
|
||||
-- Each new template can have its own extension table
|
||||
```
|
||||
|
||||
### 3. **Workflow Templates Table (Admin-Created Templates)**
|
||||
|
||||
```sql
|
||||
workflow_templates {
|
||||
template_id UUID PK
|
||||
template_name VARCHAR(200) -- Display name: "Claim Management", "Dealer Onboarding"
|
||||
template_code VARCHAR(50) UK -- Unique identifier: "CLAIM_MANAGEMENT", "DEALER_ONBOARDING"
|
||||
template_description TEXT
|
||||
template_category VARCHAR(100) -- "Dealer Operations", "HR", "Finance", etc.
|
||||
workflow_type VARCHAR(50) -- Maps to workflow_requests.workflow_type
|
||||
approval_levels_config JSONB -- Step definitions, TAT, roles, etc.
|
||||
default_tat_hours DECIMAL(10,2)
|
||||
form_fields_config JSONB -- Form field definitions for wizard
|
||||
is_active BOOLEAN
|
||||
is_system_template BOOLEAN -- True for built-in (Claim Management), False for admin-created
|
||||
created_by UUID FK
|
||||
created_at TIMESTAMP
|
||||
updated_at TIMESTAMP
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema Changes
|
||||
|
||||
### Migration: Add Workflow Type Support
|
||||
|
||||
```sql
|
||||
-- Migration: 20251210-add-workflow-type-support.ts
|
||||
|
||||
-- 1. Add workflow_type column to workflow_requests
|
||||
ALTER TABLE workflow_requests
|
||||
ADD COLUMN IF NOT EXISTS workflow_type VARCHAR(50) DEFAULT 'NON_TEMPLATIZED';
|
||||
|
||||
-- 2. Add template_id column (nullable, for admin templates)
|
||||
ALTER TABLE workflow_requests
|
||||
ADD COLUMN IF NOT EXISTS template_id UUID REFERENCES workflow_templates(template_id);
|
||||
|
||||
-- 3. Create index for workflow_type
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_requests_workflow_type
|
||||
ON workflow_requests(workflow_type);
|
||||
|
||||
-- 4. Create index for template_id
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_requests_template_id
|
||||
ON workflow_requests(template_id);
|
||||
|
||||
-- 5. Create dealer_claim_details table
|
||||
CREATE TABLE IF NOT EXISTS dealer_claim_details (
|
||||
claim_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
request_id UUID NOT NULL UNIQUE REFERENCES workflow_requests(request_id) ON DELETE CASCADE,
|
||||
activity_name VARCHAR(500) NOT NULL,
|
||||
activity_type VARCHAR(100) NOT NULL,
|
||||
dealer_code VARCHAR(50) NOT NULL,
|
||||
dealer_name VARCHAR(200) NOT NULL,
|
||||
dealer_email VARCHAR(255),
|
||||
dealer_phone VARCHAR(20),
|
||||
dealer_address TEXT,
|
||||
activity_date DATE,
|
||||
location VARCHAR(255),
|
||||
period_start_date DATE,
|
||||
period_end_date DATE,
|
||||
estimated_budget DECIMAL(15,2),
|
||||
closed_expenses DECIMAL(15,2),
|
||||
io_number VARCHAR(50),
|
||||
io_available_balance DECIMAL(15,2),
|
||||
io_blocked_amount DECIMAL(15,2),
|
||||
io_remaining_balance DECIMAL(15,2),
|
||||
sap_document_number VARCHAR(100),
|
||||
dms_number VARCHAR(100),
|
||||
e_invoice_number VARCHAR(100),
|
||||
e_invoice_date DATE,
|
||||
credit_note_number VARCHAR(100),
|
||||
credit_note_date DATE,
|
||||
credit_note_amount DECIMAL(15,2),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dealer_claim_details_request_id ON dealer_claim_details(request_id);
|
||||
CREATE INDEX idx_dealer_claim_details_dealer_code ON dealer_claim_details(dealer_code);
|
||||
|
||||
-- 6. Create proposal_details table (Step 1: Dealer Proposal)
|
||||
CREATE TABLE IF NOT EXISTS dealer_proposal_details (
|
||||
proposal_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
request_id UUID NOT NULL REFERENCES workflow_requests(request_id) ON DELETE CASCADE,
|
||||
proposal_document_path VARCHAR(500),
|
||||
proposal_document_url VARCHAR(500),
|
||||
cost_breakup JSONB, -- Array of {description, amount}
|
||||
total_estimated_budget DECIMAL(15,2),
|
||||
timeline_mode VARCHAR(10), -- 'date' | 'days'
|
||||
expected_completion_date DATE,
|
||||
expected_completion_days INTEGER,
|
||||
dealer_comments TEXT,
|
||||
submitted_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dealer_proposal_details_request_id ON dealer_proposal_details(request_id);
|
||||
|
||||
-- 7. Create completion_documents table (Step 5: Dealer Completion)
|
||||
CREATE TABLE IF NOT EXISTS dealer_completion_details (
|
||||
completion_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
request_id UUID NOT NULL REFERENCES workflow_requests(request_id) ON DELETE CASCADE,
|
||||
activity_completion_date DATE NOT NULL,
|
||||
number_of_participants INTEGER,
|
||||
closed_expenses JSONB, -- Array of {description, amount}
|
||||
total_closed_expenses DECIMAL(15,2),
|
||||
completion_documents JSONB, -- Array of document references
|
||||
activity_photos JSONB, -- Array of photo references
|
||||
submitted_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dealer_completion_details_request_id ON dealer_completion_details(request_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Model Updates
|
||||
|
||||
### 1. Update WorkflowRequest Model
|
||||
|
||||
```typescript
|
||||
// Re_Backend/src/models/WorkflowRequest.ts
|
||||
|
||||
interface WorkflowRequestAttributes {
|
||||
requestId: string;
|
||||
requestNumber: string;
|
||||
initiatorId: string;
|
||||
templateType: 'CUSTOM' | 'TEMPLATE';
|
||||
workflowType: string; // NEW: 'NON_TEMPLATIZED' | 'CLAIM_MANAGEMENT' | etc.
|
||||
templateId?: string; // NEW: Reference to workflow_templates
|
||||
title: string;
|
||||
description: string;
|
||||
// ... existing fields
|
||||
}
|
||||
|
||||
// Add association
|
||||
WorkflowRequest.hasOne(DealerClaimDetails, {
|
||||
as: 'claimDetails',
|
||||
foreignKey: 'requestId',
|
||||
sourceKey: 'requestId'
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Create DealerClaimDetails Model
|
||||
|
||||
```typescript
|
||||
// Re_Backend/src/models/DealerClaimDetails.ts
|
||||
|
||||
import { DataTypes, Model } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import { WorkflowRequest } from './WorkflowRequest';
|
||||
|
||||
interface DealerClaimDetailsAttributes {
|
||||
claimId: string;
|
||||
requestId: string;
|
||||
activityName: string;
|
||||
activityType: string;
|
||||
dealerCode: string;
|
||||
dealerName: string;
|
||||
// ... all claim-specific fields
|
||||
}
|
||||
|
||||
class DealerClaimDetails extends Model<DealerClaimDetailsAttributes> {
|
||||
public claimId!: string;
|
||||
public requestId!: string;
|
||||
// ... fields
|
||||
}
|
||||
|
||||
DealerClaimDetails.init({
|
||||
claimId: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
field: 'claim_id'
|
||||
},
|
||||
requestId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
field: 'request_id',
|
||||
references: {
|
||||
model: 'workflow_requests',
|
||||
key: 'request_id'
|
||||
}
|
||||
},
|
||||
// ... all other fields
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'DealerClaimDetails',
|
||||
tableName: 'dealer_claim_details',
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Association
|
||||
DealerClaimDetails.belongsTo(WorkflowRequest, {
|
||||
as: 'workflowRequest',
|
||||
foreignKey: 'requestId',
|
||||
targetKey: 'requestId'
|
||||
});
|
||||
|
||||
export { DealerClaimDetails };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Layer Pattern
|
||||
|
||||
### 1. Template-Aware Service Factory
|
||||
|
||||
```typescript
|
||||
// Re_Backend/src/services/templateService.factory.ts
|
||||
|
||||
import { WorkflowRequest } from '../models/WorkflowRequest';
|
||||
import { DealerClaimService } from './dealerClaim.service';
|
||||
import { NonTemplatizedService } from './nonTemplatized.service';
|
||||
|
||||
export class TemplateServiceFactory {
|
||||
static getService(workflowType: string) {
|
||||
switch (workflowType) {
|
||||
case 'CLAIM_MANAGEMENT':
|
||||
return new DealerClaimService();
|
||||
case 'NON_TEMPLATIZED':
|
||||
return new NonTemplatizedService();
|
||||
default:
|
||||
// For future templates, use a generic service or throw error
|
||||
throw new Error(`Unsupported workflow type: ${workflowType}`);
|
||||
}
|
||||
}
|
||||
|
||||
static async getRequestDetails(requestId: string) {
|
||||
const request = await WorkflowRequest.findByPk(requestId);
|
||||
if (!request) return null;
|
||||
|
||||
const service = this.getService(request.workflowType);
|
||||
return service.getRequestDetails(request);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Unified Workflow Service (No Changes Needed)
|
||||
|
||||
The existing `WorkflowService.listMyRequests()` and `listOpenForMe()` methods will **automatically** include all request types because they query `workflow_requests` table without filtering by `workflow_type`.
|
||||
|
||||
```typescript
|
||||
// Existing code works as-is - no changes needed!
|
||||
async listMyRequests(userId: string, page: number, limit: number, filters?: {...}) {
|
||||
// This query automatically includes ALL workflow types
|
||||
const requests = await WorkflowRequest.findAll({
|
||||
where: {
|
||||
initiatorId: userId,
|
||||
isDraft: false,
|
||||
// ... filters
|
||||
// NO workflow_type filter - includes everything!
|
||||
}
|
||||
});
|
||||
return requests;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. Create Claim Management Request
|
||||
|
||||
```typescript
|
||||
// Re_Backend/src/controllers/dealerClaim.controller.ts
|
||||
|
||||
async createClaimRequest(req: AuthenticatedRequest, res: Response) {
|
||||
const userId = req.user?.userId;
|
||||
const {
|
||||
activityName,
|
||||
activityType,
|
||||
dealerCode,
|
||||
// ... claim-specific fields
|
||||
} = req.body;
|
||||
|
||||
// 1. Create workflow request (common)
|
||||
const workflowRequest = await WorkflowRequest.create({
|
||||
initiatorId: userId,
|
||||
templateType: 'CUSTOM',
|
||||
workflowType: 'CLAIM_MANAGEMENT', // Identify as claim
|
||||
title: `${activityName} - Claim Request`,
|
||||
description: req.body.requestDescription,
|
||||
totalLevels: 8, // Fixed 8-step workflow
|
||||
// ... other common fields
|
||||
});
|
||||
|
||||
// 2. Create claim-specific details
|
||||
const claimDetails = await DealerClaimDetails.create({
|
||||
requestId: workflowRequest.requestId,
|
||||
activityName,
|
||||
activityType,
|
||||
dealerCode,
|
||||
// ... claim-specific fields
|
||||
});
|
||||
|
||||
// 3. Create approval levels (8 steps)
|
||||
await this.createClaimApprovalLevels(workflowRequest.requestId);
|
||||
|
||||
return ResponseHandler.success(res, {
|
||||
request: workflowRequest,
|
||||
claimDetails
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Get Request Details (Template-Aware)
|
||||
|
||||
```typescript
|
||||
async getRequestDetails(req: Request, res: Response) {
|
||||
const { requestId } = req.params;
|
||||
|
||||
const request = await WorkflowRequest.findByPk(requestId, {
|
||||
include: [
|
||||
{ model: User, as: 'initiator' },
|
||||
// Conditionally include template-specific data
|
||||
...(request.workflowType === 'CLAIM_MANAGEMENT'
|
||||
? [{ model: DealerClaimDetails, as: 'claimDetails' }]
|
||||
: [])
|
||||
]
|
||||
});
|
||||
|
||||
// Use factory to get template-specific service
|
||||
const templateService = TemplateServiceFactory.getService(request.workflowType);
|
||||
const enrichedDetails = await templateService.enrichRequestDetails(request);
|
||||
|
||||
return ResponseHandler.success(res, enrichedDetails);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
### 1. Request List Views (No Changes Needed)
|
||||
|
||||
The existing "My Requests" and "Open Requests" pages will automatically show all request types because the backend doesn't filter by `workflow_type`.
|
||||
|
||||
```typescript
|
||||
// Frontend: MyRequests.tsx - No changes needed!
|
||||
const fetchMyRequests = async () => {
|
||||
const result = await workflowApi.listMyInitiatedWorkflows({
|
||||
page,
|
||||
limit: itemsPerPage
|
||||
});
|
||||
// Returns ALL request types automatically
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Request Detail Page (Template-Aware Rendering)
|
||||
|
||||
```typescript
|
||||
// Frontend: RequestDetail.tsx
|
||||
|
||||
const RequestDetail = ({ requestId }) => {
|
||||
const request = useRequestDetails(requestId);
|
||||
|
||||
// Render based on workflow type
|
||||
if (request.workflowType === 'CLAIM_MANAGEMENT') {
|
||||
return <ClaimManagementDetail request={request} />;
|
||||
} else if (request.workflowType === 'NON_TEMPLATIZED') {
|
||||
return <NonTemplatizedDetail request={request} />;
|
||||
} else {
|
||||
// Future templates - use generic renderer or template config
|
||||
return <GenericWorkflowDetail request={request} />;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding New Templates (Future)
|
||||
|
||||
### Step 1: Admin Creates Template in UI
|
||||
|
||||
1. Admin goes to "Template Management" page
|
||||
2. Creates new template with:
|
||||
- Template name: "Vendor Payment"
|
||||
- Template code: "VENDOR_PAYMENT"
|
||||
- Approval levels configuration
|
||||
- Form fields configuration
|
||||
|
||||
### Step 2: Database Entry Created
|
||||
|
||||
```sql
|
||||
INSERT INTO workflow_templates (
|
||||
template_name,
|
||||
template_code,
|
||||
workflow_type,
|
||||
approval_levels_config,
|
||||
form_fields_config,
|
||||
is_active,
|
||||
is_system_template
|
||||
) VALUES (
|
||||
'Vendor Payment',
|
||||
'VENDOR_PAYMENT',
|
||||
'VENDOR_PAYMENT',
|
||||
'{"levels": [...], "tat": {...}}'::jsonb,
|
||||
'{"fields": [...]}'::jsonb,
|
||||
true,
|
||||
false -- Admin-created, not system template
|
||||
);
|
||||
```
|
||||
|
||||
### Step 3: Create Extension Table (If Needed)
|
||||
|
||||
```sql
|
||||
CREATE TABLE vendor_payment_details (
|
||||
payment_id UUID PRIMARY KEY,
|
||||
request_id UUID UNIQUE REFERENCES workflow_requests(request_id),
|
||||
vendor_code VARCHAR(50),
|
||||
invoice_number VARCHAR(100),
|
||||
payment_amount DECIMAL(15,2),
|
||||
-- ... vendor-specific fields
|
||||
);
|
||||
```
|
||||
|
||||
### Step 4: Create Service (Optional - Can Use Generic Service)
|
||||
|
||||
```typescript
|
||||
// Re_Backend/src/services/vendorPayment.service.ts
|
||||
|
||||
export class VendorPaymentService {
|
||||
async getRequestDetails(request: WorkflowRequest) {
|
||||
const paymentDetails = await VendorPaymentDetails.findOne({
|
||||
where: { requestId: request.requestId }
|
||||
});
|
||||
|
||||
return {
|
||||
...request.toJSON(),
|
||||
paymentDetails
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Update factory
|
||||
TemplateServiceFactory.getService(workflowType: string) {
|
||||
switch (workflowType) {
|
||||
case 'VENDOR_PAYMENT':
|
||||
return new VendorPaymentService();
|
||||
// ... existing cases
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Frontend Component (Optional)
|
||||
|
||||
```typescript
|
||||
// Frontend: components/VendorPaymentDetail.tsx
|
||||
|
||||
export function VendorPaymentDetail({ request }) {
|
||||
// Render vendor payment specific UI
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits of This Architecture
|
||||
|
||||
1. **Unified Data Model**: All requests in one table, easy to query
|
||||
2. **Automatic Inclusion**: My Requests/Open Requests show all types automatically
|
||||
3. **Extensibility**: Add new templates without modifying existing code
|
||||
4. **Type Safety**: Template-specific data in separate tables
|
||||
5. **Flexibility**: Support both system templates and admin-created templates
|
||||
6. **Backward Compatible**: Existing non-templatized requests continue to work
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
1. **Phase 1**: Add `workflow_type` column, set default to 'NON_TEMPLATIZED' for existing requests
|
||||
2. **Phase 2**: Create `dealer_claim_details` table and models
|
||||
3. **Phase 3**: Update claim management creation flow to use new structure
|
||||
4. **Phase 4**: Update request detail endpoints to be template-aware
|
||||
5. **Phase 5**: Frontend updates (if needed) for template-specific rendering
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
- **All requests** use `workflow_requests` table
|
||||
- **Template identification** via `workflow_type` field
|
||||
- **Template-specific data** in extension tables (e.g., `dealer_claim_details`)
|
||||
- **Unified views** automatically include all types
|
||||
- **Future templates** can be added by admins without code changes
|
||||
- **Existing functionality** remains unchanged
|
||||
|
||||
This architecture ensures that:
|
||||
- ✅ Claim Management requests appear in My Requests/Open Requests
|
||||
- ✅ Non-templatized requests continue to work
|
||||
- ✅ Future templates can be added easily
|
||||
- ✅ No code duplication
|
||||
- ✅ Single source of truth for all requests
|
||||
|
||||
78
docs/IMPLEMENTATION_PROGRESS.md
Normal file
78
docs/IMPLEMENTATION_PROGRESS.md
Normal file
@ -0,0 +1,78 @@
|
||||
# Dealer Claim Management - Implementation Progress
|
||||
|
||||
## ✅ Completed
|
||||
|
||||
### 1. Database Migrations
|
||||
- ✅ `20251210-add-workflow-type-support.ts` - Adds `workflow_type` and `template_id` to `workflow_requests`
|
||||
- ✅ `20251210-enhance-workflow-templates.ts` - Enhances `workflow_templates` with form configuration fields
|
||||
- ✅ `20251210-create-dealer-claim-tables.ts` - Creates dealer claim related tables:
|
||||
- `dealer_claim_details` - Main claim information
|
||||
- `dealer_proposal_details` - Step 1: Dealer proposal submission
|
||||
- `dealer_completion_details` - Step 5: Dealer completion documents
|
||||
|
||||
### 2. Models
|
||||
- ✅ Updated `WorkflowRequest` model with `workflowType` and `templateId` fields
|
||||
- ✅ Created `DealerClaimDetails` model
|
||||
- ✅ Created `DealerProposalDetails` model
|
||||
- ✅ Created `DealerCompletionDetails` model
|
||||
|
||||
### 3. Services
|
||||
- ✅ Created `TemplateFieldResolver` service for dynamic user field references
|
||||
|
||||
## 🚧 In Progress
|
||||
|
||||
### 4. Services (Next Steps)
|
||||
- ⏳ Create `EnhancedTemplateService` - Main service for template operations
|
||||
- ⏳ Create `DealerClaimService` - Claim-specific business logic
|
||||
|
||||
### 5. Controllers & Routes
|
||||
- ⏳ Create `DealerClaimController` - API endpoints for claim management
|
||||
- ⏳ Create routes for dealer claim operations
|
||||
- ⏳ Create template management endpoints
|
||||
|
||||
## 📋 Next Steps
|
||||
|
||||
1. **Create EnhancedTemplateService**
|
||||
- Get form configuration with resolved user references
|
||||
- Save step data
|
||||
- Validate form data
|
||||
|
||||
2. **Create DealerClaimService**
|
||||
- Create claim request
|
||||
- Handle 8-step workflow transitions
|
||||
- Manage proposal and completion submissions
|
||||
|
||||
3. **Create Controllers**
|
||||
- POST `/api/v1/dealer-claims` - Create claim request
|
||||
- GET `/api/v1/dealer-claims/:requestId` - Get claim details
|
||||
- POST `/api/v1/dealer-claims/:requestId/proposal` - Submit proposal (Step 1)
|
||||
- POST `/api/v1/dealer-claims/:requestId/completion` - Submit completion (Step 5)
|
||||
- GET `/api/v1/templates/:templateId/form-config` - Get form configuration
|
||||
|
||||
4. **Integration Services**
|
||||
- SAP integration for IO validation and budget blocking
|
||||
- DMS integration for e-invoice and credit note generation
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- All migrations are ready to run
|
||||
- Models are created with proper associations
|
||||
- Template field resolver supports dynamic user references
|
||||
- System is designed to be extensible for future templates
|
||||
|
||||
## 🔄 Running Migrations
|
||||
|
||||
To apply the migrations:
|
||||
|
||||
```bash
|
||||
cd Re_Backend
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
Or run individually:
|
||||
```bash
|
||||
npx ts-node src/scripts/run-migration.ts 20251210-add-workflow-type-support
|
||||
npx ts-node src/scripts/run-migration.ts 20251210-enhance-workflow-templates
|
||||
npx ts-node src/scripts/run-migration.ts 20251210-create-dealer-claim-tables
|
||||
```
|
||||
|
||||
159
docs/IMPLEMENTATION_SUMMARY.md
Normal file
159
docs/IMPLEMENTATION_SUMMARY.md
Normal file
@ -0,0 +1,159 @@
|
||||
# Dealer Claim Management - Implementation Summary
|
||||
|
||||
## ✅ Completed Implementation
|
||||
|
||||
### 1. Database Migrations (4 files)
|
||||
- ✅ `20251210-add-workflow-type-support.ts` - Adds `workflow_type` and `template_id` to `workflow_requests`
|
||||
- ✅ `20251210-enhance-workflow-templates.ts` - Enhances `workflow_templates` with form configuration
|
||||
- ✅ `20251210-add-template-id-foreign-key.ts` - Adds FK constraint for `template_id`
|
||||
- ✅ `20251210-create-dealer-claim-tables.ts` - Creates dealer claim tables:
|
||||
- `dealer_claim_details` - Main claim information
|
||||
- `dealer_proposal_details` - Step 1: Dealer proposal
|
||||
- `dealer_completion_details` - Step 5: Completion documents
|
||||
|
||||
### 2. Models (5 files)
|
||||
- ✅ Updated `WorkflowRequest` - Added `workflowType` and `templateId` fields
|
||||
- ✅ Created `DealerClaimDetails` - Main claim information model
|
||||
- ✅ Created `DealerProposalDetails` - Proposal submission model
|
||||
- ✅ Created `DealerCompletionDetails` - Completion documents model
|
||||
- ✅ Created `WorkflowTemplate` - Template configuration model
|
||||
|
||||
### 3. Services (3 files)
|
||||
- ✅ Created `TemplateFieldResolver` - Resolves dynamic user field references
|
||||
- ✅ Created `EnhancedTemplateService` - Template form management
|
||||
- ✅ Created `DealerClaimService` - Claim-specific business logic:
|
||||
- `createClaimRequest()` - Create new claim with 8-step workflow
|
||||
- `getClaimDetails()` - Get complete claim information
|
||||
- `submitDealerProposal()` - Step 1: Dealer proposal submission
|
||||
- `submitCompletionDocuments()` - Step 5: Completion submission
|
||||
- `updateIODetails()` - Step 3: IO budget blocking
|
||||
- `updateEInvoiceDetails()` - Step 7: E-Invoice generation
|
||||
- `updateCreditNoteDetails()` - Step 8: Credit note issuance
|
||||
|
||||
### 4. Controllers & Routes (2 files)
|
||||
- ✅ Created `DealerClaimController` - API endpoints for claim operations
|
||||
- ✅ Created `dealerClaim.routes.ts` - Route definitions
|
||||
- ✅ Registered routes in `routes/index.ts`
|
||||
|
||||
### 5. Frontend Utilities (1 file)
|
||||
- ✅ Created `claimRequestUtils.ts` - Utility functions for detecting claim requests
|
||||
|
||||
## 📋 API Endpoints Created
|
||||
|
||||
### Dealer Claim Management
|
||||
- `POST /api/v1/dealer-claims` - Create claim request
|
||||
- `GET /api/v1/dealer-claims/:requestId` - Get claim details
|
||||
- `POST /api/v1/dealer-claims/:requestId/proposal` - Submit dealer proposal (Step 1)
|
||||
- `POST /api/v1/dealer-claims/:requestId/completion` - Submit completion (Step 5)
|
||||
- `PUT /api/v1/dealer-claims/:requestId/io` - Update IO details (Step 3)
|
||||
- `PUT /api/v1/dealer-claims/:requestId/e-invoice` - Update e-invoice (Step 7)
|
||||
- `PUT /api/v1/dealer-claims/:requestId/credit-note` - Update credit note (Step 8)
|
||||
|
||||
## 🔄 8-Step Workflow Implementation
|
||||
|
||||
The system automatically creates 8 approval levels:
|
||||
|
||||
1. **Dealer Proposal Submission** (72h) - Dealer submits proposal
|
||||
2. **Requestor Evaluation** (48h) - Initiator reviews and confirms
|
||||
3. **Department Lead Approval** (72h) - Dept lead approves and blocks IO
|
||||
4. **Activity Creation** (1h, Auto) - System creates activity record
|
||||
5. **Dealer Completion Documents** (120h) - Dealer submits completion docs
|
||||
6. **Requestor Claim Approval** (48h) - Initiator approves claim
|
||||
7. **E-Invoice Generation** (1h, Auto) - System generates e-invoice via DMS
|
||||
8. **Credit Note Confirmation** (48h) - Finance confirms credit note
|
||||
|
||||
## 🎯 Key Features
|
||||
|
||||
1. **Unified Request System**
|
||||
- All requests use same `workflow_requests` table
|
||||
- Identified by `workflowType: 'CLAIM_MANAGEMENT'`
|
||||
- Automatically appears in "My Requests" and "Open Requests"
|
||||
|
||||
2. **Template-Specific Data Storage**
|
||||
- Claim data stored in extension tables
|
||||
- Linked via `request_id` foreign key
|
||||
- Supports future templates with their own tables
|
||||
|
||||
3. **Dynamic User References**
|
||||
- Auto-populate fields from initiator, dealer, approvers
|
||||
- Supports team lead, department lead references
|
||||
- Configurable per template
|
||||
|
||||
4. **File Upload Integration**
|
||||
- Uses GCS with local fallback
|
||||
- Organized by request number and file type
|
||||
- Supports proposal documents and completion files
|
||||
|
||||
## 📝 Next Steps
|
||||
|
||||
### Backend
|
||||
1. ⏳ Add SAP integration for IO validation and budget blocking
|
||||
2. ⏳ Add DMS integration for e-invoice and credit note generation
|
||||
3. ⏳ Create template management API endpoints
|
||||
4. ⏳ Add validation for dealer codes (SAP integration)
|
||||
|
||||
### Frontend
|
||||
1. ⏳ Create `claimDataMapper.ts` utility functions
|
||||
2. ⏳ Update `RequestDetail.tsx` to conditionally render claim components
|
||||
3. ⏳ Update API services to include `workflowType`
|
||||
4. ⏳ Create `dealerClaimApi.ts` service
|
||||
5. ⏳ Update request cards to show workflow type
|
||||
|
||||
## 🚀 Running the Implementation
|
||||
|
||||
### 1. Run Migrations
|
||||
```bash
|
||||
cd Re_Backend
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
### 2. Test API Endpoints
|
||||
```bash
|
||||
# Create claim request
|
||||
POST /api/v1/dealer-claims
|
||||
{
|
||||
"activityName": "Diwali Campaign",
|
||||
"activityType": "Marketing Activity",
|
||||
"dealerCode": "RE-MH-001",
|
||||
"dealerName": "Royal Motors Mumbai",
|
||||
"location": "Mumbai",
|
||||
"requestDescription": "Marketing campaign details..."
|
||||
}
|
||||
|
||||
# Submit proposal
|
||||
POST /api/v1/dealer-claims/:requestId/proposal
|
||||
FormData with proposalDocument file and JSON data
|
||||
```
|
||||
|
||||
## 📊 Database Structure
|
||||
|
||||
```
|
||||
workflow_requests (common)
|
||||
├── workflow_type: 'CLAIM_MANAGEMENT'
|
||||
└── template_id: (nullable)
|
||||
|
||||
dealer_claim_details (claim-specific)
|
||||
└── request_id → workflow_requests
|
||||
|
||||
dealer_proposal_details (Step 1)
|
||||
└── request_id → workflow_requests
|
||||
|
||||
dealer_completion_details (Step 5)
|
||||
└── request_id → workflow_requests
|
||||
|
||||
approval_levels (8 steps)
|
||||
└── request_id → workflow_requests
|
||||
```
|
||||
|
||||
## ✅ Testing Checklist
|
||||
|
||||
- [ ] Run migrations successfully
|
||||
- [ ] Create claim request via API
|
||||
- [ ] Submit dealer proposal
|
||||
- [ ] Update IO details
|
||||
- [ ] Submit completion documents
|
||||
- [ ] Verify request appears in "My Requests"
|
||||
- [ ] Verify request appears in "Open Requests"
|
||||
- [ ] Test file uploads (GCS and local fallback)
|
||||
- [ ] Test workflow progression through 8 steps
|
||||
|
||||
164
docs/MIGRATION_SETUP_SUMMARY.md
Normal file
164
docs/MIGRATION_SETUP_SUMMARY.md
Normal file
@ -0,0 +1,164 @@
|
||||
# Migration and Setup Summary
|
||||
|
||||
## ✅ Current Status
|
||||
|
||||
### Tables Created by Migrations
|
||||
|
||||
All **6 new dealer claim tables** are included in the migration system:
|
||||
|
||||
1. ✅ `dealer_claim_details` - Main claim information
|
||||
2. ✅ `dealer_proposal_details` - Step 1: Dealer proposal
|
||||
3. ✅ `dealer_completion_details` - Step 5: Completion documents
|
||||
4. ✅ `dealer_proposal_cost_items` - Cost breakdown items
|
||||
5. ✅ `internal_orders` ⭐ - IO details with dedicated fields
|
||||
6. ✅ `claim_budget_tracking` ⭐ - Comprehensive budget tracking
|
||||
|
||||
## Migration Commands
|
||||
|
||||
### 1. **`npm run migrate`** ✅
|
||||
**Status:** ✅ **Fully configured**
|
||||
|
||||
This command runs `src/scripts/migrate.ts` which includes **ALL** migrations including:
|
||||
- ✅ All dealer claim tables (m25-m28)
|
||||
- ✅ New tables: `internal_orders` (m27) and `claim_budget_tracking` (m28)
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Checks which migrations have already run (via `migrations` table)
|
||||
- Runs only pending migrations
|
||||
- Marks them as executed
|
||||
- Creates all new tables automatically
|
||||
|
||||
---
|
||||
|
||||
### 2. **`npm run dev`** ✅
|
||||
**Status:** ✅ **Now fixed and configured**
|
||||
|
||||
This command runs:
|
||||
```bash
|
||||
npm run setup && nodemon --exec ts-node ...
|
||||
```
|
||||
|
||||
Which calls `npm run setup` → `src/scripts/auto-setup.ts`
|
||||
|
||||
**What `auto-setup.ts` does:**
|
||||
1. ✅ Checks if database exists, creates if missing
|
||||
2. ✅ Installs PostgreSQL extensions (uuid-ossp)
|
||||
3. ✅ **Runs all pending migrations** (including dealer claim tables)
|
||||
4. ✅ Tests database connection
|
||||
|
||||
**Fixed:** ✅ Now includes all dealer claim migrations (m29-m35)
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This will automatically:
|
||||
- Create database if needed
|
||||
- Run all migrations (including new tables)
|
||||
- Start the development server
|
||||
|
||||
---
|
||||
|
||||
### 3. **`npm run setup`** ✅
|
||||
**Status:** ✅ **Now fixed and configured**
|
||||
|
||||
Same as what `npm run dev` calls - runs `auto-setup.ts`
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
npm run setup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Files Included
|
||||
|
||||
### In `migrate.ts` (for `npm run migrate`):
|
||||
- ✅ `20251210-add-workflow-type-support` (m22)
|
||||
- ✅ `20251210-enhance-workflow-templates` (m23)
|
||||
- ✅ `20251210-add-template-id-foreign-key` (m24)
|
||||
- ✅ `20251210-create-dealer-claim-tables` (m25) - Creates 3 tables
|
||||
- ✅ `20251210-create-proposal-cost-items-table` (m26)
|
||||
- ✅ `20251211-create-internal-orders-table` (m27) ⭐ NEW
|
||||
- ✅ `20251211-create-claim-budget-tracking-table` (m28) ⭐ NEW
|
||||
|
||||
### In `auto-setup.ts` (for `npm run dev` / `npm run setup`):
|
||||
- ✅ All migrations from `migrate.ts` are now included (m29-m35)
|
||||
|
||||
---
|
||||
|
||||
## What Gets Created
|
||||
|
||||
When you run either `npm run migrate` or `npm run dev`, these tables will be created:
|
||||
|
||||
### Dealer Claim Tables (from `20251210-create-dealer-claim-tables.ts`):
|
||||
1. `dealer_claim_details`
|
||||
2. `dealer_proposal_details`
|
||||
3. `dealer_completion_details`
|
||||
|
||||
### Additional Tables:
|
||||
4. `dealer_proposal_cost_items` (from `20251210-create-proposal-cost-items-table.ts`)
|
||||
5. `internal_orders` ⭐ (from `20251211-create-internal-orders-table.ts`)
|
||||
6. `claim_budget_tracking` ⭐ (from `20251211-create-claim-budget-tracking-table.ts`)
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
After running migrations, verify tables exist:
|
||||
|
||||
```sql
|
||||
-- Check if new tables exist
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN (
|
||||
'dealer_claim_details',
|
||||
'dealer_proposal_details',
|
||||
'dealer_completion_details',
|
||||
'dealer_proposal_cost_items',
|
||||
'internal_orders',
|
||||
'claim_budget_tracking'
|
||||
)
|
||||
ORDER BY table_name;
|
||||
```
|
||||
|
||||
Should return 6 rows.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Command | Runs Migrations? | Includes New Tables? | Status |
|
||||
|---------|------------------|---------------------|--------|
|
||||
| `npm run migrate` | ✅ Yes | ✅ Yes | ✅ Working |
|
||||
| `npm run dev` | ✅ Yes | ✅ Yes | ✅ Fixed |
|
||||
| `npm run setup` | ✅ Yes | ✅ Yes | ✅ Fixed |
|
||||
|
||||
**All commands now create the new tables automatically!** 🎉
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Run migrations:**
|
||||
```bash
|
||||
npm run migrate
|
||||
```
|
||||
OR
|
||||
```bash
|
||||
npm run dev # This will also run migrations via setup
|
||||
```
|
||||
|
||||
2. **Verify tables created:**
|
||||
Check the database to confirm all 6 tables exist.
|
||||
|
||||
3. **Start using:**
|
||||
The tables are ready for dealer claim management!
|
||||
|
||||
216
docs/NEW_TABLES_SUMMARY.md
Normal file
216
docs/NEW_TABLES_SUMMARY.md
Normal file
@ -0,0 +1,216 @@
|
||||
# New Tables Created for Dealer Claim Management
|
||||
|
||||
## Overview
|
||||
|
||||
This document lists all the new database tables created specifically for the Dealer Claim Management system.
|
||||
|
||||
## Tables Created
|
||||
|
||||
### 1. **`dealer_claim_details`**
|
||||
**Migration:** `20251210-create-dealer-claim-tables.ts`
|
||||
|
||||
**Purpose:** Main table storing claim-specific information
|
||||
|
||||
**Key Fields:**
|
||||
- `claim_id` (PK)
|
||||
- `request_id` (FK to `workflow_requests`, unique)
|
||||
- `activity_name`, `activity_type`
|
||||
- `dealer_code`, `dealer_name`, `dealer_email`, `dealer_phone`, `dealer_address`
|
||||
- `activity_date`, `location`
|
||||
- `period_start_date`, `period_end_date`
|
||||
- `estimated_budget`, `closed_expenses`
|
||||
- `io_number`, `io_available_balance`, `io_blocked_amount`, `io_remaining_balance` (legacy - now in `internal_orders`)
|
||||
- `sap_document_number`, `dms_number`
|
||||
- `e_invoice_number`, `e_invoice_date`
|
||||
- `credit_note_number`, `credit_note_date`, `credit_note_amount`
|
||||
|
||||
**Created:** December 10, 2025
|
||||
|
||||
---
|
||||
|
||||
### 2. **`dealer_proposal_details`**
|
||||
**Migration:** `20251210-create-dealer-claim-tables.ts`
|
||||
|
||||
**Purpose:** Stores dealer proposal submission data (Step 1 of workflow)
|
||||
|
||||
**Key Fields:**
|
||||
- `proposal_id` (PK)
|
||||
- `request_id` (FK to `workflow_requests`, unique)
|
||||
- `proposal_document_path`, `proposal_document_url`
|
||||
- `cost_breakup` (JSONB - legacy, now use `dealer_proposal_cost_items`)
|
||||
- `total_estimated_budget`
|
||||
- `timeline_mode` ('date' | 'days')
|
||||
- `expected_completion_date`, `expected_completion_days`
|
||||
- `dealer_comments`
|
||||
- `submitted_at`
|
||||
|
||||
**Created:** December 10, 2025
|
||||
|
||||
---
|
||||
|
||||
### 3. **`dealer_completion_details`**
|
||||
**Migration:** `20251210-create-dealer-claim-tables.ts`
|
||||
|
||||
**Purpose:** Stores dealer completion documents and expenses (Step 5 of workflow)
|
||||
|
||||
**Key Fields:**
|
||||
- `completion_id` (PK)
|
||||
- `request_id` (FK to `workflow_requests`, unique)
|
||||
- `activity_completion_date`
|
||||
- `number_of_participants`
|
||||
- `closed_expenses` (JSONB array)
|
||||
- `total_closed_expenses`
|
||||
- `completion_documents` (JSONB array)
|
||||
- `activity_photos` (JSONB array)
|
||||
- `submitted_at`
|
||||
|
||||
**Created:** December 10, 2025
|
||||
|
||||
---
|
||||
|
||||
### 4. **`dealer_proposal_cost_items`**
|
||||
**Migration:** `20251210-create-proposal-cost-items-table.ts`
|
||||
|
||||
**Purpose:** Separate table for cost breakdown items (replaces JSONB in `dealer_proposal_details`)
|
||||
|
||||
**Key Fields:**
|
||||
- `cost_item_id` (PK)
|
||||
- `proposal_id` (FK to `dealer_proposal_details`)
|
||||
- `request_id` (FK to `workflow_requests` - denormalized for easier querying)
|
||||
- `item_description`
|
||||
- `amount` (DECIMAL 15,2)
|
||||
- `item_order` (for maintaining order in cost breakdown)
|
||||
|
||||
**Benefits:**
|
||||
- Better querying and filtering
|
||||
- Easier to update individual cost items
|
||||
- Better for analytics and reporting
|
||||
- Maintains referential integrity
|
||||
|
||||
**Created:** December 10, 2025
|
||||
|
||||
---
|
||||
|
||||
### 5. **`internal_orders`** ⭐ NEW
|
||||
**Migration:** `20251211-create-internal-orders-table.ts`
|
||||
|
||||
**Purpose:** Dedicated table for IO (Internal Order) details with proper structure
|
||||
|
||||
**Key Fields:**
|
||||
- `io_id` (PK)
|
||||
- `request_id` (FK to `workflow_requests`, unique - one IO per request)
|
||||
- `io_number` (STRING 50)
|
||||
- `io_remark` (TEXT) ⭐ - Dedicated field for IO remarks (not in comments)
|
||||
- `io_available_balance` (DECIMAL 15,2)
|
||||
- `io_blocked_amount` (DECIMAL 15,2)
|
||||
- `io_remaining_balance` (DECIMAL 15,2)
|
||||
- `organized_by` (FK to `users`) ⭐ - Tracks who organized the IO
|
||||
- `organized_at` (DATE) ⭐ - When IO was organized
|
||||
- `sap_document_number` (STRING 100)
|
||||
- `status` (ENUM: 'PENDING', 'BLOCKED', 'RELEASED', 'CANCELLED')
|
||||
|
||||
**Why This Table:**
|
||||
- Previously IO details were stored in `dealer_claim_details` table
|
||||
- IO remark was being parsed from comments
|
||||
- Now dedicated table with proper fields and relationships
|
||||
- Better data integrity and querying
|
||||
|
||||
**Created:** December 11, 2025
|
||||
|
||||
---
|
||||
|
||||
### 6. **`claim_budget_tracking`** ⭐ NEW
|
||||
**Migration:** `20251211-create-claim-budget-tracking-table.ts`
|
||||
|
||||
**Purpose:** Comprehensive budget tracking throughout the claim lifecycle
|
||||
|
||||
**Key Fields:**
|
||||
- `budget_id` (PK)
|
||||
- `request_id` (FK to `workflow_requests`, unique - one budget record per request)
|
||||
|
||||
**Budget Values:**
|
||||
- `initial_estimated_budget` - From claim creation
|
||||
- `proposal_estimated_budget` - From Step 1 (Dealer Proposal)
|
||||
- `approved_budget` - From Step 2 (Requestor Evaluation)
|
||||
- `io_blocked_amount` - From Step 3 (Department Lead - IO blocking)
|
||||
- `closed_expenses` - From Step 5 (Dealer Completion)
|
||||
- `final_claim_amount` - From Step 6 (Requestor Claim Approval)
|
||||
- `credit_note_amount` - From Step 8 (Finance)
|
||||
|
||||
**Tracking Fields:**
|
||||
- `proposal_submitted_at`
|
||||
- `approved_at`, `approved_by` (FK to `users`)
|
||||
- `io_blocked_at`
|
||||
- `closed_expenses_submitted_at`
|
||||
- `final_claim_amount_approved_at`, `final_claim_amount_approved_by` (FK to `users`)
|
||||
- `credit_note_issued_at`
|
||||
|
||||
**Status & Analysis:**
|
||||
- `budget_status` (ENUM: 'DRAFT', 'PROPOSED', 'APPROVED', 'BLOCKED', 'CLOSED', 'SETTLED')
|
||||
- `currency` (STRING 3, default: 'INR')
|
||||
- `variance_amount` - Difference between approved and closed expenses
|
||||
- `variance_percentage` - Variance as percentage
|
||||
|
||||
**Audit Fields:**
|
||||
- `last_modified_by` (FK to `users`)
|
||||
- `last_modified_at`
|
||||
- `modification_reason` (TEXT)
|
||||
|
||||
**Why This Table:**
|
||||
- Previously budget data was scattered across multiple tables
|
||||
- No single source of truth for budget lifecycle
|
||||
- No audit trail for budget modifications
|
||||
- Now comprehensive tracking with status and variance calculation
|
||||
|
||||
**Created:** December 11, 2025
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
### Total New Tables: **6**
|
||||
|
||||
1. ✅ `dealer_claim_details` - Main claim information
|
||||
2. ✅ `dealer_proposal_details` - Step 1: Dealer proposal
|
||||
3. ✅ `dealer_completion_details` - Step 5: Completion documents
|
||||
4. ✅ `dealer_proposal_cost_items` - Cost breakdown items
|
||||
5. ✅ `internal_orders` ⭐ - IO details with dedicated fields
|
||||
6. ✅ `claim_budget_tracking` ⭐ - Comprehensive budget tracking
|
||||
|
||||
### Most Recent Additions (December 11, 2025):
|
||||
- **`internal_orders`** - Proper IO data structure with `ioRemark` field
|
||||
- **`claim_budget_tracking`** - Complete budget lifecycle tracking
|
||||
|
||||
## Migration Order
|
||||
|
||||
Run migrations in this order:
|
||||
```bash
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
The migrations will run in chronological order:
|
||||
1. `20251210-create-dealer-claim-tables.ts` (creates tables 1-3)
|
||||
2. `20251210-create-proposal-cost-items-table.ts` (creates table 4)
|
||||
3. `20251211-create-internal-orders-table.ts` (creates table 5)
|
||||
4. `20251211-create-claim-budget-tracking-table.ts` (creates table 6)
|
||||
|
||||
## Relationships
|
||||
|
||||
```
|
||||
workflow_requests (1)
|
||||
├── dealer_claim_details (1:1)
|
||||
├── dealer_proposal_details (1:1)
|
||||
│ └── dealer_proposal_cost_items (1:many)
|
||||
├── dealer_completion_details (1:1)
|
||||
├── internal_orders (1:1) ⭐ NEW
|
||||
└── claim_budget_tracking (1:1) ⭐ NEW
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- All tables have `request_id` foreign key to `workflow_requests`
|
||||
- Most tables have unique constraint on `request_id` (one record per request)
|
||||
- `dealer_proposal_cost_items` can have multiple items per proposal
|
||||
- All tables use UUID primary keys
|
||||
- All tables have `created_at` and `updated_at` timestamps
|
||||
|
||||
167
docs/OKTA_USERS_API_INTEGRATION.md
Normal file
167
docs/OKTA_USERS_API_INTEGRATION.md
Normal file
@ -0,0 +1,167 @@
|
||||
# Okta Users API Integration
|
||||
|
||||
## Overview
|
||||
|
||||
The authentication service now uses the Okta Users API (`/api/v1/users/{userId}`) to fetch complete user profile information including manager, employeeID, designation, and other fields that may not be available in the standard OAuth2 userinfo endpoint.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add the following environment variable to your `.env` file:
|
||||
|
||||
```env
|
||||
OKTA_API_TOKEN=your_okta_api_token_here
|
||||
```
|
||||
|
||||
This is the SSWS (Server-Side Web Service) token for Okta API access. You can generate this token from your Okta Admin Console under **Security > API > Tokens**.
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. Primary Method: Okta Users API
|
||||
|
||||
When a user logs in for the first time:
|
||||
|
||||
1. The system exchanges the authorization code for tokens (OAuth2 flow)
|
||||
2. Gets the `oktaSub` (subject identifier) from the userinfo endpoint
|
||||
3. **Attempts to fetch full user profile from Users API** using:
|
||||
- First: Email address (as shown in curl example)
|
||||
- Fallback: oktaSub (user ID) if email lookup fails
|
||||
4. Extracts complete user information including:
|
||||
- `profile.employeeID` - Employee ID
|
||||
- `profile.manager` - Manager name
|
||||
- `profile.title` - Job title/designation
|
||||
- `profile.department` - Department
|
||||
- `profile.mobilePhone` - Phone number
|
||||
- `profile.firstName`, `profile.lastName`, `profile.displayName`
|
||||
- And other profile fields
|
||||
|
||||
### 2. Fallback Method: OAuth2 Userinfo Endpoint
|
||||
|
||||
If the Users API:
|
||||
- Is not configured (missing `OKTA_API_TOKEN`)
|
||||
- Returns an error (4xx/5xx)
|
||||
- Fails for any reason
|
||||
|
||||
The system automatically falls back to the standard OAuth2 userinfo endpoint (`/oauth2/default/v1/userinfo`) which provides basic user information.
|
||||
|
||||
## API Endpoint
|
||||
|
||||
```
|
||||
GET https://{oktaDomain}/api/v1/users/{userId}
|
||||
Authorization: SSWS {OKTA_API_TOKEN}
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Where `{userId}` can be:
|
||||
- Email address (e.g., `testuser10@eichergroup.com`)
|
||||
- Okta user ID (e.g., `00u1e1japegDV2DkP0h8`)
|
||||
|
||||
## Response Structure
|
||||
|
||||
The Users API returns a complete user object:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "00u1e1japegDV2DkP0h8",
|
||||
"status": "ACTIVE",
|
||||
"profile": {
|
||||
"firstName": "Sanjay",
|
||||
"lastName": "Sahu",
|
||||
"manager": "Ezhilan subramanian",
|
||||
"mobilePhone": "8826740087",
|
||||
"displayName": "Sanjay Sahu",
|
||||
"employeeID": "E09994",
|
||||
"title": "Supports Business Applications (SAP) portfolio",
|
||||
"department": "Deputy Manager - Digital & IT",
|
||||
"login": "sanjaysahu@Royalenfield.com",
|
||||
"email": "sanjaysahu@royalenfield.com"
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Field Mapping
|
||||
|
||||
| Users API Field | Database Field | Notes |
|
||||
|----------------|----------------|-------|
|
||||
| `profile.employeeID` | `employeeId` | Employee ID from HR system |
|
||||
| `profile.manager` | `manager` | Manager name |
|
||||
| `profile.title` | `designation` | Job title/designation |
|
||||
| `profile.department` | `department` | Department name |
|
||||
| `profile.mobilePhone` | `phone` | Phone number |
|
||||
| `profile.firstName` | `firstName` | First name |
|
||||
| `profile.lastName` | `lastName` | Last name |
|
||||
| `profile.displayName` | `displayName` | Display name |
|
||||
| `profile.email` | `email` | Email address |
|
||||
| `id` | `oktaSub` | Okta subject identifier |
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Complete User Profile**: Gets all available user information including manager, employeeID, and other custom attributes
|
||||
2. **Automatic Fallback**: If Users API is unavailable, gracefully falls back to userinfo endpoint
|
||||
3. **No Breaking Changes**: Existing functionality continues to work even without API token
|
||||
4. **Better Data Quality**: Reduces missing user information (manager, employeeID, etc.)
|
||||
|
||||
## Logging
|
||||
|
||||
The service logs:
|
||||
- When Users API is used vs. userinfo fallback
|
||||
- Which lookup method succeeded (email or oktaSub)
|
||||
- Extracted fields (employeeId, manager, department, etc.)
|
||||
- Any errors or warnings
|
||||
|
||||
Example log:
|
||||
```
|
||||
[AuthService] Fetching user from Okta Users API (using email)
|
||||
[AuthService] Successfully fetched user from Okta Users API (using email)
|
||||
[AuthService] Extracted user data from Okta Users API
|
||||
- oktaSub: 00u1e1japegDV2DkP0h8
|
||||
- email: testuser10@eichergroup.com
|
||||
- employeeId: E09994
|
||||
- hasManager: true
|
||||
- hasDepartment: true
|
||||
- hasDesignation: true
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Test with curl
|
||||
|
||||
```bash
|
||||
curl --location 'https://dev-830839.oktapreview.com/api/v1/users/testuser10@eichergroup.com' \
|
||||
--header 'Authorization: SSWS YOUR_OKTA_API_TOKEN' \
|
||||
--header 'Accept: application/json'
|
||||
```
|
||||
|
||||
### Test in Application
|
||||
|
||||
1. Set `OKTA_API_TOKEN` in `.env`
|
||||
2. Log in with a user
|
||||
3. Check logs to see if Users API was used
|
||||
4. Verify user record in database has complete information (manager, employeeID, etc.)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Users API Not Being Used
|
||||
|
||||
- Check if `OKTA_API_TOKEN` is set in `.env`
|
||||
- Check logs for warnings about missing API token
|
||||
- Verify API token has correct permissions in Okta
|
||||
|
||||
### Users API Returns 404
|
||||
|
||||
- User may not exist in Okta
|
||||
- Email format may be incorrect
|
||||
- Try using oktaSub (user ID) instead
|
||||
|
||||
### Missing Fields in Database
|
||||
|
||||
- Check if fields exist in Okta user profile
|
||||
- Verify field mapping in `extractUserDataFromUsersAPI` method
|
||||
- Check logs to see which fields were extracted
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **API Token Security**: Store `OKTA_API_TOKEN` securely, never commit to version control
|
||||
- **Token Permissions**: Ensure API token has read access to user profiles
|
||||
- **Rate Limiting**: Be aware of Okta API rate limits when fetching user data
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"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",
|
||||
"build": "tsc && tsc-alias",
|
||||
"build:watch": "tsc --watch",
|
||||
@ -17,7 +17,9 @@
|
||||
"clean": "rm -rf dist",
|
||||
"setup": "ts-node -r tsconfig-paths/register src/scripts/auto-setup.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": {
|
||||
"@google-cloud/storage": "^7.18.0",
|
||||
|
||||
@ -66,6 +66,8 @@ export const constants = {
|
||||
REFERENCE: 'REFERENCE',
|
||||
FINAL: 'FINAL',
|
||||
OTHER: 'OTHER',
|
||||
COMPLETION_DOC: 'COMPLETION_DOC',
|
||||
ACTIVITY_PHOTO: 'ACTIVITY_PHOTO',
|
||||
},
|
||||
|
||||
// Work Note Types
|
||||
|
||||
@ -11,6 +11,7 @@ const ssoConfig: SSOConfig = {
|
||||
oktaDomain: process.env.OKTA_DOMAIN || 'https://dev-830839.oktapreview.com',
|
||||
oktaClientId: process.env.OKTA_CLIENT_ID || '',
|
||||
oktaClientSecret: process.env.OKTA_CLIENT_SECRET || '',
|
||||
oktaApiToken: process.env.OKTA_API_TOKEN || '', // SSWS token for Users API
|
||||
};
|
||||
|
||||
export { ssoConfig };
|
||||
|
||||
@ -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
|
||||
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 = new UserService();
|
||||
|
||||
try {
|
||||
// Search Okta for this user
|
||||
const oktaUsers = await userService.searchUsers(email, 1);
|
||||
// Fetch full user profile from Okta Users API (includes manager, jobTitle, etc.)
|
||||
const oktaUserData = await userService.fetchAndExtractOktaUserByEmail(email);
|
||||
|
||||
if (!oktaUsers || oktaUsers.length === 0) {
|
||||
if (!oktaUserData) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
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;
|
||||
}
|
||||
|
||||
const oktaUser = oktaUsers[0];
|
||||
|
||||
// Create user in our database
|
||||
user = await User.create({
|
||||
email: oktaUser.email,
|
||||
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
|
||||
// Create user in our database via centralized userService with all fields including manager
|
||||
const ensured = await userService.createOrUpdateUser({
|
||||
...oktaUserData,
|
||||
role, // Set the assigned role
|
||||
isActive: true, // Ensure user is active
|
||||
});
|
||||
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) {
|
||||
logger.error('[Admin] Error fetching from Okta:', oktaError);
|
||||
res.status(500).json({
|
||||
@ -826,7 +816,7 @@ export const assignRoleByEmail = async (req: Request, res: Response): Promise<vo
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// User exists, update their role
|
||||
// User exists - fetch latest data from Okta and sync all fields including role
|
||||
const previousRole = user.role;
|
||||
|
||||
// Prevent self-demotion
|
||||
@ -838,9 +828,35 @@ export const assignRoleByEmail = async (req: Request, res: Response): Promise<vo
|
||||
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({
|
||||
|
||||
86
src/controllers/dealer.controller.ts
Normal file
86
src/controllers/dealer.controller.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { Request, Response } from 'express';
|
||||
import type { AuthenticatedRequest } from '../types/express';
|
||||
import * as dealerService from '../services/dealer.service';
|
||||
import { ResponseHandler } from '../utils/responseHandler';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
export class DealerController {
|
||||
/**
|
||||
* Get all dealers
|
||||
* GET /api/v1/dealers
|
||||
*/
|
||||
async getAllDealers(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const dealers = await dealerService.getAllDealers();
|
||||
return ResponseHandler.success(res, dealers, 'Dealers fetched successfully');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[DealerController] Error fetching dealers:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch dealers', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dealer by code
|
||||
* GET /api/v1/dealers/code/:dealerCode
|
||||
*/
|
||||
async getDealerByCode(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { dealerCode } = req.params;
|
||||
const dealer = await dealerService.getDealerByCode(dealerCode);
|
||||
|
||||
if (!dealer) {
|
||||
return ResponseHandler.error(res, 'Dealer not found', 404);
|
||||
}
|
||||
|
||||
return ResponseHandler.success(res, dealer, 'Dealer fetched successfully');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[DealerController] Error fetching dealer by code:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch dealer', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dealer by email
|
||||
* GET /api/v1/dealers/email/:email
|
||||
*/
|
||||
async getDealerByEmail(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { email } = req.params;
|
||||
const dealer = await dealerService.getDealerByEmail(email);
|
||||
|
||||
if (!dealer) {
|
||||
return ResponseHandler.error(res, 'Dealer not found', 404);
|
||||
}
|
||||
|
||||
return ResponseHandler.success(res, dealer, 'Dealer fetched successfully');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[DealerController] Error fetching dealer by email:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch dealer', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search dealers
|
||||
* GET /api/v1/dealers/search?q=searchTerm
|
||||
*/
|
||||
async searchDealers(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { q } = req.query;
|
||||
|
||||
if (!q || typeof q !== 'string') {
|
||||
return ResponseHandler.error(res, 'Search term is required', 400);
|
||||
}
|
||||
|
||||
const dealers = await dealerService.searchDealers(q);
|
||||
return ResponseHandler.success(res, dealers, 'Dealers searched successfully');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[DealerController] Error searching dealers:', error);
|
||||
return ResponseHandler.error(res, 'Failed to search dealers', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
778
src/controllers/dealerClaim.controller.ts
Normal file
778
src/controllers/dealerClaim.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,20 +22,57 @@ export class DocumentController {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = String((req.body?.requestId || '').trim());
|
||||
if (!requestId) {
|
||||
// Extract requestId from body (multer should parse form fields)
|
||||
// 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);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get workflow request to retrieve requestNumber
|
||||
const workflowRequest = await WorkflowRequest.findOne({ where: { requestId } });
|
||||
// Helper to check if identifier is UUID
|
||||
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) {
|
||||
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);
|
||||
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;
|
||||
|
||||
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;
|
||||
if (!file) {
|
||||
ResponseHandler.error(res, 'No file uploaded', 400);
|
||||
@ -153,10 +190,21 @@ export class DocumentController {
|
||||
ResponseHandler.success(res, doc, 'File uploaded', 201);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
logWithContext('error', 'Document upload failed', {
|
||||
userId: req.user?.userId,
|
||||
requestId: req.body?.requestId,
|
||||
error,
|
||||
requestId: req.body?.requestId || req.body?.request_id,
|
||||
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);
|
||||
}
|
||||
|
||||
192
src/controllers/template.controller.ts
Normal file
192
src/controllers/template.controller.ts
Normal file
@ -0,0 +1,192 @@
|
||||
import { Request, Response } from 'express';
|
||||
import type { AuthenticatedRequest } from '../types/express';
|
||||
import { TemplateService } from '../services/template.service';
|
||||
import { ResponseHandler } from '../utils/responseHandler';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
export class TemplateController {
|
||||
private templateService = new TemplateService();
|
||||
|
||||
/**
|
||||
* Create a new template
|
||||
* POST /api/v1/templates
|
||||
*/
|
||||
async createTemplate(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
if (!userId) {
|
||||
return ResponseHandler.error(res, 'Unauthorized', 401);
|
||||
}
|
||||
|
||||
const {
|
||||
templateName,
|
||||
templateCode,
|
||||
templateDescription,
|
||||
templateCategory,
|
||||
workflowType,
|
||||
approvalLevelsConfig,
|
||||
defaultTatHours,
|
||||
formStepsConfig,
|
||||
userFieldMappings,
|
||||
dynamicApproverConfig,
|
||||
isActive,
|
||||
} = req.body;
|
||||
|
||||
if (!templateName) {
|
||||
return ResponseHandler.error(res, 'Template name is required', 400);
|
||||
}
|
||||
|
||||
const template = await this.templateService.createTemplate(userId, {
|
||||
templateName,
|
||||
templateCode,
|
||||
templateDescription,
|
||||
templateCategory,
|
||||
workflowType,
|
||||
approvalLevelsConfig,
|
||||
defaultTatHours: defaultTatHours ? parseFloat(defaultTatHours) : undefined,
|
||||
formStepsConfig,
|
||||
userFieldMappings,
|
||||
dynamicApproverConfig,
|
||||
isActive,
|
||||
});
|
||||
|
||||
return ResponseHandler.success(res, template, 'Template created successfully', 201);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[TemplateController] Error creating template:', error);
|
||||
return ResponseHandler.error(res, 'Failed to create template', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get template by ID
|
||||
* GET /api/v1/templates/:templateId
|
||||
*/
|
||||
async getTemplate(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { templateId } = req.params;
|
||||
|
||||
const template = await this.templateService.getTemplate(templateId);
|
||||
|
||||
if (!template) {
|
||||
return ResponseHandler.error(res, 'Template not found', 404);
|
||||
}
|
||||
|
||||
return ResponseHandler.success(res, template, 'Template fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[TemplateController] Error getting template:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch template', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List templates
|
||||
* GET /api/v1/templates
|
||||
*/
|
||||
async listTemplates(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const {
|
||||
category,
|
||||
workflowType,
|
||||
isActive,
|
||||
isSystemTemplate,
|
||||
search,
|
||||
} = req.query;
|
||||
|
||||
const filters: any = {};
|
||||
if (category) filters.category = category as string;
|
||||
if (workflowType) filters.workflowType = workflowType as string;
|
||||
if (isActive !== undefined) filters.isActive = isActive === 'true';
|
||||
if (isSystemTemplate !== undefined) filters.isSystemTemplate = isSystemTemplate === 'true';
|
||||
if (search) filters.search = search as string;
|
||||
|
||||
const templates = await this.templateService.listTemplates(filters);
|
||||
|
||||
return ResponseHandler.success(res, templates, 'Templates fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[TemplateController] Error listing templates:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch templates', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active templates (for workflow creation)
|
||||
* GET /api/v1/templates/active
|
||||
*/
|
||||
async getActiveTemplates(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const templates = await this.templateService.getActiveTemplates();
|
||||
|
||||
return ResponseHandler.success(res, templates, 'Active templates fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[TemplateController] Error getting active templates:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch active templates', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update template
|
||||
* PUT /api/v1/templates/:templateId
|
||||
*/
|
||||
async updateTemplate(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { templateId } = req.params;
|
||||
const userId = req.user?.userId;
|
||||
if (!userId) {
|
||||
return ResponseHandler.error(res, 'Unauthorized', 401);
|
||||
}
|
||||
|
||||
const {
|
||||
templateName,
|
||||
templateDescription,
|
||||
templateCategory,
|
||||
approvalLevelsConfig,
|
||||
defaultTatHours,
|
||||
formStepsConfig,
|
||||
userFieldMappings,
|
||||
dynamicApproverConfig,
|
||||
isActive,
|
||||
} = req.body;
|
||||
|
||||
const template = await this.templateService.updateTemplate(templateId, userId, {
|
||||
templateName,
|
||||
templateDescription,
|
||||
templateCategory,
|
||||
approvalLevelsConfig,
|
||||
defaultTatHours: defaultTatHours ? parseFloat(defaultTatHours) : undefined,
|
||||
formStepsConfig,
|
||||
userFieldMappings,
|
||||
dynamicApproverConfig,
|
||||
isActive,
|
||||
});
|
||||
|
||||
return ResponseHandler.success(res, template, 'Template updated successfully');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[TemplateController] Error updating template:', error);
|
||||
return ResponseHandler.error(res, 'Failed to update template', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete template
|
||||
* DELETE /api/v1/templates/:templateId
|
||||
*/
|
||||
async deleteTemplate(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { templateId } = req.params;
|
||||
|
||||
await this.templateService.deleteTemplate(templateId);
|
||||
|
||||
return ResponseHandler.success(res, { message: 'Template deleted successfully' }, 'Template deleted');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[TemplateController] Error deleting template:', error);
|
||||
return ResponseHandler.error(res, 'Failed to delete template', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
* Called when user is selected/tagged in the frontend
|
||||
|
||||
@ -4,7 +4,7 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.sequelize.query(`DO $$
|
||||
BEGIN
|
||||
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$$;`);
|
||||
|
||||
|
||||
54
src/migrations/20251210-add-template-id-foreign-key.ts
Normal file
54
src/migrations/20251210-add-template-id-foreign-key.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { QueryInterface } from 'sequelize';
|
||||
|
||||
/**
|
||||
* Add foreign key constraint for template_id after workflow_templates table exists
|
||||
* This should run after both:
|
||||
* - 20251210-enhance-workflow-templates (creates workflow_templates table)
|
||||
* - 20251210-add-workflow-type-support (adds template_id column)
|
||||
*/
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
// Check if workflow_templates table exists
|
||||
const [tables] = await queryInterface.sequelize.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'workflow_templates';
|
||||
`);
|
||||
|
||||
if (tables.length > 0) {
|
||||
// Check if foreign key already exists
|
||||
const [constraints] = await queryInterface.sequelize.query(`
|
||||
SELECT constraint_name
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'workflow_requests'
|
||||
AND constraint_name = 'workflow_requests_template_id_fkey';
|
||||
`);
|
||||
|
||||
if (constraints.length === 0) {
|
||||
// Add foreign key constraint
|
||||
await queryInterface.sequelize.query(`
|
||||
ALTER TABLE workflow_requests
|
||||
ADD CONSTRAINT workflow_requests_template_id_fkey
|
||||
FOREIGN KEY (template_id)
|
||||
REFERENCES workflow_templates(template_id)
|
||||
ON UPDATE CASCADE
|
||||
ON DELETE SET NULL;
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
// Remove foreign key constraint if it exists
|
||||
try {
|
||||
await queryInterface.sequelize.query(`
|
||||
ALTER TABLE workflow_requests
|
||||
DROP CONSTRAINT IF EXISTS workflow_requests_template_id_fkey;
|
||||
`);
|
||||
} catch (error) {
|
||||
// Ignore if constraint doesn't exist
|
||||
console.log('Note: Foreign key constraint may not exist');
|
||||
}
|
||||
}
|
||||
|
||||
116
src/migrations/20251210-add-workflow-type-support.ts
Normal file
116
src/migrations/20251210-add-workflow-type-support.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
try {
|
||||
// Check if columns already exist (for idempotency and backward compatibility)
|
||||
const tableDescription = await queryInterface.describeTable('workflow_requests');
|
||||
|
||||
// 1. Add workflow_type column to workflow_requests (only if it doesn't exist)
|
||||
if (!tableDescription.workflow_type) {
|
||||
try {
|
||||
await queryInterface.addColumn('workflow_requests', 'workflow_type', {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
defaultValue: 'NON_TEMPLATIZED'
|
||||
});
|
||||
console.log('✅ Added workflow_type column');
|
||||
} catch (error: any) {
|
||||
// Column might have been added manually, check if it exists now
|
||||
const updatedDescription = await queryInterface.describeTable('workflow_requests');
|
||||
if (!updatedDescription.workflow_type) {
|
||||
throw error; // Re-throw if column still doesn't exist
|
||||
}
|
||||
console.log('Note: workflow_type column already exists (may have been added manually)');
|
||||
}
|
||||
} else {
|
||||
console.log('Note: workflow_type column already exists, skipping');
|
||||
}
|
||||
|
||||
// 2. Add template_id column (nullable, for admin templates)
|
||||
// Note: Foreign key constraint will be added later if workflow_templates table exists
|
||||
if (!tableDescription.template_id) {
|
||||
try {
|
||||
await queryInterface.addColumn('workflow_requests', 'template_id', {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
});
|
||||
console.log('✅ Added template_id column');
|
||||
} catch (error: any) {
|
||||
// Column might have been added manually, check if it exists now
|
||||
const updatedDescription = await queryInterface.describeTable('workflow_requests');
|
||||
if (!updatedDescription.template_id) {
|
||||
throw error; // Re-throw if column still doesn't exist
|
||||
}
|
||||
console.log('Note: template_id column already exists (may have been added manually)');
|
||||
}
|
||||
} else {
|
||||
console.log('Note: template_id column already exists, skipping');
|
||||
}
|
||||
|
||||
// Get updated table description for index creation
|
||||
const finalTableDescription = await queryInterface.describeTable('workflow_requests');
|
||||
|
||||
// 3. Create index for workflow_type (only if column exists)
|
||||
if (finalTableDescription.workflow_type) {
|
||||
try {
|
||||
await queryInterface.addIndex('workflow_requests', ['workflow_type'], {
|
||||
name: 'idx_workflow_requests_workflow_type'
|
||||
});
|
||||
console.log('✅ Created workflow_type index');
|
||||
} catch (error: any) {
|
||||
// Index might already exist, ignore error
|
||||
if (error.message?.includes('already exists') || error.message?.includes('duplicate')) {
|
||||
console.log('Note: workflow_type index already exists');
|
||||
} else {
|
||||
console.log('Note: Could not create workflow_type index:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Create index for template_id (only if column exists)
|
||||
if (finalTableDescription.template_id) {
|
||||
try {
|
||||
await queryInterface.addIndex('workflow_requests', ['template_id'], {
|
||||
name: 'idx_workflow_requests_template_id'
|
||||
});
|
||||
console.log('✅ Created template_id index');
|
||||
} catch (error: any) {
|
||||
// Index might already exist, ignore error
|
||||
if (error.message?.includes('already exists') || error.message?.includes('duplicate')) {
|
||||
console.log('Note: template_id index already exists');
|
||||
} else {
|
||||
console.log('Note: Could not create template_id index:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Update existing records to have workflow_type (if any exist and column exists)
|
||||
if (finalTableDescription.workflow_type) {
|
||||
try {
|
||||
const [result] = await queryInterface.sequelize.query(`
|
||||
UPDATE workflow_requests
|
||||
SET workflow_type = 'NON_TEMPLATIZED'
|
||||
WHERE workflow_type IS NULL;
|
||||
`);
|
||||
console.log('✅ Updated existing records with workflow_type');
|
||||
} catch (error: any) {
|
||||
// Ignore if table is empty or other error
|
||||
console.log('Note: Could not update existing records:', error.message);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Migration error:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
// Remove indexes
|
||||
await queryInterface.removeIndex('workflow_requests', 'idx_workflow_requests_template_id');
|
||||
await queryInterface.removeIndex('workflow_requests', 'idx_workflow_requests_workflow_type');
|
||||
|
||||
// Remove columns
|
||||
await queryInterface.removeColumn('workflow_requests', 'template_id');
|
||||
await queryInterface.removeColumn('workflow_requests', 'workflow_type');
|
||||
}
|
||||
|
||||
214
src/migrations/20251210-create-dealer-claim-tables.ts
Normal file
214
src/migrations/20251210-create-dealer-claim-tables.ts
Normal 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');
|
||||
}
|
||||
|
||||
194
src/migrations/20251210-create-proposal-cost-items-table.ts
Normal file
194
src/migrations/20251210-create-proposal-cost-items-table.ts
Normal file
@ -0,0 +1,194 @@
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
/**
|
||||
* Migration: Create dealer_proposal_cost_items table
|
||||
*
|
||||
* Purpose: Separate table for cost breakups to enable better querying, reporting, and data integrity
|
||||
* This replaces the JSONB costBreakup field in dealer_proposal_details
|
||||
*
|
||||
* Benefits:
|
||||
* - Better querying and filtering
|
||||
* - Easier to update individual cost items
|
||||
* - Better for analytics and reporting
|
||||
* - Maintains referential integrity
|
||||
*/
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
// Check if table already exists
|
||||
const [tables] = await queryInterface.sequelize.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'dealer_proposal_cost_items';
|
||||
`);
|
||||
|
||||
if (tables.length === 0) {
|
||||
// Create dealer_proposal_cost_items table
|
||||
await queryInterface.createTable('dealer_proposal_cost_items', {
|
||||
cost_item_id: {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
field: 'cost_item_id'
|
||||
},
|
||||
proposal_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
field: 'proposal_id',
|
||||
references: {
|
||||
model: 'dealer_proposal_details',
|
||||
key: 'proposal_id'
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE'
|
||||
},
|
||||
request_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
field: 'request_id',
|
||||
references: {
|
||||
model: 'workflow_requests',
|
||||
key: 'request_id'
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
comment: 'Denormalized for easier querying without joins'
|
||||
},
|
||||
item_description: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: false,
|
||||
field: 'item_description'
|
||||
},
|
||||
amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
field: 'amount',
|
||||
comment: 'Cost amount in INR'
|
||||
},
|
||||
item_order: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'item_order',
|
||||
comment: 'Order of item in the cost breakdown list'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'created_at'
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'updated_at'
|
||||
}
|
||||
});
|
||||
|
||||
// Create indexes for better query performance
|
||||
await queryInterface.addIndex('dealer_proposal_cost_items', ['proposal_id'], {
|
||||
name: 'idx_proposal_cost_items_proposal_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('dealer_proposal_cost_items', ['request_id'], {
|
||||
name: 'idx_proposal_cost_items_request_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('dealer_proposal_cost_items', ['proposal_id', 'item_order'], {
|
||||
name: 'idx_proposal_cost_items_proposal_order'
|
||||
});
|
||||
|
||||
console.log('✅ Created dealer_proposal_cost_items table');
|
||||
} else {
|
||||
console.log('Note: dealer_proposal_cost_items table already exists');
|
||||
}
|
||||
|
||||
// Migrate existing JSONB costBreakup data to the new table
|
||||
try {
|
||||
const [existingProposals] = await queryInterface.sequelize.query(`
|
||||
SELECT proposal_id, request_id, cost_breakup
|
||||
FROM dealer_proposal_details
|
||||
WHERE cost_breakup IS NOT NULL
|
||||
AND cost_breakup::text != 'null'
|
||||
AND cost_breakup::text != '[]';
|
||||
`);
|
||||
|
||||
if (Array.isArray(existingProposals) && existingProposals.length > 0) {
|
||||
console.log(`📦 Migrating ${existingProposals.length} existing proposal(s) with cost breakups...`);
|
||||
|
||||
for (const proposal of existingProposals as any[]) {
|
||||
const proposalId = proposal.proposal_id;
|
||||
const requestId = proposal.request_id;
|
||||
let costBreakup = proposal.cost_breakup;
|
||||
|
||||
// Parse JSONB if it's a string
|
||||
if (typeof costBreakup === 'string') {
|
||||
try {
|
||||
costBreakup = JSON.parse(costBreakup);
|
||||
} catch (e) {
|
||||
console.warn(`⚠️ Failed to parse costBreakup for proposal ${proposalId}:`, e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure it's an array
|
||||
if (!Array.isArray(costBreakup)) {
|
||||
console.warn(`⚠️ costBreakup is not an array for proposal ${proposalId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Insert cost items
|
||||
for (let i = 0; i < costBreakup.length; i++) {
|
||||
const item = costBreakup[i];
|
||||
if (item && item.description && item.amount !== undefined) {
|
||||
await queryInterface.sequelize.query(`
|
||||
INSERT INTO dealer_proposal_cost_items
|
||||
(proposal_id, request_id, item_description, amount, item_order, created_at, updated_at)
|
||||
VALUES (:proposalId, :requestId, :description, :amount, :order, NOW(), NOW())
|
||||
ON CONFLICT DO NOTHING;
|
||||
`, {
|
||||
replacements: {
|
||||
proposalId,
|
||||
requestId,
|
||||
description: item.description,
|
||||
amount: item.amount,
|
||||
order: i
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Migrated existing cost breakups to new table');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.warn('⚠️ Could not migrate existing cost breakups:', error.message);
|
||||
// Don't fail the migration if migration of existing data fails
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
// Drop indexes first
|
||||
try {
|
||||
await queryInterface.removeIndex('dealer_proposal_cost_items', 'idx_proposal_cost_items_proposal_order');
|
||||
} catch (e) {
|
||||
// Index might not exist
|
||||
}
|
||||
|
||||
try {
|
||||
await queryInterface.removeIndex('dealer_proposal_cost_items', 'idx_proposal_cost_items_request_id');
|
||||
} catch (e) {
|
||||
// Index might not exist
|
||||
}
|
||||
|
||||
try {
|
||||
await queryInterface.removeIndex('dealer_proposal_cost_items', 'idx_proposal_cost_items_proposal_id');
|
||||
} catch (e) {
|
||||
// Index might not exist
|
||||
}
|
||||
|
||||
// Drop table
|
||||
await queryInterface.dropTable('dealer_proposal_cost_items');
|
||||
console.log('✅ Dropped dealer_proposal_cost_items table');
|
||||
}
|
||||
|
||||
174
src/migrations/20251210-enhance-workflow-templates.ts
Normal file
174
src/migrations/20251210-enhance-workflow-templates.ts
Normal file
@ -0,0 +1,174 @@
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
// Check if workflow_templates table exists, if not create it
|
||||
const [tables] = await queryInterface.sequelize.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'workflow_templates';
|
||||
`);
|
||||
|
||||
if (tables.length === 0) {
|
||||
// Create workflow_templates table if it doesn't exist
|
||||
await queryInterface.createTable('workflow_templates', {
|
||||
template_id: {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
defaultValue: DataTypes.UUIDV4
|
||||
},
|
||||
template_name: {
|
||||
type: DataTypes.STRING(200),
|
||||
allowNull: false
|
||||
},
|
||||
template_code: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
unique: true
|
||||
},
|
||||
template_description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
template_category: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true
|
||||
},
|
||||
workflow_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true
|
||||
},
|
||||
approval_levels_config: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true
|
||||
},
|
||||
default_tat_hours: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: true,
|
||||
defaultValue: 24
|
||||
},
|
||||
form_steps_config: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true
|
||||
},
|
||||
user_field_mappings: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true
|
||||
},
|
||||
dynamic_approver_config: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true
|
||||
},
|
||||
is_active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
},
|
||||
is_system_template: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
usage_count: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
created_by: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id'
|
||||
}
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
});
|
||||
|
||||
// Create indexes
|
||||
await queryInterface.addIndex('workflow_templates', ['template_code'], {
|
||||
name: 'idx_workflow_templates_template_code',
|
||||
unique: true
|
||||
});
|
||||
await queryInterface.addIndex('workflow_templates', ['workflow_type'], {
|
||||
name: 'idx_workflow_templates_workflow_type'
|
||||
});
|
||||
await queryInterface.addIndex('workflow_templates', ['is_active'], {
|
||||
name: 'idx_workflow_templates_is_active'
|
||||
});
|
||||
} else {
|
||||
// Table exists, add new columns if they don't exist
|
||||
const tableDescription = await queryInterface.describeTable('workflow_templates');
|
||||
|
||||
if (!tableDescription.form_steps_config) {
|
||||
await queryInterface.addColumn('workflow_templates', 'form_steps_config', {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true
|
||||
});
|
||||
}
|
||||
|
||||
if (!tableDescription.user_field_mappings) {
|
||||
await queryInterface.addColumn('workflow_templates', 'user_field_mappings', {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true
|
||||
});
|
||||
}
|
||||
|
||||
if (!tableDescription.dynamic_approver_config) {
|
||||
await queryInterface.addColumn('workflow_templates', 'dynamic_approver_config', {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true
|
||||
});
|
||||
}
|
||||
|
||||
if (!tableDescription.workflow_type) {
|
||||
await queryInterface.addColumn('workflow_templates', 'workflow_type', {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true
|
||||
});
|
||||
}
|
||||
|
||||
if (!tableDescription.is_system_template) {
|
||||
await queryInterface.addColumn('workflow_templates', 'is_system_template', {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
// Remove columns if they exist
|
||||
const tableDescription = await queryInterface.describeTable('workflow_templates');
|
||||
|
||||
if (tableDescription.dynamic_approver_config) {
|
||||
await queryInterface.removeColumn('workflow_templates', 'dynamic_approver_config');
|
||||
}
|
||||
|
||||
if (tableDescription.user_field_mappings) {
|
||||
await queryInterface.removeColumn('workflow_templates', 'user_field_mappings');
|
||||
}
|
||||
|
||||
if (tableDescription.form_steps_config) {
|
||||
await queryInterface.removeColumn('workflow_templates', 'form_steps_config');
|
||||
}
|
||||
|
||||
if (tableDescription.workflow_type) {
|
||||
await queryInterface.removeColumn('workflow_templates', 'workflow_type');
|
||||
}
|
||||
|
||||
if (tableDescription.is_system_template) {
|
||||
await queryInterface.removeColumn('workflow_templates', 'is_system_template');
|
||||
}
|
||||
}
|
||||
|
||||
197
src/migrations/20251211-create-claim-budget-tracking-table.ts
Normal file
197
src/migrations/20251211-create-claim-budget-tracking-table.ts
Normal file
@ -0,0 +1,197 @@
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
// Create claim_budget_tracking table for comprehensive budget management
|
||||
await queryInterface.createTable('claim_budget_tracking', {
|
||||
budget_id: {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
defaultValue: DataTypes.UUIDV4
|
||||
},
|
||||
request_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
references: {
|
||||
model: 'workflow_requests',
|
||||
key: 'request_id'
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE'
|
||||
},
|
||||
// Initial Budget (from claim creation)
|
||||
initial_estimated_budget: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
comment: 'Initial estimated budget when claim was created'
|
||||
},
|
||||
// Proposal Budget (from Step 1 - Dealer Proposal)
|
||||
proposal_estimated_budget: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
comment: 'Total estimated budget from dealer proposal'
|
||||
},
|
||||
proposal_submitted_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: 'When dealer submitted proposal'
|
||||
},
|
||||
// Approved Budget (from Step 2 - Requestor Evaluation)
|
||||
approved_budget: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
comment: 'Budget approved by requestor in Step 2'
|
||||
},
|
||||
approved_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: 'When budget was approved by requestor'
|
||||
},
|
||||
approved_by: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id'
|
||||
},
|
||||
onDelete: 'SET NULL',
|
||||
onUpdate: 'CASCADE',
|
||||
comment: 'User who approved the budget'
|
||||
},
|
||||
// IO Blocked Budget (from Step 3 - Department Lead)
|
||||
io_blocked_amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
comment: 'Amount blocked in IO (from internal_orders table)'
|
||||
},
|
||||
io_blocked_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: 'When budget was blocked in IO'
|
||||
},
|
||||
// Closed Expenses (from Step 5 - Dealer Completion)
|
||||
closed_expenses: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
comment: 'Total closed expenses from completion documents'
|
||||
},
|
||||
closed_expenses_submitted_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: 'When completion expenses were submitted'
|
||||
},
|
||||
// Final Claim Amount (from Step 6 - Requestor Claim Approval)
|
||||
final_claim_amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
comment: 'Final claim amount approved/modified by requestor in Step 6'
|
||||
},
|
||||
final_claim_amount_approved_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: 'When final claim amount was approved'
|
||||
},
|
||||
final_claim_amount_approved_by: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id'
|
||||
},
|
||||
onDelete: 'SET NULL',
|
||||
onUpdate: 'CASCADE',
|
||||
comment: 'User who approved final claim amount'
|
||||
},
|
||||
// Credit Note (from Step 8 - Finance)
|
||||
credit_note_amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
comment: 'Credit note amount issued by finance'
|
||||
},
|
||||
credit_note_issued_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: 'When credit note was issued'
|
||||
},
|
||||
// Budget Status
|
||||
budget_status: {
|
||||
type: DataTypes.ENUM('DRAFT', 'PROPOSED', 'APPROVED', 'BLOCKED', 'CLOSED', 'SETTLED'),
|
||||
defaultValue: 'DRAFT',
|
||||
allowNull: false,
|
||||
comment: 'Current status of budget lifecycle'
|
||||
},
|
||||
// Currency
|
||||
currency: {
|
||||
type: DataTypes.STRING(3),
|
||||
defaultValue: 'INR',
|
||||
allowNull: false,
|
||||
comment: 'Currency code (INR, USD, etc.)'
|
||||
},
|
||||
// Budget Variance
|
||||
variance_amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
comment: 'Difference between approved and closed expenses (closed - approved)'
|
||||
},
|
||||
variance_percentage: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
comment: 'Variance as percentage of approved budget'
|
||||
},
|
||||
// Audit fields
|
||||
last_modified_by: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id'
|
||||
},
|
||||
onDelete: 'SET NULL',
|
||||
onUpdate: 'CASCADE',
|
||||
comment: 'Last user who modified budget'
|
||||
},
|
||||
last_modified_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: 'When budget was last modified'
|
||||
},
|
||||
modification_reason: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Reason for budget modification'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
});
|
||||
|
||||
// Create indexes
|
||||
await queryInterface.addIndex('claim_budget_tracking', ['request_id'], {
|
||||
name: 'idx_claim_budget_tracking_request_id',
|
||||
unique: true
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('claim_budget_tracking', ['budget_status'], {
|
||||
name: 'idx_claim_budget_tracking_status'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('claim_budget_tracking', ['approved_by'], {
|
||||
name: 'idx_claim_budget_tracking_approved_by'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('claim_budget_tracking', ['final_claim_amount_approved_by'], {
|
||||
name: 'idx_claim_budget_tracking_final_approved_by'
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.dropTable('claim_budget_tracking');
|
||||
}
|
||||
|
||||
95
src/migrations/20251211-create-internal-orders-table.ts
Normal file
95
src/migrations/20251211-create-internal-orders-table.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
// Create internal_orders table for storing IO (Internal Order) details
|
||||
await queryInterface.createTable('internal_orders', {
|
||||
io_id: {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
defaultValue: DataTypes.UUIDV4
|
||||
},
|
||||
request_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'workflow_requests',
|
||||
key: 'request_id'
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE'
|
||||
},
|
||||
io_number: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
io_remark: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
io_available_balance: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true
|
||||
},
|
||||
io_blocked_amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true
|
||||
},
|
||||
io_remaining_balance: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true
|
||||
},
|
||||
organized_by: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id'
|
||||
},
|
||||
onDelete: 'SET NULL',
|
||||
onUpdate: 'CASCADE'
|
||||
},
|
||||
organized_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
sap_document_number: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('PENDING', 'BLOCKED', 'RELEASED', 'CANCELLED'),
|
||||
defaultValue: 'PENDING',
|
||||
allowNull: false
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
});
|
||||
|
||||
// Create indexes
|
||||
await queryInterface.addIndex('internal_orders', ['io_number'], {
|
||||
name: 'idx_internal_orders_io_number'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('internal_orders', ['organized_by'], {
|
||||
name: 'idx_internal_orders_organized_by'
|
||||
});
|
||||
|
||||
// Create unique constraint: one IO per request (unique index on request_id)
|
||||
await queryInterface.addIndex('internal_orders', ['request_id'], {
|
||||
name: 'idx_internal_orders_request_id_unique',
|
||||
unique: true
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.dropTable('internal_orders');
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
55
src/migrations/20251214-create-dealer-completion-expenses.ts
Normal file
55
src/migrations/20251214-create-dealer-completion-expenses.ts
Normal 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');
|
||||
}
|
||||
|
||||
295
src/models/ClaimBudgetTracking.ts
Normal file
295
src/models/ClaimBudgetTracking.ts
Normal file
@ -0,0 +1,295 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import { WorkflowRequest } from './WorkflowRequest';
|
||||
import { User } from './User';
|
||||
|
||||
export enum BudgetStatus {
|
||||
DRAFT = 'DRAFT',
|
||||
PROPOSED = 'PROPOSED',
|
||||
APPROVED = 'APPROVED',
|
||||
BLOCKED = 'BLOCKED',
|
||||
CLOSED = 'CLOSED',
|
||||
SETTLED = 'SETTLED'
|
||||
}
|
||||
|
||||
interface ClaimBudgetTrackingAttributes {
|
||||
budgetId: string;
|
||||
requestId: string;
|
||||
// Initial Budget
|
||||
initialEstimatedBudget?: number;
|
||||
// Proposal Budget
|
||||
proposalEstimatedBudget?: number;
|
||||
proposalSubmittedAt?: Date;
|
||||
// Approved Budget
|
||||
approvedBudget?: number;
|
||||
approvedAt?: Date;
|
||||
approvedBy?: string;
|
||||
// IO Blocked Budget
|
||||
ioBlockedAmount?: number;
|
||||
ioBlockedAt?: Date;
|
||||
// Closed Expenses
|
||||
closedExpenses?: number;
|
||||
closedExpensesSubmittedAt?: Date;
|
||||
// Final Claim Amount
|
||||
finalClaimAmount?: number;
|
||||
finalClaimAmountApprovedAt?: Date;
|
||||
finalClaimAmountApprovedBy?: string;
|
||||
// Credit Note
|
||||
creditNoteAmount?: number;
|
||||
creditNoteIssuedAt?: Date;
|
||||
// Status & Metadata
|
||||
budgetStatus: BudgetStatus;
|
||||
currency: string;
|
||||
varianceAmount?: number;
|
||||
variancePercentage?: number;
|
||||
// Audit
|
||||
lastModifiedBy?: string;
|
||||
lastModifiedAt?: Date;
|
||||
modificationReason?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface ClaimBudgetTrackingCreationAttributes extends Optional<ClaimBudgetTrackingAttributes, 'budgetId' | 'initialEstimatedBudget' | 'proposalEstimatedBudget' | 'proposalSubmittedAt' | 'approvedBudget' | 'approvedAt' | 'approvedBy' | 'ioBlockedAmount' | 'ioBlockedAt' | 'closedExpenses' | 'closedExpensesSubmittedAt' | 'finalClaimAmount' | 'finalClaimAmountApprovedAt' | 'finalClaimAmountApprovedBy' | 'creditNoteAmount' | 'creditNoteIssuedAt' | 'varianceAmount' | 'variancePercentage' | 'lastModifiedBy' | 'lastModifiedAt' | 'modificationReason' | 'budgetStatus' | 'currency' | 'createdAt' | 'updatedAt'> {}
|
||||
|
||||
class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudgetTrackingCreationAttributes> implements ClaimBudgetTrackingAttributes {
|
||||
public budgetId!: string;
|
||||
public requestId!: string;
|
||||
public initialEstimatedBudget?: number;
|
||||
public proposalEstimatedBudget?: number;
|
||||
public proposalSubmittedAt?: Date;
|
||||
public approvedBudget?: number;
|
||||
public approvedAt?: Date;
|
||||
public approvedBy?: string;
|
||||
public ioBlockedAmount?: number;
|
||||
public ioBlockedAt?: Date;
|
||||
public closedExpenses?: number;
|
||||
public closedExpensesSubmittedAt?: Date;
|
||||
public finalClaimAmount?: number;
|
||||
public finalClaimAmountApprovedAt?: Date;
|
||||
public finalClaimAmountApprovedBy?: string;
|
||||
public creditNoteAmount?: number;
|
||||
public creditNoteIssuedAt?: Date;
|
||||
public budgetStatus!: BudgetStatus;
|
||||
public currency!: string;
|
||||
public varianceAmount?: number;
|
||||
public variancePercentage?: number;
|
||||
public lastModifiedBy?: string;
|
||||
public lastModifiedAt?: Date;
|
||||
public modificationReason?: string;
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
|
||||
// Associations
|
||||
public request?: WorkflowRequest;
|
||||
public approver?: User;
|
||||
public finalApprover?: User;
|
||||
public lastModifier?: User;
|
||||
}
|
||||
|
||||
ClaimBudgetTracking.init(
|
||||
{
|
||||
budgetId: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
field: 'budget_id'
|
||||
},
|
||||
requestId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
field: 'request_id',
|
||||
references: {
|
||||
model: 'workflow_requests',
|
||||
key: 'request_id'
|
||||
}
|
||||
},
|
||||
initialEstimatedBudget: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'initial_estimated_budget'
|
||||
},
|
||||
proposalEstimatedBudget: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'proposal_estimated_budget'
|
||||
},
|
||||
proposalSubmittedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'proposal_submitted_at'
|
||||
},
|
||||
approvedBudget: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'approved_budget'
|
||||
},
|
||||
approvedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'approved_at'
|
||||
},
|
||||
approvedBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
field: 'approved_by',
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id'
|
||||
}
|
||||
},
|
||||
ioBlockedAmount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'io_blocked_amount'
|
||||
},
|
||||
ioBlockedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'io_blocked_at'
|
||||
},
|
||||
closedExpenses: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'closed_expenses'
|
||||
},
|
||||
closedExpensesSubmittedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'closed_expenses_submitted_at'
|
||||
},
|
||||
finalClaimAmount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'final_claim_amount'
|
||||
},
|
||||
finalClaimAmountApprovedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'final_claim_amount_approved_at'
|
||||
},
|
||||
finalClaimAmountApprovedBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
field: 'final_claim_amount_approved_by',
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id'
|
||||
}
|
||||
},
|
||||
creditNoteAmount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'credit_note_amount'
|
||||
},
|
||||
creditNoteIssuedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'credit_note_issued_at'
|
||||
},
|
||||
budgetStatus: {
|
||||
type: DataTypes.ENUM('DRAFT', 'PROPOSED', 'APPROVED', 'BLOCKED', 'CLOSED', 'SETTLED'),
|
||||
defaultValue: 'DRAFT',
|
||||
allowNull: false,
|
||||
field: 'budget_status'
|
||||
},
|
||||
currency: {
|
||||
type: DataTypes.STRING(3),
|
||||
defaultValue: 'INR',
|
||||
allowNull: false
|
||||
},
|
||||
varianceAmount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'variance_amount'
|
||||
},
|
||||
variancePercentage: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'variance_percentage'
|
||||
},
|
||||
lastModifiedBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
field: 'last_modified_by',
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id'
|
||||
}
|
||||
},
|
||||
lastModifiedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'last_modified_at'
|
||||
},
|
||||
modificationReason: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'modification_reason'
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'created_at'
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'updated_at'
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: 'ClaimBudgetTracking',
|
||||
tableName: 'claim_budget_tracking',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['request_id'],
|
||||
unique: true
|
||||
},
|
||||
{
|
||||
fields: ['budget_status']
|
||||
},
|
||||
{
|
||||
fields: ['approved_by']
|
||||
},
|
||||
{
|
||||
fields: ['final_claim_amount_approved_by']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
// Associations
|
||||
ClaimBudgetTracking.belongsTo(WorkflowRequest, {
|
||||
as: 'request',
|
||||
foreignKey: 'requestId',
|
||||
targetKey: 'requestId'
|
||||
});
|
||||
|
||||
ClaimBudgetTracking.belongsTo(User, {
|
||||
as: 'approver',
|
||||
foreignKey: 'approvedBy',
|
||||
targetKey: 'userId'
|
||||
});
|
||||
|
||||
ClaimBudgetTracking.belongsTo(User, {
|
||||
as: 'finalApprover',
|
||||
foreignKey: 'finalClaimAmountApprovedBy',
|
||||
targetKey: 'userId'
|
||||
});
|
||||
|
||||
ClaimBudgetTracking.belongsTo(User, {
|
||||
as: 'lastModifier',
|
||||
foreignKey: 'lastModifiedBy',
|
||||
targetKey: 'userId'
|
||||
});
|
||||
|
||||
export { ClaimBudgetTracking };
|
||||
|
||||
123
src/models/ClaimCreditNote.ts
Normal file
123
src/models/ClaimCreditNote.ts
Normal 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
124
src/models/ClaimInvoice.ts
Normal 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 };
|
||||
|
||||
167
src/models/DealerClaimDetails.ts
Normal file
167
src/models/DealerClaimDetails.ts
Normal 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 };
|
||||
|
||||
111
src/models/DealerCompletionDetails.ts
Normal file
111
src/models/DealerCompletionDetails.ts
Normal 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 };
|
||||
|
||||
118
src/models/DealerCompletionExpense.ts
Normal file
118
src/models/DealerCompletionExpense.ts
Normal 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 };
|
||||
|
||||
123
src/models/DealerProposalCostItem.ts
Normal file
123
src/models/DealerProposalCostItem.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import { DealerProposalDetails } from './DealerProposalDetails';
|
||||
import { WorkflowRequest } from './WorkflowRequest';
|
||||
|
||||
interface DealerProposalCostItemAttributes {
|
||||
costItemId: string;
|
||||
proposalId: string;
|
||||
requestId: string;
|
||||
itemDescription: string;
|
||||
amount: number;
|
||||
itemOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface DealerProposalCostItemCreationAttributes extends Optional<DealerProposalCostItemAttributes, 'costItemId' | 'itemOrder' | 'createdAt' | 'updatedAt'> {}
|
||||
|
||||
class DealerProposalCostItem extends Model<DealerProposalCostItemAttributes, DealerProposalCostItemCreationAttributes> implements DealerProposalCostItemAttributes {
|
||||
public costItemId!: string;
|
||||
public proposalId!: string;
|
||||
public requestId!: string;
|
||||
public itemDescription!: string;
|
||||
public amount!: number;
|
||||
public itemOrder!: number;
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
|
||||
// Associations
|
||||
public proposal?: DealerProposalDetails;
|
||||
public workflowRequest?: WorkflowRequest;
|
||||
}
|
||||
|
||||
DealerProposalCostItem.init(
|
||||
{
|
||||
costItemId: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
field: 'cost_item_id'
|
||||
},
|
||||
proposalId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
field: 'proposal_id',
|
||||
references: {
|
||||
model: 'dealer_proposal_details',
|
||||
key: 'proposal_id'
|
||||
}
|
||||
},
|
||||
requestId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
field: 'request_id',
|
||||
references: {
|
||||
model: 'workflow_requests',
|
||||
key: 'request_id'
|
||||
}
|
||||
},
|
||||
itemDescription: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: false,
|
||||
field: 'item_description'
|
||||
},
|
||||
amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false
|
||||
},
|
||||
itemOrder: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'item_order'
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'created_at'
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'updated_at'
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: 'DealerProposalCostItem',
|
||||
tableName: 'dealer_proposal_cost_items',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
indexes: [
|
||||
{ fields: ['proposal_id'], name: 'idx_proposal_cost_items_proposal_id' },
|
||||
{ fields: ['request_id'], name: 'idx_proposal_cost_items_request_id' },
|
||||
{ fields: ['proposal_id', 'item_order'], name: 'idx_proposal_cost_items_proposal_order' }
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
// Associations
|
||||
DealerProposalCostItem.belongsTo(DealerProposalDetails, {
|
||||
as: 'proposal',
|
||||
foreignKey: 'proposalId',
|
||||
targetKey: 'proposalId'
|
||||
});
|
||||
|
||||
DealerProposalCostItem.belongsTo(WorkflowRequest, {
|
||||
as: 'workflowRequest',
|
||||
foreignKey: 'requestId',
|
||||
targetKey: 'requestId'
|
||||
});
|
||||
|
||||
DealerProposalDetails.hasMany(DealerProposalCostItem, {
|
||||
as: 'costItems',
|
||||
foreignKey: 'proposalId',
|
||||
sourceKey: 'proposalId'
|
||||
});
|
||||
|
||||
export { DealerProposalCostItem };
|
||||
|
||||
142
src/models/DealerProposalDetails.ts
Normal file
142
src/models/DealerProposalDetails.ts
Normal 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
166
src/models/InternalOrder.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import { WorkflowRequest } from './WorkflowRequest';
|
||||
import { User } from './User';
|
||||
|
||||
export enum IOStatus {
|
||||
PENDING = 'PENDING',
|
||||
BLOCKED = 'BLOCKED',
|
||||
RELEASED = 'RELEASED',
|
||||
CANCELLED = 'CANCELLED'
|
||||
}
|
||||
|
||||
interface InternalOrderAttributes {
|
||||
ioId: string;
|
||||
requestId: string;
|
||||
ioNumber: string;
|
||||
ioRemark?: string;
|
||||
ioAvailableBalance?: number;
|
||||
ioBlockedAmount?: number;
|
||||
ioRemainingBalance?: number;
|
||||
organizedBy?: string;
|
||||
organizedAt?: Date;
|
||||
sapDocumentNumber?: string;
|
||||
status: IOStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface InternalOrderCreationAttributes extends Optional<InternalOrderAttributes, 'ioId' | 'ioRemark' | 'ioAvailableBalance' | 'ioBlockedAmount' | 'ioRemainingBalance' | 'organizedBy' | 'organizedAt' | 'sapDocumentNumber' | 'status' | 'createdAt' | 'updatedAt'> {}
|
||||
|
||||
class InternalOrder extends Model<InternalOrderAttributes, InternalOrderCreationAttributes> implements InternalOrderAttributes {
|
||||
public ioId!: string;
|
||||
public requestId!: string;
|
||||
public ioNumber!: string;
|
||||
public ioRemark?: string;
|
||||
public ioAvailableBalance?: number;
|
||||
public ioBlockedAmount?: number;
|
||||
public ioRemainingBalance?: number;
|
||||
public organizedBy?: string;
|
||||
public organizedAt?: Date;
|
||||
public sapDocumentNumber?: string;
|
||||
public status!: IOStatus;
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
|
||||
// Associations
|
||||
public request?: WorkflowRequest;
|
||||
public organizer?: User;
|
||||
}
|
||||
|
||||
InternalOrder.init(
|
||||
{
|
||||
ioId: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
field: 'io_id'
|
||||
},
|
||||
requestId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
field: 'request_id',
|
||||
references: {
|
||||
model: 'workflow_requests',
|
||||
key: 'request_id'
|
||||
}
|
||||
},
|
||||
ioNumber: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
field: 'io_number'
|
||||
},
|
||||
ioRemark: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'io_remark'
|
||||
},
|
||||
ioAvailableBalance: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'io_available_balance'
|
||||
},
|
||||
ioBlockedAmount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'io_blocked_amount'
|
||||
},
|
||||
ioRemainingBalance: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'io_remaining_balance'
|
||||
},
|
||||
organizedBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
field: 'organized_by',
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id'
|
||||
}
|
||||
},
|
||||
organizedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'organized_at'
|
||||
},
|
||||
sapDocumentNumber: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
field: 'sap_document_number'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('PENDING', 'BLOCKED', 'RELEASED', 'CANCELLED'),
|
||||
defaultValue: 'PENDING',
|
||||
allowNull: false
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'created_at'
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'updated_at'
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: 'InternalOrder',
|
||||
tableName: 'internal_orders',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['request_id'],
|
||||
unique: true
|
||||
},
|
||||
{
|
||||
fields: ['io_number']
|
||||
},
|
||||
{
|
||||
fields: ['organized_by']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
// Associations
|
||||
InternalOrder.belongsTo(WorkflowRequest, {
|
||||
as: 'request',
|
||||
foreignKey: 'requestId',
|
||||
targetKey: 'requestId'
|
||||
});
|
||||
|
||||
InternalOrder.belongsTo(User, {
|
||||
as: 'organizer',
|
||||
foreignKey: 'organizedBy',
|
||||
targetKey: 'userId'
|
||||
});
|
||||
|
||||
export { InternalOrder };
|
||||
|
||||
@ -7,7 +7,9 @@ interface WorkflowRequestAttributes {
|
||||
requestId: string;
|
||||
requestNumber: 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;
|
||||
description: string;
|
||||
priority: Priority;
|
||||
@ -37,7 +39,9 @@ class WorkflowRequest extends Model<WorkflowRequestAttributes, WorkflowRequestCr
|
||||
public requestId!: string;
|
||||
public requestNumber!: string;
|
||||
public initiatorId!: string;
|
||||
public templateType!: 'CUSTOM' | 'TEMPLATE';
|
||||
public templateType!: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM';
|
||||
public workflowType?: string;
|
||||
public templateId?: string;
|
||||
public title!: string;
|
||||
public description!: string;
|
||||
public priority!: Priority;
|
||||
@ -92,6 +96,23 @@ WorkflowRequest.init(
|
||||
defaultValue: 'CUSTOM',
|
||||
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: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: false
|
||||
@ -223,6 +244,12 @@ WorkflowRequest.init(
|
||||
},
|
||||
{
|
||||
fields: ['created_at']
|
||||
},
|
||||
{
|
||||
fields: ['workflow_type']
|
||||
},
|
||||
{
|
||||
fields: ['template_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
180
src/models/WorkflowTemplate.ts
Normal file
180
src/models/WorkflowTemplate.ts
Normal file
@ -0,0 +1,180 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import { User } from './User';
|
||||
|
||||
interface WorkflowTemplateAttributes {
|
||||
templateId: string;
|
||||
templateName: string;
|
||||
templateCode?: string;
|
||||
templateDescription?: string;
|
||||
templateCategory?: string;
|
||||
workflowType?: string;
|
||||
approvalLevelsConfig?: any;
|
||||
defaultTatHours?: number;
|
||||
formStepsConfig?: any;
|
||||
userFieldMappings?: any;
|
||||
dynamicApproverConfig?: any;
|
||||
isActive: boolean;
|
||||
isSystemTemplate: boolean;
|
||||
usageCount: number;
|
||||
createdBy?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface WorkflowTemplateCreationAttributes extends Optional<WorkflowTemplateAttributes, 'templateId' | 'templateCode' | 'templateDescription' | 'templateCategory' | 'workflowType' | 'approvalLevelsConfig' | 'defaultTatHours' | 'formStepsConfig' | 'userFieldMappings' | 'dynamicApproverConfig' | 'createdBy' | 'createdAt' | 'updatedAt'> {}
|
||||
|
||||
class WorkflowTemplate extends Model<WorkflowTemplateAttributes, WorkflowTemplateCreationAttributes> implements WorkflowTemplateAttributes {
|
||||
public templateId!: string;
|
||||
public templateName!: string;
|
||||
public templateCode?: string;
|
||||
public templateDescription?: string;
|
||||
public templateCategory?: string;
|
||||
public workflowType?: string;
|
||||
public approvalLevelsConfig?: any;
|
||||
public defaultTatHours?: number;
|
||||
public formStepsConfig?: any;
|
||||
public userFieldMappings?: any;
|
||||
public dynamicApproverConfig?: any;
|
||||
public isActive!: boolean;
|
||||
public isSystemTemplate!: boolean;
|
||||
public usageCount!: number;
|
||||
public createdBy?: string;
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
|
||||
// Associations
|
||||
public creator?: User;
|
||||
}
|
||||
|
||||
WorkflowTemplate.init(
|
||||
{
|
||||
templateId: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
field: 'template_id'
|
||||
},
|
||||
templateName: {
|
||||
type: DataTypes.STRING(200),
|
||||
allowNull: false,
|
||||
field: 'template_name'
|
||||
},
|
||||
templateCode: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
unique: true,
|
||||
field: 'template_code'
|
||||
},
|
||||
templateDescription: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'template_description'
|
||||
},
|
||||
templateCategory: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
field: 'template_category'
|
||||
},
|
||||
workflowType: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
field: 'workflow_type'
|
||||
},
|
||||
approvalLevelsConfig: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
field: 'approval_levels_config'
|
||||
},
|
||||
defaultTatHours: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: true,
|
||||
defaultValue: 24,
|
||||
field: 'default_tat_hours'
|
||||
},
|
||||
formStepsConfig: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
field: 'form_steps_config'
|
||||
},
|
||||
userFieldMappings: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
field: 'user_field_mappings'
|
||||
},
|
||||
dynamicApproverConfig: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
field: 'dynamic_approver_config'
|
||||
},
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
field: 'is_active'
|
||||
},
|
||||
isSystemTemplate: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_system_template'
|
||||
},
|
||||
usageCount: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'usage_count'
|
||||
},
|
||||
createdBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
field: 'created_by',
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id'
|
||||
}
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'created_at'
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'updated_at'
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: 'WorkflowTemplate',
|
||||
tableName: 'workflow_templates',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['template_code']
|
||||
},
|
||||
{
|
||||
fields: ['workflow_type']
|
||||
},
|
||||
{
|
||||
fields: ['is_active']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
// Associations
|
||||
WorkflowTemplate.belongsTo(User, {
|
||||
as: 'creator',
|
||||
foreignKey: 'createdBy',
|
||||
targetKey: 'userId'
|
||||
});
|
||||
|
||||
export { WorkflowTemplate };
|
||||
|
||||
@ -16,6 +16,13 @@ import { Notification } from './Notification';
|
||||
import ConclusionRemark from './ConclusionRemark';
|
||||
import RequestSummary from './RequestSummary';
|
||||
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
|
||||
const defineAssociations = () => {
|
||||
@ -114,6 +121,20 @@ const defineAssociations = () => {
|
||||
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
|
||||
// Only hasMany associations from WorkflowRequest are defined here since they're one-way
|
||||
};
|
||||
@ -138,7 +159,14 @@ export {
|
||||
Notification,
|
||||
ConclusionRemark,
|
||||
RequestSummary,
|
||||
SharedSummary
|
||||
SharedSummary,
|
||||
DealerClaimDetails,
|
||||
DealerProposalDetails,
|
||||
DealerCompletionDetails,
|
||||
DealerProposalCostItem,
|
||||
WorkflowTemplate,
|
||||
InternalOrder,
|
||||
ClaimBudgetTracking
|
||||
};
|
||||
|
||||
// Export default sequelize instance
|
||||
|
||||
38
src/routes/dealer.routes.ts
Normal file
38
src/routes/dealer.routes.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Router } from 'express';
|
||||
import { DealerController } from '../controllers/dealer.controller';
|
||||
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||
import { asyncHandler } from '../middlewares/errorHandler.middleware';
|
||||
|
||||
const router = Router();
|
||||
const dealerController = new DealerController();
|
||||
|
||||
/**
|
||||
* @route GET /api/v1/dealers
|
||||
* @desc Get all dealers
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/', authenticateToken, asyncHandler(dealerController.getAllDealers.bind(dealerController)));
|
||||
|
||||
/**
|
||||
* @route GET /api/v1/dealers/search
|
||||
* @desc Search dealers by name, code, or email
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/search', authenticateToken, asyncHandler(dealerController.searchDealers.bind(dealerController)));
|
||||
|
||||
/**
|
||||
* @route GET /api/v1/dealers/code/:dealerCode
|
||||
* @desc Get dealer by code
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/code/:dealerCode', authenticateToken, asyncHandler(dealerController.getDealerByCode.bind(dealerController)));
|
||||
|
||||
/**
|
||||
* @route GET /api/v1/dealers/email/:email
|
||||
* @desc Get dealer by email
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/email/:email', authenticateToken, asyncHandler(dealerController.getDealerByEmail.bind(dealerController)));
|
||||
|
||||
export default router;
|
||||
|
||||
90
src/routes/dealerClaim.routes.ts
Normal file
90
src/routes/dealerClaim.routes.ts
Normal 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;
|
||||
|
||||
@ -13,6 +13,9 @@ import dashboardRoutes from './dashboard.routes';
|
||||
import notificationRoutes from './notification.routes';
|
||||
import conclusionRoutes from './conclusion.routes';
|
||||
import aiRoutes from './ai.routes';
|
||||
import dealerClaimRoutes from './dealerClaim.routes';
|
||||
import templateRoutes from './template.routes';
|
||||
import dealerRoutes from './dealer.routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -40,6 +43,9 @@ router.use('/notifications', notificationRoutes);
|
||||
router.use('/conclusions', conclusionRoutes);
|
||||
router.use('/ai', aiRoutes);
|
||||
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
|
||||
// router.use('/approvals', approvalRoutes);
|
||||
|
||||
53
src/routes/template.routes.ts
Normal file
53
src/routes/template.routes.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { Router } from 'express';
|
||||
import { TemplateController } from '../controllers/template.controller';
|
||||
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||
import { asyncHandler } from '../middlewares/errorHandler.middleware';
|
||||
import { requireAdmin } from '../middlewares/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
const templateController = new TemplateController();
|
||||
|
||||
/**
|
||||
* @route GET /api/v1/templates
|
||||
* @desc List all templates (with optional filters)
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/', authenticateToken, asyncHandler(templateController.listTemplates.bind(templateController)));
|
||||
|
||||
/**
|
||||
* @route GET /api/v1/templates/active
|
||||
* @desc Get active templates for workflow creation
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/active', authenticateToken, asyncHandler(templateController.getActiveTemplates.bind(templateController)));
|
||||
|
||||
/**
|
||||
* @route GET /api/v1/templates/:templateId
|
||||
* @desc Get template by ID
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/:templateId', authenticateToken, asyncHandler(templateController.getTemplate.bind(templateController)));
|
||||
|
||||
/**
|
||||
* @route POST /api/v1/templates
|
||||
* @desc Create a new template
|
||||
* @access Private (Admin only)
|
||||
*/
|
||||
router.post('/', authenticateToken, requireAdmin, asyncHandler(templateController.createTemplate.bind(templateController)));
|
||||
|
||||
/**
|
||||
* @route PUT /api/v1/templates/:templateId
|
||||
* @desc Update template
|
||||
* @access Private (Admin only)
|
||||
*/
|
||||
router.put('/:templateId', authenticateToken, requireAdmin, asyncHandler(templateController.updateTemplate.bind(templateController)));
|
||||
|
||||
/**
|
||||
* @route DELETE /api/v1/templates/:templateId
|
||||
* @desc Delete template (soft delete)
|
||||
* @access Private (Admin only)
|
||||
*/
|
||||
router.delete('/:templateId', authenticateToken, requireAdmin, asyncHandler(templateController.deleteTemplate.bind(templateController)));
|
||||
|
||||
export default router;
|
||||
|
||||
@ -10,6 +10,9 @@ const userController = new UserController();
|
||||
// GET /api/v1/users/search?q=<email or name>
|
||||
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)
|
||||
router.get('/configurations', authenticateToken, asyncHandler(getPublicConfigurations));
|
||||
|
||||
|
||||
@ -118,8 +118,20 @@ async function runMigrations(): Promise<void> {
|
||||
const m25 = require('../migrations/20250126-add-pause-fields-to-workflow-requests');
|
||||
const m26 = require('../migrations/20250126-add-pause-fields-to-approval-levels');
|
||||
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 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 = [
|
||||
{ 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-approval-levels', module: m26 },
|
||||
{ name: '20250127-migrate-in-progress-to-pending', module: m27 },
|
||||
// Base branch migrations (m28-m29)
|
||||
{ name: '20250130-migrate-to-vertex-ai', module: m28 },
|
||||
{ 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();
|
||||
|
||||
167
src/scripts/cleanup-dealer-claims.ts
Normal file
167
src/scripts/cleanup-dealer-claims.ts
Normal file
@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Cleanup Dealer Claims Script
|
||||
* Removes all dealer claim related data for a fresh start
|
||||
*
|
||||
* Usage: npm run cleanup:dealer-claims
|
||||
*
|
||||
* WARNING: This will permanently delete all CLAIM_MANAGEMENT requests and related data!
|
||||
*/
|
||||
|
||||
import { sequelize } from '../config/database';
|
||||
import { QueryTypes } from 'sequelize';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
async function cleanupDealerClaims(): Promise<void> {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
logger.info('[Cleanup] Starting dealer claim cleanup...');
|
||||
|
||||
// Step 1: Find all CLAIM_MANAGEMENT request IDs
|
||||
logger.info('[Cleanup] Finding all CLAIM_MANAGEMENT requests...');
|
||||
const claimRequests = await sequelize.query<{ request_id: string }>(
|
||||
`SELECT request_id FROM workflow_requests WHERE workflow_type = 'CLAIM_MANAGEMENT'`,
|
||||
{ type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
|
||||
const requestIds = claimRequests.map(r => r.request_id);
|
||||
const count = requestIds.length;
|
||||
|
||||
if (count === 0) {
|
||||
logger.info('[Cleanup] No CLAIM_MANAGEMENT requests found. Nothing to clean up.');
|
||||
await transaction.commit();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[Cleanup] Found ${count} CLAIM_MANAGEMENT request(s) to delete`);
|
||||
|
||||
// Step 2: Delete in order (respecting foreign key constraints)
|
||||
// Start with child tables, then parent tables
|
||||
|
||||
// Convert UUID array to PostgreSQL array format
|
||||
const requestIdsArray = `{${requestIds.map(id => `'${id}'`).join(',')}}`;
|
||||
|
||||
// Delete from claim_budget_tracking (new table)
|
||||
logger.info('[Cleanup] Deleting from claim_budget_tracking...');
|
||||
await sequelize.query(
|
||||
`DELETE FROM claim_budget_tracking WHERE request_id = ANY(ARRAY[${requestIds.map(() => '?').join(',')}]::uuid[])`,
|
||||
{
|
||||
replacements: requestIds,
|
||||
type: QueryTypes.DELETE,
|
||||
transaction
|
||||
}
|
||||
);
|
||||
|
||||
// Step 2: Delete in order (respecting foreign key constraints)
|
||||
// Start with child tables, then parent tables
|
||||
|
||||
// Helper function to delete with array
|
||||
const deleteWithArray = async (tableName: string, columnName: string = 'request_id') => {
|
||||
await sequelize.query(
|
||||
`DELETE FROM ${tableName} WHERE ${columnName} = ANY(ARRAY[${requestIds.map(() => '?').join(',')}]::uuid[])`,
|
||||
{
|
||||
replacements: requestIds,
|
||||
type: QueryTypes.DELETE,
|
||||
transaction
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Delete from claim_budget_tracking (new table)
|
||||
logger.info('[Cleanup] Deleting from claim_budget_tracking...');
|
||||
await deleteWithArray('claim_budget_tracking');
|
||||
|
||||
// Delete from internal_orders (new table)
|
||||
logger.info('[Cleanup] Deleting from internal_orders...');
|
||||
await deleteWithArray('internal_orders');
|
||||
|
||||
// Delete from dealer_proposal_cost_items
|
||||
logger.info('[Cleanup] Deleting from dealer_proposal_cost_items...');
|
||||
await deleteWithArray('dealer_proposal_cost_items');
|
||||
|
||||
// Delete from dealer_completion_details
|
||||
logger.info('[Cleanup] Deleting from dealer_completion_details...');
|
||||
await deleteWithArray('dealer_completion_details');
|
||||
|
||||
// Delete from dealer_proposal_details
|
||||
logger.info('[Cleanup] Deleting from dealer_proposal_details...');
|
||||
await deleteWithArray('dealer_proposal_details');
|
||||
|
||||
// Delete from dealer_claim_details
|
||||
logger.info('[Cleanup] Deleting from dealer_claim_details...');
|
||||
await deleteWithArray('dealer_claim_details');
|
||||
|
||||
// Delete from activities (workflow activities)
|
||||
logger.info('[Cleanup] Deleting from activities...');
|
||||
await deleteWithArray('activities');
|
||||
|
||||
// Delete from work_notes
|
||||
logger.info('[Cleanup] Deleting from work_notes...');
|
||||
await deleteWithArray('work_notes');
|
||||
|
||||
// Delete from documents
|
||||
logger.info('[Cleanup] Deleting from documents...');
|
||||
await deleteWithArray('documents');
|
||||
|
||||
// Delete from participants
|
||||
logger.info('[Cleanup] Deleting from participants...');
|
||||
await deleteWithArray('participants');
|
||||
|
||||
// Delete from approval_levels
|
||||
logger.info('[Cleanup] Deleting from approval_levels...');
|
||||
await deleteWithArray('approval_levels');
|
||||
|
||||
// Note: subscriptions table doesn't have request_id - it's for push notification subscriptions
|
||||
// Skip subscriptions as it's not related to workflow requests
|
||||
|
||||
// Delete from notifications
|
||||
logger.info('[Cleanup] Deleting from notifications...');
|
||||
await deleteWithArray('notifications');
|
||||
|
||||
// Delete from request_summaries
|
||||
logger.info('[Cleanup] Deleting from request_summaries...');
|
||||
await deleteWithArray('request_summaries');
|
||||
|
||||
// Delete from shared_summaries
|
||||
logger.info('[Cleanup] Deleting from shared_summaries...');
|
||||
await deleteWithArray('shared_summaries');
|
||||
|
||||
// Delete from conclusion_remarks
|
||||
logger.info('[Cleanup] Deleting from conclusion_remarks...');
|
||||
await deleteWithArray('conclusion_remarks');
|
||||
|
||||
// Delete from tat_alerts
|
||||
logger.info('[Cleanup] Deleting from tat_alerts...');
|
||||
await deleteWithArray('tat_alerts');
|
||||
|
||||
// Finally, delete from workflow_requests
|
||||
logger.info('[Cleanup] Deleting from workflow_requests...');
|
||||
await deleteWithArray('workflow_requests');
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
logger.info(`[Cleanup] ✅ Successfully deleted ${count} CLAIM_MANAGEMENT request(s) and all related data!`);
|
||||
logger.info('[Cleanup] Database is now clean and ready for fresh dealer claim requests.');
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
logger.error('[Cleanup] ❌ Error during cleanup:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run cleanup if called directly
|
||||
if (require.main === module) {
|
||||
cleanupDealerClaims()
|
||||
.then(() => {
|
||||
logger.info('[Cleanup] Cleanup completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('[Cleanup] Cleanup failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { cleanupDealerClaims };
|
||||
|
||||
@ -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 m26 from '../migrations/20250126-add-pause-fields-to-approval-levels';
|
||||
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 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 {
|
||||
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-approval-levels', module: m26 },
|
||||
{ name: '20250127-migrate-in-progress-to-pending', module: m27 },
|
||||
// Base branch migrations (m28-m29)
|
||||
{ name: '20250130-migrate-to-vertex-ai', module: m28 },
|
||||
{ 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
182
src/scripts/seed-dealers.ts
Normal file
@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Seed Dealer Users
|
||||
* Creates dealer users for claim management workflow
|
||||
* These users will act as action takers in the workflow
|
||||
*/
|
||||
|
||||
import { sequelize } from '../config/database';
|
||||
import { User } from '../models/User';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
interface DealerData {
|
||||
email: string;
|
||||
dealerCode: string;
|
||||
dealerName: string;
|
||||
displayName: string;
|
||||
department?: string;
|
||||
designation?: string;
|
||||
phone?: string;
|
||||
role?: 'USER' | 'MANAGEMENT' | 'ADMIN';
|
||||
}
|
||||
|
||||
const dealers: DealerData[] = [
|
||||
{
|
||||
email: 'test.2@royalenfield.com',
|
||||
dealerCode: 'RE-MH-001',
|
||||
dealerName: 'Royal Motors Mumbai',
|
||||
displayName: 'Royal Motors Mumbai',
|
||||
department: 'Dealer Operations',
|
||||
designation: 'Dealer',
|
||||
phone: '+91-9876543210',
|
||||
role: 'USER',
|
||||
},
|
||||
{
|
||||
email: 'test.4@royalenfield.com',
|
||||
dealerCode: 'RE-DL-002',
|
||||
dealerName: 'Delhi enfield center',
|
||||
displayName: 'Delhi Enfield Center',
|
||||
department: 'Dealer Operations',
|
||||
designation: 'Dealer',
|
||||
phone: '+91-9876543211',
|
||||
role: 'USER',
|
||||
},
|
||||
];
|
||||
|
||||
async function seedDealers(): Promise<void> {
|
||||
try {
|
||||
logger.info('[Seed Dealers] Starting dealer user seeding...');
|
||||
|
||||
for (const dealer of dealers) {
|
||||
// Check if user already exists
|
||||
const existingUser = await User.findOne({
|
||||
where: { email: dealer.email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
// User already exists (likely from Okta SSO login)
|
||||
const isOktaUser = existingUser.oktaSub && !existingUser.oktaSub.startsWith('dealer-');
|
||||
|
||||
if (isOktaUser) {
|
||||
logger.info(`[Seed Dealers] User ${dealer.email} already exists as Okta user (oktaSub: ${existingUser.oktaSub}), updating dealer-specific fields only...`);
|
||||
} else {
|
||||
logger.info(`[Seed Dealers] User ${dealer.email} already exists, updating dealer information...`);
|
||||
}
|
||||
|
||||
// Update existing user with dealer information
|
||||
// IMPORTANT: Preserve Okta data (oktaSub, role from Okta, etc.) and only update dealer-specific fields
|
||||
const nameParts = dealer.dealerName.split(' ');
|
||||
const firstName = nameParts[0] || dealer.dealerName;
|
||||
const lastName = nameParts.slice(1).join(' ') || '';
|
||||
|
||||
// Build update object - only update fields that don't conflict with Okta data
|
||||
const updateData: any = {
|
||||
// Always update dealer code in employeeId (this is dealer-specific, safe to update)
|
||||
employeeId: dealer.dealerCode,
|
||||
};
|
||||
|
||||
// Only update displayName if it's different or if current one is empty
|
||||
if (!existingUser.displayName || existingUser.displayName !== dealer.displayName) {
|
||||
updateData.displayName = dealer.displayName;
|
||||
}
|
||||
|
||||
// Only update designation if current one doesn't indicate dealer role
|
||||
if (!existingUser.designation || !existingUser.designation.toLowerCase().includes('dealer')) {
|
||||
updateData.designation = dealer.designation || existingUser.designation;
|
||||
}
|
||||
|
||||
// Only update department if it's not set or if we want to ensure "Dealer Operations"
|
||||
if (!existingUser.department || existingUser.department !== 'Dealer Operations') {
|
||||
updateData.department = dealer.department || existingUser.department;
|
||||
}
|
||||
|
||||
// Update phone if not set
|
||||
if (!existingUser.phone && dealer.phone) {
|
||||
updateData.phone = dealer.phone;
|
||||
}
|
||||
|
||||
// Update name parts if not set
|
||||
if (!existingUser.firstName && firstName) {
|
||||
updateData.firstName = firstName;
|
||||
}
|
||||
if (!existingUser.lastName && lastName) {
|
||||
updateData.lastName = lastName;
|
||||
}
|
||||
|
||||
await existingUser.update(updateData);
|
||||
|
||||
if (isOktaUser) {
|
||||
logger.info(`[Seed Dealers] ✅ Updated existing Okta user ${dealer.email} with dealer code: ${dealer.dealerCode}`);
|
||||
logger.info(`[Seed Dealers] Preserved Okta data: oktaSub=${existingUser.oktaSub}, role=${existingUser.role}`);
|
||||
} else {
|
||||
logger.info(`[Seed Dealers] ✅ Updated user ${dealer.email} with dealer code: ${dealer.dealerCode}`);
|
||||
}
|
||||
} else {
|
||||
// User doesn't exist - create new dealer user
|
||||
// NOTE: If dealer is an Okta user, they should login via SSO first to be created automatically
|
||||
// This creates a placeholder user that will be updated when they login via SSO
|
||||
logger.warn(`[Seed Dealers] User ${dealer.email} not found in database. Creating placeholder user...`);
|
||||
logger.warn(`[Seed Dealers] ⚠️ If this user is an Okta user, they should login via SSO first to be created automatically.`);
|
||||
logger.warn(`[Seed Dealers] ⚠️ The oktaSub will be updated when they login via SSO.`);
|
||||
|
||||
// Generate a UUID for userId
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const userId = uuidv4();
|
||||
|
||||
const nameParts = dealer.dealerName.split(' ');
|
||||
const firstName = nameParts[0] || dealer.dealerName;
|
||||
const lastName = nameParts.slice(1).join(' ') || '';
|
||||
|
||||
await User.create({
|
||||
userId,
|
||||
email: dealer.email.toLowerCase(),
|
||||
displayName: dealer.displayName,
|
||||
firstName,
|
||||
lastName,
|
||||
department: dealer.department || 'Dealer Operations',
|
||||
designation: dealer.designation || 'Dealer',
|
||||
phone: dealer.phone,
|
||||
role: dealer.role || 'USER',
|
||||
employeeId: dealer.dealerCode, // Store dealer code in employeeId field
|
||||
isActive: true,
|
||||
// Set placeholder oktaSub - will be updated when user logs in via SSO
|
||||
// Using a recognizable pattern so we know it's a placeholder
|
||||
oktaSub: `dealer-${dealer.dealerCode}-pending-sso`,
|
||||
emailNotificationsEnabled: true,
|
||||
pushNotificationsEnabled: false,
|
||||
inAppNotificationsEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
logger.info(`[Seed Dealers] ⚠️ Created placeholder dealer user: ${dealer.email} (${dealer.dealerCode})`);
|
||||
logger.info(`[Seed Dealers] ⚠️ User should login via SSO to update oktaSub field with real Okta subject ID`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('[Seed Dealers] ✅ Dealer seeding completed successfully');
|
||||
} catch (error) {
|
||||
logger.error('[Seed Dealers] ❌ Error seeding dealers:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
sequelize
|
||||
.authenticate()
|
||||
.then(() => {
|
||||
logger.info('[Seed Dealers] Database connection established');
|
||||
return seedDealers();
|
||||
})
|
||||
.then(() => {
|
||||
logger.info('[Seed Dealers] Seeding completed');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('[Seed Dealers] Seeding failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { seedDealers, dealers };
|
||||
|
||||
@ -12,6 +12,7 @@ import { notificationService } from './notification.service';
|
||||
import { activityService } from './activity.service';
|
||||
import { tatSchedulerService } from './tatScheduler.service';
|
||||
import { emitToRequestRoom } from '../realtime/socket';
|
||||
import { DealerClaimService } from './dealerClaim.service';
|
||||
|
||||
export class ApprovalService {
|
||||
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}`);
|
||||
// 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 ], {
|
||||
title: `Action required: ${(wf as any).requestNumber}`,
|
||||
body: `${(wf as any).title}`,
|
||||
requestNumber: (wf as any).requestNumber,
|
||||
url: `/request/${(wf as any).requestNumber}`
|
||||
});
|
||||
}
|
||||
activityService.log({
|
||||
requestId: level.requestId,
|
||||
type: 'approval',
|
||||
|
||||
@ -7,6 +7,230 @@ import logger, { logAuthEvent } from '../utils/logger';
|
||||
import axios from 'axios';
|
||||
|
||||
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
|
||||
* 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.designation) userUpdateData.designation = userData.designation;
|
||||
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)
|
||||
let user = await User.findOne({
|
||||
@ -88,6 +313,7 @@ export class AuthService {
|
||||
department: userData.department || null,
|
||||
designation: userData.designation || null,
|
||||
phone: userData.phone || null,
|
||||
manager: userData.manager || null, // Manager name from Okta
|
||||
isActive: true,
|
||||
role: 'USER',
|
||||
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) {
|
||||
throw new Error('Okta sub (subject identifier) not found in response');
|
||||
}
|
||||
|
||||
const employeeId =
|
||||
oktaUser.employeeId ||
|
||||
oktaUser.employee_id ||
|
||||
oktaUser.empId ||
|
||||
oktaUser.employeeNumber ||
|
||||
undefined;
|
||||
// Step 3: Try Users API first (provides full profile including manager, employeeID, etc.)
|
||||
let userData: SSOUserData | null = null;
|
||||
const usersApiResponse = await this.fetchUserFromOktaUsersAPI(oktaSub, oktaUserInfo.email || username, access_token);
|
||||
|
||||
const userData: SSOUserData = {
|
||||
oktaSub: oktaSub,
|
||||
email: oktaUser.email || username,
|
||||
employeeId: employeeId,
|
||||
};
|
||||
if (usersApiResponse) {
|
||||
userData = this.extractUserDataFromUsersAPI(usersApiResponse, oktaSub);
|
||||
}
|
||||
|
||||
// Add optional fields
|
||||
if (oktaUser.given_name || oktaUser.firstName) {
|
||||
userData.firstName = oktaUser.given_name || oktaUser.firstName;
|
||||
// Fallback to userinfo endpoint if Users API failed or returned null
|
||||
if (!userData) {
|
||||
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', {
|
||||
email: userData.email,
|
||||
employeeId: userData.employeeId || 'not provided',
|
||||
hasEmployeeId: !!userData.employeeId,
|
||||
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
|
||||
@ -483,7 +700,8 @@ export class AuthService {
|
||||
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 userInfoResponse = await axios.get(userInfoEndpoint, {
|
||||
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) {
|
||||
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
|
||||
// Check multiple possible sources for actual employee ID attribute:
|
||||
// 1. Custom Okta attribute: employeeId, employee_id, empId, employeeNumber
|
||||
// 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
|
||||
// Try Users API first (provides full profile including manager, employeeID, etc.)
|
||||
let userData: SSOUserData | null = null;
|
||||
const usersApiResponse = await this.fetchUserFromOktaUsersAPI(oktaSub, oktaUserInfo.email || '', access_token);
|
||||
|
||||
// Extract user data from Okta response
|
||||
// Adjust these mappings based on your Okta user profile attributes
|
||||
// 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;
|
||||
if (usersApiResponse) {
|
||||
userData = this.extractUserDataFromUsersAPI(usersApiResponse, oktaSub);
|
||||
}
|
||||
|
||||
logger.info('User data extracted from Okta', {
|
||||
oktaSub: oktaSub,
|
||||
email: oktaUser.email,
|
||||
employeeId: employeeId || 'not provided (optional)',
|
||||
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;
|
||||
// Fallback to userinfo endpoint if Users API failed or returned null
|
||||
if (!userData) {
|
||||
logger.info('Using userinfo endpoint as fallback (Users API unavailable or failed)');
|
||||
userData = this.extractUserDataFromUserInfo(oktaUserInfo, oktaSub);
|
||||
}
|
||||
|
||||
logger.info('Extracted user data from Okta', {
|
||||
employeeId: userData.employeeId,
|
||||
logger.info('Final extracted user data', {
|
||||
oktaSub: userData.oktaSub,
|
||||
email: userData.email,
|
||||
hasFirstName: !!userData.firstName,
|
||||
hasLastName: !!userData.lastName,
|
||||
hasDisplayName: !!userData.displayName,
|
||||
employeeId: userData.employeeId || 'not provided',
|
||||
hasManager: !!(userData as any).manager,
|
||||
manager: (userData as any).manager || 'not provided',
|
||||
hasDepartment: !!userData.department,
|
||||
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
|
||||
|
||||
166
src/services/dealer.service.ts
Normal file
166
src/services/dealer.service.ts
Normal file
@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Dealer Service
|
||||
* Handles dealer-related operations for claim management
|
||||
*/
|
||||
|
||||
import { User } from '../models/User';
|
||||
import { Op } from 'sequelize';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
export interface DealerInfo {
|
||||
userId: string;
|
||||
email: string;
|
||||
dealerCode: string;
|
||||
dealerName: string;
|
||||
displayName: string;
|
||||
phone?: string;
|
||||
department?: string;
|
||||
designation?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all dealers (users with dealer designation or employeeId starting with 'RE-')
|
||||
*/
|
||||
export async function getAllDealers(): Promise<DealerInfo[]> {
|
||||
try {
|
||||
const dealers = await User.findAll({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ designation: { [Op.iLike]: '%dealer%' } as any },
|
||||
{ employeeId: { [Op.like]: 'RE-%' } as any },
|
||||
{ department: { [Op.iLike]: '%dealer%' } as any },
|
||||
],
|
||||
isActive: true,
|
||||
},
|
||||
order: [['displayName', 'ASC']],
|
||||
});
|
||||
|
||||
return dealers.map((dealer) => ({
|
||||
userId: dealer.userId,
|
||||
email: dealer.email,
|
||||
dealerCode: dealer.employeeId || '', // Dealer code stored in employeeId
|
||||
dealerName: dealer.displayName || dealer.email,
|
||||
displayName: dealer.displayName || dealer.email,
|
||||
phone: dealer.phone || undefined,
|
||||
department: dealer.department || undefined,
|
||||
designation: dealer.designation || undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('[DealerService] Error fetching dealers:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dealer by code
|
||||
*/
|
||||
export async function getDealerByCode(dealerCode: string): Promise<DealerInfo | null> {
|
||||
try {
|
||||
const dealer = await User.findOne({
|
||||
where: {
|
||||
employeeId: dealerCode,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dealer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: dealer.userId,
|
||||
email: dealer.email,
|
||||
dealerCode: dealer.employeeId || '',
|
||||
dealerName: dealer.displayName || dealer.email,
|
||||
displayName: dealer.displayName || dealer.email,
|
||||
phone: dealer.phone || undefined,
|
||||
department: dealer.department || undefined,
|
||||
designation: dealer.designation || undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[DealerService] Error fetching dealer by code:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dealer by email
|
||||
*/
|
||||
export async function getDealerByEmail(email: string): Promise<DealerInfo | null> {
|
||||
try {
|
||||
const dealer = await User.findOne({
|
||||
where: {
|
||||
email: email.toLowerCase(),
|
||||
isActive: true,
|
||||
[Op.or]: [
|
||||
{ designation: { [Op.iLike]: '%dealer%' } as any },
|
||||
{ employeeId: { [Op.like]: 'RE-%' } as any },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!dealer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: dealer.userId,
|
||||
email: dealer.email,
|
||||
dealerCode: dealer.employeeId || '',
|
||||
dealerName: dealer.displayName || dealer.email,
|
||||
displayName: dealer.displayName || dealer.email,
|
||||
phone: dealer.phone || undefined,
|
||||
department: dealer.department || undefined,
|
||||
designation: dealer.designation || undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[DealerService] Error fetching dealer by email:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search dealers by name or code
|
||||
*/
|
||||
export async function searchDealers(searchTerm: string): Promise<DealerInfo[]> {
|
||||
try {
|
||||
const dealers = await User.findAll({
|
||||
where: {
|
||||
[Op.and]: [
|
||||
{
|
||||
[Op.or]: [
|
||||
{ designation: { [Op.iLike]: '%dealer%' } as any },
|
||||
{ employeeId: { [Op.like]: 'RE-%' } as any },
|
||||
{ department: { [Op.iLike]: '%dealer%' } as any },
|
||||
],
|
||||
},
|
||||
{
|
||||
[Op.or]: [
|
||||
{ displayName: { [Op.iLike]: `%${searchTerm}%` } as any },
|
||||
{ email: { [Op.iLike]: `%${searchTerm}%` } as any },
|
||||
{ employeeId: { [Op.iLike]: `%${searchTerm}%` } as any },
|
||||
],
|
||||
},
|
||||
{ isActive: true },
|
||||
],
|
||||
},
|
||||
order: [['displayName', 'ASC']],
|
||||
limit: 50, // Limit results
|
||||
});
|
||||
|
||||
return dealers.map((dealer) => ({
|
||||
userId: dealer.userId,
|
||||
email: dealer.email,
|
||||
dealerCode: dealer.employeeId || '',
|
||||
dealerName: dealer.displayName || dealer.email,
|
||||
displayName: dealer.displayName || dealer.email,
|
||||
phone: dealer.phone || undefined,
|
||||
department: dealer.department || undefined,
|
||||
designation: dealer.designation || undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('[DealerService] Error searching dealers:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
1630
src/services/dealerClaim.service.ts
Normal file
1630
src/services/dealerClaim.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
313
src/services/dmsIntegration.service.ts
Normal file
313
src/services/dmsIntegration.service.ts
Normal file
@ -0,0 +1,313 @@
|
||||
import logger from '../utils/logger';
|
||||
|
||||
/**
|
||||
* DMS (Document Management System) Integration Service
|
||||
* Handles integration with DMS for e-invoice and credit note generation
|
||||
*
|
||||
* NOTE: This is a placeholder/stub implementation.
|
||||
* Replace with actual DMS API integration based on your DMS system.
|
||||
*/
|
||||
export class DMSIntegrationService {
|
||||
private dmsBaseUrl: string;
|
||||
private dmsApiKey?: string;
|
||||
private dmsUsername?: string;
|
||||
private dmsPassword?: string;
|
||||
|
||||
constructor() {
|
||||
this.dmsBaseUrl = process.env.DMS_BASE_URL || '';
|
||||
this.dmsApiKey = process.env.DMS_API_KEY;
|
||||
this.dmsUsername = process.env.DMS_USERNAME;
|
||||
this.dmsPassword = process.env.DMS_PASSWORD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if DMS integration is configured
|
||||
*/
|
||||
private isConfigured(): boolean {
|
||||
return !!this.dmsBaseUrl && (!!this.dmsApiKey || (!!this.dmsUsername && !!this.dmsPassword));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate e-invoice in DMS
|
||||
* @param invoiceData - Invoice data
|
||||
* @returns E-invoice details including invoice number, DMS number, etc.
|
||||
*/
|
||||
async generateEInvoice(invoiceData: {
|
||||
requestNumber: string;
|
||||
dealerCode: string;
|
||||
dealerName: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
ioNumber?: string;
|
||||
taxDetails?: any;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
eInvoiceNumber?: string;
|
||||
dmsNumber?: string;
|
||||
invoiceDate?: Date;
|
||||
invoiceUrl?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
if (!this.isConfigured()) {
|
||||
logger.warn('[DMS] DMS integration not configured, generating mock e-invoice');
|
||||
// Return mock data for development/testing
|
||||
const mockInvoiceNumber = `EINV-${Date.now()}`;
|
||||
const mockDmsNumber = `DMS-${Date.now()}`;
|
||||
return {
|
||||
success: true,
|
||||
eInvoiceNumber: mockInvoiceNumber,
|
||||
dmsNumber: mockDmsNumber,
|
||||
invoiceDate: new Date(),
|
||||
invoiceUrl: `https://dms.example.com/invoices/${mockInvoiceNumber}`,
|
||||
error: 'DMS not configured - e-invoice generation simulated'
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Implement actual DMS API call
|
||||
// Example:
|
||||
// const response = await axios.post(`${this.dmsBaseUrl}/api/invoices/generate`, {
|
||||
// request_number: invoiceData.requestNumber,
|
||||
// dealer_code: invoiceData.dealerCode,
|
||||
// dealer_name: invoiceData.dealerName,
|
||||
// amount: invoiceData.amount,
|
||||
// description: invoiceData.description,
|
||||
// io_number: invoiceData.ioNumber,
|
||||
// tax_details: invoiceData.taxDetails
|
||||
// }, {
|
||||
// headers: {
|
||||
// 'Authorization': `Bearer ${this.dmsApiKey}`,
|
||||
// 'Content-Type': 'application/json'
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// return {
|
||||
// success: response.data.success,
|
||||
// eInvoiceNumber: response.data.e_invoice_number,
|
||||
// dmsNumber: response.data.dms_number,
|
||||
// invoiceDate: new Date(response.data.invoice_date),
|
||||
// invoiceUrl: response.data.invoice_url
|
||||
// };
|
||||
|
||||
logger.warn('[DMS] DMS e-invoice generation not implemented, generating mock invoice');
|
||||
const mockInvoiceNumber = `EINV-${Date.now()}`;
|
||||
const mockDmsNumber = `DMS-${Date.now()}`;
|
||||
return {
|
||||
success: true,
|
||||
eInvoiceNumber: mockInvoiceNumber,
|
||||
dmsNumber: mockDmsNumber,
|
||||
invoiceDate: new Date(),
|
||||
invoiceUrl: `https://dms.example.com/invoices/${mockInvoiceNumber}`,
|
||||
error: 'DMS API not implemented - e-invoice generation simulated'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[DMS] Error generating e-invoice:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate credit note in DMS
|
||||
* @param creditNoteData - Credit note data
|
||||
* @returns Credit note details including credit note number, amount, etc.
|
||||
*/
|
||||
async generateCreditNote(creditNoteData: {
|
||||
requestNumber: string;
|
||||
eInvoiceNumber: string;
|
||||
dealerCode: string;
|
||||
dealerName: string;
|
||||
amount: number;
|
||||
reason: string;
|
||||
description?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
creditNoteNumber?: string;
|
||||
creditNoteDate?: Date;
|
||||
creditNoteAmount?: number;
|
||||
creditNoteUrl?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
if (!this.isConfigured()) {
|
||||
logger.warn('[DMS] DMS integration not configured, generating mock credit note');
|
||||
// Return mock data for development/testing
|
||||
const mockCreditNoteNumber = `CN-${Date.now()}`;
|
||||
return {
|
||||
success: true,
|
||||
creditNoteNumber: mockCreditNoteNumber,
|
||||
creditNoteDate: new Date(),
|
||||
creditNoteAmount: creditNoteData.amount,
|
||||
creditNoteUrl: `https://dms.example.com/credit-notes/${mockCreditNoteNumber}`,
|
||||
error: 'DMS not configured - credit note generation simulated'
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Implement actual DMS API call
|
||||
// Example:
|
||||
// const response = await axios.post(`${this.dmsBaseUrl}/api/credit-notes/generate`, {
|
||||
// request_number: creditNoteData.requestNumber,
|
||||
// e_invoice_number: creditNoteData.eInvoiceNumber,
|
||||
// dealer_code: creditNoteData.dealerCode,
|
||||
// dealer_name: creditNoteData.dealerName,
|
||||
// amount: creditNoteData.amount,
|
||||
// reason: creditNoteData.reason,
|
||||
// description: creditNoteData.description
|
||||
// }, {
|
||||
// headers: {
|
||||
// 'Authorization': `Bearer ${this.dmsApiKey}`,
|
||||
// 'Content-Type': 'application/json'
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// return {
|
||||
// success: response.data.success,
|
||||
// creditNoteNumber: response.data.credit_note_number,
|
||||
// creditNoteDate: new Date(response.data.credit_note_date),
|
||||
// creditNoteAmount: response.data.credit_note_amount,
|
||||
// creditNoteUrl: response.data.credit_note_url
|
||||
// };
|
||||
|
||||
logger.warn('[DMS] DMS credit note generation not implemented, generating mock credit note');
|
||||
const mockCreditNoteNumber = `CN-${Date.now()}`;
|
||||
return {
|
||||
success: true,
|
||||
creditNoteNumber: mockCreditNoteNumber,
|
||||
creditNoteDate: new Date(),
|
||||
creditNoteAmount: creditNoteData.amount,
|
||||
creditNoteUrl: `https://dms.example.com/credit-notes/${mockCreditNoteNumber}`,
|
||||
error: 'DMS API not implemented - credit note generation simulated'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[DMS] Error generating credit note:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoice status from DMS
|
||||
* @param eInvoiceNumber - E-invoice number
|
||||
* @returns Invoice status and details
|
||||
*/
|
||||
async getInvoiceStatus(eInvoiceNumber: string): Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
invoiceNumber?: string;
|
||||
dmsNumber?: string;
|
||||
invoiceDate?: Date;
|
||||
amount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
if (!this.isConfigured()) {
|
||||
logger.warn('[DMS] DMS integration not configured, returning mock invoice status');
|
||||
return {
|
||||
success: true,
|
||||
status: 'GENERATED',
|
||||
invoiceNumber: eInvoiceNumber,
|
||||
dmsNumber: `DMS-${Date.now()}`,
|
||||
invoiceDate: new Date(),
|
||||
amount: 0,
|
||||
error: 'DMS not configured - invoice status simulated'
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Implement actual DMS API call
|
||||
// Example:
|
||||
// const response = await axios.get(`${this.dmsBaseUrl}/api/invoices/${eInvoiceNumber}/status`, {
|
||||
// headers: {
|
||||
// 'Authorization': `Bearer ${this.dmsApiKey}`,
|
||||
// 'Content-Type': 'application/json'
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// return {
|
||||
// success: true,
|
||||
// status: response.data.status,
|
||||
// invoiceNumber: response.data.invoice_number,
|
||||
// dmsNumber: response.data.dms_number,
|
||||
// invoiceDate: new Date(response.data.invoice_date),
|
||||
// amount: response.data.amount
|
||||
// };
|
||||
|
||||
logger.warn('[DMS] DMS invoice status check not implemented, returning mock status');
|
||||
return {
|
||||
success: true,
|
||||
status: 'GENERATED',
|
||||
invoiceNumber: eInvoiceNumber,
|
||||
dmsNumber: `DMS-${Date.now()}`,
|
||||
invoiceDate: new Date(),
|
||||
amount: 0,
|
||||
error: 'DMS API not implemented - invoice status simulated'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[DMS] Error getting invoice status:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download invoice document from DMS
|
||||
* @param eInvoiceNumber - E-invoice number
|
||||
* @returns Invoice document URL or file buffer
|
||||
*/
|
||||
async downloadInvoice(eInvoiceNumber: string): Promise<{
|
||||
success: boolean;
|
||||
documentUrl?: string;
|
||||
documentBuffer?: Buffer;
|
||||
mimeType?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
if (!this.isConfigured()) {
|
||||
logger.warn('[DMS] DMS integration not configured, returning mock download URL');
|
||||
return {
|
||||
success: true,
|
||||
documentUrl: `https://dms.example.com/invoices/${eInvoiceNumber}/download`,
|
||||
mimeType: 'application/pdf',
|
||||
error: 'DMS not configured - download URL simulated'
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Implement actual DMS API call
|
||||
// Example:
|
||||
// const response = await axios.get(`${this.dmsBaseUrl}/api/invoices/${eInvoiceNumber}/download`, {
|
||||
// headers: {
|
||||
// 'Authorization': `Bearer ${this.dmsApiKey}`
|
||||
// },
|
||||
// responseType: 'arraybuffer'
|
||||
// });
|
||||
//
|
||||
// return {
|
||||
// success: true,
|
||||
// documentBuffer: Buffer.from(response.data),
|
||||
// mimeType: response.headers['content-type'] || 'application/pdf'
|
||||
// };
|
||||
|
||||
logger.warn('[DMS] DMS invoice download not implemented, returning mock URL');
|
||||
return {
|
||||
success: true,
|
||||
documentUrl: `https://dms.example.com/invoices/${eInvoiceNumber}/download`,
|
||||
mimeType: 'application/pdf',
|
||||
error: 'DMS API not implemented - download URL simulated'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[DMS] Error downloading invoice:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const dmsIntegrationService = new DMSIntegrationService();
|
||||
|
||||
241
src/services/enhancedTemplate.service.ts
Normal file
241
src/services/enhancedTemplate.service.ts
Normal file
@ -0,0 +1,241 @@
|
||||
import { WorkflowTemplate } from '../models/WorkflowTemplate';
|
||||
import { WorkflowRequest } from '../models/WorkflowRequest';
|
||||
import { ApprovalLevel } from '../models/ApprovalLevel';
|
||||
import { TemplateFieldResolver, FormStepConfig } from './templateFieldResolver.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Enhanced Template Service
|
||||
* Handles template-based workflow operations with dynamic form configuration
|
||||
*/
|
||||
export class EnhancedTemplateService {
|
||||
private fieldResolver = new TemplateFieldResolver();
|
||||
|
||||
/**
|
||||
* Get form configuration for a template with resolved user references
|
||||
*/
|
||||
async getFormConfig(
|
||||
templateId: string,
|
||||
requestId?: string,
|
||||
currentUserId?: string
|
||||
): Promise<FormStepConfig[]> {
|
||||
try {
|
||||
const template = await WorkflowTemplate.findByPk(templateId);
|
||||
if (!template) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
const stepsConfig = (template.formStepsConfig || []) as FormStepConfig[];
|
||||
|
||||
// If request exists, resolve user references
|
||||
if (requestId && currentUserId) {
|
||||
const request = await WorkflowRequest.findByPk(requestId, {
|
||||
include: [{ model: ApprovalLevel, as: 'approvalLevels' }]
|
||||
});
|
||||
if (request) {
|
||||
return await this.resolveStepsWithUserData(stepsConfig, request, currentUserId);
|
||||
}
|
||||
}
|
||||
|
||||
return stepsConfig;
|
||||
} catch (error) {
|
||||
logger.error('[EnhancedTemplateService] Error getting form config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve user references in all steps
|
||||
*/
|
||||
private async resolveStepsWithUserData(
|
||||
steps: FormStepConfig[],
|
||||
request: WorkflowRequest,
|
||||
currentUserId: string
|
||||
): Promise<FormStepConfig[]> {
|
||||
try {
|
||||
// Get all approvers for context
|
||||
const approvers = await ApprovalLevel.findAll({
|
||||
where: { requestId: request.requestId }
|
||||
});
|
||||
const approverMap = new Map(
|
||||
approvers.map(a => [a.levelNumber, a])
|
||||
);
|
||||
|
||||
const resolvedSteps = await Promise.all(
|
||||
steps.map(async (step) => {
|
||||
const resolvedFields = await this.fieldResolver.resolveUserReferences(
|
||||
step,
|
||||
request,
|
||||
currentUserId,
|
||||
{
|
||||
currentLevel: request.currentLevel,
|
||||
approvers: approverMap
|
||||
}
|
||||
);
|
||||
|
||||
// Merge resolved values into field defaults
|
||||
const enrichedFields = step.fields.map(field => ({
|
||||
...field,
|
||||
defaultValue: resolvedFields[field.fieldId] || field.defaultValue
|
||||
}));
|
||||
|
||||
return {
|
||||
...step,
|
||||
fields: enrichedFields
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return resolvedSteps;
|
||||
} catch (error) {
|
||||
logger.error('[EnhancedTemplateService] Error resolving steps:', error);
|
||||
return steps; // Return original steps on error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and save form data for a step
|
||||
*/
|
||||
async saveStepData(
|
||||
templateId: string,
|
||||
requestId: string,
|
||||
stepNumber: number,
|
||||
formData: Record<string, any>,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const template = await WorkflowTemplate.findByPk(templateId);
|
||||
if (!template) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
const stepsConfig = (template.formStepsConfig || []) as FormStepConfig[];
|
||||
const stepConfig = stepsConfig.find(s => s.stepNumber === stepNumber);
|
||||
|
||||
if (!stepConfig) {
|
||||
throw new Error(`Step ${stepNumber} not found in template`);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
this.validateStepData(stepConfig, formData);
|
||||
|
||||
// Save to template-specific storage
|
||||
await this.saveToTemplateStorage(template.workflowType, requestId, stepNumber, formData);
|
||||
} catch (error) {
|
||||
logger.error('[EnhancedTemplateService] Error saving step data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate step data against configuration
|
||||
*/
|
||||
private validateStepData(stepConfig: FormStepConfig, formData: Record<string, any>): void {
|
||||
for (const field of stepConfig.fields) {
|
||||
if (field.required && !formData[field.fieldId]) {
|
||||
throw new Error(`Field ${field.label} is required`);
|
||||
}
|
||||
|
||||
// Apply validation rules
|
||||
if (field.validation && formData[field.fieldId]) {
|
||||
const value = formData[field.fieldId];
|
||||
if (field.validation.min !== undefined && value < field.validation.min) {
|
||||
throw new Error(`${field.label} must be at least ${field.validation.min}`);
|
||||
}
|
||||
if (field.validation.max !== undefined && value > field.validation.max) {
|
||||
throw new Error(`${field.label} must be at most ${field.validation.max}`);
|
||||
}
|
||||
if (field.validation.pattern) {
|
||||
const regex = new RegExp(field.validation.pattern);
|
||||
if (!regex.test(String(value))) {
|
||||
throw new Error(`${field.label} format is invalid`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save to template-specific storage based on workflow type
|
||||
*/
|
||||
private async saveToTemplateStorage(
|
||||
workflowType: string,
|
||||
requestId: string,
|
||||
stepNumber: number,
|
||||
formData: Record<string, any>
|
||||
): Promise<void> {
|
||||
switch (workflowType) {
|
||||
case 'CLAIM_MANAGEMENT':
|
||||
await this.saveClaimManagementStepData(requestId, stepNumber, formData);
|
||||
break;
|
||||
default:
|
||||
// Generic storage for custom templates
|
||||
logger.warn(`[EnhancedTemplateService] No specific storage handler for workflow type: ${workflowType}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save claim management step data
|
||||
*/
|
||||
private async saveClaimManagementStepData(
|
||||
requestId: string,
|
||||
stepNumber: number,
|
||||
formData: Record<string, any>
|
||||
): Promise<void> {
|
||||
const { DealerClaimDetails } = await import('../models/DealerClaimDetails');
|
||||
const { DealerProposalDetails } = await import('../models/DealerProposalDetails');
|
||||
const { DealerCompletionDetails } = await import('../models/DealerCompletionDetails');
|
||||
|
||||
switch (stepNumber) {
|
||||
case 1:
|
||||
// Save to dealer_claim_details
|
||||
await DealerClaimDetails.upsert({
|
||||
requestId,
|
||||
activityName: formData.activity_name,
|
||||
activityType: formData.activity_type,
|
||||
dealerCode: formData.dealer_code,
|
||||
dealerName: formData.dealer_name,
|
||||
dealerEmail: formData.dealer_email,
|
||||
dealerPhone: formData.dealer_phone,
|
||||
dealerAddress: formData.dealer_address,
|
||||
activityDate: formData.activity_date,
|
||||
location: formData.location,
|
||||
periodStartDate: formData.period_start_date,
|
||||
periodEndDate: formData.period_end_date,
|
||||
estimatedBudget: formData.estimated_budget,
|
||||
});
|
||||
break;
|
||||
case 2:
|
||||
// Save to dealer_proposal_details
|
||||
await DealerProposalDetails.upsert({
|
||||
requestId,
|
||||
costBreakup: formData.cost_breakup,
|
||||
totalEstimatedBudget: formData.total_estimated_budget,
|
||||
timelineMode: formData.timeline_mode,
|
||||
expectedCompletionDate: formData.expected_completion_date,
|
||||
expectedCompletionDays: formData.expected_completion_days,
|
||||
dealerComments: formData.dealer_comments,
|
||||
proposalDocumentPath: formData.proposal_document_path,
|
||||
proposalDocumentUrl: formData.proposal_document_url,
|
||||
submittedAt: new Date(),
|
||||
});
|
||||
break;
|
||||
case 5:
|
||||
// Save to dealer_completion_details
|
||||
await DealerCompletionDetails.upsert({
|
||||
requestId,
|
||||
activityCompletionDate: formData.activity_completion_date,
|
||||
numberOfParticipants: formData.number_of_participants,
|
||||
closedExpenses: formData.closed_expenses,
|
||||
totalClosedExpenses: formData.total_closed_expenses,
|
||||
completionDocuments: formData.completion_documents,
|
||||
activityPhotos: formData.activity_photos,
|
||||
submittedAt: new Date(),
|
||||
});
|
||||
break;
|
||||
default:
|
||||
logger.warn(`[EnhancedTemplateService] No storage handler for claim management step ${stepNumber}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -657,7 +657,10 @@ export class PauseService {
|
||||
const now = new Date();
|
||||
|
||||
// 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: {
|
||||
isPaused: true,
|
||||
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;
|
||||
for (const workflow of pausedWorkflows) {
|
||||
|
||||
298
src/services/sapIntegration.service.ts
Normal file
298
src/services/sapIntegration.service.ts
Normal file
@ -0,0 +1,298 @@
|
||||
import logger from '../utils/logger';
|
||||
|
||||
/**
|
||||
* SAP Integration Service
|
||||
* Handles integration with SAP for IO validation and budget blocking
|
||||
*
|
||||
* NOTE: This is a placeholder/stub implementation.
|
||||
* Replace with actual SAP API integration based on your SAP system.
|
||||
*/
|
||||
export class SAPIntegrationService {
|
||||
private sapBaseUrl: string;
|
||||
private sapApiKey?: string;
|
||||
private sapUsername?: string;
|
||||
private sapPassword?: string;
|
||||
|
||||
constructor() {
|
||||
this.sapBaseUrl = process.env.SAP_BASE_URL || '';
|
||||
this.sapApiKey = process.env.SAP_API_KEY;
|
||||
this.sapUsername = process.env.SAP_USERNAME;
|
||||
this.sapPassword = process.env.SAP_PASSWORD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if SAP integration is configured
|
||||
*/
|
||||
private isConfigured(): boolean {
|
||||
return !!this.sapBaseUrl && (!!this.sapApiKey || (!!this.sapUsername && !!this.sapPassword));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate IO number and get IO details
|
||||
* @param ioNumber - IO (Internal Order) number from SAP
|
||||
* @returns IO details including available balance, blocked amount, etc.
|
||||
*/
|
||||
async validateIONumber(ioNumber: string): Promise<{
|
||||
isValid: boolean;
|
||||
ioNumber: string;
|
||||
availableBalance: number;
|
||||
blockedAmount: number;
|
||||
remainingBalance: number;
|
||||
currency: string;
|
||||
description?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
if (!this.isConfigured()) {
|
||||
logger.warn('[SAP] SAP integration not configured, returning mock data');
|
||||
// Return mock data for development/testing
|
||||
return {
|
||||
isValid: true,
|
||||
ioNumber,
|
||||
availableBalance: 1000000,
|
||||
blockedAmount: 0,
|
||||
remainingBalance: 1000000,
|
||||
currency: 'INR',
|
||||
description: 'Mock IO Data (SAP not configured)'
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Implement actual SAP API call
|
||||
// Example:
|
||||
// const response = await axios.get(`${this.sapBaseUrl}/api/io/${ioNumber}`, {
|
||||
// headers: {
|
||||
// 'Authorization': `Bearer ${this.sapApiKey}`,
|
||||
// 'Content-Type': 'application/json'
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// return {
|
||||
// isValid: response.data.valid,
|
||||
// ioNumber: response.data.io_number,
|
||||
// availableBalance: response.data.available_balance,
|
||||
// blockedAmount: response.data.blocked_amount,
|
||||
// remainingBalance: response.data.remaining_balance,
|
||||
// currency: response.data.currency,
|
||||
// description: response.data.description
|
||||
// };
|
||||
|
||||
logger.warn('[SAP] SAP API integration not implemented, returning mock data');
|
||||
return {
|
||||
isValid: true,
|
||||
ioNumber,
|
||||
availableBalance: 1000000,
|
||||
blockedAmount: 0,
|
||||
remainingBalance: 1000000,
|
||||
currency: 'INR',
|
||||
description: 'Mock IO Data (SAP API not implemented)'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[SAP] Error validating IO number:', error);
|
||||
return {
|
||||
isValid: false,
|
||||
ioNumber,
|
||||
availableBalance: 0,
|
||||
blockedAmount: 0,
|
||||
remainingBalance: 0,
|
||||
currency: 'INR',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block budget in SAP for a claim request
|
||||
* @param ioNumber - IO number
|
||||
* @param amount - Amount to block
|
||||
* @param requestNumber - Request number for reference
|
||||
* @param description - Description of the block
|
||||
* @returns Blocking confirmation details
|
||||
*/
|
||||
async blockBudget(
|
||||
ioNumber: string,
|
||||
amount: number,
|
||||
requestNumber: string,
|
||||
description?: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
blockId?: string;
|
||||
blockedAmount: number;
|
||||
remainingBalance: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
if (!this.isConfigured()) {
|
||||
logger.warn('[SAP] SAP integration not configured, budget blocking skipped');
|
||||
return {
|
||||
success: true,
|
||||
blockedAmount: amount,
|
||||
remainingBalance: 1000000 - amount,
|
||||
error: 'SAP not configured - budget blocking simulated'
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Implement actual SAP API call to block budget
|
||||
// Example:
|
||||
// const response = await axios.post(`${this.sapBaseUrl}/api/io/${ioNumber}/block`, {
|
||||
// amount,
|
||||
// reference: requestNumber,
|
||||
// description: description || `Budget block for request ${requestNumber}`
|
||||
// }, {
|
||||
// headers: {
|
||||
// 'Authorization': `Bearer ${this.sapApiKey}`,
|
||||
// 'Content-Type': 'application/json'
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// return {
|
||||
// success: response.data.success,
|
||||
// blockId: response.data.block_id,
|
||||
// blockedAmount: response.data.blocked_amount,
|
||||
// remainingBalance: response.data.remaining_balance
|
||||
// };
|
||||
|
||||
logger.warn('[SAP] SAP budget blocking not implemented, simulating block');
|
||||
return {
|
||||
success: true,
|
||||
blockId: `BLOCK-${Date.now()}`,
|
||||
blockedAmount: amount,
|
||||
remainingBalance: 1000000 - amount,
|
||||
error: 'SAP API not implemented - budget blocking simulated'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[SAP] Error blocking budget:', error);
|
||||
return {
|
||||
success: false,
|
||||
blockedAmount: 0,
|
||||
remainingBalance: 0,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release blocked budget in SAP
|
||||
* @param ioNumber - IO number
|
||||
* @param blockId - Block ID from previous block operation
|
||||
* @param requestNumber - Request number for reference
|
||||
* @returns Release confirmation
|
||||
*/
|
||||
async releaseBudget(
|
||||
ioNumber: string,
|
||||
blockId: string,
|
||||
requestNumber: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
releasedAmount: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
if (!this.isConfigured()) {
|
||||
logger.warn('[SAP] SAP integration not configured, budget release skipped');
|
||||
return {
|
||||
success: true,
|
||||
releasedAmount: 0,
|
||||
error: 'SAP not configured - budget release simulated'
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Implement actual SAP API call to release budget
|
||||
// Example:
|
||||
// const response = await axios.post(`${this.sapBaseUrl}/api/io/${ioNumber}/release`, {
|
||||
// block_id: blockId,
|
||||
// reference: requestNumber
|
||||
// }, {
|
||||
// headers: {
|
||||
// 'Authorization': `Bearer ${this.sapApiKey}`,
|
||||
// 'Content-Type': 'application/json'
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// return {
|
||||
// success: response.data.success,
|
||||
// releasedAmount: response.data.released_amount
|
||||
// };
|
||||
|
||||
logger.warn('[SAP] SAP budget release not implemented, simulating release');
|
||||
return {
|
||||
success: true,
|
||||
releasedAmount: 0,
|
||||
error: 'SAP API not implemented - budget release simulated'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[SAP] Error releasing budget:', error);
|
||||
return {
|
||||
success: false,
|
||||
releasedAmount: 0,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dealer information from SAP
|
||||
* @param dealerCode - Dealer code
|
||||
* @returns Dealer details from SAP
|
||||
*/
|
||||
async getDealerInfo(dealerCode: string): Promise<{
|
||||
isValid: boolean;
|
||||
dealerCode: string;
|
||||
dealerName?: string;
|
||||
dealerEmail?: string;
|
||||
dealerPhone?: string;
|
||||
dealerAddress?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
if (!this.isConfigured()) {
|
||||
logger.warn('[SAP] SAP integration not configured, returning mock dealer data');
|
||||
return {
|
||||
isValid: true,
|
||||
dealerCode,
|
||||
dealerName: `Dealer ${dealerCode}`,
|
||||
dealerEmail: `dealer${dealerCode}@example.com`,
|
||||
dealerPhone: '+91-XXXXXXXXXX',
|
||||
dealerAddress: 'Mock Address'
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Implement actual SAP API call to get dealer info
|
||||
// Example:
|
||||
// const response = await axios.get(`${this.sapBaseUrl}/api/dealers/${dealerCode}`, {
|
||||
// headers: {
|
||||
// 'Authorization': `Bearer ${this.sapApiKey}`,
|
||||
// 'Content-Type': 'application/json'
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// return {
|
||||
// isValid: response.data.valid,
|
||||
// dealerCode: response.data.dealer_code,
|
||||
// dealerName: response.data.dealer_name,
|
||||
// dealerEmail: response.data.dealer_email,
|
||||
// dealerPhone: response.data.dealer_phone,
|
||||
// dealerAddress: response.data.dealer_address
|
||||
// };
|
||||
|
||||
logger.warn('[SAP] SAP dealer lookup not implemented, returning mock data');
|
||||
return {
|
||||
isValid: true,
|
||||
dealerCode,
|
||||
dealerName: `Dealer ${dealerCode}`,
|
||||
dealerEmail: `dealer${dealerCode}@example.com`,
|
||||
dealerPhone: '+91-XXXXXXXXXX',
|
||||
dealerAddress: 'Mock Address'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[SAP] Error getting dealer info:', error);
|
||||
return {
|
||||
isValid: false,
|
||||
dealerCode,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const sapIntegrationService = new SAPIntegrationService();
|
||||
|
||||
246
src/services/template.service.ts
Normal file
246
src/services/template.service.ts
Normal file
@ -0,0 +1,246 @@
|
||||
import { WorkflowTemplate } from '../models/WorkflowTemplate';
|
||||
import { WorkflowRequest } from '../models/WorkflowRequest';
|
||||
import { User } from '../models/User';
|
||||
import { Op } from 'sequelize';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Template Service
|
||||
* Handles CRUD operations for workflow templates
|
||||
*/
|
||||
export class TemplateService {
|
||||
/**
|
||||
* Create a new workflow template
|
||||
*/
|
||||
async createTemplate(
|
||||
userId: string,
|
||||
templateData: {
|
||||
templateName: string;
|
||||
templateCode?: string;
|
||||
templateDescription?: string;
|
||||
templateCategory?: string;
|
||||
workflowType?: string;
|
||||
approvalLevelsConfig?: any;
|
||||
defaultTatHours?: number;
|
||||
formStepsConfig?: any;
|
||||
userFieldMappings?: any;
|
||||
dynamicApproverConfig?: any;
|
||||
isActive?: boolean;
|
||||
}
|
||||
): Promise<WorkflowTemplate> {
|
||||
try {
|
||||
// Validate template code uniqueness if provided
|
||||
if (templateData.templateCode) {
|
||||
const existing = await WorkflowTemplate.findOne({
|
||||
where: { templateCode: templateData.templateCode }
|
||||
});
|
||||
if (existing) {
|
||||
throw new Error(`Template code '${templateData.templateCode}' already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const template = await WorkflowTemplate.create({
|
||||
templateName: templateData.templateName,
|
||||
templateCode: templateData.templateCode,
|
||||
templateDescription: templateData.templateDescription,
|
||||
templateCategory: templateData.templateCategory,
|
||||
workflowType: templateData.workflowType || templateData.templateCode?.toUpperCase(),
|
||||
approvalLevelsConfig: templateData.approvalLevelsConfig,
|
||||
defaultTatHours: templateData.defaultTatHours || 24,
|
||||
formStepsConfig: templateData.formStepsConfig,
|
||||
userFieldMappings: templateData.userFieldMappings,
|
||||
dynamicApproverConfig: templateData.dynamicApproverConfig,
|
||||
isActive: templateData.isActive !== undefined ? templateData.isActive : true,
|
||||
isSystemTemplate: false, // Admin-created templates are not system templates
|
||||
usageCount: 0,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
logger.info(`[TemplateService] Created template: ${template.templateId}`);
|
||||
return template;
|
||||
} catch (error) {
|
||||
logger.error('[TemplateService] Error creating template:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get template by ID
|
||||
*/
|
||||
async getTemplate(templateId: string): Promise<WorkflowTemplate | null> {
|
||||
try {
|
||||
return await WorkflowTemplate.findByPk(templateId, {
|
||||
include: [{ model: User, as: 'creator' }]
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[TemplateService] Error getting template:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get template by code
|
||||
*/
|
||||
async getTemplateByCode(templateCode: string): Promise<WorkflowTemplate | null> {
|
||||
try {
|
||||
return await WorkflowTemplate.findOne({
|
||||
where: { templateCode },
|
||||
include: [{ model: User, as: 'creator' }]
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[TemplateService] Error getting template by code:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all templates with filters
|
||||
*/
|
||||
async listTemplates(filters?: {
|
||||
category?: string;
|
||||
workflowType?: string;
|
||||
isActive?: boolean;
|
||||
isSystemTemplate?: boolean;
|
||||
search?: string;
|
||||
}): Promise<WorkflowTemplate[]> {
|
||||
try {
|
||||
const where: any = {};
|
||||
|
||||
if (filters?.category) {
|
||||
where.templateCategory = filters.category;
|
||||
}
|
||||
|
||||
if (filters?.workflowType) {
|
||||
where.workflowType = filters.workflowType;
|
||||
}
|
||||
|
||||
if (filters?.isActive !== undefined) {
|
||||
where.isActive = filters.isActive;
|
||||
}
|
||||
|
||||
if (filters?.isSystemTemplate !== undefined) {
|
||||
where.isSystemTemplate = filters.isSystemTemplate;
|
||||
}
|
||||
|
||||
if (filters?.search) {
|
||||
where[Op.or] = [
|
||||
{ templateName: { [Op.iLike]: `%${filters.search}%` } },
|
||||
{ templateCode: { [Op.iLike]: `%${filters.search}%` } },
|
||||
{ templateDescription: { [Op.iLike]: `%${filters.search}%` } }
|
||||
];
|
||||
}
|
||||
|
||||
return await WorkflowTemplate.findAll({
|
||||
where,
|
||||
include: [{ model: User, as: 'creator' }],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[TemplateService] Error listing templates:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update template
|
||||
*/
|
||||
async updateTemplate(
|
||||
templateId: string,
|
||||
userId: string,
|
||||
updateData: {
|
||||
templateName?: string;
|
||||
templateDescription?: string;
|
||||
templateCategory?: string;
|
||||
approvalLevelsConfig?: any;
|
||||
defaultTatHours?: number;
|
||||
formStepsConfig?: any;
|
||||
userFieldMappings?: any;
|
||||
dynamicApproverConfig?: any;
|
||||
isActive?: boolean;
|
||||
}
|
||||
): Promise<WorkflowTemplate> {
|
||||
try {
|
||||
const template = await WorkflowTemplate.findByPk(templateId);
|
||||
if (!template) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
// Check if template is system template (system templates should not be modified)
|
||||
if (template.isSystemTemplate && updateData.approvalLevelsConfig) {
|
||||
throw new Error('Cannot modify approval levels of system templates');
|
||||
}
|
||||
|
||||
await template.update(updateData);
|
||||
|
||||
logger.info(`[TemplateService] Updated template: ${templateId}`);
|
||||
return template;
|
||||
} catch (error) {
|
||||
logger.error('[TemplateService] Error updating template:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete template (soft delete by setting isActive to false)
|
||||
*/
|
||||
async deleteTemplate(templateId: string): Promise<void> {
|
||||
try {
|
||||
const template = await WorkflowTemplate.findByPk(templateId);
|
||||
if (!template) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
// Check if template is in use
|
||||
const usageCount = await WorkflowRequest.count({
|
||||
where: { templateId }
|
||||
});
|
||||
|
||||
if (usageCount > 0) {
|
||||
throw new Error(`Cannot delete template: ${usageCount} request(s) are using this template`);
|
||||
}
|
||||
|
||||
// System templates cannot be deleted
|
||||
if (template.isSystemTemplate) {
|
||||
throw new Error('Cannot delete system templates');
|
||||
}
|
||||
|
||||
// Soft delete by deactivating
|
||||
await template.update({ isActive: false });
|
||||
|
||||
logger.info(`[TemplateService] Deleted (deactivated) template: ${templateId}`);
|
||||
} catch (error) {
|
||||
logger.error('[TemplateService] Error deleting template:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active templates for workflow creation
|
||||
*/
|
||||
async getActiveTemplates(): Promise<WorkflowTemplate[]> {
|
||||
try {
|
||||
return await WorkflowTemplate.findAll({
|
||||
where: { isActive: true },
|
||||
order: [['templateName', 'ASC']]
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[TemplateService] Error getting active templates:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment usage count when template is used
|
||||
*/
|
||||
async incrementUsageCount(templateId: string): Promise<void> {
|
||||
try {
|
||||
await WorkflowTemplate.increment('usageCount', {
|
||||
where: { templateId }
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[TemplateService] Error incrementing usage count:', error);
|
||||
// Don't throw - this is not critical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
287
src/services/templateFieldResolver.service.ts
Normal file
287
src/services/templateFieldResolver.service.ts
Normal file
@ -0,0 +1,287 @@
|
||||
import { WorkflowRequest } from '../models/WorkflowRequest';
|
||||
import { ApprovalLevel } from '../models/ApprovalLevel';
|
||||
import { User } from '../models/User';
|
||||
import { Participant } from '../models/Participant';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Interface for user reference configuration in form fields
|
||||
*/
|
||||
export interface UserReference {
|
||||
role: 'initiator' | 'dealer' | 'approver' | 'team_lead' | 'department_lead' | 'current_approver' | 'previous_approver';
|
||||
level?: number; // For approver: which approval level
|
||||
field: 'name' | 'email' | 'phone' | 'department' | 'employee_id' | 'all'; // Which user field to reference
|
||||
autoPopulate: boolean; // Auto-fill from user data
|
||||
editable: boolean; // Can user edit the auto-populated value
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for form step configuration
|
||||
*/
|
||||
export interface FormStepConfig {
|
||||
stepNumber: number;
|
||||
stepName: string;
|
||||
stepDescription?: string;
|
||||
fields: FormFieldConfig[];
|
||||
userReferences?: UserReferenceConfig[];
|
||||
}
|
||||
|
||||
export interface FormFieldConfig {
|
||||
fieldId: string;
|
||||
fieldType: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
defaultValue?: any;
|
||||
userReference?: UserReference;
|
||||
}
|
||||
|
||||
export interface UserReferenceConfig {
|
||||
role: string;
|
||||
captureFields: string[];
|
||||
autoPopulateFrom: 'workflow' | 'user_profile' | 'approval_level';
|
||||
allowOverride: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service to resolve user references in template forms
|
||||
*/
|
||||
export class TemplateFieldResolver {
|
||||
/**
|
||||
* Resolve user reference fields in a step
|
||||
*/
|
||||
async resolveUserReferences(
|
||||
stepConfig: FormStepConfig,
|
||||
request: WorkflowRequest,
|
||||
currentUserId: string,
|
||||
context?: {
|
||||
currentLevel?: number;
|
||||
approvers?: Map<number, ApprovalLevel>;
|
||||
}
|
||||
): Promise<Record<string, any>> {
|
||||
const resolvedFields: Record<string, any> = {};
|
||||
|
||||
try {
|
||||
for (const field of stepConfig.fields) {
|
||||
if (field.userReference) {
|
||||
const userData = await this.getUserDataForReference(
|
||||
field.userReference,
|
||||
request,
|
||||
currentUserId,
|
||||
context
|
||||
);
|
||||
|
||||
if (field.userReference.autoPopulate && userData) {
|
||||
resolvedFields[field.fieldId] = this.extractUserField(
|
||||
userData,
|
||||
field.userReference.field
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[TemplateFieldResolver] Error resolving user references:', error);
|
||||
}
|
||||
|
||||
return resolvedFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user data based on reference configuration
|
||||
*/
|
||||
private async getUserDataForReference(
|
||||
userRef: UserReference,
|
||||
request: WorkflowRequest,
|
||||
currentUserId: string,
|
||||
context?: any
|
||||
): Promise<User | null> {
|
||||
try {
|
||||
switch (userRef.role) {
|
||||
case 'initiator':
|
||||
return await User.findByPk(request.initiatorId);
|
||||
|
||||
case 'dealer':
|
||||
// Get dealer from participants
|
||||
const dealerParticipant = await Participant.findOne({
|
||||
where: {
|
||||
requestId: request.requestId,
|
||||
participantType: 'DEALER' as any,
|
||||
isActive: true
|
||||
},
|
||||
include: [{ model: User, as: 'user' }]
|
||||
});
|
||||
return dealerParticipant?.user || null;
|
||||
|
||||
case 'approver':
|
||||
if (userRef.level && context?.approvers) {
|
||||
const approverLevel = context.approvers.get(userRef.level);
|
||||
if (approverLevel?.approverId) {
|
||||
return await User.findByPk(approverLevel.approverId);
|
||||
}
|
||||
}
|
||||
// Fallback to current approver
|
||||
const currentLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId: request.requestId,
|
||||
levelNumber: context?.currentLevel || request.currentLevel,
|
||||
status: 'PENDING' as any
|
||||
}
|
||||
});
|
||||
if (currentLevel?.approverId) {
|
||||
return await User.findByPk(currentLevel.approverId);
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'team_lead':
|
||||
// Find team lead based on initiator's manager
|
||||
const initiator = await User.findByPk(request.initiatorId);
|
||||
if (initiator?.manager) {
|
||||
return await User.findOne({
|
||||
where: {
|
||||
email: initiator.manager,
|
||||
role: 'MANAGEMENT' as any
|
||||
}
|
||||
});
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'department_lead':
|
||||
const initiatorUser = await User.findByPk(request.initiatorId);
|
||||
if (initiatorUser?.department) {
|
||||
return await User.findOne({
|
||||
where: {
|
||||
department: initiatorUser.department,
|
||||
role: 'MANAGEMENT' as any
|
||||
},
|
||||
order: [['created_at', 'DESC']]
|
||||
});
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'current_approver':
|
||||
const currentApprovalLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId: request.requestId,
|
||||
status: 'PENDING' as any
|
||||
},
|
||||
order: [['level_number', 'ASC']]
|
||||
});
|
||||
if (currentApprovalLevel?.approverId) {
|
||||
return await User.findByPk(currentApprovalLevel.approverId);
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'previous_approver':
|
||||
const previousLevel = request.currentLevel - 1;
|
||||
if (previousLevel > 0) {
|
||||
const previousApprovalLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId: request.requestId,
|
||||
levelNumber: previousLevel
|
||||
}
|
||||
});
|
||||
if (previousApprovalLevel?.approverId) {
|
||||
return await User.findByPk(previousApprovalLevel.approverId);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[TemplateFieldResolver] Error getting user data for role ${userRef.role}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract specific field from user data
|
||||
*/
|
||||
private extractUserField(user: User, field: string): any {
|
||||
if (!user) return null;
|
||||
|
||||
switch (field) {
|
||||
case 'name':
|
||||
return user.displayName || `${user.firstName || ''} ${user.lastName || ''}`.trim();
|
||||
case 'email':
|
||||
return user.email;
|
||||
case 'phone':
|
||||
return user.phone || user.mobilePhone;
|
||||
case 'department':
|
||||
return user.department;
|
||||
case 'employee_id':
|
||||
return user.employeeId;
|
||||
case 'all':
|
||||
return {
|
||||
name: user.displayName || `${user.firstName || ''} ${user.lastName || ''}`.trim(),
|
||||
email: user.email,
|
||||
phone: user.phone || user.mobilePhone,
|
||||
department: user.department,
|
||||
employeeId: user.employeeId
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve dynamic approver based on configuration
|
||||
*/
|
||||
async resolveDynamicApprover(
|
||||
level: number,
|
||||
config: any, // DynamicApproverConfig
|
||||
request: WorkflowRequest
|
||||
): Promise<User | null> {
|
||||
if (!config?.enabled || !config?.approverSelection?.dynamicRules) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const rule = config.approverSelection.dynamicRules.find((r: any) => r.level === level);
|
||||
if (!rule) return null;
|
||||
|
||||
const criteria = rule.selectionCriteria;
|
||||
|
||||
switch (criteria.type) {
|
||||
case 'role':
|
||||
return await User.findOne({
|
||||
where: {
|
||||
role: criteria.value as any
|
||||
},
|
||||
order: [['created_at', 'DESC']]
|
||||
});
|
||||
|
||||
case 'department':
|
||||
const initiator = await User.findByPk(request.initiatorId);
|
||||
const deptValue = criteria.value?.replace('${initiator.department}', initiator?.department || '') || initiator?.department;
|
||||
if (deptValue) {
|
||||
return await User.findOne({
|
||||
where: {
|
||||
department: deptValue,
|
||||
role: 'MANAGEMENT' as any
|
||||
}
|
||||
});
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'manager':
|
||||
const initiatorUser = await User.findByPk(request.initiatorId);
|
||||
if (initiatorUser?.manager) {
|
||||
return await User.findOne({
|
||||
where: {
|
||||
email: initiatorUser.manager
|
||||
}
|
||||
});
|
||||
}
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[TemplateFieldResolver] Error resolving dynamic approver:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { User as UserModel } from '../models/User';
|
||||
import { Op } from 'sequelize';
|
||||
import { SSOUserData } from '../types/auth.types'; // Use shared type
|
||||
import axios from 'axios';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
// Using UserModel type directly - interface removed to avoid duplication
|
||||
|
||||
@ -16,10 +17,83 @@ interface OktaUser {
|
||||
login: string;
|
||||
department?: 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 {
|
||||
/**
|
||||
* 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> {
|
||||
// Validate required fields
|
||||
if (!ssoData.email || !ssoData.oktaSub) {
|
||||
@ -36,44 +110,18 @@ export class UserService {
|
||||
}
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (existingUser) {
|
||||
// Update existing user - include oktaSub to ensure it's synced
|
||||
await existingUser.update({
|
||||
email: ssoData.email,
|
||||
oktaSub: ssoData.oktaSub,
|
||||
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
|
||||
});
|
||||
// Update existing user - DO NOT update email (crucial identifier)
|
||||
const updatePayload = this.buildUserPayload(ssoData, existingUser.role, true); // isUpdate = true
|
||||
|
||||
await existingUser.update(updatePayload);
|
||||
|
||||
return existingUser;
|
||||
} else {
|
||||
// Create new user - oktaSub is required
|
||||
const newUser = await UserModel.create({
|
||||
email: ssoData.email,
|
||||
oktaSub: ssoData.oktaSub, // Required
|
||||
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
|
||||
});
|
||||
// Create new user - oktaSub is required, email is included
|
||||
const createPayload = this.buildUserPayload(ssoData, 'USER', false); // isUpdate = false
|
||||
|
||||
const newUser = await UserModel.create(createPayload);
|
||||
|
||||
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 {
|
||||
const oktaDomain = process.env.OKTA_DOMAIN;
|
||||
const oktaApiToken = process.env.OKTA_API_TOKEN;
|
||||
@ -232,7 +281,25 @@ export class UserService {
|
||||
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`, {
|
||||
params: { search: `profile.email eq "${email}"`, limit: 1 },
|
||||
headers: {
|
||||
@ -242,14 +309,81 @@ export class UserService {
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
const users: OktaUser[] = response.data || [];
|
||||
return users.length > 0 ? users[0] : null;
|
||||
const users: any[] = response.data || [];
|
||||
if (users.length > 0) {
|
||||
return extractOktaUserData(users[0]);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to fetch user from Okta by email ${email}:`, error.message);
|
||||
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)
|
||||
* Used when tagging users from Okta search results or when only email is provided
|
||||
|
||||
@ -9,6 +9,14 @@ export interface SSOUserData {
|
||||
designation?: string;
|
||||
phone?: 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 {
|
||||
@ -20,6 +28,7 @@ export interface SSOConfig {
|
||||
oktaDomain: string;
|
||||
oktaClientId: string;
|
||||
oktaClientSecret: string;
|
||||
oktaApiToken?: string; // Optional - SSWS token for Okta Users API
|
||||
}
|
||||
|
||||
export interface AuthTokens {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user