Merge pull request 'fixes' (#5) from fixes into main

Reviewed-on: #5
pull/6/head^2
SYED MUSTUFA AHMED NAQVI 2026-04-02 05:50:32 +00:00
commit e2363ed394
5 changed files with 476 additions and 41 deletions

View File

@ -0,0 +1,45 @@
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Maps HRMS <c>employee_rfid_tag.grade_type</c> to <c>menu_item.item_for</c>.
/// Menu rows only use MANAGEMENT / NON_MANAGEMENT; other grades must map to one of those.
/// Contractual and MTO/TE/Intern use the management menu; Unassigned uses non-management.
/// </summary>
public static class HrmsMenuItemForMapping
{
/// <summary>
/// Returns MANAGEMENT or NON_MANAGEMENT for menu queries. Never returns empty.
/// </summary>
public static string FromGradeType(string? gradeType)
{
var compact = ToCompactAlphaNum(gradeType);
if (string.IsNullOrEmpty(compact))
return "NON_MANAGEMENT";
if (compact == "management")
return "MANAGEMENT";
if (compact == "nonmanagement")
return "NON_MANAGEMENT";
if (compact == "contractual" || compact == "mtoteintern")
return "MANAGEMENT";
if (compact == "unassigned")
return "NON_MANAGEMENT";
if (compact.StartsWith("non", StringComparison.Ordinal) && compact.Contains("management", StringComparison.Ordinal))
return "NON_MANAGEMENT";
// Any other label (e.g. future grade codes) — use non-management menu so items still resolve.
return "NON_MANAGEMENT";
}
private static string ToCompactAlphaNum(string? s)
{
if (string.IsNullOrWhiteSpace(s))
return string.Empty;
var chars = s.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray();
return new string(chars);
}
}

View File

@ -20,5 +20,5 @@ public interface IMenuLookupService
/// </summary>
//Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAndDateAsync(int siteIdNumeric, DateTime menuDateLocal, CancellationToken cancellationToken = default);
Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAndDateAsync(int siteIdNumeric, DateTime menuDateLocal, string gradeType, CancellationToken cancellationToken = default);
Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAndDateAsync(int siteIdNumeric, DateTime menuDateLocal, string gradeType, string mealName ,CancellationToken cancellationToken = default);
}

View File

@ -147,16 +147,77 @@ public class MenuLookupService : IMenuLookupService
public async Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAsync(int siteIdNumeric, CancellationToken cancellationToken = default)
{
// Backwards-compatible: use today's local date.
return await GetMenuItemsForSiteAndDateAsync(siteIdNumeric, DateTime.Today, "NonManagement", cancellationToken).ConfigureAwait(false);
return await GetMenuItemsForSiteAndDateAsync(siteIdNumeric, DateTime.Today, "NonManagement","no-active-meal-session", cancellationToken).ConfigureAwait(false);
}
// Implementing the method for fetching menu items by site, date, and gradeType
public async Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAndDateAsync(int siteIdNumeric, DateTime menuDateLocal, string gradeType, CancellationToken cancellationToken = default)
//public async Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAndDateAsync(int siteIdNumeric, DateTime menuDateLocal, string gradeType, CancellationToken cancellationToken = default)
//{
// var connectionString = _configService.GetHrmsLookupConnectionString();
// if (string.IsNullOrWhiteSpace(connectionString))
// return Array.Empty<HrmsMenuItem>();
// const string sql = @"
// SELECT
// mi.id,
// mi.item_name,
// mi.item_type,
// mi.price,
// li.menu_date,
// li.day_of_week
// FROM lunch_menu_week w
// JOIN lunch_menu_item li ON li.lunch_menu_week_id = w.id
// JOIN menu_item mi ON mi.id = li.menu_item_id
// WHERE w.location_site_id = @siteId
// AND @menuDate BETWEEN w.week_start_date AND w.week_end_date
// AND li.menu_date = @menuDate
// AND mi.item_for = @itemFor
// ORDER BY mi.item_name;";
// var itemFor = GetItemForFromGradeType(gradeType);
// await using var conn = new MySqlConnection(connectionString);
// await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
// await using var cmd = new MySqlCommand(sql, conn);
// cmd.Parameters.AddWithValue("@siteId", siteIdNumeric);
// cmd.Parameters.AddWithValue("@menuDate", menuDateLocal.Date);
// cmd.Parameters.AddWithValue("@itemFor", itemFor);
// var list = new List<HrmsMenuItem>();
// await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
// while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
// {
// list.Add(new HrmsMenuItem
// {
// MenuItemId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0),
// ItemName = GetString(reader, 1),
// ItemType = GetString(reader, 2),
// Price = GetDecimal(reader, 3),
// MenuDate = GetString(reader, 4),
// DayOfWeek = GetString(reader, 5),
// MealName = string.Empty
// });
// }
// return list;
//}
public async Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAndDateAsync(
int siteIdNumeric,
DateTime menuDateLocal,
string gradeType,
string mealName, // Add mealName parameter
CancellationToken cancellationToken = default)
{
var connectionString = _configService.GetHrmsLookupConnectionString();
if (string.IsNullOrWhiteSpace(connectionString))
return Array.Empty<HrmsMenuItem>();
// Updated SQL to include meal_name filter and select meal_name
const string sql = @"
SELECT
mi.id,
@ -164,23 +225,26 @@ public class MenuLookupService : IMenuLookupService
mi.item_type,
mi.price,
li.menu_date,
li.day_of_week
li.day_of_week,
li.meal_name -- Add meal_name to SELECT
FROM lunch_menu_week w
JOIN lunch_menu_item li ON li.lunch_menu_week_id = w.id
JOIN menu_item mi ON mi.id = li.menu_item_id
WHERE w.location_site_id = @siteId
AND @menuDate BETWEEN w.week_start_date AND w.week_end_date
AND li.menu_date = @menuDate
AND li.meal_name = @mealName -- Filter by meal session
AND mi.item_for = @itemFor
ORDER BY mi.item_name;";
var itemFor = GetItemForFromGradeType(gradeType);
var itemFor = HrmsMenuItemForMapping.FromGradeType(gradeType);
await using var conn = new MySqlConnection(connectionString);
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new MySqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@siteId", siteIdNumeric);
cmd.Parameters.AddWithValue("@menuDate", menuDateLocal.Date);
cmd.Parameters.AddWithValue("@mealName", mealName); // Add mealName parameter
cmd.Parameters.AddWithValue("@itemFor", itemFor);
var list = new List<HrmsMenuItem>();
@ -195,26 +259,12 @@ public class MenuLookupService : IMenuLookupService
Price = GetDecimal(reader, 3),
MenuDate = GetString(reader, 4),
DayOfWeek = GetString(reader, 5),
MealName = string.Empty
MealName = GetString(reader, 6) // Map meal_name from column index 6
});
}
return list;
}
private static string GetItemForFromGradeType(string? gradeType)
{
var value = (gradeType ?? string.Empty).Trim().ToLowerInvariant();
return value switch
{
"management" => "MANAGEMENT",
"nonmanagement" => "NON_MANAGEMENT",
"non_management" => "NON_MANAGEMENT",
_ => string.Empty
};
}
private static string GetString(MySqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal)) return string.Empty;

