Utopia-Canteen-System/Services/RfidService.cs

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