Merge pull request 'fixes' (#9) from fixes into main

Reviewed-on: #9
pull/10/head^2
SYED MUSTUFA AHMED NAQVI 2026-04-20 12:10:41 +00:00
commit bda42b2a84
5 changed files with 169 additions and 25 deletions

View File

@ -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)

View File

@ -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;

View File

@ -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 (0365), Hours (023), Minutes (059), Seconds (059).</summary> /// <summary>Scan interval: minimum time between scans. Days (0365), Hours (023), Minutes (059), Seconds (059).</summary>

View File

@ -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)
//{ //{

View File

@ -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>