using Microsoft.EntityFrameworkCore; using MySqlConnector; using UtopiaCanteenSystem.Data; using UtopiaCanteenSystem.Models; namespace UtopiaCanteenSystem.Services; /// /// 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. /// public class SyncService : ISyncService { private readonly IDbContextFactory _dbFactory; private readonly IConfigService _configService; public SyncService(IDbContextFactory 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 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 } } /// /// Deletes only synced rows (IsSynced = 1) where ScanTime is before the start of today (local day). /// 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); } }