diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index f5294fe..0ad1294 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -1,4 +1,5 @@ using System.Data; +using System.Data.Common; using System.IO; using Microsoft.EntityFrameworkCore; using UtopiaCanteenSystem.Models; @@ -163,6 +164,13 @@ public class AppDbContext : DbContext cmd.ExecuteNonQuery(); } + if (columns.Count > 0 && !columns.Contains("MealSessionCode", StringComparer.OrdinalIgnoreCase)) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "ALTER TABLE lunch_order_transactions ADD COLUMN MealSessionCode INTEGER DEFAULT -1"; + cmd.ExecuteNonQuery(); + } + if (columns.Count > 0 && !columns.Contains("ParentDocumentId", StringComparer.OrdinalIgnoreCase)) { using var cmd = conn.CreateCommand(); @@ -177,6 +185,41 @@ public class AppDbContext : DbContext cmd.ExecuteNonQuery(); } + if (columns.Count > 0 && !columns.Contains("UindSerial", StringComparer.OrdinalIgnoreCase)) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "ALTER TABLE lunch_order_transactions ADD COLUMN UindSerial TEXT DEFAULT ''"; + cmd.ExecuteNonQuery(); + } + + if (columns.Count > 0 && !columns.Contains("FunctionId", StringComparer.OrdinalIgnoreCase)) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "ALTER TABLE lunch_order_transactions ADD COLUMN FunctionId INTEGER DEFAULT 0"; + cmd.ExecuteNonQuery(); + } + + if (columns.Count > 0 && !columns.Contains("DepartmentId", StringComparer.OrdinalIgnoreCase)) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "ALTER TABLE lunch_order_transactions ADD COLUMN DepartmentId INTEGER DEFAULT 0"; + cmd.ExecuteNonQuery(); + } + + if (columns.Count > 0 && !columns.Contains("TagCreatedAtUtc", StringComparer.OrdinalIgnoreCase)) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "ALTER TABLE lunch_order_transactions ADD COLUMN TagCreatedAtUtc TEXT"; + cmd.ExecuteNonQuery(); + } + + if (columns.Count > 0 && !columns.Contains("TagCreatedBy", StringComparer.OrdinalIgnoreCase)) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "ALTER TABLE lunch_order_transactions ADD COLUMN TagCreatedBy TEXT DEFAULT ''"; + cmd.ExecuteNonQuery(); + } + if (columns.Count > 0 && !columns.Contains("EmployeeName", StringComparer.OrdinalIgnoreCase)) { using var cmd = conn.CreateCommand(); @@ -218,6 +261,9 @@ public class AppDbContext : DbContext cmd.CommandText = "ALTER TABLE lunch_order_transactions ADD COLUMN TotalPrice REAL DEFAULT 0"; cmd.ExecuteNonQuery(); } + + // Remove deprecated column if it exists (older installs). + RemoveEmployeeDbIdColumnIfPresent(conn, columns); } catch { @@ -225,6 +271,110 @@ public class AppDbContext : DbContext } } + /// + /// Removes deprecated EmployeeDbId column from lunch_order_transactions if present. + /// Uses DROP COLUMN when supported; otherwise rebuilds the table without that column. + /// Best-effort and non-fatal. + /// + private static void RemoveEmployeeDbIdColumnIfPresent(DbConnection conn, List existingColumns) + { + if (existingColumns.Count == 0 || !existingColumns.Contains("EmployeeDbId", StringComparer.OrdinalIgnoreCase)) + return; + + // Try modern SQLite DROP COLUMN first. + try + { + using var dropCmd = conn.CreateCommand(); + dropCmd.CommandText = "ALTER TABLE lunch_order_transactions DROP COLUMN EmployeeDbId"; + dropCmd.ExecuteNonQuery(); + return; + } + catch + { + // Fall back to table rebuild. + } + + try + { + using var tx = conn.BeginTransaction(); + + using (var createCmd = conn.CreateCommand()) + { + createCmd.Transaction = tx; + createCmd.CommandText = @" + CREATE TABLE IF NOT EXISTS lunch_order_transactions__new ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + CardId TEXT, + ScanTime TEXT, + IsSynced INTEGER, + SiteId TEXT, + DeviceId TEXT, + IpAddress TEXT, + ParentDocumentId TEXT, + EmployeeId TEXT, + UindSerial TEXT, + FunctionId INTEGER, + DepartmentId INTEGER, + TagCreatedAtUtc TEXT, + TagCreatedBy TEXT, + EmployeeName TEXT, + Department TEXT, + DepartmentType TEXT, + MealLabel TEXT, + MealItems TEXT, + TotalPrice REAL + )"; + createCmd.ExecuteNonQuery(); + } + + using (var copyCmd = conn.CreateCommand()) + { + copyCmd.Transaction = tx; + copyCmd.CommandText = @" + INSERT INTO lunch_order_transactions__new + (Id, CardId, ScanTime, IsSynced, SiteId, DeviceId, IpAddress, ParentDocumentId, EmployeeId, UindSerial, FunctionId, DepartmentId, TagCreatedAtUtc, TagCreatedBy, EmployeeName, Department, DepartmentType, MealLabel, MealItems, TotalPrice) + SELECT + Id, CardId, ScanTime, IsSynced, SiteId, DeviceId, IpAddress, ParentDocumentId, EmployeeId, UindSerial, FunctionId, DepartmentId, TagCreatedAtUtc, TagCreatedBy, EmployeeName, Department, DepartmentType, MealLabel, MealItems, TotalPrice + FROM lunch_order_transactions"; + copyCmd.ExecuteNonQuery(); + } + + using (var dropOldCmd = conn.CreateCommand()) + { + dropOldCmd.Transaction = tx; + dropOldCmd.CommandText = "DROP TABLE lunch_order_transactions"; + dropOldCmd.ExecuteNonQuery(); + } + + using (var renameCmd = conn.CreateCommand()) + { + renameCmd.Transaction = tx; + renameCmd.CommandText = "ALTER TABLE lunch_order_transactions__new RENAME TO lunch_order_transactions"; + renameCmd.ExecuteNonQuery(); + } + + // Recreate basic indexes used by the app. + using (var idxCmd = conn.CreateCommand()) + { + idxCmd.Transaction = tx; + idxCmd.CommandText = "CREATE INDEX IF NOT EXISTS IX_lunch_order_transactions_IsSynced ON lunch_order_transactions(IsSynced)"; + idxCmd.ExecuteNonQuery(); + } + using (var idxCmd = conn.CreateCommand()) + { + idxCmd.Transaction = tx; + idxCmd.CommandText = "CREATE INDEX IF NOT EXISTS IX_lunch_order_transactions_ScanTime ON lunch_order_transactions(ScanTime)"; + idxCmd.ExecuteNonQuery(); + } + + tx.Commit(); + } + catch + { + // Best-effort; ignore failures. + } + } + /// /// Lightweight creation of AdminLoginRecords table for existing databases. /// diff --git a/Models/HrmsEmployeeInfo.cs b/Models/HrmsEmployeeInfo.cs index cde2f73..5a166e1 100644 --- a/Models/HrmsEmployeeInfo.cs +++ b/Models/HrmsEmployeeInfo.cs @@ -9,9 +9,18 @@ public class HrmsEmployeeInfo public string ParentDocumentId { get; set; } = string.Empty; /// Employee serial number (employee.serial_number). public string EmployeeId { get; set; } = string.Empty; + /// uind_serial from employee_rfid_tag (production lunch_order.employee_serial_number). + public string UindSerial { get; set; } = string.Empty; + /// function_id from employee_rfid_tag. + public int FunctionId { get; set; } + /// department_id from employee_rfid_tag (production lunch_order.department_id). + public int DepartmentId { get; set; } + /// date_time_created from employee_rfid_tag (production lunch_order.created_at). + public DateTime? TagCreatedAtUtc { get; set; } + /// created_by from employee_rfid_tag (production lunch_order.created_by). + public string TagCreatedBy { get; set; } = string.Empty; public string FirstName { get; set; } = string.Empty; public string MiddleName { get; set; } = string.Empty; - public string DepartmentId { get; set; } = string.Empty; public string DepartmentTitle { get; set; } = string.Empty; public string DepartmentType { get; set; } = string.Empty; /// Site from employee_rfid_tag.location_site_id; used for menu lookup (lunch_menu_week). diff --git a/Models/HrmsMenuItem.cs b/Models/HrmsMenuItem.cs index 2498e17..f40bbb6 100644 --- a/Models/HrmsMenuItem.cs +++ b/Models/HrmsMenuItem.cs @@ -6,9 +6,19 @@ namespace UtopiaCanteenSystem.Models; public class HrmsMenuItem { /// menu_item.id + //public int MenuItemId { get; set; } + //public string ItemName { get; set; } = string.Empty; + //public string ItemType { get; set; } = string.Empty; + ///// menu_item.price (HRMS). Decimal is safest for money. + //public decimal Price { get; set; } + public int MenuItemId { get; set; } public string ItemName { get; set; } = string.Empty; public string ItemType { get; set; } = string.Empty; - /// menu_item.price (HRMS). Decimal is safest for money. public decimal Price { get; set; } + + // NEW: + public string MealName { get; set; } = string.Empty; // Sehri / Iftari / Dinner + public string MenuDate { get; set; } = string.Empty; // "2026-03-05" + public string DayOfWeek { get; set; } = string.Empty; // Thursday } diff --git a/Models/MealSchedule.cs b/Models/MealSchedule.cs index 8ffc7c5..7ab4a25 100644 --- a/Models/MealSchedule.cs +++ b/Models/MealSchedule.cs @@ -6,6 +6,8 @@ namespace UtopiaCanteenSystem.Models; public class MealSchedule { public long Id { get; set; } + /// Raw meal_name from hrms.meal_schedule (e.g. \"Sehri\", \"Iftari\", \"Tea\", \"Dinner\"). + public string MealName { get; set; } = string.Empty; /// Site identifier (matches ScanRecord.SiteId / config). public string LocationSiteId { get; set; } = string.Empty; /// Session type: 0=Breakfast, 1=Lunch, 2=Tea, 3=Dinner (MealSession enum value). diff --git a/Models/ScanRecord.cs b/Models/ScanRecord.cs index 9cd1577..8b6a58c 100644 --- a/Models/ScanRecord.cs +++ b/Models/ScanRecord.cs @@ -18,10 +18,22 @@ public class ScanRecord public string DeviceId { get; set; } = string.Empty; /// IP address of the device at scan time. public string IpAddress { get; set; } = string.Empty; + /// Meal session enum value at scan time (int cast of MealSession). + public int MealSessionCode { get; set; } /// HRMS employee document id (employee.id) at scan time; used to load photo from hrms.employee_photo. public string ParentDocumentId { get; set; } = string.Empty; /// HRMS employee ID (serial_number) at scan time, when lookup succeeded. public string EmployeeId { get; set; } = string.Empty; + /// uind_serial from employee_rfid_tag at scan time. + public string UindSerial { get; set; } = string.Empty; + /// function_id from employee_rfid_tag at scan time. + public int FunctionId { get; set; } + /// department_id from employee_rfid_tag at scan time. + public int DepartmentId { get; set; } + /// date_time_created from employee_rfid_tag (stored as DateTime; naming kept for clarity). + public DateTime? TagCreatedAtUtc { get; set; } + /// created_by from employee_rfid_tag. + public string TagCreatedBy { get; set; } = string.Empty; /// Full name (first + middle) from HRMS at scan time, when lookup succeeded. public string EmployeeName { get; set; } = string.Empty; /// HRMS department title at scan time. diff --git a/Properties/PublishProfiles/ClickOnceProfile.pubxml b/Properties/PublishProfiles/ClickOnceProfile.pubxml index e5fcd31..2f0f0d5 100644 --- a/Properties/PublishProfiles/ClickOnceProfile.pubxml +++ b/Properties/PublishProfiles/ClickOnceProfile.pubxml @@ -2,37 +2,33 @@ - 13 + 0 1.0.0.* True Release - True False true True - Unc - \\FileServer\Edata\Deployments-by-DotNet-Team\UtopiaCanteenSystem\ + Disk True False True False Any CPU bin\Release\net8.0-windows\win-x86\app.publish\ - \\FileServer\Edata\Deployments-by-DotNet-Team\UtopiaCanteenSystem\ + D:\Publish\UtopiaCanteenSystem\ ClickOnce False - True + False win-x86 True (none) False false net8.0-windows - True + False Foreground False - \\FileServer\Edata\Deployments-by-DotNet-Team\UtopiaCanteenSystem\ Publish.html - False|2026-03-03T06:08:36.2813025Z||;False|2026-03-03T11:07:21.6156279+05:00||;True|2026-02-18T16:44:11.3509970+05:00||;True|2026-02-12T11:25:40.9881813+05:00||;True|2026-02-12T11:22:02.6537306+05:00||;True|2026-02-12T10:57:16.2775876+05:00||;True|2026-02-12T10:50:58.6968682+05:00||;False|2026-02-12T10:49:27.1867616+05:00||;True|2026-02-12T10:44:55.6022990+05:00||;True|2026-02-11T17:00:04.2786466+05:00||;True|2026-02-11T16:54:37.5052808+05:00||;True|2026-02-11T16:39:26.1892892+05:00||;True|2026-02-11T16:30:57.2420414+05:00||;True|2026-02-11T16:20:44.4749529+05:00||; \ No newline at end of file diff --git a/Properties/PublishProfiles/FolderProfile.pubxml b/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..4fe46ef --- /dev/null +++ b/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,16 @@ + + + + + Release + Any CPU + D:\ + FileSystem + <_TargetId>Folder + net8.0-windows + win-x86 + true + false + false + + \ No newline at end of file diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs index f75f70b..6769955 100644 --- a/Services/ConfigService.cs +++ b/Services/ConfigService.cs @@ -224,7 +224,17 @@ public class ConfigService : IConfigService SaveConfig(); } - public string GetHrmsLookupConnectionString() => (_config.HrmsLookupConnectionString ?? string.Empty).Trim(); + //public string GetHrmsLookupConnectionString() => (_config.HrmsLookupConnectionString ?? string.Empty).Trim(); + + public string GetHrmsLookupConnectionString() + { + var hrms = (_config.HrmsLookupConnectionString ?? string.Empty).Trim(); + if (!string.IsNullOrWhiteSpace(hrms)) + return hrms; + + // fallback to production/AWS connection string + return GetMySqlConnectionString(); + } public void SetHrmsLookupConnectionString(string connectionString) { diff --git a/Services/EmployeeLookupService.cs b/Services/EmployeeLookupService.cs index 4cee5b1..19ae2a4 100644 --- a/Services/EmployeeLookupService.cs +++ b/Services/EmployeeLookupService.cs @@ -30,7 +30,11 @@ public class EmployeeLookupService : IEmployeeLookupService e.serial_number AS employee_id, e.concatenated_name AS first_name, '' AS middle_name, - e.department_id, + r.uind_serial AS uind_serial, + r.function_id AS function_id, + r.department_id AS tag_department_id, + r.date_time_created AS tag_date_time_created, + r.created_by AS tag_created_by, d.title AS department_title, d.department_type, r.location_site_id AS location_site_id @@ -50,16 +54,21 @@ public class EmployeeLookupService : IEmployeeLookupService if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) return null; + var parentDocumentId = GetString(reader, 0); return new HrmsEmployeeInfo { - ParentDocumentId = GetString(reader, 0), + ParentDocumentId = parentDocumentId, EmployeeId = GetString(reader, 1), FirstName = GetString(reader, 2), MiddleName = GetString(reader, 3), - DepartmentId = GetString(reader, 4), - DepartmentTitle = GetString(reader, 5), - DepartmentType = GetString(reader, 6), - LocationSiteId = GetString(reader, 7) + UindSerial = GetString(reader, 4), + FunctionId = GetInt(reader, 5), + DepartmentId = GetInt(reader, 6), + TagCreatedAtUtc = GetDateTimeNullable(reader, 7), + TagCreatedBy = GetString(reader, 8), + DepartmentTitle = GetString(reader, 9), + DepartmentType = GetString(reader, 10), + LocationSiteId = GetString(reader, 11) }; } @@ -69,4 +78,36 @@ public class EmployeeLookupService : IEmployeeLookupService var v = reader.GetValue(ordinal); return v?.ToString() ?? string.Empty; } + + private static int GetInt(MySqlDataReader reader, int ordinal) + { + try + { + if (reader.IsDBNull(ordinal)) return 0; + var v = reader.GetValue(ordinal); + if (v is int i) return i; + if (v is long l) return (int)l; + return int.TryParse(v?.ToString(), out var parsed) ? parsed : 0; + } + catch + { + return 0; + } + } + + private static DateTime? GetDateTimeNullable(MySqlDataReader reader, int ordinal) + { + try + { + if (reader.IsDBNull(ordinal)) return null; + var v = reader.GetValue(ordinal); + if (v is DateTime dt) return dt; + if (DateTime.TryParse(v?.ToString(), out var parsed)) return parsed; + return null; + } + catch + { + return null; + } + } } diff --git a/Services/MenuLookupService.cs b/Services/MenuLookupService.cs index 20f8c20..323539f 100644 --- a/Services/MenuLookupService.cs +++ b/Services/MenuLookupService.cs @@ -23,14 +23,31 @@ public class MenuLookupService : IMenuLookupService if (string.IsNullOrWhiteSpace(connectionString)) return Array.Empty(); + //const string sql = @" + // SELECT DISTINCT mi.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 CURDATE() BETWEEN w.week_start_date AND w.week_end_date + // ORDER BY mi.item_type, mi.item_name"; const string sql = @" - SELECT DISTINCT mi.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 CURDATE() BETWEEN w.week_start_date AND w.week_end_date - ORDER BY mi.item_type, mi.item_name"; + SELECT + mi.id, + mi.item_name, + mi.item_type, + mi.price, + li.meal_name, + 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 CURDATE() BETWEEN w.week_start_date AND w.week_end_date + AND li.menu_date = DATE_FORMAT(CURDATE(), '%Y-%m-%d') + ORDER BY li.meal_name, mi.item_name; +"; await using var conn = new MySqlConnection(connectionString); await conn.OpenAsync(cancellationToken).ConfigureAwait(false); @@ -41,12 +58,24 @@ public class MenuLookupService : IMenuLookupService 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) + //}); + list.Add(new HrmsMenuItem { MenuItemId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0), ItemName = GetString(reader, 1), ItemType = GetString(reader, 2), - Price = GetDecimal(reader, 3) + Price = GetDecimal(reader, 3), + + MealName = GetString(reader, 4), + MenuDate = GetString(reader, 5), + DayOfWeek = GetString(reader, 6), }); } diff --git a/Services/ProductionMealScheduleService.cs b/Services/ProductionMealScheduleService.cs index 5ee582f..3454b16 100644 --- a/Services/ProductionMealScheduleService.cs +++ b/Services/ProductionMealScheduleService.cs @@ -72,7 +72,7 @@ public class ProductionMealScheduleService : IMealScheduleService throw new InvalidOperationException("HRMS MySQL connection string not configured."); var utc = DateTime.UtcNow; - var mealName = MealSessionToName(schedule.MealSession); + var mealName = (schedule.MealName ?? string.Empty).Trim(); var siteIdInt = SiteIdStringToInt(schedule.LocationSiteId); await using var conn = new MySqlConnection(connStr); @@ -98,7 +98,7 @@ public class ProductionMealScheduleService : IMealScheduleService throw new InvalidOperationException("HRMS MySQL connection string not configured."); var utc = DateTime.UtcNow; - var mealName = MealSessionToName(schedule.MealSession); + var mealName = (schedule.MealName ?? string.Empty).Trim(); var siteIdInt = SiteIdStringToInt(schedule.LocationSiteId); await using var conn = new MySqlConnection(connStr); @@ -135,6 +135,7 @@ public class ProductionMealScheduleService : IMealScheduleService return new MealSchedule { Id = r.GetInt64(0), + MealName = GetString(r, 1), MealSession = MealNameToSession(GetString(r, 1)), StartTime = GetTimeString(r, 2), EndTime = GetTimeString(r, 3), @@ -171,8 +172,15 @@ public class ProductionMealScheduleService : IMealScheduleService { if (string.IsNullOrWhiteSpace(mealName)) return 0; var n = mealName.Trim(); - if (n.Equals("Breakfast", StringComparison.OrdinalIgnoreCase) || n.Equals("BreakFast", StringComparison.OrdinalIgnoreCase)) return (int)MealSession.Breakfast; - if (n.Equals("Lunch", StringComparison.OrdinalIgnoreCase)) return (int)MealSession.Lunch; + // Map legacy names as well as Ramadan labels used in production (Sehri/Iftari) + if (n.Equals("Breakfast", StringComparison.OrdinalIgnoreCase) + || n.Equals("BreakFast", StringComparison.OrdinalIgnoreCase) + || n.Equals("Sehri", StringComparison.OrdinalIgnoreCase)) + return (int)MealSession.Breakfast; + if (n.Equals("Lunch", StringComparison.OrdinalIgnoreCase) + || n.Equals("Iftari", StringComparison.OrdinalIgnoreCase) + || n.Equals("Iftar", StringComparison.OrdinalIgnoreCase)) + return (int)MealSession.Lunch; if (n.Equals("Tea", StringComparison.OrdinalIgnoreCase)) return (int)MealSession.Tea; if (n.Equals("Dinner", StringComparison.OrdinalIgnoreCase)) return (int)MealSession.Dinner; return 0; diff --git a/Services/RfidService.cs b/Services/RfidService.cs index b2ccc40..356950f 100644 --- a/Services/RfidService.cs +++ b/Services/RfidService.cs @@ -58,14 +58,35 @@ public class RfidService : IRfidService 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); - // Enforce timeout: no duplicate scan within the timeout window + // 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) @@ -81,21 +102,6 @@ public class RfidService : IRfidService 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 fullName = string.Join(" ", new[] { employee.FirstName, employee.MiddleName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); var siteId = !string.IsNullOrWhiteSpace(employee.LocationSiteId) ? employee.LocationSiteId.Trim() @@ -108,8 +114,14 @@ public class RfidService : IRfidService 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 @@ -120,6 +132,18 @@ public class RfidService : IRfidService 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(); diff --git a/Services/SyncService.cs b/Services/SyncService.cs index 07dff84..79bd5e5 100644 --- a/Services/SyncService.cs +++ b/Services/SyncService.cs @@ -21,11 +21,18 @@ public class SyncService : ISyncService } public async Task SyncNowAsync(CancellationToken cancellationToken = default) - { - var connectionString = _configService.GetMySqlConnectionString(); - if (string.IsNullOrWhiteSpace(connectionString)) + { + var productionConnStr = _configService.GetMySqlConnectionString(); + if (string.IsNullOrWhiteSpace(productionConnStr)) { - System.Diagnostics.Debug.WriteLine("MySQL connection string not configured; skipping sync."); + System.Diagnostics.Debug.WriteLine("MySQL connection string (production) not configured; skipping sync."); + return; + } + + var hrmsConnStr = _configService.GetHrmsLookupConnectionString(); + if (string.IsNullOrWhiteSpace(hrmsConnStr)) + { + System.Diagnostics.Debug.WriteLine("HRMS lookup connection string not configured; skipping sync (need both for lunch_order)."); return; } @@ -45,85 +52,136 @@ public class SyncService : ISyncService if (toSync.Count == 0) return; - try + // Production: lunch_order_transactions (GetMySqlConnectionString) + const string insertTxnSql = @" + INSERT IGNORE INTO lunch_order_transactions + (scan_date, site_id, device_id, card_id, ip_address, received_date) + VALUES + (@ScanTimeUtc, @SiteId, @DeviceId, @CardId, @IpAddress, UTC_TIMESTAMP(3))"; + + // HRMS/local: lunch_order (GetHrmsLookupConnectionString) + const string existsOrderSql = @" + SELECT id + FROM lunch_order + WHERE employee_id = @EmployeeId + AND order_date = @OrderDate + AND total_cost = @TotalCost + AND created_at = @CreatedAt + LIMIT 1"; + + const string insertOrderSql = @" + INSERT INTO lunch_order + (employee_id, employee_serial_number, order_date, shift, created_at, total_cost, is_cancelled, cancelled_by, created_by, function_id, department_id, location_site_id) + VALUES + (@EmployeeId, @EmployeeSerialNumber, @OrderDate, @Shift, @CreatedAt, @TotalCost, 0, @CancelledBy, @CreatedBy, @FunctionId, @DepartmentId, @LocationSiteId)"; + + var syncedIds = new List(capacity: toSync.Count); + + foreach (var record in toSync) { - // Insert into MySQL using transaction for atomicity - await using var mysqlConn = new MySqlConnection(connectionString); - await mysqlConn.OpenAsync(cancellationToken).ConfigureAwait(false); - - await using var transaction = await mysqlConn.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); - try { - // INSERT IGNORE: duplicates avoided by UNIQUE(site_id, device_id, card_id, scan_date) in production. - // Column names match production: hrms.lunch_order_transactions (snake_case). No device_local_row_id. - var insertSql = @" - INSERT IGNORE INTO lunch_order_transactions - (scan_date, site_id, device_id, card_id, ip_address, received_date) - VALUES - (@ScanTimeUtc, @SiteId, @DeviceId, @CardId, @IpAddress, UTC_TIMESTAMP(3))"; - - await using var cmd = new MySqlCommand(insertSql, mysqlConn, transaction); - - foreach (var record in toSync) + // 1) Insert into lunch_order_transactions (production – MySqlConnectionString) + await using (var prodConn = new MySqlConnection(productionConnStr)) { - cmd.Parameters.Clear(); + await prodConn.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var cmd = new MySqlCommand(insertTxnSql, prodConn); cmd.Parameters.AddWithValue("@ScanTimeUtc", record.ScanTime); cmd.Parameters.AddWithValue("@SiteId", record.SiteId ?? string.Empty); cmd.Parameters.AddWithValue("@DeviceId", record.DeviceId ?? string.Empty); cmd.Parameters.AddWithValue("@CardId", record.CardId ?? string.Empty); cmd.Parameters.AddWithValue("@IpAddress", record.IpAddress ?? string.Empty); - - var rowsAffected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - // Note: INSERT IGNORE returns 0 if row already exists (duplicate), 1 if inserted + await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } - // Commit MySQL transaction - await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + // 2) Insert into lunch_order (HRMS/local – HrmsLookupConnectionString) + if (string.IsNullOrWhiteSpace(record.ParentDocumentId) || record.FunctionId <= 0 || record.DepartmentId <= 0) + throw new InvalidOperationException("Missing required HRMS tag fields for lunch_order insert."); - // Only mark as synced in SQLite after successful MySQL insert - var ids = toSync.Select(r => r.Id).ToList(); - using (var db = _dbFactory.CreateDbContext()) + if (!long.TryParse(record.ParentDocumentId.Trim(), out var employeeIdBigint) || employeeIdBigint <= 0) + throw new InvalidOperationException("Invalid ParentDocumentId for lunch_order.employee_id."); + + var orderDateLocal = record.ScanTime.ToLocalTime().Date; + var totalCost = Math.Round((decimal)record.TotalPrice, 2, MidpointRounding.AwayFromZero); + var createdAt = record.TagCreatedAtUtc ?? record.ScanTime; + var locationSiteId = SiteIdStringToInt(record.SiteId ?? string.Empty); + + await using (var hrmsConn = new MySqlConnection(hrmsConnStr)) { - var records = await db.LunchOrderTransactions - .Where(r => ids.Contains(r.Id)) - .ToListAsync(cancellationToken) - .ConfigureAwait(false); - foreach (var record in records) - record.IsSynced = true; + await hrmsConn.OpenAsync(cancellationToken).ConfigureAwait(false); - await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + var alreadyExists = false; + await using (var existsCmd = new MySqlCommand(existsOrderSql, hrmsConn)) + { + existsCmd.Parameters.AddWithValue("@EmployeeId", employeeIdBigint); + existsCmd.Parameters.AddWithValue("@OrderDate", orderDateLocal); + existsCmd.Parameters.AddWithValue("@TotalCost", totalCost); + existsCmd.Parameters.AddWithValue("@CreatedAt", createdAt); + var existing = await existsCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + alreadyExists = existing != null && existing != DBNull.Value; + } + + if (!alreadyExists) + { + await using var orderCmd = new MySqlCommand(insertOrderSql, hrmsConn); + orderCmd.Parameters.AddWithValue("@EmployeeId", employeeIdBigint); + orderCmd.Parameters.AddWithValue("@EmployeeSerialNumber", record.UindSerial ?? string.Empty); + orderCmd.Parameters.AddWithValue("@OrderDate", orderDateLocal); + orderCmd.Parameters.AddWithValue("@Shift", string.Empty); + orderCmd.Parameters.AddWithValue("@CreatedAt", createdAt); + orderCmd.Parameters.AddWithValue("@TotalCost", totalCost); + orderCmd.Parameters.AddWithValue("@CancelledBy", string.Empty); + orderCmd.Parameters.AddWithValue("@CreatedBy", record.TagCreatedBy ?? string.Empty); + orderCmd.Parameters.AddWithValue("@FunctionId", record.FunctionId); + orderCmd.Parameters.AddWithValue("@DepartmentId", record.DepartmentId); + orderCmd.Parameters.AddWithValue("@LocationSiteId", locationSiteId); + await orderCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } } - // Day-end cleanup: delete only synced rows from previous days. - await CleanupOldSyncedRowsAsync(cancellationToken).ConfigureAwait(false); + syncedIds.Add(record.Id); } catch (Exception ex) { - // Rollback MySQL transaction on error - try - { - await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); - } - catch - { - // Ignore rollback errors - } - - // Log error for debugging (you can replace with proper logging) - System.Diagnostics.Debug.WriteLine($"Sync error: {ex.Message}"); - System.Diagnostics.Debug.WriteLine($"Stack trace: {ex.StackTrace}"); - - throw; // Re-throw to be caught by outer catch + System.Diagnostics.Debug.WriteLine($"Sync record failed (SQLite Id={record.Id}): {ex.Message}"); + // Leave unsynced; will retry later. } } + + if (syncedIds.Count > 0) + { + using var db = _dbFactory.CreateDbContext(); + var records = await db.LunchOrderTransactions + .Where(r => syncedIds.Contains(r.Id)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + foreach (var r in records) + r.IsSynced = true; + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + try + { + await CleanupOldSyncedRowsAsync(cancellationToken).ConfigureAwait(false); + } catch (Exception ex) { - // Log error for debugging (you can replace with proper logging) - System.Diagnostics.Debug.WriteLine($"Sync failed: {ex.Message}"); - System.Diagnostics.Debug.WriteLine($"Stack trace: {ex.StackTrace}"); - - // Leave records intact; will retry on next run + System.Diagnostics.Debug.WriteLine($"Sync cleanup failed: {ex.Message}"); + } + } + + private static int SiteIdStringToInt(string siteId) + { + try + { + var s = (siteId ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(s)) return 0; + var digits = new string(s.Where(char.IsDigit).ToArray()); + return int.TryParse(digits, out var n) ? n : 0; + } + catch + { + return 0; } } diff --git a/ViewModels/MealSchedulesViewModel.cs b/ViewModels/MealSchedulesViewModel.cs index fe4333e..f6d3adf 100644 --- a/ViewModels/MealSchedulesViewModel.cs +++ b/ViewModels/MealSchedulesViewModel.cs @@ -35,7 +35,7 @@ public partial class MealSchedulesViewModel : ObservableObject private string _locationSiteId = string.Empty; [ObservableProperty] - private int _mealSessionIndex; // 0=Breakfast, 1=Lunch, 2=Tea, 3=Dinner + private string _mealName = string.Empty; [ObservableProperty] private string _startTime = "06:00:00"; @@ -52,8 +52,6 @@ public partial class MealSchedulesViewModel : ObservableObject [ObservableProperty] private bool _isLoading; - public static readonly string[] MealSessionNames = { "Breakfast", "Lunch", "Tea", "Dinner" }; - public MealSchedulesViewModel(IMealScheduleService mealScheduleService, INavigationService navigation) { _mealScheduleService = mealScheduleService; @@ -70,7 +68,7 @@ public partial class MealSchedulesViewModel : ObservableObject { if (value == null) return; LocationSiteId = value.LocationSiteId ?? string.Empty; - MealSessionIndex = value.MealSession; + MealName = value.MealName ?? string.Empty; StartTime = value.StartTime ?? "00:00:00"; EndTime = value.EndTime ?? "23:59:59"; } @@ -122,7 +120,7 @@ public partial class MealSchedulesViewModel : ObservableObject { SelectedSchedule = null; LocationSiteId = "02"; - MealSessionIndex = 0; + MealName = string.Empty; StartTime = "06:00:00"; EndTime = "09:00:00"; Message = string.Empty; @@ -162,8 +160,8 @@ public partial class MealSchedulesViewModel : ObservableObject var dto = new MealSchedule { Id = SelectedSchedule.Id, + MealName = (MealName ?? string.Empty).Trim(), LocationSiteId = siteId, - MealSession = MealSessionIndex, StartTime = StartTime.Trim(), EndTime = EndTime.Trim() }; @@ -174,8 +172,8 @@ public partial class MealSchedulesViewModel : ObservableObject { var dto = new MealSchedule { + MealName = (MealName ?? string.Empty).Trim(), LocationSiteId = siteId, - MealSession = MealSessionIndex, StartTime = StartTime.Trim(), EndTime = EndTime.Trim() }; diff --git a/ViewModels/ScannerDashboardViewModel.cs b/ViewModels/ScannerDashboardViewModel.cs index a249a74..625ec44 100644 --- a/ViewModels/ScannerDashboardViewModel.cs +++ b/ViewModels/ScannerDashboardViewModel.cs @@ -258,10 +258,18 @@ public partial class ScannerDashboardViewModel : ObservableObject } else { + //mealLabel = activeSession switch + //{ + // MealSession.Breakfast => "Breakfast", + // MealSession.Lunch => "Lunch", + // MealSession.Tea => "Tea", + // MealSession.Dinner => "Dinner", + // _ => "No active meal session" + //}; mealLabel = activeSession switch { - MealSession.Breakfast => "Breakfast", - MealSession.Lunch => "Lunch", + MealSession.Breakfast => "Sehri", + MealSession.Lunch => "Iftari", MealSession.Tea => "Tea", MealSession.Dinner => "Dinner", _ => "No active meal session" @@ -276,11 +284,15 @@ public partial class ScannerDashboardViewModel : ObservableObject !string.Equals(mealLabel, "No active meal session", StringComparison.OrdinalIgnoreCase) && items.Count > 0) { + //var matchingItems = items + // .Where(i => + // !string.IsNullOrWhiteSpace(i.ItemType) && + // i.ItemType.IndexOf(mealLabel, StringComparison.OrdinalIgnoreCase) >= 0) + // .ToList(); + var matchingItems = items - .Where(i => - !string.IsNullOrWhiteSpace(i.ItemType) && - i.ItemType.IndexOf(mealLabel, StringComparison.OrdinalIgnoreCase) >= 0) - .ToList(); + .Where(i => string.Equals(i.MealName, mealLabel, StringComparison.OrdinalIgnoreCase)) + .ToList(); var matchingItemNames = matchingItems .Select(i => i.ItemName) diff --git a/Views/MealSchedulesView.xaml b/Views/MealSchedulesView.xaml index 3948fa0..327424d 100644 --- a/Views/MealSchedulesView.xaml +++ b/Views/MealSchedulesView.xaml @@ -367,7 +367,7 @@ Binding="{Binding LocationSiteId}" Width="*"/> - - - - - - +