Add offline HRMS cache schema and sync services

Adds local SQLite cache tables for employee RFID tags, meal schedules, lunch menu weeks, lunch menu items, and menu items.

Adds sync services that pull HRMS RFID/menu data into the local cache and track the last cache sync time.
feature/centralized-offline-canteen
SYED MUSTUFA AHMED NAQVI 2026-05-21 09:26:43 +05:00
parent b3087f08a2
commit 1d427b6e18
12 changed files with 1095 additions and 0 deletions

View File

@ -18,6 +18,11 @@ public class AppDbContext : DbContext
public DbSet<Labour> Labour { get; set; } public DbSet<Labour> Labour { get; set; }
public DbSet<ScanRecord> LunchOrderTransactions { get; set; } public DbSet<ScanRecord> LunchOrderTransactions { get; set; }
public DbSet<AdminLoginRecord> AdminLoginRecords { get; set; } public DbSet<AdminLoginRecord> AdminLoginRecords { get; set; }
public DbSet<EmployeeRfidTagCache> EmployeeRfidTagCache { get; set; }
public DbSet<MealScheduleCache> MealScheduleCache { get; set; }
public DbSet<LunchMenuWeekCache> LunchMenuWeekCache { get; set; }
public DbSet<LunchMenuItemCache> LunchMenuItemCache { get; set; }
public DbSet<MenuItemCache> MenuItemCache { get; set; }
public AppDbContext() { } public AppDbContext() { }
@ -52,6 +57,46 @@ public class AppDbContext : DbContext
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
e.HasIndex(x => x.LoginTimeUtc); e.HasIndex(x => x.LoginTimeUtc);
}); });
modelBuilder.Entity<EmployeeRfidTagCache>(e =>
{
e.ToTable("employee_rfid_tag_cache");
e.HasKey(x => x.LocalId);
e.HasIndex(x => x.HrmsId).IsUnique();
e.HasIndex(x => x.ManufacturerSerial);
});
modelBuilder.Entity<MealScheduleCache>(e =>
{
e.ToTable("meal_schedule_cache");
e.HasKey(x => x.LocalId);
e.HasIndex(x => x.HrmsId).IsUnique();
e.HasIndex(x => x.LocationSiteId);
});
modelBuilder.Entity<LunchMenuWeekCache>(e =>
{
e.ToTable("lunch_menu_week_cache");
e.HasKey(x => x.LocalId);
e.HasIndex(x => x.HrmsId).IsUnique();
e.HasIndex(x => x.LocationSiteId);
});
modelBuilder.Entity<LunchMenuItemCache>(e =>
{
e.ToTable("lunch_menu_item_cache");
e.HasKey(x => x.LocalId);
e.HasIndex(x => x.HrmsId).IsUnique();
e.HasIndex(x => x.LunchMenuWeekHrmsId);
e.HasIndex(x => x.MenuDate);
});
modelBuilder.Entity<MenuItemCache>(e =>
{
e.ToTable("menu_item_cache");
e.HasKey(x => x.LocalId);
e.HasIndex(x => x.HrmsId).IsUnique();
});
} }
/// <summary> /// <summary>
@ -63,6 +108,8 @@ public class AppDbContext : DbContext
MigrateScanRecordsToLunchOrderTransactionsIfNeeded(); MigrateScanRecordsToLunchOrderTransactionsIfNeeded();
UpgradeLunchOrderTransactionsSchemaIfNeeded(); UpgradeLunchOrderTransactionsSchemaIfNeeded();
EnsureAdminLoginTableExists(); EnsureAdminLoginTableExists();
EnsureEmployeeRfidTagCacheTableExists();
EnsureMealMenuCacheTablesExist();
RemoveLegacyMealRelatedSchemaIfNeeded(); RemoveLegacyMealRelatedSchemaIfNeeded();
} }
@ -401,6 +448,152 @@ public class AppDbContext : DbContext
} }
} }
/// <summary>
/// Lightweight creation of employee_rfid_tag_cache for existing databases (no EF migrations).
/// </summary>
private void EnsureEmployeeRfidTagCacheTableExists()
{
try
{
var conn = Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText =
"CREATE TABLE IF NOT EXISTS employee_rfid_tag_cache (" +
"LocalId INTEGER PRIMARY KEY AUTOINCREMENT, " +
"HrmsId INTEGER NOT NULL, " +
"ManufacturerSerial TEXT NOT NULL DEFAULT '', " +
"UindSerial TEXT NOT NULL DEFAULT '', " +
"Secret TEXT NOT NULL DEFAULT '', " +
"ParentDocumentType TEXT NOT NULL DEFAULT '', " +
"ParentDocumentId TEXT NOT NULL DEFAULT '', " +
"DateTimeCreated TEXT, " +
"CreatedBy TEXT NOT NULL DEFAULT '', " +
"GradeId INTEGER, " +
"GradeType TEXT NOT NULL DEFAULT '', " +
"ShiftId INTEGER, " +
"LocationSiteId TEXT NOT NULL DEFAULT '', " +
"RfidLocationSiteId TEXT NOT NULL DEFAULT '', " +
"FunctionId INTEGER NOT NULL DEFAULT 0, " +
"DepartmentId INTEGER NOT NULL DEFAULT 0, " +
"ReportingManagerIds TEXT NOT NULL DEFAULT '', " +
"EmployeeSerialNumber TEXT NOT NULL DEFAULT '', " +
"EmployeeConcatenatedName TEXT NOT NULL DEFAULT '', " +
"DepartmentTitle TEXT NOT NULL DEFAULT '', " +
"DepartmentType TEXT NOT NULL DEFAULT '', " +
"LastSyncedAtUtc TEXT NOT NULL)";
cmd.ExecuteNonQuery();
using var idxHrms = conn.CreateCommand();
idxHrms.CommandText =
"CREATE UNIQUE INDEX IF NOT EXISTS IX_employee_rfid_tag_cache_HrmsId ON employee_rfid_tag_cache(HrmsId)";
idxHrms.ExecuteNonQuery();
using var idxSerial = conn.CreateCommand();
idxSerial.CommandText =
"CREATE INDEX IF NOT EXISTS IX_employee_rfid_tag_cache_ManufacturerSerial ON employee_rfid_tag_cache(ManufacturerSerial)";
idxSerial.ExecuteNonQuery();
}
catch
{
// Ignore; table may already exist or DB may be read-only.
}
}
private void EnsureMealMenuCacheTablesExist()
{
try
{
var conn = Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
"CREATE TABLE IF NOT EXISTS meal_schedule_cache (" +
"LocalId INTEGER PRIMARY KEY AUTOINCREMENT, " +
"HrmsId INTEGER NOT NULL, " +
"MealName TEXT NOT NULL DEFAULT '', " +
"StartTime TEXT NOT NULL DEFAULT '', " +
"EndTime TEXT NOT NULL DEFAULT '', " +
"CreatedAt TEXT, " +
"UpdatedAt TEXT, " +
"LocationSiteId INTEGER NOT NULL DEFAULT 0, " +
"LastSyncedAtUtc TEXT NOT NULL)";
cmd.ExecuteNonQuery();
}
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
"CREATE TABLE IF NOT EXISTS lunch_menu_week_cache (" +
"LocalId INTEGER PRIMARY KEY AUTOINCREMENT, " +
"HrmsId INTEGER NOT NULL, " +
"WeekStartDate TEXT, " +
"WeekEndDate TEXT, " +
"CreatedBy TEXT NOT NULL DEFAULT '', " +
"CreatedAt TEXT, " +
"LocationSiteId INTEGER NOT NULL DEFAULT 0, " +
"LastSyncedAtUtc TEXT NOT NULL)";
cmd.ExecuteNonQuery();
}
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
"CREATE TABLE IF NOT EXISTS lunch_menu_item_cache (" +
"LocalId INTEGER PRIMARY KEY AUTOINCREMENT, " +
"HrmsId INTEGER NOT NULL, " +
"LunchMenuWeekHrmsId INTEGER NOT NULL DEFAULT 0, " +
"DayOfWeek TEXT NOT NULL DEFAULT '', " +
"MealName TEXT NOT NULL DEFAULT '', " +
"MenuItemHrmsId INTEGER NOT NULL DEFAULT 0, " +
"CreatedAt TEXT, " +
"MenuDate TEXT, " +
"LastSyncedAtUtc TEXT NOT NULL)";
cmd.ExecuteNonQuery();
}
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
"CREATE TABLE IF NOT EXISTS menu_item_cache (" +
"LocalId INTEGER PRIMARY KEY AUTOINCREMENT, " +
"HrmsId INTEGER NOT NULL, " +
"ItemName TEXT NOT NULL DEFAULT '', " +
"ItemType TEXT NOT NULL DEFAULT '', " +
"Price REAL NOT NULL DEFAULT 0, " +
"ItemFor TEXT NOT NULL DEFAULT '', " +
"LocationSiteId INTEGER NOT NULL DEFAULT 0, " +
"LastSyncedAtUtc TEXT NOT NULL)";
cmd.ExecuteNonQuery();
}
CreateIndexIfNotExists(conn, "IX_meal_schedule_cache_HrmsId", "meal_schedule_cache", "HrmsId", unique: true);
CreateIndexIfNotExists(conn, "IX_meal_schedule_cache_LocationSiteId", "meal_schedule_cache", "LocationSiteId", unique: false);
CreateIndexIfNotExists(conn, "IX_lunch_menu_week_cache_HrmsId", "lunch_menu_week_cache", "HrmsId", unique: true);
CreateIndexIfNotExists(conn, "IX_lunch_menu_week_cache_LocationSiteId", "lunch_menu_week_cache", "LocationSiteId", unique: false);
CreateIndexIfNotExists(conn, "IX_lunch_menu_item_cache_HrmsId", "lunch_menu_item_cache", "HrmsId", unique: true);
CreateIndexIfNotExists(conn, "IX_lunch_menu_item_cache_WeekId", "lunch_menu_item_cache", "LunchMenuWeekHrmsId", unique: false);
CreateIndexIfNotExists(conn, "IX_menu_item_cache_HrmsId", "menu_item_cache", "HrmsId", unique: true);
}
catch
{
// Ignore; table may already exist or DB may be read-only.
}
}
private static void CreateIndexIfNotExists(DbConnection conn, string indexName, string tableName, string columnName, bool unique)
{
using var cmd = conn.CreateCommand();
var uniqueSql = unique ? "UNIQUE " : string.Empty;
cmd.CommandText = $"CREATE {uniqueSql}INDEX IF NOT EXISTS {indexName} ON {tableName}({columnName})";
cmd.ExecuteNonQuery();
}
/// <summary> /// <summary>
/// Removes legacy meal-related tables and columns that should never be present in production. /// Removes legacy meal-related tables and columns that should never be present in production.
/// This is a best-effort cleanup that runs on every startup for existing SQLite databases. /// This is a best-effort cleanup that runs on every startup for existing SQLite databases.

