Utopia-Canteen-System/Services/SyncService.cs

154 lines
6.5 KiB
C#

using Microsoft.EntityFrameworkCore;
using MySqlConnector;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Scheduled sync: fetches unsynced lunch_order_transactions, inserts them into MySQL database,
/// marks them as synced in SQLite, and periodically cleans up old synced rows.
/// </summary>
public class SyncService : ISyncService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly IConfigService _configService;
public SyncService(IDbContextFactory<AppDbContext> dbFactory, IConfigService configService)
{
_dbFactory = dbFactory;
_configService = configService;
}
public async Task SyncNowAsync(CancellationToken cancellationToken = default)
{
var connectionString = _configService.GetMySqlConnectionString();
if (string.IsNullOrWhiteSpace(connectionString))
{
System.Diagnostics.Debug.WriteLine("MySQL connection string not configured; skipping sync.");
return;
}
List<ScanRecord> toSync;
using (var db = _dbFactory.CreateDbContext())
{
toSync = await db.LunchOrderTransactions
.Where(r => !r.IsSynced)
.OrderBy(r => r.ScanTime)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
// 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;
try
{
// Insert into MySQL using transaction for atomicity
await using var mysqlConn = new MySqlConnection(connectionString);
await mysqlConn.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await mysqlConn.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
// Use INSERT IGNORE to handle duplicates (unique key on DeviceId, DeviceLocalRowId)
// This ensures no duplicates even if sync runs multiple times
// Column names match production: hrms.lunch_order_transactions (snake_case)
var insertSql = @"
INSERT IGNORE INTO lunch_order_transactions
(device_local_row_id, scan_time_utc, site_id, device_id, card_id, ip_address, received_at_utc)
VALUES
(@DeviceLocalRowId, @ScanTimeUtc, @SiteId, @DeviceId, @CardId, @IpAddress, UTC_TIMESTAMP(3))";
await using var cmd = new MySqlCommand(insertSql, mysqlConn, transaction);
foreach (var record in toSync)
{
cmd.Parameters.Clear();
cmd.Parameters.AddWithValue("@DeviceLocalRowId", record.Id);
cmd.Parameters.AddWithValue("@ScanTimeUtc", record.ScanTime);
cmd.Parameters.AddWithValue("@SiteId", record.SiteId ?? string.Empty);
cmd.Parameters.AddWithValue("@DeviceId", record.DeviceId ?? string.Empty);
cmd.Parameters.AddWithValue("@CardId", record.CardId ?? string.Empty);
cmd.Parameters.AddWithValue("@IpAddress", record.IpAddress ?? string.Empty);
var rowsAffected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
// Note: INSERT IGNORE returns 0 if row already exists (duplicate), 1 if inserted
}
// Commit MySQL transaction
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
// Only mark as synced in SQLite after successful MySQL insert
var ids = toSync.Select(r => r.Id).ToList();
using (var db = _dbFactory.CreateDbContext())
{
var records = await db.LunchOrderTransactions
.Where(r => ids.Contains(r.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var record in records)
record.IsSynced = true;
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
// Day-end cleanup: delete only synced rows from previous days.
await CleanupOldSyncedRowsAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
// Rollback MySQL transaction on error
try
{
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
// Ignore rollback errors
}
// Log error for debugging (you can replace with proper logging)
System.Diagnostics.Debug.WriteLine($"Sync error: {ex.Message}");
System.Diagnostics.Debug.WriteLine($"Stack trace: {ex.StackTrace}");
throw; // Re-throw to be caught by outer catch
}
}
catch (Exception ex)
{
// Log error for debugging (you can replace with proper logging)
System.Diagnostics.Debug.WriteLine($"Sync failed: {ex.Message}");
System.Diagnostics.Debug.WriteLine($"Stack trace: {ex.StackTrace}");
// Leave records intact; will retry on next run
}
}
/// <summary>
/// Deletes only synced rows (IsSynced = 1) where ScanTime is before the start of today (local day).
/// </summary>
private async Task CleanupOldSyncedRowsAsync(CancellationToken cancellationToken)
{
// Define "today" by the local calendar day, but ScanTime is stored as UTC.
var startOfTodayLocal = DateTime.Today;
var startUtc = startOfTodayLocal.ToUniversalTime();
using var db = _dbFactory.CreateDbContext();
var oldSynced = await db.LunchOrderTransactions
.Where(r => r.IsSynced && r.ScanTime < startUtc)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (oldSynced.Count == 0)
return;
db.LunchOrderTransactions.RemoveRange(oldSynced);
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}