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).
///