View File

@ -0,0 +1,43 @@
namespace UtopiaCanteenSystem.Models;
/// <summary>
/// Local SQLite cache of production <c>hrms.employee_rfid_tag</c>, plus denormalized employee/department
/// fields populated during sync for offline RFID lookup.
/// </summary>
public class EmployeeRfidTagCache
{
public int LocalId { get; set; }
/// <summary>Production <c>employee_rfid_tag.id</c>.</summary>
public long HrmsId { get; set; }
public string ManufacturerSerial { get; set; } = string.Empty;
public string UindSerial { get; set; } = string.Empty;
public string Secret { get; set; } = string.Empty;
public string ParentDocumentType { get; set; } = string.Empty;
public string ParentDocumentId { get; set; } = string.Empty;
public DateTime? DateTimeCreated { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public int? GradeId { get; set; }
public string GradeType { get; set; } = string.Empty;
public int? ShiftId { get; set; }
public string LocationSiteId { get; set; } = string.Empty;
public string RfidLocationSiteId { get; set; } = string.Empty;
public int FunctionId { get; set; }
public int DepartmentId { get; set; }
public string ReportingManagerIds { get; set; } = string.Empty;
/// <summary><c>employee.serial_number</c> from sync join.</summary>
public string EmployeeSerialNumber { get; set; } = string.Empty;
/// <summary><c>employee.concatenated_name</c> from sync join.</summary>
public string EmployeeConcatenatedName { get; set; } = string.Empty;
/// <summary><c>department.title</c> from sync join.</summary>
public string DepartmentTitle { get; set; } = string.Empty;
/// <summary><c>department.department_type</c> from sync join.</summary>
public string DepartmentType { get; set; } = string.Empty;
public DateTime LastSyncedAtUtc { get; set; }
}

View File

@ -0,0 +1,15 @@
namespace UtopiaCanteenSystem.Models;
/// <summary>Local SQLite cache of production <c>hrms.lunch_menu_item</c>.</summary>
public class LunchMenuItemCache
{
public int LocalId { get; set; }
public long HrmsId { get; set; }
public long LunchMenuWeekHrmsId { get; set; }
public string DayOfWeek { get; set; } = string.Empty;
public string MealName { get; set; } = string.Empty;
public long MenuItemHrmsId { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? MenuDate { get; set; }
public DateTime LastSyncedAtUtc { get; set; }
}

View File

@ -0,0 +1,14 @@
namespace UtopiaCanteenSystem.Models;
/// <summary>Local SQLite cache of production <c>hrms.lunch_menu_week</c>.</summary>
public class LunchMenuWeekCache
{
public int LocalId { get; set; }
public long HrmsId { get; set; }
public DateTime? WeekStartDate { get; set; }
public DateTime? WeekEndDate { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public DateTime? CreatedAt { get; set; }
public int LocationSiteId { get; set; }
public DateTime LastSyncedAtUtc { get; set; }
}

View File

@ -0,0 +1,15 @@
namespace UtopiaCanteenSystem.Models;
/// <summary>Local SQLite cache of production <c>hrms.meal_schedule</c>.</summary>
public class MealScheduleCache
{
public int LocalId { get; set; }
public long HrmsId { get; set; }
public string MealName { get; set; } = string.Empty;
public string StartTime { get; set; } = string.Empty;
public string EndTime { get; set; } = string.Empty;
public DateTime? CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int LocationSiteId { get; set; }
public DateTime LastSyncedAtUtc { get; set; }
}

14
Models/MenuItemCache.cs Normal file
View File

@ -0,0 +1,14 @@
namespace UtopiaCanteenSystem.Models;
/// <summary>Local SQLite cache of production <c>hrms.menu_item</c>.</summary>
public class MenuItemCache
{
public int LocalId { get; set; }
public long HrmsId { get; set; }
public string ItemName { get; set; } = string.Empty;
public string ItemType { get; set; } = string.Empty;
public decimal Price { get; set; }
public string ItemFor { get; set; } = string.Empty;
public int LocationSiteId { get; set; }
public DateTime LastSyncedAtUtc { get; set; }
}

View File

@ -0,0 +1,256 @@
using Microsoft.EntityFrameworkCore;
using MySqlConnector;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Pulls <c>hrms.employee_rfid_tag</c> from production HRMS and upserts into local SQLite.
/// HRMS is only accessed here—not during RFID scan.
/// </summary>
public class EmployeeRfidTagSyncService : IEmployeeRfidTagSyncService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly IConfigService _configService;
public EmployeeRfidTagSyncService(IDbContextFactory<AppDbContext> dbFactory, IConfigService configService)
{
_dbFactory = dbFactory;
_configService = configService;
}
public async Task<EmployeeRfidTagSyncResult> SyncAllAsync(CancellationToken cancellationToken = default)
{
var connectionString = _configService.GetHrmsLookupConnectionString();
if (string.IsNullOrWhiteSpace(connectionString))
{
return new EmployeeRfidTagSyncResult
{
Success = false,
ErrorMessage = "HRMS lookup connection string is not configured."
};
}
List<HrmsRfidTagRow> rows;
try
{
rows = await FetchAllFromHrmsAsync(connectionString, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "EmployeeRfidTagSyncService.SyncAllAsync");
return new EmployeeRfidTagSyncResult
{
Success = false,
ErrorMessage = ex.Message
};
}
var syncedAt = DateTime.UtcNow;
var upserted = 0;
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
foreach (var row in rows)
{
if (row.HrmsId <= 0)
continue;
var manufacturerSerial = row.ManufacturerSerial?.Trim() ?? string.Empty;
EmployeeRfidTagCache? existing = await db.EmployeeRfidTagCache
.FirstOrDefaultAsync(x => x.HrmsId == row.HrmsId, cancellationToken)
.ConfigureAwait(false);
if (existing == null && !string.IsNullOrEmpty(manufacturerSerial))
{
existing = await db.EmployeeRfidTagCache
.FirstOrDefaultAsync(x => x.ManufacturerSerial == manufacturerSerial, cancellationToken)
.ConfigureAwait(false);
}
if (existing == null)
{
db.EmployeeRfidTagCache.Add(MapToEntity(row, syncedAt));
}
else
{
ApplyRow(existing, row, syncedAt);
}
upserted++;
}
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_configService.SetLastEmployeeRfidCacheSyncUtc(syncedAt);
return new EmployeeRfidTagSyncResult
{
Success = true,
UpsertedCount = upserted
};
}
private static async Task<List<HrmsRfidTagRow>> FetchAllFromHrmsAsync(
string connectionString,
CancellationToken cancellationToken)
{
const string sql = @"
SELECT
r.id,
r.manufacturer_serial,
r.uind_serial,
r.secret,
r.parent_document_type,
r.parent_document_id,
r.date_time_created,
r.created_by,
r.grade_id,
r.grade_type,
r.shift_id,
r.location_site_id,
r.rfid_location_site_id,
r.function_id,
r.department_id,
r.reporting_manager_ids,
e.serial_number AS employee_serial_number,
e.concatenated_name AS employee_concatenated_name,
d.title AS department_title,
d.department_type AS department_type
FROM employee_rfid_tag r
LEFT JOIN employee e ON e.id = r.parent_document_id
AND r.parent_document_type = 'Employee'
LEFT JOIN department d ON d.id = e.department_id";
var rows = new List<HrmsRfidTagRow>();
await using var conn = new MySqlConnection(connectionString);
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new MySqlCommand(sql, conn);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
rows.Add(new HrmsRfidTagRow
{
HrmsId = GetInt64(reader, 0),
ManufacturerSerial = GetString(reader, 1),
UindSerial = GetString(reader, 2),
Secret = GetString(reader, 3),
ParentDocumentType = GetString(reader, 4),
ParentDocumentId = GetString(reader, 5),
DateTimeCreated = GetDateTimeNullable(reader, 6),
CreatedBy = GetString(reader, 7),
GradeId = GetIntNullable(reader, 8),
GradeType = GetString(reader, 9),
ShiftId = GetIntNullable(reader, 10),
LocationSiteId = GetString(reader, 11),
RfidLocationSiteId = GetString(reader, 12),
FunctionId = GetInt(reader, 13),
DepartmentId = GetInt(reader, 14),
ReportingManagerIds = GetString(reader, 15),
EmployeeSerialNumber = GetString(reader, 16),
EmployeeConcatenatedName = GetString(reader, 17),
DepartmentTitle = GetString(reader, 18),
DepartmentType = GetString(reader, 19)
});
}
return rows;
}
private static EmployeeRfidTagCache MapToEntity(HrmsRfidTagRow row, DateTime syncedAtUtc)
{
var entity = new EmployeeRfidTagCache();
ApplyRow(entity, row, syncedAtUtc);
return entity;
}
private static void ApplyRow(EmployeeRfidTagCache entity, HrmsRfidTagRow row, DateTime syncedAtUtc)
{
entity.HrmsId = row.HrmsId;
entity.ManufacturerSerial = row.ManufacturerSerial?.Trim() ?? string.Empty;
entity.UindSerial = row.UindSerial ?? string.Empty;
entity.Secret = row.Secret ?? string.Empty;
entity.ParentDocumentType = row.ParentDocumentType ?? string.Empty;
entity.ParentDocumentId = row.ParentDocumentId ?? string.Empty;
entity.DateTimeCreated = row.DateTimeCreated;
entity.CreatedBy = row.CreatedBy ?? string.Empty;
entity.GradeId = row.GradeId;
entity.GradeType = row.GradeType ?? string.Empty;
entity.ShiftId = row.ShiftId;
entity.LocationSiteId = row.LocationSiteId ?? string.Empty;
entity.RfidLocationSiteId = row.RfidLocationSiteId ?? string.Empty;
entity.FunctionId = row.FunctionId;
entity.DepartmentId = row.DepartmentId;
entity.ReportingManagerIds = row.ReportingManagerIds ?? string.Empty;
entity.EmployeeSerialNumber = row.EmployeeSerialNumber ?? string.Empty;
entity.EmployeeConcatenatedName = row.EmployeeConcatenatedName ?? string.Empty;
entity.DepartmentTitle = row.DepartmentTitle ?? string.Empty;
entity.DepartmentType = row.DepartmentType ?? string.Empty;
entity.LastSyncedAtUtc = syncedAtUtc;
}
private static string GetString(MySqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal)) return string.Empty;
return reader.GetValue(ordinal)?.ToString() ?? string.Empty;
}
private static int GetInt(MySqlDataReader reader, int ordinal)
{
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;
}
private static long GetInt64(MySqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal)) return 0;
var v = reader.GetValue(ordinal);
if (v is long l) return l;
if (v is int i) return i;
return long.TryParse(v?.ToString(), out var parsed) ? parsed : 0;
}
private static int? GetIntNullable(MySqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal)) return null;
return GetInt(reader, ordinal);
}
private static DateTime? GetDateTimeNullable(MySqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal)) return null;
var v = reader.GetValue(ordinal);
if (v is DateTime dt) return dt;
return DateTime.TryParse(v?.ToString(), out var parsed) ? parsed : null;
}
private sealed class HrmsRfidTagRow
{
public long HrmsId { get; set; }
public string ManufacturerSerial { get; set; } = string.Empty;
public string UindSerial { get; set; } = string.Empty;
public string Secret { get; set; } = string.Empty;
public string ParentDocumentType { get; set; } = string.Empty;
public string ParentDocumentId { get; set; } = string.Empty;
public DateTime? DateTimeCreated { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public int? GradeId { get; set; }
public string GradeType { get; set; } = string.Empty;
public int? ShiftId { get; set; }
public string LocationSiteId { get; set; } = string.Empty;
public string RfidLocationSiteId { get; set; } = string.Empty;
public int FunctionId { get; set; }
public int DepartmentId { get; set; }
public string ReportingManagerIds { get; set; } = string.Empty;
public string EmployeeSerialNumber { get; set; } = string.Empty;
public string EmployeeConcatenatedName { get; set; } = string.Empty;
public string DepartmentTitle { get; set; } = string.Empty;
public string DepartmentType { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,19 @@
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Syncs production <c>hrms.employee_rfid_tag</c> into local SQLite for offline RFID lookup.
/// </summary>
public interface IEmployeeRfidTagSyncService
{
/// <summary>
/// Fetches all RFID tags from production HRMS and upserts into <c>employee_rfid_tag_cache</c>.
/// </summary>
Task<EmployeeRfidTagSyncResult> SyncAllAsync(CancellationToken cancellationToken = default);
}
public sealed class EmployeeRfidTagSyncResult
{
public bool Success { get; init; }
public int UpsertedCount { get; init; }
public string? ErrorMessage { get; init; }
}

View File

@ -0,0 +1,19 @@
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Syncs meal schedules and lunch menu tables from production HRMS into local SQLite.
/// </summary>
public interface IMealMenuCacheSyncService
{
Task<MealMenuCacheSyncResult> SyncAllAsync(CancellationToken cancellationToken = default);
}
public sealed class MealMenuCacheSyncResult
{
public bool Success { get; init; }
public int MealScheduleCount { get; init; }
public int LunchMenuWeekCount { get; init; }
public int LunchMenuItemCount { get; init; }
public int MenuItemCount { get; init; }
public string? ErrorMessage { get; init; }
}

View File

@ -0,0 +1,17 @@
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Runs employee RFID and meal/menu offline cache sync jobs (no lunch order production sync).
/// </summary>
public interface IOfflineCacheSyncService
{
Task<OfflineCacheSyncResult> SyncEmployeeAndMenuCacheAsync(CancellationToken cancellationToken = default);
}
public sealed class OfflineCacheSyncResult
{
public bool Success { get; init; }
public string? ErrorMessage { get; init; }
public EmployeeRfidTagSyncResult? EmployeeSync { get; init; }
public MealMenuCacheSyncResult? MealMenuSync { get; init; }
}

View File

@ -0,0 +1,442 @@
using Microsoft.EntityFrameworkCore;
using MySqlConnector;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Pulls meal_schedule, lunch_menu_week, lunch_menu_item, and menu_item from HRMS into SQLite.
/// </summary>
public class MealMenuCacheSyncService : IMealMenuCacheSyncService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly IConfigService _configService;
public MealMenuCacheSyncService(IDbContextFactory<AppDbContext> dbFactory, IConfigService configService)
{
_dbFactory = dbFactory;
_configService = configService;
}
public async Task<MealMenuCacheSyncResult> SyncAllAsync(CancellationToken cancellationToken = default)
{
var connectionString = _configService.GetHrmsLookupConnectionString();
if (string.IsNullOrWhiteSpace(connectionString))
{
return new MealMenuCacheSyncResult
{
Success = false,
ErrorMessage = "HRMS lookup connection string is not configured."
};
}
try
{
var syncedAt = DateTime.UtcNow;
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var menuItems = await FetchMenuItemsAsync(connectionString, cancellationToken).ConfigureAwait(false);
var mealSchedules = await FetchMealSchedulesAsync(connectionString, cancellationToken).ConfigureAwait(false);
var lunchWeeks = await FetchLunchMenuWeeksAsync(connectionString, cancellationToken).ConfigureAwait(false);
var lunchItems = await FetchLunchMenuItemsAsync(connectionString, cancellationToken).ConfigureAwait(false);
var menuItemCount = await UpsertMenuItemsAsync(db, menuItems, syncedAt, cancellationToken).ConfigureAwait(false);
var mealScheduleCount = await UpsertMealSchedulesAsync(db, mealSchedules, syncedAt, cancellationToken).ConfigureAwait(false);
var lunchWeekCount = await UpsertLunchMenuWeeksAsync(db, lunchWeeks, syncedAt, cancellationToken).ConfigureAwait(false);
var lunchItemCount = await UpsertLunchMenuItemsAsync(db, lunchItems, syncedAt, cancellationToken).ConfigureAwait(false);
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_configService.SetLastMealMenuCacheSyncUtc(syncedAt);
return new MealMenuCacheSyncResult
{
Success = true,
MealScheduleCount = mealScheduleCount,
LunchMenuWeekCount = lunchWeekCount,
LunchMenuItemCount = lunchItemCount,
MenuItemCount = menuItemCount
};
}
catch (Exception ex)
{
Logger.Log(ex, "MealMenuCacheSyncService.SyncAllAsync");
return new MealMenuCacheSyncResult
{
Success = false,
ErrorMessage = ex.Message
};
}
}
private static async Task<int> UpsertMenuItemsAsync(
AppDbContext db,
List<MenuItemRow> rows,
DateTime syncedAt,
CancellationToken cancellationToken)
{
var count = 0;
foreach (var row in rows)
{
if (row.HrmsId <= 0)
continue;
var existing = await db.MenuItemCache
.FirstOrDefaultAsync(x => x.HrmsId == row.HrmsId, cancellationToken)
.ConfigureAwait(false);
if (existing == null)
{
db.MenuItemCache.Add(new MenuItemCache
{
HrmsId = row.HrmsId,
ItemName = row.ItemName,
ItemType = row.ItemType,
Price = row.Price,
ItemFor = row.ItemFor,
LocationSiteId = row.LocationSiteId,
LastSyncedAtUtc = syncedAt
});
}
else
{
existing.ItemName = row.ItemName;
existing.ItemType = row.ItemType;
existing.Price = row.Price;
existing.ItemFor = row.ItemFor;
existing.LocationSiteId = row.LocationSiteId;
existing.LastSyncedAtUtc = syncedAt;
}
count++;
}
return count;
}
private static async Task<int> UpsertMealSchedulesAsync(
AppDbContext db,
List<MealScheduleRow> rows,
DateTime syncedAt,
CancellationToken cancellationToken)
{
var count = 0;
foreach (var row in rows)
{
if (row.HrmsId <= 0)
continue;
var existing = await db.MealScheduleCache
.FirstOrDefaultAsync(x => x.HrmsId == row.HrmsId, cancellationToken)
.ConfigureAwait(false);
if (existing == null)
{
db.MealScheduleCache.Add(new MealScheduleCache
{
HrmsId = row.HrmsId,
MealName = row.MealName,
StartTime = row.StartTime,
EndTime = row.EndTime,
CreatedAt = row.CreatedAt,
UpdatedAt = row.UpdatedAt,
LocationSiteId = row.LocationSiteId,
LastSyncedAtUtc = syncedAt
});
}
else
{
existing.MealName = row.MealName;
existing.StartTime = row.StartTime;
existing.EndTime = row.EndTime;
existing.CreatedAt = row.CreatedAt;
existing.UpdatedAt = row.UpdatedAt;
existing.LocationSiteId = row.LocationSiteId;
existing.LastSyncedAtUtc = syncedAt;
}
count++;
}
return count;
}
private static async Task<int> UpsertLunchMenuWeeksAsync(
AppDbContext db,
List<LunchMenuWeekRow> rows,
DateTime syncedAt,
CancellationToken cancellationToken)
{
var count = 0;
foreach (var row in rows)
{
if (row.HrmsId <= 0)
continue;
var existing = await db.LunchMenuWeekCache
.FirstOrDefaultAsync(x => x.HrmsId == row.HrmsId, cancellationToken)
.ConfigureAwait(false);
if (existing == null)
{
db.LunchMenuWeekCache.Add(new LunchMenuWeekCache
{
HrmsId = row.HrmsId,
WeekStartDate = row.WeekStartDate,
WeekEndDate = row.WeekEndDate,
CreatedBy = row.CreatedBy,
CreatedAt = row.CreatedAt,
LocationSiteId = row.LocationSiteId,
LastSyncedAtUtc = syncedAt
});
}
else
{
existing.WeekStartDate = row.WeekStartDate;
existing.WeekEndDate = row.WeekEndDate;
existing.CreatedBy = row.CreatedBy;
existing.CreatedAt = row.CreatedAt;
existing.LocationSiteId = row.LocationSiteId;
existing.LastSyncedAtUtc = syncedAt;
}
count++;
}
return count;
}
private static async Task<int> UpsertLunchMenuItemsAsync(
AppDbContext db,
List<LunchMenuItemRow> rows,
DateTime syncedAt,
CancellationToken cancellationToken)
{
var count = 0;
foreach (var row in rows)
{
if (row.HrmsId <= 0)
continue;
var existing = await db.LunchMenuItemCache
.FirstOrDefaultAsync(x => x.HrmsId == row.HrmsId, cancellationToken)
.ConfigureAwait(false);
if (existing == null)
{
db.LunchMenuItemCache.Add(new LunchMenuItemCache
{
HrmsId = row.HrmsId,
LunchMenuWeekHrmsId = row.LunchMenuWeekHrmsId,
DayOfWeek = row.DayOfWeek,
MealName = row.MealName,
MenuItemHrmsId = row.MenuItemHrmsId,
CreatedAt = row.CreatedAt,
MenuDate = row.MenuDate,
LastSyncedAtUtc = syncedAt
});
}
else
{
existing.LunchMenuWeekHrmsId = row.LunchMenuWeekHrmsId;
existing.DayOfWeek = row.DayOfWeek;
existing.MealName = row.MealName;
existing.MenuItemHrmsId = row.MenuItemHrmsId;
existing.CreatedAt = row.CreatedAt;
existing.MenuDate = row.MenuDate;
existing.LastSyncedAtUtc = syncedAt;
}
count++;
}
return count;
}
private static async Task<List<MealScheduleRow>> FetchMealSchedulesAsync(string connectionString, CancellationToken cancellationToken)
{
const string sql = @"
SELECT id, meal_name, start_time, end_time, created_at, updated_at, location_site_id
FROM meal_schedule";
return await QueryRowsAsync(connectionString, sql, cancellationToken, reader => new MealScheduleRow
{
HrmsId = GetInt64(reader, 0),
MealName = GetString(reader, 1),
StartTime = GetTimeString(reader, 2),
EndTime = GetTimeString(reader, 3),
CreatedAt = GetDateTimeNullable(reader, 4),
UpdatedAt = GetDateTimeNullable(reader, 5),
LocationSiteId = GetInt(reader, 6)
}).ConfigureAwait(false);
}
private static async Task<List<LunchMenuWeekRow>> FetchLunchMenuWeeksAsync(string connectionString, CancellationToken cancellationToken)
{
const string sql = @"
SELECT id, week_start_date, week_end_date, created_by, created_at, location_site_id
FROM lunch_menu_week";
return await QueryRowsAsync(connectionString, sql, cancellationToken, reader => new LunchMenuWeekRow
{
HrmsId = GetInt64(reader, 0),
WeekStartDate = GetDateTimeNullable(reader, 1),
WeekEndDate = GetDateTimeNullable(reader, 2),
CreatedBy = GetString(reader, 3),
CreatedAt = GetDateTimeNullable(reader, 4),
LocationSiteId = GetInt(reader, 5)
}).ConfigureAwait(false);
}
private static async Task<List<LunchMenuItemRow>> FetchLunchMenuItemsAsync(string connectionString, CancellationToken cancellationToken)
{
const string sql = @"
SELECT id, lunch_menu_week_id, day_of_week, meal_name, menu_item_id, created_at, menu_date
FROM lunch_menu_item";
return await QueryRowsAsync(connectionString, sql, cancellationToken, reader => new LunchMenuItemRow
{
HrmsId = GetInt64(reader, 0),
LunchMenuWeekHrmsId = GetInt64(reader, 1),
DayOfWeek = GetString(reader, 2),
MealName = GetString(reader, 3),
MenuItemHrmsId = GetInt64(reader, 4),
CreatedAt = GetDateTimeNullable(reader, 5),
MenuDate = GetDateTimeNullable(reader, 6)
}).ConfigureAwait(false);
}
private static async Task<List<MenuItemRow>> FetchMenuItemsAsync(string connectionString, CancellationToken cancellationToken)
{
const string sql = @"
SELECT id, item_name, item_type, price, item_for, location_site_id
FROM menu_item";
return await QueryRowsAsync(connectionString, sql, cancellationToken, reader => new MenuItemRow
{
HrmsId = GetInt64(reader, 0),
ItemName = GetString(reader, 1),
ItemType = GetString(reader, 2),
Price = GetDecimal(reader, 3),
ItemFor = GetString(reader, 4),
LocationSiteId = GetInt(reader, 5)
}).ConfigureAwait(false);
}
private static async Task<List<T>> QueryRowsAsync<T>(
string connectionString,
string sql,
CancellationToken cancellationToken,
Func<MySqlDataReader, T> map)
{
var rows = new List<T>();
await using var conn = new MySqlConnection(connectionString);
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new MySqlCommand(sql, conn);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
rows.Add(map(reader));
return rows;
}
private static string GetString(MySqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
return string.Empty;
return reader.GetValue(ordinal)?.ToString() ?? string.Empty;
}
private static int GetInt(MySqlDataReader reader, int ordinal)
{
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;
}
private static long GetInt64(MySqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
return 0;
var v = reader.GetValue(ordinal);
if (v is long l)
return l;
if (v is int i)
return i;
return long.TryParse(v?.ToString(), out var parsed) ? parsed : 0;
}
private static decimal GetDecimal(MySqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
return 0m;
var v = reader.GetValue(ordinal);
return v is decimal d ? d : Convert.ToDecimal(v);
}
private static DateTime? GetDateTimeNullable(MySqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
return null;
var v = reader.GetValue(ordinal);
if (v is DateTime dt)
return dt;
return DateTime.TryParse(v?.ToString(), out var parsed) ? parsed : null;
}
private static string GetTimeString(MySqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
return "00:00:00";
var v = reader.GetValue(ordinal);
if (v is TimeSpan ts)
return ts.ToString(@"hh\:mm\:ss");
return v?.ToString()?.Trim() ?? "00:00:00";
}
private sealed class MealScheduleRow
{
public long HrmsId { get; set; }
public string MealName { get; set; } = string.Empty;
public string StartTime { get; set; } = string.Empty;
public string EndTime { get; set; } = string.Empty;
public DateTime? CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int LocationSiteId { get; set; }
}
private sealed class LunchMenuWeekRow
{
public long HrmsId { get; set; }
public DateTime? WeekStartDate { get; set; }
public DateTime? WeekEndDate { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public DateTime? CreatedAt { get; set; }
public int LocationSiteId { get; set; }
}
private sealed class LunchMenuItemRow
{
public long HrmsId { get; set; }
public long LunchMenuWeekHrmsId { get; set; }
public string DayOfWeek { get; set; } = string.Empty;
public string MealName { get; set; } = string.Empty;
public long MenuItemHrmsId { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? MenuDate { get; set; }
}
private sealed class MenuItemRow
{
public long HrmsId { get; set; }
public string ItemName { get; set; } = string.Empty;
public string ItemType { get; set; } = string.Empty;
public decimal Price { get; set; }
public string ItemFor { get; set; } = string.Empty;
public int LocationSiteId { get; set; }
}
}

View File

@ -0,0 +1,48 @@
namespace UtopiaCanteenSystem.Services;
public class OfflineCacheSyncService : IOfflineCacheSyncService
{
private readonly IEmployeeRfidTagSyncService _employeeRfidTagSync;
private readonly IMealMenuCacheSyncService _mealMenuCacheSync;
public OfflineCacheSyncService(
IEmployeeRfidTagSyncService employeeRfidTagSync,
IMealMenuCacheSyncService mealMenuCacheSync)
{
_employeeRfidTagSync = employeeRfidTagSync;
_mealMenuCacheSync = mealMenuCacheSync;
}
public async Task<OfflineCacheSyncResult> SyncEmployeeAndMenuCacheAsync(CancellationToken cancellationToken = default)
{
var employeeResult = await _employeeRfidTagSync.SyncAllAsync(cancellationToken).ConfigureAwait(false);
if (!employeeResult.Success)
{
return new OfflineCacheSyncResult
{
Success = false,
ErrorMessage = employeeResult.ErrorMessage ?? "Employee RFID cache sync failed.",
EmployeeSync = employeeResult
};
}
var mealMenuResult = await _mealMenuCacheSync.SyncAllAsync(cancellationToken).ConfigureAwait(false);
if (!mealMenuResult.Success)
{
return new OfflineCacheSyncResult
{
Success = false,
ErrorMessage = mealMenuResult.ErrorMessage ?? "Meal/menu cache sync failed.",
EmployeeSync = employeeResult,
MealMenuSync = mealMenuResult
};
}
return new OfflineCacheSyncResult
{
Success = true,
EmployeeSync = employeeResult,
MealMenuSync = mealMenuResult
};
}
}