using GB5Shared.DTO.Framework.Login; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using PayRollBLL.SAPPaySlip.Client; using PayRollBLL.SAPPaySlip.Config; using PayRollBLL.SAPPaySlip.DTO; using PayRollDAL.CustomeCode.SAPPaySlip; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; namespace PayRollBLL.SAPPaySlip { public class SAPPaySlipBLL : ISAPPaySlipBLL { private readonly ISapPaySlipApiClient _sapClient; private readonly ISAPPaySlipDAL _dal; private readonly SapPaySlipOptions _options; private readonly ILogger _logger; public SAPPaySlipBLL( ISapPaySlipApiClient sapClient, ISAPPaySlipDAL dal, IOptions options, ILogger logger) { _sapClient = sapClient; _dal = dal; _options = options.Value; _logger = logger; } public async Task> GetPaySlipAsync( string employeeId, string fiscalYear, string month, LoginDTO login, CancellationToken ct) { // ── Step 1: Validate query parameters ──────────────────────────────────────── ValidateQueryParameters(employeeId, fiscalYear, month); // ── Step 2: Resolve SAP credentials from configuration ──────────────────────── string username = ResolveConfiguredUsername(); string password = ResolveConfiguredPassword(); // ── Step 4: Resolve SAP client / mandt from morganizationunit ──────────────── string sapClient = await ResolveSapClientCodeAsync(login, ct).ConfigureAwait(false); // Normalise month to 2-digit ("1" → "01") after validation confirms 1–12. month = month.Trim().PadLeft(2, '0'); _logger.LogInformation( "Fetching SAP PaySlip — EmployeeID={EmployeeID}, FiscalYear={FiscalYear}, " + "Month={Month}, SapClient={SapClient}", employeeId, fiscalYear, month, sapClient); // ── Step 5: Call SAP OData API ──────────────────────────────────────────────── var apiResult = await _sapClient .GetAsync(employeeId, fiscalYear.Trim(), month, username, password, sapClient, ct) .ConfigureAwait(false); // ── Step 6: Validate HTTP response ──────────────────────────────────────────── ValidateApiResponse(apiResult, employeeId, fiscalYear, month); // ── Step 7: Deserialize OData envelope ──────────────────────────────────────── SapPaySlipODataResponse? odataResponse = DeserializeODataResponse(apiResult.ResponseBody, employeeId, fiscalYear, month); var items = odataResponse?.D?.Results ?? new List(); if (items.Count == 0) { _logger.LogInformation( "No payslip found in SAP for EmployeeID={EmployeeID}, " + "FiscalYear={FiscalYear}, Month={Month}", employeeId, fiscalYear, month); return new List(); } // ── Step 8: Validate each item — including Base64 integrity ─────────────────── var results = new List(items.Count); for (int i = 0; i < items.Count; i++) { var item = items[i]; ValidateODataItem(item, index: i + 1, employeeId, fiscalYear, month); results.Add(new SapPaySlipResultDTO( item.EmployeeID, item.FiscalYear, item.Month, item.PayslipForm)); } _logger.LogInformation( "SAP returned {Count} valid payslip record(s) for EmployeeID={EmployeeID}, " + "FiscalYear={FiscalYear}, Month={Month}", results.Count, employeeId, fiscalYear, month); return results; } // ───────────────────────────────────────────────────────────────────────────────── // Resolution helpers // ───────────────────────────────────────────────────────────────────────────────── private string ResolveConfiguredUsername() { if (string.IsNullOrWhiteSpace(_options.Username)) throw new InvalidOperationException( "SAP Username is not configured. " + "Set SapPaySlip:Username in appsettings.json or the corresponding " + "environment variable, then restart the service."); return _options.Username; } private string ResolveConfiguredPassword() { if (string.IsNullOrWhiteSpace(_options.Password)) throw new InvalidOperationException( "SAP Password is not configured. " + "Set SapPaySlip:Password in appsettings.json or the corresponding " + "environment variable, then restart the service."); return _options.Password; } private async Task ResolveSapClientCodeAsync(LoginDTO login, CancellationToken ct) { string? sapClient = await _dal .GetSapClientCodeAsync(login.ClientId, login, ct) .ConfigureAwait(false); if (string.IsNullOrWhiteSpace(sapClient)) throw new InvalidOperationException( $"No SAP client code found in morganizationunit for ClientId={login.ClientId}. " + "Ensure the organization unit has an OrganizationUnitCode configured " + "that matches the SAP client / mandt number (e.g. '633')."); return sapClient.Trim(); } // ───────────────────────────────────────────────────────────────────────────────── // Validation helpers // ───────────────────────────────────────────────────────────────────────────────── private static void ValidateQueryParameters(string employeeId, string fiscalYear, string month) { var errors = new List(); // ── EmployeeID ──────────────────────────────────────────────────────────────── if (string.IsNullOrWhiteSpace(employeeId)) errors.Add("EmployeeID is required."); // ── FiscalYear ──────────────────────────────────────────────────────────────── if (string.IsNullOrWhiteSpace(fiscalYear)) { errors.Add("FiscalYear is required."); } else { string fy = fiscalYear.Trim(); if (fy.Length != 4 || !int.TryParse(fy, out int year)) errors.Add( $"FiscalYear must be a 4-digit numeric year (e.g. '2025'). " + $"Received: '{fy}'."); else if (year < 2000 || year > 2100) errors.Add( $"FiscalYear must be between 2000 and 2100. Received: {year}."); } // ── Month ───────────────────────────────────────────────────────────────────── if (string.IsNullOrWhiteSpace(month)) { errors.Add("Month is required."); } else if (!int.TryParse(month.Trim(), out int m)) { errors.Add( $"Month must be a numeric value between 1 and 12. Received: '{month}'."); } else if (m < 1 || m > 12) { errors.Add( $"Month must be between 1 and 12. Received: {m}."); } if (errors.Count > 0) throw new ArgumentException( $"Request validation failed ({errors.Count} error(s)):\n" + string.Join("\n", errors.Select((e, i) => $" {i + 1}. {e}"))); } private void ValidateApiResponse( SapPaySlipApiResultDTO apiResult, string employeeId, string fiscalYear, string month) { if (apiResult.IsSuccess) return; var sb = new StringBuilder(); if (apiResult.StatusCode == 0) { // Network-level failure — no HTTP response received from SAP. sb.AppendLine("Cannot reach the SAP PaySlip server."); sb.AppendLine(apiResult.NetworkFailureReason ?? "Unknown network error."); } else { // HTTP-level failure — SAP responded but indicated an error. string hint = apiResult.StatusCode switch { 400 => "The SAP request was malformed. " + "Check the EmployeeID format or OData filter syntax.", 401 => "SAP authentication failed. " + "Verify SapPaySlip:Username and SapPaySlip:Password in appsettings.json.", 403 => "SAP denied access. " + "The configured SAP user may not have authorisation to access ZPY_PAYSLIP_SRV.", 404 => "SAP OData service endpoint not found. " + "Verify that ZPY_PAYSLIP_SRV is activated in SAP " + "(transaction /IWFND/MAINT_SERVICE) and that SapPaySlip:BaseUrl " + "points to the correct SAP system.", 408 or 504 => "The SAP server timed out processing the request. " + "Retry later or contact the SAP BASIS team.", 500 => "SAP internal server error. Contact the SAP BASIS team.", 503 => "SAP service is temporarily unavailable. " + "Retry later or contact the SAP BASIS team.", _ => $"SAP responded with HTTP {apiResult.StatusCode}." }; sb.AppendLine($"SAP HTTP {apiResult.StatusCode}: {hint}"); if (!string.IsNullOrWhiteSpace(apiResult.SapErrorCode)) sb.AppendLine($"SAP Error Code : {apiResult.SapErrorCode}"); if (!string.IsNullOrWhiteSpace(apiResult.SapErrorMessage)) sb.AppendLine($"SAP Error Message : {apiResult.SapErrorMessage}"); if (!string.IsNullOrWhiteSpace(apiResult.SapTransactionId)) sb.AppendLine($"SAP Transaction ID: {apiResult.SapTransactionId} " + "(provide this to the SAP BASIS team for server-side log tracing)"); } if (!string.IsNullOrWhiteSpace(apiResult.AttemptedUri)) sb.AppendLine($"Endpoint : {apiResult.AttemptedUri}"); sb.Append($"Context : EmployeeID={employeeId}, FiscalYear={fiscalYear}, Month={month}"); string errorMessage = sb.ToString().Trim(); _logger.LogError( "SAP PaySlip API failure — EmployeeID={EmployeeID}, FiscalYear={FiscalYear}, " + "Month={Month}, StatusCode={StatusCode}, SapErrorCode={SapErrorCode}, " + "SapErrorMessage={SapErrorMessage}, SapTransactionId={SapTransactionId}, " + "Uri={AttemptedUri}", employeeId, fiscalYear, month, apiResult.StatusCode, apiResult.SapErrorCode, apiResult.SapErrorMessage, apiResult.SapTransactionId, apiResult.AttemptedUri); throw new InvalidOperationException(errorMessage); } private SapPaySlipODataResponse? DeserializeODataResponse( string responseBody, string employeeId, string fiscalYear, string month) { if (string.IsNullOrWhiteSpace(responseBody)) { _logger.LogWarning( "SAP PaySlip API returned an empty response body for " + "EmployeeID={EmployeeID}, FiscalYear={FiscalYear}, Month={Month}.", employeeId, fiscalYear, month); return null; } try { return JsonSerializer.Deserialize( responseBody, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); } catch (JsonException ex) { _logger.LogError(ex, "Failed to deserialize SAP OData response for " + "EmployeeID={EmployeeID}, FiscalYear={FiscalYear}, Month={Month}. " + "Response preview: {Preview}", employeeId, fiscalYear, month, TruncateForLog(responseBody, 300)); throw new InvalidOperationException( "SAP PaySlip API returned an unrecognised response format. " + $"JSON deserialization error: {ex.Message}"); } } private void ValidateODataItem( SapPaySlipODataItem item, int index, string employeeId, string fiscalYear, string month) { if (string.IsNullOrWhiteSpace(item.EmployeeID)) _logger.LogWarning( "SAP PaySlip result #{Index} — EmployeeID is empty in the SAP response " + "(filter was EmployeeID={EmployeeID}, FiscalYear={FiscalYear}, Month={Month}).", index, employeeId, fiscalYear, month); if (string.IsNullOrWhiteSpace(item.FiscalYear)) _logger.LogWarning( "SAP PaySlip result #{Index} (EmployeeID={EmployeeID}) — FiscalYear is empty.", index, item.EmployeeID); if (string.IsNullOrWhiteSpace(item.Month)) _logger.LogWarning( "SAP PaySlip result #{Index} (EmployeeID={EmployeeID}) — Month is empty.", index, item.EmployeeID); // ── PayslipForm: presence check ─────────────────────────────────────────────── if (string.IsNullOrWhiteSpace(item.PayslipForm)) { _logger.LogError( "SAP PaySlip result #{Index} (EmployeeID={EmployeeID}, " + "FiscalYear={FiscalYear}, Month={Month}) — PayslipForm is empty. " + "The payslip PDF has not been generated in SAP for this period.", index, item.EmployeeID, item.FiscalYear, item.Month); throw new InvalidOperationException( $"SAP returned a payslip record for EmployeeID '{item.EmployeeID}' " + $"(FiscalYear={item.FiscalYear}, Month={item.Month}) " + "but the PayslipForm (PDF data) is empty. " + "The payslip may not have been generated yet in SAP for this period."); } // ── PayslipForm: Base64 integrity check ─────────────────────────────────────── try { Convert.FromBase64String(item.PayslipForm); } catch (FormatException ex) { _logger.LogError(ex, "SAP PaySlip result #{Index} (EmployeeID={EmployeeID}, " + "FiscalYear={FiscalYear}, Month={Month}) — PayslipForm is not valid Base64. " + "Data length={Length} chars. First 100 chars: {Sample}", index, item.EmployeeID, item.FiscalYear, item.Month, item.PayslipForm.Length, item.PayslipForm.Length > 100 ? item.PayslipForm[..100] : item.PayslipForm); throw new InvalidOperationException( $"SAP returned corrupt payslip data for EmployeeID '{item.EmployeeID}' " + $"(FiscalYear={item.FiscalYear}, Month={item.Month}). " + "The PayslipForm field is not valid Base64-encoded PDF data. " + $"Error: {ex.Message}"); } _logger.LogDebug( "SAP PaySlip result #{Index} — EmployeeID={EmployeeID}, FiscalYear={FiscalYear}, " + "Month={Month}, PayslipForm Base64 length={Length} chars — VALID.", index, item.EmployeeID, item.FiscalYear, item.Month, item.PayslipForm.Length); } private static string TruncateForLog(string message, int maxLength = 500) { if (string.IsNullOrEmpty(message) || message.Length <= maxLength) return message; return message[..maxLength] + $"... [truncated — full length: {message.Length} chars]"; } } }