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 menuLookupService = new MenuLookupService(configService);
|
||||||
var mealScheduleService = new ProductionMealScheduleService(configService);
|
var mealScheduleService = new ProductionMealScheduleService(configService);
|
||||||
var mealSessionResolver = new DbMealSessionResolver(mealScheduleService);
|
var mealSessionResolver = new DbMealSessionResolver(mealScheduleService);
|
||||||
var rfidService = new RfidService(dbFactory, configService, employeeLookupService, mealSessionResolver, menuLookupService);
|
|
||||||
var syncService = new SyncService(dbFactory, configService);
|
var syncService = new SyncService(dbFactory, configService);
|
||||||
|
var rfidService = new RfidService(dbFactory, configService, employeeLookupService, mealSessionResolver, menuLookupService, syncService);
|
||||||
var adminAuditService = new AdminAuditService(dbFactory);
|
var adminAuditService = new AdminAuditService(dbFactory);
|
||||||
var session = new AppSession();
|
var session = new AppSession();
|
||||||
var authenticationUrl = "https://portal.utopiaindustries.pk/uind/rest/auth/user/";
|
var authenticationUrl = "https://portal.utopiaindustries.pk/uind/rest/auth/user/";
|
||||||
|
|
@ -76,30 +76,33 @@ public partial class App : Application
|
||||||
mainWindow.Show();
|
mainWindow.Show();
|
||||||
|
|
||||||
// Background sync: every 15 minutes, POST unsynced lunch_order_transactions to API
|
// 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 = new System.Timers.Timer(TimeSpan.FromMinutes(1).TotalMilliseconds)
|
||||||
};
|
{
|
||||||
_syncTimer.Elapsed += async (_, _) =>
|
AutoReset = true
|
||||||
{
|
};
|
||||||
// Prevent overlapping sync runs; if one is still running, skip this tick.
|
_syncTimer.Elapsed += async (_, _) =>
|
||||||
if (Interlocked.Exchange(ref _isSyncRunning, 1) == 1)
|
{
|
||||||
return;
|
// Prevent overlapping sync runs; if one is still running, skip this tick.
|
||||||
|
if (Interlocked.Exchange(ref _isSyncRunning, 1) == 1)
|
||||||
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await syncService.SyncNowAsync().ConfigureAwait(false);
|
await syncService.SyncNowAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Ignore; will retry next tick
|
// Ignore; will retry next tick
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
Interlocked.Exchange(ref _isSyncRunning, 0);
|
Interlocked.Exchange(ref _isSyncRunning, 0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
_syncTimer.Start();
|
_syncTimer.Start();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnExit(ExitEventArgs e)
|
protected override void OnExit(ExitEventArgs e)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,14 @@ public class ConfigService : IConfigService
|
||||||
SaveConfig();
|
SaveConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool GetSyncServiceEnabled() => _config.SyncServiceEnabled;
|
||||||
|
|
||||||
|
public void SetSyncServiceEnabled(bool enabled)
|
||||||
|
{
|
||||||
|
_config.SyncServiceEnabled = enabled;
|
||||||
|
SaveConfig();
|
||||||
|
}
|
||||||
|
|
||||||
public bool GetScannerConnected() => _config.ScannerConnected;
|
public bool GetScannerConnected() => _config.ScannerConnected;
|
||||||
|
|
||||||
public void SetScannerConnected(bool connected)
|
public void SetScannerConnected(bool connected)
|
||||||
|
|
@ -362,6 +370,7 @@ public class ConfigService : IConfigService
|
||||||
private sealed class AppConfig
|
private sealed class AppConfig
|
||||||
{
|
{
|
||||||
public string SyncApiEndpoint { get; set; } = "https://api.example.com/uind/sync";
|
public string SyncApiEndpoint { get; set; } = "https://api.example.com/uind/sync";
|
||||||
|
public bool SyncServiceEnabled { get; set; } = true;
|
||||||
public bool ScannerConnected { get; set; } = false;
|
public bool ScannerConnected { get; set; } = false;
|
||||||
/// <summary>Legacy: migrated to ScanIntervalDays/Hours/Minutes on first load. Kept for JSON deserialization.</summary>
|
/// <summary>Legacy: migrated to ScanIntervalDays/Hours/Minutes on first load. Kept for JSON deserialization.</summary>
|
||||||
public int ScanTimeoutSeconds { get; set; } = 0;
|
public int ScanTimeoutSeconds { get; set; } = 0;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ public interface IConfigService
|
||||||
{
|
{
|
||||||
string GetSyncApiEndpoint();
|
string GetSyncApiEndpoint();
|
||||||
void SetSyncApiEndpoint(string endpoint);
|
void SetSyncApiEndpoint(string endpoint);
|
||||||
|
bool GetSyncServiceEnabled();
|
||||||
|
void SetSyncServiceEnabled(bool enabled);
|
||||||
bool GetScannerConnected();
|
bool GetScannerConnected();
|
||||||
void SetScannerConnected(bool connected);
|
void SetScannerConnected(bool connected);
|
||||||
/// <summary>Scan interval: minimum time between scans. Days (0–365), Hours (0–23), Minutes (0–59), Seconds (0–59).</summary>
|
/// <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 Microsoft.EntityFrameworkCore;
|
||||||
|
using MySqlConnector;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
|
@ -21,19 +22,22 @@ public class RfidService : IRfidService
|
||||||
private readonly IEmployeeLookupService _employeeLookup;
|
private readonly IEmployeeLookupService _employeeLookup;
|
||||||
private readonly IMealSessionResolver _mealSessionResolver;
|
private readonly IMealSessionResolver _mealSessionResolver;
|
||||||
private readonly IMenuLookupService _menuLookup;
|
private readonly IMenuLookupService _menuLookup;
|
||||||
|
private readonly ISyncService _syncService;
|
||||||
|
|
||||||
public RfidService(
|
public RfidService(
|
||||||
IDbContextFactory<AppDbContext> dbFactory,
|
IDbContextFactory<AppDbContext> dbFactory,
|
||||||
IConfigService configService,
|
IConfigService configService,
|
||||||
IEmployeeLookupService employeeLookup,
|
IEmployeeLookupService employeeLookup,
|
||||||
IMealSessionResolver mealSessionResolver,
|
IMealSessionResolver mealSessionResolver,
|
||||||
IMenuLookupService menuLookup)
|
IMenuLookupService menuLookup,
|
||||||
|
ISyncService syncService)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
_employeeLookup = employeeLookup;
|
_employeeLookup = employeeLookup;
|
||||||
_mealSessionResolver = mealSessionResolver;
|
_mealSessionResolver = mealSessionResolver;
|
||||||
_menuLookup = menuLookup;
|
_menuLookup = menuLookup;
|
||||||
|
_syncService = syncService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public (bool Success, string Message) ProcessScan(string cardId)
|
public (bool Success, string Message) ProcessScan(string cardId)
|
||||||
|
|
@ -269,6 +273,7 @@ public class RfidService : IRfidService
|
||||||
|
|
||||||
var session = resolvedSession.Session;
|
var session = resolvedSession.Session;
|
||||||
var sessionCode = (int)session;
|
var sessionCode = (int)session;
|
||||||
|
var mealLabel = resolvedSession.MealName;
|
||||||
|
|
||||||
using var db = _dbFactory.CreateDbContext();
|
using var db = _dbFactory.CreateDbContext();
|
||||||
|
|
||||||
|
|
@ -314,13 +319,21 @@ public class RfidService : IRfidService
|
||||||
session);
|
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 fullName = string.Join(" ", new[] { employee.FirstName, employee.MiddleName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
|
||||||
var siteId = !string.IsNullOrWhiteSpace(employee.LocationSiteId)
|
var siteId = !string.IsNullOrWhiteSpace(employee.LocationSiteId)
|
||||||
? employee.LocationSiteId.Trim()
|
? employee.LocationSiteId.Trim()
|
||||||
: _configService.GetSiteId();
|
: _configService.GetSiteId();
|
||||||
|
|
||||||
// Resolve menu items for this scan
|
// Resolve menu items for this scan
|
||||||
var mealLabel = resolvedSession.MealName;
|
|
||||||
var mealItemsDisplay = string.Empty;
|
var mealItemsDisplay = string.Empty;
|
||||||
double totalPrice = 0;
|
double totalPrice = 0;
|
||||||
try
|
try
|
||||||
|
|
@ -410,9 +423,80 @@ public class RfidService : IRfidService
|
||||||
db.LunchOrderTransactions.Add(record);
|
db.LunchOrderTransactions.Add(record);
|
||||||
db.SaveChanges();
|
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);
|
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)
|
//public ScanResult ProcessScanDetailed(string cardId)
|
||||||
//{
|
//{
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,15 @@ public class SyncService : ISyncService
|
||||||
{
|
{
|
||||||
try
|
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)
|
// 1) Insert into lunch_order_transactions (production – MySqlConnectionString)
|
||||||
await using (var prodConn = new MySqlConnection(productionConnStr))
|
await using (var prodConn = new MySqlConnection(productionConnStr))
|
||||||
{
|
{
|
||||||
|
|
@ -379,6 +388,43 @@ public class SyncService : ISyncService
|
||||||
return (lunchMenuItemId, resolvedItemName, itemType, price);
|
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>
|
/// <summary>
|
||||||
/// Deletes only synced rows (IsSynced = 1) where ScanTime is before the start of today (local day).
|
/// Deletes only synced rows (IsSynced = 1) where ScanTime is before the start of today (local day).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue