diff --git a/App.xaml.cs b/App.xaml.cs index abe4604..3f61a43 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -47,8 +47,8 @@ public partial class App : Application var menuLookupService = new MenuLookupService(configService); var mealScheduleService = new ProductionMealScheduleService(configService); var mealSessionResolver = new DbMealSessionResolver(mealScheduleService); - var rfidService = new RfidService(dbFactory, configService, employeeLookupService, mealSessionResolver, menuLookupService); var syncService = new SyncService(dbFactory, configService); + var rfidService = new RfidService(dbFactory, configService, employeeLookupService, mealSessionResolver, menuLookupService, syncService); var adminAuditService = new AdminAuditService(dbFactory); var session = new AppSession(); var authenticationUrl = "https://portal.utopiaindustries.pk/uind/rest/auth/user/"; @@ -76,30 +76,33 @@ public partial class App : Application mainWindow.Show(); // Background sync: every 15 minutes, POST unsynced lunch_order_transactions to API - _syncTimer = new System.Timers.Timer(TimeSpan.FromMinutes(15).TotalMilliseconds) + if (configService.GetSyncServiceEnabled()) { - AutoReset = true - }; - _syncTimer.Elapsed += async (_, _) => - { - // Prevent overlapping sync runs; if one is still running, skip this tick. - if (Interlocked.Exchange(ref _isSyncRunning, 1) == 1) - return; + _syncTimer = new System.Timers.Timer(TimeSpan.FromMinutes(1).TotalMilliseconds) + { + AutoReset = true + }; + _syncTimer.Elapsed += async (_, _) => + { + // Prevent overlapping sync runs; if one is still running, skip this tick. + if (Interlocked.Exchange(ref _isSyncRunning, 1) == 1) + return; - try - { - await syncService.SyncNowAsync().ConfigureAwait(false); - } - catch - { - // Ignore; will retry next tick - } - finally - { - Interlocked.Exchange(ref _isSyncRunning, 0); - } - }; - _syncTimer.Start(); + try + { + await syncService.SyncNowAsync().ConfigureAwait(false); + } + catch + { + // Ignore; will retry next tick + } + finally + { + Interlocked.Exchange(ref _isSyncRunning, 0); + } + }; + _syncTimer.Start(); + } } protected override void OnExit(ExitEventArgs e) diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs index b8c4370..1083ba4 100644 --- a/Services/ConfigService.cs +++ b/Services/ConfigService.cs @@ -33,6 +33,14 @@ public class ConfigService : IConfigService SaveConfig(); } + public bool GetSyncServiceEnabled() => _config.SyncServiceEnabled; + + public void SetSyncServiceEnabled(bool enabled) + { + _config.SyncServiceEnabled = enabled; + SaveConfig(); + } + public bool GetScannerConnected() => _config.ScannerConnected; public void SetScannerConnected(bool connected) @@ -362,6 +370,7 @@ public class ConfigService : IConfigService private sealed class AppConfig { public string SyncApiEndpoint { get; set; } = "https://api.example.com/uind/sync"; + public bool SyncServiceEnabled { get; set; } = true; public bool ScannerConnected { get; set; } = false; /// Legacy: migrated to ScanIntervalDays/Hours/Minutes on first load. Kept for JSON deserialization. public int ScanTimeoutSeconds { get; set; } = 0; diff --git a/Services/IConfigService.cs b/Services/IConfigService.cs index 89ff5ea..a6ba34e 100644 --- a/Services/IConfigService.cs +++ b/Services/IConfigService.cs @@ -7,6 +7,8 @@ public interface IConfigService { string GetSyncApiEndpoint(); void SetSyncApiEndpoint(string endpoint); + bool GetSyncServiceEnabled(); + void SetSyncServiceEnabled(bool enabled); bool GetScannerConnected(); void SetScannerConnected(bool connected); /// Scan interval: minimum time between scans. Days (0–365), Hours (0–23), Minutes (0–59), Seconds (0–59). diff --git a/Services/RfidService.cs b/Services/RfidService.cs index cddf45a..e0e21a7 100644 --- a/Services/RfidService.cs +++ b/Services/RfidService.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using MySqlConnector; using System.Net; using System.Net.Sockets; using System.Threading; @@ -21,19 +22,22 @@ public class RfidService : IRfidService private readonly IEmployeeLookupService _employeeLookup; private readonly IMealSessionResolver _mealSessionResolver; private readonly IMenuLookupService _menuLookup; + private readonly ISyncService _syncService; public RfidService( IDbContextFactory dbFactory, IConfigService configService, IEmployeeLookupService employeeLookup, IMealSessionResolver mealSessionResolver, - IMenuLookupService menuLookup) + IMenuLookupService menuLookup, + ISyncService syncService) { _dbFactory = dbFactory; _configService = configService; _employeeLookup = employeeLookup; _mealSessionResolver = mealSessionResolver; _menuLookup = menuLookup; + _syncService = syncService; } public (bool Success, string Message) ProcessScan(string cardId) @@ -269,6 +273,7 @@ public class RfidService : IRfidService var session = resolvedSession.Session; var sessionCode = (int)session; + var mealLabel = resolvedSession.MealName; using var db = _dbFactory.CreateDbContext(); @@ -314,13 +319,21 @@ public class RfidService : IRfidService session); } + // Production duplicate check by employee serial + date + meal + site. + // Different meal on same day is allowed. + if (int.TryParse(new string(siteIdForSession.Where(char.IsDigit).ToArray()), out var siteNumericForDup) && + siteNumericForDup > 0 && + HasProductionMealAlreadyTaken(employee.EmployeeId, siteNumericForDup, mealLabel, nowLocal.Date)) + { + return new ScanResult(false, "Meal already taken", 0, employee, session); + } + var fullName = string.Join(" ", new[] { employee.FirstName, employee.MiddleName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); var siteId = !string.IsNullOrWhiteSpace(employee.LocationSiteId) ? employee.LocationSiteId.Trim() : _configService.GetSiteId(); // Resolve menu items for this scan - var mealLabel = resolvedSession.MealName; var mealItemsDisplay = string.Empty; double totalPrice = 0; try @@ -410,9 +423,80 @@ public class RfidService : IRfidService db.LunchOrderTransactions.Add(record); db.SaveChanges(); + // If we can reach production now, try posting immediately. + // On success (or duplicate already in production), SyncService will mark IsSynced=1. + if (CanReachProduction()) + { + try + { + _syncService.SyncNowAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Logger.Log(ex, "RfidService.ProcessScanDetailed immediate sync"); + } + } + return new ScanResult(true, "Order recorded successfully.", 0, employee, session, normalizedEmployeeSite, normalizedCurrentSite); } + private bool HasProductionMealAlreadyTaken(string? employeeSerialNumber, int siteIdNumeric, string mealName, DateTime orderDateLocal) + { + var employeeSerial = employeeSerialNumber?.Trim() ?? string.Empty; + var meal = mealName?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(employeeSerial) || string.IsNullOrWhiteSpace(meal) || siteIdNumeric <= 0) + return false; + + var hrmsConnStr = _configService.GetHrmsLookupConnectionString(); + if (string.IsNullOrWhiteSpace(hrmsConnStr)) + return false; + + const string sql = @" + SELECT 1 + FROM lunch_order + WHERE employee_serial_number = @EmployeeSerialNumber + AND order_date = @OrderDate + AND meal_name = @MealName + AND location_site_id = @LocationSiteId + LIMIT 1"; + + try + { + using var conn = new MySqlConnection(hrmsConnStr); + conn.Open(); + using var cmd = new MySqlCommand(sql, conn); + cmd.Parameters.AddWithValue("@EmployeeSerialNumber", employeeSerial); + cmd.Parameters.AddWithValue("@OrderDate", orderDateLocal.Date); + cmd.Parameters.AddWithValue("@MealName", meal); + cmd.Parameters.AddWithValue("@LocationSiteId", siteIdNumeric); + var exists = cmd.ExecuteScalar(); + return exists != null && exists != DBNull.Value; + } + catch (Exception ex) + { + Logger.Log(ex, "RfidService.HasProductionMealAlreadyTaken"); + return false; + } + } + + private bool CanReachProduction() + { + var connStr = _configService.GetMySqlConnectionString(); + if (string.IsNullOrWhiteSpace(connStr)) + return false; + + try + { + using var conn = new MySqlConnection(connStr); + conn.Open(); + return true; + } + catch + { + return false; + } + } + //public ScanResult ProcessScanDetailed(string cardId) //{ diff --git a/Services/SyncService.cs b/Services/SyncService.cs index 9cbbdf0..c289735 100644 --- a/Services/SyncService.cs +++ b/Services/SyncService.cs @@ -134,6 +134,15 @@ public class SyncService : ISyncService { try { + // Duplicate-safe sync rule: + // if this meal is already present in production for the same employee serial/date/site/session, + // mark SQLite as synced and skip re-insert. + if (await IsDuplicateMealInProductionAsync(record, hrmsConnStr, cancellationToken).ConfigureAwait(false)) + { + syncedIds.Add(record.Id); + continue; + } + // 1) Insert into lunch_order_transactions (production – MySqlConnectionString) await using (var prodConn = new MySqlConnection(productionConnStr)) { @@ -379,6 +388,43 @@ public class SyncService : ISyncService return (lunchMenuItemId, resolvedItemName, itemType, price); } + private async Task IsDuplicateMealInProductionAsync(ScanRecord record, string hrmsConnStr, CancellationToken cancellationToken) + { + var employeeSerial = (record.EmployeeId ?? string.Empty).Trim(); + var mealName = (record.MealLabel ?? string.Empty).Trim(); + var siteId = SiteIdStringToInt(record.SiteId ?? string.Empty); + var orderDate = record.ScanTime.ToLocalTime().Date; + if (string.IsNullOrWhiteSpace(employeeSerial) || string.IsNullOrWhiteSpace(mealName) || siteId <= 0) + return false; + + const string sql = @" + SELECT 1 + FROM lunch_order + WHERE employee_serial_number = @EmployeeSerialNumber + AND order_date = @OrderDate + AND meal_name = @MealName + AND location_site_id = @LocationSiteId + LIMIT 1"; + + try + { + await using var conn = new MySqlConnection(hrmsConnStr); + await conn.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var cmd = new MySqlCommand(sql, conn); + cmd.Parameters.AddWithValue("@EmployeeSerialNumber", employeeSerial); + cmd.Parameters.AddWithValue("@OrderDate", orderDate); + cmd.Parameters.AddWithValue("@MealName", mealName); + cmd.Parameters.AddWithValue("@LocationSiteId", siteId); + var exists = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return exists != null && exists != DBNull.Value; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Duplicate check failed for SQLite Id={record.Id}: {ex.Message}"); + return false; + } + } + /// /// Deletes only synced rows (IsSynced = 1) where ScanTime is before the start of today (local day). ///