Implement session-based duplicate meal guard with optional auto-sync control
Added a configurable SyncServiceEnabled flag in app config and wired startup to run the background sync timer only when enabled. Updated scan processing to enforce production duplicate checks by employee_serial_number + order_date + meal_name + location_site_id, blocking same-session repeat scans with “Meal already taken” while still allowing different meals on the same day. Improved offline/online behavior by saving locally first and attempting immediate sync when production is reachable, and enhanced manual sync to treat production duplicates as success by marking local IsSynced=1 instead of retrying forever.pull/9/head
parent
316f463962
commit
a22562cb59
49
App.xaml.cs
49
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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/// <summary>Legacy: migrated to ScanIntervalDays/Hours/Minutes on first load. Kept for JSON deserialization.</summary>
|
||||
public int ScanTimeoutSeconds { get; set; } = 0;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
/// <summary>Scan interval: minimum time between scans. Days (0–365), Hours (0–23), Minutes (0–59), Seconds (0–59).</summary>
|
||||
|
|
|
|||
|
|
@ -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<AppDbContext> 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)
|
||||
//{
|
||||
|
|
|
|||
|
|
@ -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<bool> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes only synced rows (IsSynced = 1) where ScanTime is before the start of today (local day).
|
||||
/// </summary>
|
||||
|
|
|
|||
Loading…
Reference in New Issue