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="*"/>
-
-
-
-
-
-
+