sap implementsion for internal order and budget block
This commit is contained in:
parent
c97e5df689
commit
4de9cddb6b
214
docs/SAP_INTEGRATION_TESTING.md
Normal file
214
docs/SAP_INTEGRATION_TESTING.md
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
# SAP Integration Testing Guide
|
||||||
|
|
||||||
|
## Postman Testing
|
||||||
|
|
||||||
|
### 1. Testing IO Validation API
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/v1/dealer-claims/:requestId/io`
|
||||||
|
|
||||||
|
**Method:** GET
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer <your_jwt_token>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** The CSRF error in Postman is likely coming from SAP, not our backend. Our backend doesn't have CSRF protection enabled.
|
||||||
|
|
||||||
|
### 2. Testing Budget Blocking API
|
||||||
|
|
||||||
|
**Endpoint:** `PUT /api/v1/dealer-claims/:requestId/io`
|
||||||
|
|
||||||
|
**Method:** PUT
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer <your_jwt_token>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ioNumber": "600060",
|
||||||
|
"ioRemark": "Test remark",
|
||||||
|
"availableBalance": 1000000,
|
||||||
|
"blockedAmount": 500,
|
||||||
|
"remainingBalance": 999500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Direct SAP API Testing in Postman
|
||||||
|
|
||||||
|
If you want to test SAP API directly (bypassing our backend):
|
||||||
|
|
||||||
|
#### IO Validation
|
||||||
|
- **URL:** `https://RENOIHND01.Eichergroup.com:1443/sap/opu/odata/sap/ZFI_BUDGET_CHECK_API_SRV/GetSenderDataSet?$filter=IONumber eq '600060'&$select=Sender,ResponseDate,GetIODetailsSet01&$expand=GetIODetailsSet01&$format=json`
|
||||||
|
- **Method:** GET
|
||||||
|
- **Authentication:** Basic Auth
|
||||||
|
- Username: Your SAP username
|
||||||
|
- Password: Your SAP password
|
||||||
|
- **Headers:**
|
||||||
|
- `Accept: application/json`
|
||||||
|
- `Content-Type: application/json`
|
||||||
|
|
||||||
|
#### Budget Blocking
|
||||||
|
- **URL:** `https://RENOIHND01.Eichergroup.com:1443/sap/opu/odata/sap/ZFI_BUDGET_BLOCK_API_SRV/RequesterInputSet`
|
||||||
|
- **Method:** POST
|
||||||
|
- **Authentication:** Basic Auth
|
||||||
|
- Username: Your SAP username
|
||||||
|
- Password: Your SAP password
|
||||||
|
- **Headers:**
|
||||||
|
- `Accept: application/json`
|
||||||
|
- `Content-Type: application/json`
|
||||||
|
- **Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Request_Date_Time": "2025-08-29T10:51:00",
|
||||||
|
"Requester": "REFMS",
|
||||||
|
"lt_io_input": [
|
||||||
|
{
|
||||||
|
"IONumber": "600060",
|
||||||
|
"Amount": "500"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lt_io_output": [],
|
||||||
|
"ls_response": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Errors and Solutions
|
||||||
|
|
||||||
|
### 1. CSRF Token Validation Error
|
||||||
|
|
||||||
|
**Error:** "CSRF token validation error"
|
||||||
|
|
||||||
|
**Possible Causes:**
|
||||||
|
- SAP API requires CSRF tokens for POST/PUT requests
|
||||||
|
- SAP might be checking for specific headers
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. **Get CSRF Token First:**
|
||||||
|
- Make a GET request to the SAP service root to get CSRF token
|
||||||
|
- Example: `GET https://RENOIHND01.Eichergroup.com:1443/sap/opu/odata/sap/ZFI_BUDGET_BLOCK_API_SRV/`
|
||||||
|
- Look for `x-csrf-token` header in response
|
||||||
|
- Add this token to subsequent POST/PUT requests as header: `X-CSRF-Token: <token>`
|
||||||
|
|
||||||
|
2. **Add Required Headers:**
|
||||||
|
```
|
||||||
|
X-CSRF-Token: Fetch
|
||||||
|
X-Requested-With: XMLHttpRequest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Authentication Failed
|
||||||
|
|
||||||
|
**Error:** "Authentication failed" or "401 Unauthorized"
|
||||||
|
|
||||||
|
**Possible Causes:**
|
||||||
|
1. Wrong username/password
|
||||||
|
2. Basic auth not being sent correctly
|
||||||
|
3. SSL certificate issues
|
||||||
|
4. SAP account locked or expired
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
|
||||||
|
1. **Verify Credentials:**
|
||||||
|
- Double-check `SAP_USERNAME` and `SAP_PASSWORD` in `.env`
|
||||||
|
- Ensure no extra spaces or special characters
|
||||||
|
- Test credentials in browser first
|
||||||
|
|
||||||
|
2. **Check SSL Certificate:**
|
||||||
|
- If using self-signed certificate, set `SAP_DISABLE_SSL_VERIFY=true` in `.env` (testing only!)
|
||||||
|
- For production, ensure proper SSL certificates are configured
|
||||||
|
|
||||||
|
3. **Test Basic Auth Manually:**
|
||||||
|
- Use Postman with Basic Auth enabled
|
||||||
|
- Verify the Authorization header format: `Basic <base64(username:password)>`
|
||||||
|
|
||||||
|
4. **Check SAP Account Status:**
|
||||||
|
- Verify account is active and not locked
|
||||||
|
- Check if password has expired
|
||||||
|
- Contact SAP administrator if needed
|
||||||
|
|
||||||
|
### 3. Connection Errors
|
||||||
|
|
||||||
|
**Error:** "ECONNREFUSED" or "ENOTFOUND"
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Verify `SAP_BASE_URL` is correct
|
||||||
|
2. Check network connectivity to SAP server
|
||||||
|
3. Ensure firewall allows connections to port 1443
|
||||||
|
4. Verify Zscaler is configured correctly
|
||||||
|
|
||||||
|
### 4. Timeout Errors
|
||||||
|
|
||||||
|
**Error:** "Request timeout"
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Increase `SAP_TIMEOUT_MS` in `.env` (default: 30000ms = 30 seconds)
|
||||||
|
2. Check SAP server response time
|
||||||
|
3. Verify network latency
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Enable Debug Logging
|
||||||
|
|
||||||
|
Set log level to debug in your `.env`:
|
||||||
|
```
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
```
|
||||||
|
|
||||||
|
This will log:
|
||||||
|
- Request URLs
|
||||||
|
- Request payloads
|
||||||
|
- Response status codes
|
||||||
|
- Response data
|
||||||
|
- Error details
|
||||||
|
|
||||||
|
### Check Backend Logs
|
||||||
|
|
||||||
|
Look for `[SAP]` prefixed log messages:
|
||||||
|
```bash
|
||||||
|
# In development
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Check logs for SAP-related messages
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test SAP Connection
|
||||||
|
|
||||||
|
You can test if SAP is reachable:
|
||||||
|
```bash
|
||||||
|
curl -u "username:password" \
|
||||||
|
"https://RENOIHND01.Eichergroup.com:1443/sap/opu/odata/sap/ZFI_BUDGET_CHECK_API_SRV/"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables Checklist
|
||||||
|
|
||||||
|
Ensure these are set in your `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required
|
||||||
|
SAP_BASE_URL=https://RENOIHND01.Eichergroup.com:1443
|
||||||
|
SAP_USERNAME=your_username
|
||||||
|
SAP_PASSWORD=your_password
|
||||||
|
|
||||||
|
# Optional (with defaults)
|
||||||
|
SAP_TIMEOUT_MS=30000
|
||||||
|
SAP_SERVICE_NAME=ZFI_BUDGET_CHECK_API_SRV
|
||||||
|
SAP_BLOCK_SERVICE_NAME=ZFI_BUDGET_BLOCK_API_SRV
|
||||||
|
SAP_REQUESTER=REFMS
|
||||||
|
SAP_DISABLE_SSL_VERIFY=false # Only for testing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
If you're still getting errors:
|
||||||
|
|
||||||
|
1. **Check Backend Logs:** Look for detailed error messages
|
||||||
|
2. **Test Directly in Postman:** Bypass backend and test SAP API directly
|
||||||
|
3. **Verify SAP Credentials:** Test with SAP administrator
|
||||||
|
4. **Check Network:** Ensure server can reach SAP URL
|
||||||
|
5. **Review SAP Documentation:** Check if there are additional requirements
|
||||||
|
|
||||||
15
env.example
15
env.example
@ -84,3 +84,18 @@ VAPID_CONTACT=mailto:you@example.com
|
|||||||
REDIS_URL={{REDIS_URL_FOR DELAY JoBS create redis setup and add url here}}
|
REDIS_URL={{REDIS_URL_FOR DELAY JoBS create redis setup and add url here}}
|
||||||
TAT_TEST_MODE=false (on true it will consider 1 hour==1min)
|
TAT_TEST_MODE=false (on true it will consider 1 hour==1min)
|
||||||
|
|
||||||
|
# SAP Integration (OData Service via Zscaler)
|
||||||
|
SAP_BASE_URL=https://RENOIHND01.Eichergroup.com:1443
|
||||||
|
SAP_USERNAME={{SAP_USERNAME}}
|
||||||
|
SAP_PASSWORD={{SAP_PASSWORD}}
|
||||||
|
SAP_TIMEOUT_MS=30000
|
||||||
|
# SAP OData Service Name for IO Validation (default: ZFI_BUDGET_CHECK_API_SRV)
|
||||||
|
SAP_SERVICE_NAME=ZFI_BUDGET_CHECK_API_SRV
|
||||||
|
# SAP OData Service Name for Budget Blocking (default: ZFI_BUDGET_BLOCK_API_SRV)
|
||||||
|
SAP_BLOCK_SERVICE_NAME=ZFI_BUDGET_BLOCK_API_SRV
|
||||||
|
# SAP Requester identifier for budget blocking API (default: REFMS)
|
||||||
|
SAP_REQUESTER=REFMS
|
||||||
|
# SAP SSL Verification (set to 'true' to disable SSL verification for testing with self-signed certs)
|
||||||
|
# WARNING: Only use in development/testing environments
|
||||||
|
SAP_DISABLE_SSL_VERIFY=false
|
||||||
|
|
||||||
|
|||||||
57
package-lock.json
generated
57
package-lock.json
generated
@ -21,6 +21,7 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
|
"fast-xml-parser": "^5.3.3",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"ioredis": "^5.8.2",
|
"ioredis": "^5.8.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@ -793,18 +794,6 @@
|
|||||||
"fxparser": "src/cli/cli.js"
|
"fxparser": "src/cli/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/xml-builder/node_modules/strnum": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@aws/lambda-invoke-store": {
|
"node_modules/@aws/lambda-invoke-store": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz",
|
||||||
@ -1651,6 +1640,36 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@google-cloud/storage/node_modules/fast-xml-parser": {
|
||||||
|
"version": "4.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
|
||||||
|
"integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"strnum": "^1.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"fxparser": "src/cli/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@google-cloud/storage/node_modules/strnum": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@google-cloud/vertexai": {
|
"node_modules/@google-cloud/vertexai": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@google-cloud/vertexai/-/vertexai-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@google-cloud/vertexai/-/vertexai-1.10.0.tgz",
|
||||||
@ -6346,9 +6365,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-parser": {
|
"node_modules/fast-xml-parser": {
|
||||||
"version": "4.5.3",
|
"version": "5.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.3.tgz",
|
||||||
"integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
|
"integrity": "sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@ -6357,7 +6376,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"strnum": "^1.1.1"
|
"strnum": "^2.1.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"fxparser": "src/cli/cli.js"
|
"fxparser": "src/cli/cli.js"
|
||||||
@ -10791,9 +10810,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strnum": {
|
"node_modules/strnum": {
|
||||||
"version": "1.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
||||||
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
|
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|||||||
@ -35,6 +35,7 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
|
"fast-xml-parser": "^5.3.3",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"ioredis": "^5.8.2",
|
"ioredis": "^5.8.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { ResponseHandler } from '../utils/responseHandler';
|
|||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import { gcsStorageService } from '../services/gcsStorage.service';
|
import { gcsStorageService } from '../services/gcsStorage.service';
|
||||||
import { Document } from '../models/Document';
|
import { Document } from '../models/Document';
|
||||||
|
import { InternalOrder } from '../models/InternalOrder';
|
||||||
import { constants } from '../config/constants';
|
import { constants } from '../config/constants';
|
||||||
import { sapIntegrationService } from '../services/sapIntegration.service';
|
import { sapIntegrationService } from '../services/sapIntegration.service';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@ -653,6 +654,8 @@ export class DealerClaimController {
|
|||||||
return ResponseHandler.error(res, 'Available balance is required when blocking amount', 400);
|
return ResponseHandler.error(res, 'Available balance is required when blocking amount', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't pass remainingBalance - let the service calculate it from SAP's response
|
||||||
|
// This ensures we always use the actual remaining balance from SAP after blocking
|
||||||
await this.dealerClaimService.updateIODetails(
|
await this.dealerClaimService.updateIODetails(
|
||||||
requestId,
|
requestId,
|
||||||
{
|
{
|
||||||
@ -660,23 +663,43 @@ export class DealerClaimController {
|
|||||||
ioRemark: ioRemark || '',
|
ioRemark: ioRemark || '',
|
||||||
availableBalance: parseFloat(availableBalance),
|
availableBalance: parseFloat(availableBalance),
|
||||||
blockedAmount: blockAmount,
|
blockedAmount: blockAmount,
|
||||||
remainingBalance: remainingBalance ? parseFloat(remainingBalance) : parseFloat(availableBalance) - blockAmount,
|
// remainingBalance will be calculated by the service from SAP's response
|
||||||
},
|
},
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fetch and return the updated IO details from database
|
||||||
|
const updatedIO = await InternalOrder.findOne({ where: { requestId } });
|
||||||
|
|
||||||
|
if (updatedIO) {
|
||||||
|
return ResponseHandler.success(res, {
|
||||||
|
message: 'IO blocked successfully in SAP',
|
||||||
|
ioDetails: {
|
||||||
|
ioNumber: updatedIO.ioNumber,
|
||||||
|
ioAvailableBalance: updatedIO.ioAvailableBalance,
|
||||||
|
ioBlockedAmount: updatedIO.ioBlockedAmount,
|
||||||
|
ioRemainingBalance: updatedIO.ioRemainingBalance,
|
||||||
|
ioRemark: updatedIO.ioRemark,
|
||||||
|
status: updatedIO.status,
|
||||||
|
}
|
||||||
|
}, 'IO blocked');
|
||||||
|
}
|
||||||
|
|
||||||
return ResponseHandler.success(res, { message: 'IO blocked successfully in SAP' }, 'IO blocked');
|
return ResponseHandler.success(res, { message: 'IO blocked successfully in SAP' }, 'IO blocked');
|
||||||
} else if (ioNumber && ioRemark !== undefined) {
|
} else if (ioNumber && ioRemark !== undefined) {
|
||||||
// Save IO details (ioNumber, ioRemark) even without blocking amount
|
// Save IO details (ioNumber, ioRemark) even without blocking amount
|
||||||
// This is useful when Step 3 is approved but amount hasn't been blocked yet
|
// This is useful when Step 3 is approved but amount hasn't been blocked yet
|
||||||
|
// IMPORTANT: Don't pass balance fields to preserve existing values from previous blocking
|
||||||
await this.dealerClaimService.updateIODetails(
|
await this.dealerClaimService.updateIODetails(
|
||||||
requestId,
|
requestId,
|
||||||
{
|
{
|
||||||
ioNumber,
|
ioNumber,
|
||||||
ioRemark: ioRemark || '',
|
ioRemark: ioRemark || '',
|
||||||
availableBalance: availableBalance ? parseFloat(availableBalance) : 0,
|
// Don't pass balance fields - preserve existing values from previous blocking
|
||||||
|
// Only pass if explicitly provided and > 0 (for new records)
|
||||||
|
...(availableBalance && parseFloat(availableBalance) > 0 && { availableBalance: parseFloat(availableBalance) }),
|
||||||
blockedAmount: 0,
|
blockedAmount: 0,
|
||||||
remainingBalance: remainingBalance ? parseFloat(remainingBalance) : 0,
|
// Don't pass remainingBalance - preserve existing value from previous blocking
|
||||||
},
|
},
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
|
|||||||
@ -480,16 +480,30 @@ export class ApprovalService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Log assignment activity for next level (when it becomes active)
|
// Log assignment activity for next level (when it becomes active)
|
||||||
// Skip notifications and assignment logging for system/auto-steps
|
// IMPORTANT: Skip notifications and assignment logging for system/auto-steps
|
||||||
|
// System steps are: Activity Creation (Step 4), E-Invoice Generation (Step 7), and any step with system@royalenfield.com
|
||||||
|
// These steps are processed automatically and should NOT trigger notifications
|
||||||
if (!isAutoStep && (nextLevel as any).approverId && (nextLevel as any).approverId !== 'system') {
|
if (!isAutoStep && (nextLevel as any).approverId && (nextLevel as any).approverId !== 'system') {
|
||||||
// Additional check: ensure approverEmail is not a system email
|
// Additional checks: ensure approverEmail and approverName are not system-related
|
||||||
|
// This prevents notifications to system accounts even if they pass other checks
|
||||||
const approverEmail = (nextLevel as any).approverEmail || '';
|
const approverEmail = (nextLevel as any).approverEmail || '';
|
||||||
|
const approverName = (nextLevel as any).approverName || '';
|
||||||
const isSystemEmail = approverEmail.toLowerCase() === 'system@royalenfield.com'
|
const isSystemEmail = approverEmail.toLowerCase() === 'system@royalenfield.com'
|
||||||
|| approverEmail.toLowerCase().includes('system');
|
|| approverEmail.toLowerCase().includes('system');
|
||||||
|
const isSystemName = approverName.toLowerCase() === 'system auto-process'
|
||||||
|
|| approverName.toLowerCase().includes('system');
|
||||||
|
|
||||||
if (!isSystemEmail) {
|
// EXCLUDE all system-related steps from notifications
|
||||||
|
// Only send notifications to real users, NOT system processes
|
||||||
|
if (!isSystemEmail && !isSystemName) {
|
||||||
// Send notification to next approver (only for real users, not system processes)
|
// Send notification to next approver (only for real users, not system processes)
|
||||||
await notificationService.sendToUsers([ (nextLevel as any).approverId ], {
|
// This will send both in-app and email notifications
|
||||||
|
const nextApproverId = (nextLevel as any).approverId;
|
||||||
|
const nextApproverName = (nextLevel as any).approverName || (nextLevel as any).approverEmail || 'approver';
|
||||||
|
|
||||||
|
logger.info(`[Approval] Sending assignment notification to next approver: ${nextApproverName} (${nextApproverId}) at level ${nextLevelNumber} for request ${(wf as any).requestNumber}`);
|
||||||
|
|
||||||
|
await notificationService.sendToUsers([ nextApproverId ], {
|
||||||
title: `Action required: ${(wf as any).requestNumber}`,
|
title: `Action required: ${(wf as any).requestNumber}`,
|
||||||
body: `${(wf as any).title}`,
|
body: `${(wf as any).title}`,
|
||||||
requestNumber: (wf as any).requestNumber,
|
requestNumber: (wf as any).requestNumber,
|
||||||
@ -500,6 +514,8 @@ export class ApprovalService {
|
|||||||
actionRequired: true
|
actionRequired: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logger.info(`[Approval] Assignment notification sent successfully to ${nextApproverName} for level ${nextLevelNumber}`);
|
||||||
|
|
||||||
// Log assignment activity for the next approver
|
// Log assignment activity for the next approver
|
||||||
activityService.log({
|
activityService.log({
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
@ -507,7 +523,7 @@ export class ApprovalService {
|
|||||||
user: { userId: level.approverId, name: level.approverName },
|
user: { userId: level.approverId, name: level.approverName },
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
action: 'Assigned to approver',
|
action: 'Assigned to approver',
|
||||||
details: `Request assigned to ${(nextLevel as any).approverName || (nextLevel as any).approverEmail || 'approver'} for ${(nextLevel as any).levelName || `level ${nextLevelNumber}`}`,
|
details: `Request assigned to ${nextApproverName} for ${(nextLevel as any).levelName || `level ${nextLevelNumber}`}`,
|
||||||
ipAddress: requestMetadata?.ipAddress || undefined,
|
ipAddress: requestMetadata?.ipAddress || undefined,
|
||||||
userAgent: requestMetadata?.userAgent || undefined
|
userAgent: requestMetadata?.userAgent || undefined
|
||||||
});
|
});
|
||||||
@ -517,6 +533,31 @@ export class ApprovalService {
|
|||||||
} else {
|
} else {
|
||||||
logger.info(`[Approval] Skipping notification for auto-step at level ${nextLevelNumber}`);
|
logger.info(`[Approval] Skipping notification for auto-step at level ${nextLevelNumber}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify initiator when dealer submits documents (Step 1 or Step 5 approval in claim management)
|
||||||
|
const workflowType = (wf as any)?.workflowType;
|
||||||
|
const isClaimManagement = workflowType === 'CLAIM_MANAGEMENT';
|
||||||
|
const isStep1Approval = level.levelNumber === 1;
|
||||||
|
const isStep5Approval = level.levelNumber === 5;
|
||||||
|
|
||||||
|
if (isClaimManagement && (isStep1Approval || isStep5Approval) && (wf as any).initiatorId) {
|
||||||
|
const stepMessage = isStep1Approval
|
||||||
|
? 'Dealer proposal has been submitted and is now under review.'
|
||||||
|
: 'Dealer completion documents have been submitted and are now under review.';
|
||||||
|
|
||||||
|
await notificationService.sendToUsers([(wf as any).initiatorId], {
|
||||||
|
title: isStep1Approval ? 'Proposal Submitted' : 'Completion Documents Submitted',
|
||||||
|
body: `Your claim request "${(wf as any).title}" - ${stepMessage}`,
|
||||||
|
requestNumber: (wf as any).requestNumber,
|
||||||
|
requestId: (wf as any).requestId,
|
||||||
|
url: `/request/${(wf as any).requestNumber}`,
|
||||||
|
type: 'approval',
|
||||||
|
priority: 'MEDIUM',
|
||||||
|
actionRequired: false
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`[Approval] Sent notification to initiator for ${isStep1Approval ? 'Step 1' : 'Step 5'} approval in claim management workflow`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No next level found but not final approver - this shouldn't happen
|
// No next level found but not final approver - this shouldn't happen
|
||||||
|
|||||||
@ -1273,15 +1273,24 @@ export class DealerClaimService {
|
|||||||
|
|
||||||
if (!created) {
|
if (!created) {
|
||||||
// Update existing IO record with new IO details
|
// Update existing IO record with new IO details
|
||||||
|
// IMPORTANT: When updating existing record, preserve balance fields from previous blocking
|
||||||
|
// Only update ioNumber and ioRemark - don't overwrite balance values
|
||||||
await internalOrder.update({
|
await internalOrder.update({
|
||||||
ioNumber: ioData.ioNumber,
|
ioNumber: ioData.ioNumber,
|
||||||
ioRemark: ioData.ioRemark || '',
|
ioRemark: ioData.ioRemark || '',
|
||||||
// Only update balance fields if provided
|
// Don't update balance fields for existing records - preserve values from previous blocking
|
||||||
...(ioData.availableBalance !== undefined && { ioAvailableBalance: ioData.availableBalance }),
|
// Only update organizedBy and organizedAt
|
||||||
...(ioData.remainingBalance !== undefined && { ioRemainingBalance: ioData.remainingBalance }),
|
|
||||||
organizedBy: organizedBy || internalOrder.organizedBy,
|
organizedBy: organizedBy || internalOrder.organizedBy,
|
||||||
organizedAt: new Date(),
|
organizedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimService] IO details updated (preserved existing balance values) for request: ${requestId}`, {
|
||||||
|
ioNumber: ioData.ioNumber,
|
||||||
|
ioRemark: ioData.ioRemark,
|
||||||
|
preservedAvailableBalance: internalOrder.ioAvailableBalance,
|
||||||
|
preservedBlockedAmount: internalOrder.ioBlockedAmount,
|
||||||
|
preservedRemainingBalance: internalOrder.ioRemainingBalance,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`[DealerClaimService] IO details saved (without blocking) for request: ${requestId}`, {
|
logger.info(`[DealerClaimService] IO details saved (without blocking) for request: ${requestId}`, {
|
||||||
@ -1318,41 +1327,101 @@ export class DealerClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const finalBlockedAmount = blockResult.blockedAmount;
|
const finalBlockedAmount = blockResult.blockedAmount;
|
||||||
const remainingBalance = blockResult.remainingBalance;
|
|
||||||
const availableBalance = ioData.availableBalance || ioValidation.availableBalance;
|
const availableBalance = ioData.availableBalance || ioValidation.availableBalance;
|
||||||
|
|
||||||
|
// Calculate remaining balance: availableBalance - blockedAmount
|
||||||
|
// Use SAP's returned value if available and valid (> 0), otherwise calculate it
|
||||||
|
// This ensures we always have a valid remaining balance value
|
||||||
|
const sapRemainingBalance = blockResult.remainingBalance;
|
||||||
|
const calculatedRemainingBalance = availableBalance - finalBlockedAmount;
|
||||||
|
const remainingBalance = (sapRemainingBalance > 0 && sapRemainingBalance <= availableBalance)
|
||||||
|
? sapRemainingBalance
|
||||||
|
: calculatedRemainingBalance;
|
||||||
|
|
||||||
|
// Ensure remaining balance is not negative
|
||||||
|
const finalRemainingBalance = Math.max(0, remainingBalance);
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimService] Budget blocking calculation:`, {
|
||||||
|
availableBalance,
|
||||||
|
blockedAmount: finalBlockedAmount,
|
||||||
|
sapRemainingBalance,
|
||||||
|
calculatedRemainingBalance,
|
||||||
|
finalRemainingBalance
|
||||||
|
});
|
||||||
|
|
||||||
// Get the user who is blocking the IO (current user)
|
// Get the user who is blocking the IO (current user)
|
||||||
const organizedBy = organizedByUserId || null;
|
const organizedBy = organizedByUserId || null;
|
||||||
|
|
||||||
// Create or update Internal Order record (only when blocking)
|
// Create or update Internal Order record (only when blocking)
|
||||||
|
const ioRecordData = {
|
||||||
|
requestId,
|
||||||
|
ioNumber: ioData.ioNumber,
|
||||||
|
ioRemark: ioData.ioRemark || '',
|
||||||
|
ioAvailableBalance: availableBalance,
|
||||||
|
ioBlockedAmount: finalBlockedAmount,
|
||||||
|
ioRemainingBalance: finalRemainingBalance,
|
||||||
|
organizedBy: organizedBy || undefined,
|
||||||
|
organizedAt: new Date(),
|
||||||
|
status: IOStatus.BLOCKED,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimService] Storing IO details in database:`, {
|
||||||
|
ioNumber: ioData.ioNumber,
|
||||||
|
ioAvailableBalance: availableBalance,
|
||||||
|
ioBlockedAmount: finalBlockedAmount,
|
||||||
|
ioRemainingBalance: finalRemainingBalance,
|
||||||
|
requestId
|
||||||
|
});
|
||||||
|
|
||||||
const [internalOrder, created] = await InternalOrder.findOrCreate({
|
const [internalOrder, created] = await InternalOrder.findOrCreate({
|
||||||
where: { requestId },
|
where: { requestId },
|
||||||
defaults: {
|
defaults: ioRecordData
|
||||||
requestId,
|
|
||||||
ioNumber: ioData.ioNumber,
|
|
||||||
ioRemark: ioData.ioRemark || '',
|
|
||||||
ioAvailableBalance: availableBalance,
|
|
||||||
ioBlockedAmount: finalBlockedAmount,
|
|
||||||
ioRemainingBalance: remainingBalance,
|
|
||||||
organizedBy: organizedBy || undefined,
|
|
||||||
organizedAt: new Date(),
|
|
||||||
status: IOStatus.BLOCKED,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!created) {
|
if (!created) {
|
||||||
// Update existing IO record
|
// Update existing IO record - explicitly update all fields including remainingBalance
|
||||||
await internalOrder.update({
|
logger.info(`[DealerClaimService] Updating existing IO record for request: ${requestId}`);
|
||||||
ioNumber: ioData.ioNumber,
|
logger.info(`[DealerClaimService] Update data:`, {
|
||||||
ioRemark: ioData.ioRemark || '',
|
ioRemainingBalance: ioRecordData.ioRemainingBalance,
|
||||||
ioAvailableBalance: availableBalance,
|
ioBlockedAmount: ioRecordData.ioBlockedAmount,
|
||||||
ioBlockedAmount: finalBlockedAmount,
|
ioAvailableBalance: ioRecordData.ioAvailableBalance
|
||||||
ioRemainingBalance: remainingBalance,
|
|
||||||
// Update to current user who is blocking
|
|
||||||
organizedBy: organizedBy || internalOrder.organizedBy,
|
|
||||||
organizedAt: new Date(),
|
|
||||||
status: IOStatus.BLOCKED,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Explicitly update all fields to ensure remainingBalance is saved
|
||||||
|
const updateResult = await internalOrder.update({
|
||||||
|
ioNumber: ioRecordData.ioNumber,
|
||||||
|
ioRemark: ioRecordData.ioRemark,
|
||||||
|
ioAvailableBalance: ioRecordData.ioAvailableBalance,
|
||||||
|
ioBlockedAmount: ioRecordData.ioBlockedAmount,
|
||||||
|
ioRemainingBalance: ioRecordData.ioRemainingBalance, // Explicitly ensure this is updated
|
||||||
|
organizedBy: ioRecordData.organizedBy,
|
||||||
|
organizedAt: ioRecordData.organizedAt,
|
||||||
|
status: ioRecordData.status
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimService] Update result:`, updateResult ? 'Success' : 'Failed');
|
||||||
|
} else {
|
||||||
|
logger.info(`[DealerClaimService] Created new IO record for request: ${requestId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify what was actually saved - reload from database
|
||||||
|
await internalOrder.reload();
|
||||||
|
const savedRemainingBalance = internalOrder.ioRemainingBalance;
|
||||||
|
|
||||||
|
logger.info(`[DealerClaimService] ✅ IO record after save (verified from database):`, {
|
||||||
|
ioId: internalOrder.ioId,
|
||||||
|
ioNumber: internalOrder.ioNumber,
|
||||||
|
ioAvailableBalance: internalOrder.ioAvailableBalance,
|
||||||
|
ioBlockedAmount: internalOrder.ioBlockedAmount,
|
||||||
|
ioRemainingBalance: savedRemainingBalance,
|
||||||
|
expectedRemainingBalance: finalRemainingBalance,
|
||||||
|
match: savedRemainingBalance === finalRemainingBalance || Math.abs((savedRemainingBalance || 0) - finalRemainingBalance) < 0.01,
|
||||||
|
status: internalOrder.status
|
||||||
|
});
|
||||||
|
|
||||||
|
// Warn if remaining balance doesn't match
|
||||||
|
if (Math.abs((savedRemainingBalance || 0) - finalRemainingBalance) >= 0.01) {
|
||||||
|
logger.error(`[DealerClaimService] ⚠️ WARNING: Remaining balance mismatch! Expected: ${finalRemainingBalance}, Saved: ${savedRemainingBalance}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update budget tracking with blocked amount
|
// Update budget tracking with blocked amount
|
||||||
@ -1367,7 +1436,8 @@ export class DealerClaimService {
|
|||||||
logger.info(`[DealerClaimService] IO blocked for request: ${requestId}`, {
|
logger.info(`[DealerClaimService] IO blocked for request: ${requestId}`, {
|
||||||
ioNumber: ioData.ioNumber,
|
ioNumber: ioData.ioNumber,
|
||||||
blockedAmount: finalBlockedAmount,
|
blockedAmount: finalBlockedAmount,
|
||||||
remainingBalance
|
availableBalance,
|
||||||
|
remainingBalance: finalRemainingBalance
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[DealerClaimService] Error blocking IO:', error);
|
logger.error('[DealerClaimService] Error blocking IO:', error);
|
||||||
|
|||||||
@ -143,46 +143,61 @@ class NotificationService {
|
|||||||
// 1. Check admin + user preferences for in-app notifications
|
// 1. Check admin + user preferences for in-app notifications
|
||||||
const canSendInApp = await shouldSendInAppNotification(userId, payload.type || 'general');
|
const canSendInApp = await shouldSendInAppNotification(userId, payload.type || 'general');
|
||||||
|
|
||||||
|
logger.info(`[Notification] In-app notification check for user ${userId}:`, {
|
||||||
|
canSendInApp,
|
||||||
|
inAppNotificationsEnabled: user.inAppNotificationsEnabled,
|
||||||
|
notificationType: payload.type,
|
||||||
|
willCreate: canSendInApp && user.inAppNotificationsEnabled
|
||||||
|
});
|
||||||
|
|
||||||
|
let notification: any = null;
|
||||||
if (canSendInApp && user.inAppNotificationsEnabled) {
|
if (canSendInApp && user.inAppNotificationsEnabled) {
|
||||||
const notification = await Notification.create({
|
|
||||||
userId,
|
|
||||||
requestId: payload.requestId,
|
|
||||||
notificationType: payload.type || 'general',
|
|
||||||
title: payload.title,
|
|
||||||
message: payload.body,
|
|
||||||
isRead: false,
|
|
||||||
priority: payload.priority || 'MEDIUM',
|
|
||||||
actionUrl: payload.url,
|
|
||||||
actionRequired: payload.actionRequired || false,
|
|
||||||
metadata: {
|
|
||||||
requestNumber: payload.requestNumber,
|
|
||||||
...payload.metadata
|
|
||||||
},
|
|
||||||
sentVia: ['IN_APP'],
|
|
||||||
emailSent: false,
|
|
||||||
smsSent: false,
|
|
||||||
pushSent: false
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
sentVia.push('IN_APP');
|
|
||||||
logger.info(`[Notification] Created in-app notification for user ${userId}: ${payload.title}`);
|
|
||||||
|
|
||||||
// 2. Emit real-time socket event for immediate delivery
|
|
||||||
try {
|
try {
|
||||||
const { emitToUser } = require('../realtime/socket');
|
notification = await Notification.create({
|
||||||
if (emitToUser) {
|
userId,
|
||||||
emitToUser(userId, 'notification:new', {
|
requestId: payload.requestId,
|
||||||
notification: notification.toJSON(),
|
notificationType: payload.type || 'general',
|
||||||
...payload
|
title: payload.title,
|
||||||
});
|
message: payload.body,
|
||||||
logger.info(`[Notification] Emitted socket event to user ${userId}`);
|
isRead: false,
|
||||||
|
priority: payload.priority || 'MEDIUM',
|
||||||
|
actionUrl: payload.url,
|
||||||
|
actionRequired: payload.actionRequired || false,
|
||||||
|
metadata: {
|
||||||
|
requestNumber: payload.requestNumber,
|
||||||
|
...payload.metadata
|
||||||
|
},
|
||||||
|
sentVia: ['IN_APP'],
|
||||||
|
emailSent: false,
|
||||||
|
smsSent: false,
|
||||||
|
pushSent: false
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
sentVia.push('IN_APP');
|
||||||
|
logger.info(`[Notification] ✅ Created in-app notification for user ${userId}: ${payload.title} (ID: ${(notification as any).notificationId})`);
|
||||||
|
|
||||||
|
// 2. Emit real-time socket event for immediate delivery
|
||||||
|
try {
|
||||||
|
const { emitToUser } = require('../realtime/socket');
|
||||||
|
if (emitToUser) {
|
||||||
|
emitToUser(userId, 'notification:new', {
|
||||||
|
notification: notification.toJSON(),
|
||||||
|
...payload
|
||||||
|
});
|
||||||
|
logger.info(`[Notification] ✅ Emitted socket event to user ${userId}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`[Notification] emitToUser function not available`);
|
||||||
|
}
|
||||||
|
} catch (socketError) {
|
||||||
|
logger.warn(`[Notification] Socket emit failed (not critical):`, socketError);
|
||||||
}
|
}
|
||||||
} catch (socketError) {
|
} catch (notificationError) {
|
||||||
logger.warn(`[Notification] Socket emit failed (not critical):`, socketError);
|
logger.error(`[Notification] ❌ Failed to create in-app notification for user ${userId}:`, notificationError);
|
||||||
|
// Continue - don't block other notification channels
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Send push notification (if enabled and user has subscriptions)
|
// 3. Send push notification (if enabled and user has subscriptions)
|
||||||
if (user.pushNotificationsEnabled && canSendInApp) {
|
if (user.pushNotificationsEnabled && canSendInApp && notification) {
|
||||||
let subs = this.userIdToSubscriptions.get(userId) || [];
|
let subs = this.userIdToSubscriptions.get(userId) || [];
|
||||||
// Load from DB if memory empty
|
// Load from DB if memory empty
|
||||||
if (subs.length === 0) {
|
if (subs.length === 0) {
|
||||||
@ -290,18 +305,24 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if email should be sent (admin + user preferences)
|
// Check if email should be sent (admin + user preferences)
|
||||||
|
// For assignment notifications, always attempt to send email (unless explicitly disabled by admin)
|
||||||
|
// This ensures next approvers always receive email notifications
|
||||||
const shouldSend = payload.type === 'rejection' || payload.type === 'tat_breach'
|
const shouldSend = payload.type === 'rejection' || payload.type === 'tat_breach'
|
||||||
? await shouldSendEmailWithOverride(userId, emailType) // Critical emails
|
? await shouldSendEmailWithOverride(userId, emailType) // Critical emails
|
||||||
|
: payload.type === 'assignment'
|
||||||
|
? await shouldSendEmailWithOverride(userId, emailType) // Assignment emails - use override to ensure delivery
|
||||||
: await shouldSendEmail(userId, emailType); // Regular emails
|
: await shouldSendEmail(userId, emailType); // Regular emails
|
||||||
|
|
||||||
console.log(`[DEBUG Email] Should send email: ${shouldSend}`);
|
console.log(`[DEBUG Email] Should send email: ${shouldSend} for type: ${payload.type}, userId: ${userId}`);
|
||||||
|
|
||||||
if (!shouldSend) {
|
if (!shouldSend) {
|
||||||
console.log(`[DEBUG Email] Email skipped for user ${userId}, type: ${payload.type} (preferences)`);
|
console.log(`[DEBUG Email] Email skipped for user ${userId}, type: ${payload.type} (preferences)`);
|
||||||
logger.info(`[Email] Skipped for user ${userId}, type: ${payload.type} (preferences)`);
|
logger.warn(`[Email] Email skipped for user ${userId}, type: ${payload.type} (preferences or admin disabled)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(`[Email] Sending email notification to user ${userId} for type: ${payload.type}, requestId: ${payload.requestId}`);
|
||||||
|
|
||||||
// Trigger email based on notification type
|
// Trigger email based on notification type
|
||||||
// Email service will fetch additional data as needed
|
// Email service will fetch additional data as needed
|
||||||
console.log(`[DEBUG Email] Triggering email for type: ${payload.type}`);
|
console.log(`[DEBUG Email] Triggering email for type: ${payload.type}`);
|
||||||
|
|||||||
@ -1,30 +1,237 @@
|
|||||||
|
import axios, { AxiosError } from 'axios';
|
||||||
|
import https from 'https';
|
||||||
|
import { XMLParser } from 'fast-xml-parser';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SAP Integration Service
|
* SAP Integration Service
|
||||||
* Handles integration with SAP for IO validation and budget blocking
|
* Handles integration with SAP for IO validation and budget blocking
|
||||||
*
|
* Integrates with SAP OData service via Zscaler URL
|
||||||
* NOTE: This is a placeholder/stub implementation.
|
|
||||||
* Replace with actual SAP API integration based on your SAP system.
|
|
||||||
*/
|
*/
|
||||||
export class SAPIntegrationService {
|
export class SAPIntegrationService {
|
||||||
private sapBaseUrl: string;
|
private sapBaseUrl: string;
|
||||||
private sapApiKey?: string;
|
|
||||||
private sapUsername?: string;
|
private sapUsername?: string;
|
||||||
private sapPassword?: string;
|
private sapPassword?: string;
|
||||||
|
private sapTimeout: number;
|
||||||
|
private sapServiceName: string; // OData service name for IO validation (e.g., ZFI_BUDGET_CHECK_API_SRV)
|
||||||
|
private sapBlockServiceName: string; // OData service name for budget blocking (e.g., ZFI_BUDGET_BLOCK_API_SRV)
|
||||||
|
private sapRequester: string; // Requester identifier for budget blocking
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.sapBaseUrl = process.env.SAP_BASE_URL || '';
|
this.sapBaseUrl = process.env.SAP_BASE_URL || '';
|
||||||
this.sapApiKey = process.env.SAP_API_KEY;
|
|
||||||
this.sapUsername = process.env.SAP_USERNAME;
|
this.sapUsername = process.env.SAP_USERNAME;
|
||||||
this.sapPassword = process.env.SAP_PASSWORD;
|
this.sapPassword = process.env.SAP_PASSWORD;
|
||||||
|
this.sapTimeout = parseInt(process.env.SAP_TIMEOUT_MS || '30000', 10); // Default 30 seconds
|
||||||
|
// Service name can be configured per environment, defaults to budget check service
|
||||||
|
this.sapServiceName = process.env.SAP_SERVICE_NAME || 'ZFI_BUDGET_CHECK_API_SRV';
|
||||||
|
// Budget blocking service name (different from validation service)
|
||||||
|
this.sapBlockServiceName = process.env.SAP_BLOCK_SERVICE_NAME || 'ZFI_BUDGET_BLOCK_API_SRV';
|
||||||
|
// Requester identifier for budget blocking API
|
||||||
|
this.sapRequester = process.env.SAP_REQUESTER || 'REFMS';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if SAP integration is configured
|
* Check if SAP integration is configured
|
||||||
*/
|
*/
|
||||||
private isConfigured(): boolean {
|
private isConfigured(): boolean {
|
||||||
return !!this.sapBaseUrl && (!!this.sapApiKey || (!!this.sapUsername && !!this.sapPassword));
|
return !!this.sapBaseUrl && !!this.sapUsername && !!this.sapPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CSRF token from SAP service (required for POST/PUT/DELETE operations)
|
||||||
|
* SAP OData services require CSRF tokens for state-changing operations
|
||||||
|
*
|
||||||
|
* Process:
|
||||||
|
* 1. Make GET request to service root with header "x-csrf-token: fetch"
|
||||||
|
* 2. Include query params: $format=json&sap-client=200
|
||||||
|
* 3. Extract CSRF token from response headers
|
||||||
|
* 4. Extract cookies from response (SAP session cookies)
|
||||||
|
* 5. Return both token and cookies for use in POST requests
|
||||||
|
*
|
||||||
|
* @returns Object with csrfToken and cookies, or null if failed
|
||||||
|
*/
|
||||||
|
private async getCsrfToken(serviceName: string): Promise<{ csrfToken: string; cookies: string } | null> {
|
||||||
|
try {
|
||||||
|
// Build service root URL with required query parameters
|
||||||
|
const serviceRootUrl = `/sap/opu/odata/sap/${serviceName}/`;
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
'$format': 'json',
|
||||||
|
'sap-client': '200'
|
||||||
|
});
|
||||||
|
const fullUrl = `${this.sapBaseUrl}${serviceRootUrl}?${queryParams.toString()}`;
|
||||||
|
|
||||||
|
logger.debug(`[SAP] Fetching CSRF token from service: ${serviceName}`);
|
||||||
|
logger.debug(`[SAP] CSRF token request URL: ${fullUrl}`);
|
||||||
|
|
||||||
|
// Use standalone axios request with Basic Auth in header
|
||||||
|
// We need to capture cookies from this response to use in POST request
|
||||||
|
const response = await axios.get(fullUrl, {
|
||||||
|
auth: {
|
||||||
|
username: this.sapUsername!,
|
||||||
|
password: this.sapPassword!
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'x-csrf-token': 'fetch', // Lowercase as per SAP requirement
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
timeout: this.sapTimeout,
|
||||||
|
httpsAgent: process.env.SAP_DISABLE_SSL_VERIFY === 'true' ? new https.Agent({
|
||||||
|
rejectUnauthorized: false
|
||||||
|
}) : undefined,
|
||||||
|
validateStatus: (status) => status < 500, // Don't throw on 4xx
|
||||||
|
// Enable cookie handling to capture SAP session cookies
|
||||||
|
withCredentials: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// SAP returns CSRF token in response headers (check multiple case variations)
|
||||||
|
const csrfToken = response.headers['x-csrf-token'] ||
|
||||||
|
response.headers['X-CSRF-Token'] ||
|
||||||
|
response.headers['X-Csrf-Token'] ||
|
||||||
|
response.headers['x-csrf-token'];
|
||||||
|
|
||||||
|
// Extract cookies from response headers
|
||||||
|
// SAP sets cookies like: SAP_SESSIONID_DRE_200 and sap-usercontext
|
||||||
|
const setCookieHeaders = response.headers['set-cookie'] as string | string[] | undefined;
|
||||||
|
let cookies = '';
|
||||||
|
|
||||||
|
if (setCookieHeaders) {
|
||||||
|
if (Array.isArray(setCookieHeaders)) {
|
||||||
|
// Extract cookie values from Set-Cookie headers
|
||||||
|
cookies = setCookieHeaders.map((cookie: string) => {
|
||||||
|
// Extract cookie name=value (before semicolon)
|
||||||
|
return cookie.split(';')[0].trim();
|
||||||
|
}).join('; ');
|
||||||
|
} else {
|
||||||
|
// It's a string
|
||||||
|
cookies = setCookieHeaders.split(';')[0].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log full GET response for debugging
|
||||||
|
logger.info(`[SAP] GET Response Status: ${response.status} ${response.statusText || ''}`);
|
||||||
|
logger.info(`[SAP] GET Response Headers:`, JSON.stringify(response.headers, null, 2));
|
||||||
|
logger.info(`[SAP] GET Response Data:`, JSON.stringify(response.data, null, 2));
|
||||||
|
|
||||||
|
if (csrfToken && typeof csrfToken === 'string' && csrfToken !== 'fetch') {
|
||||||
|
logger.info(`[SAP] CSRF token obtained successfully (length: ${csrfToken.length})`);
|
||||||
|
logger.debug(`[SAP] CSRF token preview: ${csrfToken.substring(0, 20)}...`);
|
||||||
|
|
||||||
|
if (cookies) {
|
||||||
|
logger.debug(`[SAP] Session cookies captured: ${cookies.substring(0, 50)}...`);
|
||||||
|
} else {
|
||||||
|
logger.warn('[SAP] No cookies found in CSRF token response - POST may fail');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { csrfToken, cookies };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('[SAP] CSRF token not found in response headers or invalid');
|
||||||
|
logger.debug('[SAP] Response status:', response.status);
|
||||||
|
logger.debug('[SAP] Available headers:', Object.keys(response.headers).filter(h => h.toLowerCase().includes('csrf')));
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
const axiosError = error as AxiosError;
|
||||||
|
|
||||||
|
if (axiosError.response) {
|
||||||
|
logger.error(`[SAP] Failed to get CSRF token: ${axiosError.response.status} ${axiosError.response.statusText}`);
|
||||||
|
logger.error(`[SAP] Response data:`, axiosError.response.data);
|
||||||
|
|
||||||
|
if (axiosError.response.status === 401 || axiosError.response.status === 403) {
|
||||||
|
logger.error('[SAP] Authentication failed while fetching CSRF token - check SAP credentials');
|
||||||
|
} else if (axiosError.response.status === 404) {
|
||||||
|
logger.error('[SAP] Service not found - check SAP_SERVICE_NAME configuration');
|
||||||
|
}
|
||||||
|
} else if (axiosError.request) {
|
||||||
|
logger.error('[SAP] No response received while fetching CSRF token:', axiosError.message);
|
||||||
|
if (axiosError.code === 'ECONNREFUSED') {
|
||||||
|
logger.error('[SAP] Connection refused - check if SAP server is reachable');
|
||||||
|
} else if (axiosError.code === 'ENOTFOUND') {
|
||||||
|
logger.error('[SAP] Host not found - check SAP_BASE_URL');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error('[SAP] Error setting up CSRF token request:', error instanceof Error ? error.message : 'Unknown error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create axios instance with basic auth for SAP API calls
|
||||||
|
*/
|
||||||
|
private createSapClient() {
|
||||||
|
// Check if SSL verification should be disabled (for testing with self-signed certs)
|
||||||
|
const disableSSLVerification = process.env.SAP_DISABLE_SSL_VERIFY === 'true';
|
||||||
|
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: this.sapBaseUrl,
|
||||||
|
timeout: this.sapTimeout,
|
||||||
|
auth: {
|
||||||
|
username: this.sapUsername!,
|
||||||
|
password: this.sapPassword!
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
validateStatus: (status) => status < 500, // Don't throw on 4xx errors
|
||||||
|
// SSL/TLS configuration
|
||||||
|
httpsAgent: disableSSLVerification ? new https.Agent({
|
||||||
|
rejectUnauthorized: false
|
||||||
|
}) : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add request interceptor for debugging
|
||||||
|
client.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
logger.debug(`[SAP] Making ${config.method?.toUpperCase()} request to: ${config.baseURL}${config.url}`);
|
||||||
|
logger.debug(`[SAP] Auth username: ${this.sapUsername}`);
|
||||||
|
// Don't log password for security
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
logger.error('[SAP] Request interceptor error:', error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add response interceptor for debugging
|
||||||
|
client.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
logger.debug(`[SAP] Response status: ${response.status}`);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (error.response) {
|
||||||
|
logger.error(`[SAP] Response error: ${error.response.status} ${error.response.statusText}`);
|
||||||
|
logger.error(`[SAP] Response data:`, error.response.data);
|
||||||
|
} else if (error.request) {
|
||||||
|
logger.error('[SAP] No response received:', error.message);
|
||||||
|
if (error.code === 'ECONNREFUSED') {
|
||||||
|
logger.error('[SAP] Connection refused - check if SAP server is reachable');
|
||||||
|
} else if (error.code === 'ENOTFOUND') {
|
||||||
|
logger.error('[SAP] Host not found - check SAP_BASE_URL');
|
||||||
|
} else if (error.code === 'CERT_HAS_EXPIRED' || error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
|
||||||
|
logger.error('[SAP] SSL certificate error - set SAP_DISABLE_SSL_VERIFY=true to bypass (testing only)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build SAP OData endpoint URL
|
||||||
|
* @param entitySet - OData entity set name (e.g., GetSenderDataSet)
|
||||||
|
* @param serviceName - Optional service name override (defaults to configured service)
|
||||||
|
* @returns Full OData endpoint path
|
||||||
|
*/
|
||||||
|
private buildODataEndpoint(entitySet: string, serviceName?: string): string {
|
||||||
|
const service = serviceName || this.sapServiceName;
|
||||||
|
return `/sap/opu/odata/sap/${service}/${entitySet}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -57,46 +264,201 @@ export class SAPIntegrationService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement actual SAP API call
|
const sapClient = this.createSapClient();
|
||||||
// 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');
|
// SAP OData endpoint: GetSenderDataSet with filter on IONumber
|
||||||
return {
|
// Service name is configurable via SAP_SERVICE_NAME env variable
|
||||||
isValid: true,
|
const endpoint = this.buildODataEndpoint('GetSenderDataSet');
|
||||||
ioNumber,
|
|
||||||
availableBalance: 1000000,
|
// Build OData query parameters matching the working URL format
|
||||||
blockedAmount: 0,
|
// $filter: Filter by IO number
|
||||||
remainingBalance: 1000000,
|
// $select: Select specific fields (Sender, ResponseDate, GetIODetailsSet01)
|
||||||
currency: 'INR',
|
// $expand: Expand the nested GetIODetailsSet01 entity set to get IO details
|
||||||
description: 'Mock IO Data (SAP API not implemented)'
|
// $format: Explicitly request JSON format
|
||||||
};
|
const queryParams = new URLSearchParams({
|
||||||
|
'$filter': `IONumber eq '${ioNumber}'`,
|
||||||
|
'$select': 'Sender,ResponseDate,GetIODetailsSet01',
|
||||||
|
'$expand': 'GetIODetailsSet01',
|
||||||
|
'$format': 'json'
|
||||||
|
});
|
||||||
|
|
||||||
|
const fullUrl = `${endpoint}?${queryParams.toString()}`;
|
||||||
|
|
||||||
|
logger.info(`[SAP] Validating IO number: ${ioNumber} using service: ${this.sapServiceName}`);
|
||||||
|
logger.debug(`[SAP] Request URL: ${this.sapBaseUrl}${fullUrl}`);
|
||||||
|
|
||||||
|
const response = await sapClient.get(fullUrl);
|
||||||
|
|
||||||
|
if (response.status === 200 && response.data) {
|
||||||
|
// SAP OData response format: { d: { results: [...] } }
|
||||||
|
const results = response.data.d?.results || response.data.results || [];
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
logger.warn(`[SAP] IO number ${ioNumber} not found in SAP`);
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
ioNumber,
|
||||||
|
availableBalance: 0,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 0,
|
||||||
|
currency: 'INR',
|
||||||
|
error: 'IO number not found in SAP system'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first result (should be only one for a specific IO number)
|
||||||
|
const senderData = results[0];
|
||||||
|
|
||||||
|
// IO details are in the expanded GetIODetailsSet01 entity set
|
||||||
|
// Structure: senderData.GetIODetailsSet01.results[0]
|
||||||
|
const ioDetailsSet = senderData.GetIODetailsSet01;
|
||||||
|
|
||||||
|
if (!ioDetailsSet || !ioDetailsSet.results || !Array.isArray(ioDetailsSet.results) || ioDetailsSet.results.length === 0) {
|
||||||
|
logger.warn(`[SAP] No IO details found in expanded GetIODetailsSet01 for IO ${ioNumber}`);
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
ioNumber,
|
||||||
|
availableBalance: 0,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 0,
|
||||||
|
currency: 'INR',
|
||||||
|
error: 'IO details not found in SAP response'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first IO detail from the results array
|
||||||
|
const ioDetails = ioDetailsSet.results[0];
|
||||||
|
|
||||||
|
// Map SAP response fields to our format based on actual response structure:
|
||||||
|
// - AvailableAmount: string with trailing space (e.g., "14333415.00 ")
|
||||||
|
// - IODescription: description text
|
||||||
|
// - IONumber: IO number
|
||||||
|
// - BlockedAmount: may not be present in response, default to 0
|
||||||
|
// - Currency: may not be present, default to INR
|
||||||
|
|
||||||
|
// Parse AvailableAmount - it's a string that may have trailing spaces
|
||||||
|
const availableAmountStr = (ioDetails.AvailableAmount || '0').toString().trim();
|
||||||
|
const availableBalance = parseFloat(availableAmountStr) || 0;
|
||||||
|
|
||||||
|
// BlockedAmount may not be in the response, default to 0
|
||||||
|
// If it exists, it might also be a string with trailing space
|
||||||
|
const blockedAmountStr = (ioDetails.BlockedAmount || ioDetails.Blocked || '0').toString().trim();
|
||||||
|
const blockedAmount = parseFloat(blockedAmountStr) || 0;
|
||||||
|
|
||||||
|
const remainingBalance = availableBalance - blockedAmount;
|
||||||
|
|
||||||
|
// Currency may not be in response, default to INR
|
||||||
|
const currency = (ioDetails.Currency || ioDetails.CurrencyCode || ioDetails.Curr || 'INR').toString().trim();
|
||||||
|
|
||||||
|
// Description from IODescription field
|
||||||
|
const description = ioDetails.IODescription || ioDetails.Description || ioDetails.Text || ioDetails.ShortText || undefined;
|
||||||
|
|
||||||
|
// IO Number from the IO details
|
||||||
|
const validatedIONumber = ioDetails.IONumber || ioDetails.InternalOrder || ioNumber;
|
||||||
|
|
||||||
|
logger.info(`[SAP] IO ${validatedIONumber} validated successfully. Available: ${availableBalance}, Blocked: ${blockedAmount}, Remaining: ${remainingBalance} ${currency}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
ioNumber: validatedIONumber,
|
||||||
|
availableBalance,
|
||||||
|
blockedAmount,
|
||||||
|
remainingBalance,
|
||||||
|
currency,
|
||||||
|
description
|
||||||
|
};
|
||||||
|
} else if (response.status === 401 || response.status === 403) {
|
||||||
|
logger.error('[SAP] Authentication failed - check SAP credentials');
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
ioNumber,
|
||||||
|
availableBalance: 0,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 0,
|
||||||
|
currency: 'INR',
|
||||||
|
error: 'SAP authentication failed - invalid credentials'
|
||||||
|
};
|
||||||
|
} else if (response.status === 404) {
|
||||||
|
logger.warn(`[SAP] IO number ${ioNumber} not found (404)`);
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
ioNumber,
|
||||||
|
availableBalance: 0,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 0,
|
||||||
|
currency: 'INR',
|
||||||
|
error: 'IO number not found in SAP system'
|
||||||
|
};
|
||||||
|
} else if (response.status === 501) {
|
||||||
|
logger.error(`[SAP] Not Implemented (501) - Check URL format and OData query parameters`);
|
||||||
|
logger.error(`[SAP] Request URL was: ${this.sapBaseUrl}${fullUrl}`);
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
ioNumber,
|
||||||
|
availableBalance: 0,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 0,
|
||||||
|
currency: 'INR',
|
||||||
|
error: 'SAP API returned 501 Not Implemented - check OData query format'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
logger.error(`[SAP] Unexpected response status: ${response.status}`);
|
||||||
|
logger.error(`[SAP] Response data:`, response.data);
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
ioNumber,
|
||||||
|
availableBalance: 0,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 0,
|
||||||
|
currency: 'INR',
|
||||||
|
error: `SAP API returned status ${response.status}`
|
||||||
|
};
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[SAP] Error validating IO number:', error);
|
const axiosError = error as AxiosError;
|
||||||
return {
|
|
||||||
isValid: false,
|
if (axiosError.response) {
|
||||||
ioNumber,
|
// SAP returned an error response
|
||||||
availableBalance: 0,
|
logger.error(`[SAP] Error validating IO number ${ioNumber}:`, {
|
||||||
blockedAmount: 0,
|
status: axiosError.response.status,
|
||||||
remainingBalance: 0,
|
statusText: axiosError.response.statusText,
|
||||||
currency: 'INR',
|
data: axiosError.response.data
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
});
|
||||||
};
|
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
ioNumber,
|
||||||
|
availableBalance: 0,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 0,
|
||||||
|
currency: 'INR',
|
||||||
|
error: `SAP API error: ${axiosError.response.status} ${axiosError.response.statusText}`
|
||||||
|
};
|
||||||
|
} else if (axiosError.request) {
|
||||||
|
// Request was made but no response received
|
||||||
|
logger.error(`[SAP] No response from SAP API for IO ${ioNumber}:`, axiosError.message);
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
ioNumber,
|
||||||
|
availableBalance: 0,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 0,
|
||||||
|
currency: 'INR',
|
||||||
|
error: `SAP API connection failed: ${axiosError.message}`
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Error setting up request
|
||||||
|
logger.error('[SAP] Error setting up SAP API request:', error);
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
ioNumber,
|
||||||
|
availableBalance: 0,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 0,
|
||||||
|
currency: 'INR',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,42 +493,421 @@ export class SAPIntegrationService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement actual SAP API call to block budget
|
const sapClient = this.createSapClient();
|
||||||
// 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');
|
// SAP OData endpoint for budget blocking
|
||||||
return {
|
// Service: ZFI_BUDGET_BLOCK_API_SRV
|
||||||
success: true,
|
// Entity Set: RequesterInputSet
|
||||||
blockId: `BLOCK-${Date.now()}`,
|
const endpoint = `/sap/opu/odata/sap/${this.sapBlockServiceName}/RequesterInputSet`;
|
||||||
blockedAmount: amount,
|
|
||||||
remainingBalance: 1000000 - amount,
|
// Format current date/time in ISO format: "2025-08-29T10:51:00"
|
||||||
error: 'SAP API not implemented - budget blocking simulated'
|
const now = new Date();
|
||||||
|
const requestDateTime = now.toISOString().replace(/\.\d{3}Z$/, ''); // Remove milliseconds and Z
|
||||||
|
|
||||||
|
// Build request payload matching SAP API structure
|
||||||
|
const requestPayload = {
|
||||||
|
Request_Date_Time: requestDateTime,
|
||||||
|
Requester: this.sapRequester,
|
||||||
|
lt_io_input: [
|
||||||
|
{
|
||||||
|
IONumber: ioNumber,
|
||||||
|
Amount: amount.toString() // Amount as string
|
||||||
|
}
|
||||||
|
],
|
||||||
|
lt_io_output: [],
|
||||||
|
ls_response: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
logger.info(`[SAP] Blocking budget for IO ${ioNumber}, Amount: ${amount}, Request: ${requestNumber}`);
|
||||||
|
logger.debug(`[SAP] Budget block request payload:`, JSON.stringify(requestPayload, null, 2));
|
||||||
|
|
||||||
|
// Get CSRF token and cookies for POST request (SAP OData requires both)
|
||||||
|
// SAP sets session cookies during CSRF token fetch that must be included in POST
|
||||||
|
const csrfData = await this.getCsrfToken(this.sapBlockServiceName);
|
||||||
|
|
||||||
|
if (!csrfData || !csrfData.csrfToken) {
|
||||||
|
logger.warn('[SAP] CSRF token not available, request may fail with CSRF validation error');
|
||||||
|
logger.warn('[SAP] This is expected if SAP requires CSRF tokens for POST requests');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build headers with CSRF token, cookies, and other required headers
|
||||||
|
// Force JSON format via Accept header (SAP returns XML by default for POST)
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Accept': 'application/json, application/atom+xml;q=0.9', // Prefer JSON, fallback to XML
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add CSRF token if available (required by SAP for POST/PUT/DELETE)
|
||||||
|
// Use lowercase 'x-csrf-token' as per SAP requirement
|
||||||
|
if (csrfData?.csrfToken) {
|
||||||
|
headers['x-csrf-token'] = csrfData.csrfToken;
|
||||||
|
logger.debug(`[SAP] CSRF token added to request headers`);
|
||||||
|
} else {
|
||||||
|
logger.warn('[SAP] CSRF token not available - request may fail with CSRF validation error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add cookies if available (SAP session cookies required for POST)
|
||||||
|
// Cookies like: SAP_SESSIONID_DRE_200 and sap-usercontext
|
||||||
|
if (csrfData?.cookies) {
|
||||||
|
headers['Cookie'] = csrfData.cookies;
|
||||||
|
logger.debug(`[SAP] Session cookies added to request headers`);
|
||||||
|
} else {
|
||||||
|
logger.warn('[SAP] No session cookies available - request may fail');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some SAP systems also require these headers
|
||||||
|
headers['X-Requested-With'] = 'XMLHttpRequest';
|
||||||
|
|
||||||
|
// NOTE: Do NOT add query parameters ($format, sap-client) to POST requests
|
||||||
|
// SAP OData does not allow SystemQueryOptions in POST requests
|
||||||
|
// Query parameters are only allowed for GET requests
|
||||||
|
// Use the endpoint directly without query parameters
|
||||||
|
const urlWithParams = endpoint;
|
||||||
|
|
||||||
|
logger.debug(`[SAP] POST request URL: ${urlWithParams}`);
|
||||||
|
logger.debug(`[SAP] Request headers (CSRF token and cookies masked):`, {
|
||||||
|
...headers,
|
||||||
|
'x-csrf-token': csrfData?.csrfToken ? `${csrfData.csrfToken.substring(0, 10)}...` : 'not set',
|
||||||
|
'Cookie': csrfData?.cookies ? `${csrfData.cookies.substring(0, 30)}...` : 'not set'
|
||||||
|
});
|
||||||
|
logger.debug(`[SAP] Using username: ${this.sapUsername}`);
|
||||||
|
|
||||||
|
// Ensure auth is explicitly included in POST request config
|
||||||
|
// The axios instance has auth configured, but we'll include it explicitly to be safe
|
||||||
|
// This ensures auth is sent even if the instance config is overridden
|
||||||
|
const postConfig = {
|
||||||
|
headers,
|
||||||
|
auth: {
|
||||||
|
username: this.sapUsername!,
|
||||||
|
password: this.sapPassword!
|
||||||
|
},
|
||||||
|
timeout: this.sapTimeout,
|
||||||
|
httpsAgent: process.env.SAP_DISABLE_SSL_VERIFY === 'true' ? new https.Agent({
|
||||||
|
rejectUnauthorized: false
|
||||||
|
}) : undefined,
|
||||||
|
validateStatus: (status: number) => status < 500 // Don't throw on 4xx
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug(`[SAP] POST request config prepared (auth included)`);
|
||||||
|
const response = await sapClient.post(urlWithParams, requestPayload, postConfig);
|
||||||
|
|
||||||
|
// Log full response for debugging
|
||||||
|
logger.info(`[SAP] POST Response Status: ${response.status} ${response.statusText || ''}`);
|
||||||
|
logger.info(`[SAP] POST Response Headers:`, JSON.stringify(response.headers, null, 2));
|
||||||
|
|
||||||
|
// Check if response is XML (SAP returns XML/Atom by default for POST)
|
||||||
|
const contentType = response.headers['content-type'] || '';
|
||||||
|
const isXML = contentType.includes('xml') || contentType.includes('atom') ||
|
||||||
|
(typeof response.data === 'string' && response.data.trim().startsWith('<'));
|
||||||
|
|
||||||
|
let responseData: any = response.data;
|
||||||
|
|
||||||
|
// Parse XML if needed
|
||||||
|
if (isXML && typeof response.data === 'string') {
|
||||||
|
logger.info(`[SAP] Response is XML, parsing to JSON...`);
|
||||||
|
try {
|
||||||
|
const parser = new XMLParser({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
attributeNamePrefix: '@_',
|
||||||
|
textNodeName: '#text',
|
||||||
|
parseAttributeValue: true,
|
||||||
|
trimValues: true
|
||||||
|
});
|
||||||
|
responseData = parser.parse(response.data);
|
||||||
|
logger.info(`[SAP] XML parsed successfully`);
|
||||||
|
} catch (xmlError) {
|
||||||
|
logger.error(`[SAP] Failed to parse XML response:`, xmlError);
|
||||||
|
// Continue with original data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log response data - handle different formats
|
||||||
|
if (responseData) {
|
||||||
|
try {
|
||||||
|
logger.info(`[SAP] POST Response Data:`, JSON.stringify(responseData, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
logger.info(`[SAP] POST Response Data (raw):`, responseData);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(`[SAP] POST Response Data: (empty or null)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also log the request that was sent
|
||||||
|
logger.info(`[SAP] POST Request URL: ${urlWithParams}`);
|
||||||
|
logger.info(`[SAP] POST Request Payload:`, JSON.stringify(requestPayload, null, 2));
|
||||||
|
|
||||||
|
if (response.status === 200 || response.status === 201) {
|
||||||
|
// Parse SAP response
|
||||||
|
// Response structure may vary, but typically contains:
|
||||||
|
// - Success indicator
|
||||||
|
// - Blocked amount confirmation
|
||||||
|
// - Remaining balance (in lt_io_output[0].Available_Amount for XML)
|
||||||
|
// - Block ID or reference number
|
||||||
|
|
||||||
|
// Helper function to extract remaining balance from various field names
|
||||||
|
// For XML: Available_Amount in lt_io_output[0] (may be prefixed with 'd:' namespace)
|
||||||
|
// For JSON: RemainingBalance, Remaining, Available_Amount, etc.
|
||||||
|
const extractRemainingBalance = (obj: any): number => {
|
||||||
|
if (!obj) return 0;
|
||||||
|
|
||||||
|
// Try various field name variations (both JSON and XML formats)
|
||||||
|
// XML namespace prefixes: 'd:Available_Amount', 'd:RemainingBalance', etc.
|
||||||
|
const value = obj['d:Available_Amount'] || // XML format with namespace prefix
|
||||||
|
obj.Available_Amount || // XML format without prefix
|
||||||
|
obj.RemainingBalance ||
|
||||||
|
obj['d:RemainingBalance'] ||
|
||||||
|
obj.Remaining ||
|
||||||
|
obj.RemainingAmount ||
|
||||||
|
obj.AvailableBalance ||
|
||||||
|
obj.Balance ||
|
||||||
|
obj.Available ||
|
||||||
|
0;
|
||||||
|
|
||||||
|
const parsed = parseFloat(value?.toString() || '0');
|
||||||
|
return isNaN(parsed) ? 0 : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to extract blocked amount
|
||||||
|
const extractBlockedAmount = (obj: any): number => {
|
||||||
|
if (!obj) return amount;
|
||||||
|
|
||||||
|
const value = obj.BlockedAmount ||
|
||||||
|
obj.Amount ||
|
||||||
|
obj.Blocked ||
|
||||||
|
amount.toString();
|
||||||
|
|
||||||
|
const parsed = parseFloat(value?.toString() || amount.toString());
|
||||||
|
return isNaN(parsed) ? amount : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle different possible response structures
|
||||||
|
let success = false;
|
||||||
|
let blockedAmount = amount;
|
||||||
|
let remainingBalance = 0;
|
||||||
|
let blockId: string | undefined;
|
||||||
|
|
||||||
|
// Parse XML structure: entry -> link[@rel='lt_io_output'] -> inline -> feed -> entry -> content -> properties
|
||||||
|
// Or JSON structure: { d: {...} } or { lt_io_output: [...] }
|
||||||
|
|
||||||
|
// Check for XML structure first (parsed XML from fast-xml-parser)
|
||||||
|
let ioOutputData: any = null;
|
||||||
|
let message = '';
|
||||||
|
|
||||||
|
// XML structure: entry.link (array) -> find link with @rel='lt_io_output' -> inline.feed.entry
|
||||||
|
if (responseData.entry) {
|
||||||
|
const entry = responseData.entry;
|
||||||
|
|
||||||
|
// Find lt_io_output link
|
||||||
|
const links = Array.isArray(entry.link) ? entry.link : (entry.link ? [entry.link] : []);
|
||||||
|
const ioOutputLink = links.find((link: any) =>
|
||||||
|
link['@_rel']?.includes('lt_io_output') ||
|
||||||
|
link['@_title'] === 'IOOutputSet' ||
|
||||||
|
link.title === 'IOOutputSet'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ioOutputLink?.inline?.feed?.entry) {
|
||||||
|
const ioEntry = Array.isArray(ioOutputLink.inline.feed.entry)
|
||||||
|
? ioOutputLink.inline.feed.entry[0]
|
||||||
|
: ioOutputLink.inline.feed.entry;
|
||||||
|
|
||||||
|
// Handle both namespace-prefixed and non-prefixed property names
|
||||||
|
const content = ioEntry.content || {};
|
||||||
|
const properties = content['m:properties'] || content.properties || content['@_type'] === 'application/xml' ? content : null;
|
||||||
|
|
||||||
|
if (properties) {
|
||||||
|
ioOutputData = properties;
|
||||||
|
// Try both namespace-prefixed and non-prefixed field names
|
||||||
|
message = ioOutputData['d:Message'] || ioOutputData.Message || ioOutputData['#text'] || '';
|
||||||
|
|
||||||
|
logger.info(`[SAP] Found XML structure - lt_io_output data extracted`);
|
||||||
|
logger.debug(`[SAP] XML properties keys:`, Object.keys(ioOutputData));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for JSON OData format
|
||||||
|
if (responseData.d) {
|
||||||
|
// OData format: { d: { ... } }
|
||||||
|
const data = responseData.d;
|
||||||
|
success = data.Success !== false && data.Message !== 'Error';
|
||||||
|
blockedAmount = extractBlockedAmount(data);
|
||||||
|
remainingBalance = extractRemainingBalance(data);
|
||||||
|
blockId = data.BlockId || data.Reference || data.RequestId || data.DocumentNumber || data.Sap_Reference_no || undefined;
|
||||||
|
} else if (ioOutputData) {
|
||||||
|
// XML parsed structure - extract from lt_io_output properties
|
||||||
|
// Available_Amount is the remaining balance after blocking
|
||||||
|
success = message.includes('Successful') || message.includes('Success') || !message.includes('Error');
|
||||||
|
blockedAmount = amount; // Use the amount we sent (from lt_io_input)
|
||||||
|
remainingBalance = extractRemainingBalance(ioOutputData); // Available_Amount from XML
|
||||||
|
// Try both namespace-prefixed and non-prefixed field names
|
||||||
|
blockId = ioOutputData['d:Sap_Reference_no'] ||
|
||||||
|
ioOutputData.Sap_Reference_no ||
|
||||||
|
ioOutputData['d:Reference'] ||
|
||||||
|
ioOutputData.Reference ||
|
||||||
|
ioOutputData['d:BlockId'] ||
|
||||||
|
ioOutputData.BlockId ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
logger.info(`[SAP] Extracted from XML lt_io_output:`, {
|
||||||
|
message,
|
||||||
|
availableAmount: remainingBalance,
|
||||||
|
sapReference: blockId,
|
||||||
|
allKeys: Object.keys(ioOutputData)
|
||||||
|
});
|
||||||
|
} else if (responseData.ls_response && Array.isArray(responseData.ls_response) && responseData.ls_response.length > 0) {
|
||||||
|
// Response in ls_response array
|
||||||
|
const responseItem = responseData.ls_response[0];
|
||||||
|
success = responseItem.Success !== false && responseItem.Message !== 'Error';
|
||||||
|
blockedAmount = extractBlockedAmount(responseItem);
|
||||||
|
remainingBalance = extractRemainingBalance(responseItem);
|
||||||
|
blockId = responseItem.BlockId || responseItem.Reference || responseItem.DocumentNumber || undefined;
|
||||||
|
} else if (responseData.lt_io_output && Array.isArray(responseData.lt_io_output) && responseData.lt_io_output.length > 0) {
|
||||||
|
// Response in lt_io_output array (JSON format)
|
||||||
|
const outputItem = responseData.lt_io_output[0];
|
||||||
|
success = outputItem.Success !== false && outputItem.Message !== 'Error';
|
||||||
|
blockedAmount = extractBlockedAmount(outputItem);
|
||||||
|
remainingBalance = extractRemainingBalance(outputItem);
|
||||||
|
blockId = outputItem.BlockId || outputItem.Reference || outputItem.DocumentNumber || outputItem.Sap_Reference_no || undefined;
|
||||||
|
} else if (responseData.Success !== undefined) {
|
||||||
|
// Direct success field
|
||||||
|
success = responseData.Success === true || responseData.Success === 'true';
|
||||||
|
blockedAmount = extractBlockedAmount(responseData);
|
||||||
|
remainingBalance = extractRemainingBalance(responseData);
|
||||||
|
blockId = responseData.BlockId || responseData.Reference || responseData.DocumentNumber || undefined;
|
||||||
|
} else {
|
||||||
|
// If no clear success indicator, assume success if status is 200/201
|
||||||
|
success = true;
|
||||||
|
blockedAmount = amount;
|
||||||
|
remainingBalance = 0; // Will be calculated in dealerClaim.service
|
||||||
|
logger.warn('[SAP] Budget block response structure unclear, assuming success');
|
||||||
|
logger.warn('[SAP] Response data keys:', Object.keys(responseData || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log what we extracted
|
||||||
|
logger.info(`[SAP] Extracted from response:`, {
|
||||||
|
success,
|
||||||
|
blockedAmount,
|
||||||
|
remainingBalance,
|
||||||
|
blockId,
|
||||||
|
note: remainingBalance === 0 ? 'Remaining balance will be calculated from availableBalance - blockedAmount' : 'Remaining balance from SAP response'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
logger.info(`[SAP] Budget blocked successfully for IO ${ioNumber}. Blocked: ${blockedAmount}, Remaining: ${remainingBalance}`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
blockId: blockId || `BLOCK-${Date.now()}`,
|
||||||
|
blockedAmount,
|
||||||
|
remainingBalance
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const errorMessage = responseData.Message || responseData.Error || 'Budget blocking failed';
|
||||||
|
logger.error(`[SAP] Budget blocking failed for IO ${ioNumber}: ${errorMessage}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 0,
|
||||||
|
error: errorMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (response.status === 401 || response.status === 403) {
|
||||||
|
logger.error(`[SAP] Authentication failed during budget blocking (Status: ${response.status}) - check SAP credentials`);
|
||||||
|
logger.error(`[SAP] Response data:`, response.data);
|
||||||
|
logger.error(`[SAP] Response headers:`, response.headers);
|
||||||
|
|
||||||
|
// Check if it's actually a CSRF error disguised as auth error
|
||||||
|
const responseText = JSON.stringify(response.data || {});
|
||||||
|
if (responseText.includes('CSRF') || responseText.includes('csrf') || responseText.includes('token')) {
|
||||||
|
logger.error('[SAP] This might be a CSRF token validation error, not authentication');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 0,
|
||||||
|
error: 'SAP CSRF token validation failed - token may have expired or be invalid'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 0,
|
||||||
|
error: `SAP authentication failed (${response.status}) - check SAP credentials`
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Handle 400 Bad Request - usually means invalid request format
|
||||||
|
let errorMessage = `SAP API returned status ${response.status}`;
|
||||||
|
|
||||||
|
if (response.status === 400) {
|
||||||
|
errorMessage = 'SAP API returned 400 Bad Request - check request payload format';
|
||||||
|
|
||||||
|
// Try to extract error message from response
|
||||||
|
if (response.data) {
|
||||||
|
try {
|
||||||
|
const errorData = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
|
||||||
|
|
||||||
|
// Check for common SAP error fields
|
||||||
|
if (errorData.error) {
|
||||||
|
errorMessage = errorData.error.message?.value || errorData.error.message || errorMessage;
|
||||||
|
} else if (errorData.message) {
|
||||||
|
errorMessage = errorData.message;
|
||||||
|
} else if (errorData.Message) {
|
||||||
|
errorMessage = errorData.Message;
|
||||||
|
} else if (errorData.d?.error) {
|
||||||
|
errorMessage = errorData.d.error.message?.value || errorData.d.error.message || errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(`[SAP] SAP Error Details:`, JSON.stringify(errorData, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`[SAP] Error parsing response data:`, response.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(`[SAP] Unexpected response status during budget blocking: ${response.status}`);
|
||||||
|
logger.error(`[SAP] Response data:`, response.data);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 0,
|
||||||
|
error: errorMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[SAP] Error blocking budget:', error);
|
const axiosError = error as AxiosError;
|
||||||
return {
|
|
||||||
success: false,
|
if (axiosError.response) {
|
||||||
blockedAmount: 0,
|
// SAP returned an error response
|
||||||
remainingBalance: 0,
|
logger.error(`[SAP] Error blocking budget for IO ${ioNumber}:`, {
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
status: axiosError.response.status,
|
||||||
};
|
statusText: axiosError.response.statusText,
|
||||||
|
data: axiosError.response.data
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 0,
|
||||||
|
error: `SAP API error: ${axiosError.response.status} ${axiosError.response.statusText}`
|
||||||
|
};
|
||||||
|
} else if (axiosError.request) {
|
||||||
|
// Request was made but no response received
|
||||||
|
logger.error(`[SAP] No response from SAP API during budget blocking for IO ${ioNumber}:`, axiosError.message);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 0,
|
||||||
|
error: `SAP API connection failed: ${axiosError.message}`
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Error setting up request
|
||||||
|
logger.error('[SAP] Error setting up budget blocking request:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
blockedAmount: 0,
|
||||||
|
remainingBalance: 0,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user