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; /// /// 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. /// public class RfidService : IRfidService { private readonly IDbContextFactory _dbFactory; private readonly IConfigService _configService; private readonly IEmployeeLookupService _employeeLookup; private readonly IMealSessionResolver _mealSessionResolver; private readonly IMenuLookupService _menuLookup; public RfidService( IDbContextFactory dbFactory, IConfigService configService, IEmployeeLookupService employeeLookup, IMealSessionResolver mealSessionResolver, IMenuLookupService menuLookup) { _dbFactory = dbFactory; _configService = configService; _employeeLookup = employeeLookup; _mealSessionResolver = mealSessionResolver; _menuLookup = menuLookup; } 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; // var resolvedSession = _mealSessionResolver.GetCurrentSession(nowLocal, siteIdForSession); // if (resolvedSession == null || resolvedSession.Session == MealSession.None) // return new ScanResult(false, "This scan is outside of valid meal timings.", 0); // var session = resolvedSession.Session; // 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); // //} // if (alreadyScannedThisSessionToday != null) // { // //var sessionName = session.ToString(); // var sessionName = resolvedSession.MealName; // 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(); // // Resolve menu items for this scan (store on record so order history shows actual items) // //var mealLabel = GetMealSessionDisplayName(session); // //var mealLabel = session.ToString(); // var mealLabel = resolvedSession.MealName; // var mealItemsDisplay = string.Empty; // double totalPrice = 0; // try // { // if (int.TryParse(new string(siteIdForSession.Where(char.IsDigit).ToArray()), out var siteNumeric) && siteNumeric > 0) // { // var menuItems = _menuLookup // .GetMenuItemsForSiteAndDateAsync(siteNumeric, nowLocal.Date) // .GetAwaiter() // .GetResult(); // //var matching = menuItems // // .Where(i => string.Equals(i.MealName, mealLabel, StringComparison.OrdinalIgnoreCase)) // // .ToList(); // var matching = menuItems.ToList(); // var names = matching // .Select(i => i.ItemName) // .Where(n => !string.IsNullOrWhiteSpace(n)) // .Distinct() // .ToList(); // if (names.Count > 0) // { // mealItemsDisplay = string.Join(" + ", names); // totalPrice = matching.Sum(i => (double)i.Price); // } // } // } // catch (Exception ex) // { // Logger.Log(ex, "RfidService.ProcessScanDetailed menu lookup"); // } // 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, // MealLabel = mealLabel, // MealItems = mealItemsDisplay, // TotalPrice = totalPrice // }; // db.LunchOrderTransactions.Add(record); // db.SaveChanges(); // return new ScanResult(true, "Order recorded successfully.", 0, employee, session); //} 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; // SITE VERIFICATION: Check if employee is assigned to this site var currentSiteId = _configService.GetSiteId(); var normalizedCurrentSite = NormalizeSiteId(currentSiteId); var employeeSiteId = !string.IsNullOrWhiteSpace(employee.LocationSiteId) ? employee.LocationSiteId.Trim() : string.Empty; var normalizedEmployeeSite = NormalizeSiteId(employeeSiteId); // If employee has a site assigned, verify it matches the current system if (!string.IsNullOrWhiteSpace(normalizedEmployeeSite)) { if (!string.Equals(normalizedEmployeeSite, normalizedCurrentSite, StringComparison.OrdinalIgnoreCase)) { var message = $"Employee from site {normalizedEmployeeSite} is not allowed to scan here. " + $"Only employees from site {normalizedCurrentSite} can scan."; return new ScanResult( false, message, 0, employee, // Include employee info so UI can show who tried to scan MealSession.None, normalizedEmployeeSite, normalizedCurrentSite); } } else { // Employee has no site assigned - log warning but allow scan // You can change this to block if required by commenting out the next line Logger.Log( new Exception($"Site Validation - Card: {cardId}, Employee Site: '{employeeSiteId}', Config Site: '{currentSiteId}'"), "RfidService"); // If you want to BLOCK employees with no site, uncomment the following: /* return new ScanResult( false, "Employee has no site assignment. Please contact administrator.", 0, employee, MealSession.None, "UNASSIGNED", normalizedCurrentSite); */ } // Continue with meal session validation var siteIdForSession = !string.IsNullOrWhiteSpace(employee.LocationSiteId) ? employee.LocationSiteId.Trim() : _configService.GetSiteId(); var resolvedSession = _mealSessionResolver.GetCurrentSession(nowLocal, siteIdForSession); if (resolvedSession == null || resolvedSession.Session == MealSession.None) return new ScanResult(false, "This scan is outside of valid meal timings.", 0, employee); var session = resolvedSession.Session; 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 = resolvedSession.MealName; return new ScanResult(false, $"This card is already scanned for {sessionName} today.", 0, employee, session); } 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, employee, session); } 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(); // Resolve menu items for this scan var mealLabel = resolvedSession.MealName; var mealItemsDisplay = string.Empty; double totalPrice = 0; try { if (int.TryParse(new string(siteIdForSession.Where(char.IsDigit).ToArray()), out var siteNumeric) && siteNumeric > 0) { // Pass mealLabel as the mealName parameter var menuItems = _menuLookup .GetMenuItemsForSiteAndDateAsync(siteNumeric, nowLocal.Date, employee.GradeType, mealLabel) .GetAwaiter() .GetResult(); var matching = menuItems .Where(i => i.MenuItemId > 0) .Where(i => { try { return _employeeLookup .IsMenuItemAuthorizedForRfidAsync(cardId, i.MenuItemId) .GetAwaiter() .GetResult(); } catch (Exception ex) { Logger.Log(ex, $"RfidService.EmployeeMenuValidation card={cardId}, menuItemId={i.MenuItemId}"); return false; } }) .ToList(); if (matching.Count == 0) { Logger.Log( new Exception($"No authorized menu item found for card={cardId}, site={siteNumeric}, date={nowLocal:yyyy-MM-dd}, meal={mealLabel}, grade={employee.GradeType}"), "RfidService.ProcessScanDetailed authorization"); return new ScanResult(false, "No menu item selected.", 0, employee, session); } var names = matching .Select(i => i.ItemName) .Where(n => !string.IsNullOrWhiteSpace(n)) .Distinct() .ToList(); if (names.Count > 0) { mealItemsDisplay = string.Join(" + ", names); totalPrice = matching.Sum(i => (double)i.Price); } } } catch (Exception ex) { Logger.Log(ex, "RfidService.ProcessScanDetailed menu lookup"); } 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, MealLabel = mealLabel, MealItems = mealItemsDisplay, TotalPrice = totalPrice, grade_type = employee.GradeType ?? string.Empty }; db.LunchOrderTransactions.Add(record); db.SaveChanges(); return new ScanResult(true, "Order recorded successfully.", 0, employee, session, normalizedEmployeeSite, normalizedCurrentSite); } //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; // // SITE VERIFICATION: Check if employee is assigned to this site // var currentSiteId = _configService.GetSiteId(); // var normalizedCurrentSite = NormalizeSiteId(currentSiteId); // var employeeSiteId = !string.IsNullOrWhiteSpace(employee.LocationSiteId) // ? employee.LocationSiteId.Trim() // : string.Empty; // var normalizedEmployeeSite = NormalizeSiteId(employeeSiteId); // // If employee has a site assigned, verify it matches the current system // if (!string.IsNullOrWhiteSpace(normalizedEmployeeSite)) // { // if (!string.Equals(normalizedEmployeeSite, normalizedCurrentSite, StringComparison.OrdinalIgnoreCase)) // { // var message = $"Employee from site {normalizedEmployeeSite} is not allowed to scan here. " + // $"Only employees from site {normalizedCurrentSite} can scan."; // return new ScanResult( // false, // message, // 0, // employee, // Include employee info so UI can show who tried to scan // MealSession.None, // normalizedEmployeeSite, // normalizedCurrentSite); // } // } // else // { // // Employee has no site assigned - log warning but allow scan // // You can change this to block if required by commenting out the next line // Logger.Log( // new Exception($"Site Validation - Card: {cardId}, Employee Site: '{employeeSiteId}', Config Site: '{currentSiteId}'"), // "RfidService"); // // If you want to BLOCK employees with no site, uncomment the following: // /* // return new ScanResult( // false, // "Employee has no site assignment. Please contact administrator.", // 0, // employee, // MealSession.None, // "UNASSIGNED", // normalizedCurrentSite); // */ // } // // Continue with meal session validation // var siteIdForSession = !string.IsNullOrWhiteSpace(employee.LocationSiteId) // ? employee.LocationSiteId.Trim() // : _configService.GetSiteId(); // var resolvedSession = _mealSessionResolver.GetCurrentSession(nowLocal, siteIdForSession); // if (resolvedSession == null || resolvedSession.Session == MealSession.None) // return new ScanResult(false, "This scan is outside of valid meal timings.", 0, employee); // var session = resolvedSession.Session; // 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 = resolvedSession.MealName; // return new ScanResult(false, $"This card is already scanned for {sessionName} today.", 0, employee, session); // } // 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, // employee, // session); // } // 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(); // // Resolve menu items for this scan // var mealLabel = resolvedSession.MealName; // var mealItemsDisplay = string.Empty; // double totalPrice = 0; // try // { // if (int.TryParse(new string(siteIdForSession.Where(char.IsDigit).ToArray()), out var siteNumeric) && siteNumeric > 0) // { // var menuItems = _menuLookup // .GetMenuItemsForSiteAndDateAsync(siteNumeric, nowLocal.Date, employee.GradeType) // .GetAwaiter() // .GetResult(); // var matching = menuItems.ToList(); // var names = matching // .Select(i => i.ItemName) // .Where(n => !string.IsNullOrWhiteSpace(n)) // .Distinct() // .ToList(); // if (names.Count > 0) // { // mealItemsDisplay = string.Join(" + ", names); // totalPrice = matching.Sum(i => (double)i.Price); // } // } // } // catch (Exception ex) // { // Logger.Log(ex, "RfidService.ProcessScanDetailed menu lookup"); // } // 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, // MealLabel = mealLabel, // MealItems = mealItemsDisplay, // TotalPrice = totalPrice, // grade_type = employee.GradeType ?? string.Empty // }; // db.LunchOrderTransactions.Add(record); // db.SaveChanges(); // return new ScanResult(true, "Order recorded successfully.", 0, employee, session, normalizedEmployeeSite, normalizedCurrentSite); //} /// /// Normalizes a site ID to a consistent format for comparison /// Handles formats like "1", "01", "SITE : 1", "Site 01", etc. /// private static string NormalizeSiteId(string? siteId) { if (string.IsNullOrWhiteSpace(siteId)) return string.Empty; // Extract digits only var digits = new string(siteId.Where(char.IsDigit).ToArray()); if (string.IsNullOrEmpty(digits)) return siteId.Trim(); // Return original if no digits // Pad to 2 digits if it's a single digit if (digits.Length == 1) return digits.PadLeft(2, '0'); return digits; } //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 GetLastScans(int count) { if (count <= 0) return Array.Empty(); using var db = _dbFactory.CreateDbContext(); return db.LunchOrderTransactions .OrderByDescending(r => r.ScanTime) .Take(count) .ToList(); } public IReadOnlyList 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 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 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 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 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; } } }