View File

@ -195,6 +195,7 @@ public class RfidService : IRfidService
//}
public ScanResult ProcessScanDetailed(string cardId)
{
if (string.IsNullOrWhiteSpace(cardId))
@ -326,8 +327,9 @@ public class RfidService : IRfidService
{
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)
.GetMenuItemsForSiteAndDateAsync(siteNumeric, nowLocal.Date, employee.GradeType, mealLabel)
.GetAwaiter()
.GetResult();
@ -381,6 +383,193 @@ public class RfidService : IRfidService
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);
//}
/// <summary>
/// Normalizes a site ID to a consistent format for comparison
/// Handles formats like "1", "01", "SITE : 1", "Site 01", etc.

View File

@ -81,6 +81,52 @@ public class SyncService : ISyncService
SET code = @Code
WHERE id = @Id";
const string findLunchMenuItemSql = @"
SELECT
li.id AS lunch_menu_item_id,
mi.item_name,
mi.item_type,
mi.price
FROM lunch_menu_week w
JOIN lunch_menu_item li ON li.lunch_menu_week_id = w.id
JOIN menu_item mi ON mi.id = li.menu_item_id
WHERE w.location_site_id = @SiteId
AND @MenuDate BETWEEN w.week_start_date AND w.week_end_date
AND li.menu_date = @MenuDate
AND li.meal_name = @MealName
AND mi.item_name = @ItemName
AND (@ItemFor = '' OR mi.item_for = @ItemFor)
LIMIT 1";
const string findLunchMenuItemFallbackSql = @"
SELECT
li.id AS lunch_menu_item_id,
mi.item_name,
mi.item_type,
mi.price
FROM lunch_menu_week w
JOIN lunch_menu_item li ON li.lunch_menu_week_id = w.id
JOIN menu_item mi ON mi.id = li.menu_item_id
WHERE w.location_site_id = @SiteId
AND @MenuDate BETWEEN w.week_start_date AND w.week_end_date
AND li.menu_date = @MenuDate
AND mi.item_name = @ItemName
AND (@ItemFor = '' OR mi.item_for = @ItemFor)
LIMIT 1";
const string existsOrderItemSql = @"
SELECT id
FROM lunch_order_item
WHERE lunch_order_id = @LunchOrderId
AND lunch_menu_item_id = @LunchMenuItemId
LIMIT 1";
const string insertOrderItemSql = @"
INSERT INTO lunch_order_item
(lunch_order_id, lunch_menu_item_id, quantity, price_at_order_time, item_name, item_type)
VALUES
(@LunchOrderId, @LunchMenuItemId, @Quantity, @PriceAtOrderTime, @ItemName, @ItemType)";
var syncedIds = new List<int>(capacity: toSync.Count);
@ -120,7 +166,7 @@ public class SyncService : ISyncService
{
await hrmsConn.OpenAsync(cancellationToken).ConfigureAwait(false);
var alreadyExists = false;
long lunchOrderId = 0;
await using (var existsCmd = new MySqlCommand(existsOrderSql, hrmsConn))
{
existsCmd.Parameters.AddWithValue("@EmployeeId", employeeIdBigint);
@ -129,10 +175,11 @@ public class SyncService : ISyncService
existsCmd.Parameters.AddWithValue("@CreatedAt", createdAt);
existsCmd.Parameters.AddWithValue("@MealName", mealName);
var existing = await existsCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
alreadyExists = existing != null && existing != DBNull.Value;
if (existing != null && existing != DBNull.Value)
lunchOrderId = Convert.ToInt64(existing);
}
if (!alreadyExists)
if (lunchOrderId <= 0)
{
await using var orderCmd = new MySqlCommand(insertOrderSql, hrmsConn);
orderCmd.Parameters.AddWithValue("@EmployeeId", employeeIdBigint);
@ -152,6 +199,7 @@ public class SyncService : ISyncService
var insertedId = orderCmd.LastInsertedId;
if (insertedId > 0)
{
lunchOrderId = insertedId;
var code = GenerateLunchOrderCode(insertedId, createdAt);
await using var updateCodeCmd = new MySqlCommand(updateOrderCodeSql, hrmsConn);
@ -160,6 +208,65 @@ public class SyncService : ISyncService
await updateCodeCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
if (lunchOrderId > 0)
{
var mealItems = SplitMealItems(record.MealItems);
var itemFor = HrmsMenuItemForMapping.FromGradeType(record.grade_type);
foreach (var itemName in mealItems)
{
var menuLookup = await FindLunchMenuItemAsync(
hrmsConn,
findLunchMenuItemSql,
locationSiteId,
orderDateLocal,
mealName,
itemName,
itemFor,
cancellationToken).ConfigureAwait(false);
if (menuLookup is null)
{
menuLookup = await FindLunchMenuItemAsync(
hrmsConn,
findLunchMenuItemFallbackSql,
locationSiteId,
orderDateLocal,
mealName,
itemName,
itemFor,
cancellationToken).ConfigureAwait(false);
}
if (menuLookup is null)
{
System.Diagnostics.Debug.WriteLine($"Menu item mapping not found for '{itemName}' (site={locationSiteId}, date={orderDateLocal:yyyy-MM-dd}, meal={mealName}).");
continue;
}
var itemExists = false;
await using (var existsItemCmd = new MySqlCommand(existsOrderItemSql, hrmsConn))
{
existsItemCmd.Parameters.AddWithValue("@LunchOrderId", lunchOrderId);
existsItemCmd.Parameters.AddWithValue("@LunchMenuItemId", menuLookup.Value.LunchMenuItemId);
var existingItem = await existsItemCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
itemExists = existingItem != null && existingItem != DBNull.Value;
}
if (itemExists)
continue;
await using var insertItemCmd = new MySqlCommand(insertOrderItemSql, hrmsConn);
insertItemCmd.Parameters.AddWithValue("@LunchOrderId", lunchOrderId);
insertItemCmd.Parameters.AddWithValue("@LunchMenuItemId", menuLookup.Value.LunchMenuItemId);
insertItemCmd.Parameters.AddWithValue("@Quantity", 1);
insertItemCmd.Parameters.AddWithValue("@PriceAtOrderTime", menuLookup.Value.Price);
insertItemCmd.Parameters.AddWithValue("@ItemName", menuLookup.Value.ItemName);
insertItemCmd.Parameters.AddWithValue("@ItemType", menuLookup.Value.ItemType);
await insertItemCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
}
syncedIds.Add(record.Id);
@ -228,6 +335,50 @@ public class SyncService : ISyncService
}
}
private static IReadOnlyList<string> SplitMealItems(string? mealItems)
{
if (string.IsNullOrWhiteSpace(mealItems))
return Array.Empty<string>();
return mealItems
.Split('+', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static async Task<(long LunchMenuItemId, string ItemName, string ItemType, decimal Price)?> FindLunchMenuItemAsync(
MySqlConnection conn,
string sql,
int siteId,
DateTime menuDate,
string mealName,
string itemName,
string itemFor,
CancellationToken cancellationToken)
{
await using var cmd = new MySqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@SiteId", siteId);
cmd.Parameters.AddWithValue("@MenuDate", menuDate.Date);
cmd.Parameters.AddWithValue("@MealName", mealName);
cmd.Parameters.AddWithValue("@ItemName", itemName);
cmd.Parameters.AddWithValue("@ItemFor", itemFor);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
return null;
var lunchMenuItemId = reader.IsDBNull(0) ? 0 : Convert.ToInt64(reader.GetValue(0));
if (lunchMenuItemId <= 0)
return null;
var resolvedItemName = reader.IsDBNull(1) ? string.Empty : reader.GetValue(1)?.ToString() ?? string.Empty;
var itemType = reader.IsDBNull(2) ? string.Empty : reader.GetValue(2)?.ToString() ?? string.Empty;
var price = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3));
return (lunchMenuItemId, resolvedItemName, itemType, price);
}
/// <summary>
/// Deletes only synced rows (IsSynced = 1) where ScanTime is before the start of today (local day).
/// </summary>