Return order sync & Update sync contract and client API logging

Changes order sync to return pending, posted, duplicate, failed, and skipped counts.

Updates the sync interface for result reporting and logs client API health, scan, and manual sync calls.
feature/centralized-offline-canteen
SYED MUSTUFA AHMED NAQVI 2026-06-01 15:14:26 +05:00
parent 1237878db2
commit 3df4bc2672
4 changed files with 85 additions and 14 deletions

View File

@ -4,6 +4,7 @@ using System.Text.Json;
using UtopiaCanteen.Shared;
using UtopiaCanteenSystem.Api;
using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services.Logging;
namespace UtopiaCanteenSystem.Services;
@ -32,15 +33,24 @@ public class CanteenBackendApiClient : ICanteenBackendApiClient
public async Task<bool> HealthCheckAsync(CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(BaseUrl))
{
FileLogger.Warn("ClientApi", "Health check skipped. Backend base URL is not configured.");
return false;
}
try
{
var url = $"{BaseUrl}/api/health";
var resp = await _http.GetFromJsonAsync<HealthResponse>(url, JsonOptions, cancellationToken).ConfigureAwait(false);
return resp != null && string.Equals(resp.Status, "ok", StringComparison.OrdinalIgnoreCase);
var ok = resp != null && string.Equals(resp.Status, "ok", StringComparison.OrdinalIgnoreCase);
if (ok)
FileLogger.Info("ClientApi", $"Health check succeeded. Url={BaseUrl}");
else
FileLogger.Warn("ClientApi", $"Health check failed (unexpected response). Url={BaseUrl}");
return ok;
}
catch (Exception ex)
{
FileLogger.Error("ClientApi", $"Health check failed. Url={BaseUrl}", ex);
Logger.Log(ex, "CanteenBackendApiClient.HealthCheckAsync");
return false;
}
@ -79,6 +89,10 @@ public class CanteenBackendApiClient : ICanteenBackendApiClient
if (string.IsNullOrWhiteSpace(BaseUrl))
return new ScanResult(false, "Backend URL is not configured.", 0);
FileLogger.Info(
"ClientApi",
$"API scan request started. CardId={LogMasking.MaskCardId(cardId)}, POST {BaseUrl}/api/rfid/scan");
try
{
var url = $"{BaseUrl}/api/rfid/scan";
@ -94,15 +108,20 @@ public class CanteenBackendApiClient : ICanteenBackendApiClient
if (!response.IsSuccessStatusCode)
{
var err = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
return new ScanResult(false,
var fail = new ScanResult(false,
$"Backend error ({(int)response.StatusCode}): {Truncate(err, 200)}", 0);
FileLogger.Warn("ClientApi", $"API scan request failed. Success=false, Message={fail.Message}");
return fail;
}
var dto = response.Content.ReadFromJsonAsync<ScanResponse>(JsonOptions).GetAwaiter().GetResult();
if (dto == null)
{
FileLogger.Warn("ClientApi", "API scan request failed. Invalid response from backend.");
return new ScanResult(false, "Invalid response from backend.", 0);
}
return new ScanResult(
var scanResult = new ScanResult(
dto.Success,
dto.Message ?? string.Empty,
dto.CooldownSecondsRemaining,
@ -110,9 +129,15 @@ public class CanteenBackendApiClient : ICanteenBackendApiClient
(MealSession)dto.MealSession,
dto.EmployeeSiteId,
dto.CurrentSiteId);
FileLogger.Info(
"ClientApi",
$"API scan request completed. Success={scanResult.Success}, Message={scanResult.Message}");
return scanResult;
}
catch (Exception ex)
{
FileLogger.Error("ClientApi", "API scan request failed with exception.", ex);
Logger.Log(ex, "CanteenBackendApiClient.ProcessScanDetailed");
return new ScanResult(false, "Cannot reach backend: " + ex.Message, 0);
}
@ -172,29 +197,40 @@ public class CanteenBackendApiClient : ICanteenBackendApiClient
};
}
var url = $"{BaseUrl}{path}";
FileLogger.Info("ClientApi", $"POST {url} started.");
try
{
var url = $"{BaseUrl}{path}";
using var response = await _http.PostAsync(url, null, cancellationToken).ConfigureAwait(false);
var dto = await response.Content.ReadFromJsonAsync<ManualSyncResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
if (dto != null)
{
FileLogger.Info(
"ClientApi",
$"POST {path} completed. Success={dto.Success}, Message={dto.Message}, Details={dto.Details}");
return dto;
}
if (!response.IsSuccessStatusCode)
{
var err = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return new ManualSyncResponse
var fail = new ManualSyncResponse
{
Success = false,
Message = $"Backend error ({(int)response.StatusCode}): {Truncate(err, 200)}"
};
FileLogger.Warn("ClientApi", $"POST {path} failed. {fail.Message}");
return fail;
}
FileLogger.Warn("ClientApi", $"POST {path} failed. Invalid response from backend.");
return new ManualSyncResponse { Success = false, Message = "Invalid response from backend." };
}
catch (Exception ex)
{
FileLogger.Error("ClientApi", $"POST {path} failed with exception.", ex);
Logger.Log(ex, $"CanteenBackendApiClient.PostJobAsync{path}");
return new ManualSyncResponse
{

View File

@ -5,6 +5,6 @@ namespace UtopiaCanteenSystem.Services;
/// </summary>
public interface ISyncService
{
/// <summary>Runs one sync: POST unsynced records to API and mark as synced on success.</summary>
Task SyncNowAsync(CancellationToken cancellationToken = default);
/// <summary>Runs one sync: POST unsynced records to production/HRMS and mark as synced on success.</summary>
Task<SyncNowResult> SyncNowAsync(CancellationToken cancellationToken = default);
}

10
Services/SyncNowResult.cs Normal file
View File

@ -0,0 +1,10 @@
namespace UtopiaCanteenSystem.Services;
public sealed class SyncNowResult
{
public int PendingCount { get; init; }
public int PostedCount { get; init; }
public int DuplicatesSkippedCount { get; init; }
public int FailedCount { get; init; }
public bool SkippedNoConnection { get; init; }
}

View File

@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using MySqlConnector;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services.Logging;
namespace UtopiaCanteenSystem.Services;
@ -20,13 +21,13 @@ public class SyncService : ISyncService
_configService = configService;
}
public async Task SyncNowAsync(CancellationToken cancellationToken = default)
public async Task<SyncNowResult> SyncNowAsync(CancellationToken cancellationToken = default)
{
var productionConnStr = _configService.GetMySqlConnectionString();
if (string.IsNullOrWhiteSpace(productionConnStr))
{
System.Diagnostics.Debug.WriteLine("MySQL connection string (production) not configured; skipping sync.");
return;
FileLogger.Warn("OrderSync", "MySQL connection string is not configured; skipping order sync.");
return new SyncNowResult { SkippedNoConnection = true };
}
var hrmsConnStr = _configService.GetHrmsLookupConnectionString();
@ -41,11 +42,20 @@ public class SyncService : ISyncService
.ConfigureAwait(false);
}
FileLogger.Info("OrderSync", $"Order sync started. PendingCount={toSync.Count}");
var posted = 0;
var duplicatesSkipped = 0;
var failed = 0;
// Always run day-end cleanup (remove synced rows from previous days), even when there's nothing to sync.
await CleanupOldSyncedRowsAsync(cancellationToken).ConfigureAwait(false);
if (toSync.Count == 0)
return;
{
FileLogger.Info("OrderSync", "Order sync completed. No pending orders.");
return new SyncNowResult();
}
// Production: lunch_order_transactions (GetMySqlConnectionString)
const string insertTxnSql = @"
@ -135,6 +145,7 @@ public class SyncService : ISyncService
if (await IsDuplicateMealInProductionAsync(record, hrmsConnStr, cancellationToken).ConfigureAwait(false))
{
syncedIds.Add(record.Id);
duplicatesSkipped++;
continue;
}
@ -274,11 +285,12 @@ public class SyncService : ISyncService
}
syncedIds.Add(record.Id);
posted++;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Sync record failed (SQLite Id={record.Id}): {ex.Message}");
// Leave unsynced; will retry later.
failed++;
FileLogger.Error("OrderSync", $"UIND/HRMS post failed for SQLite Id={record.Id}, CardId={LogMasking.MaskCardId(record.CardId)}.", ex);
}
}
@ -300,8 +312,21 @@ public class SyncService : ISyncService
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Sync cleanup failed: {ex.Message}");
FileLogger.Warn("OrderSync", "Sync cleanup failed.", ex);
}
var result = new SyncNowResult
{
PendingCount = toSync.Count,
PostedCount = posted,
DuplicatesSkippedCount = duplicatesSkipped,
FailedCount = failed
};
FileLogger.Info(
"OrderSync",
$"Order sync completed. Pending={result.PendingCount}, Posted={result.PostedCount}, " +
$"DuplicatesSkipped={result.DuplicatesSkippedCount}, Failed={result.FailedCount}.");
return result;
}
private static string GenerateLunchOrderCode(long id, DateTime date)