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

View File

@ -5,6 +5,6 @@ namespace UtopiaCanteenSystem.Services;
/// </summary> /// </summary>
public interface ISyncService public interface ISyncService
{ {
/// <summary>Runs one sync: POST unsynced records to API and mark as synced on success.</summary> /// <summary>Runs one sync: POST unsynced records to production/HRMS and mark as synced on success.</summary>
Task SyncNowAsync(CancellationToken cancellationToken = default); 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 MySqlConnector;
using UtopiaCanteenSystem.Data; using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models; using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services.Logging;
namespace UtopiaCanteenSystem.Services; namespace UtopiaCanteenSystem.Services;
@ -20,13 +21,13 @@ public class SyncService : ISyncService
_configService = configService; _configService = configService;
} }
public async Task SyncNowAsync(CancellationToken cancellationToken = default) public async Task<SyncNowResult> SyncNowAsync(CancellationToken cancellationToken = default)
{ {
var productionConnStr = _configService.GetMySqlConnectionString(); var productionConnStr = _configService.GetMySqlConnectionString();
if (string.IsNullOrWhiteSpace(productionConnStr)) if (string.IsNullOrWhiteSpace(productionConnStr))
{ {
System.Diagnostics.Debug.WriteLine("MySQL connection string (production) not configured; skipping sync."); FileLogger.Warn("OrderSync", "MySQL connection string is not configured; skipping order sync.");
return; return new SyncNowResult { SkippedNoConnection = true };
} }
var hrmsConnStr = _configService.GetHrmsLookupConnectionString(); var hrmsConnStr = _configService.GetHrmsLookupConnectionString();
@ -41,11 +42,20 @@ public class SyncService : ISyncService
.ConfigureAwait(false); .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. // Always run day-end cleanup (remove synced rows from previous days), even when there's nothing to sync.
await CleanupOldSyncedRowsAsync(cancellationToken).ConfigureAwait(false); await CleanupOldSyncedRowsAsync(cancellationToken).ConfigureAwait(false);
if (toSync.Count == 0) if (toSync.Count == 0)
return; {
FileLogger.Info("OrderSync", "Order sync completed. No pending orders.");
return new SyncNowResult();
}
// Production: lunch_order_transactions (GetMySqlConnectionString) // Production: lunch_order_transactions (GetMySqlConnectionString)
const string insertTxnSql = @" const string insertTxnSql = @"
@ -135,6 +145,7 @@ public class SyncService : ISyncService
if (await IsDuplicateMealInProductionAsync(record, hrmsConnStr, cancellationToken).ConfigureAwait(false)) if (await IsDuplicateMealInProductionAsync(record, hrmsConnStr, cancellationToken).ConfigureAwait(false))
{ {
syncedIds.Add(record.Id); syncedIds.Add(record.Id);
duplicatesSkipped++;
continue; continue;
} }
@ -274,11 +285,12 @@ public class SyncService : ISyncService
} }
syncedIds.Add(record.Id); syncedIds.Add(record.Id);
posted++;
} }
catch (Exception ex) catch (Exception ex)
{ {
System.Diagnostics.Debug.WriteLine($"Sync record failed (SQLite Id={record.Id}): {ex.Message}"); failed++;
// Leave unsynced; will retry later. 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) 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) private static string GenerateLunchOrderCode(long id, DateTime date)