302 lines
12 KiB
C#
302 lines
12 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using UtopiaCanteenSystem.Data;
|
|
using UtopiaCanteenSystem.Helpers;
|
|
using UtopiaCanteenSystem.Models;
|
|
|
|
namespace UtopiaCanteenSystem.Services;
|
|
|
|
/// <summary>
|
|
/// Handles RFID scan logic: validates input, enforces configurable timeout,
|
|
/// and saves scan events to SQLite (lunch_order_transactions table).
|
|
/// Validates card against local HRMS (employee lookup) before accepting scan.
|
|
/// </summary>
|
|
public class RfidService : IRfidService
|
|
{
|
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
|
private readonly IConfigService _configService;
|
|
private readonly IEmployeeLookupService _employeeLookup;
|
|
private readonly IMealSessionResolver _mealSessionResolver;
|
|
|
|
public RfidService(IDbContextFactory<AppDbContext> dbFactory, IConfigService configService, IEmployeeLookupService employeeLookup, IMealSessionResolver mealSessionResolver)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
_configService = configService;
|
|
_employeeLookup = employeeLookup;
|
|
_mealSessionResolver = mealSessionResolver;
|
|
}
|
|
|
|
public (bool Success, string Message) ProcessScan(string cardId)
|
|
{
|
|
var result = ProcessScanDetailed(cardId);
|
|
return (result.Success, result.Message);
|
|
}
|
|
|
|
public ScanResult ProcessScanDetailed(string cardId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(cardId))
|
|
return new ScanResult(false, "Card ID cannot be empty.", 0);
|
|
|
|
cardId = cardId.Trim();
|
|
|
|
// HRMS lookup: reject if card not registered
|
|
var employee = _employeeLookup.GetEmployeeByRfidAsync(cardId).GetAwaiter().GetResult();
|
|
if (employee == null)
|
|
return new ScanResult(false, "Card not registered in HRMS.", 0);
|
|
|
|
var nowUtc = DateTime.UtcNow;
|
|
var nowLocal = DateTime.Now;
|
|
|
|
// Phase 2.5: DB-driven meal windows (MealSchedules table). Reject if no matching session.
|
|
var siteIdForSession = !string.IsNullOrWhiteSpace(employee.LocationSiteId)
|
|
? employee.LocationSiteId.Trim()
|
|
: _configService.GetSiteId();
|
|
var session = _mealSessionResolver.GetCurrentSession(nowLocal, siteIdForSession);
|
|
if (session == MealSession.None)
|
|
return new ScanResult(false, "This scan is outside of valid meal timings.", 0);
|
|
|
|
var sessionCode = (int)session;
|
|
|
|
using var db = _dbFactory.CreateDbContext();
|
|
|
|
// Once-per-session-per-day rule: same card, same session, same local day is not allowed.
|
|
var startOfTodayLocal = DateTime.Today;
|
|
var startOfTomorrowLocal = startOfTodayLocal.AddDays(1);
|
|
var startUtc = startOfTodayLocal.ToUniversalTime();
|
|
var endUtc = startOfTomorrowLocal.ToUniversalTime();
|
|
|
|
var alreadyScannedThisSessionToday = db.LunchOrderTransactions
|
|
.Where(r => r.CardId == cardId)
|
|
.Where(r => r.MealSessionCode == sessionCode)
|
|
.Where(r => r.ScanTime >= startUtc && r.ScanTime < endUtc)
|
|
.OrderByDescending(r => r.ScanTime)
|
|
.FirstOrDefault();
|
|
|
|
if (alreadyScannedThisSessionToday != null)
|
|
{
|
|
var sessionName = GetMealSessionDisplayName(session);
|
|
return new ScanResult(false, $"This card is already scanned for {sessionName} today.", 0);
|
|
}
|
|
|
|
var interval = _configService.GetScanInterval();
|
|
var timeoutSeconds = (int)Math.Max(1, interval.TotalSeconds);
|
|
|
|
var windowStart = nowUtc.AddSeconds(-timeoutSeconds);
|
|
|
|
// Safety debounce: short cooldown per card to prevent accidental double-tap.
|
|
var lastInWindow = db.LunchOrderTransactions
|
|
.Where(r => r.CardId == cardId)
|
|
.Where(r => r.ScanTime >= windowStart)
|
|
.OrderByDescending(r => r.ScanTime)
|
|
.FirstOrDefault();
|
|
|
|
if (lastInWindow != null)
|
|
{
|
|
var remaining = GetCooldownRemainingSeconds(nowUtc, lastInWindow.ScanTime, timeoutSeconds);
|
|
return new ScanResult(
|
|
false,
|
|
$"One order per customer within {FormatTimeout(timeoutSeconds)}. Ask this customer to rescan after countdown.",
|
|
remaining);
|
|
}
|
|
|
|
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();
|
|
var record = new ScanRecord
|
|
{
|
|
CardId = cardId,
|
|
ScanTime = nowUtc,
|
|
IsSynced = false,
|
|
SiteId = siteId,
|
|
DeviceId = _configService.GetDeviceId(),
|
|
IpAddress = GetLocalIpAddress(),
|
|
MealSessionCode = sessionCode,
|
|
ParentDocumentId = employee.ParentDocumentId ?? string.Empty,
|
|
EmployeeId = employee.EmployeeId ?? string.Empty,
|
|
UindSerial = employee.UindSerial ?? string.Empty,
|
|
FunctionId = employee.FunctionId,
|
|
DepartmentId = employee.DepartmentId,
|
|
TagCreatedAtUtc = employee.TagCreatedAtUtc,
|
|
TagCreatedBy = employee.TagCreatedBy ?? string.Empty,
|
|
EmployeeName = string.IsNullOrEmpty(fullName) ? string.Empty : fullName,
|
|
Department = employee.DepartmentTitle ?? string.Empty,
|
|
DepartmentType = employee.DepartmentType ?? string.Empty
|
|
};
|
|
db.LunchOrderTransactions.Add(record);
|
|
db.SaveChanges();
|
|
|
|
return new ScanResult(true, "Order recorded successfully.", 0, employee, session);
|
|
}
|
|
|
|
private static string GetMealSessionDisplayName(MealSession session)
|
|
{
|
|
return session switch
|
|
{
|
|
MealSession.Breakfast => "Sehri",
|
|
MealSession.Lunch => "Iftari",
|
|
MealSession.Tea => "Tea",
|
|
MealSession.Dinner => "Dinner",
|
|
_ => "this meal"
|
|
};
|
|
}
|
|
|
|
public ScanRecord? GetLastScan()
|
|
{
|
|
using var db = _dbFactory.CreateDbContext();
|
|
return db.LunchOrderTransactions
|
|
.OrderByDescending(r => r.ScanTime)
|
|
.FirstOrDefault();
|
|
}
|
|
|
|
public IReadOnlyList<ScanRecord> GetLastScans(int count)
|
|
{
|
|
if (count <= 0) return Array.Empty<ScanRecord>();
|
|
using var db = _dbFactory.CreateDbContext();
|
|
return db.LunchOrderTransactions
|
|
.OrderByDescending(r => r.ScanTime)
|
|
.Take(count)
|
|
.ToList();
|
|
}
|
|
|
|
public IReadOnlyList<ScanRecord> GetScansForToday()
|
|
{
|
|
var startOfTodayLocal = DateTime.Today;
|
|
var startOfTomorrowLocal = startOfTodayLocal.AddDays(1);
|
|
var startUtc = startOfTodayLocal.ToUniversalTime();
|
|
var endUtc = startOfTomorrowLocal.ToUniversalTime();
|
|
using var db = _dbFactory.CreateDbContext();
|
|
return db.LunchOrderTransactions
|
|
.Where(r => r.ScanTime >= startUtc && r.ScanTime < endUtc)
|
|
.OrderByDescending(r => r.ScanTime)
|
|
.ToList();
|
|
}
|
|
|
|
public int GetTodayScanCount()
|
|
{
|
|
// Define "today" by the local calendar day, but ScanTime is stored as UTC.
|
|
// Convert local day boundaries to UTC for correct comparisons.
|
|
var startOfTodayLocal = DateTime.Today;
|
|
var startOfTomorrowLocal = startOfTodayLocal.AddDays(1);
|
|
var startUtc = startOfTodayLocal.ToUniversalTime();
|
|
var endUtc = startOfTomorrowLocal.ToUniversalTime();
|
|
|
|
using var db = _dbFactory.CreateDbContext();
|
|
return db.LunchOrderTransactions
|
|
.Count(r => r.ScanTime >= startUtc && r.ScanTime < endUtc);
|
|
}
|
|
|
|
public async Task<int> GetTodayScanCountAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
// Define "today" by the local calendar day, but ScanTime is stored as UTC.
|
|
// Convert local day boundaries to UTC for correct comparisons.
|
|
var startOfTodayLocal = DateTime.Today;
|
|
var startOfTomorrowLocal = startOfTodayLocal.AddDays(1);
|
|
var startUtc = startOfTodayLocal.ToUniversalTime();
|
|
var endUtc = startOfTomorrowLocal.ToUniversalTime();
|
|
|
|
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
|
return await db.LunchOrderTransactions
|
|
.CountAsync(r => r.ScanTime >= startUtc && r.ScanTime < endUtc, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task<int> GetTotalScanCountAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
|
return await db.LunchOrderTransactions.CountAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task<int> GetTotalScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(cardId))
|
|
return 0;
|
|
|
|
cardId = cardId.Trim();
|
|
|
|
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
|
return await db.LunchOrderTransactions
|
|
.CountAsync(r => r.CardId == cardId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task<int> GetTodayScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(cardId))
|
|
return 0;
|
|
|
|
cardId = cardId.Trim();
|
|
|
|
// Define "today" by the local calendar day, but ScanTime is stored as UTC.
|
|
// Convert local day boundaries to UTC for correct comparisons.
|
|
var startOfTodayLocal = DateTime.Today;
|
|
var startOfTomorrowLocal = startOfTodayLocal.AddDays(1);
|
|
var startUtc = startOfTodayLocal.ToUniversalTime();
|
|
var endUtc = startOfTomorrowLocal.ToUniversalTime();
|
|
|
|
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
|
return await db.LunchOrderTransactions
|
|
.CountAsync(r => r.CardId == cardId && r.ScanTime >= startUtc && r.ScanTime < endUtc, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
public void UpdateLastScanMealInfo(string mealLabel, string mealItems, double totalPrice)
|
|
{
|
|
using var db = _dbFactory.CreateDbContext();
|
|
var last = db.LunchOrderTransactions
|
|
.OrderByDescending(r => r.ScanTime)
|
|
.FirstOrDefault();
|
|
if (last == null)
|
|
return;
|
|
|
|
last.MealLabel = mealLabel ?? string.Empty;
|
|
last.MealItems = mealItems ?? string.Empty;
|
|
last.TotalPrice = totalPrice;
|
|
db.SaveChanges();
|
|
}
|
|
|
|
private static string FormatTimeout(int seconds)
|
|
{
|
|
if (seconds <= 0)
|
|
return "the timeout window";
|
|
|
|
var minutes = seconds / 60;
|
|
var secs = seconds % 60;
|
|
|
|
if (minutes == 0)
|
|
return secs == 1 ? "1 second" : $"{secs} seconds";
|
|
|
|
if (secs == 0)
|
|
return minutes == 1 ? "1 minute" : $"{minutes} minutes";
|
|
|
|
var minutePart = minutes == 1 ? "1 minute" : $"{minutes} minutes";
|
|
var secondPart = secs == 1 ? "1 second" : $"{secs} seconds";
|
|
return $"{minutePart} {secondPart}";
|
|
}
|
|
|
|
private static int GetCooldownRemainingSeconds(DateTime nowUtc, DateTime lastScanUtc, int timeoutSeconds)
|
|
{
|
|
// Remaining = (lastScan + timeout) - now. Use ceiling so UI shows a whole-second countdown.
|
|
var endUtc = lastScanUtc.AddSeconds(timeoutSeconds);
|
|
var remaining = (int)Math.Ceiling((endUtc - nowUtc).TotalSeconds);
|
|
return Math.Max(0, remaining);
|
|
}
|
|
|
|
private static string GetLocalIpAddress()
|
|
{
|
|
try
|
|
{
|
|
var host = Dns.GetHostEntry(Dns.GetHostName());
|
|
var ip = host.AddressList.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork);
|
|
return ip?.ToString() ?? string.Empty;
|
|
}
|
|
catch
|
|
{
|
|
return string.Empty;
|
|
}
|
|
}
|
|
}
|