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 SiteIdpull/1/head
parent
18a6b7a668
commit
a42f43b2cc
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Data;
|
using System.Data;
|
||||||
|
using System.Data.Common;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using UtopiaCanteenSystem.Models;
|
using UtopiaCanteenSystem.Models;
|
||||||
|
|
@ -163,6 +164,13 @@ public class AppDbContext : DbContext
|
||||||
cmd.ExecuteNonQuery();
|
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))
|
if (columns.Count > 0 && !columns.Contains("ParentDocumentId", StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
using var cmd = conn.CreateCommand();
|
using var cmd = conn.CreateCommand();
|
||||||
|
|
@ -177,6 +185,41 @@ public class AppDbContext : DbContext
|
||||||
cmd.ExecuteNonQuery();
|
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))
|
if (columns.Count > 0 && !columns.Contains("EmployeeName", StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
using var cmd = conn.CreateCommand();
|
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.CommandText = "ALTER TABLE lunch_order_transactions ADD COLUMN TotalPrice REAL DEFAULT 0";
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove deprecated column if it exists (older installs).
|
||||||
|
RemoveEmployeeDbIdColumnIfPresent(conn, columns);
|
||||||
}
|
}
|
||||||
catch
|
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>
|
/// <summary>
|
||||||
/// Lightweight creation of AdminLoginRecords table for existing databases.
|
/// Lightweight creation of AdminLoginRecords table for existing databases.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,18 @@ public class HrmsEmployeeInfo
|
||||||
public string ParentDocumentId { get; set; } = string.Empty;
|
public string ParentDocumentId { get; set; } = string.Empty;
|
||||||
/// <summary>Employee serial number (employee.serial_number).</summary>
|
/// <summary>Employee serial number (employee.serial_number).</summary>
|
||||||
public string EmployeeId { get; set; } = string.Empty;
|
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 FirstName { get; set; } = string.Empty;
|
||||||
public string MiddleName { 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 DepartmentTitle { get; set; } = string.Empty;
|
||||||
public string DepartmentType { 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>
|
/// <summary>Site from employee_rfid_tag.location_site_id; used for menu lookup (lunch_menu_week).</summary>
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,19 @@ namespace UtopiaCanteenSystem.Models;
|
||||||
public class HrmsMenuItem
|
public class HrmsMenuItem
|
||||||
{
|
{
|
||||||
/// <summary>menu_item.id</summary>
|
/// <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 int MenuItemId { get; set; }
|
||||||
public string ItemName { get; set; } = string.Empty;
|
public string ItemName { get; set; } = string.Empty;
|
||||||
public string ItemType { 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 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ namespace UtopiaCanteenSystem.Models;
|
||||||
public class MealSchedule
|
public class MealSchedule
|
||||||
{
|
{
|
||||||
public long Id { get; set; }
|
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>
|
/// <summary>Site identifier (matches ScanRecord.SiteId / config).</summary>
|
||||||
public string LocationSiteId { get; set; } = string.Empty;
|
public string LocationSiteId { get; set; } = string.Empty;
|
||||||
/// <summary>Session type: 0=Breakfast, 1=Lunch, 2=Tea, 3=Dinner (MealSession enum value).</summary>
|
/// <summary>Session type: 0=Breakfast, 1=Lunch, 2=Tea, 3=Dinner (MealSession enum value).</summary>
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,22 @@ public class ScanRecord
|
||||||
public string DeviceId { get; set; } = string.Empty;
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
/// <summary>IP address of the device at scan time.</summary>
|
/// <summary>IP address of the device at scan time.</summary>
|
||||||
public string IpAddress { get; set; } = string.Empty;
|
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>
|
/// <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;
|
public string ParentDocumentId { get; set; } = string.Empty;
|
||||||
/// <summary>HRMS employee ID (serial_number) at scan time, when lookup succeeded.</summary>
|
/// <summary>HRMS employee ID (serial_number) at scan time, when lookup succeeded.</summary>
|
||||||
public string EmployeeId { get; set; } = string.Empty;
|
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>
|
/// <summary>Full name (first + middle) from HRMS at scan time, when lookup succeeded.</summary>
|
||||||
public string EmployeeName { get; set; } = string.Empty;
|
public string EmployeeName { get; set; } = string.Empty;
|
||||||
/// <summary>HRMS department title at scan time.</summary>
|
/// <summary>HRMS department title at scan time.</summary>
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,33 @@
|
||||||
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
|
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
|
||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<ApplicationRevision>13</ApplicationRevision>
|
<ApplicationRevision>0</ApplicationRevision>
|
||||||
<ApplicationVersion>1.0.0.*</ApplicationVersion>
|
<ApplicationVersion>1.0.0.*</ApplicationVersion>
|
||||||
<BootstrapperEnabled>True</BootstrapperEnabled>
|
<BootstrapperEnabled>True</BootstrapperEnabled>
|
||||||
<Configuration>Release</Configuration>
|
<Configuration>Release</Configuration>
|
||||||
<CreateDesktopShortcut>True</CreateDesktopShortcut>
|
|
||||||
<CreateWebPageOnPublish>False</CreateWebPageOnPublish>
|
<CreateWebPageOnPublish>False</CreateWebPageOnPublish>
|
||||||
<GenerateManifests>true</GenerateManifests>
|
<GenerateManifests>true</GenerateManifests>
|
||||||
<Install>True</Install>
|
<Install>True</Install>
|
||||||
<InstallFrom>Unc</InstallFrom>
|
<InstallFrom>Disk</InstallFrom>
|
||||||
<InstallUrl>\\FileServer\Edata\Deployments-by-DotNet-Team\UtopiaCanteenSystem\</InstallUrl>
|
|
||||||
<IsRevisionIncremented>True</IsRevisionIncremented>
|
<IsRevisionIncremented>True</IsRevisionIncremented>
|
||||||
<IsWebBootstrapper>False</IsWebBootstrapper>
|
<IsWebBootstrapper>False</IsWebBootstrapper>
|
||||||
<MapFileExtensions>True</MapFileExtensions>
|
<MapFileExtensions>True</MapFileExtensions>
|
||||||
<OpenBrowserOnPublish>False</OpenBrowserOnPublish>
|
<OpenBrowserOnPublish>False</OpenBrowserOnPublish>
|
||||||
<Platform>Any CPU</Platform>
|
<Platform>Any CPU</Platform>
|
||||||
<PublishDir>bin\Release\net8.0-windows\win-x86\app.publish\</PublishDir>
|
<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>
|
<PublishProtocol>ClickOnce</PublishProtocol>
|
||||||
<PublishReadyToRun>False</PublishReadyToRun>
|
<PublishReadyToRun>False</PublishReadyToRun>
|
||||||
<PublishSingleFile>True</PublishSingleFile>
|
<PublishSingleFile>False</PublishSingleFile>
|
||||||
<RuntimeIdentifier>win-x86</RuntimeIdentifier>
|
<RuntimeIdentifier>win-x86</RuntimeIdentifier>
|
||||||
<SelfContained>True</SelfContained>
|
<SelfContained>True</SelfContained>
|
||||||
<SignatureAlgorithm>(none)</SignatureAlgorithm>
|
<SignatureAlgorithm>(none)</SignatureAlgorithm>
|
||||||
<SignManifests>False</SignManifests>
|
<SignManifests>False</SignManifests>
|
||||||
<SkipPublishVerification>false</SkipPublishVerification>
|
<SkipPublishVerification>false</SkipPublishVerification>
|
||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
<UpdateEnabled>True</UpdateEnabled>
|
<UpdateEnabled>False</UpdateEnabled>
|
||||||
<UpdateMode>Foreground</UpdateMode>
|
<UpdateMode>Foreground</UpdateMode>
|
||||||
<UpdateRequired>False</UpdateRequired>
|
<UpdateRequired>False</UpdateRequired>
|
||||||
<UpdateUrl>\\FileServer\Edata\Deployments-by-DotNet-Team\UtopiaCanteenSystem\</UpdateUrl>
|
|
||||||
<WebPageFileName>Publish.html</WebPageFileName>
|
<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>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -224,7 +224,17 @@ public class ConfigService : IConfigService
|
||||||
SaveConfig();
|
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)
|
public void SetHrmsLookupConnectionString(string connectionString)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,11 @@ public class EmployeeLookupService : IEmployeeLookupService
|
||||||
e.serial_number AS employee_id,
|
e.serial_number AS employee_id,
|
||||||
e.concatenated_name AS first_name,
|
e.concatenated_name AS first_name,
|
||||||
'' AS middle_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.title AS department_title,
|
||||||
d.department_type,
|
d.department_type,
|
||||||
r.location_site_id AS location_site_id
|
r.location_site_id AS location_site_id
|
||||||
|
|
@ -50,16 +54,21 @@ public class EmployeeLookupService : IEmployeeLookupService
|
||||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
var parentDocumentId = GetString(reader, 0);
|
||||||
return new HrmsEmployeeInfo
|
return new HrmsEmployeeInfo
|
||||||
{
|
{
|
||||||
ParentDocumentId = GetString(reader, 0),
|
ParentDocumentId = parentDocumentId,
|
||||||
EmployeeId = GetString(reader, 1),
|
EmployeeId = GetString(reader, 1),
|
||||||
FirstName = GetString(reader, 2),
|
FirstName = GetString(reader, 2),
|
||||||
MiddleName = GetString(reader, 3),
|
MiddleName = GetString(reader, 3),
|
||||||
DepartmentId = GetString(reader, 4),
|
UindSerial = GetString(reader, 4),
|
||||||
DepartmentTitle = GetString(reader, 5),
|
FunctionId = GetInt(reader, 5),
|
||||||
DepartmentType = GetString(reader, 6),
|
DepartmentId = GetInt(reader, 6),
|
||||||
LocationSiteId = GetString(reader, 7)
|
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);
|
var v = reader.GetValue(ordinal);
|
||||||
return v?.ToString() ?? string.Empty;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,31 @@ public class MenuLookupService : IMenuLookupService
|
||||||
if (string.IsNullOrWhiteSpace(connectionString))
|
if (string.IsNullOrWhiteSpace(connectionString))
|
||||||
return Array.Empty<HrmsMenuItem>();
|
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 = @"
|
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
|
FROM lunch_menu_week w
|
||||||
JOIN lunch_menu_item li ON li.lunch_menu_week_id = w.id
|
JOIN lunch_menu_item li ON li.lunch_menu_week_id = w.id
|
||||||
JOIN menu_item mi ON mi.id = li.menu_item_id
|
JOIN menu_item mi ON mi.id = li.menu_item_id
|
||||||
WHERE w.location_site_id = @siteId
|
WHERE w.location_site_id = @siteId
|
||||||
AND CURDATE() BETWEEN w.week_start_date AND w.week_end_date
|
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 using var conn = new MySqlConnection(connectionString);
|
||||||
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
@ -41,12 +58,24 @@ public class MenuLookupService : IMenuLookupService
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
while (await reader.ReadAsync(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
|
list.Add(new HrmsMenuItem
|
||||||
{
|
{
|
||||||
MenuItemId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0),
|
MenuItemId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0),
|
||||||
ItemName = GetString(reader, 1),
|
ItemName = GetString(reader, 1),
|
||||||
ItemType = GetString(reader, 2),
|
ItemType = GetString(reader, 2),
|
||||||
Price = GetDecimal(reader, 3)
|
Price = GetDecimal(reader, 3),
|
||||||
|
|
||||||
|
MealName = GetString(reader, 4),
|
||||||
|
MenuDate = GetString(reader, 5),
|
||||||
|
DayOfWeek = GetString(reader, 6),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ public class ProductionMealScheduleService : IMealScheduleService
|
||||||
throw new InvalidOperationException("HRMS MySQL connection string not configured.");
|
throw new InvalidOperationException("HRMS MySQL connection string not configured.");
|
||||||
|
|
||||||
var utc = DateTime.UtcNow;
|
var utc = DateTime.UtcNow;
|
||||||
var mealName = MealSessionToName(schedule.MealSession);
|
var mealName = (schedule.MealName ?? string.Empty).Trim();
|
||||||
var siteIdInt = SiteIdStringToInt(schedule.LocationSiteId);
|
var siteIdInt = SiteIdStringToInt(schedule.LocationSiteId);
|
||||||
|
|
||||||
await using var conn = new MySqlConnection(connStr);
|
await using var conn = new MySqlConnection(connStr);
|
||||||
|
|
@ -98,7 +98,7 @@ public class ProductionMealScheduleService : IMealScheduleService
|
||||||
throw new InvalidOperationException("HRMS MySQL connection string not configured.");
|
throw new InvalidOperationException("HRMS MySQL connection string not configured.");
|
||||||
|
|
||||||
var utc = DateTime.UtcNow;
|
var utc = DateTime.UtcNow;
|
||||||
var mealName = MealSessionToName(schedule.MealSession);
|
var mealName = (schedule.MealName ?? string.Empty).Trim();
|
||||||
var siteIdInt = SiteIdStringToInt(schedule.LocationSiteId);
|
var siteIdInt = SiteIdStringToInt(schedule.LocationSiteId);
|
||||||
|
|
||||||
await using var conn = new MySqlConnection(connStr);
|
await using var conn = new MySqlConnection(connStr);
|
||||||
|
|
@ -135,6 +135,7 @@ public class ProductionMealScheduleService : IMealScheduleService
|
||||||
return new MealSchedule
|
return new MealSchedule
|
||||||
{
|
{
|
||||||
Id = r.GetInt64(0),
|
Id = r.GetInt64(0),
|
||||||
|
MealName = GetString(r, 1),
|
||||||
MealSession = MealNameToSession(GetString(r, 1)),
|
MealSession = MealNameToSession(GetString(r, 1)),
|
||||||
StartTime = GetTimeString(r, 2),
|
StartTime = GetTimeString(r, 2),
|
||||||
EndTime = GetTimeString(r, 3),
|
EndTime = GetTimeString(r, 3),
|
||||||
|
|
@ -171,8 +172,15 @@ public class ProductionMealScheduleService : IMealScheduleService
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(mealName)) return 0;
|
if (string.IsNullOrWhiteSpace(mealName)) return 0;
|
||||||
var n = mealName.Trim();
|
var n = mealName.Trim();
|
||||||
if (n.Equals("Breakfast", StringComparison.OrdinalIgnoreCase) || n.Equals("BreakFast", StringComparison.OrdinalIgnoreCase)) return (int)MealSession.Breakfast;
|
// Map legacy names as well as Ramadan labels used in production (Sehri/Iftari)
|
||||||
if (n.Equals("Lunch", StringComparison.OrdinalIgnoreCase)) return (int)MealSession.Lunch;
|
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("Tea", StringComparison.OrdinalIgnoreCase)) return (int)MealSession.Tea;
|
||||||
if (n.Equals("Dinner", StringComparison.OrdinalIgnoreCase)) return (int)MealSession.Dinner;
|
if (n.Equals("Dinner", StringComparison.OrdinalIgnoreCase)) return (int)MealSession.Dinner;
|
||||||
return 0;
|
return 0;
|
||||||
|
|
|
||||||
|
|
@ -58,14 +58,35 @@ public class RfidService : IRfidService
|
||||||
if (session == MealSession.None)
|
if (session == MealSession.None)
|
||||||
return new ScanResult(false, "This scan is outside of valid meal timings.", 0);
|
return new ScanResult(false, "This scan is outside of valid meal timings.", 0);
|
||||||
|
|
||||||
|
var sessionCode = (int)session;
|
||||||
|
|
||||||
using var db = _dbFactory.CreateDbContext();
|
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 interval = _configService.GetScanInterval();
|
||||||
var timeoutSeconds = (int)Math.Max(1, interval.TotalSeconds);
|
var timeoutSeconds = (int)Math.Max(1, interval.TotalSeconds);
|
||||||
|
|
||||||
var windowStart = nowUtc.AddSeconds(-timeoutSeconds);
|
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
|
var lastInWindow = db.LunchOrderTransactions
|
||||||
.Where(r => r.CardId == cardId)
|
.Where(r => r.CardId == cardId)
|
||||||
.Where(r => r.ScanTime >= windowStart)
|
.Where(r => r.ScanTime >= windowStart)
|
||||||
|
|
@ -81,21 +102,6 @@ public class RfidService : IRfidService
|
||||||
remaining);
|
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 fullName = string.Join(" ", new[] { employee.FirstName, employee.MiddleName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
|
||||||
var siteId = !string.IsNullOrWhiteSpace(employee.LocationSiteId)
|
var siteId = !string.IsNullOrWhiteSpace(employee.LocationSiteId)
|
||||||
? employee.LocationSiteId.Trim()
|
? employee.LocationSiteId.Trim()
|
||||||
|
|
@ -108,8 +114,14 @@ public class RfidService : IRfidService
|
||||||
SiteId = siteId,
|
SiteId = siteId,
|
||||||
DeviceId = _configService.GetDeviceId(),
|
DeviceId = _configService.GetDeviceId(),
|
||||||
IpAddress = GetLocalIpAddress(),
|
IpAddress = GetLocalIpAddress(),
|
||||||
|
MealSessionCode = sessionCode,
|
||||||
ParentDocumentId = employee.ParentDocumentId ?? string.Empty,
|
ParentDocumentId = employee.ParentDocumentId ?? string.Empty,
|
||||||
EmployeeId = employee.EmployeeId ?? 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,
|
EmployeeName = string.IsNullOrEmpty(fullName) ? string.Empty : fullName,
|
||||||
Department = employee.DepartmentTitle ?? string.Empty,
|
Department = employee.DepartmentTitle ?? string.Empty,
|
||||||
DepartmentType = employee.DepartmentType ?? 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);
|
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()
|
public ScanRecord? GetLastScan()
|
||||||
{
|
{
|
||||||
using var db = _dbFactory.CreateDbContext();
|
using var db = _dbFactory.CreateDbContext();
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,17 @@ public class SyncService : ISyncService
|
||||||
|
|
||||||
public async Task SyncNowAsync(CancellationToken cancellationToken = default)
|
public async Task SyncNowAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var connectionString = _configService.GetMySqlConnectionString();
|
var productionConnStr = _configService.GetMySqlConnectionString();
|
||||||
if (string.IsNullOrWhiteSpace(connectionString))
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,85 +52,136 @@ public class SyncService : ISyncService
|
||||||
if (toSync.Count == 0)
|
if (toSync.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try
|
// Production: lunch_order_transactions (GetMySqlConnectionString)
|
||||||
{
|
const string insertTxnSql = @"
|
||||||
// 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
|
INSERT IGNORE INTO lunch_order_transactions
|
||||||
(scan_date, site_id, device_id, card_id, ip_address, received_date)
|
(scan_date, site_id, device_id, card_id, ip_address, received_date)
|
||||||
VALUES
|
VALUES
|
||||||
(@ScanTimeUtc, @SiteId, @DeviceId, @CardId, @IpAddress, UTC_TIMESTAMP(3))";
|
(@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)
|
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("@ScanTimeUtc", record.ScanTime);
|
||||||
cmd.Parameters.AddWithValue("@SiteId", record.SiteId ?? string.Empty);
|
cmd.Parameters.AddWithValue("@SiteId", record.SiteId ?? string.Empty);
|
||||||
cmd.Parameters.AddWithValue("@DeviceId", record.DeviceId ?? string.Empty);
|
cmd.Parameters.AddWithValue("@DeviceId", record.DeviceId ?? string.Empty);
|
||||||
cmd.Parameters.AddWithValue("@CardId", record.CardId ?? string.Empty);
|
cmd.Parameters.AddWithValue("@CardId", record.CardId ?? string.Empty);
|
||||||
cmd.Parameters.AddWithValue("@IpAddress", record.IpAddress ?? string.Empty);
|
cmd.Parameters.AddWithValue("@IpAddress", record.IpAddress ?? string.Empty);
|
||||||
|
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
var rowsAffected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
// Note: INSERT IGNORE returns 0 if row already exists (duplicate), 1 if inserted
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit MySQL transaction
|
// 2) Insert into lunch_order (HRMS/local – HrmsLookupConnectionString)
|
||||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
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
|
if (!long.TryParse(record.ParentDocumentId.Trim(), out var employeeIdBigint) || employeeIdBigint <= 0)
|
||||||
var ids = toSync.Select(r => r.Id).ToList();
|
throw new InvalidOperationException("Invalid ParentDocumentId for lunch_order.employee_id.");
|
||||||
using (var db = _dbFactory.CreateDbContext())
|
|
||||||
|
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
|
var records = await db.LunchOrderTransactions
|
||||||
.Where(r => ids.Contains(r.Id))
|
.Where(r => syncedIds.Contains(r.Id))
|
||||||
.ToListAsync(cancellationToken)
|
.ToListAsync(cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
foreach (var record in records)
|
foreach (var r in records)
|
||||||
record.IsSynced = true;
|
r.IsSynced = true;
|
||||||
|
|
||||||
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Day-end cleanup: delete only synced rows from previous days.
|
try
|
||||||
|
{
|
||||||
await CleanupOldSyncedRowsAsync(cancellationToken).ConfigureAwait(false);
|
await CleanupOldSyncedRowsAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Rollback MySQL transaction on error
|
System.Diagnostics.Debug.WriteLine($"Sync cleanup failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int SiteIdStringToInt(string siteId)
|
||||||
|
{
|
||||||
try
|
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
|
catch
|
||||||
{
|
{
|
||||||
// Ignore rollback errors
|
return 0;
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ public partial class MealSchedulesViewModel : ObservableObject
|
||||||
private string _locationSiteId = string.Empty;
|
private string _locationSiteId = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private int _mealSessionIndex; // 0=Breakfast, 1=Lunch, 2=Tea, 3=Dinner
|
private string _mealName = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _startTime = "06:00:00";
|
private string _startTime = "06:00:00";
|
||||||
|
|
@ -52,8 +52,6 @@ public partial class MealSchedulesViewModel : ObservableObject
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _isLoading;
|
private bool _isLoading;
|
||||||
|
|
||||||
public static readonly string[] MealSessionNames = { "Breakfast", "Lunch", "Tea", "Dinner" };
|
|
||||||
|
|
||||||
public MealSchedulesViewModel(IMealScheduleService mealScheduleService, INavigationService navigation)
|
public MealSchedulesViewModel(IMealScheduleService mealScheduleService, INavigationService navigation)
|
||||||
{
|
{
|
||||||
_mealScheduleService = mealScheduleService;
|
_mealScheduleService = mealScheduleService;
|
||||||
|
|
@ -70,7 +68,7 @@ public partial class MealSchedulesViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
LocationSiteId = value.LocationSiteId ?? string.Empty;
|
LocationSiteId = value.LocationSiteId ?? string.Empty;
|
||||||
MealSessionIndex = value.MealSession;
|
MealName = value.MealName ?? string.Empty;
|
||||||
StartTime = value.StartTime ?? "00:00:00";
|
StartTime = value.StartTime ?? "00:00:00";
|
||||||
EndTime = value.EndTime ?? "23:59:59";
|
EndTime = value.EndTime ?? "23:59:59";
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +120,7 @@ public partial class MealSchedulesViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
SelectedSchedule = null;
|
SelectedSchedule = null;
|
||||||
LocationSiteId = "02";
|
LocationSiteId = "02";
|
||||||
MealSessionIndex = 0;
|
MealName = string.Empty;
|
||||||
StartTime = "06:00:00";
|
StartTime = "06:00:00";
|
||||||
EndTime = "09:00:00";
|
EndTime = "09:00:00";
|
||||||
Message = string.Empty;
|
Message = string.Empty;
|
||||||
|
|
@ -162,8 +160,8 @@ public partial class MealSchedulesViewModel : ObservableObject
|
||||||
var dto = new MealSchedule
|
var dto = new MealSchedule
|
||||||
{
|
{
|
||||||
Id = SelectedSchedule.Id,
|
Id = SelectedSchedule.Id,
|
||||||
|
MealName = (MealName ?? string.Empty).Trim(),
|
||||||
LocationSiteId = siteId,
|
LocationSiteId = siteId,
|
||||||
MealSession = MealSessionIndex,
|
|
||||||
StartTime = StartTime.Trim(),
|
StartTime = StartTime.Trim(),
|
||||||
EndTime = EndTime.Trim()
|
EndTime = EndTime.Trim()
|
||||||
};
|
};
|
||||||
|
|
@ -174,8 +172,8 @@ public partial class MealSchedulesViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
var dto = new MealSchedule
|
var dto = new MealSchedule
|
||||||
{
|
{
|
||||||
|
MealName = (MealName ?? string.Empty).Trim(),
|
||||||
LocationSiteId = siteId,
|
LocationSiteId = siteId,
|
||||||
MealSession = MealSessionIndex,
|
|
||||||
StartTime = StartTime.Trim(),
|
StartTime = StartTime.Trim(),
|
||||||
EndTime = EndTime.Trim()
|
EndTime = EndTime.Trim()
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -258,10 +258,18 @@ public partial class ScannerDashboardViewModel : ObservableObject
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
//mealLabel = activeSession switch
|
||||||
|
//{
|
||||||
|
// MealSession.Breakfast => "Breakfast",
|
||||||
|
// MealSession.Lunch => "Lunch",
|
||||||
|
// MealSession.Tea => "Tea",
|
||||||
|
// MealSession.Dinner => "Dinner",
|
||||||
|
// _ => "No active meal session"
|
||||||
|
//};
|
||||||
mealLabel = activeSession switch
|
mealLabel = activeSession switch
|
||||||
{
|
{
|
||||||
MealSession.Breakfast => "Breakfast",
|
MealSession.Breakfast => "Sehri",
|
||||||
MealSession.Lunch => "Lunch",
|
MealSession.Lunch => "Iftari",
|
||||||
MealSession.Tea => "Tea",
|
MealSession.Tea => "Tea",
|
||||||
MealSession.Dinner => "Dinner",
|
MealSession.Dinner => "Dinner",
|
||||||
_ => "No active meal session"
|
_ => "No active meal session"
|
||||||
|
|
@ -276,10 +284,14 @@ public partial class ScannerDashboardViewModel : ObservableObject
|
||||||
!string.Equals(mealLabel, "No active meal session", StringComparison.OrdinalIgnoreCase) &&
|
!string.Equals(mealLabel, "No active meal session", StringComparison.OrdinalIgnoreCase) &&
|
||||||
items.Count > 0)
|
items.Count > 0)
|
||||||
{
|
{
|
||||||
|
//var matchingItems = items
|
||||||
|
// .Where(i =>
|
||||||
|
// !string.IsNullOrWhiteSpace(i.ItemType) &&
|
||||||
|
// i.ItemType.IndexOf(mealLabel, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
|
// .ToList();
|
||||||
|
|
||||||
var matchingItems = items
|
var matchingItems = items
|
||||||
.Where(i =>
|
.Where(i => string.Equals(i.MealName, mealLabel, StringComparison.OrdinalIgnoreCase))
|
||||||
!string.IsNullOrWhiteSpace(i.ItemType) &&
|
|
||||||
i.ItemType.IndexOf(mealLabel, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var matchingItemNames = matchingItems
|
var matchingItemNames = matchingItems
|
||||||
|
|
|
||||||
|
|
@ -367,7 +367,7 @@
|
||||||
Binding="{Binding LocationSiteId}"
|
Binding="{Binding LocationSiteId}"
|
||||||
Width="*"/>
|
Width="*"/>
|
||||||
<DataGridTextColumn Header="Session"
|
<DataGridTextColumn Header="Session"
|
||||||
Binding="{Binding MealSession, Converter={StaticResource MealSessionConverter}}"
|
Binding="{Binding MealName}"
|
||||||
Width="*"/>
|
Width="*"/>
|
||||||
<DataGridTextColumn Header="Start"
|
<DataGridTextColumn Header="Start"
|
||||||
Binding="{Binding StartTime}"
|
Binding="{Binding StartTime}"
|
||||||
|
|
@ -473,13 +473,8 @@
|
||||||
FontSize="12"
|
FontSize="12"
|
||||||
Foreground="{StaticResource MutedText}"
|
Foreground="{StaticResource MutedText}"
|
||||||
Margin="0,0,0,4"/>
|
Margin="0,0,0,4"/>
|
||||||
<ComboBox SelectedIndex="{Binding MealSessionIndex, Mode=TwoWay}"
|
<TextBox Text="{Binding MealName, UpdateSourceTrigger=PropertyChanged}"
|
||||||
Style="{StaticResource ModernComboBox}">
|
Style="{StaticResource ModernTextBox}"/>
|
||||||
<ComboBoxItem Content="Breakfast"/>
|
|
||||||
<ComboBoxItem Content="Lunch"/>
|
|
||||||
<ComboBoxItem Content="Tea"/>
|
|
||||||
<ComboBoxItem Content="Dinner"/>
|
|
||||||
</ComboBox>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue