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
parent
1237878db2
commit
3df4bc2672
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue