Sync scans to lunch_order and enforce per-session daily scans

Extend HRMS RFID lookup to include uind_serial, function_id, department_id, created_by, and tag created timestamp
Persist new tag fields + meal session code in SQLite scan records; auto-migrate schema and drop deprecated EmployeeDbId column
Enforce “once per meal session per local day” scan rule (keep short per-card debounce; remove global cooldown)
Enhance sync: insert scans into production lunch_order_transactions (MySqlConnectionString) and into HRMS/local lunch_order (HrmsLookupConnectionString) with duplicate guard; include location_site_id from SQLite SiteId
pull/1/head
SYED MUSTUFA AHMED NAQVI 2026-03-09 13:01:08 +05:00
parent 18a6b7a668
commit a42f43b2cc
16 changed files with 497 additions and 127 deletions

View File

@ -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
}
}
/// <summary>
/// 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.
/// </summary>
private static void RemoveEmployeeDbIdColumnIfPresent(DbConnection conn, List<string> 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.
}
}
/// <summary>
/// Lightweight creation of AdminLoginRecords table for existing databases.
/// </summary>

View File

@ -9,9 +9,18 @@ public class HrmsEmployeeInfo
public string ParentDocumentId { get; set; } = string.Empty;
/// <summary>Employee serial number (employee.serial_number).</summary>
public string EmployeeId { get; set; } = string.Empty;
/// <summary>uind_serial from employee_rfid_tag (production lunch_order.employee_serial_number).</summary>
public string UindSerial { get; set; } = string.Empty;
/// <summary>function_id from employee_rfid_tag.</summary>
public int FunctionId { get; set; }
/// <summary>department_id from employee_rfid_tag (production lunch_order.department_id).</summary>
public int DepartmentId { get; set; }
/// <summary>date_time_created from employee_rfid_tag (production lunch_order.created_at).</summary>
public DateTime? TagCreatedAtUtc { get; set; }
/// <summary>created_by from employee_rfid_tag (production lunch_order.created_by).</summary>
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;
/// <summary>Site from employee_rfid_tag.location_site_id; used for menu lookup (lunch_menu_week).</summary>

View File

@ -6,9 +6,19 @@ namespace UtopiaCanteenSystem.Models;
public class HrmsMenuItem
{
/// <summary>menu_item.id</summary>
//public int MenuItemId { get; set; }
//public string ItemName { get; set; } = string.Empty;
//public string ItemType { get; set; } = string.Empty;
///// <summary>menu_item.price (HRMS). Decimal is safest for money.</summary>
//public decimal Price { get; set; }
public int MenuItemId { get; set; }
public string ItemName { get; set; } = string.Empty;
public string ItemType { get; set; } = string.Empty;
/// <summary>menu_item.price (HRMS). Decimal is safest for money.</summary>
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
}

View File

@ -6,6 +6,8 @@ namespace UtopiaCanteenSystem.Models;
public class MealSchedule
{
public long Id { get; set; }
/// <summary>Raw meal_name from hrms.meal_schedule (e.g. \"Sehri\", \"Iftari\", \"Tea\", \"Dinner\").</summary>
public string MealName { get; set; } = string.Empty;
/// <summary>Site identifier (matches ScanRecord.SiteId / config).</summary>
public string LocationSiteId { get; set; } = string.Empty;
/// <summary>Session type: 0=Breakfast, 1=Lunch, 2=Tea, 3=Dinner (MealSession enum value).</summary>

View File

@ -18,10 +18,22 @@ public class ScanRecord
public string DeviceId { get; set; } = string.Empty;
/// <summary>IP address of the device at scan time.</summary>
public string IpAddress { get; set; } = string.Empty;
/// <summary>Meal session enum value at scan time (int cast of MealSession).</summary>
public int MealSessionCode { get; set; }
/// <summary>HRMS employee document id (employee.id) at scan time; used to load photo from hrms.employee_photo.</summary>
public string ParentDocumentId { get; set; } = string.Empty;
/// <summary>HRMS employee ID (serial_number) at scan time, when lookup succeeded.</summary>
public string EmployeeId { get; set; } = string.Empty;
/// <summary>uind_serial from employee_rfid_tag at scan time.</summary>
public string UindSerial { get; set; } = string.Empty;
/// <summary>function_id from employee_rfid_tag at scan time.</summary>
public int FunctionId { get; set; }
/// <summary>department_id from employee_rfid_tag at scan time.</summary>
public int DepartmentId { get; set; }
/// <summary>date_time_created from employee_rfid_tag (stored as DateTime; naming kept for clarity).</summary>
public DateTime? TagCreatedAtUtc { get; set; }
/// <summary>created_by from employee_rfid_tag.</summary>
public string TagCreatedBy { get; set; } = string.Empty;
/// <summary>Full name (first + middle) from HRMS at scan time, when lookup succeeded.</summary>
public string EmployeeName { get; set; } = string.Empty;
/// <summary>HRMS department title at scan time.</summary>

View File

@ -2,37 +2,33 @@
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<ApplicationRevision>13</ApplicationRevision>
<ApplicationRevision>0</ApplicationRevision>
<ApplicationVersion>1.0.0.*</ApplicationVersion>
<BootstrapperEnabled>True</BootstrapperEnabled>
<Configuration>Release</Configuration>
<CreateDesktopShortcut>True</CreateDesktopShortcut>
<CreateWebPageOnPublish>False</CreateWebPageOnPublish>
<GenerateManifests>true</GenerateManifests>
<Install>True</Install>
<InstallFrom>Unc</InstallFrom>
<InstallUrl>\\FileServer\Edata\Deployments-by-DotNet-Team\UtopiaCanteenSystem\</InstallUrl>
<InstallFrom>Disk</InstallFrom>
<IsRevisionIncremented>True</IsRevisionIncremented>
<IsWebBootstrapper>False</IsWebBootstrapper>
<MapFileExtensions>True</MapFileExtensions>
<OpenBrowserOnPublish>False</OpenBrowserOnPublish>
<Platform>Any CPU</Platform>
<PublishDir>bin\Release\net8.0-windows\win-x86\app.publish\</PublishDir>
<PublishUrl>\\FileServer\Edata\Deployments-by-DotNet-Team\UtopiaCanteenSystem\</PublishUrl>
<PublishUrl>D:\Publish\UtopiaCanteenSystem\</PublishUrl>
<PublishProtocol>ClickOnce</PublishProtocol>
<PublishReadyToRun>False</PublishReadyToRun>
<PublishSingleFile>True</PublishSingleFile>
<PublishSingleFile>False</PublishSingleFile>
<RuntimeIdentifier>win-x86</RuntimeIdentifier>
<SelfContained>True</SelfContained>
<SignatureAlgorithm>(none)</SignatureAlgorithm>
<SignManifests>False</SignManifests>
<SkipPublishVerification>false</SkipPublishVerification>
<TargetFramework>net8.0-windows</TargetFramework>
<UpdateEnabled>True</UpdateEnabled>
<UpdateEnabled>False</UpdateEnabled>
<UpdateMode>Foreground</UpdateMode>
<UpdateRequired>False</UpdateRequired>
<UpdateUrl>\\FileServer\Edata\Deployments-by-DotNet-Team\UtopiaCanteenSystem\</UpdateUrl>
<WebPageFileName>Publish.html</WebPageFileName>
<History>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||;</History>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>D:\</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<_TargetId>Folder</_TargetId>
<TargetFramework>net8.0-windows</TargetFramework>
<RuntimeIdentifier>win-x86</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishReadyToRun>false</PublishReadyToRun>
</PropertyGroup>
</Project>

View File

@ -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)
{

View File

@ -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;
}
}
}

View File

@ -23,14 +23,31 @@ public class MenuLookupService : IMenuLookupService
if (string.IsNullOrWhiteSpace(connectionString))
return Array.Empty<HrmsMenuItem>();
//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
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
ORDER BY mi.item_type, mi.item_name";
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),
});
}

View File

@ -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;

View File

@ -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();

View File

@ -22,10 +22,17 @@ 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
{
// 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 = @"
// 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))";
await using var cmd = new MySqlCommand(insertSql, mysqlConn, transaction);
// 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<int>(capacity: toSync.Count);
foreach (var record in toSync)
{
cmd.Parameters.Clear();
try
{
// 1) Insert into lunch_order_transactions (production MySqlConnectionString)
await using (var prodConn = new MySqlConnection(productionConnStr))
{
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))
{
await hrmsConn.OpenAsync(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);
}
}
syncedIds.Add(record.Id);
}
catch (Exception ex)
{
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 => ids.Contains(r.Id))
.Where(r => syncedIds.Contains(r.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var record in records)
record.IsSynced = true;
foreach (var r in records)
r.IsSynced = true;
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
// Day-end cleanup: delete only synced rows from previous days.
try
{
await CleanupOldSyncedRowsAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
// Rollback MySQL transaction on error
System.Diagnostics.Debug.WriteLine($"Sync cleanup failed: {ex.Message}");
}
}
private static int SiteIdStringToInt(string siteId)
{
try
{
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
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
{
// 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
}
}
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
return 0;
}
}

View File

@ -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()
};

View File

@ -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,10 +284,14 @@ 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)
.Where(i => string.Equals(i.MealName, mealLabel, StringComparison.OrdinalIgnoreCase))
.ToList();
var matchingItemNames = matchingItems

View File

@ -367,7 +367,7 @@
Binding="{Binding LocationSiteId}"
Width="*"/>
<DataGridTextColumn Header="Session"
Binding="{Binding MealSession, Converter={StaticResource MealSessionConverter}}"
Binding="{Binding MealName}"
Width="*"/>
<DataGridTextColumn Header="Start"
Binding="{Binding StartTime}"
@ -473,13 +473,8 @@
FontSize="12"
Foreground="{StaticResource MutedText}"
Margin="0,0,0,4"/>
<ComboBox SelectedIndex="{Binding MealSessionIndex, Mode=TwoWay}"
Style="{StaticResource ModernComboBox}">
<ComboBoxItem Content="Breakfast"/>
<ComboBoxItem Content="Lunch"/>
<ComboBoxItem Content="Tea"/>
<ComboBoxItem Content="Dinner"/>
</ComboBox>
<TextBox Text="{Binding MealName, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource ModernTextBox}"/>
</StackPanel>
</StackPanel>