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}}
|
||||
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",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
@ -793,18 +794,6 @@
|
||||
"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": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz",
|
||||
@ -1651,6 +1640,36 @@
|
||||
"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": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/vertexai/-/vertexai-1.10.0.tgz",
|
||||
@ -6346,9 +6365,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"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==",
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.3.tgz",
|
||||
"integrity": "sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@ -6357,7 +6376,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strnum": "^1.1.1"
|
||||
"strnum": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
@ -10791,9 +10810,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
|
||||
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
||||
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
||||
@ -35,6 +35,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
|
||||
@ -5,6 +5,7 @@ import { ResponseHandler } from '../utils/responseHandler';
|
||||
import logger from '../utils/logger';
|
||||
import { gcsStorageService } from '../services/gcsStorage.service';
|
||||
import { Document } from '../models/Document';
|
||||
import { InternalOrder } from '../models/InternalOrder';
|
||||
import { constants } from '../config/constants';
|
||||
import { sapIntegrationService } from '../services/sapIntegration.service';
|
||||
import fs from 'fs';
|
||||
@ -653,6 +654,8 @@ export class DealerClaimController {
|
||||
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(
|
||||
requestId,
|
||||
{
|
||||
@ -660,23 +663,43 @@ export class DealerClaimController {
|
||||
ioRemark: ioRemark || '',
|
||||
availableBalance: parseFloat(availableBalance),
|
||||
blockedAmount: blockAmount,
|
||||
remainingBalance: remainingBalance ? parseFloat(remainingBalance) : parseFloat(availableBalance) - blockAmount,
|
||||
// remainingBalance will be calculated by the service from SAP's response
|
||||
},
|
||||
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');
|
||||
} else if (ioNumber && ioRemark !== undefined) {
|
||||
// Save IO details (ioNumber, ioRemark) even without blocking amount
|
||||
// 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(
|
||||
requestId,
|
||||
{
|
||||
ioNumber,
|
||||
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,
|
||||
remainingBalance: remainingBalance ? parseFloat(remainingBalance) : 0,
|
||||
// Don't pass remainingBalance - preserve existing value from previous blocking
|
||||
},
|
||||
userId
|
||||
);
|
||||
|
||||
@ -480,16 +480,30 @@ export class ApprovalService {
|
||||
});
|
||||
|
||||
// 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') {
|
||||
// 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 approverName = (nextLevel as any).approverName || '';
|
||||
const isSystemEmail = approverEmail.toLowerCase() === 'system@royalenfield.com'
|
||||
|| 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)
|
||||
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}`,
|
||||
body: `${(wf as any).title}`,
|
||||
requestNumber: (wf as any).requestNumber,
|
||||
@ -500,6 +514,8 @@ export class ApprovalService {
|
||||
actionRequired: true
|
||||
});
|
||||
|
||||
logger.info(`[Approval] Assignment notification sent successfully to ${nextApproverName} for level ${nextLevelNumber}`);
|
||||
|
||||
// Log assignment activity for the next approver
|
||||
activityService.log({
|
||||
requestId: level.requestId,
|
||||
@ -507,7 +523,7 @@ export class ApprovalService {
|
||||
user: { userId: level.approverId, name: level.approverName },
|
||||
timestamp: new Date().toISOString(),
|
||||
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,
|
||||
userAgent: requestMetadata?.userAgent || undefined
|
||||
});
|
||||
@ -517,6 +533,31 @@ export class ApprovalService {
|
||||
} else {
|
||||
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 {
|
||||
// No next level found but not final approver - this shouldn't happen
|
||||
|
||||
@ -1273,15 +1273,24 @@ export class DealerClaimService {
|
||||
|
||||
if (!created) {
|
||||
// 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({
|
||||
ioNumber: ioData.ioNumber,
|
||||
ioRemark: ioData.ioRemark || '',
|
||||
// Only update balance fields if provided
|
||||
...(ioData.availableBalance !== undefined && { ioAvailableBalance: ioData.availableBalance }),
|
||||
...(ioData.remainingBalance !== undefined && { ioRemainingBalance: ioData.remainingBalance }),
|
||||
// Don't update balance fields for existing records - preserve values from previous blocking
|
||||
// Only update organizedBy and organizedAt
|
||||
organizedBy: organizedBy || internalOrder.organizedBy,
|
||||
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}`, {
|
||||
@ -1318,41 +1327,101 @@ export class DealerClaimService {
|
||||
}
|
||||
|
||||
const finalBlockedAmount = blockResult.blockedAmount;
|
||||
const remainingBalance = blockResult.remainingBalance;
|
||||
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)
|
||||
const organizedBy = organizedByUserId || null;
|
||||
|
||||
// Create or update Internal Order record (only when blocking)
|
||||
const [internalOrder, created] = await InternalOrder.findOrCreate({
|
||||
where: { requestId },
|
||||
defaults: {
|
||||
const ioRecordData = {
|
||||
requestId,
|
||||
ioNumber: ioData.ioNumber,
|
||||
ioRemark: ioData.ioRemark || '',
|
||||
ioAvailableBalance: availableBalance,
|
||||
ioBlockedAmount: finalBlockedAmount,
|
||||
ioRemainingBalance: remainingBalance,
|
||||
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({
|
||||
where: { requestId },
|
||||
defaults: ioRecordData
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
// Update existing IO record
|
||||
await internalOrder.update({
|
||||
ioNumber: ioData.ioNumber,
|
||||
ioRemark: ioData.ioRemark || '',
|
||||
ioAvailableBalance: availableBalance,
|
||||
ioBlockedAmount: finalBlockedAmount,
|
||||
ioRemainingBalance: remainingBalance,
|
||||
// Update to current user who is blocking
|
||||
organizedBy: organizedBy || internalOrder.organizedBy,
|
||||
organizedAt: new Date(),
|
||||
status: IOStatus.BLOCKED,
|
||||
// Update existing IO record - explicitly update all fields including remainingBalance
|
||||
logger.info(`[DealerClaimService] Updating existing IO record for request: ${requestId}`);
|
||||
logger.info(`[DealerClaimService] Update data:`, {
|
||||
ioRemainingBalance: ioRecordData.ioRemainingBalance,
|
||||
ioBlockedAmount: ioRecordData.ioBlockedAmount,
|
||||
ioAvailableBalance: ioRecordData.ioAvailableBalance
|
||||
});
|
||||
|
||||
// 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
|
||||
@ -1367,7 +1436,8 @@ export class DealerClaimService {
|
||||
logger.info(`[DealerClaimService] IO blocked for request: ${requestId}`, {
|
||||
ioNumber: ioData.ioNumber,
|
||||
blockedAmount: finalBlockedAmount,
|
||||
remainingBalance
|
||||
availableBalance,
|
||||
remainingBalance: finalRemainingBalance
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[DealerClaimService] Error blocking IO:', error);
|
||||
|
||||
@ -143,8 +143,17 @@ class NotificationService {
|
||||
// 1. Check admin + user preferences for in-app notifications
|
||||
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) {
|
||||
const notification = await Notification.create({
|
||||
try {
|
||||
notification = await Notification.create({
|
||||
userId,
|
||||
requestId: payload.requestId,
|
||||
notificationType: payload.type || 'general',
|
||||
@ -165,7 +174,7 @@ class NotificationService {
|
||||
} as any);
|
||||
|
||||
sentVia.push('IN_APP');
|
||||
logger.info(`[Notification] Created in-app notification for user ${userId}: ${payload.title}`);
|
||||
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 {
|
||||
@ -175,14 +184,20 @@ class NotificationService {
|
||||
notification: notification.toJSON(),
|
||||
...payload
|
||||
});
|
||||
logger.info(`[Notification] Emitted socket event to user ${userId}`);
|
||||
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 (notificationError) {
|
||||
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)
|
||||
if (user.pushNotificationsEnabled && canSendInApp) {
|
||||
if (user.pushNotificationsEnabled && canSendInApp && notification) {
|
||||
let subs = this.userIdToSubscriptions.get(userId) || [];
|
||||
// Load from DB if memory empty
|
||||
if (subs.length === 0) {
|
||||
@ -290,18 +305,24 @@ class NotificationService {
|
||||
}
|
||||
|
||||
// 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'
|
||||
? 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
|
||||
|
||||
console.log(`[DEBUG Email] Should send email: ${shouldSend}`);
|
||||
console.log(`[DEBUG Email] Should send email: ${shouldSend} for type: ${payload.type}, userId: ${userId}`);
|
||||
|
||||
if (!shouldSend) {
|
||||
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;
|
||||
}
|
||||
|
||||
logger.info(`[Email] Sending email notification to user ${userId} for type: ${payload.type}, requestId: ${payload.requestId}`);
|
||||
|
||||
// Trigger email based on notification type
|
||||
// Email service will fetch additional data as needed
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Integrates with SAP OData service via Zscaler URL
|
||||
*/
|
||||
export class SAPIntegrationService {
|
||||
private sapBaseUrl: string;
|
||||
private sapApiKey?: string;
|
||||
private sapUsername?: 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() {
|
||||
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;
|
||||
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
|
||||
*/
|
||||
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,37 +264,191 @@ export class SAPIntegrationService {
|
||||
};
|
||||
}
|
||||
|
||||
// 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
|
||||
// };
|
||||
const sapClient = this.createSapClient();
|
||||
|
||||
// SAP OData endpoint: GetSenderDataSet with filter on IONumber
|
||||
// Service name is configurable via SAP_SERVICE_NAME env variable
|
||||
const endpoint = this.buildODataEndpoint('GetSenderDataSet');
|
||||
|
||||
// Build OData query parameters matching the working URL format
|
||||
// $filter: Filter by IO number
|
||||
// $select: Select specific fields (Sender, ResponseDate, GetIODetailsSet01)
|
||||
// $expand: Expand the nested GetIODetailsSet01 entity set to get IO details
|
||||
// $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}`);
|
||||
|
||||
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)'
|
||||
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) {
|
||||
logger.error('[SAP] Error validating IO number:', error);
|
||||
const axiosError = error as AxiosError;
|
||||
|
||||
if (axiosError.response) {
|
||||
// SAP returned an error response
|
||||
logger.error(`[SAP] Error validating IO number ${ioNumber}:`, {
|
||||
status: axiosError.response.status,
|
||||
statusText: axiosError.response.statusText,
|
||||
data: axiosError.response.data
|
||||
});
|
||||
|
||||
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,
|
||||
@ -99,6 +460,7 @@ export class SAPIntegrationService {
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block budget in SAP for a claim request
|
||||
@ -131,36 +493,414 @@ export class SAPIntegrationService {
|
||||
};
|
||||
}
|
||||
|
||||
// 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
|
||||
// };
|
||||
const sapClient = this.createSapClient();
|
||||
|
||||
logger.warn('[SAP] SAP budget blocking not implemented, simulating block');
|
||||
// SAP OData endpoint for budget blocking
|
||||
// Service: ZFI_BUDGET_BLOCK_API_SRV
|
||||
// Entity Set: RequesterInputSet
|
||||
const endpoint = `/sap/opu/odata/sap/${this.sapBlockServiceName}/RequesterInputSet`;
|
||||
|
||||
// Format current date/time in ISO format: "2025-08-29T10:51:00"
|
||||
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: `BLOCK-${Date.now()}`,
|
||||
blockedAmount: amount,
|
||||
remainingBalance: 1000000 - amount,
|
||||
error: 'SAP API not implemented - budget blocking simulated'
|
||||
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) {
|
||||
logger.error('[SAP] Error blocking budget:', error);
|
||||
const axiosError = error as AxiosError;
|
||||
|
||||
if (axiosError.response) {
|
||||
// SAP returned an error response
|
||||
logger.error(`[SAP] Error blocking budget for IO ${ioNumber}:`, {
|
||||
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,
|
||||
@ -169,6 +909,7 @@ export class SAPIntegrationService {
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release blocked budget in SAP
|
||||
|
||||
Loading…
Reference in New Issue
Block a user