218 lines
8.2 KiB
C#
218 lines
8.2 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
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).
|
|
/// </summary>
|
|
public class RfidService : IRfidService
|
|
{
|
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
|
private readonly IConfigService _configService;
|
|
|
|
public RfidService(IDbContextFactory<AppDbContext> dbFactory, IConfigService configService)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
_configService = configService;
|
|
}
|
|
|
|
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();
|
|
var nowUtc = DateTime.UtcNow;
|
|
|
|
using var db = _dbFactory.CreateDbContext();
|
|
|
|
var timeoutSeconds = _configService.GetScanTimeoutSeconds();
|
|
if (timeoutSeconds <= 0)
|
|
timeoutSeconds = 60;
|
|
|
|
var windowStart = nowUtc.AddSeconds(-timeoutSeconds);
|
|
|
|
// Enforce timeout: no duplicate scan within the timeout window
|
|
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);
|
|
}
|
|
|
|
// Optional: prevent any scan within the timeout window (any card).
|
|
var lastAnyScanInWindow = db.LunchOrderTransactions
|
|
.Where(r => r.ScanTime >= windowStart)
|
|
.OrderByDescending(r => r.ScanTime)
|
|
.FirstOrDefault();
|
|
|
|
if (lastAnyScanInWindow != null)
|
|
{
|
|
var remaining = GetCooldownRemainingSeconds(nowUtc, lastAnyScanInWindow.ScanTime, timeoutSeconds);
|
|
return new ScanResult(
|
|
false,
|
|
$"Only one order at a time within {FormatTimeout(timeoutSeconds)}. Ask this customer to rescan after countdown.",
|
|
remaining);
|
|
}
|
|
|
|
var record = new ScanRecord
|
|
{
|
|
CardId = cardId,
|
|
ScanTime = nowUtc,
|
|
IsSynced = false,
|
|
SiteId = _configService.GetSiteId(),
|
|
DeviceId = _configService.GetDeviceId()
|
|
};
|
|
db.LunchOrderTransactions.Add(record);
|
|
db.SaveChanges();
|
|
|
|
return new ScanResult(true, "Order recorded successfully.", 0);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|