From 0d6732ad2ef25e193faf67317f489e24c154e4ba Mon Sep 17 00:00:00 2001 From: "mustafa.ahmed" Date: Tue, 17 Mar 2026 14:07:02 +0500 Subject: [PATCH 1/3] Added site mismatch check to block employees from scanning on different sites - Implemented a check to validate the employee's LocationSiteId against the current site. - If the employee is from a different site, the scan is blocked and an error message is shown. - Site mismatch message displays the correct site IDs and prevents scanning for employees registered on a different site. - Ensured the scan only proceeds for employees registered on the current site. --- Services/RfidService.cs | 252 +++++++++++++++++++++--- Services/ScanResult.cs | 4 +- ViewModels/ScannerDashboardViewModel.cs | 105 +++++++++- 3 files changed, 332 insertions(+), 29 deletions(-) diff --git a/Services/RfidService.cs b/Services/RfidService.cs index c3394d0..805fda3 100644 --- a/Services/RfidService.cs +++ b/Services/RfidService.cs @@ -42,6 +42,159 @@ public class RfidService : IRfidService 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)) @@ -57,19 +210,61 @@ public class RfidService : IRfidService var nowUtc = DateTime.UtcNow; var nowLocal = DateTime.Now; - // Phase 2.5: DB-driven meal windows (MealSchedules table). Reject if no matching session. + // 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 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); + return new ScanResult(false, "This scan is outside of valid meal timings.", 0, employee); var session = resolvedSession.Session; var sessionCode = (int)session; @@ -89,16 +284,10 @@ public class RfidService : IRfidService .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); + return new ScanResult(false, $"This card is already scanned for {sessionName} today.", 0, employee, session); } var interval = _configService.GetScanInterval(); @@ -119,7 +308,9 @@ public class RfidService : IRfidService return new ScanResult( false, $"One order per customer within {FormatTimeout(timeoutSeconds)}. Ask this customer to rescan after countdown.", - remaining); + remaining, + employee, + session); } var fullName = string.Join(" ", new[] { employee.FirstName, employee.MiddleName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); @@ -127,9 +318,7 @@ public class RfidService : IRfidService ? 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(); + // Resolve menu items for this scan var mealLabel = resolvedSession.MealName; var mealItemsDisplay = string.Empty; double totalPrice = 0; @@ -142,9 +331,6 @@ public class RfidService : IRfidService .GetAwaiter() .GetResult(); - //var matching = menuItems - // .Where(i => string.Equals(i.MealName, mealLabel, StringComparison.OrdinalIgnoreCase)) - // .ToList(); var matching = menuItems.ToList(); var names = matching @@ -191,7 +377,29 @@ public class RfidService : IRfidService db.LunchOrderTransactions.Add(record); db.SaveChanges(); - return new ScanResult(true, "Order recorded successfully.", 0, employee, session); + 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) diff --git a/Services/ScanResult.cs b/Services/ScanResult.cs index 809d9d9..38e949f 100644 --- a/Services/ScanResult.cs +++ b/Services/ScanResult.cs @@ -12,5 +12,7 @@ public readonly record struct ScanResult( string Message, int CooldownSecondsRemaining, HrmsEmployeeInfo? EmployeeInfo = null, - MealSession MealSession = MealSession.None); + MealSession MealSession = MealSession.None, + string? EmployeeSiteId = null, + string? CurrentSiteId = null); diff --git a/ViewModels/ScannerDashboardViewModel.cs b/ViewModels/ScannerDashboardViewModel.cs index cc4cccd..d92c067 100644 --- a/ViewModels/ScannerDashboardViewModel.cs +++ b/ViewModels/ScannerDashboardViewModel.cs @@ -485,6 +485,77 @@ public partial class ScannerDashboardViewModel : ObservableObject _navigation.NavigateToAdminSettingsAuth(); } + //[RelayCommand] + //private void Scan() + //{ + // lock (_submitLock) + // { + // if (_isSubmitting || IsProcessing || _isCooldownActive) + // return; + // _isSubmitting = true; + // IsProcessing = true; + // } + + // try + // { + // _debounceTimer.Stop(); + + // Message = string.Empty; + // IsSuccess = false; + + // var cardId = CardIdInput?.Trim() ?? string.Empty; + // if (string.IsNullOrWhiteSpace(cardId)) + // return; + + // var result = _rfidService.ProcessScanDetailed(cardId); + // IsSuccess = result.Success; + // Message = result.Message; + + // if (!result.Success && result.CooldownSecondsRemaining > 0) + // { + // // Requirement: when blocked due to cooldown, keep the scanned ID visible + // // so users understand which card was blocked. + // CardIdInput = cardId; + // StartCooldownCountdown(result.CooldownSecondsRemaining); + // return; + // } + + // // Clear input for normal success/failure so the next scan starts cleanly. + // CardIdInput = string.Empty; + + // if (result.Success) + // { + // // Update left panel with HRMS employee data from this scan + // if (result.EmployeeInfo is { } info) + // { + // EmployeeId = !string.IsNullOrWhiteSpace(info.EmployeeId) ? info.EmployeeId : "—"; + // var fullName = string.Join(" ", new[] { info.FirstName, info.MiddleName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); + // EmployeeName = string.IsNullOrEmpty(fullName) ? "—" : fullName; + // EmployeeDepartment = !string.IsNullOrWhiteSpace(info.DepartmentTitle) ? info.DepartmentTitle : "—"; + // EmployeeDepartmentType = !string.IsNullOrWhiteSpace(info.DepartmentType) ? info.DepartmentType : "—"; + // // Load menu using site from employee_rfid_tag.location_site_id (not config) + // if (int.TryParse(info.LocationSiteId?.Trim(), out var siteFromRfid)) + // //_ = LoadMenuForSiteAsync(siteFromRfid, result.MealSession); + // // Load employee photo from hrms.employee_photo by parent_document_id (employee document id) + // _ = LoadEmployeePhotoAsync(info.ParentDocumentId); + // } + // // Refresh dashboard stats and order history + // _ = RefreshDashboardAfterSuccessfulScanAsync(cardId); + // } + // } + // finally + // { + // lock (_submitLock) + // { + // _isSubmitting = false; + // // Cooldown keeps the UI locked; otherwise unlock after scan completes. + // if (!_isCooldownActive) + // IsProcessing = false; + // } + // } + //} + + [RelayCommand] private void Scan() { @@ -511,10 +582,34 @@ public partial class ScannerDashboardViewModel : ObservableObject IsSuccess = result.Success; Message = result.Message; + // Check if this is a site mismatch error + if (!result.Success && result.Message.Contains("not allowed to scan here")) + { + // Clear input for site mismatch so the next scan can start fresh + CardIdInput = string.Empty; + + // The employee info is included so we can show who tried to scan + if (result.EmployeeInfo != null) + { + var fullName = string.Join(" ", new[] { result.EmployeeInfo.FirstName, result.EmployeeInfo.MiddleName } + .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); + + // Optionally update the UI with the employee info even though scan failed + EmployeeName = string.IsNullOrEmpty(fullName) ? "—" : fullName; + EmployeeId = !string.IsNullOrWhiteSpace(result.EmployeeInfo.EmployeeId) ? result.EmployeeInfo.EmployeeId : "—"; + EmployeeDepartment = !string.IsNullOrWhiteSpace(result.EmployeeInfo.DepartmentTitle) ? result.EmployeeInfo.DepartmentTitle : "—"; + EmployeeDepartmentType = !string.IsNullOrWhiteSpace(result.EmployeeInfo.DepartmentType) ? result.EmployeeInfo.DepartmentType : "—"; + + // Load employee photo + _ = LoadEmployeePhotoAsync(result.EmployeeInfo.ParentDocumentId); + } + + return; + } + if (!result.Success && result.CooldownSecondsRemaining > 0) { - // Requirement: when blocked due to cooldown, keep the scanned ID visible - // so users understand which card was blocked. + // Keep the scanned ID visible during cooldown CardIdInput = cardId; StartCooldownCountdown(result.CooldownSecondsRemaining); return; @@ -533,10 +628,8 @@ public partial class ScannerDashboardViewModel : ObservableObject EmployeeName = string.IsNullOrEmpty(fullName) ? "—" : fullName; EmployeeDepartment = !string.IsNullOrWhiteSpace(info.DepartmentTitle) ? info.DepartmentTitle : "—"; EmployeeDepartmentType = !string.IsNullOrWhiteSpace(info.DepartmentType) ? info.DepartmentType : "—"; - // Load menu using site from employee_rfid_tag.location_site_id (not config) - if (int.TryParse(info.LocationSiteId?.Trim(), out var siteFromRfid)) - //_ = LoadMenuForSiteAsync(siteFromRfid, result.MealSession); - // Load employee photo from hrms.employee_photo by parent_document_id (employee document id) + + // Load employee photo _ = LoadEmployeePhotoAsync(info.ParentDocumentId); } // Refresh dashboard stats and order history From d3472f37a367f56589e0ae16e2f6ace535adddf8 Mon Sep 17 00:00:00 2001 From: Muhammad Faisal Malik Date: Tue, 17 Mar 2026 14:18:55 +0500 Subject: [PATCH 2/3] "Stop tracking PublishProfiles" --- .gitignore | 3 ++ .../PublishProfiles/ClickOnceProfile.pubxml | 34 ------------------- .../PublishProfiles/FolderProfile.pubxml | 16 --------- 3 files changed, 3 insertions(+), 50 deletions(-) delete mode 100644 Properties/PublishProfiles/ClickOnceProfile.pubxml delete mode 100644 Properties/PublishProfiles/FolderProfile.pubxml diff --git a/.gitignore b/.gitignore index 551f5be..99de91a 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ appsettings.json # Publish publish/ +# Publish Profiles +Properties/PublishProfiles/ + # OS Thumbs.db .DS_Store diff --git a/Properties/PublishProfiles/ClickOnceProfile.pubxml b/Properties/PublishProfiles/ClickOnceProfile.pubxml deleted file mode 100644 index 2f0f0d5..0000000 --- a/Properties/PublishProfiles/ClickOnceProfile.pubxml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - 0 - 1.0.0.* - True - Release - False - true - True - Disk - True - False - True - False - Any CPU - bin\Release\net8.0-windows\win-x86\app.publish\ - D:\Publish\UtopiaCanteenSystem\ - ClickOnce - False - False - win-x86 - True - (none) - False - false - net8.0-windows - False - Foreground - False - Publish.html - - \ No newline at end of file diff --git a/Properties/PublishProfiles/FolderProfile.pubxml b/Properties/PublishProfiles/FolderProfile.pubxml deleted file mode 100644 index 4fe46ef..0000000 --- a/Properties/PublishProfiles/FolderProfile.pubxml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - Release - Any CPU - D:\ - FileSystem - <_TargetId>Folder - net8.0-windows - win-x86 - true - false - false - - \ No newline at end of file From fc762456a43a69f918f7965df7ec22ec09fae0dc Mon Sep 17 00:00:00 2001 From: "mustafa.ahmed" Date: Wed, 18 Mar 2026 13:38:09 +0500 Subject: [PATCH 3/3] Implemented dynamic site filter logic for meal schedules view. Updated the site selection filter to ensure proper normalization of site IDs from the config and database, allowing accurate display of meal schedules based on the selected site. --- ViewModels/MealSchedulesViewModel.cs | 33 +++++++++++++++++++++------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/ViewModels/MealSchedulesViewModel.cs b/ViewModels/MealSchedulesViewModel.cs index e867893..e3b6692 100644 --- a/ViewModels/MealSchedulesViewModel.cs +++ b/ViewModels/MealSchedulesViewModel.cs @@ -296,15 +296,32 @@ public partial class MealSchedulesViewModel : ObservableObject { ApplyFilter(); } + + private void ApplyFilter() +{ + // Remove "SITE : " from the config SiteId and normalize it (pad with leading zeros if necessary) + var site = string.IsNullOrWhiteSpace(_currentSiteId) + ? "01" + : _currentSiteId.Replace("SITE : ", "").PadLeft(2, '0'); // Normalize to two digits if empty or contains "SITE : " - private void ApplyFilter() - { - var site = string.IsNullOrWhiteSpace(_currentSiteId) ? "01" : _currentSiteId; - var filtered = _allSchedules - .Where(s => string.Equals(s.LocationSiteId?.Trim(), site, StringComparison.OrdinalIgnoreCase)) - .ToList(); - Schedules = new ObservableCollection(filtered); - } + // Log the site ID we're filtering by (for debugging purposes). + Console.WriteLine($"Filtering for site: {site}"); + + // Filter the meal schedules by the normalized site ID. + var filtered = _allSchedules + .Where(s => + { + // Normalize the LocationSiteId from the schedule to ensure consistency. + var normalizedLocationSiteId = s.LocationSiteId?.Trim().PadLeft(2, '0') ?? "00"; + Console.WriteLine($"Checking: {normalizedLocationSiteId} vs {site}"); + + return string.Equals(normalizedLocationSiteId, site, StringComparison.OrdinalIgnoreCase); + }) + .ToList(); + + // Update the UI with the filtered results. + Schedules = new ObservableCollection(filtered); +} private async Task LoadSchedulesAsync() {