Compare commits

...

30 Commits

Author SHA1 Message Date
SYED MUSTUFA AHMED NAQVI 2792a02460 Add dedicated self-contained publish helpers
Add backend and client self-contained publish scripts that clean the public output folders and publish Release win-x64 builds with the .NET runtime included for easier deployment.
2026-07-13 14:04:16 +05:00
SYED MUSTUFA AHMED NAQVI 688ef2afd3 Make publish scripts self-contained by default
Update backend and client publish scripts to produce win-x64 self-contained builds unless framework-dependent publishing is explicitly requested. Add runtime selection, clearer publish output, and deployment notes for target machines.
2026-07-13 14:03:12 +05:00
SYED MUSTUFA AHMED NAQVI 95c8ac7f63 Normalize Backend Scan Device Context
Ensures backend scan requests use a resolved Device ID and logs backend startup Device ID details.
2026-06-02 14:35:10 +05:00
SYED MUSTUFA AHMED NAQVI 11ab230d87 Resolve Device ID Automatically
Uses the Windows machine name as the default Device ID, saves it when missing, and logs the resolved value on startup.
2026-06-02 14:33:41 +05:00
SYED MUSTUFA AHMED NAQVI 3e91cfaba8 Redesign Client Settings Into Tabs
Reorganizes the settings screen into General, Mode & Connectivity, and Data Sync tabs while keeping the shared bottom action bar.
2026-06-02 14:32:01 +05:00
SYED MUSTUFA AHMED NAQVI ca7e388989 Move Backend Storage to Interactive User Profile
Resolves backend SQLite/config storage from the logged-on Windows user instead of LOCAL SYSTEM and migrates existing systemprofile backend files when needed.
2026-06-02 14:30:45 +05:00
SYED MUSTUFA AHMED NAQVI c659bbaa86 Log meal schedule loading and add clean helper
Logs meal schedule load failures and adds a build cleanup helper for locked build artifacts.
2026-06-01 15:24:33 +05:00
SYED MUSTUFA AHMED NAQVI 7b2b9b353e Include shared logging sources in projects & Log admin login and scanner outcomes
Links logging/shared sync result files into the client and adds backend logging abstraction dependency.

Adds client-side logs for admin login success/failure and scan processing results.
2026-06-01 15:22:24 +05:00
SYED MUSTUFA AHMED NAQVI e95614f0fb Add client log startup and settings UI acces
Initializes client logging on app startup and adds an Open Logs Folder button to settings.
2026-06-01 15:19:40 +05:00
SYED MUSTUFA AHMED NAQVI 55e75b7347 Log employee and Trace offline sync and navigation events
Adds cache sync start, skip, success, and failure logs for employee RFID and meal/menu refreshes.

Logs full offline cache sync flow and meal schedule screen navigation.
2026-06-01 15:18:01 +05:00
SYED MUSTUFA AHMED NAQVI 3df4bc2672 Return order sync & Update sync contract and client API logging
Changes order sync to return pending, posted, duplicate, failed, and skipped counts.

Updates the sync interface for result reporting and logs client API health, scan, and manual sync calls.
2026-06-01 15:14:26 +05:00
SYED MUSTUFA AHMED NAQVI 1237878db2 Add masked backend API request logging
Logs backend API activity while masking card IDs and connection-style sensitive values.

Configures backend file logging and records startup, timer, cache sync, order sync, and shutdown events.
2026-06-01 15:12:31 +05:00
SYED MUSTUFA AHMED NAQVI 4d8ee06b04 Resolve service logs under interactive user profile
Resolves %LocalAppData% from the logged-on console user and separates client/backend log folders.
2026-06-01 15:09:47 +05:00
SYED MUSTUFA AHMED NAQVI 42cd473e73 Route legacy logging through file logger provider
Connects existing exception logging to the new file logger and adds an ILoggerProvider bridge for Microsoft logging.
2026-06-01 15:08:29 +05:00
SYED MUSTUFA AHMED NAQVI 2ce78b6c65 Add daily file logger target selection
Adds backend/client daily log writing with separate log prefixes and target-aware log directories.
2026-06-01 15:07:19 +05:00
SYED MUSTUFA AHMED NAQVI 3bb82ff968 Add solution, project wiring, deployment docs, and scripts
Adds the multi-project solution structure, updates the main project wiring, and includes publish/install scripts plus deployment documentation for backend and client rollout.
2026-05-25 16:30:50 +05:00
SYED MUSTUFA AHMED NAQVI e3effcb416 Harden sync, cache, dashboard, and schedule flows
Updates cache sync, production order sync, meal schedule view logic, and scanner dashboard behavior to support the new backend/client architecture.
2026-05-25 16:29:42 +05:00
SYED MUSTUFA AHMED NAQVI e85d465bf1 Add standalone scanner client application
Adds a separate WPF scanner client app with its own startup, project file, client settings view model, and client settings UI.
2026-05-25 16:27:19 +05:00
SYED MUSTUFA AHMED NAQVI eff36f0e9f Add backend Windows service project
Adds a Windows worker service host for running the canteen backend independently, including backend configuration and worker startup logic.
2026-05-25 16:25:35 +05:00
SYED MUSTUFA AHMED NAQVI e59a3f8af6 Update settings screen for backend operations
Updates settings to work through the backend API for cache sync, order posting, backend health checks, backend URL display, and server/client mode configuration.
2026-05-25 16:23:45 +05:00
SYED MUSTUFA AHMED NAQVI 659c162c93 Persist admin login defaults consistently
Adds shared admin login post-processing to save the admin employee id and site defaults, and updates login flows to use the helper.
2026-05-25 16:22:35 +05:00
SYED MUSTUFA AHMED NAQVI 8123d67121 Wire WPF app for server and client modes
Updates application startup to compose server-mode backend services or client-mode API services, and adds empty/no-op services for frontend-only client behavior.
2026-05-25 16:21:39 +05:00
SYED MUSTUFA AHMED NAQVI bf1f8accd1 Split server and client configuration handling
Adds client-safe configuration storage, backend base URL support, shared connection string fallback logic, and updated config path handling for separate backend/client deployments.
2026-05-23 12:55:06 +05:00
SYED MUSTUFA AHMED NAQVI 891ddc9175 Add backend API client and remove old client RFID service
Adds a reusable backend API client for scanner and settings operations, replacing the older ClientRfidService with a broader HTTP client abstraction.
2026-05-23 12:51:45 +05:00
SYED MUSTUFA AHMED NAQVI 8bc0e215f7 Add central canteen backend host
Replaces the RFID-only local API host with a broader canteen backend host and adds DTO mapping for scan, stats, cache, sync, employee, and schedule API responses.
2026-05-23 12:50:27 +05:00
SYED MUSTUFA AHMED NAQVI 280811eda4 Add shared canteen API contracts
Adds shared DTOs for backend/client communication, including scan requests, scan responses, health checks, stats, cache status, manual sync responses, employee data, meal schedules, and recent scan records.
2026-05-23 12:48:43 +05:00
SYED MUSTUFA AHMED NAQVI 2abd38cfcc Wire server/client mode and cache sync settings
Adds Server and Client application modes, central server URL settings, and local API listen URL settings.

Wires startup so server mode hosts the RFID API and cache sync timers, while client mode sends scans to the central server. Updates settings UI to save mode and manually sync cache data.
2026-05-21 09:33:17 +05:00
SYED MUSTUFA AHMED NAQVI b2c99017c9 Add central RFID API and client scanner service
Adds a local HTTP API for the central canteen server to accept RFID scans and expose scan dashboard data.

Adds a client RFID service so scanner PCs can forward scans to the central server with device, site, and IP context.
2026-05-21 09:32:33 +05:00
SYED MUSTUFA AHMED NAQVI 37f52bc3c6 Use offline cache for RFID employee, session, and menu lookup
Switches RFID employee lookup, meal session resolution, and menu lookup from live HRMS queries to the local SQLite cache.

Adds cache availability checks and shared site ID normalization for scan validation.
2026-05-21 09:31:10 +05:00
SYED MUSTUFA AHMED NAQVI 1d427b6e18 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.
2026-05-21 09:26:43 +05:00
90 changed files with 6355 additions and 731 deletions

149
Api/ApiDtoMapper.cs Normal file
View File

@ -0,0 +1,149 @@
using UtopiaCanteen.Shared;
using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services;
namespace UtopiaCanteenSystem.Api;
public static class ApiDtoMapper
{
public static EmployeeDto? ToEmployeeDto(HrmsEmployeeInfo? e)
{
if (e == null) return null;
return new EmployeeDto
{
ParentDocumentId = e.ParentDocumentId,
EmployeeId = e.EmployeeId,
UindSerial = e.UindSerial,
FunctionId = e.FunctionId,
DepartmentId = e.DepartmentId,
TagCreatedAtUtc = e.TagCreatedAtUtc,
TagCreatedBy = e.TagCreatedBy,
FirstName = e.FirstName,
MiddleName = e.MiddleName,
DepartmentTitle = e.DepartmentTitle,
DepartmentType = e.DepartmentType,
LocationSiteId = e.LocationSiteId,
GradeType = e.GradeType
};
}
public static HrmsEmployeeInfo ToModel(EmployeeDto e) => new()
{
ParentDocumentId = e.ParentDocumentId ?? string.Empty,
EmployeeId = e.EmployeeId ?? string.Empty,
UindSerial = e.UindSerial ?? string.Empty,
FunctionId = e.FunctionId,
DepartmentId = e.DepartmentId,
TagCreatedAtUtc = e.TagCreatedAtUtc,
TagCreatedBy = e.TagCreatedBy ?? string.Empty,
FirstName = e.FirstName ?? string.Empty,
MiddleName = e.MiddleName ?? string.Empty,
DepartmentTitle = e.DepartmentTitle ?? string.Empty,
DepartmentType = e.DepartmentType ?? string.Empty,
LocationSiteId = e.LocationSiteId ?? string.Empty,
GradeType = e.GradeType ?? string.Empty
};
public static ScanResponse ToScanResponse(ScanResult r, string? mealLabel = null, string? mealItems = null, double totalPrice = 0) =>
new()
{
Success = r.Success,
Message = r.Message,
CooldownSecondsRemaining = r.CooldownSecondsRemaining,
Employee = ToEmployeeDto(r.EmployeeInfo),
MealSession = (int)r.MealSession,
EmployeeSiteId = r.EmployeeSiteId,
CurrentSiteId = r.CurrentSiteId,
MealLabel = mealLabel,
MealItems = mealItems,
TotalPrice = totalPrice
};
public static RecentScanDto ToRecentScanDto(ScanRecord r) => new()
{
Id = r.Id,
CardId = r.CardId,
ScanTime = r.ScanTime,
IsSynced = r.IsSynced,
SiteId = r.SiteId,
DeviceId = r.DeviceId,
IpAddress = r.IpAddress,
MealSessionCode = r.MealSessionCode,
ParentDocumentId = r.ParentDocumentId,
EmployeeId = r.EmployeeId,
UindSerial = r.UindSerial,
FunctionId = r.FunctionId,
DepartmentId = r.DepartmentId,
TagCreatedAtUtc = r.TagCreatedAtUtc,
TagCreatedBy = r.TagCreatedBy,
EmployeeName = r.EmployeeName,
Department = r.Department,
DepartmentType = r.DepartmentType,
MealLabel = r.MealLabel,
MealItems = r.MealItems,
TotalPrice = r.TotalPrice,
GradeType = r.grade_type
};
public static ScanRecord ToEntity(RecentScanDto d) => new()
{
Id = d.Id,
CardId = d.CardId ?? string.Empty,
ScanTime = d.ScanTime,
IsSynced = d.IsSynced,
SiteId = d.SiteId ?? string.Empty,
DeviceId = d.DeviceId ?? string.Empty,
IpAddress = d.IpAddress ?? string.Empty,
MealSessionCode = d.MealSessionCode,
ParentDocumentId = d.ParentDocumentId ?? string.Empty,
EmployeeId = d.EmployeeId ?? string.Empty,
UindSerial = d.UindSerial ?? string.Empty,
FunctionId = d.FunctionId,
DepartmentId = d.DepartmentId,
TagCreatedAtUtc = d.TagCreatedAtUtc,
TagCreatedBy = d.TagCreatedBy ?? string.Empty,
EmployeeName = d.EmployeeName ?? string.Empty,
Department = d.Department ?? string.Empty,
DepartmentType = d.DepartmentType ?? string.Empty,
MealLabel = d.MealLabel ?? string.Empty,
MealItems = d.MealItems ?? string.Empty,
TotalPrice = d.TotalPrice,
grade_type = d.GradeType ?? string.Empty
};
public static ManualSyncResponse ToManualSync(bool success, string message, DateTime? started, DateTime? completed, string? details = null) =>
new()
{
Success = success,
Message = message,
StartedAt = started,
CompletedAt = completed,
Details = details
};
public static MealScheduleDto ToMealScheduleDto(MealSchedule s) => new()
{
Id = s.Id,
MealName = s.MealName,
LocationSiteId = s.LocationSiteId,
MealSession = s.MealSession,
StartTime = s.StartTime,
EndTime = s.EndTime,
IsActive = s.IsActive,
CreatedAt = s.CreatedAt,
UpdatedAt = s.UpdatedAt
};
public static MealSchedule ToMealSchedule(MealScheduleDto d) => new()
{
Id = d.Id,
MealName = d.MealName ?? string.Empty,
LocationSiteId = d.LocationSiteId ?? string.Empty,
MealSession = d.MealSession,
StartTime = d.StartTime ?? string.Empty,
EndTime = d.EndTime ?? string.Empty,
IsActive = d.IsActive,
CreatedAt = d.CreatedAt,
UpdatedAt = d.UpdatedAt
};
}

23
Api/BackendApiDtos.cs Normal file
View File

@ -0,0 +1,23 @@
namespace UtopiaCanteenSystem.Api;
public sealed class HealthResponseDto
{
public string Status { get; set; } = "ok";
public string Mode { get; set; } = "server";
public DateTime Utc { get; set; }
}
public sealed class BackendJobResponseDto
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public string? Details { get; set; }
}
public sealed class CacheStatusResponseDto
{
public DateTime? LastEmployeeRfidCacheSyncUtc { get; set; }
public DateTime? LastMealMenuCacheSyncUtc { get; set; }
}

567
Api/CanteenBackendHost.cs Normal file
View File

@ -0,0 +1,567 @@
using System.IO;
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using UtopiaCanteen.Shared;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services;
using UtopiaCanteenSystem.Services.Logging;
namespace UtopiaCanteenSystem.Api;
/// <summary>
/// Central server HTTP API (backend). Scanner and settings UIs call this; only this layer touches SQLite and HRMS.
/// </summary>
public static class CanteenBackendHost
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public static async Task RunAsync(CanteenBackendServices backend, string listenUrls, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(listenUrls))
listenUrls = "http://0.0.0.0:5000/";
var prefixes = listenUrls
.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(NormalizePrefix)
.Where(p => !string.IsNullOrEmpty(p))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (prefixes.Length == 0)
prefixes = new[] { "http://+:5000/" };
using var listener = new HttpListener();
foreach (var prefix in prefixes)
listener.Prefixes.Add(prefix);
listener.Start();
FileLogger.Info("BackendHost", $"Listening on {string.Join(", ", prefixes)}");
try
{
while (!cancellationToken.IsCancellationRequested)
{
HttpListenerContext ctx;
try
{
ctx = await listener.GetContextAsync().ConfigureAwait(false);
}
catch (HttpListenerException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (ObjectDisposedException)
{
break;
}
_ = Task.Run(() => ProcessRequestAsync(ctx, backend), cancellationToken);
}
}
finally
{
listener.Stop();
listener.Close();
}
}
private static async Task ProcessRequestAsync(HttpListenerContext ctx, CanteenBackendServices backend)
{
try
{
AddCorsHeaders(ctx.Response);
if (string.Equals(ctx.Request.HttpMethod, "OPTIONS", StringComparison.OrdinalIgnoreCase))
{
ctx.Response.StatusCode = 204;
ctx.Response.Close();
return;
}
var path = ctx.Request.Url?.AbsolutePath?.TrimEnd('/') ?? string.Empty;
var method = ctx.Request.HttpMethod ?? "GET";
var clientIp = GetClientIp(ctx);
var rfid = backend.Rfid;
FileLogger.Info("BackendHost", $"{method} {path} from {clientIp}");
if (method == "GET" && path.Equals("/api/health", StringComparison.OrdinalIgnoreCase))
{
FileLogger.Debug("BackendHost", "Health check OK.");
await WriteJsonAsync(ctx, new HealthResponse
{
Status = "ok",
Mode = "server",
Utc = DateTime.UtcNow
}).ConfigureAwait(false);
return;
}
if (method == "GET" && path.Equals("/api/logs/path", StringComparison.OrdinalIgnoreCase))
{
await WriteJsonAsync(ctx, new { logsPath = LogPaths.BackendLogsDirectory }).ConfigureAwait(false);
return;
}
if (method == "POST" && path.Equals("/api/rfid/scan", StringComparison.OrdinalIgnoreCase))
{
await HandleScanAsync(ctx, rfid, backend).ConfigureAwait(false);
return;
}
if (method == "POST" && path.Equals("/api/cache/sync-now", StringComparison.OrdinalIgnoreCase))
{
await HandleCacheSyncAsync(ctx, backend).ConfigureAwait(false);
return;
}
if (method == "POST" && path.Equals("/api/orders/sync-now", StringComparison.OrdinalIgnoreCase))
{
await HandleOrdersSyncAsync(ctx, backend).ConfigureAwait(false);
return;
}
if (path.StartsWith("/api/meal-schedules", StringComparison.OrdinalIgnoreCase))
{
await HandleMealSchedulesAsync(ctx, backend, path, method).ConfigureAwait(false);
return;
}
if (method == "GET")
{
if (path.Equals("/api/cache/status", StringComparison.OrdinalIgnoreCase))
{
await WriteJsonAsync(ctx, new CacheStatusResponse
{
LastEmployeeRfidCacheSyncUtc = backend.Config.GetLastEmployeeRfidCacheSyncUtc(),
LastMealMenuCacheSyncUtc = backend.Config.GetLastMealMenuCacheSyncUtc()
}).ConfigureAwait(false);
return;
}
if (path.Equals("/api/rfid/scans/today", StringComparison.OrdinalIgnoreCase))
{
await WriteJsonAsync(ctx, rfid.GetScansForToday().Select(ApiDtoMapper.ToRecentScanDto).ToList()).ConfigureAwait(false);
return;
}
if (path.Equals("/api/rfid/scans/last", StringComparison.OrdinalIgnoreCase))
{
var last = rfid.GetLastScan();
await WriteJsonAsync(ctx, last == null ? null : ApiDtoMapper.ToRecentScanDto(last)).ConfigureAwait(false);
return;
}
if (path.StartsWith("/api/rfid/scans/recent", StringComparison.OrdinalIgnoreCase))
{
var count = ParseQueryInt(ctx.Request.Url?.Query, "count", 4);
var list = rfid.GetLastScans(count).Select(ApiDtoMapper.ToRecentScanDto).ToList();
await WriteJsonAsync(ctx, list).ConfigureAwait(false);
return;
}
if (path.Equals("/api/rfid/stats/today", StringComparison.OrdinalIgnoreCase))
{
var n = await rfid.GetTodayScanCountAsync().ConfigureAwait(false);
await WriteJsonAsync(ctx, new StatsDto { Count = n }).ConfigureAwait(false);
return;
}
if (path.Equals("/api/rfid/stats/total", StringComparison.OrdinalIgnoreCase))
{
var n = await rfid.GetTotalScanCountAsync().ConfigureAwait(false);
await WriteJsonAsync(ctx, new StatsDto { Count = n }).ConfigureAwait(false);
return;
}
if (path.StartsWith("/api/rfid/stats/today-for-card", StringComparison.OrdinalIgnoreCase))
{
var cardId = ParseQueryString(ctx.Request.Url?.Query, "cardId");
var n = await rfid.GetTodayScanCountForCardAsync(cardId ?? string.Empty).ConfigureAwait(false);
await WriteJsonAsync(ctx, new StatsDto { Count = n }).ConfigureAwait(false);
return;
}
if (path.StartsWith("/api/rfid/stats/total-for-card", StringComparison.OrdinalIgnoreCase))
{
var cardId = ParseQueryString(ctx.Request.Url?.Query, "cardId");
var n = await rfid.GetTotalScanCountForCardAsync(cardId ?? string.Empty).ConfigureAwait(false);
await WriteJsonAsync(ctx, new StatsDto { Count = n }).ConfigureAwait(false);
return;
}
if (path.Equals("/api/employees/location-site", StringComparison.OrdinalIgnoreCase))
{
await HandleEmployeeLocationSiteAsync(ctx).ConfigureAwait(false);
return;
}
}
await WriteJsonAsync(ctx, new { message = "Not found" }, 404).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendHost.ProcessRequestAsync");
try
{
await WriteJsonAsync(ctx, new { message = ex.Message }, 500).ConfigureAwait(false);
}
catch
{
// Ignore
}
}
}
private static async Task HandleCacheSyncAsync(HttpListenerContext ctx, CanteenBackendServices backend)
{
var started = DateTime.UtcNow;
FileLogger.Info("CacheSync", "Manual cache sync API request received (POST /api/cache/sync-now).");
try
{
var result = await backend.RunCacheSyncExclusiveAsync(async () =>
{
if (string.IsNullOrWhiteSpace(backend.Config.GetHrmsLookupConnectionString()))
{
FileLogger.Warn("CacheSync", "Manual cache sync failed. MySQL connection string is not configured.");
return ApiDtoMapper.ToManualSync(false, "MySQL connection string is not configured.", started, DateTime.UtcNow);
}
var sync = await backend.OfflineCacheSync.SyncEmployeeAndMenuCacheAsync().ConfigureAwait(false);
var completed = DateTime.UtcNow;
if (!sync.Success)
{
FileLogger.Error("CacheSync", $"Manual cache sync failed. Error={sync.ErrorMessage}");
return ApiDtoMapper.ToManualSync(false, sync.ErrorMessage ?? "Cache sync failed.", started, completed, sync.ErrorMessage);
}
var emp = sync.EmployeeSync?.UpsertedCount ?? 0;
var meal = sync.MealMenuSync;
var details =
$"Employee RFID tags: {emp}; " +
$"Meal schedules: {meal?.MealScheduleCount ?? 0}; " +
$"Menu weeks: {meal?.LunchMenuWeekCount ?? 0}; " +
$"Menu items: {meal?.LunchMenuItemCount ?? 0}; " +
$"Menu catalog: {meal?.MenuItemCount ?? 0}.";
FileLogger.Info("CacheSync", $"Manual cache sync completed successfully. {details}");
return ApiDtoMapper.ToManualSync(true, "Employee and menu cache synced successfully.", started, completed, details);
}).ConfigureAwait(false);
FileLogger.Info("CacheSync", $"Cache sync API response. Success={result.Success}, Message={result.Message}");
await WriteJsonAsync(ctx, result, result.Success ? 200 : 500).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendHost.HandleCacheSyncAsync");
FileLogger.Error("CacheSync", "Manual cache sync API failed with exception.", ex);
await WriteJsonAsync(ctx, ApiDtoMapper.ToManualSync(false, ex.Message, started, DateTime.UtcNow, ex.ToString()), 500).ConfigureAwait(false);
}
}
private static async Task HandleOrdersSyncAsync(HttpListenerContext ctx, CanteenBackendServices backend)
{
var started = DateTime.UtcNow;
FileLogger.Info("OrderSync", "Manual order sync API request received (POST /api/orders/sync-now).");
try
{
var result = await backend.RunOrderSyncExclusiveAsync(async () =>
{
if (string.IsNullOrWhiteSpace(backend.Config.GetMySqlConnectionString()))
{
FileLogger.Warn("OrderSync", "Manual order sync failed. Production MySQL connection is not configured.");
return ApiDtoMapper.ToManualSync(false, "Production MySQL connection is not configured.", started, DateTime.UtcNow);
}
var stats = await backend.ProductionSync.SyncNowAsync().ConfigureAwait(false);
var completed = DateTime.UtcNow;
var details =
$"Pending={stats.PendingCount}, Posted={stats.PostedCount}, " +
$"DuplicatesSkipped={stats.DuplicatesSkippedCount}, Failed={stats.FailedCount}.";
var success = !stats.SkippedNoConnection && stats.FailedCount == 0;
var message = stats.PendingCount == 0
? "No pending orders to post."
: success
? "Pending orders posted to production."
: "Order sync completed with errors.";
if (success)
FileLogger.Info("OrderSync", $"Manual order sync completed. {details}");
else
FileLogger.Warn("OrderSync", $"Manual order sync finished with issues. {details}");
return ApiDtoMapper.ToManualSync(success, message, started, completed, details);
}).ConfigureAwait(false);
FileLogger.Info("OrderSync", $"Order sync API response. Success={result.Success}, Message={result.Message}");
await WriteJsonAsync(ctx, result, result.Success ? 200 : 500).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendHost.HandleOrdersSyncAsync");
FileLogger.Error("OrderSync", "Manual order sync API failed with exception.", ex);
await WriteJsonAsync(ctx, ApiDtoMapper.ToManualSync(false, ex.Message, started, DateTime.UtcNow, ex.ToString()), 500).ConfigureAwait(false);
}
}
private static async Task HandleScanAsync(HttpListenerContext ctx, RfidService rfid, CanteenBackendServices backend)
{
ScanRequest? dto;
using (var reader = new StreamReader(ctx.Request.InputStream, ctx.Request.ContentEncoding))
{
var body = await reader.ReadToEndAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(body))
{
await WriteJsonAsync(ctx, new { message = "Request body required." }, 400).ConfigureAwait(false);
return;
}
dto = JsonSerializer.Deserialize<ScanRequest>(body, JsonOptions);
}
if (dto == null || string.IsNullOrWhiteSpace(dto.CardId))
{
await WriteJsonAsync(ctx, new { message = "cardId is required." }, 400).ConfigureAwait(false);
return;
}
var remoteIp = ctx.Request.RemoteEndPoint?.Address?.ToString() ?? string.Empty;
if (remoteIp == "::1")
remoteIp = "127.0.0.1";
var ip = string.IsNullOrWhiteSpace(dto.IpAddress) ? remoteIp : dto.IpAddress!.Trim();
var clientCtx = new RfidScanClientContext
{
DeviceId = string.IsNullOrWhiteSpace(dto.DeviceId)
? backend.Config.GetDeviceId()
: dto.DeviceId.Trim(),
SiteId = dto.SiteId?.Trim() ?? string.Empty,
IpAddress = ip
};
FileLogger.Info(
"RfidScan",
$"Scan request. CardId={LogMasking.MaskCardId(dto.CardId)}, DeviceId={clientCtx.DeviceId}, SiteId={clientCtx.SiteId}, ClientIp={remoteIp}");
var result = rfid.ProcessScanDetailed(dto.CardId.Trim(), clientCtx);
var duplicateBlocked = !result.Success &&
(result.Message.Contains("already", StringComparison.OrdinalIgnoreCase) ||
result.Message.Contains("within", StringComparison.OrdinalIgnoreCase));
FileLogger.Info(
"RfidScan",
$"Scan result. Success={result.Success}, Message={result.Message}, DuplicateBlocked={duplicateBlocked}, OrderSaved={result.Success}");
string? mealLabel = null;
string? mealItems = null;
double totalPrice = 0;
if (result.Success)
{
var last = rfid.GetLastScan();
if (last != null)
{
mealLabel = last.MealLabel;
mealItems = last.MealItems;
totalPrice = last.TotalPrice;
}
}
var response = ApiDtoMapper.ToScanResponse(result, mealLabel, mealItems, totalPrice);
await WriteJsonAsync(ctx, response).ConfigureAwait(false);
}
private static void AddCorsHeaders(HttpListenerResponse response)
{
response.Headers.Add("Access-Control-Allow-Origin", "*");
response.Headers.Add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.Headers.Add("Access-Control-Allow-Headers", "Content-Type");
}
private static async Task WriteJsonAsync(HttpListenerContext ctx, object? payload, int statusCode = 200)
{
var json = JsonSerializer.Serialize(payload, JsonOptions);
var bytes = Encoding.UTF8.GetBytes(json);
ctx.Response.StatusCode = statusCode;
ctx.Response.ContentType = "application/json; charset=utf-8";
ctx.Response.ContentLength64 = bytes.Length;
AddCorsHeaders(ctx.Response);
await ctx.Response.OutputStream.WriteAsync(bytes).ConfigureAwait(false);
ctx.Response.Close();
}
private static async Task HandleEmployeeLocationSiteAsync(HttpListenerContext ctx)
{
var employeeId = ParseQueryString(ctx.Request.Url?.Query, "employeeId")?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(employeeId))
{
await WriteJsonAsync(ctx, new EmployeeLocationSiteResponse(), 400).ConfigureAwait(false);
return;
}
try
{
var dbFactory = new DbContextFactory();
await using var db = dbFactory.CreateDbContext();
var siteId = await db.EmployeeRfidTagCache
.AsNoTracking()
.Where(x => x.ParentDocumentType == "Employee")
.Where(x => x.EmployeeSerialNumber == employeeId)
.Where(x => !string.IsNullOrEmpty(x.LocationSiteId))
.Select(x => x.LocationSiteId)
.FirstOrDefaultAsync()
.ConfigureAwait(false);
await WriteJsonAsync(ctx, new EmployeeLocationSiteResponse
{
LocationSiteId = string.IsNullOrWhiteSpace(siteId) ? null : siteId.Trim()
}).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendHost.HandleEmployeeLocationSiteAsync");
await WriteJsonAsync(ctx, new EmployeeLocationSiteResponse(), 500).ConfigureAwait(false);
}
}
private static async Task HandleMealSchedulesAsync(HttpListenerContext ctx, CanteenBackendServices backend, string path, string method)
{
var mealSvc = new ProductionMealScheduleService(backend.Config);
if (method == "GET" && path.Equals("/api/meal-schedules", StringComparison.OrdinalIgnoreCase))
{
var list = await mealSvc.GetAllSchedulesAsync().ConfigureAwait(false);
await WriteJsonAsync(ctx, list.Select(ApiDtoMapper.ToMealScheduleDto).ToList()).ConfigureAwait(false);
return;
}
if (method == "POST" && path.Equals("/api/meal-schedules", StringComparison.OrdinalIgnoreCase))
{
var dto = await ReadJsonBodyAsync<MealScheduleDto>(ctx).ConfigureAwait(false);
if (dto == null)
{
await WriteJsonAsync(ctx, new { message = "Request body required." }, 400).ConfigureAwait(false);
return;
}
try
{
var entity = ApiDtoMapper.ToMealSchedule(dto);
var id = await mealSvc.CreateAsync(entity).ConfigureAwait(false);
entity.Id = id;
await WriteJsonAsync(ctx, ApiDtoMapper.ToMealScheduleDto(entity), 201).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendHost.CreateMealSchedule");
await WriteJsonAsync(ctx, new { message = ex.Message }, 500).ConfigureAwait(false);
}
return;
}
var scheduleId = TryParseMealScheduleId(path);
if (scheduleId.HasValue)
{
if (method == "PUT")
{
var dto = await ReadJsonBodyAsync<MealScheduleDto>(ctx).ConfigureAwait(false);
if (dto == null)
{
await WriteJsonAsync(ctx, new { message = "Request body required." }, 400).ConfigureAwait(false);
return;
}
try
{
var entity = ApiDtoMapper.ToMealSchedule(dto);
entity.Id = scheduleId.Value;
await mealSvc.UpdateAsync(entity).ConfigureAwait(false);
await WriteJsonAsync(ctx, ApiDtoMapper.ToMealScheduleDto(entity)).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendHost.UpdateMealSchedule");
await WriteJsonAsync(ctx, new { message = ex.Message }, 500).ConfigureAwait(false);
}
return;
}
if (method == "DELETE")
{
try
{
await mealSvc.DeleteAsync(scheduleId.Value).ConfigureAwait(false);
await WriteJsonAsync(ctx, new { success = true }).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendHost.DeleteMealSchedule");
await WriteJsonAsync(ctx, new { message = ex.Message }, 500).ConfigureAwait(false);
}
return;
}
}
await WriteJsonAsync(ctx, new { message = "Not found" }, 404).ConfigureAwait(false);
}
private static long? TryParseMealScheduleId(string path)
{
const string prefix = "/api/meal-schedules/";
if (!path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return null;
var idPart = path[prefix.Length..];
return long.TryParse(idPart, out var id) ? id : null;
}
private static async Task<T?> ReadJsonBodyAsync<T>(HttpListenerContext ctx) where T : class
{
using var reader = new StreamReader(ctx.Request.InputStream, ctx.Request.ContentEncoding);
var body = await reader.ReadToEndAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(body))
return null;
return JsonSerializer.Deserialize<T>(body, JsonOptions);
}
private static string NormalizePrefix(string url)
{
var u = url.Trim();
if (!u.EndsWith('/'))
u += "/";
if (u.Contains("0.0.0.0", StringComparison.Ordinal))
u = u.Replace("0.0.0.0", "+", StringComparison.Ordinal);
return u;
}
private static int ParseQueryInt(string? query, string key, int defaultValue)
{
var s = ParseQueryString(query, key);
return int.TryParse(s, out var n) ? n : defaultValue;
}
private static string GetClientIp(HttpListenerContext ctx)
{
var ip = ctx.Request.RemoteEndPoint?.Address?.ToString() ?? "unknown";
return ip == "::1" ? "127.0.0.1" : ip;
}
private static string? ParseQueryString(string? query, string key)
{
if (string.IsNullOrEmpty(query))
return null;
var q = query.TrimStart('?');
foreach (var part in q.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var kv = part.Split('=', 2);
if (kv.Length > 0 && string.Equals(Uri.UnescapeDataString(kv[0]), key, StringComparison.OrdinalIgnoreCase))
return kv.Length > 1 ? Uri.UnescapeDataString(kv[1]) : string.Empty;
}
return null;
}
}

178
Api/RfidApiDtos.cs Normal file
View File

@ -0,0 +1,178 @@
using System.Text.Json.Serialization;
using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services;
namespace UtopiaCanteenSystem.Api;
public sealed class RfidScanRequestDto
{
public string CardId { get; set; } = string.Empty;
public string DeviceId { get; set; } = string.Empty;
public string SiteId { get; set; } = string.Empty;
public string? IpAddress { get; set; }
}
public sealed class HrmsEmployeeInfoDto
{
public string ParentDocumentId { get; set; } = string.Empty;
public string EmployeeId { get; set; } = string.Empty;
public string UindSerial { get; set; } = string.Empty;
public int FunctionId { get; set; }
public int DepartmentId { get; set; }
public DateTime? TagCreatedAtUtc { get; set; }
public string TagCreatedBy { get; set; } = string.Empty;
public string FirstName { get; set; } = string.Empty;
public string MiddleName { get; set; } = string.Empty;
public string DepartmentTitle { get; set; } = string.Empty;
public string DepartmentType { get; set; } = string.Empty;
public string LocationSiteId { get; set; } = string.Empty;
public string GradeType { get; set; } = string.Empty;
public static HrmsEmployeeInfoDto? FromModel(HrmsEmployeeInfo? e)
{
if (e == null) return null;
return new HrmsEmployeeInfoDto
{
ParentDocumentId = e.ParentDocumentId,
EmployeeId = e.EmployeeId,
UindSerial = e.UindSerial,
FunctionId = e.FunctionId,
DepartmentId = e.DepartmentId,
TagCreatedAtUtc = e.TagCreatedAtUtc,
TagCreatedBy = e.TagCreatedBy,
FirstName = e.FirstName,
MiddleName = e.MiddleName,
DepartmentTitle = e.DepartmentTitle,
DepartmentType = e.DepartmentType,
LocationSiteId = e.LocationSiteId,
GradeType = e.GradeType
};
}
public HrmsEmployeeInfo ToModel() => new()
{
ParentDocumentId = ParentDocumentId ?? string.Empty,
EmployeeId = EmployeeId ?? string.Empty,
UindSerial = UindSerial ?? string.Empty,
FunctionId = FunctionId,
DepartmentId = DepartmentId,
TagCreatedAtUtc = TagCreatedAtUtc,
TagCreatedBy = TagCreatedBy ?? string.Empty,
FirstName = FirstName ?? string.Empty,
MiddleName = MiddleName ?? string.Empty,
DepartmentTitle = DepartmentTitle ?? string.Empty,
DepartmentType = DepartmentType ?? string.Empty,
LocationSiteId = LocationSiteId ?? string.Empty,
GradeType = GradeType ?? string.Empty
};
}
public sealed class RfidScanResponseDto
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public int CooldownSecondsRemaining { get; set; }
public HrmsEmployeeInfoDto? Employee { get; set; }
public int MealSession { get; set; }
public string? EmployeeSiteId { get; set; }
public string? CurrentSiteId { get; set; }
public string? MealLabel { get; set; }
public string? MealItems { get; set; }
public double TotalPrice { get; set; }
public static RfidScanResponseDto FromScanResult(ScanResult r, string? mealLabel = null, string? mealItems = null, double totalPrice = 0)
{
return new RfidScanResponseDto
{
Success = r.Success,
Message = r.Message,
CooldownSecondsRemaining = r.CooldownSecondsRemaining,
Employee = HrmsEmployeeInfoDto.FromModel(r.EmployeeInfo),
MealSession = (int)r.MealSession,
EmployeeSiteId = r.EmployeeSiteId,
CurrentSiteId = r.CurrentSiteId,
MealLabel = mealLabel,
MealItems = mealItems,
TotalPrice = totalPrice
};
}
}
public sealed class ScanRecordDto
{
public int Id { get; set; }
public string CardId { get; set; } = string.Empty;
public DateTime ScanTime { get; set; }
public bool IsSynced { get; set; }
public string SiteId { get; set; } = string.Empty;
public string DeviceId { get; set; } = string.Empty;
public string IpAddress { get; set; } = string.Empty;
public int MealSessionCode { get; set; }
public string ParentDocumentId { get; set; } = string.Empty;
public string EmployeeId { get; set; } = string.Empty;
public string UindSerial { get; set; } = string.Empty;
public int FunctionId { get; set; }
public int DepartmentId { get; set; }
public DateTime? TagCreatedAtUtc { get; set; }
public string TagCreatedBy { get; set; } = string.Empty;
public string EmployeeName { get; set; } = string.Empty;
public string Department { get; set; } = string.Empty;
public string DepartmentType { get; set; } = string.Empty;
public string MealLabel { get; set; } = string.Empty;
public string MealItems { get; set; } = string.Empty;
public double TotalPrice { get; set; }
[JsonPropertyName("grade_type")]
public string GradeType { get; set; } = string.Empty;
public static ScanRecordDto FromEntity(ScanRecord r) => new()
{
Id = r.Id,
CardId = r.CardId,
ScanTime = r.ScanTime,
IsSynced = r.IsSynced,
SiteId = r.SiteId,
DeviceId = r.DeviceId,
IpAddress = r.IpAddress,
MealSessionCode = r.MealSessionCode,
ParentDocumentId = r.ParentDocumentId,
EmployeeId = r.EmployeeId,
UindSerial = r.UindSerial,
FunctionId = r.FunctionId,
DepartmentId = r.DepartmentId,
TagCreatedAtUtc = r.TagCreatedAtUtc,
TagCreatedBy = r.TagCreatedBy,
EmployeeName = r.EmployeeName,
Department = r.Department,
DepartmentType = r.DepartmentType,
MealLabel = r.MealLabel,
MealItems = r.MealItems,
TotalPrice = r.TotalPrice,
GradeType = r.grade_type
};
public ScanRecord ToEntity() => new()
{
Id = Id,
CardId = CardId ?? string.Empty,
ScanTime = ScanTime,
IsSynced = IsSynced,
SiteId = SiteId ?? string.Empty,
DeviceId = DeviceId ?? string.Empty,
IpAddress = IpAddress ?? string.Empty,
MealSessionCode = MealSessionCode,
ParentDocumentId = ParentDocumentId ?? string.Empty,
EmployeeId = EmployeeId ?? string.Empty,
UindSerial = UindSerial ?? string.Empty,
FunctionId = FunctionId,
DepartmentId = DepartmentId,
TagCreatedAtUtc = TagCreatedAtUtc,
TagCreatedBy = TagCreatedBy ?? string.Empty,
EmployeeName = EmployeeName ?? string.Empty,
Department = Department ?? string.Empty,
DepartmentType = DepartmentType ?? string.Empty,
MealLabel = MealLabel ?? string.Empty,
MealItems = MealItems ?? string.Empty,
TotalPrice = TotalPrice,
grade_type = GradeType ?? string.Empty
};
}

View File

@ -1,21 +1,27 @@
using Microsoft.EntityFrameworkCore;
using System.Net.Http;
using System.Threading;
using System.Windows;
using Microsoft.EntityFrameworkCore;
using UtopiaCanteenSystem.Api;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services;
using UtopiaCanteenSystem.ViewModels;
namespace UtopiaCanteenSystem;
/// <summary>
/// Application entry point. Initializes database, builds service graph, starts hourly sync timer.
/// WPF shell: Server = backend + SQLite + API host; Client = scanner frontend calling backend HTTP API only.
/// </summary>
public partial class App : Application
{
private static Mutex _mutex;
private static Mutex _mutex = null!;
private System.Timers.Timer? _syncTimer;
private System.Timers.Timer? _offlineCacheSyncTimer;
private int _isSyncRunning;
private int _isOfflineCacheSyncRunning;
private CancellationTokenSource? _apiHostCts;
private Task? _apiHostTask;
protected override void OnStartup(StartupEventArgs e)
{
@ -28,88 +34,183 @@ public partial class App : Application
Current.Shutdown();
return;
}
base.OnStartup(e);
// Build services (simple composition; no DI container)
var dbFactory = new DbContextFactory();
var configService = new ConfigService();
var isServer = configService.GetAppMode() == AppMode.Server;
var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
var backendApiClient = new CanteenBackendApiClient(httpClient, configService);
var dbFactory = new DbContextFactory();
CanteenBackendServices? backendServices = null;
RfidService? serverRfid = null;
IEmployeeLookupService employeeLookupService;
IMenuLookupService menuLookupService;
IRfidService scannerRfidService;
ISyncService? syncService = null;
ProductionMealScheduleService? mealScheduleService = null;
// Auto-create SQLite database on first run
using (var db = dbFactory.CreateDbContext())
{
db.EnsureDatabaseCreated();
if (isServer)
{
var employeeRfidTagSync = new EmployeeRfidTagSyncService(dbFactory, configService);
var mealMenuCacheSync = new MealMenuCacheSyncService(dbFactory, configService);
var offlineCacheSync = new OfflineCacheSyncService(employeeRfidTagSync, mealMenuCacheSync);
employeeLookupService = new EmployeeLookupService(dbFactory, configService);
menuLookupService = new MenuLookupService(dbFactory);
mealScheduleService = new ProductionMealScheduleService(configService);
var mealSessionResolver = new DbMealSessionResolver(dbFactory);
syncService = new SyncService(dbFactory, configService);
serverRfid = new RfidService(dbFactory, configService, employeeLookupService, mealSessionResolver, menuLookupService, syncService);
backendServices = new CanteenBackendServices(serverRfid, offlineCacheSync, syncService, configService);
scannerRfidService = serverRfid;
}
else
{
employeeLookupService = new EmployeeLookupService(dbFactory, configService);
menuLookupService = new EmptyMenuLookupService();
scannerRfidService = backendApiClient;
}
var configService = new ConfigService();
var employeeLookupService = new EmployeeLookupService(configService);
//var employeePhotoService = new EmployeePhotoService(configService);
var httpClient = new HttpClient();
var employeePhotoService = new EmployeePhotoService(configService, httpClient);
var menuLookupService = new MenuLookupService(configService);
var mealScheduleService = new ProductionMealScheduleService(configService);
var mealSessionResolver = new DbMealSessionResolver(mealScheduleService);
var syncService = new SyncService(dbFactory, configService);
var rfidService = new RfidService(dbFactory, configService, employeeLookupService, mealSessionResolver, menuLookupService, syncService);
var adminAuditService = new AdminAuditService(dbFactory);
var session = new AppSession();
var authenticationUrl = "https://portal.utopiaindustries.pk/uind/rest/auth/user/";
var authService = new AuthService(authenticationUrl);
var authService = new AuthService("https://portal.utopiaindustries.pk/uind/rest/auth/user/");
// NavigationService: declare first so lambdas can capture it, then assign (avoids "used before declared")
NavigationService navigationService = null!;
navigationService = new NavigationService(
session,
() => new AdminLoginViewModel(authService, session, navigationService, configService, adminAuditService, employeeLookupService),
() => new ScannerDashboardViewModel(rfidService, navigationService, session, configService, menuLookupService, employeePhotoService),
() => new MainDashboardViewModel(navigationService, rfidService, configService, session),
() => new AdminSettingsAuthViewModel(authService, session, navigationService, configService, employeeLookupService),
() => new SettingsViewModel(configService, navigationService, adminAuditService, syncService),
() => new MealSchedulesViewModel(mealScheduleService, navigationService, configService));
() => new AdminLoginViewModel(authService, session, navigationService, configService, adminAuditService, employeeLookupService, backendApiClient),
() => new ScannerDashboardViewModel(scannerRfidService, navigationService, session, configService, menuLookupService, employeePhotoService),
() => new MainDashboardViewModel(navigationService, scannerRfidService, configService, session),
() => new AdminSettingsAuthViewModel(authService, session, navigationService, configService, employeeLookupService, backendApiClient),
() => new SettingsViewModel(configService, navigationService, adminAuditService, backendApiClient),
() => new MealSchedulesViewModel(
mealScheduleService ?? new ProductionMealScheduleService(configService),
navigationService,
configService));
var mainViewModel = new MainViewModel(navigationService);
var mainWindow = new MainWindow
{
DataContext = mainViewModel
};
// Set the window to open maximized
var mainWindow = new MainWindow { DataContext = new MainViewModel(navigationService) };
mainWindow.WindowState = WindowState.Maximized;
mainWindow.Show();
// Background sync: every 15 minutes, POST unsynced lunch_order_transactions to API
if (configService.GetSyncServiceEnabled())
if (isServer && backendServices != null)
{
_syncTimer = new System.Timers.Timer(TimeSpan.FromMinutes(1).TotalMilliseconds)
_apiHostCts = new CancellationTokenSource();
var listenUrls = configService.GetLocalServerListenUrls();
var backend = backendServices;
var cts = _apiHostCts;
_apiHostTask = Task.Run(async () =>
{
AutoReset = true
};
_syncTimer.Elapsed += async (_, _) =>
{
// Prevent overlapping sync runs; if one is still running, skip this tick.
if (Interlocked.Exchange(ref _isSyncRunning, 1) == 1)
return;
try
{
await syncService.SyncNowAsync().ConfigureAwait(false);
await Task.Delay(500, cts!.Token).ConfigureAwait(false);
await CanteenBackendHost.RunAsync(backend, listenUrls, cts.Token).ConfigureAwait(false);
}
catch
catch (OperationCanceledException)
{
// Ignore; will retry next tick
// Shutdown
}
finally
catch (Exception ex)
{
Interlocked.Exchange(ref _isSyncRunning, 0);
Logger.Log(ex, "App.CanteenBackendHost");
}
};
_syncTimer.Start();
}, CancellationToken.None);
_ = RunOfflineCacheSyncViaApiAsync(backendApiClient);
if (!string.IsNullOrWhiteSpace(configService.GetHrmsLookupConnectionString()))
{
_offlineCacheSyncTimer = new System.Timers.Timer(TimeSpan.FromMinutes(15).TotalMilliseconds) { AutoReset = true };
_offlineCacheSyncTimer.Elapsed += async (_, _) =>
{
if (Interlocked.Exchange(ref _isOfflineCacheSyncRunning, 1) == 1)
return;
try
{
await RunOfflineCacheSyncViaApiAsync(backendApiClient).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "App.OfflineCacheSyncTimer");
}
finally
{
Interlocked.Exchange(ref _isOfflineCacheSyncRunning, 0);
}
};
_offlineCacheSyncTimer.Start();
}
if (configService.GetSyncServiceEnabled() && syncService != null)
{
_syncTimer = new System.Timers.Timer(TimeSpan.FromMinutes(1).TotalMilliseconds) { AutoReset = true };
_syncTimer.Elapsed += async (_, _) =>
{
if (Interlocked.Exchange(ref _isSyncRunning, 1) == 1)
return;
try
{
if (await backendApiClient.HealthCheckAsync().ConfigureAwait(false))
await backendApiClient.SyncOrdersNowAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "App.ProductionSyncTimer");
}
finally
{
Interlocked.Exchange(ref _isSyncRunning, 0);
}
};
_syncTimer.Start();
}
}
}
protected override void OnExit(ExitEventArgs e)
{
try
{
_apiHostCts?.Cancel();
_apiHostTask?.Wait(TimeSpan.FromSeconds(5));
}
catch
{
// Best-effort
}
_mutex.ReleaseMutex();
_syncTimer?.Stop();
_syncTimer?.Dispose();
_offlineCacheSyncTimer?.Stop();
_offlineCacheSyncTimer?.Dispose();
_apiHostCts?.Dispose();
base.OnExit(e);
}
private static async Task RunOfflineCacheSyncViaApiAsync(ICanteenBackendApiClient api)
{
try
{
for (var i = 0; i < 30; i++)
{
if (await api.HealthCheckAsync().ConfigureAwait(false))
{
await api.SyncCacheNowAsync().ConfigureAwait(false);
return;
}
await Task.Delay(1000).ConfigureAwait(false);
}
}
catch (Exception ex)
{
Logger.Log(ex, "App.RunOfflineCacheSyncViaApiAsync");
}
}
}

View File

@ -18,6 +18,11 @@ public class AppDbContext : DbContext
public DbSet<Labour> Labour { get; set; }
public DbSet<ScanRecord> LunchOrderTransactions { 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() { }
@ -52,6 +57,46 @@ public class AppDbContext : DbContext
e.HasKey(x => x.Id);
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>
@ -63,6 +108,8 @@ public class AppDbContext : DbContext
MigrateScanRecordsToLunchOrderTransactionsIfNeeded();
UpgradeLunchOrderTransactionsSchemaIfNeeded();
EnsureAdminLoginTableExists();
EnsureEmployeeRfidTagCacheTableExists();
EnsureMealMenuCacheTablesExist();
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>
/// 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.

View File

@ -1,4 +1,5 @@
using System.IO;
using UtopiaCanteenSystem.Services.Logging;
namespace UtopiaCanteenSystem.Data;
@ -10,6 +11,27 @@ public static class DatabasePath
{
private static string? _appDataFolder;
private static string? _dbPath;
private static string _productFolderName = "UtopiaCanteenSystem";
private static bool _useInteractiveLocalAppData;
/// <summary>
/// Use "UtopiaCanteenBackend" for the Windows Service; default is legacy WPF folder name.
/// </summary>
public static void UseBackendServiceStorage()
{
_productFolderName = "UtopiaCanteenBackend";
_useInteractiveLocalAppData = true;
_appDataFolder = null;
_dbPath = null;
}
public static void UseClientStorage()
{
_productFolderName = "UtopiaCanteenClient";
_useInteractiveLocalAppData = false;
_appDataFolder = null;
_dbPath = null;
}
/// <summary>
/// Folder under LocalApplicationData for DB and config. Created on first use.
@ -19,13 +41,65 @@ public static class DatabasePath
if (_appDataFolder != null)
return _appDataFolder;
_appDataFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"UtopiaCanteenSystem");
var localAppDataRoot = ResolveLocalAppDataRoot();
_appDataFolder = Path.Combine(localAppDataRoot, _productFolderName);
Directory.CreateDirectory(_appDataFolder);
MigrateBackendSystemProfileStorageIfNeeded(_appDataFolder);
return _appDataFolder;
}
private static string ResolveLocalAppDataRoot()
{
if (_useInteractiveLocalAppData)
{
var interactive = InteractiveUserPath.TryGetLocalAppDataPath();
if (!string.IsNullOrWhiteSpace(interactive))
return interactive;
}
return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
}
private static void MigrateBackendSystemProfileStorageIfNeeded(string newFolder)
{
if (!_useInteractiveLocalAppData)
return;
var systemProfileFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
_productFolderName);
if (string.Equals(systemProfileFolder, newFolder, StringComparison.OrdinalIgnoreCase) ||
!Directory.Exists(systemProfileFolder))
return;
var oldDbPath = Path.Combine(systemProfileFolder, "utopia_canteen.db");
var newDbPath = Path.Combine(newFolder, "utopia_canteen.db");
if (File.Exists(oldDbPath) && !File.Exists(newDbPath))
{
CopyIfMissing(oldDbPath, newDbPath);
CopyIfMissing(Path.Combine(systemProfileFolder, "utopia_canteen.db-wal"), Path.Combine(newFolder, "utopia_canteen.db-wal"));
CopyIfMissing(Path.Combine(systemProfileFolder, "utopia_canteen.db-shm"), Path.Combine(newFolder, "utopia_canteen.db-shm"));
}
CopyIfMissing(Path.Combine(systemProfileFolder, "backend-settings.json"), Path.Combine(newFolder, "backend-settings.json"));
}
private static void CopyIfMissing(string sourcePath, string destinationPath)
{
if (!File.Exists(sourcePath) || File.Exists(destinationPath))
return;
try
{
File.Copy(sourcePath, destinationPath);
}
catch
{
// If migration fails, the app will continue with files already present or create new ones.
}
}
/// <summary>
/// Full path to the SQLite database file. If a DB exists in the legacy app-directory
/// location, it is copied to the new location once.

10
Models/AppMode.cs Normal file
View File

@ -0,0 +1,10 @@
namespace UtopiaCanteenSystem.Models;
/// <summary>
/// Deployment role: central PC hosts SQLite and HTTP API; scanner PCs call the API only.
/// </summary>
public enum AppMode
{
Server = 0,
Client = 1
}

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

@ -1,7 +1,7 @@
namespace UtopiaCanteenSystem.Models;
/// <summary>
/// Employee info loaded from local HRMS by RFID (employee_rfid_tag → employee → department).
/// Employee info from offline RFID cache (synced from employee_rfid_tag → employee → department).
/// </summary>
public class HrmsEmployeeInfo
{

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,11 @@
namespace UtopiaCanteenSystem.Models;
/// <summary>
/// Per-scan context from a scanner PC (API client). Used on the central server instead of server-local config for site/device/IP.
/// </summary>
public sealed class RfidScanClientContext
{
public string DeviceId { get; init; } = string.Empty;
public string SiteId { get; init; } = string.Empty;
public string IpAddress { get; init; } = string.Empty;
}

View File

@ -32,9 +32,10 @@ public class AdminAuditService : IAdminAuditService
db.AdminLoginRecords.Add(record);
await db.SaveChangesAsync().ConfigureAwait(false);
}
catch
catch (Exception ex)
{
// Audit failures should never block login; ignore errors.
// Audit failures should never block login.
Logger.Log(ex, "AdminAuditService.RecordLoginAsync");
}
}
@ -50,8 +51,9 @@ public class AdminAuditService : IAdminAuditService
.OrderByDescending(x => x.LoginTimeUtc)
.FirstOrDefault();
}
catch
catch (Exception ex)
{
Logger.Log(ex, "AdminAuditService.GetLastLogin");
return null;
}
}

View File

@ -0,0 +1,97 @@
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Shared admin login post-processing: persist employee id and site from session/backend.
/// </summary>
public static class AdminLoginHelper
{
/// <summary>
/// Admin Access field: persisted <see cref="IConfigService.GetAdminCardId"/> first,
/// then current session, then SQLite audit, then "ADMIN".
/// </summary>
public static string ResolveAdminCardIdForDisplay(
IConfigService config,
AppSession? session = null,
IAdminAuditService? adminAudit = null)
{
var fromConfig = (config.GetAdminCardId() ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(fromConfig))
return fromConfig;
if (session != null && !string.IsNullOrWhiteSpace(session.AdminEmployeeId))
return session.AdminEmployeeId.Trim();
var last = adminAudit?.GetLastLogin();
if (last != null && !string.IsNullOrWhiteSpace(last.EmployeeId))
return last.EmployeeId.Trim();
return "ADMIN";
}
public static async Task ApplyAdminLoginDefaultsAsync(
string employeeId,
IConfigService config,
IEmployeeLookupService employeeLookup,
ICanteenBackendApiClient? backendApi = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(employeeId))
return;
config.SetAdminCardId(employeeId.Trim());
string? siteId = null;
try
{
siteId = await employeeLookup.GetLocationSiteIdByEmployeeSerialAsync(employeeId, cancellationToken)
.ConfigureAwait(false);
}
catch
{
// Local/HRMS lookup optional on client.
}
if (string.IsNullOrWhiteSpace(siteId) && backendApi != null)
{
try
{
siteId = await backendApi.GetEmployeeLocationSiteAsync(employeeId, cancellationToken)
.ConfigureAwait(false);
}
catch
{
// Backend lookup optional.
}
}
config.ApplyLocationSiteIdFromAuth(siteId);
}
/// <summary>Format site for UI fields (e.g. "SITE : 02").</summary>
public static string FormatSiteIdForDisplay(string? siteId)
{
if (string.IsNullOrWhiteSpace(siteId))
return string.Empty;
var raw = siteId.Trim();
if (raw.StartsWith("SITE :", StringComparison.OrdinalIgnoreCase))
return raw;
var digits = new string(raw.Where(char.IsDigit).ToArray());
return string.IsNullOrEmpty(digits) ? raw : $"SITE : {digits}";
}
/// <summary>Extract digits from site display for scanner header.</summary>
public static string ExtractSiteDigits(string? siteId)
{
if (string.IsNullOrWhiteSpace(siteId))
return "1";
var raw = siteId.Trim();
if (raw.StartsWith("SITE :", StringComparison.OrdinalIgnoreCase))
raw = raw.Substring(raw.IndexOf(':') + 1).Trim();
var digits = new string(raw.Where(char.IsDigit).ToArray());
return string.IsNullOrEmpty(digits) ? "1" : digits;
}
}

View File

@ -0,0 +1,48 @@
using UtopiaCanteen.Shared;
using UtopiaCanteenSystem.Api;
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Meal schedule CRUD via central backend API (client never touches HRMS/MySQL).
/// </summary>
public sealed class BackendApiMealScheduleService : IMealScheduleService
{
private readonly ICanteenBackendApiClient _api;
public BackendApiMealScheduleService(ICanteenBackendApiClient api)
{
_api = api;
}
public IReadOnlyList<MealSchedule> GetActiveSchedulesForSite(string siteId)
{
var all = GetAllSchedulesAsync().GetAwaiter().GetResult();
if (string.IsNullOrWhiteSpace(siteId))
return all;
var site = siteId.Trim().Replace("SITE : ", "", StringComparison.OrdinalIgnoreCase).Trim();
return all.Where(s => string.Equals(s.LocationSiteId?.Trim(), site, StringComparison.OrdinalIgnoreCase)).ToList();
}
public async Task<IReadOnlyList<MealSchedule>> GetAllSchedulesAsync(CancellationToken cancellationToken = default)
{
var dtos = await _api.GetMealSchedulesAsync(cancellationToken).ConfigureAwait(false);
return dtos.Select(ApiDtoMapper.ToMealSchedule).ToList();
}
public async Task<long> CreateAsync(MealSchedule schedule, CancellationToken cancellationToken = default)
{
var dto = ApiDtoMapper.ToMealScheduleDto(schedule);
return await _api.CreateMealScheduleAsync(dto, cancellationToken).ConfigureAwait(false);
}
public async Task UpdateAsync(MealSchedule schedule, CancellationToken cancellationToken = default)
{
var dto = ApiDtoMapper.ToMealScheduleDto(schedule);
await _api.UpdateMealScheduleAsync(dto, cancellationToken).ConfigureAwait(false);
}
public async Task DeleteAsync(long id, CancellationToken cancellationToken = default) =>
await _api.DeleteMealScheduleAsync(id, cancellationToken).ConfigureAwait(false);
}

View File

@ -0,0 +1,372 @@
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using UtopiaCanteen.Shared;
using UtopiaCanteenSystem.Api;
using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services.Logging;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Frontend HTTP client: all scan/history/stats and settings actions go through the central backend API.
/// </summary>
public class CanteenBackendApiClient : ICanteenBackendApiClient
{
private readonly HttpClient _http;
private readonly IConfigService _config;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public CanteenBackendApiClient(HttpClient http, IConfigService config)
{
_http = http;
_config = config;
}
private string BaseUrl => (_config.GetBackendBaseUrl() ?? string.Empty).TrimEnd('/');
public async Task<bool> HealthCheckAsync(CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(BaseUrl))
{
FileLogger.Warn("ClientApi", "Health check skipped. Backend base URL is not configured.");
return false;
}
try
{
var url = $"{BaseUrl}/api/health";
var resp = await _http.GetFromJsonAsync<HealthResponse>(url, JsonOptions, cancellationToken).ConfigureAwait(false);
var ok = resp != null && string.Equals(resp.Status, "ok", StringComparison.OrdinalIgnoreCase);
if (ok)
FileLogger.Info("ClientApi", $"Health check succeeded. Url={BaseUrl}");
else
FileLogger.Warn("ClientApi", $"Health check failed (unexpected response). Url={BaseUrl}");
return ok;
}
catch (Exception ex)
{
FileLogger.Error("ClientApi", $"Health check failed. Url={BaseUrl}", ex);
Logger.Log(ex, "CanteenBackendApiClient.HealthCheckAsync");
return false;
}
}
public async Task<ManualSyncResponse> SyncCacheNowAsync(CancellationToken cancellationToken = default) =>
await PostJobAsync("/api/cache/sync-now", cancellationToken).ConfigureAwait(false);
public async Task<ManualSyncResponse> SyncOrdersNowAsync(CancellationToken cancellationToken = default) =>
await PostJobAsync("/api/orders/sync-now", cancellationToken).ConfigureAwait(false);
public async Task<CacheStatusResponse?> GetCacheStatusAsync(CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(BaseUrl))
return null;
try
{
var url = $"{BaseUrl}/api/cache/status";
return await _http.GetFromJsonAsync<CacheStatusResponse>(url, JsonOptions, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendApiClient.GetCacheStatusAsync");
return null;
}
}
public (bool Success, string Message) ProcessScan(string cardId)
{
var r = ProcessScanDetailed(cardId);
return (r.Success, r.Message);
}
public ScanResult ProcessScanDetailed(string cardId)
{
if (string.IsNullOrWhiteSpace(BaseUrl))
return new ScanResult(false, "Backend URL is not configured.", 0);
FileLogger.Info(
"ClientApi",
$"API scan request started. CardId={LogMasking.MaskCardId(cardId)}, POST {BaseUrl}/api/rfid/scan");
try
{
var url = $"{BaseUrl}/api/rfid/scan";
var body = new ScanRequest
{
CardId = cardId?.Trim() ?? string.Empty,
DeviceId = _config.GetDeviceId(),
SiteId = _config.GetSiteId(),
IpAddress = null
};
using var response = _http.PostAsJsonAsync(url, body, JsonOptions).GetAwaiter().GetResult();
if (!response.IsSuccessStatusCode)
{
var err = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
var fail = new ScanResult(false,
$"Backend error ({(int)response.StatusCode}): {Truncate(err, 200)}", 0);
FileLogger.Warn("ClientApi", $"API scan request failed. Success=false, Message={fail.Message}");
return fail;
}
var dto = response.Content.ReadFromJsonAsync<ScanResponse>(JsonOptions).GetAwaiter().GetResult();
if (dto == null)
{
FileLogger.Warn("ClientApi", "API scan request failed. Invalid response from backend.");
return new ScanResult(false, "Invalid response from backend.", 0);
}
var scanResult = new ScanResult(
dto.Success,
dto.Message ?? string.Empty,
dto.CooldownSecondsRemaining,
dto.Employee == null ? null : ApiDtoMapper.ToModel(dto.Employee),
(MealSession)dto.MealSession,
dto.EmployeeSiteId,
dto.CurrentSiteId);
FileLogger.Info(
"ClientApi",
$"API scan request completed. Success={scanResult.Success}, Message={scanResult.Message}");
return scanResult;
}
catch (Exception ex)
{
FileLogger.Error("ClientApi", "API scan request failed with exception.", ex);
Logger.Log(ex, "CanteenBackendApiClient.ProcessScanDetailed");
return new ScanResult(false, "Cannot reach backend: " + ex.Message, 0);
}
}
public ScanRecord? GetLastScan() =>
GetAsync<RecentScanDto?, ScanRecord?>("/api/rfid/scans/last", d => d == null ? null : ApiDtoMapper.ToEntity(d));
public IReadOnlyList<ScanRecord> GetLastScans(int count)
{
if (count <= 0) return Array.Empty<ScanRecord>();
return GetAsync<List<RecentScanDto>, IReadOnlyList<ScanRecord>>(
$"/api/rfid/scans/recent?count={count}",
list => list?.Select(ApiDtoMapper.ToEntity).ToList() ?? new List<ScanRecord>());
}
public IReadOnlyList<ScanRecord> GetScansForToday() =>
GetAsync<List<RecentScanDto>, IReadOnlyList<ScanRecord>>(
"/api/rfid/scans/today",
list => list?.Select(ApiDtoMapper.ToEntity).ToList() ?? new List<ScanRecord>());
public int GetTodayScanCount() => GetTodayScanCountAsync().GetAwaiter().GetResult();
public async Task<int> GetTodayScanCountAsync(CancellationToken cancellationToken = default) =>
await GetCountAsync("/api/rfid/stats/today", cancellationToken).ConfigureAwait(false);
public async Task<int> GetTotalScanCountAsync(CancellationToken cancellationToken = default) =>
await GetCountAsync("/api/rfid/stats/total", cancellationToken).ConfigureAwait(false);
public async Task<int> GetTotalScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(cardId)) return 0;
return await GetCountAsync($"/api/rfid/stats/total-for-card?cardId={Uri.EscapeDataString(cardId.Trim())}", cancellationToken)
.ConfigureAwait(false);
}
public async Task<int> GetTodayScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(cardId)) return 0;
return await GetCountAsync($"/api/rfid/stats/today-for-card?cardId={Uri.EscapeDataString(cardId.Trim())}", cancellationToken)
.ConfigureAwait(false);
}
public void UpdateLastScanMealInfo(string mealLabel, string mealItems, double totalPrice)
{
// Backend owns SQLite; no local update on frontend.
}
private async Task<ManualSyncResponse> PostJobAsync(string path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(BaseUrl))
{
return new ManualSyncResponse
{
Success = false,
Message = "Backend URL is not configured."
};
}
var url = $"{BaseUrl}{path}";
FileLogger.Info("ClientApi", $"POST {url} started.");
try
{
using var response = await _http.PostAsync(url, null, cancellationToken).ConfigureAwait(false);
var dto = await response.Content.ReadFromJsonAsync<ManualSyncResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
if (dto != null)
{
FileLogger.Info(
"ClientApi",
$"POST {path} completed. Success={dto.Success}, Message={dto.Message}, Details={dto.Details}");
return dto;
}
if (!response.IsSuccessStatusCode)
{
var err = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var fail = new ManualSyncResponse
{
Success = false,
Message = $"Backend error ({(int)response.StatusCode}): {Truncate(err, 200)}"
};
FileLogger.Warn("ClientApi", $"POST {path} failed. {fail.Message}");
return fail;
}
FileLogger.Warn("ClientApi", $"POST {path} failed. Invalid response from backend.");
return new ManualSyncResponse { Success = false, Message = "Invalid response from backend." };
}
catch (Exception ex)
{
FileLogger.Error("ClientApi", $"POST {path} failed with exception.", ex);
Logger.Log(ex, $"CanteenBackendApiClient.PostJobAsync{path}");
return new ManualSyncResponse
{
Success = false,
Message = "Cannot reach backend: " + ex.Message
};
}
}
private async Task<int> GetCountAsync(string path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(BaseUrl)) return 0;
try
{
var url = $"{BaseUrl}{path}";
var wrap = await _http.GetFromJsonAsync<StatsDto>(url, JsonOptions, cancellationToken).ConfigureAwait(false);
return wrap?.Count ?? 0;
}
catch (Exception ex)
{
Logger.Log(ex, $"CanteenBackendApiClient.GetCountAsync{path}");
return 0;
}
}
private TResult GetAsync<TResponse, TResult>(string path, Func<TResponse?, TResult> map)
{
if (string.IsNullOrWhiteSpace(BaseUrl))
return map(default);
try
{
var url = $"{BaseUrl}{path}";
var data = _http.GetFromJsonAsync<TResponse>(url, JsonOptions).GetAwaiter().GetResult();
return map(data);
}
catch (Exception ex)
{
Logger.Log(ex, $"CanteenBackendApiClient.GetAsync{path}");
return map(default);
}
}
public async Task<HealthResponse?> GetHealthAsync(CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(BaseUrl))
return null;
try
{
return await _http.GetFromJsonAsync<HealthResponse>($"{BaseUrl}/api/health", JsonOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendApiClient.GetHealthAsync");
return null;
}
}
public async Task<IReadOnlyList<MealScheduleDto>> GetMealSchedulesAsync(CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(BaseUrl))
return Array.Empty<MealScheduleDto>();
try
{
var list = await _http.GetFromJsonAsync<List<MealScheduleDto>>($"{BaseUrl}/api/meal-schedules", JsonOptions, cancellationToken)
.ConfigureAwait(false);
return list ?? new List<MealScheduleDto>();
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendApiClient.GetMealSchedulesAsync");
return Array.Empty<MealScheduleDto>();
}
}
public async Task<long> CreateMealScheduleAsync(MealScheduleDto schedule, CancellationToken cancellationToken = default)
{
EnsureBaseUrl();
var url = $"{BaseUrl}/api/meal-schedules";
using var response = await _http.PostAsJsonAsync(url, schedule, JsonOptions, cancellationToken).ConfigureAwait(false);
await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);
var created = await response.Content.ReadFromJsonAsync<MealScheduleDto>(JsonOptions, cancellationToken).ConfigureAwait(false);
return created?.Id ?? 0;
}
public async Task UpdateMealScheduleAsync(MealScheduleDto schedule, CancellationToken cancellationToken = default)
{
EnsureBaseUrl();
var url = $"{BaseUrl}/api/meal-schedules/{schedule.Id}";
using var response = await _http.PutAsJsonAsync(url, schedule, JsonOptions, cancellationToken).ConfigureAwait(false);
await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);
}
public async Task DeleteMealScheduleAsync(long id, CancellationToken cancellationToken = default)
{
EnsureBaseUrl();
var url = $"{BaseUrl}/api/meal-schedules/{id}";
using var response = await _http.DeleteAsync(url, cancellationToken).ConfigureAwait(false);
await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);
}
public async Task<string?> GetEmployeeLocationSiteAsync(string employeeId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(BaseUrl) || string.IsNullOrWhiteSpace(employeeId))
return null;
try
{
var url = $"{BaseUrl}/api/employees/location-site?employeeId={Uri.EscapeDataString(employeeId.Trim())}";
var resp = await _http.GetFromJsonAsync<EmployeeLocationSiteResponse>(url, JsonOptions, cancellationToken)
.ConfigureAwait(false);
return string.IsNullOrWhiteSpace(resp?.LocationSiteId) ? null : resp.LocationSiteId.Trim();
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendApiClient.GetEmployeeLocationSiteAsync");
return null;
}
}
private void EnsureBaseUrl()
{
if (string.IsNullOrWhiteSpace(BaseUrl))
throw new InvalidOperationException("Backend URL is not configured.");
}
private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.IsSuccessStatusCode)
return;
var err = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new HttpRequestException($"Backend error ({(int)response.StatusCode}): {Truncate(err, 300)}");
}
private static string Truncate(string s, int max)
{
if (string.IsNullOrEmpty(s) || s.Length <= max) return s;
return s.Substring(0, max) + "…";
}
}

View File

@ -0,0 +1,54 @@
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Server-side backend dependencies: SQLite, RFID processing, HRMS cache sync, production post.
/// Only constructed when <see cref="Models.AppMode.Server"/>.
/// </summary>
public sealed class CanteenBackendServices
{
private readonly SemaphoreSlim _cacheSyncLock = new(1, 1);
private readonly SemaphoreSlim _orderSyncLock = new(1, 1);
public CanteenBackendServices(
RfidService rfid,
IOfflineCacheSyncService offlineCacheSync,
ISyncService productionSync,
IConfigService config)
{
Rfid = rfid;
OfflineCacheSync = offlineCacheSync;
ProductionSync = productionSync;
Config = config;
}
public RfidService Rfid { get; }
public IOfflineCacheSyncService OfflineCacheSync { get; }
public ISyncService ProductionSync { get; }
public IConfigService Config { get; }
public async Task<T> RunCacheSyncExclusiveAsync<T>(Func<Task<T>> action, CancellationToken cancellationToken = default)
{
await _cacheSyncLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await action().ConfigureAwait(false);
}
finally
{
_cacheSyncLock.Release();
}
}
public async Task<T> RunOrderSyncExclusiveAsync<T>(Func<Task<T>> action, CancellationToken cancellationToken = default)
{
await _orderSyncLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await action().ConfigureAwait(false);
}
finally
{
_orderSyncLock.Release();
}
}
}

View File

@ -0,0 +1,204 @@
using System.IO;
using System.Text.Json;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Scanner frontend configuration: backend URL, device/site, admin UI. No HRMS/production credentials.
/// </summary>
public sealed class ClientConfigService : IConfigService
{
private readonly string _configPath;
private ClientAppConfig _config;
public ClientConfigService()
{
DatabasePath.UseClientStorage();
_configPath = DatabasePath.GetConfigPath();
_config = LoadConfig();
}
public string GetSyncApiEndpoint() => string.Empty;
public void SetSyncApiEndpoint(string endpoint) { }
public bool GetSyncServiceEnabled() => false;
public void SetSyncServiceEnabled(bool enabled) { }
public bool GetScannerConnected() => _config.ScannerConnected;
public void SetScannerConnected(bool connected)
{
_config.ScannerConnected = connected;
SaveConfig();
}
public int GetScanIntervalDays() => Math.Clamp(_config.ScanIntervalDays, 0, 365);
public void SetScanIntervalDays(int value) { _config.ScanIntervalDays = Math.Clamp(value, 0, 365); SaveConfig(); }
public int GetScanIntervalHours() => Math.Clamp(_config.ScanIntervalHours, 0, 23);
public void SetScanIntervalHours(int value) { _config.ScanIntervalHours = Math.Clamp(value, 0, 23); SaveConfig(); }
public int GetScanIntervalMinutes() => Math.Clamp(_config.ScanIntervalMinutes, 0, 59);
public void SetScanIntervalMinutes(int value) { _config.ScanIntervalMinutes = Math.Clamp(value, 0, 59); SaveConfig(); }
public int GetScanIntervalSeconds() => Math.Clamp(_config.ScanIntervalSeconds, 0, 59);
public void SetScanIntervalSeconds(int value) { _config.ScanIntervalSeconds = Math.Clamp(value, 0, 59); SaveConfig(); }
public TimeSpan GetScanInterval()
{
var ts = TimeSpan.FromDays(GetScanIntervalDays()) + TimeSpan.FromHours(GetScanIntervalHours())
+ TimeSpan.FromMinutes(GetScanIntervalMinutes()) + TimeSpan.FromSeconds(GetScanIntervalSeconds());
return ts > TimeSpan.Zero ? ts : TimeSpan.FromMinutes(1);
}
public string GetAdminCardId() => _config.AdminCardId ?? "ADMIN";
public void SetAdminCardId(string cardId) { _config.AdminCardId = cardId ?? string.Empty; SaveConfig(); }
public string GetSiteId() => _config.SiteId ?? string.Empty;
public void SetSiteId(string siteId) { _config.SiteId = siteId ?? string.Empty; SaveConfig(); }
public void ApplyLocationSiteIdFromAuth(string? locationSiteId)
{
if (string.IsNullOrWhiteSpace(locationSiteId))
return;
var digits = new string(locationSiteId.Where(char.IsDigit).ToArray());
if (string.IsNullOrEmpty(digits))
return;
_config.SiteId = $"SITE : {digits}";
SaveConfig();
}
public string GetDeviceId()
{
if (!string.IsNullOrWhiteSpace(_config.DeviceId))
return _config.DeviceId.Trim();
_config.DeviceId = ResolveMachineDeviceId();
SaveConfig();
return _config.DeviceId;
}
public void SetDeviceId(string deviceId)
{
_config.DeviceId = string.IsNullOrWhiteSpace(deviceId)
? ResolveMachineDeviceId()
: deviceId.Trim();
SaveConfig();
}
public bool GetRememberAdminCredentials() => _config.RememberAdminCredentials;
public void SetRememberAdminCredentials(bool remember) { _config.RememberAdminCredentials = remember; SaveConfig(); }
public string GetSavedAdminUsername() => _config.SavedAdminUsername ?? string.Empty;
public void SetSavedAdminUsername(string username) { _config.SavedAdminUsername = username ?? string.Empty; SaveConfig(); }
public string GetSavedAdminPassword() => DecryptPassword(_config.SavedAdminPasswordProtected ?? string.Empty);
public void SetSavedAdminPassword(string password) { _config.SavedAdminPasswordProtected = EncryptPassword(password ?? string.Empty); SaveConfig(); }
public string GetMySqlConnectionString() => string.Empty;
public void SetMySqlConnectionString(string connectionString) { }
public string GetHrmsLookupConnectionString() => string.Empty;
public void SetHrmsLookupConnectionString(string connectionString) { }
public DateTime? GetLastEmployeeRfidCacheSyncUtc() => null;
public void SetLastEmployeeRfidCacheSyncUtc(DateTime utc) { }
public DateTime? GetLastMealMenuCacheSyncUtc() => null;
public void SetLastMealMenuCacheSyncUtc(DateTime utc) { }
public AppMode GetAppMode() => AppMode.Client;
public void SetAppMode(AppMode mode) { }
public string GetCentralServerBaseUrl() => GetBackendBaseUrl();
public void SetCentralServerBaseUrl(string url) => SetBackendBaseUrl(url);
public string GetBackendBaseUrl() => (_config.BackendBaseUrl ?? string.Empty).TrimEnd('/');
public void SetBackendBaseUrl(string url)
{
_config.BackendBaseUrl = (url ?? string.Empty).Trim().TrimEnd('/');
SaveConfig();
}
public string GetLocalServerListenUrls() => string.Empty;
public void SetLocalServerListenUrls(string urls) { }
private ClientAppConfig LoadConfig()
{
try
{
if (!File.Exists(_configPath))
return new ClientAppConfig { BackendBaseUrl = "http://localhost:5000" };
var json = File.ReadAllText(_configPath);
return JsonSerializer.Deserialize<ClientAppConfig>(json) ?? new ClientAppConfig();
}
catch
{
return new ClientAppConfig();
}
}
private void SaveConfig()
{
try
{
var json = JsonSerializer.Serialize(_config, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_configPath, json);
}
catch (Exception ex)
{
Logger.Log(ex, "ClientConfigService.SaveConfig");
}
}
private static string ResolveMachineDeviceId()
{
var machineName = Environment.MachineName;
return string.IsNullOrWhiteSpace(machineName)
? Guid.NewGuid().ToString("N")
: machineName.Trim();
}
private static string EncryptPassword(string plain)
{
if (string.IsNullOrEmpty(plain)) return string.Empty;
try
{
var bytes = System.Text.Encoding.UTF8.GetBytes(plain);
var protectedBytes = System.Security.Cryptography.ProtectedData.Protect(bytes, null, System.Security.Cryptography.DataProtectionScope.CurrentUser);
return Convert.ToBase64String(protectedBytes);
}
catch
{
return string.Empty;
}
}
private static string DecryptPassword(string protectedBase64)
{
if (string.IsNullOrEmpty(protectedBase64)) return string.Empty;
try
{
var protectedBytes = Convert.FromBase64String(protectedBase64);
var bytes = System.Security.Cryptography.ProtectedData.Unprotect(protectedBytes, null, System.Security.Cryptography.DataProtectionScope.CurrentUser);
return System.Text.Encoding.UTF8.GetString(bytes);
}
catch
{
return string.Empty;
}
}
private sealed class ClientAppConfig
{
public string? BackendBaseUrl { get; set; }
public bool ScannerConnected { get; set; }
public int ScanIntervalDays { get; set; }
public int ScanIntervalHours { get; set; }
public int ScanIntervalMinutes { get; set; }
public int ScanIntervalSeconds { get; set; } = 5;
public string? AdminCardId { get; set; }
public string? SiteId { get; set; }
public string? DeviceId { get; set; }
public bool RememberAdminCredentials { get; set; }
public string? SavedAdminUsername { get; set; }
public string? SavedAdminPasswordProtected { get; set; }
}
}

View File

@ -0,0 +1,17 @@
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Resolves the effective HRMS/UIND MySQL connection: explicit HRMS lookup string when set,
/// otherwise the production <see cref="IConfigService.GetMySqlConnectionString"/> value.
/// </summary>
public static class ConfigConnectionHelper
{
public static string GetHrmsOrProductionConnectionString(string? hrmsLookupConnectionString, string? mySqlConnectionString)
{
var hrms = (hrmsLookupConnectionString ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(hrms))
return hrms;
return (mySqlConnectionString ?? string.Empty).Trim();
}
}

View File

@ -4,6 +4,7 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
@ -128,23 +129,30 @@ public class ConfigService : IConfigService
public string GetDeviceId()
{
//var id = _config.DeviceId ?? string.Empty;
var id = Environment.MachineName;
if (string.IsNullOrWhiteSpace(id))
{
id = Guid.NewGuid().ToString("N");
_config.DeviceId = id;
SaveConfig();
}
return id;
if (!string.IsNullOrWhiteSpace(_config.DeviceId))
return _config.DeviceId.Trim();
_config.DeviceId = ResolveMachineDeviceId();
SaveConfig();
return _config.DeviceId;
}
public void SetDeviceId(string deviceId)
{
_config.DeviceId = deviceId ?? string.Empty;
_config.DeviceId = string.IsNullOrWhiteSpace(deviceId)
? ResolveMachineDeviceId()
: deviceId.Trim();
SaveConfig();
}
private static string ResolveMachineDeviceId()
{
var machineName = Environment.MachineName;
return string.IsNullOrWhiteSpace(machineName)
? Guid.NewGuid().ToString("N")
: machineName.Trim();
}
public bool GetRememberAdminCredentials() => _config.RememberAdminCredentials;
public void SetRememberAdminCredentials(bool remember)
@ -217,17 +225,10 @@ public class ConfigService : IConfigService
SaveConfig();
}
//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 string GetHrmsLookupConnectionString() =>
ConfigConnectionHelper.GetHrmsOrProductionConnectionString(
_config.HrmsLookupConnectionString,
_config.MySqlConnectionString);
public void SetHrmsLookupConnectionString(string connectionString)
{
@ -235,6 +236,89 @@ public class ConfigService : IConfigService
SaveConfig();
}
public DateTime? GetLastEmployeeRfidCacheSyncUtc() => _config.LastEmployeeRfidCacheSyncUtc;
public void SetLastEmployeeRfidCacheSyncUtc(DateTime utc)
{
_config.LastEmployeeRfidCacheSyncUtc = utc;
SaveConfig();
}
public DateTime? GetLastMealMenuCacheSyncUtc() => _config.LastMealMenuCacheSyncUtc;
public void SetLastMealMenuCacheSyncUtc(DateTime utc)
{
_config.LastMealMenuCacheSyncUtc = utc;
SaveConfig();
}
public AppMode GetAppMode()
{
var raw = (_config.AppMode ?? string.Empty).Trim();
if (raw.Equals("Client", StringComparison.OrdinalIgnoreCase))
return AppMode.Client;
return AppMode.Server;
}
public void SetAppMode(AppMode mode)
{
_config.AppMode = mode == AppMode.Client ? "Client" : "Server";
SaveConfig();
}
public string GetCentralServerBaseUrl() => (_config.CentralServerBaseUrl ?? string.Empty).Trim();
public void SetCentralServerBaseUrl(string url)
{
var v = url?.Trim() ?? string.Empty;
_config.CentralServerBaseUrl = v;
_config.BackendBaseUrl = v;
SaveConfig();
}
public string GetLocalServerListenUrls()
{
var u = (_config.LocalServerListenUrls ?? string.Empty).Trim();
return string.IsNullOrEmpty(u) ? "http://0.0.0.0:5000" : u;
}
public void SetLocalServerListenUrls(string urls)
{
_config.LocalServerListenUrls = urls?.Trim() ?? string.Empty;
SaveConfig();
}
public string GetBackendBaseUrl()
{
if (GetAppMode() == AppMode.Server)
return DeriveLocalhostApiBaseUrl(GetLocalServerListenUrls());
var url = (_config.BackendBaseUrl ?? string.Empty).Trim();
if (!string.IsNullOrEmpty(url))
return url.TrimEnd('/');
return GetCentralServerBaseUrl().TrimEnd('/');
}
private static string DeriveLocalhostApiBaseUrl(string listenUrls)
{
var first = (listenUrls ?? string.Empty)
.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault() ?? "http://0.0.0.0:5000";
first = first.Trim().TrimEnd('/');
if (first.Contains("0.0.0.0", StringComparison.Ordinal))
first = first.Replace("0.0.0.0", "localhost", StringComparison.Ordinal);
if (first.Contains('+'))
first = first.Replace("+", "localhost", StringComparison.Ordinal);
if (!first.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!first.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
first = "http://" + first;
return first;
}
private AppConfig LoadConfig()
{
try
@ -251,6 +335,8 @@ public class ConfigService : IConfigService
var json = File.ReadAllText(_configPath);
var config = JsonSerializer.Deserialize<AppConfig>(json);
config ??= new AppConfig();
if (string.IsNullOrWhiteSpace(config.BackendBaseUrl) && !string.IsNullOrWhiteSpace(config.CentralServerBaseUrl))
config.BackendBaseUrl = config.CentralServerBaseUrl;
MigrateScanIntervalFromSecondsIfNeeded(config);
var changed = EnsureConnectionStringDefaults(config);
if (changed)
@ -392,5 +478,20 @@ public class ConfigService : IConfigService
// Local HRMS MySQL for employee lookup by RFID. Separate from production sync.
public string HrmsLookupConnectionString { get; set; } = string.Empty;
public DateTime? LastEmployeeRfidCacheSyncUtc { get; set; }
public DateTime? LastMealMenuCacheSyncUtc { get; set; }
/// <summary>Server (default) or Client.</summary>
public string AppMode { get; set; } = "Server";
/// <summary>Legacy name; same as BackendBaseUrl for client PCs.</summary>
public string CentralServerBaseUrl { get; set; } = string.Empty;
/// <summary>Client: remote backend URL. Server: optional override (defaults to localhost from ListenUrls).</summary>
public string BackendBaseUrl { get; set; } = string.Empty;
/// <summary>Central PC: API listen URL(s), e.g. http://0.0.0.0:5000</summary>
public string LocalServerListenUrls { get; set; } = "http://0.0.0.0:5000";
}
}

View File

@ -1,24 +1,41 @@
using Microsoft.EntityFrameworkCore;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Resolves current meal session from production (MySQL meal_schedule). Caches schedules per site for 60 seconds.
/// No SQLite; scan validation uses production data only.
/// Resolves current meal session from local SQLite <c>meal_schedule_cache</c>.
/// </summary>
public class DbMealSessionResolver : IMealSessionResolver
{
private readonly IMealScheduleService _mealScheduleService;
public const string OfflineCacheMissingMessage =
"Meal schedule/menu cache not found. Please sync once while online.";
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly TimeSpan _cacheTtl = TimeSpan.FromSeconds(60);
private readonly Dictionary<string, (List<MealSchedule> Schedules, DateTime ExpiryUtc)> _cache = new(StringComparer.OrdinalIgnoreCase);
private readonly object _cacheLock = new();
public DbMealSessionResolver(IMealScheduleService mealScheduleService)
public DbMealSessionResolver(IDbContextFactory<AppDbContext> dbFactory)
{
_mealScheduleService = mealScheduleService;
_dbFactory = dbFactory;
}
public bool IsScheduleCacheAvailable()
{
using var db = _dbFactory.CreateDbContext();
return db.MealScheduleCache.Any();
}
public bool IsMenuCacheAvailable()
{
using var db = _dbFactory.CreateDbContext();
return db.MenuItemCache.Any() && db.LunchMenuWeekCache.Any();
}
public bool IsOfflineMealDataAvailable() => IsScheduleCacheAvailable() && IsMenuCacheAvailable();
private static MealSession MapMealNameToSession(string? mealName)
{
@ -39,49 +56,9 @@ public class DbMealSessionResolver : IMealSessionResolver
};
}
/// <inheritdoc />
//public MealSession GetCurrentSession(DateTime nowLocal, string siteId)
//{
// var normalizedSite = (siteId ?? string.Empty).Trim();
// if (string.IsNullOrEmpty(normalizedSite))
// normalizedSite = "01";
// if (normalizedSite.Length == 1 && char.IsDigit(normalizedSite[0]))
// normalizedSite = normalizedSite.PadLeft(2, '0');
// var schedules = GetSchedulesForSiteCached(normalizedSite);
// var t = nowLocal.TimeOfDay;
// //foreach (var s in schedules)
// //{
// // if (!TryParseTime(s.StartTime, out var start) || !TryParseTime(s.EndTime, out var end))
// // continue;
// // if (t >= start && t < end)
// // return (MealSession)s.MealSession;
// //}
// foreach (var s in schedules)
// {
// if (!TryParseTime(s.StartTime, out var start) || !TryParseTime(s.EndTime, out var end))
// continue;
// if (t >= start && t < end)
// return MapMealNameToSession(s.MealName);
// }
// return MealSession.None;
//}
public ResolvedMealSession? GetCurrentSession(DateTime nowLocal, string siteId)
{
var normalizedSite = (siteId ?? string.Empty).Trim();
if (string.IsNullOrEmpty(normalizedSite))
normalizedSite = "01";
if (normalizedSite.Length == 1 && char.IsDigit(normalizedSite[0]))
normalizedSite = normalizedSite.PadLeft(2, '0');
var normalizedSite = SiteIdHelper.NormalizeSiteId(siteId);
var schedules = GetSchedulesForSiteCached(normalizedSite);
var t = nowLocal.TimeOfDay;
@ -106,7 +83,6 @@ public class DbMealSessionResolver : IMealSessionResolver
return null;
}
private List<MealSchedule> GetSchedulesForSiteCached(string siteId)
{
lock (_cacheLock)
@ -115,7 +91,7 @@ public class DbMealSessionResolver : IMealSessionResolver
return entry.Schedules;
}
var list = _mealScheduleService.GetActiveSchedulesForSite(siteId).ToList();
var list = LoadSchedulesForSiteFromCache(siteId);
lock (_cacheLock)
{
@ -125,6 +101,35 @@ public class DbMealSessionResolver : IMealSessionResolver
return list;
}
private List<MealSchedule> LoadSchedulesForSiteFromCache(string siteId)
{
var siteIdInt = SiteIdHelper.ToInt(siteId);
using var db = _dbFactory.CreateDbContext();
var rows = db.MealScheduleCache
.AsNoTracking()
.Where(x => x.LocationSiteId == siteIdInt)
.OrderBy(x => x.StartTime)
.ToList();
return rows.Select(MapToMealSchedule).ToList();
}
private static MealSchedule MapToMealSchedule(MealScheduleCache row)
{
return new MealSchedule
{
Id = row.HrmsId,
MealName = row.MealName,
LocationSiteId = SiteIdHelper.ToDisplayString(row.LocationSiteId),
StartTime = row.StartTime,
EndTime = row.EndTime,
CreatedAt = row.CreatedAt ?? DateTime.MinValue,
UpdatedAt = row.UpdatedAt ?? DateTime.MinValue,
IsActive = true
};
}
private static bool TryParseTime(string value, out TimeSpan time)
{
time = TimeSpan.Zero;

View File

@ -1,124 +1,83 @@
using Microsoft.EntityFrameworkCore;
using MySqlConnector;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Looks up employee from local HRMS MySQL: employee_rfid_tag.manufacturer_serial → employee (parent_document_id = serial_number) → department.
/// Uses IConfigService.GetHrmsLookupConnectionString(); separate from production sync connection.
/// Looks up employee by RFID from local SQLite <c>employee_rfid_tag_cache</c> (synced from production HRMS).
/// Menu authorization and admin site lookup still use HRMS when configured.
/// </summary>
public class EmployeeLookupService : IEmployeeLookupService
{
private readonly IConfigService _configService;
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly IConfigService _configService;
public EmployeeLookupService(IConfigService configService)
{
_configService = configService;
}
public EmployeeLookupService(IDbContextFactory<AppDbContext> dbFactory, IConfigService configService)
{
_dbFactory = dbFactory;
_configService = configService;
}
/// <inheritdoc />
public async Task<HrmsEmployeeInfo?> GetEmployeeByRfidAsync(string rfid, CancellationToken cancellationToken = default)
/// <inheritdoc />
public async Task<HrmsEmployeeInfo?> GetEmployeeByRfidAsync(string rfid, CancellationToken cancellationToken = default)
{
var cardId = rfid?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(cardId))
return null;
{
var connectionString = _configService.GetHrmsLookupConnectionString();
if (string.IsNullOrWhiteSpace(connectionString))
return null;
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT
e.id AS parent_document_id,
e.serial_number AS employee_id,
e.concatenated_name AS first_name,
'' AS middle_name,
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,
r.grade_type AS grade_type
FROM employee_rfid_tag r
JOIN employee e ON e.id = r.parent_document_id
LEFT JOIN department d ON d.id = e.department_id
WHERE r.manufacturer_serial = @rfid
AND r.parent_document_type = 'Employee'
LIMIT 1";
var tag = await db.EmployeeRfidTagCache
.AsNoTracking()
.Where(x => x.ManufacturerSerial == cardId)
.Where(x => x.ParentDocumentType == "Employee")
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
await using var conn = new MySqlConnection(connectionString);
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new MySqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@rfid", rfid?.Trim() ?? string.Empty);
if (tag == null)
return null;
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
return null;
return MapToHrmsEmployeeInfo(tag);
}
var parentDocumentId = GetString(reader, 0);
return new HrmsEmployeeInfo
{
ParentDocumentId = parentDocumentId,
EmployeeId = GetString(reader, 1),
FirstName = GetString(reader, 2),
MiddleName = GetString(reader, 3),
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),
GradeType = GetString(reader, 12)
};
}
/// <inheritdoc />
public async Task<string?> GetLocationSiteIdByEmployeeSerialAsync(string employeeSerial, CancellationToken cancellationToken = default)
{
var serial = employeeSerial?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(serial))
return null;
/// <inheritdoc />
public async Task<string?> GetLocationSiteIdByEmployeeSerialAsync(string employeeSerial, CancellationToken cancellationToken = default)
{
var serial = employeeSerial?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(serial))
return null;
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var connectionString = _configService.GetHrmsLookupConnectionString();
if (string.IsNullOrWhiteSpace(connectionString))
return null;
var siteId = await db.EmployeeRfidTagCache
.AsNoTracking()
.Where(x => x.ParentDocumentType == "Employee")
.Where(x => x.EmployeeSerialNumber == serial)
.Where(x => !string.IsNullOrEmpty(x.LocationSiteId))
.Select(x => x.LocationSiteId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
const string sql = @"
SELECT location_site_id
FROM employee
WHERE serial_number = @serial
LIMIT 1";
if (!string.IsNullOrWhiteSpace(siteId))
return siteId.Trim();
await using var conn = new MySqlConnection(connectionString);
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new MySqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@serial", serial);
return await GetLocationSiteIdFromHrmsAsync(serial, cancellationToken).ConfigureAwait(false);
}
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
return null;
/// <inheritdoc />
public async Task<bool> IsMenuItemAuthorizedForRfidAsync(string rfid, int menuItemId, CancellationToken cancellationToken = default)
{
var card = rfid?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(card) || menuItemId <= 0)
return false;
if (reader.IsDBNull(0))
return null;
var connectionString = _configService.GetHrmsLookupConnectionString();
if (string.IsNullOrWhiteSpace(connectionString))
return false;
var raw = reader.GetValue(0)?.ToString()?.Trim();
return string.IsNullOrEmpty(raw) ? null : raw;
}
/// <inheritdoc />
public async Task<bool> IsMenuItemAuthorizedForRfidAsync(string rfid, int menuItemId, CancellationToken cancellationToken = default)
{
var card = rfid?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(card) || menuItemId <= 0)
return false;
var connectionString = _configService.GetHrmsLookupConnectionString();
if (string.IsNullOrWhiteSpace(connectionString))
return false;
const string sql = @"
const string sql = @"
SELECT 1
FROM employee_rfid_tag r
JOIN employee_menu_item_tag em ON em.employee_rfid_tag_id = r.id
@ -126,52 +85,61 @@ public class EmployeeLookupService : IEmployeeLookupService
AND em.item_id = @itemId
LIMIT 1";
await using var conn = new MySqlConnection(connectionString);
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new MySqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@rfid", card);
cmd.Parameters.AddWithValue("@itemId", menuItemId);
await using var conn = new MySqlConnection(connectionString);
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new MySqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@rfid", card);
cmd.Parameters.AddWithValue("@itemId", menuItemId);
var exists = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return exists != null && exists != DBNull.Value;
}
var exists = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return exists != null && exists != DBNull.Value;
}
private static string GetString(MySqlDataReader reader, int ordinal)
private static HrmsEmployeeInfo MapToHrmsEmployeeInfo(EmployeeRfidTagCache tag)
{
return new HrmsEmployeeInfo
{
if (reader.IsDBNull(ordinal)) return string.Empty;
var v = reader.GetValue(ordinal);
return v?.ToString() ?? string.Empty;
}
ParentDocumentId = tag.ParentDocumentId,
EmployeeId = tag.EmployeeSerialNumber,
FirstName = tag.EmployeeConcatenatedName,
MiddleName = string.Empty,
UindSerial = tag.UindSerial,
FunctionId = tag.FunctionId,
DepartmentId = tag.DepartmentId,
TagCreatedAtUtc = tag.DateTimeCreated,
TagCreatedBy = tag.CreatedBy,
DepartmentTitle = tag.DepartmentTitle,
DepartmentType = tag.DepartmentType,
LocationSiteId = tag.LocationSiteId,
GradeType = tag.GradeType
};
}
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 async Task<string?> GetLocationSiteIdFromHrmsAsync(string serial, CancellationToken cancellationToken)
{
var connectionString = _configService.GetHrmsLookupConnectionString();
if (string.IsNullOrWhiteSpace(connectionString))
return null;
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;
}
}
const string sql = @"
SELECT location_site_id
FROM employee
WHERE serial_number = @serial
LIMIT 1";
await using var conn = new MySqlConnection(connectionString);
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new MySqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@serial", serial);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
return null;
if (reader.IsDBNull(0))
return null;
var raw = reader.GetValue(0)?.ToString()?.Trim();
return string.IsNullOrEmpty(raw) ? null : raw;
}
}

View File

@ -0,0 +1,266 @@
using Microsoft.EntityFrameworkCore;
using MySqlConnector;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services.Logging;
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)
{
FileLogger.Info("CacheSync", "Employee RFID sync started.");
var connectionString = _configService.GetHrmsLookupConnectionString();
if (string.IsNullOrWhiteSpace(connectionString))
{
FileLogger.Warn("CacheSync", "Employee RFID sync skipped. MySQL connection string is not configured.");
return new EmployeeRfidTagSyncResult
{
Success = false,
ErrorMessage = "MySQL connection string is not configured."
};
}
List<HrmsRfidTagRow> rows;
try
{
rows = await FetchAllFromHrmsAsync(connectionString, cancellationToken).ConfigureAwait(false);
FileLogger.Info("CacheSync", $"Employee RFID rows fetched from HRMS/UIND. RowsFetched={rows.Count}.");
}
catch (Exception ex)
{
Logger.Log(ex, "EmployeeRfidTagSyncService.SyncAllAsync");
FileLogger.Error("CacheSync", "Employee RFID sync failed while fetching from HRMS/UIND.", ex);
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);
FileLogger.Info(
"CacheSync",
$"Employee RFID sync completed. RowsFetched={rows.Count}, Upserted={upserted}.");
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,20 @@
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Client scanner UI: menu data comes from scan API response, not local SQLite/HRMS.
/// </summary>
public sealed class EmptyMenuLookupService : IMenuLookupService
{
public Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAsync(int siteIdNumeric, CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<HrmsMenuItem>>(Array.Empty<HrmsMenuItem>());
public Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAndDateAsync(
int siteIdNumeric,
DateTime menuDateLocal,
string gradeType,
string mealName,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<HrmsMenuItem>>(Array.Empty<HrmsMenuItem>());
}

View File

@ -0,0 +1,29 @@
using UtopiaCanteen.Shared;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// HTTP client for the central canteen backend API (scanner UI and settings).
/// </summary>
public interface ICanteenBackendApiClient : IRfidService
{
Task<bool> HealthCheckAsync(CancellationToken cancellationToken = default);
Task<ManualSyncResponse> SyncCacheNowAsync(CancellationToken cancellationToken = default);
Task<ManualSyncResponse> SyncOrdersNowAsync(CancellationToken cancellationToken = default);
Task<CacheStatusResponse?> GetCacheStatusAsync(CancellationToken cancellationToken = default);
Task<HealthResponse?> GetHealthAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<MealScheduleDto>> GetMealSchedulesAsync(CancellationToken cancellationToken = default);
Task<long> CreateMealScheduleAsync(MealScheduleDto schedule, CancellationToken cancellationToken = default);
Task UpdateMealScheduleAsync(MealScheduleDto schedule, CancellationToken cancellationToken = default);
Task DeleteMealScheduleAsync(long id, CancellationToken cancellationToken = default);
Task<string?> GetEmployeeLocationSiteAsync(string employeeId, CancellationToken cancellationToken = default);
}

View File

@ -1,3 +1,5 @@
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
@ -42,7 +44,30 @@ public interface IConfigService
string GetMySqlConnectionString();
void SetMySqlConnectionString(string connectionString);
/// <summary>Connection string for local HRMS MySQL (employee lookup by RFID). Separate from production sync.</summary>
/// <summary>
/// MySQL connection for HRMS/UIND (cache sync, employee lookup, lunch_order post).
/// Uses <c>HrmsLookupConnectionString</c> when set; otherwise <see cref="GetMySqlConnectionString"/>.
/// </summary>
string GetHrmsLookupConnectionString();
void SetHrmsLookupConnectionString(string connectionString);
DateTime? GetLastEmployeeRfidCacheSyncUtc();
void SetLastEmployeeRfidCacheSyncUtc(DateTime utc);
DateTime? GetLastMealMenuCacheSyncUtc();
void SetLastMealMenuCacheSyncUtc(DateTime utc);
AppMode GetAppMode();
void SetAppMode(AppMode mode);
/// <summary>Client mode: central server API base URL (e.g. http://192.168.1.10:5000).</summary>
string GetCentralServerBaseUrl();
void SetCentralServerBaseUrl(string url);
/// <summary>Backend API base URL for HTTP calls (client: remote server; server: http://localhost:port).</summary>
string GetBackendBaseUrl();
/// <summary>HttpListener bind URL(s) when <see cref="AppMode.Server"/> (e.g. http://0.0.0.0:5000).</summary>
string GetLocalServerListenUrls();
void SetLocalServerListenUrls(string urls);
}

View File

@ -3,13 +3,13 @@ using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Looks up employee info from local HRMS MySQL by RFID (manufacturer_serial).
/// Uses a separate connection from production sync.
/// Employee lookup by RFID from local SQLite cache; menu authorization may still use HRMS.
/// </summary>
public interface IEmployeeLookupService
{
/// <summary>
/// Finds employee by RFID. Returns null if card is not registered in HRMS.
/// Finds employee by RFID (<c>manufacturer_serial</c>) in local <c>employee_rfid_tag_cache</c>.
/// Returns null if the card is not in the cache (sync from HRMS first).
/// </summary>
Task<HrmsEmployeeInfo?> GetEmployeeByRfidAsync(string rfid, CancellationToken cancellationToken = default);

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

@ -7,9 +7,17 @@ namespace UtopiaCanteenSystem.Services;
/// </summary>
public interface IMealSessionResolver
{
/// <summary>True when local <c>meal_schedule_cache</c> has at least one row.</summary>
bool IsScheduleCacheAvailable();
/// <summary>True when local menu cache tables have data.</summary>
bool IsMenuCacheAvailable();
/// <summary>True when both schedule and menu caches are populated.</summary>
bool IsOfflineMealDataAvailable();
/// <summary>
/// Returns the active meal session for the given local time and site, or MealSession.None if outside all windows.
/// Returns the active meal session for the given local time and site, or null if outside all windows.
/// </summary>
//MealSession GetCurrentSession(DateTime nowLocal, string siteId);
ResolvedMealSession? GetCurrentSession(DateTime nowLocal, string siteId);
}

View File

@ -3,7 +3,7 @@ using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Fetches lunch menu items from local HRMS by site (lunch_menu_week.location_site_id).
/// Fetches lunch menu items from local SQLite cache by site (synced from HRMS).
/// Uses same HRMS connection as employee lookup.
/// </summary>
public interface IMenuLookupService

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

@ -5,6 +5,6 @@ namespace UtopiaCanteenSystem.Services;
/// </summary>
public interface ISyncService
{
/// <summary>Runs one sync: POST unsynced records to API and mark as synced on success.</summary>
Task SyncNowAsync(CancellationToken cancellationToken = default);
/// <summary>Runs one sync: POST unsynced records to production/HRMS and mark as synced on success.</summary>
Task<SyncNowResult> SyncNowAsync(CancellationToken cancellationToken = default);
}

View File

@ -1,53 +1,52 @@
using System;
using System.IO;
using System.Text;
using UtopiaCanteenSystem.Services.Logging;
namespace UtopiaCanteenSystem.Services;
public static class Logger
{
private static readonly object _lock = new();
public static void Log(Exception ex, string context = "")
{
try
{
if (ex == null) return;
if (ex == null)
return;
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var appRoot = Path.Combine(localAppData, "UtopiaCanteenSystem");
var logsDir = Path.Combine(appRoot, "Logs");
var logFile = Path.Combine(logsDir, "error.log");
try
{
if (!Directory.Exists(logsDir))
Directory.CreateDirectory(logsDir);
}
catch
if (FileLogger.IsConfigured)
{
FileLogger.Error(context, ex.Message, ex);
return;
}
var sb = new StringBuilder();
sb.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}]");
if (!string.IsNullOrWhiteSpace(context))
sb.AppendLine($"Context: {context}");
sb.AppendLine($"Message: {ex.Message}");
sb.AppendLine($"StackTrace: {ex.StackTrace}");
if (ex.InnerException != null)
sb.AppendLine($"InnerException: {ex.InnerException.Message}");
sb.AppendLine("----------------------------------------------------");
lock (_lock)
{
File.AppendAllText(logFile, sb.ToString());
}
WriteLegacyErrorLog(ex, context);
}
catch
{
// never throw from logger
}
}
private static void WriteLegacyErrorLog(Exception ex, string context)
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var logsDir = Path.Combine(localAppData, "UtopiaCanteenSystem", "Logs");
var logFile = Path.Combine(logsDir, "error.log");
if (!Directory.Exists(logsDir))
Directory.CreateDirectory(logsDir);
var lines = new List<string>
{
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}]",
string.IsNullOrWhiteSpace(context) ? string.Empty : $"Context: {context}",
$"Message: {ex.Message}",
$"StackTrace: {ex.StackTrace}"
};
if (ex.InnerException != null)
lines.Add($"InnerException: {ex.InnerException.Message}");
lines.Add("----------------------------------------------------");
File.AppendAllText(logFile, string.Join(Environment.NewLine, lines.Where(l => !string.IsNullOrEmpty(l))) + Environment.NewLine);
}
}

View File

@ -0,0 +1,7 @@
namespace UtopiaCanteenSystem.Services.Logging;
public enum FileLogTarget
{
Backend,
Client
}

View File

@ -0,0 +1,99 @@
using System.IO;
using System.Text;
namespace UtopiaCanteenSystem.Services.Logging;
/// <summary>
/// Thread-safe daily file logger. Never throws to callers.
/// </summary>
public static class FileLogger
{
private static readonly object Lock = new();
private static FileLogTarget? _target;
public static bool IsConfigured => _target.HasValue;
public static void ConfigureBackend() => Configure(FileLogTarget.Backend);
public static void ConfigureClient() => Configure(FileLogTarget.Client);
public static void Configure(FileLogTarget target)
{
_target = target;
var dir = GetLogsDirectory();
try
{
Directory.CreateDirectory(dir);
}
catch
{
// Ignore
}
Info("FileLogger", $"Logging initialized. Directory={dir}");
}
public static string GetLogsDirectory() =>
_target == FileLogTarget.Client
? LogPaths.ClientLogsDirectory
: LogPaths.BackendLogsDirectory;
public static void Debug(string component, string message) => Write("DEBUG", component, message, null);
public static void Info(string component, string message) => Write("INFO", component, message, null);
public static void Warn(string component, string message, Exception? ex = null) =>
Write("WARN", component, message, ex);
public static void Error(string component, string message, Exception? ex = null) =>
Write("ERROR", component, message, ex);
private static void Write(string level, string component, string message, Exception? ex)
{
if (!_target.HasValue)
return;
try
{
var dir = GetLogsDirectory();
var prefix = _target == FileLogTarget.Client ? "client" : "backend";
var file = Path.Combine(dir, $"{prefix}-{DateTime.Now:yyyy-MM-dd}.log");
var line = FormatLine(level, component, message, ex);
lock (Lock)
{
Directory.CreateDirectory(dir);
File.AppendAllText(file, line, Encoding.UTF8);
}
}
catch
{
// Never crash the app because logging failed.
}
}
private static string FormatLine(string level, string component, string message, Exception? ex)
{
var sb = new StringBuilder();
sb.Append($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] [{component}] {message}");
if (ex != null)
{
sb.AppendLine();
sb.Append($"Exception: {ex.GetType().Name}: {ex.Message}");
if (!string.IsNullOrWhiteSpace(ex.StackTrace))
{
sb.AppendLine();
sb.Append(ex.StackTrace);
}
if (ex.InnerException != null)
{
sb.AppendLine();
sb.Append($"InnerException: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}");
}
}
sb.AppendLine();
return sb.ToString();
}
}

View File

@ -0,0 +1,60 @@
using Microsoft.Extensions.Logging;
namespace UtopiaCanteenSystem.Services.Logging;
public sealed class FileLoggerProvider : ILoggerProvider
{
public ILogger CreateLogger(string categoryName) => new FileLoggerAdapter(categoryName);
public void Dispose()
{
}
private sealed class FileLoggerAdapter : ILogger
{
private readonly string _category;
public FileLoggerAdapter(string category) => _category = category;
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
return;
var message = formatter(state, exception);
var component = ShortCategory(_category);
switch (logLevel)
{
case LogLevel.Critical:
case LogLevel.Error:
FileLogger.Error(component, message, exception);
break;
case LogLevel.Warning:
FileLogger.Warn(component, message, exception);
break;
case LogLevel.Debug:
case LogLevel.Trace:
FileLogger.Debug(component, message);
break;
default:
FileLogger.Info(component, message);
break;
}
}
private static string ShortCategory(string category)
{
var idx = category.LastIndexOf('.');
return idx >= 0 ? category[(idx + 1)..] : category;
}
}
}

View File

@ -0,0 +1,58 @@
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
namespace UtopiaCanteenSystem.Services.Logging;
/// <summary>
/// Resolves the logged-on Windows user's profile paths when the app runs as a service (SYSTEM).
/// </summary>
public static class InteractiveUserPath
{
public static string? TryGetLocalAppDataPath()
{
var profile = TryGetProfileDirectory();
if (string.IsNullOrWhiteSpace(profile))
return null;
var localAppData = Path.Combine(profile, "AppData", "Local");
return Directory.Exists(localAppData) ? localAppData : null;
}
private static string? TryGetProfileDirectory()
{
var sessionId = WTSGetActiveConsoleSessionId();
if (sessionId == 0xFFFFFFFF)
return null;
if (!WTSQueryUserToken(sessionId, out var userToken) || userToken == IntPtr.Zero)
return null;
try
{
var sb = new StringBuilder(512);
var size = sb.Capacity;
if (!GetUserProfileDirectory(userToken, sb, ref size, out _))
return null;
var path = sb.ToString();
return string.IsNullOrWhiteSpace(path) ? null : path;
}
finally
{
CloseHandle(userToken);
}
}
[DllImport("kernel32.dll")]
private static extern uint WTSGetActiveConsoleSessionId();
[DllImport("wtsapi32.dll", SetLastError = true)]
private static extern bool WTSQueryUserToken(uint sessionId, out IntPtr phToken);
[DllImport("userenv.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool GetUserProfileDirectory(IntPtr hToken, StringBuilder lpProfileDir, ref int lpcchSize, out int lpDwFlags);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr hObject);
}

View File

@ -0,0 +1,29 @@
namespace UtopiaCanteenSystem.Services.Logging;
public static class LogMasking
{
public static string MaskCardId(string? cardId)
{
if (string.IsNullOrWhiteSpace(cardId))
return "(empty)";
var s = cardId.Trim();
if (s.Length <= 4)
return new string('*', s.Length);
var visible = Math.Min(4, s.Length);
return s[..visible] + new string('*', s.Length - visible);
}
public static string MaskConnectionString(string? connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
return "(not configured)";
var s = connectionString.Trim();
if (s.Length <= 20)
return "***";
return s[..12] + "***" + s[^4..];
}
}

View File

@ -0,0 +1,65 @@
using System.IO;
namespace UtopiaCanteenSystem.Services.Logging;
/// <summary>
/// File log directories. Client uses the current user's LocalAppData.
/// Backend (Windows Service) uses the interactive user's LocalAppData when possible
/// so logs appear under the logged-on user's profile instead of systemprofile.
/// </summary>
public static class LogPaths
{
private static string? _backendLogsDirectoryOverride;
private static string? _cachedLocalAppDataRoot;
/// <summary>
/// Optional absolute path (e.g. from appsettings LogsDirectory). Used by the backend service only.
/// </summary>
public static void SetBackendLogsDirectory(string? directory)
{
_backendLogsDirectoryOverride = string.IsNullOrWhiteSpace(directory)
? null
: directory.Trim();
}
/// <summary>
/// Client logs folder: %LocalAppData%\UtopiaCanteenClient\Logs.
/// </summary>
public static string ClientLogsDirectory =>
Path.Combine(ResolveLocalAppDataRoot(), "UtopiaCanteenClient", "Logs");
/// <summary>
/// Backend logs folder: %LocalAppData%\UtopiaCanteenBackend\Logs
/// (interactive user when running as a service).
/// </summary>
public static string BackendLogsDirectory =>
_backendLogsDirectoryOverride ?? Path.Combine(ResolveLocalAppDataRoot(), "UtopiaCanteenBackend", "Logs");
public static string GetBackendLogFilePath(DateTime? date = null)
{
var d = date ?? DateTime.Now;
return Path.Combine(BackendLogsDirectory, $"backend-{d:yyyy-MM-dd}.log");
}
public static string GetClientLogFilePath(DateTime? date = null)
{
var d = date ?? DateTime.Now;
return Path.Combine(ClientLogsDirectory, $"client-{d:yyyy-MM-dd}.log");
}
/// <summary>
/// User's LocalAppData, or SYSTEM profile when no interactive session (service at login screen).
/// </summary>
public static string ResolveLocalAppDataRoot()
{
if (!string.IsNullOrEmpty(_cachedLocalAppDataRoot))
return _cachedLocalAppDataRoot;
var interactive = InteractiveUserPath.TryGetLocalAppDataPath();
if (!string.IsNullOrWhiteSpace(interactive))
return _cachedLocalAppDataRoot = interactive;
return _cachedLocalAppDataRoot =
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
}
}

View File

@ -0,0 +1,452 @@
using Microsoft.EntityFrameworkCore;
using MySqlConnector;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services.Logging;
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)
{
FileLogger.Info("CacheSync", "Meal/menu cache sync started.");
var connectionString = _configService.GetHrmsLookupConnectionString();
if (string.IsNullOrWhiteSpace(connectionString))
{
FileLogger.Warn("CacheSync", "Meal/menu sync skipped. MySQL connection string is not configured.");
return new MealMenuCacheSyncResult
{
Success = false,
ErrorMessage = "MySQL 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);
FileLogger.Info(
"CacheSync",
$"Meal/menu sync completed. MealSchedules={mealScheduleCount}, MenuWeeks={lunchWeekCount}, " +
$"MenuItems={lunchItemCount}, CatalogItems={menuItemCount}.");
return new MealMenuCacheSyncResult
{
Success = true,
MealScheduleCount = mealScheduleCount,
LunchMenuWeekCount = lunchWeekCount,
LunchMenuItemCount = lunchItemCount,
MenuItemCount = menuItemCount
};
}
catch (Exception ex)
{
Logger.Log(ex, "MealMenuCacheSyncService.SyncAllAsync");
FileLogger.Error("CacheSync", $"Meal/menu sync failed. Error={ex.Message}", ex);
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

@ -1,281 +1,75 @@
using MySqlConnector;
using Microsoft.EntityFrameworkCore;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Fetches lunch menu from HRMS: lunch_menu_week (by location_site_id) → lunch_menu_item → menu_item.
/// Uses IConfigService.GetHrmsLookupConnectionString(). Only current week is considered.
/// Fetches lunch menu from local SQLite cache (lunch_menu_week → lunch_menu_item → menu_item).
/// </summary>
//public class MenuLookupService : IMenuLookupService
//{
// private readonly IConfigService _configService;
// public MenuLookupService(IConfigService configService)
// {
// _configService = configService;
// }
// /// <inheritdoc />
// public async Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAsync(int siteIdNumeric, CancellationToken cancellationToken = default)
// {
// // Backwards-compatible: use today's local date.
// return await GetMenuItemsForSiteAndDateAsync(siteIdNumeric, DateTime.Today, cancellationToken).ConfigureAwait(false);
// }
// /// <inheritdoc />
// public async Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAndDateAsync(int siteIdNumeric, DateTime menuDateLocal, CancellationToken cancellationToken = default)
// {
// var connectionString = _configService.GetHrmsLookupConnectionString();
// 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
// mi.id,
// mi.item_name,
// mi.item_type,
// mi.price,
// 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 @menuDate BETWEEN w.week_start_date AND w.week_end_date
// AND li.menu_date = @menuDate
// ORDER BY mi.item_name;";
// //const string sql = @"
// // 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 @menuDate BETWEEN w.week_start_date AND w.week_end_date
// // AND li.menu_date = @menuDate
// // ORDER BY li.meal_name, mi.item_name;";
// await using var conn = new MySqlConnection(connectionString);
// await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
// await using var cmd = new MySqlCommand(sql, conn);
// cmd.Parameters.AddWithValue("@siteId", siteIdNumeric);
// cmd.Parameters.AddWithValue("@menuDate", menuDateLocal.Date);
// var list = new List<HrmsMenuItem>();
// 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),
// MenuDate = GetString(reader, 4),
// DayOfWeek = GetString(reader, 5),
// MealName = string.Empty
// });
// //list.Add(new HrmsMenuItem
// //{
// // MenuItemId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0),
// // ItemName = GetString(reader, 1),
// // ItemType = GetString(reader, 2),
// // Price = GetDecimal(reader, 3),
// // MealName = GetString(reader, 4),
// // MenuDate = GetString(reader, 5),
// // DayOfWeek = GetString(reader, 6),
// //});
// }
// return list;
// }
// private static string GetString(MySqlDataReader reader, int ordinal)
// {
// if (reader.IsDBNull(ordinal)) return string.Empty;
// var v = reader.GetValue(ordinal);
// return v?.ToString() ?? string.Empty;
// }
// 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);
// }
//}
public class MenuLookupService : IMenuLookupService
{
private readonly IConfigService _configService;
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public MenuLookupService(IConfigService configService)
public MenuLookupService(IDbContextFactory<AppDbContext> dbFactory)
{
_configService = configService;
_dbFactory = dbFactory;
}
// Implementing the method for fetching menu items by site
public async Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAsync(int siteIdNumeric, CancellationToken cancellationToken = default)
{
// Backwards-compatible: use today's local date.
return await GetMenuItemsForSiteAndDateAsync(siteIdNumeric, DateTime.Today, "NonManagement","no-active-meal-session", cancellationToken).ConfigureAwait(false);
return await GetMenuItemsForSiteAndDateAsync(
siteIdNumeric,
DateTime.Today,
"NonManagement",
"no-active-meal-session",
cancellationToken).ConfigureAwait(false);
}
// Implementing the method for fetching menu items by site, date, and gradeType
//public async Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAndDateAsync(int siteIdNumeric, DateTime menuDateLocal, string gradeType, CancellationToken cancellationToken = default)
//{
// var connectionString = _configService.GetHrmsLookupConnectionString();
// if (string.IsNullOrWhiteSpace(connectionString))
// return Array.Empty<HrmsMenuItem>();
// const string sql = @"
// SELECT
// mi.id,
// mi.item_name,
// mi.item_type,
// mi.price,
// 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 @menuDate BETWEEN w.week_start_date AND w.week_end_date
// AND li.menu_date = @menuDate
// AND mi.item_for = @itemFor
// ORDER BY mi.item_name;";
// var itemFor = GetItemForFromGradeType(gradeType);
// await using var conn = new MySqlConnection(connectionString);
// await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
// await using var cmd = new MySqlCommand(sql, conn);
// cmd.Parameters.AddWithValue("@siteId", siteIdNumeric);
// cmd.Parameters.AddWithValue("@menuDate", menuDateLocal.Date);
// cmd.Parameters.AddWithValue("@itemFor", itemFor);
// var list = new List<HrmsMenuItem>();
// 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),
// MenuDate = GetString(reader, 4),
// DayOfWeek = GetString(reader, 5),
// MealName = string.Empty
// });
// }
// return list;
//}
public async Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAndDateAsync(
int siteIdNumeric,
DateTime menuDateLocal,
string gradeType,
string mealName, // Add mealName parameter
CancellationToken cancellationToken = default)
int siteIdNumeric,
DateTime menuDateLocal,
string gradeType,
string mealName,
CancellationToken cancellationToken = default)
{
var connectionString = _configService.GetHrmsLookupConnectionString();
if (string.IsNullOrWhiteSpace(connectionString))
if (siteIdNumeric <= 0)
return Array.Empty<HrmsMenuItem>();
// Updated SQL to include meal_name filter and select meal_name
const string sql = @"
SELECT
mi.id,
mi.item_name,
mi.item_type,
mi.price,
li.menu_date,
li.day_of_week,
li.meal_name -- Add meal_name to SELECT
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 @menuDate BETWEEN w.week_start_date AND w.week_end_date
AND li.menu_date = @menuDate
AND li.meal_name = @mealName -- Filter by meal session
AND mi.item_for = @itemFor
ORDER BY mi.item_name;";
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
if (!await db.MenuItemCache.AnyAsync(cancellationToken).ConfigureAwait(false))
return Array.Empty<HrmsMenuItem>();
var menuDate = menuDateLocal.Date;
var itemFor = HrmsMenuItemForMapping.FromGradeType(gradeType);
var meal = mealName?.Trim() ?? string.Empty;
await using var conn = new MySqlConnection(connectionString);
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new MySqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@siteId", siteIdNumeric);
cmd.Parameters.AddWithValue("@menuDate", menuDateLocal.Date);
cmd.Parameters.AddWithValue("@mealName", mealName); // Add mealName parameter
cmd.Parameters.AddWithValue("@itemFor", itemFor);
var list = new List<HrmsMenuItem>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
list.Add(new HrmsMenuItem
var query =
from w in db.LunchMenuWeekCache.AsNoTracking()
join li in db.LunchMenuItemCache.AsNoTracking() on w.HrmsId equals li.LunchMenuWeekHrmsId
join mi in db.MenuItemCache.AsNoTracking() on li.MenuItemHrmsId equals mi.HrmsId
where w.LocationSiteId == siteIdNumeric
&& w.WeekStartDate != null
&& w.WeekEndDate != null
&& menuDate >= w.WeekStartDate.Value.Date
&& menuDate <= w.WeekEndDate.Value.Date
&& li.MenuDate != null
&& li.MenuDate.Value.Date == menuDate
&& li.MealName == meal
&& mi.ItemFor == itemFor
orderby mi.ItemName
select new HrmsMenuItem
{
MenuItemId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0),
ItemName = GetString(reader, 1),
ItemType = GetString(reader, 2),
Price = GetDecimal(reader, 3),
MenuDate = GetString(reader, 4),
DayOfWeek = GetString(reader, 5),
MealName = GetString(reader, 6) // Map meal_name from column index 6
});
}
MenuItemId = (int)mi.HrmsId,
ItemName = mi.ItemName,
ItemType = mi.ItemType,
Price = mi.Price,
MenuDate = li.MenuDate != null ? li.MenuDate.Value.ToString("yyyy-MM-dd") : string.Empty,
DayOfWeek = li.DayOfWeek,
MealName = li.MealName
};
return list;
}
private static string GetString(MySqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal)) return string.Empty;
var v = reader.GetValue(ordinal);
return v?.ToString() ?? string.Empty;
}
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);
return await query.ToListAsync(cancellationToken).ConfigureAwait(false);
}
}

View File

@ -1,5 +1,7 @@
using UtopiaCanteenSystem.ViewModels;
using UtopiaCanteenSystem.Services.Logging;
namespace UtopiaCanteenSystem.Services;
/// <summary>
@ -28,7 +30,7 @@ public class NavigationService : INavigationService
private readonly Func<ScannerDashboardViewModel> _scannerVm;
private readonly Func<MainDashboardViewModel> _dashboardVm;
private readonly Func<AdminSettingsAuthViewModel> _adminSettingsAuthVm;
private readonly Func<SettingsViewModel> _settingsVm;
private readonly Func<object> _settingsVm;
private readonly Func<MealSchedulesViewModel> _mealSchedulesVm;
public NavigationService(
@ -37,7 +39,7 @@ public class NavigationService : INavigationService
Func<ScannerDashboardViewModel> scannerVm,
Func<MainDashboardViewModel> dashboardVm,
Func<AdminSettingsAuthViewModel> adminSettingsAuthVm,
Func<SettingsViewModel> settingsVm,
Func<object> settingsVm,
Func<MealSchedulesViewModel> mealSchedulesVm)
{
_session = session;
@ -105,6 +107,7 @@ public class NavigationService : INavigationService
NavigateToAdminLogin();
return;
}
FileLogger.Info("ClientNav", "Meal Schedules screen opened.");
CurrentViewModel = _mealSchedulesVm();
}

View File

@ -0,0 +1,10 @@
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Client scanners do not call HRMS for employee photos.
/// </summary>
public sealed class NoOpEmployeePhotoService : IEmployeePhotoService
{
public Task<byte[]?> GetPhotoBytesAsync(string parentDocumentId, CancellationToken cancellationToken = default) =>
Task.FromResult<byte[]?>(null);
}

View File

@ -0,0 +1,24 @@
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Meal schedules are owned by the backend; clients do not query HRMS directly.
/// </summary>
public sealed class NoOpMealScheduleService : IMealScheduleService
{
public IReadOnlyList<MealSchedule> GetActiveSchedulesForSite(string siteId) =>
Array.Empty<MealSchedule>();
public Task<IReadOnlyList<MealSchedule>> GetAllSchedulesAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<MealSchedule>>(Array.Empty<MealSchedule>());
public Task<long> CreateAsync(MealSchedule schedule, CancellationToken cancellationToken = default) =>
Task.FromResult(0L);
public Task UpdateAsync(MealSchedule schedule, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
public Task DeleteAsync(long id, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
}

View File

@ -0,0 +1,56 @@
using UtopiaCanteenSystem.Services.Logging;
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)
{
FileLogger.Info("CacheSync", "Full offline cache sync started (employee RFID + meal/menu).");
var employeeResult = await _employeeRfidTagSync.SyncAllAsync(cancellationToken).ConfigureAwait(false);
if (!employeeResult.Success)
{
FileLogger.Error("CacheSync", $"Full cache sync failed at employee RFID step. Error={employeeResult.ErrorMessage}");
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)
{
FileLogger.Error("CacheSync", $"Full cache sync failed at meal/menu step. Error={mealMenuResult.ErrorMessage}");
return new OfflineCacheSyncResult
{
Success = false,
ErrorMessage = mealMenuResult.ErrorMessage ?? "Meal/menu cache sync failed.",
EmployeeSync = employeeResult,
MealMenuSync = mealMenuResult
};
}
FileLogger.Info("CacheSync", "Full offline cache sync completed successfully.");
return new OfflineCacheSyncResult
{
Success = true,
EmployeeSync = employeeResult,
MealMenuSync = mealMenuResult
};
}
}

View File

@ -200,23 +200,46 @@ public class RfidService : IRfidService
/// <summary>Processes a scan using this kiosk's site/device/IP (from config or optional API client context).</summary>
public ScanResult ProcessScanDetailed(string cardId)
{
var ctx = new RfidScanClientContext
{
DeviceId = _configService.GetDeviceId(),
SiteId = _configService.GetSiteId(),
IpAddress = GetLocalIpAddress()
};
return ProcessScanDetailed(cardId, ctx);
}
/// <summary>Central server entry: uses scanner PC site/device/IP so duplicate checks and records are per kiosk.</summary>
public ScanResult ProcessScanDetailed(string cardId, RfidScanClientContext clientContext)
{
if (string.IsNullOrWhiteSpace(cardId))
return new ScanResult(false, "Card ID cannot be empty.", 0);
cardId = cardId.Trim();
var deviceId = string.IsNullOrWhiteSpace(clientContext.DeviceId)
? _configService.GetDeviceId()
: clientContext.DeviceId.Trim();
var siteForKiosk = string.IsNullOrWhiteSpace(clientContext.SiteId)
? _configService.GetSiteId()
: clientContext.SiteId.Trim();
var ipAddress = string.IsNullOrWhiteSpace(clientContext.IpAddress)
? GetLocalIpAddress()
: clientContext.IpAddress.Trim();
// HRMS lookup: reject if card not registered
var employee = _employeeLookup.GetEmployeeByRfidAsync(cardId).GetAwaiter().GetResult();
if (employee == null)
return new ScanResult(false, "Card not registered in HRMS.", 0);
return new ScanResult(false, "Card not registered in local cache.", 0);
var nowUtc = DateTime.UtcNow;
var nowLocal = DateTime.Now;
// SITE VERIFICATION: Check if employee is assigned to this site
var currentSiteId = _configService.GetSiteId();
var currentSiteId = siteForKiosk;
var normalizedCurrentSite = NormalizeSiteId(currentSiteId);
var employeeSiteId = !string.IsNullOrWhiteSpace(employee.LocationSiteId)
@ -262,10 +285,13 @@ public class RfidService : IRfidService
*/
}
// Continue with meal session validation
// Continue with meal session validation (local SQLite cache only)
var siteIdForSession = !string.IsNullOrWhiteSpace(employee.LocationSiteId)
? employee.LocationSiteId.Trim()
: _configService.GetSiteId();
: siteForKiosk;
if (!_mealSessionResolver.IsOfflineMealDataAvailable())
return new ScanResult(false, DbMealSessionResolver.OfflineCacheMissingMessage, 0, employee);
var resolvedSession = _mealSessionResolver.GetCurrentSession(nowLocal, siteIdForSession);
if (resolvedSession == null || resolvedSession.Session == MealSession.None)
@ -277,24 +303,29 @@ public class RfidService : IRfidService
using var db = _dbFactory.CreateDbContext();
// Once-per-session-per-day rule: same card, same session, same local day is not allowed.
var siteId = !string.IsNullOrWhiteSpace(employee.LocationSiteId)
? employee.LocationSiteId.Trim()
: siteForKiosk;
var normalizedSiteForDup = NormalizeSiteId(siteId);
// Once-per-session-per-day rule: same card, same session, same local day, same site is not allowed.
var startOfTodayLocal = DateTime.Today;
var startOfTomorrowLocal = startOfTodayLocal.AddDays(1);
var startUtc = startOfTodayLocal.ToUniversalTime();
var endUtc = startOfTomorrowLocal.ToUniversalTime();
var alreadyScannedThisSessionToday = db.LunchOrderTransactions
var dupCandidates = 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();
.ToList();
var alreadyScannedThisSessionToday = dupCandidates.FirstOrDefault(r =>
string.Equals(NormalizeSiteId(r.SiteId), normalizedSiteForDup, StringComparison.OrdinalIgnoreCase));
if (alreadyScannedThisSessionToday != null)
{
var sessionName = resolvedSession.MealName;
return new ScanResult(false, $"This card is already scanned for {sessionName} today.", 0, employee, session);
}
return new ScanResult(false, "Meal already taken.", 0, employee, session);
var interval = _configService.GetScanInterval();
var timeoutSeconds = (int)Math.Max(1, interval.TotalSeconds);
@ -302,11 +333,14 @@ public class RfidService : IRfidService
var windowStart = nowUtc.AddSeconds(-timeoutSeconds);
// Safety debounce: short cooldown per card to prevent accidental double-tap.
var lastInWindow = db.LunchOrderTransactions
var windowCandidates = db.LunchOrderTransactions
.Where(r => r.CardId == cardId)
.Where(r => r.ScanTime >= windowStart)
.OrderByDescending(r => r.ScanTime)
.FirstOrDefault();
.ToList();
var lastInWindow = windowCandidates.FirstOrDefault(r =>
string.Equals(NormalizeSiteId(r.SiteId), normalizedSiteForDup, StringComparison.OrdinalIgnoreCase));
if (lastInWindow != null)
{
@ -319,19 +353,7 @@ public class RfidService : IRfidService
session);
}
// Production duplicate check by employee serial + date + meal + site.
// Different meal on same day is allowed.
if (int.TryParse(new string(siteIdForSession.Where(char.IsDigit).ToArray()), out var siteNumericForDup) &&
siteNumericForDup > 0 &&
HasProductionMealAlreadyTaken(employee.EmployeeId, siteNumericForDup, mealLabel, nowLocal.Date))
{
return new ScanResult(false, "Meal already taken", 0, employee, session);
}
var fullName = string.Join(" ", new[] { employee.FirstName, employee.MiddleName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
var siteId = !string.IsNullOrWhiteSpace(employee.LocationSiteId)
? employee.LocationSiteId.Trim()
: _configService.GetSiteId();
// Resolve menu items for this scan
var mealItemsDisplay = string.Empty;
@ -346,38 +368,10 @@ public class RfidService : IRfidService
.GetAwaiter()
.GetResult();
var authorized = menuItems
var matching = menuItems
.Where(i => i.MenuItemId > 0)
.Where(i =>
{
try
{
return _employeeLookup
.IsMenuItemAuthorizedForRfidAsync(cardId, i.MenuItemId)
.GetAwaiter()
.GetResult();
}
catch (Exception ex)
{
Logger.Log(ex, $"RfidService.EmployeeMenuValidation card={cardId}, menuItemId={i.MenuItemId}");
return false;
}
})
.ToList();
// Fallback: if employee_menu_item_tag has no matches for this employee/session,
// still allow showing the menu items fetched from HRMS menu_item.
var matching = authorized.Count > 0
? authorized
: menuItems.ToList();
if (authorized.Count == 0)
{
Logger.Log(
new Exception($"No employee_menu_item_tag match; using fallback menu. card={cardId}, site={siteNumeric}, date={nowLocal:yyyy-MM-dd}, meal={mealLabel}, grade={employee.GradeType}"),
"RfidService.ProcessScanDetailed authorization");
}
var names = matching
.Select(i => i.ItemName)
.Where(n => !string.IsNullOrWhiteSpace(n))
@ -402,8 +396,8 @@ public class RfidService : IRfidService
ScanTime = nowUtc,
IsSynced = false,
SiteId = siteId,
DeviceId = _configService.GetDeviceId(),
IpAddress = GetLocalIpAddress(),
DeviceId = deviceId,
IpAddress = ipAddress,
MealSessionCode = sessionCode,
ParentDocumentId = employee.ParentDocumentId ?? string.Empty,
EmployeeId = employee.EmployeeId ?? string.Empty,
@ -425,17 +419,17 @@ public class RfidService : IRfidService
// If we can reach production now, try posting immediately.
// On success (or duplicate already in production), SyncService will mark IsSynced=1.
if (CanReachProduction())
{
try
{
_syncService.SyncNowAsync().GetAwaiter().GetResult();
}
catch (Exception ex)
{
Logger.Log(ex, "RfidService.ProcessScanDetailed immediate sync");
}
}
// if (CanReachProduction())
// {
// try
// {
// _syncService.SyncNowAsync().GetAwaiter().GetResult();
// }
// catch (Exception ex)
// {
// Logger.Log(ex, "RfidService.ProcessScanDetailed immediate sync");
// }
// }
return new ScanResult(true, "Order recorded successfully.", 0, employee, session, normalizedEmployeeSite, normalizedCurrentSite);
}

30
Services/SiteIdHelper.cs Normal file
View File

@ -0,0 +1,30 @@
namespace UtopiaCanteenSystem.Services;
internal static class SiteIdHelper
{
public static int ToInt(string? siteId)
{
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;
}
public static string ToDisplayString(int siteIdInt)
{
if (siteIdInt <= 0)
return "01";
return siteIdInt <= 99 ? siteIdInt.ToString("D2") : siteIdInt.ToString();
}
public static string NormalizeSiteId(string? siteId)
{
var normalized = (siteId ?? string.Empty).Trim();
if (string.IsNullOrEmpty(normalized))
normalized = "01";
if (normalized.Length == 1 && char.IsDigit(normalized[0]))
normalized = normalized.PadLeft(2, '0');
return normalized;
}
}

10
Services/SyncNowResult.cs Normal file
View File

@ -0,0 +1,10 @@
namespace UtopiaCanteenSystem.Services;
public sealed class SyncNowResult
{
public int PendingCount { get; init; }
public int PostedCount { get; init; }
public int DuplicatesSkippedCount { get; init; }
public int FailedCount { get; init; }
public bool SkippedNoConnection { get; init; }
}

View File

@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using MySqlConnector;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services.Logging;
namespace UtopiaCanteenSystem.Services;
@ -20,21 +21,16 @@ public class SyncService : ISyncService
_configService = configService;
}
public async Task SyncNowAsync(CancellationToken cancellationToken = default)
public async Task<SyncNowResult> SyncNowAsync(CancellationToken cancellationToken = default)
{
var productionConnStr = _configService.GetMySqlConnectionString();
if (string.IsNullOrWhiteSpace(productionConnStr))
{
System.Diagnostics.Debug.WriteLine("MySQL connection string (production) not configured; skipping sync.");
return;
FileLogger.Warn("OrderSync", "MySQL connection string is not configured; skipping order sync.");
return new SyncNowResult { SkippedNoConnection = true };
}
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;
}
List<ScanRecord> toSync;
using (var db = _dbFactory.CreateDbContext())
@ -46,11 +42,20 @@ public class SyncService : ISyncService
.ConfigureAwait(false);
}
FileLogger.Info("OrderSync", $"Order sync started. PendingCount={toSync.Count}");
var posted = 0;
var duplicatesSkipped = 0;
var failed = 0;
// Always run day-end cleanup (remove synced rows from previous days), even when there's nothing to sync.
await CleanupOldSyncedRowsAsync(cancellationToken).ConfigureAwait(false);
if (toSync.Count == 0)
return;
{
FileLogger.Info("OrderSync", "Order sync completed. No pending orders.");
return new SyncNowResult();
}
// Production: lunch_order_transactions (GetMySqlConnectionString)
const string insertTxnSql = @"
@ -59,7 +64,7 @@ public class SyncService : ISyncService
VALUES
(@ScanTimeUtc, @SiteId, @DeviceId, @CardId, @IpAddress, UTC_TIMESTAMP(3))";
// HRMS/local: lunch_order (GetHrmsLookupConnectionString)
// HRMS/UIND: lunch_order (HrmsLookup when set, else MySqlConnectionString)
const string existsOrderSql = @"
SELECT id
FROM lunch_order
@ -140,6 +145,7 @@ public class SyncService : ISyncService
if (await IsDuplicateMealInProductionAsync(record, hrmsConnStr, cancellationToken).ConfigureAwait(false))
{
syncedIds.Add(record.Id);
duplicatesSkipped++;
continue;
}
@ -279,11 +285,12 @@ public class SyncService : ISyncService
}
syncedIds.Add(record.Id);
posted++;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Sync record failed (SQLite Id={record.Id}): {ex.Message}");
// Leave unsynced; will retry later.
failed++;
FileLogger.Error("OrderSync", $"UIND/HRMS post failed for SQLite Id={record.Id}, CardId={LogMasking.MaskCardId(record.CardId)}.", ex);
}
}
@ -305,8 +312,21 @@ public class SyncService : ISyncService
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Sync cleanup failed: {ex.Message}");
FileLogger.Warn("OrderSync", "Sync cleanup failed.", ex);
}
var result = new SyncNowResult
{
PendingCount = toSync.Count,
PostedCount = posted,
DuplicatesSkippedCount = duplicatesSkipped,
FailedCount = failed
};
FileLogger.Info(
"OrderSync",
$"Order sync completed. Pending={result.PendingCount}, Posted={result.PostedCount}, " +
$"DuplicatesSkipped={result.DuplicatesSkippedCount}, Failed={result.FailedCount}.");
return result;
}
private static string GenerateLunchOrderCode(long id, DateTime date)

View File

@ -0,0 +1,49 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>UtopiaCanteenSystem</RootNamespace>
<AssemblyName>UtopiaCanteen.Backend</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\UtopiaCanteen.Shared\UtopiaCanteen.Shared.csproj" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="MySqlConnector" Version="2.3.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\Data\**\*.cs" LinkBase="Data" />
<Compile Include="..\Models\**\*.cs" LinkBase="Models" />
<Compile Include="..\Helpers\**\*.cs" LinkBase="Helpers" />
<Compile Include="..\Api\CanteenBackendHost.cs" Link="Api\CanteenBackendHost.cs" />
<Compile Include="..\Api\ApiDtoMapper.cs" Link="Api\ApiDtoMapper.cs" />
<Compile Include="..\Services\**\*.cs" LinkBase="Services" />
<Compile Remove="..\Services\CanteenBackendApiClient.cs" />
<Compile Remove="..\Services\ICanteenBackendApiClient.cs" />
<Compile Remove="..\Services\EmptyMenuLookupService.cs" />
<Compile Remove="..\Services\AuthService.cs" />
<Compile Remove="..\Services\IAuthService.cs" />
<Compile Remove="..\Services\AuthResult.cs" />
<Compile Remove="..\Services\NavigationService.cs" />
<Compile Remove="..\Services\INavigationService.cs" />
<Compile Remove="..\Services\AppSession.cs" />
<Compile Remove="..\Services\AdminAuditService.cs" />
<Compile Remove="..\Services\IAdminAuditService.cs" />
<Compile Remove="..\Services\EmployeePhotoService.cs" />
<Compile Remove="..\Services\IEmployeePhotoService.cs" />
<Compile Remove="..\Services\SampleSyncApiController.cs" />
<Compile Remove="..\Services\ClientConfigService.cs" />
<Compile Remove="..\Services\NoOpEmployeePhotoService.cs" />
<Compile Remove="..\Services\NoOpMealScheduleService.cs" />
<Compile Remove="..\Models\OrderHistoryItem.cs" />
<Compile Remove="..\Services\ConfigService.cs" />
<Compile Remove="..\Services\BackendApiMealScheduleService.cs" />
<Compile Remove="..\Services\AdminLoginHelper.cs" />
<Compile Remove="..\Api\RfidApiDtos.cs" />
<Compile Remove="..\Api\BackendApiDtos.cs" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,211 @@
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services;
namespace UtopiaCanteen.BackendService;
/// <summary>
/// Backend service configuration: appsettings.json + persisted JSON under LocalApplicationData.
/// </summary>
public sealed class BackendHostConfigService : IConfigService
{
private readonly IConfiguration _configuration;
private readonly string _persistedPath;
private PersistedConfig _persisted;
public BackendHostConfigService(IConfiguration configuration)
{
_configuration = configuration;
_persistedPath = Path.Combine(DatabasePath.GetAppDataFolder(), "backend-settings.json");
_persisted = LoadPersisted();
MergeFromAppSettingsIfNeeded();
}
public string GetListenUrls() =>
_configuration["ListenUrls"] ?? "http://0.0.0.0:5000";
public string GetSyncApiEndpoint() => _configuration["SyncApiEndpoint"] ?? _persisted.SyncApiEndpoint ?? string.Empty;
public void SetSyncApiEndpoint(string endpoint) { _persisted.SyncApiEndpoint = endpoint ?? string.Empty; SavePersisted(); }
public bool GetSyncServiceEnabled() =>
_configuration.GetValue("SyncServiceEnabled", _persisted.SyncServiceEnabled);
public void SetSyncServiceEnabled(bool enabled) { _persisted.SyncServiceEnabled = enabled; SavePersisted(); }
public bool GetScannerConnected() => false;
public void SetScannerConnected(bool connected) { }
public int GetScanIntervalDays() => 0;
public void SetScanIntervalDays(int value) { }
public int GetScanIntervalHours() => 0;
public void SetScanIntervalHours(int value) { }
public int GetScanIntervalMinutes() => 1;
public void SetScanIntervalMinutes(int value) { }
public int GetScanIntervalSeconds() => 0;
public void SetScanIntervalSeconds(int value) { }
public TimeSpan GetScanInterval() => TimeSpan.FromMinutes(1);
public string GetAdminCardId() => "ADMIN";
public void SetAdminCardId(string cardId) { }
public string GetSiteId() => _persisted.SiteId ?? "02";
public void SetSiteId(string siteId) { _persisted.SiteId = siteId ?? string.Empty; SavePersisted(); }
public void ApplyLocationSiteIdFromAuth(string? locationSiteId) { }
public string GetDeviceId()
{
if (!string.IsNullOrWhiteSpace(_persisted.DeviceId))
return _persisted.DeviceId.Trim();
_persisted.DeviceId = ResolveMachineDeviceId();
SavePersisted();
return _persisted.DeviceId;
}
public void SetDeviceId(string deviceId)
{
_persisted.DeviceId = string.IsNullOrWhiteSpace(deviceId)
? ResolveMachineDeviceId()
: deviceId.Trim();
SavePersisted();
}
public bool GetRememberAdminCredentials() => false;
public void SetRememberAdminCredentials(bool remember) { }
public string GetSavedAdminUsername() => string.Empty;
public void SetSavedAdminUsername(string username) { }
public string GetSavedAdminPassword() => string.Empty;
public void SetSavedAdminPassword(string password) { }
public string GetMySqlConnectionString() =>
FirstNonEmpty(_configuration["MySqlConnectionString"], _persisted.MySqlConnectionString);
public void SetMySqlConnectionString(string connectionString)
{
_persisted.MySqlConnectionString = connectionString ?? string.Empty;
SavePersisted();
}
public string GetHrmsLookupConnectionString() =>
ConfigConnectionHelper.GetHrmsOrProductionConnectionString(
FirstNonEmpty(_configuration["HrmsLookupConnectionString"], _persisted.HrmsLookupConnectionString),
GetMySqlConnectionString());
public void SetHrmsLookupConnectionString(string connectionString)
{
_persisted.HrmsLookupConnectionString = connectionString ?? string.Empty;
SavePersisted();
}
public DateTime? GetLastEmployeeRfidCacheSyncUtc() => _persisted.LastEmployeeRfidCacheSyncUtc;
public void SetLastEmployeeRfidCacheSyncUtc(DateTime utc)
{
_persisted.LastEmployeeRfidCacheSyncUtc = utc;
SavePersisted();
}
public DateTime? GetLastMealMenuCacheSyncUtc() => _persisted.LastMealMenuCacheSyncUtc;
public void SetLastMealMenuCacheSyncUtc(DateTime utc)
{
_persisted.LastMealMenuCacheSyncUtc = utc;
SavePersisted();
}
public AppMode GetAppMode() => AppMode.Server;
public void SetAppMode(AppMode mode) { }
public string GetCentralServerBaseUrl() => string.Empty;
public void SetCentralServerBaseUrl(string url) { }
public string GetBackendBaseUrl() => DeriveLocalhostApiBaseUrl(GetListenUrls());
public string GetLocalServerListenUrls() => GetListenUrls();
public void SetLocalServerListenUrls(string urls) { }
private static string ResolveMachineDeviceId()
{
var machineName = Environment.MachineName;
return string.IsNullOrWhiteSpace(machineName)
? Guid.NewGuid().ToString("N")
: machineName.Trim();
}
private void MergeFromAppSettingsIfNeeded()
{
var mysql = _configuration["MySqlConnectionString"];
if (!string.IsNullOrWhiteSpace(mysql) && string.IsNullOrWhiteSpace(_persisted.MySqlConnectionString))
_persisted.MySqlConnectionString = mysql;
var hrms = _configuration["HrmsLookupConnectionString"];
if (!string.IsNullOrWhiteSpace(hrms) && string.IsNullOrWhiteSpace(_persisted.HrmsLookupConnectionString))
_persisted.HrmsLookupConnectionString = hrms;
SavePersisted();
}
private static string FirstNonEmpty(params string?[] values)
{
foreach (var v in values)
{
if (!string.IsNullOrWhiteSpace(v))
return v!;
}
return string.Empty;
}
private static string DeriveLocalhostApiBaseUrl(string listenUrls)
{
var first = listenUrls
.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault() ?? "http://0.0.0.0:5000";
first = first.TrimEnd('/');
if (first.Contains("0.0.0.0", StringComparison.Ordinal))
first = first.Replace("0.0.0.0", "localhost", StringComparison.Ordinal);
if (first.Contains('+'))
first = first.Replace("+", "localhost", StringComparison.Ordinal);
return first;
}
private PersistedConfig LoadPersisted()
{
try
{
if (!File.Exists(_persistedPath))
return new PersistedConfig();
var json = File.ReadAllText(_persistedPath);
return JsonSerializer.Deserialize<PersistedConfig>(json) ?? new PersistedConfig();
}
catch
{
return new PersistedConfig();
}
}
private void SavePersisted()
{
try
{
var json = JsonSerializer.Serialize(_persisted, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_persistedPath, json);
}
catch (Exception ex)
{
Logger.Log(ex, "BackendHostConfigService.SavePersisted");
}
}
private sealed class PersistedConfig
{
public string? SyncApiEndpoint { get; set; }
public bool SyncServiceEnabled { get; set; } = true;
public string? MySqlConnectionString { get; set; }
public string? HrmsLookupConnectionString { get; set; }
public DateTime? LastEmployeeRfidCacheSyncUtc { get; set; }
public DateTime? LastMealMenuCacheSyncUtc { get; set; }
public string? SiteId { get; set; }
public string? DeviceId { get; set; }
}
}

View File

@ -0,0 +1,254 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using UtopiaCanteenSystem.Api;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Services;
using UtopiaCanteenSystem.Services.Logging;
namespace UtopiaCanteen.BackendService;
public sealed class CanteenBackendWorker : BackgroundService
{
private readonly IConfigService _config;
private readonly DbContextFactory _dbFactory;
private readonly IConfiguration _configuration;
private readonly ILogger<CanteenBackendWorker> _logger;
private CancellationTokenSource? _apiCts;
private Task? _apiTask;
private System.Timers.Timer? _cacheSyncTimer;
private System.Timers.Timer? _orderSyncTimer;
private int _cacheSyncRunning;
private int _orderSyncRunning;
private CanteenBackendServices? _backend;
public CanteenBackendWorker(
IConfigService config,
DbContextFactory dbFactory,
IConfiguration configuration,
ILogger<CanteenBackendWorker> logger)
{
_config = config;
_dbFactory = dbFactory;
_configuration = configuration;
_logger = logger;
}
public override async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Service starting...");
FileLogger.Info("BackendService", "Service starting...");
try
{
using (var db = _dbFactory.CreateDbContext())
db.EnsureDatabaseCreated();
FileLogger.Info("BackendService", $"Configuration loaded. SQLite DB path={DatabasePath.GetDbPath()}");
FileLogger.Info(
"BackendService",
$"Device ID resolved. MachineName={Environment.MachineName}, DeviceId={_config.GetDeviceId()}");
FileLogger.Info("BackendService", $"ListenUrls={_config.GetLocalServerListenUrls()}");
FileLogger.Info(
"BackendService",
$"MySqlConnectionString={LogMasking.MaskConnectionString(_config.GetMySqlConnectionString())}");
FileLogger.Info(
"BackendService",
$"HrmsConnection(effective)={LogMasking.MaskConnectionString(_config.GetHrmsLookupConnectionString())}");
FileLogger.Info("BackendService", $"Logs directory={LogPaths.BackendLogsDirectory}");
}
catch (Exception ex)
{
FileLogger.Error("BackendService", "Failed during startup initialization.", ex);
throw;
}
var employeeRfidTagSync = new EmployeeRfidTagSyncService(_dbFactory, _config);
var mealMenuCacheSync = new MealMenuCacheSyncService(_dbFactory, _config);
var offlineCacheSync = new OfflineCacheSyncService(employeeRfidTagSync, mealMenuCacheSync);
var employeeLookup = new EmployeeLookupService(_dbFactory, _config);
var menuLookup = new MenuLookupService(_dbFactory);
var mealSessionResolver = new DbMealSessionResolver(_dbFactory);
var productionSync = new SyncService(_dbFactory, _config);
var rfid = new RfidService(_dbFactory, _config, employeeLookup, mealSessionResolver, menuLookup, productionSync);
_backend = new CanteenBackendServices(rfid, offlineCacheSync, productionSync, _config);
_apiCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var listenUrls = _config.GetLocalServerListenUrls();
var backend = _backend;
var cts = _apiCts;
_apiTask = Task.Run(async () =>
{
try
{
await Task.Delay(500, cts.Token).ConfigureAwait(false);
await CanteenBackendHost.RunAsync(backend, listenUrls, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
FileLogger.Info("BackendService", "API host stopped (cancellation).");
}
catch (Exception ex)
{
FileLogger.Error("BackendService", "API host failed.", ex);
Logger.Log(ex, "CanteenBackendWorker.ApiHost");
}
}, CancellationToken.None);
_ = RunStartupCacheSyncAsync(cancellationToken);
var cacheMinutes = Math.Max(1, _configuration.GetValue("CacheSyncIntervalMinutes", 15));
if (!string.IsNullOrWhiteSpace(_config.GetHrmsLookupConnectionString()))
{
_cacheSyncTimer = new System.Timers.Timer(TimeSpan.FromMinutes(cacheMinutes).TotalMilliseconds) { AutoReset = true };
_cacheSyncTimer.Elapsed += async (_, _) => await RunCacheSyncSafeAsync("timer").ConfigureAwait(false);
_cacheSyncTimer.Start();
FileLogger.Info("BackendService", $"Background cache sync timer started. IntervalMinutes={cacheMinutes}");
}
else
{
FileLogger.Warn("BackendService", "Background cache sync timer not started (MySQL/HRMS connection not configured).");
}
var orderMinutes = Math.Max(1, _configuration.GetValue("ProductionSyncIntervalMinutes", 1));
if (_config.GetSyncServiceEnabled() && !string.IsNullOrWhiteSpace(_config.GetMySqlConnectionString()))
{
_orderSyncTimer = new System.Timers.Timer(TimeSpan.FromMinutes(orderMinutes).TotalMilliseconds) { AutoReset = true };
_orderSyncTimer.Elapsed += async (_, _) => await RunOrderSyncSafeAsync("timer").ConfigureAwait(false);
_orderSyncTimer.Start();
FileLogger.Info("BackendService", $"Background production sync timer started. IntervalMinutes={orderMinutes}");
}
else
{
FileLogger.Warn("BackendService", "Background production sync timer not started (disabled or MySQL not configured).");
}
_logger.LogInformation("Service started.");
FileLogger.Info("BackendService", "Service started.");
await base.StartAsync(cancellationToken).ConfigureAwait(false);
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Service stopping...");
FileLogger.Info("BackendService", "Service stopping...");
_cacheSyncTimer?.Stop();
_orderSyncTimer?.Stop();
_apiCts?.Cancel();
if (_apiTask != null)
{
try
{
await _apiTask.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
}
catch
{
// Best-effort
}
}
_cacheSyncTimer?.Dispose();
_orderSyncTimer?.Dispose();
_apiCts?.Dispose();
FileLogger.Info("BackendService", "Service stopped.");
await base.StopAsync(cancellationToken).ConfigureAwait(false);
}
protected override Task ExecuteAsync(CancellationToken stoppingToken) =>
Task.Delay(Timeout.Infinite, stoppingToken);
private async Task RunStartupCacheSyncAsync(CancellationToken cancellationToken)
{
if (_backend == null || string.IsNullOrWhiteSpace(_config.GetHrmsLookupConnectionString()))
{
FileLogger.Info("CacheSync", "Startup cache sync skipped (MySQL/HRMS connection not configured).");
return;
}
FileLogger.Info("CacheSync", "Startup cache sync started.");
for (var i = 0; i < 30 && !cancellationToken.IsCancellationRequested; i++)
{
try
{
await _backend.RunCacheSyncExclusiveAsync(async () =>
{
await _backend.OfflineCacheSync.SyncEmployeeAndMenuCacheAsync().ConfigureAwait(false);
return true;
}).ConfigureAwait(false);
FileLogger.Info("CacheSync", "Startup cache sync completed.");
return;
}
catch (Exception ex)
{
FileLogger.Warn("CacheSync", $"Startup cache sync attempt {i + 1} failed; retrying.", ex);
Logger.Log(ex, "CanteenBackendWorker.RunStartupCacheSyncAsync");
}
await Task.Delay(1000, cancellationToken).ConfigureAwait(false);
}
FileLogger.Error("CacheSync", "Startup cache sync failed after retries.");
}
private async Task RunCacheSyncSafeAsync(string trigger)
{
if (_backend == null)
return;
if (Interlocked.Exchange(ref _cacheSyncRunning, 1) == 1)
{
FileLogger.Info("CacheSync", $"Background cache sync skipped ({trigger}): already running.");
return;
}
FileLogger.Info("CacheSync", $"Background cache sync started ({trigger}).");
try
{
await _backend.RunCacheSyncExclusiveAsync(async () =>
{
await _backend.OfflineCacheSync.SyncEmployeeAndMenuCacheAsync().ConfigureAwait(false);
return true;
}).ConfigureAwait(false);
FileLogger.Info("CacheSync", $"Background cache sync completed ({trigger}).");
}
catch (Exception ex)
{
FileLogger.Error("CacheSync", $"Background cache sync failed ({trigger}).", ex);
Logger.Log(ex, "CanteenBackendWorker.RunCacheSyncSafeAsync");
}
finally
{
Interlocked.Exchange(ref _cacheSyncRunning, 0);
}
}
private async Task RunOrderSyncSafeAsync(string trigger)
{
if (_backend == null)
return;
if (Interlocked.Exchange(ref _orderSyncRunning, 1) == 1)
{
FileLogger.Info("OrderSync", $"Background order sync skipped ({trigger}): already running.");
return;
}
FileLogger.Info("OrderSync", $"Background order sync started ({trigger}).");
try
{
await _backend.RunOrderSyncExclusiveAsync(async () =>
{
await _backend.ProductionSync.SyncNowAsync().ConfigureAwait(false);
return true;
}).ConfigureAwait(false);
FileLogger.Info("OrderSync", $"Background order sync completed ({trigger}).");
}
catch (Exception ex)
{
FileLogger.Error("OrderSync", $"Background order sync failed ({trigger}).", ex);
Logger.Log(ex, "CanteenBackendWorker.RunOrderSyncSafeAsync");
}
finally
{
Interlocked.Exchange(ref _orderSyncRunning, 0);
}
}
}

View File

@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using UtopiaCanteen.BackendService;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Services;
using UtopiaCanteenSystem.Services.Logging;
DatabasePath.UseBackendServiceStorage();
var builder = Host.CreateApplicationBuilder(args);
var logsDirectory = builder.Configuration["LogsDirectory"];
if (!string.IsNullOrWhiteSpace(logsDirectory))
LogPaths.SetBackendLogsDirectory(logsDirectory);
FileLogger.ConfigureBackend();
builder.Logging.ClearProviders();
builder.Logging.AddProvider(new FileLoggerProvider());
builder.Services.AddWindowsService(options =>
{
options.ServiceName = "UtopiaCanteenBackend";
});
builder.Services.AddSingleton<IConfigService, BackendHostConfigService>();
builder.Services.AddSingleton<DbContextFactory>();
builder.Services.AddHostedService<CanteenBackendWorker>();
var host = builder.Build();
await host.RunAsync();

View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>UtopiaCanteen.BackendService</RootNamespace>
<AssemblyName>UtopiaCanteen.BackendService</AssemblyName>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\UtopiaCanteen.Backend\UtopiaCanteen.Backend.csproj" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -0,0 +1,31 @@
<Application x:Class="UtopiaCanteen.Client.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:UtopiaCanteenSystem.ViewModels"
xmlns:clientVm="clr-namespace:UtopiaCanteen.Client.ViewModels"
xmlns:views="clr-namespace:UtopiaCanteenSystem.Views"
xmlns:clientViews="clr-namespace:UtopiaCanteen.Client.Views">
<Application.Resources>
<ResourceDictionary>
<BooleanToVisibilityConverter x:Key="BoolToVisibility" />
<DataTemplate DataType="{x:Type vm:AdminLoginViewModel}">
<views:AdminLoginView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:AdminSettingsAuthViewModel}">
<views:AdminSettingsAuthView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:ScannerDashboardViewModel}">
<views:ScannerDashboardView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:MainDashboardViewModel}">
<views:MainDashboardView />
</DataTemplate>
<DataTemplate DataType="{x:Type clientVm:ClientSettingsViewModel}">
<clientViews:ClientSettingsView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:MealSchedulesViewModel}">
<views:MealSchedulesView />
</DataTemplate>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@ -0,0 +1,77 @@
using System.Net.Http;
using System.Windows;
using Microsoft.EntityFrameworkCore;
using UtopiaCanteenSystem;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Services;
using UtopiaCanteenSystem.Services.Logging;
using UtopiaCanteen.Client.ViewModels;
using UtopiaCanteenSystem.ViewModels;
namespace UtopiaCanteen.Client;
public partial class App : Application
{
private static Mutex _mutex = null!;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
DatabasePath.UseClientStorage();
FileLogger.ConfigureClient();
FileLogger.Info("ClientApp", "Application started.");
bool isNewInstance;
_mutex = new Mutex(true, "UtopiaCanteenClientMutex", out isNewInstance);
if (!isNewInstance)
{
MessageBox.Show("Another instance of the scanner client is already running.", "Warning",
MessageBoxButton.OK, MessageBoxImage.Warning);
Current.Shutdown();
return;
}
var configService = new ClientConfigService();
FileLogger.Info("ClientApp", $"BackendBaseUrl = {configService.GetBackendBaseUrl()}");
FileLogger.Info(
"ClientApp",
$"Device ID resolved. MachineName={Environment.MachineName}, DeviceId={configService.GetDeviceId()}");
FileLogger.Info("ClientApp", $"Logs directory={LogPaths.ClientLogsDirectory}");
var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
var backendApiClient = new CanteenBackendApiClient(httpClient, configService);
var dbFactory = new DbContextFactory();
using (var db = dbFactory.CreateDbContext())
db.EnsureDatabaseCreated();
var employeeLookupService = new EmployeeLookupService(dbFactory, configService);
var menuLookupService = new EmptyMenuLookupService();
var employeePhotoService = new NoOpEmployeePhotoService();
var adminAuditService = new AdminAuditService(dbFactory);
var session = new AppSession();
var authService = new AuthService("https://portal.utopiaindustries.pk/uind/rest/auth/user/");
NavigationService navigationService = null!;
navigationService = new NavigationService(
session,
() => new AdminLoginViewModel(authService, session, navigationService, configService, adminAuditService, employeeLookupService, backendApiClient),
() => new ScannerDashboardViewModel(backendApiClient, navigationService, session, configService, menuLookupService, employeePhotoService),
() => new MainDashboardViewModel(navigationService, backendApiClient, configService, session),
() => new AdminSettingsAuthViewModel(authService, session, navigationService, configService, employeeLookupService, backendApiClient),
() => new ClientSettingsViewModel(configService, navigationService, backendApiClient, session),
() => new MealSchedulesViewModel(new BackendApiMealScheduleService(backendApiClient), navigationService, configService));
var mainWindow = new MainWindow { DataContext = new MainViewModel(navigationService) };
mainWindow.WindowState = WindowState.Maximized;
mainWindow.Show();
}
protected override void OnExit(ExitEventArgs e)
{
_mutex?.ReleaseMutex();
base.OnExit(e);
}
}

View File

@ -0,0 +1,90 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<RootNamespace>UtopiaCanteen.Client</RootNamespace>
<AssemblyName>UtopiaCanteen.Client</AssemblyName>
<ApplicationIcon Condition="Exists('..\assets\favicon.ico')">..\assets\favicon.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\UtopiaCanteen.Shared\UtopiaCanteen.Shared.csproj" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="MySqlConnector" Version="2.3.5" />
</ItemGroup>
<ItemGroup>
<Page Include="..\MainWindow.xaml" Link="MainWindow.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="..\Views\**\*.xaml" LinkBase="Views" Exclude="..\Views\SettingsView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Compile Include="..\MainWindow.xaml.cs" Link="MainWindow.xaml.cs" />
<Compile Include="..\Views\**\*.xaml.cs" LinkBase="Views" Exclude="..\Views\SettingsView.xaml.cs" />
<Compile Include="..\ViewModels\**\*.cs" LinkBase="ViewModels" Exclude="..\ViewModels\SettingsViewModel.cs" />
<Compile Include="..\Converters\**\*.cs" LinkBase="Converters" />
<Compile Include="..\Helpers\**\*.cs" LinkBase="Helpers" />
<Compile Include="..\Models\ScanRecord.cs" Link="Models\ScanRecord.cs" />
<Compile Include="..\Models\HrmsEmployeeInfo.cs" Link="Models\HrmsEmployeeInfo.cs" />
<Compile Include="..\Models\MealSession.cs" Link="Models\MealSession.cs" />
<Compile Include="..\Models\ResolvedMealSession.cs" Link="Models\ResolvedMealSession.cs" />
<Compile Include="..\Models\OrderHistoryItem.cs" Link="Models\OrderHistoryItem.cs" />
<Compile Include="..\Models\AdminLoginRecord.cs" Link="Models\AdminLoginRecord.cs" />
<Compile Include="..\Models\HrmsMenuItem.cs" Link="Models\HrmsMenuItem.cs" />
<Compile Include="..\Models\MealSchedule.cs" Link="Models\MealSchedule.cs" />
<Compile Include="..\Models\Labour.cs" Link="Models\Labour.cs" />
<Compile Include="..\Models\EmployeeRfidTagCache.cs" Link="Models\EmployeeRfidTagCache.cs" />
<Compile Include="..\Models\MealScheduleCache.cs" Link="Models\MealScheduleCache.cs" />
<Compile Include="..\Models\LunchMenuWeekCache.cs" Link="Models\LunchMenuWeekCache.cs" />
<Compile Include="..\Models\LunchMenuItemCache.cs" Link="Models\LunchMenuItemCache.cs" />
<Compile Include="..\Models\MenuItemCache.cs" Link="Models\MenuItemCache.cs" />
<Compile Include="..\Models\AppMode.cs" Link="Models\AppMode.cs" />
<Compile Include="..\Data\AppDbContext.cs" Link="Data\AppDbContext.cs" />
<Compile Include="..\Data\DbContextFactory.cs" Link="Data\DbContextFactory.cs" />
<Compile Include="..\Data\DatabasePath.cs" Link="Data\DatabasePath.cs" />
<Compile Include="..\Services\CanteenBackendApiClient.cs" Link="Services\CanteenBackendApiClient.cs" />
<Compile Include="..\Services\ICanteenBackendApiClient.cs" Link="Services\ICanteenBackendApiClient.cs" />
<Compile Include="..\Services\ClientConfigService.cs" Link="Services\ClientConfigService.cs" />
<Compile Include="..\Services\IConfigService.cs" Link="Services\IConfigService.cs" />
<Compile Include="..\Services\EmptyMenuLookupService.cs" Link="Services\EmptyMenuLookupService.cs" />
<Compile Include="..\Services\IMenuLookupService.cs" Link="Services\IMenuLookupService.cs" />
<Compile Include="..\Services\IRfidService.cs" Link="Services\IRfidService.cs" />
<Compile Include="..\Services\ScanResult.cs" Link="Services\ScanResult.cs" />
<Compile Include="..\Services\Logger.cs" Link="Services\Logger.cs" />
<Compile Include="..\Services\Logging\**\*.cs" LinkBase="Services\Logging" />
<Compile Include="..\Services\SyncNowResult.cs" Link="Services\SyncNowResult.cs" />
<Compile Include="..\Services\NavigationService.cs" Link="Services\NavigationService.cs" />
<Compile Include="..\Services\INavigationService.cs" Link="Services\INavigationService.cs" />
<Compile Include="..\Services\AppSession.cs" Link="Services\AppSession.cs" />
<Compile Include="..\Services\AuthService.cs" Link="Services\AuthService.cs" />
<Compile Include="..\Services\IAuthService.cs" Link="Services\IAuthService.cs" />
<Compile Include="..\Services\AuthResult.cs" Link="Services\AuthResult.cs" />
<Compile Include="..\Services\AdminAuditService.cs" Link="Services\AdminAuditService.cs" />
<Compile Include="..\Services\IAdminAuditService.cs" Link="Services\IAdminAuditService.cs" />
<Compile Include="..\Services\IEmployeeLookupService.cs" Link="Services\IEmployeeLookupService.cs" />
<Compile Include="..\Services\EmployeeLookupService.cs" Link="Services\EmployeeLookupService.cs" />
<Compile Include="..\Services\NoOpEmployeePhotoService.cs" Link="Services\NoOpEmployeePhotoService.cs" />
<Compile Include="..\Services\IEmployeePhotoService.cs" Link="Services\IEmployeePhotoService.cs" />
<Compile Include="..\Services\IMealScheduleService.cs" Link="Services\IMealScheduleService.cs" />
<Compile Include="..\Services\BackendApiMealScheduleService.cs" Link="Services\BackendApiMealScheduleService.cs" />
<Compile Include="..\Services\NoOpMealScheduleService.cs" Link="Services\NoOpMealScheduleService.cs" />
<Compile Include="..\Services\SiteIdHelper.cs" Link="Services\SiteIdHelper.cs" />
<Compile Include="..\Services\AdminLoginHelper.cs" Link="Services\AdminLoginHelper.cs" />
<Compile Include="..\Api\ApiDtoMapper.cs" Link="Api\ApiDtoMapper.cs" />
</ItemGroup>
<ItemGroup>
<Content Include="..\assets\**\*">
<Link>assets\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@ -0,0 +1,384 @@
using System.Diagnostics;
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using UtopiaCanteenSystem.Services;
using UtopiaCanteenSystem.Services.Logging;
namespace UtopiaCanteen.Client.ViewModels;
public partial class ClientSettingsViewModel : ObservableObject
{
public const string BackendUnavailableMessage =
"Backend server unavailable. Please check Backend Server Base URL or server service.";
private readonly IConfigService _configService;
private readonly INavigationService _navigation;
private readonly ICanteenBackendApiClient _backendApi;
private readonly AppSession _session;
[ObservableProperty]
private string _scanIntervalDays = "0";
[ObservableProperty]
private string _scanIntervalHours = "0";
[ObservableProperty]
private string _scanIntervalMinutes = "0";
[ObservableProperty]
private string _scanIntervalSeconds = "5";
[ObservableProperty]
private string _adminCardId = "ADMIN";
[ObservableProperty]
private string _applicationModeDisplay = "Client";
[ObservableProperty]
private string _backendBaseUrl = string.Empty;
[ObservableProperty]
private string _deviceId = string.Empty;
[ObservableProperty]
private string _siteId = string.Empty;
[ObservableProperty]
private string _backendHealthDisplay = "Checking backend...";
[ObservableProperty]
private bool _isBackendOnline;
[ObservableProperty]
private string _saveMessage = string.Empty;
[ObservableProperty]
private bool _isError;
[ObservableProperty]
private bool _isSaving;
[ObservableProperty]
private bool _isPosting;
[ObservableProperty]
private bool _isSyncingCache;
[ObservableProperty]
private string _lastEmployeeRfidCacheSyncDisplay = "Never";
[ObservableProperty]
private string _lastMealMenuCacheSyncDisplay = "Never";
public bool HasBackendUrl => !string.IsNullOrWhiteSpace(BackendBaseUrl?.Trim());
public bool CanPostNow => !IsPosting && HasBackendUrl;
public string PostButtonText => IsPosting ? "Posting..." : "Post Data";
public bool CanSyncCacheNow => !IsSyncingCache && HasBackendUrl;
public string SyncCacheButtonText => IsSyncingCache ? "Syncing..." : "Sync Now";
partial void OnIsPostingChanged(bool value)
{
OnPropertyChanged(nameof(PostButtonText));
OnPropertyChanged(nameof(CanPostNow));
}
partial void OnIsSyncingCacheChanged(bool value)
{
OnPropertyChanged(nameof(SyncCacheButtonText));
OnPropertyChanged(nameof(CanSyncCacheNow));
}
partial void OnBackendBaseUrlChanged(string value)
{
OnPropertyChanged(nameof(HasBackendUrl));
OnPropertyChanged(nameof(CanPostNow));
OnPropertyChanged(nameof(CanSyncCacheNow));
}
public ClientSettingsViewModel(
IConfigService configService,
INavigationService navigation,
ICanteenBackendApiClient backendApi,
AppSession session)
{
_configService = configService;
_navigation = navigation;
_backendApi = backendApi;
_session = session;
LoadFromConfig();
_ = InitializeAsync();
}
public void LoadFromConfig()
{
ScanIntervalDays = _configService.GetScanIntervalDays().ToString();
ScanIntervalHours = _configService.GetScanIntervalHours().ToString();
ScanIntervalMinutes = _configService.GetScanIntervalMinutes().ToString();
ScanIntervalSeconds = _configService.GetScanIntervalSeconds().ToString();
AdminCardId = AdminLoginHelper.ResolveAdminCardIdForDisplay(_configService, _session);
ApplicationModeDisplay = "Client";
BackendBaseUrl = _configService.GetBackendBaseUrl();
DeviceId = _configService.GetDeviceId();
SiteId = AdminLoginHelper.FormatSiteIdForDisplay(_configService.GetSiteId());
OnPropertyChanged(nameof(HasBackendUrl));
}
public async Task InitializeAsync()
{
await RefreshBackendStatusAsync().ConfigureAwait(true);
await RefreshCacheSyncTimestampsAsync().ConfigureAwait(true);
}
public async Task RefreshBackendStatusAsync()
{
if (!HasBackendUrl)
{
IsBackendOnline = false;
BackendHealthDisplay = "Backend URL not configured.";
return;
}
try
{
var health = await _backendApi.GetHealthAsync().ConfigureAwait(true);
if (health != null && string.Equals(health.Status, "ok", StringComparison.OrdinalIgnoreCase))
{
IsBackendOnline = true;
BackendHealthDisplay = $"Backend online ({health.Mode}) — {health.Utc.ToLocalTime():MM/dd/yyyy hh:mm:ss tt}";
}
else
{
IsBackendOnline = false;
BackendHealthDisplay = BackendUnavailableMessage;
}
}
catch (Exception ex)
{
Logger.Log(ex, "ClientSettingsViewModel.RefreshBackendStatusAsync");
IsBackendOnline = false;
BackendHealthDisplay = BackendUnavailableMessage;
}
}
public async Task RefreshCacheSyncTimestampsAsync()
{
if (!HasBackendUrl || !IsBackendOnline)
return;
var status = await _backendApi.GetCacheStatusAsync().ConfigureAwait(true);
if (status == null)
return;
LastEmployeeRfidCacheSyncDisplay = FormatSyncTime(status.LastEmployeeRfidCacheSyncUtc);
LastMealMenuCacheSyncDisplay = FormatSyncTime(status.LastMealMenuCacheSyncUtc);
}
[RelayCommand]
private async Task Save()
{
IsSaving = true;
SaveMessage = string.Empty;
IsError = false;
try
{
if (!int.TryParse(ScanIntervalDays, out var days) || days < 0 || days > 365)
{
SetSaveError("Scan interval Days must be 0365.");
return;
}
if (!int.TryParse(ScanIntervalHours, out var hours) || hours < 0 || hours > 23)
{
SetSaveError("Scan interval Hours must be 023.");
return;
}
if (!int.TryParse(ScanIntervalMinutes, out var minutes) || minutes < 0 || minutes > 59)
{
SetSaveError("Scan interval Minutes must be 059.");
return;
}
if (!int.TryParse(ScanIntervalSeconds, out var seconds) || seconds < 0 || seconds > 59)
{
SetSaveError("Scan interval Seconds must be 059.");
return;
}
if (days == 0 && hours == 0 && minutes == 0 && seconds == 0)
{
SetSaveError("Scan interval cannot be zero. At least 1 second required.");
return;
}
if (string.IsNullOrWhiteSpace(BackendBaseUrl?.Trim()))
{
SetSaveError("Backend Server Base URL is required.");
return;
}
_configService.SetScanIntervalDays(days);
_configService.SetScanIntervalHours(hours);
_configService.SetScanIntervalMinutes(minutes);
_configService.SetScanIntervalSeconds(seconds);
_configService.SetAdminCardId(AdminCardId?.Trim() ?? string.Empty);
_configService.SetCentralServerBaseUrl(BackendBaseUrl.Trim());
_configService.SetDeviceId(DeviceId?.Trim() ?? string.Empty);
DeviceId = _configService.GetDeviceId();
FileLogger.Info(
"ClientSettings",
$"Settings saved. AdminCardId={AdminCardId?.Trim()}, SiteId={SiteId?.Trim()}, DeviceId={DeviceId}");
var site = SiteId?.Trim() ?? string.Empty;
_configService.SetSiteId(
string.IsNullOrEmpty(site)
? string.Empty
: AdminLoginHelper.FormatSiteIdForDisplay(site));
SaveMessage = "Settings saved.";
IsError = false;
await RefreshBackendStatusAsync().ConfigureAwait(true);
}
finally
{
IsSaving = false;
}
}
[RelayCommand]
private void Back() => _navigation.NavigateBackFromSettings(_configService.GetScanInterval());
[RelayCommand]
private void OpenMealSchedules() => _navigation.NavigateToMealSchedules();
[RelayCommand]
private void OpenLogsFolder()
{
try
{
var dir = LogPaths.ClientLogsDirectory;
Directory.CreateDirectory(dir);
Process.Start(new ProcessStartInfo
{
FileName = dir,
UseShellExecute = true
});
FileLogger.Info("ClientSettings", $"Opened logs folder: {dir}");
}
catch (Exception ex)
{
FileLogger.Error("ClientSettings", "Failed to open logs folder.", ex);
SaveMessage = "Could not open logs folder: " + ex.Message;
IsError = true;
}
}
[RelayCommand]
private async Task SyncEmployeeAndMenuCacheNow()
{
if (IsSyncingCache)
return;
SaveMessage = "Syncing...";
IsError = false;
IsSyncingCache = true;
try
{
FileLogger.Info("ClientSettings", "Sync Now clicked.");
if (!await EnsureBackendAvailableAsync().ConfigureAwait(true))
return;
var url = $"{_configService.GetBackendBaseUrl().TrimEnd('/')}/api/cache/sync-now";
FileLogger.Info("ClientSettings", $"Calling POST {url}");
var result = await _backendApi.SyncCacheNowAsync().ConfigureAwait(true);
await RefreshCacheSyncTimestampsAsync().ConfigureAwait(true);
SaveMessage = result.Message;
IsError = !result.Success;
FileLogger.Info(
"ClientSettings",
$"Sync Now response. Success={result.Success}, Message={result.Message}, Details={result.Details}");
if (!string.IsNullOrWhiteSpace(result.Details) && result.Success)
SaveMessage += " " + result.Details;
}
catch (Exception ex)
{
Logger.Log(ex, "ClientSettingsViewModel.SyncEmployeeAndMenuCacheNow");
SaveMessage = "Cache sync failed: " + ex.Message;
IsError = true;
}
finally
{
IsSyncingCache = false;
}
}
[RelayCommand]
private async Task PostDataNow()
{
if (IsPosting)
return;
SaveMessage = string.Empty;
IsError = false;
IsPosting = true;
try
{
FileLogger.Info("ClientSettings", "Post Data clicked.");
if (!await EnsureBackendAvailableAsync().ConfigureAwait(true))
return;
var url = $"{_configService.GetBackendBaseUrl().TrimEnd('/')}/api/orders/sync-now";
FileLogger.Info("ClientSettings", $"Calling POST {url}");
var result = await _backendApi.SyncOrdersNowAsync().ConfigureAwait(true);
SaveMessage = result.Message;
IsError = !result.Success;
FileLogger.Info(
"ClientSettings",
$"Post Data response. Success={result.Success}, Message={result.Message}, Details={result.Details}");
if (!string.IsNullOrWhiteSpace(result.Details) && result.Success)
SaveMessage += " " + result.Details;
}
catch (Exception ex)
{
Logger.Log(ex, "ClientSettingsViewModel.PostDataNow");
SaveMessage = "Failed to post data: " + ex.Message;
IsError = true;
}
finally
{
IsPosting = false;
}
}
private async Task<bool> EnsureBackendAvailableAsync()
{
if (!HasBackendUrl)
{
SaveMessage = "Backend URL is not configured.";
IsError = true;
return false;
}
await RefreshBackendStatusAsync().ConfigureAwait(true);
if (IsBackendOnline)
return true;
SaveMessage = BackendUnavailableMessage;
IsError = true;
FileLogger.Warn("ClientSettings", $"Backend unavailable. Url={_configService.GetBackendBaseUrl()}");
return false;
}
private void SetSaveError(string message)
{
SaveMessage = message;
IsError = true;
}
private static string FormatSyncTime(DateTime? utc) =>
utc == null ? "Never" : utc.Value.ToLocalTime().ToString("MM/dd/yyyy, hh:mm:ss tt");
}

View File

@ -0,0 +1,413 @@
<UserControl x:Class="UtopiaCanteen.Client.Views.ClientSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<SolidColorBrush x:Key="AppBackground" Color="#F0F2F5"/>
<SolidColorBrush x:Key="CardBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="PrimaryText" Color="#2D3748"/>
<SolidColorBrush x:Key="MutedText" Color="#718096"/>
<SolidColorBrush x:Key="PrimaryAccent" Color="#5BA3A0"/>
<SolidColorBrush x:Key="PrimaryHover" Color="#4a918e"/>
<SolidColorBrush x:Key="SuccessBrush" Color="#38A169"/>
<SolidColorBrush x:Key="ErrorBrush" Color="#E53E3E"/>
<SolidColorBrush x:Key="BorderBrush" Color="#E2E8F0"/>
<SolidColorBrush x:Key="OrangeAccent" Color="#ED8936"/>
<SolidColorBrush x:Key="CardHeaderBg" Color="#F7FAFC"/>
<Style x:Key="SectionCardStyle" TargetType="Border">
<Setter Property="Background" Value="{StaticResource CardBackground}"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="24"/>
<Setter Property="Margin" Value="0,0,0,16"/>
<Setter Property="HorizontalAlignment" Value="Stretch"/>
</Style>
<Style x:Key="ModernTextBoxStyle" TargetType="TextBox">
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="MinHeight" Value="48"/>
<Setter Property="Background" Value="White"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="BorderBrush" Value="#E2E8F0"/>
<Setter Property="Foreground" Value="#0A1628"/>
<Setter Property="FontSize" Value="16"/>
<Setter Property="Padding" Value="14,0"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
<Style x:Key="PrimaryButtonStyle" TargetType="Button">
<Setter Property="Background" Value="{StaticResource PrimaryAccent}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="FontSize" Value="15"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="20,10"/>
<Setter Property="MinHeight" Value="42"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<Style x:Key="OutlineButtonStyle" TargetType="Button">
<Setter Property="Background" Value="White"/>
<Setter Property="Foreground" Value="{StaticResource PrimaryText}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="FontSize" Value="15"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="20,10"/>
<Setter Property="MinHeight" Value="42"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<Style x:Key="SyncSubCardStyle" TargetType="Border">
<Setter Property="Background" Value="{StaticResource CardHeaderBg}"/>
<Setter Property="CornerRadius" Value="10"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="20"/>
<Setter Property="HorizontalAlignment" Value="Stretch"/>
</Style>
<Style x:Key="SettingsTabItemStyle" TargetType="TabItem">
<Setter Property="Foreground" Value="{StaticResource MutedText}"/>
<Setter Property="FontSize" Value="15"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="18,10"/>
<Setter Property="Margin" Value="0,0,8,0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TabItem">
<Border x:Name="TabBorder"
Background="Transparent"
BorderBrush="Transparent"
BorderThickness="0,0,0,3"
Padding="{TemplateBinding Padding}">
<ContentPresenter ContentSource="Header"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="TabBorder" Property="Background" Value="#F7FAFC"/>
<Setter Property="Foreground" Value="{StaticResource PrimaryText}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="TabBorder" Property="Background" Value="#E6F4F3"/>
<Setter TargetName="TabBorder" Property="BorderBrush" Value="{StaticResource PrimaryAccent}"/>
<Setter Property="Foreground" Value="{StaticResource PrimaryAccent}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<Grid Background="{StaticResource AppBackground}" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="32,24,32,12">
<Border Width="48" Height="48" Background="#E6F4F3" CornerRadius="24" Margin="0,0,16,0">
<Viewbox Margin="10">
<Canvas Width="24" Height="24">
<Path Data="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11.03L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.26C16.04,5.86 15.48,5.51 14.87,5.23L14.5,2.58C14.46,2.34 14.25,2.17 14,2.17H10C9.75,2.17 9.54,2.34 9.5,2.58L9.13,5.23C8.52,5.51 7.96,5.86 7.44,6.26L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.57,11.03C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.22,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.74C7.96,18.14 8.52,18.49 9.13,18.77L9.5,21.42C9.54,21.66 9.75,21.83 10,21.83H14C14.25,21.83 14.46,21.66 14.5,21.42L14.87,18.77C15.48,18.49 16.04,18.14 16.56,17.74L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"
Fill="{StaticResource PrimaryAccent}" Stretch="Uniform"/>
</Canvas>
</Viewbox>
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Settings" FontSize="32" FontWeight="Bold" Foreground="{StaticResource PrimaryText}"/>
<TextBlock Text="Configure scanning, access, connectivity, and sync settings."
FontSize="15" Foreground="{StaticResource MutedText}" Margin="0,4,0,0"/>
</StackPanel>
</StackPanel>
<TabControl Grid.Row="1"
Margin="32,0,32,0"
Background="Transparent"
BorderThickness="0"
ItemContainerStyle="{StaticResource SettingsTabItemStyle}">
<TabControl.Resources>
<Style TargetType="TabPanel">
<Setter Property="Margin" Value="0,0,0,16"/>
</Style>
</TabControl.Resources>
<TabItem Header="General">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<Border Style="{StaticResource SectionCardStyle}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<Border Width="36" Height="36" Background="#E6F4F3" CornerRadius="18" Margin="0,0,12,0">
<TextBlock Text="&#xE121;" FontFamily="Segoe MDL2 Assets" FontSize="16"
Foreground="{StaticResource PrimaryAccent}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Scan Interval" FontSize="20" FontWeight="Bold" Foreground="{StaticResource PrimaryText}"/>
<TextBlock Text="Set the minimum time between scans to prevent duplicates."
FontSize="14" Foreground="{StaticResource MutedText}"/>
</StackPanel>
</StackPanel>
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/><ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/><ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/><ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="Days (0-365)" FontSize="13" Foreground="{StaticResource MutedText}" Margin="0,0,0,4"/>
<TextBox Text="{Binding ScanIntervalDays, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource ModernTextBoxStyle}"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="Hours (0-23)" FontSize="13" Foreground="{StaticResource MutedText}" Margin="0,0,0,4"/>
<TextBox Text="{Binding ScanIntervalHours, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource ModernTextBoxStyle}"/>
</StackPanel>
<StackPanel Grid.Column="4">
<TextBlock Text="Minutes (0-59)" FontSize="13" Foreground="{StaticResource MutedText}" Margin="0,0,0,4"/>
<TextBox Text="{Binding ScanIntervalMinutes, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource ModernTextBoxStyle}"/>
</StackPanel>
<StackPanel Grid.Column="6">
<TextBlock Text="Seconds (0-59)" FontSize="13" Foreground="{StaticResource MutedText}" Margin="0,0,0,4"/>
<TextBox Text="{Binding ScanIntervalSeconds, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource ModernTextBoxStyle}"/>
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,12,0,0">
<TextBlock Text="&#xE946;" FontFamily="Segoe MDL2 Assets" FontSize="14"
Foreground="{StaticResource MutedText}" Margin="0,2,8,0"/>
<TextBlock Text="The minimum time between scans to prevent duplicates. At least 1 second required."
FontSize="13" Foreground="{StaticResource MutedText}" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel>
</Border>
<Border Style="{StaticResource SectionCardStyle}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<Border Width="36" Height="36" Background="#E6F4F3" CornerRadius="18" Margin="0,0,12,0">
<TextBlock Text="&#xE77B;" FontFamily="Segoe MDL2 Assets" FontSize="16"
Foreground="{StaticResource PrimaryAccent}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="Admin Access" FontSize="20" FontWeight="Bold" Foreground="{StaticResource PrimaryText}" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Text="Admin Employee ID" FontSize="14" FontWeight="SemiBold" Foreground="{StaticResource PrimaryText}"/>
<TextBox Text="{Binding AdminCardId, UpdateSourceTrigger=PropertyChanged}"
IsReadOnly="True"
IsTabStop="False"
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,4" Background="#F8FAFC"/>
<TextBlock Text="Card ID with admin access to settings." FontSize="13" Foreground="{StaticResource MutedText}"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="Mode &amp; Connectivity">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<Border Style="{StaticResource SectionCardStyle}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<Border Width="36" Height="36" Background="#E6F4F3" CornerRadius="18" Margin="0,0,12,0">
<TextBlock Text="&#xE968;" FontFamily="Segoe MDL2 Assets" FontSize="16"
Foreground="{StaticResource PrimaryAccent}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Application Mode &amp; Connectivity" FontSize="20" FontWeight="Bold" Foreground="{StaticResource PrimaryText}"/>
<TextBlock Text="Configure how this application connects to servers."
FontSize="14" Foreground="{StaticResource MutedText}"/>
</StackPanel>
</StackPanel>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="24"/>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="24"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Row="0" Grid.Column="0">
<TextBlock Text="Application Mode" FontSize="14" FontWeight="SemiBold" Foreground="{StaticResource PrimaryText}"/>
<TextBox Text="{Binding ApplicationModeDisplay}" IsReadOnly="True" IsTabStop="False"
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,4" Background="#F8FAFC"/>
<TextBlock Text="Client: this PC sends scans to the central backend server."
FontSize="12" Foreground="{StaticResource MutedText}" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="2">
<TextBlock Text="Backend Server Base URL" FontSize="14" FontWeight="SemiBold" Foreground="{StaticResource PrimaryText}"/>
<TextBox Text="{Binding BackendBaseUrl, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,4"/>
<TextBlock Text="Example: http://192.168.1.10:5000" FontSize="12" Foreground="{StaticResource MutedText}"/>
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="4">
<TextBlock Text="Device ID" FontSize="14" FontWeight="SemiBold" Foreground="{StaticResource PrimaryText}"/>
<TextBox Text="{Binding DeviceId, UpdateSourceTrigger=PropertyChanged}"
IsReadOnly="True"
IsTabStop="False"
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,4" Background="#F8FAFC"/>
<TextBlock Text="Identifies this scanner PC in scan records." FontSize="12" Foreground="{StaticResource MutedText}"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" Margin="0,16,0,0">
<TextBlock Text="Site ID" FontSize="14" FontWeight="SemiBold" Foreground="{StaticResource PrimaryText}"/>
<TextBox Text="{Binding SiteId, UpdateSourceTrigger=PropertyChanged}"
IsReadOnly="True"
IsTabStop="False"
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,4" MaxWidth="480" HorizontalAlignment="Left" Background="#F8FAFC"/>
<TextBlock Text="Canteen location site code sent with each scan." FontSize="12" Foreground="{StaticResource MutedText}"/>
</StackPanel>
</Grid>
<Border Margin="0,16,0,0" Padding="12" CornerRadius="8" Background="#EDF2F7">
<StackPanel Orientation="Horizontal">
<Ellipse Width="10" Height="10" Margin="0,4,10,0" VerticalAlignment="Top">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="{StaticResource ErrorBrush}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsBackendOnline}" Value="True">
<Setter Property="Fill" Value="{StaticResource SuccessBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<TextBlock Text="{Binding BackendHealthDisplay}" FontSize="13" Foreground="{StaticResource PrimaryText}" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
</ScrollViewer>
</TabItem>
<TabItem Header="Data Sync">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<Border Style="{StaticResource SectionCardStyle}" Margin="0,0,0,8">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<Border Width="36" Height="36" Background="#E6F4F3" CornerRadius="18" Margin="0,0,12,0">
<TextBlock Text="&#xE895;" FontFamily="Segoe MDL2 Assets" FontSize="16"
Foreground="{StaticResource PrimaryAccent}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Data Sync" FontSize="20" FontWeight="Bold" Foreground="{StaticResource PrimaryText}"/>
<TextBlock Text="Keep your offline data up to date and send transactions to production."
FontSize="14" Foreground="{StaticResource MutedText}"/>
</StackPanel>
</StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/><ColumnDefinition Width="16"/><ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Style="{StaticResource SyncSubCardStyle}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="&#xE896;" FontFamily="Segoe MDL2 Assets" FontSize="18"
Foreground="{StaticResource PrimaryAccent}" Margin="0,0,8,0"/>
<TextBlock Text="Download Offline Data" FontSize="17" FontWeight="SemiBold" Foreground="{StaticResource PrimaryText}"/>
</StackPanel>
<TextBlock Text="Download employee RFID tags, meal schedules, and menu data for offline scanning."
FontSize="13" Foreground="{StaticResource MutedText}" TextWrapping="Wrap" Margin="0,0,0,16"/>
<Button Content="{Binding SyncCacheButtonText}"
Command="{Binding SyncEmployeeAndMenuCacheNowCommand}"
Style="{StaticResource PrimaryButtonStyle}"
HorizontalAlignment="Left"
IsEnabled="{Binding CanSyncCacheNow}"/>
</StackPanel>
</Border>
<Border Grid.Column="2" Style="{StaticResource SyncSubCardStyle}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="&#xE898;" FontFamily="Segoe MDL2 Assets" FontSize="18"
Foreground="{StaticResource OrangeAccent}" Margin="0,0,8,0"/>
<TextBlock Text="Post Data to Production" FontSize="17" FontWeight="SemiBold" Foreground="{StaticResource PrimaryText}"/>
</StackPanel>
<TextBlock Text="Post captured scan and transaction data to the production server."
FontSize="13" Foreground="{StaticResource MutedText}" TextWrapping="Wrap" Margin="0,0,0,16"/>
<Button Content="{Binding PostButtonText}"
Command="{Binding PostDataNowCommand}"
Style="{StaticResource PrimaryButtonStyle}"
HorizontalAlignment="Left"
IsEnabled="{Binding CanPostNow}"/>
</StackPanel>
</Border>
</Grid>
<TextBlock Text="{Binding LastEmployeeRfidCacheSyncDisplay, StringFormat=Last employee RFID cache sync: {0}}"
FontSize="13" Foreground="{StaticResource MutedText}" Margin="0,16,0,4"/>
<TextBlock Text="{Binding LastMealMenuCacheSyncDisplay, StringFormat=Last meal/menu cache sync: {0}}"
FontSize="13" Foreground="{StaticResource MutedText}"/>
</StackPanel>
</Border>
</ScrollViewer>
</TabItem>
</TabControl>
<Border Grid.Row="2" Padding="14" CornerRadius="8" Margin="32,8,32,12">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#dcfce7"/>
<Setter Property="BorderBrush" Value="#86efac"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SaveMessage}" Value="">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsError}" Value="True">
<Setter Property="Background" Value="#fee2e2"/>
<Setter Property="BorderBrush" Value="#fca5a5"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="{Binding SaveMessage}" FontSize="15" FontWeight="SemiBold" TextWrapping="Wrap">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource SuccessBrush}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsError}" Value="True">
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Border>
<Border Grid.Row="3" Background="#F9FBFB" BorderBrush="{StaticResource BorderBrush}" BorderThickness="0,1,0,0" Padding="32,16">
<Grid HorizontalAlignment="Stretch">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Command="{Binding OpenMealSchedulesCommand}" Style="{StaticResource OutlineButtonStyle}" MinWidth="160" Margin="0,0,12,0">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE787;" FontFamily="Segoe MDL2 Assets" FontSize="14" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="Meal Schedules" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Content="Open Logs Folder" Command="{Binding OpenLogsFolderCommand}"
Style="{StaticResource OutlineButtonStyle}" MinWidth="140" Margin="0,0,12,0"/>
<Button Content="Back" Command="{Binding BackCommand}" Style="{StaticResource OutlineButtonStyle}" MinWidth="100" Margin="0,0,12,0"/>
<Button Command="{Binding SaveCommand}" Style="{StaticResource PrimaryButtonStyle}" MinWidth="100">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE74E;" FontFamily="Segoe MDL2 Assets" FontSize="14" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="Save" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
</Grid>
</Border>
</Grid>
</UserControl>

View File

@ -0,0 +1,19 @@
using System.Windows.Controls;
using UtopiaCanteen.Client.ViewModels;
namespace UtopiaCanteen.Client.Views;
public partial class ClientSettingsView : UserControl
{
public ClientSettingsView()
{
InitializeComponent();
}
public ClientSettingsView(ClientSettingsViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
Loaded += (_, _) => _ = viewModel.InitializeAsync();
}
}

View File

@ -0,0 +1,7 @@
namespace UtopiaCanteen.Shared;
public sealed class CacheStatusResponse
{
public DateTime? LastEmployeeRfidCacheSyncUtc { get; set; }
public DateTime? LastMealMenuCacheSyncUtc { get; set; }
}

View File

@ -0,0 +1,18 @@
namespace UtopiaCanteen.Shared;
public sealed class EmployeeDto
{
public string ParentDocumentId { get; set; } = string.Empty;
public string EmployeeId { get; set; } = string.Empty;
public string UindSerial { get; set; } = string.Empty;
public int FunctionId { get; set; }
public int DepartmentId { get; set; }
public DateTime? TagCreatedAtUtc { get; set; }
public string TagCreatedBy { get; set; } = string.Empty;
public string FirstName { get; set; } = string.Empty;
public string MiddleName { get; set; } = string.Empty;
public string DepartmentTitle { get; set; } = string.Empty;
public string DepartmentType { get; set; } = string.Empty;
public string LocationSiteId { get; set; } = string.Empty;
public string GradeType { get; set; } = string.Empty;
}

View File

@ -0,0 +1,6 @@
namespace UtopiaCanteen.Shared;
public sealed class EmployeeLocationSiteResponse
{
public string? LocationSiteId { get; set; }
}

View File

@ -0,0 +1,8 @@
namespace UtopiaCanteen.Shared;
public sealed class HealthResponse
{
public string Status { get; set; } = "ok";
public string Mode { get; set; } = "server";
public DateTime Utc { get; set; }
}

View File

@ -0,0 +1,10 @@
namespace UtopiaCanteen.Shared;
public sealed class ManualSyncResponse
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public string? Details { get; set; }
}

View File

@ -0,0 +1,14 @@
namespace UtopiaCanteen.Shared;
public sealed class MealScheduleDto
{
public long Id { get; set; }
public string MealName { get; set; } = string.Empty;
public string LocationSiteId { get; set; } = string.Empty;
public int MealSession { get; set; }
public string StartTime { get; set; } = string.Empty;
public string EndTime { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}

View File

@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
namespace UtopiaCanteen.Shared;
public sealed class RecentScanDto
{
public int Id { get; set; }
public string CardId { get; set; } = string.Empty;
public DateTime ScanTime { get; set; }
public bool IsSynced { get; set; }
public string SiteId { get; set; } = string.Empty;
public string DeviceId { get; set; } = string.Empty;
public string IpAddress { get; set; } = string.Empty;
public int MealSessionCode { get; set; }
public string ParentDocumentId { get; set; } = string.Empty;
public string EmployeeId { get; set; } = string.Empty;
public string UindSerial { get; set; } = string.Empty;
public int FunctionId { get; set; }
public int DepartmentId { get; set; }
public DateTime? TagCreatedAtUtc { get; set; }
public string TagCreatedBy { get; set; } = string.Empty;
public string EmployeeName { get; set; } = string.Empty;
public string Department { get; set; } = string.Empty;
public string DepartmentType { get; set; } = string.Empty;
public string MealLabel { get; set; } = string.Empty;
public string MealItems { get; set; } = string.Empty;
public double TotalPrice { get; set; }
[JsonPropertyName("grade_type")]
public string GradeType { get; set; } = string.Empty;
}

View File

@ -0,0 +1,9 @@
namespace UtopiaCanteen.Shared;
public sealed class ScanRequest
{
public string CardId { get; set; } = string.Empty;
public string DeviceId { get; set; } = string.Empty;
public string SiteId { get; set; } = string.Empty;
public string? IpAddress { get; set; }
}

View File

@ -0,0 +1,15 @@
namespace UtopiaCanteen.Shared;
public sealed class ScanResponse
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public int CooldownSecondsRemaining { get; set; }
public EmployeeDto? Employee { get; set; }
public int MealSession { get; set; }
public string? EmployeeSiteId { get; set; }
public string? CurrentSiteId { get; set; }
public string? MealLabel { get; set; }
public string? MealItems { get; set; }
public double TotalPrice { get; set; }
}

View File

@ -0,0 +1,6 @@
namespace UtopiaCanteen.Shared;
public sealed class StatsDto
{
public int Count { get; set; }
}

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>UtopiaCanteen.Shared</RootNamespace>
<AssemblyName>UtopiaCanteen.Shared</AssemblyName>
</PropertyGroup>
</Project>

43
UtopiaCanteen.sln Normal file
View File

@ -0,0 +1,43 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UtopiaCanteen.Shared", "UtopiaCanteen.Shared\UtopiaCanteen.Shared.csproj", "{B1B2C3D4-E5F6-7890-ABCD-EF1234567891}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UtopiaCanteen.Backend", "UtopiaCanteen.Backend\UtopiaCanteen.Backend.csproj", "{B1B2C3D4-E5F6-7890-ABCD-EF1234567892}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UtopiaCanteen.BackendService", "UtopiaCanteen.BackendService\UtopiaCanteen.BackendService.csproj", "{B1B2C3D4-E5F6-7890-ABCD-EF1234567893}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UtopiaCanteen.Client", "UtopiaCanteen.Client\UtopiaCanteen.Client.csproj", "{B1B2C3D4-E5F6-7890-ABCD-EF1234567894}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UtopiaCanteenSystem", "UtopiaCanteenSystem.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{B1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|Any CPU.Build.0 = Release|Any CPU
{B1B2C3D4-E5F6-7890-ABCD-EF1234567892}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B1B2C3D4-E5F6-7890-ABCD-EF1234567892}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B1B2C3D4-E5F6-7890-ABCD-EF1234567892}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B1B2C3D4-E5F6-7890-ABCD-EF1234567892}.Release|Any CPU.Build.0 = Release|Any CPU
{B1B2C3D4-E5F6-7890-ABCD-EF1234567893}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B1B2C3D4-E5F6-7890-ABCD-EF1234567893}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B1B2C3D4-E5F6-7890-ABCD-EF1234567893}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B1B2C3D4-E5F6-7890-ABCD-EF1234567893}.Release|Any CPU.Build.0 = Release|Any CPU
{B1B2C3D4-E5F6-7890-ABCD-EF1234567894}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B1B2C3D4-E5F6-7890-ABCD-EF1234567894}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B1B2C3D4-E5F6-7890-ABCD-EF1234567894}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B1B2C3D4-E5F6-7890-ABCD-EF1234567894}.Release|Any CPU.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -2,6 +2,7 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<EnableDefaultCompileItems>true</EnableDefaultCompileItems>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
@ -13,6 +14,13 @@
</PropertyGroup>
<ItemGroup>
<Compile Remove="UtopiaCanteen.Shared\**" />
<Compile Remove="UtopiaCanteen.Backend\**" />
<Compile Remove="UtopiaCanteen.BackendService\**" />
<Compile Remove="UtopiaCanteen.Client\**" />
<Page Remove="UtopiaCanteen.Client\**" />
<ApplicationDefinition Remove="UtopiaCanteen.Client\**" />
<ProjectReference Include="UtopiaCanteen.Shared\UtopiaCanteen.Shared.csproj" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />

View File

@ -1,6 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using UtopiaCanteenSystem.Services;
using UtopiaCanteenSystem.Services.Logging;
namespace UtopiaCanteenSystem.ViewModels;
@ -12,6 +13,7 @@ public partial class AdminLoginViewModel : ObservableObject
private readonly IConfigService _config;
private readonly IAdminAuditService _adminAudit;
private readonly IEmployeeLookupService _employeeLookup;
private readonly ICanteenBackendApiClient? _backendApi;
[ObservableProperty]
private string _username = string.Empty;
@ -35,7 +37,8 @@ public partial class AdminLoginViewModel : ObservableObject
INavigationService navigation,
IConfigService config,
IAdminAuditService adminAudit,
IEmployeeLookupService employeeLookup)
IEmployeeLookupService employeeLookup,
ICanteenBackendApiClient? backendApi = null)
{
_authService = authService;
_session = session;
@ -43,6 +46,7 @@ public partial class AdminLoginViewModel : ObservableObject
_config = config;
_adminAudit = adminAudit;
_employeeLookup = employeeLookup;
_backendApi = backendApi;
RememberCredentials = _config.GetRememberAdminCredentials();
if (RememberCredentials)
@ -81,20 +85,21 @@ public partial class AdminLoginViewModel : ObservableObject
if (!result.Success)
{
ErrorMessage = "Invalid username or password.";
FileLogger.Warn("ClientLogin", $"Admin login failed for user '{user}'.");
return;
}
_session.SetAdminAuthenticated(user, result.EmployeeId);
try
{
var siteId = await _employeeLookup.GetLocationSiteIdByEmployeeSerialAsync(result.EmployeeId).ConfigureAwait(true);
_config.ApplyLocationSiteIdFromAuth(siteId);
}
catch
{
// HRMS lookup optional; login still succeeds.
}
await AdminLoginHelper.ApplyAdminLoginDefaultsAsync(
result.EmployeeId,
_config,
_employeeLookup,
_backendApi).ConfigureAwait(true);
FileLogger.Info(
"ClientLogin",
$"Admin login succeeded. EmployeeId={result.EmployeeId}, AdminCardId saved, SiteId={_config.GetSiteId()}");
// Persist who logged in (for audit / reporting).
await _adminAudit.RecordLoginAsync(user, result.EmployeeId).ConfigureAwait(false);

View File

@ -11,6 +11,7 @@ public partial class AdminSettingsAuthViewModel : ObservableObject
private readonly INavigationService _navigation;
private readonly IConfigService _config;
private readonly IEmployeeLookupService _employeeLookup;
private readonly ICanteenBackendApiClient? _backendApi;
[ObservableProperty]
private string _username = string.Empty;
@ -33,13 +34,15 @@ public partial class AdminSettingsAuthViewModel : ObservableObject
AppSession session,
INavigationService navigation,
IConfigService config,
IEmployeeLookupService employeeLookup)
IEmployeeLookupService employeeLookup,
ICanteenBackendApiClient? backendApi = null)
{
_authService = authService;
_session = session;
_navigation = navigation;
_config = config;
_employeeLookup = employeeLookup;
_backendApi = backendApi;
RememberCredentials = _config.GetRememberAdminCredentials();
if (RememberCredentials)
@ -90,15 +93,11 @@ public partial class AdminSettingsAuthViewModel : ObservableObject
// Refresh session details (who authenticated for Settings).
_session.SetAdminAuthenticated(user, result.EmployeeId);
try
{
var siteId = await _employeeLookup.GetLocationSiteIdByEmployeeSerialAsync(result.EmployeeId).ConfigureAwait(true);
_config.ApplyLocationSiteIdFromAuth(siteId);
}
catch
{
// HRMS lookup optional; auth still succeeds.
}
await AdminLoginHelper.ApplyAdminLoginDefaultsAsync(
result.EmployeeId,
_config,
_employeeLookup,
_backendApi).ConfigureAwait(true);
// Persist credentials only if user opted in.
_config.SetRememberAdminCredentials(RememberCredentials);

View File

@ -246,6 +246,7 @@ using CommunityToolkit.Mvvm.Input;
using MySqlConnector;
using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services;
using UtopiaCanteenSystem.Services.Logging;
namespace UtopiaCanteenSystem.ViewModels;
@ -289,6 +290,7 @@ public partial class MealSchedulesViewModel : ObservableObject
_navigation = navigation;
_configService = configService;
_currentSiteId = (_configService.GetSiteId() ?? string.Empty).Trim();
FileLogger.Info("MealSchedules", "Meal Schedules view opened; loading schedules from backend.");
_ = LoadSchedulesAsync();
}
@ -352,12 +354,13 @@ public partial class MealSchedulesViewModel : ObservableObject
catch (Exception ex)
{
IsError = true;
FileLogger.Error("MealSchedules", "Could not load schedules from backend.", ex);
var msg = ex is MySqlException mysql
? $"{mysql.Message}{Environment.NewLine}{Environment.NewLine}{mysql.StackTrace}"
: $"{ex.Message}{Environment.NewLine}{Environment.NewLine}{ex.StackTrace}";
Message = "Could not load schedules.";
Message = "Could not load schedules from backend.";
MessageBox.Show(msg, "Meal Schedules Load Error", MessageBoxButton.OK, MessageBoxImage.Error);
}
finally

View File

@ -12,6 +12,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services;
using UtopiaCanteenSystem.Services.Logging;
namespace UtopiaCanteenSystem.ViewModels;
@ -215,22 +216,7 @@ public partial class ScannerDashboardViewModel : ObservableObject
_clockTimer.Tick += (_, _) => CurrentTime = DateTime.Now.ToString("MM/dd/yyyy, hh:mm:ss tt");
_clockTimer.Start();
// Load initial site from config.
var siteId = _configService.GetSiteId();
if (!string.IsNullOrWhiteSpace(siteId))
{
// Extract digits from legacy format "SITE : X"or use value directly if already numeric
if (siteId.StartsWith("SITE : ", StringComparison.OrdinalIgnoreCase))
{
var num = siteId.Substring("SITE : ".Length).Trim();
if (num.Length > 0 && num.All(char.IsDigit))
SiteNumber = num;
}
else if (siteId.All(char.IsDigit))
{
SiteNumber = siteId;
}
}
RefreshSiteFromConfig();
RefreshScannerStatus();
_ = RefreshDashboardAsync();
@ -361,6 +347,13 @@ public partial class ScannerDashboardViewModel : ObservableObject
return localDate.ToString("MMM d");
}
/// <summary>Loads SITE header from config (set on admin login).</summary>
public void RefreshSiteFromConfig()
{
SiteNumber = AdminLoginHelper.ExtractSiteDigits(_configService.GetSiteId());
OnPropertyChanged(nameof(CurrentSiteDisplay));
}
partial void OnSiteNumberChanged(string value)
{
// Numeric only: filter to digits so display stays valid.
@ -578,9 +571,11 @@ public partial class ScannerDashboardViewModel : ObservableObject
if (string.IsNullOrWhiteSpace(cardId))
return;
FileLogger.Info("ClientScan", $"Scan started. CardId={LogMasking.MaskCardId(cardId)}");
var result = _rfidService.ProcessScanDetailed(cardId);
IsSuccess = result.Success;
Message = result.Message;
FileLogger.Info("ClientScan", $"Scan UI message. Success={result.Success}, Message={result.Message}");
// Check if this is a site mismatch error
if (!result.Success && result.Message.Contains("not allowed to scan here"))
@ -933,10 +928,14 @@ public partial class ScannerDashboardViewModel : ObservableObject
private static string BuildCsvFromScanRecords(IReadOnlyList<ScanRecord> records)
{
var sb = new StringBuilder();
sb.AppendLine("Id,CardId,ScanTime,IsSynced,SiteId,DeviceId,IpAddress,EmployeeId,EmployeeName,Department,Meal,Price");
sb.AppendLine("Id,CardId,ScanTime,IsSynced,SiteId,DeviceId,IpAddress,EmployeeId,EmployeeName,Department,Meal Session,Meal,Price");
foreach (var r in records)
{
var localScanTime = r.ScanTime.ToLocalTime();
var mealSession = r.MealLabel ?? string.Empty;
var meal = !string.IsNullOrWhiteSpace(r.MealItems)
? r.MealItems
: mealSession;
sb.Append(r.Id);
sb.Append(',');
sb.Append(ToExcelText(r.CardId));
@ -957,7 +956,9 @@ public partial class ScannerDashboardViewModel : ObservableObject
sb.Append(',');
sb.Append(EscapeCsv(r.Department ?? string.Empty));
sb.Append(',');
sb.Append(EscapeCsv(r.MealItems ?? string.Empty));
sb.Append(EscapeCsv(mealSession));
sb.Append(',');
sb.Append(EscapeCsv(meal));
sb.Append(',');
sb.Append(r.TotalPrice.ToString("0.##", CultureInfo.InvariantCulture));
sb.AppendLine();
@ -1052,4 +1053,3 @@ public partial class ScannerDashboardViewModel : ObservableObject
_debounceTimer.Stop();
}
}

View File

@ -1,18 +1,19 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services;
namespace UtopiaCanteenSystem.ViewModels;
/// <summary>
/// ViewModel for SettingsView: editable API endpoint (UIND sync URL), save, load from config.
/// Settings UI (frontend): persists local kiosk config; cache/production jobs call backend APIs only.
/// </summary>
public partial class SettingsViewModel : ObservableObject
{
private readonly IConfigService _configService;
private readonly INavigationService _navigation;
private readonly IAdminAuditService _adminAudit;
private readonly ISyncService _syncService;
private readonly ICanteenBackendApiClient _backendApi;
[ObservableProperty]
private string _syncApiEndpoint = string.Empty;
@ -44,9 +45,40 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty]
private bool _isPosting;
[ObservableProperty]
private bool _isSyncingCache;
public bool CanPostNow => !IsPosting;
public string PostButtonText => IsPosting ? "Posting..." : "Post Data";
[ObservableProperty]
private string _lastEmployeeRfidCacheSyncDisplay = "Never";
[ObservableProperty]
private string _lastMealMenuCacheSyncDisplay = "Never";
[ObservableProperty]
private string _selectedAppMode = "Server";
[ObservableProperty]
private string _backendBaseUrl = string.Empty;
[ObservableProperty]
private string _localServerListenUrls = "http://0.0.0.0:5000";
public IReadOnlyList<string> AppModeOptions { get; } = new[] { "Server", "Client" };
public bool IsCentralServerMode => string.Equals(SelectedAppMode, "Server", StringComparison.OrdinalIgnoreCase);
public string EffectiveBackendUrlDisplay =>
IsCentralServerMode
? _configService.GetBackendBaseUrl()
: (BackendBaseUrl?.Trim() ?? string.Empty);
public bool HasBackendUrl => !string.IsNullOrWhiteSpace(_configService.GetBackendBaseUrl());
public bool CanPostNow => !IsPosting && HasBackendUrl;
public string PostButtonText => IsPosting ? "Posting..." : "Post Pending Orders";
public bool CanSyncCacheNow => !IsSyncingCache && HasBackendUrl;
public string SyncCacheButtonText => IsSyncingCache ? "Syncing cache..." : "Sync Now";
partial void OnIsPostingChanged(bool value)
{
@ -54,20 +86,41 @@ public partial class SettingsViewModel : ObservableObject
OnPropertyChanged(nameof(CanPostNow));
}
partial void OnIsSyncingCacheChanged(bool value)
{
OnPropertyChanged(nameof(SyncCacheButtonText));
OnPropertyChanged(nameof(CanSyncCacheNow));
}
partial void OnSelectedAppModeChanged(string value)
{
OnPropertyChanged(nameof(IsCentralServerMode));
OnPropertyChanged(nameof(EffectiveBackendUrlDisplay));
OnPropertyChanged(nameof(CanPostNow));
OnPropertyChanged(nameof(CanSyncCacheNow));
}
partial void OnBackendBaseUrlChanged(string value)
{
OnPropertyChanged(nameof(HasBackendUrl));
OnPropertyChanged(nameof(CanPostNow));
OnPropertyChanged(nameof(CanSyncCacheNow));
}
public SettingsViewModel(
IConfigService configService,
INavigationService navigation,
IAdminAuditService adminAudit,
ISyncService syncService)
ICanteenBackendApiClient backendApi)
{
_configService = configService;
_navigation = navigation;
_adminAudit = adminAudit;
_syncService = syncService;
_backendApi = backendApi;
LoadFromConfig();
_ = RefreshCacheSyncTimestampsAsync();
}
/// <summary>Loads config from service.</summary>
public void LoadFromConfig()
{
SyncApiEndpoint = _configService.GetSyncApiEndpoint();
@ -76,12 +129,37 @@ public partial class SettingsViewModel : ObservableObject
ScanIntervalMinutes = _configService.GetScanIntervalMinutes().ToString();
ScanIntervalSeconds = _configService.GetScanIntervalSeconds().ToString();
// Always prefer the latest admin login from the database.
var last = _adminAudit.GetLastLogin();
if (last != null && !string.IsNullOrWhiteSpace(last.EmployeeId))
AdminCardId = last.EmployeeId;
else
AdminCardId = "ADMIN";
AdminCardId = AdminLoginHelper.ResolveAdminCardIdForDisplay(_configService, adminAudit: _adminAudit);
SelectedAppMode = _configService.GetAppMode() == AppMode.Client ? "Client" : "Server";
BackendBaseUrl = string.IsNullOrWhiteSpace(_configService.GetCentralServerBaseUrl())
? _configService.GetBackendBaseUrl()
: _configService.GetCentralServerBaseUrl();
LocalServerListenUrls = _configService.GetLocalServerListenUrls();
OnPropertyChanged(nameof(EffectiveBackendUrlDisplay));
OnPropertyChanged(nameof(HasBackendUrl));
}
public async Task RefreshCacheSyncTimestampsAsync()
{
if (IsCentralServerMode)
{
LastEmployeeRfidCacheSyncDisplay = FormatSyncTime(_configService.GetLastEmployeeRfidCacheSyncUtc());
LastMealMenuCacheSyncDisplay = FormatSyncTime(_configService.GetLastMealMenuCacheSyncUtc());
return;
}
var status = await _backendApi.GetCacheStatusAsync().ConfigureAwait(true);
if (status != null)
{
LastEmployeeRfidCacheSyncDisplay = FormatSyncTime(status.LastEmployeeRfidCacheSyncUtc);
LastMealMenuCacheSyncDisplay = FormatSyncTime(status.LastMealMenuCacheSyncUtc);
}
}
public void RefreshCacheSyncTimestamps()
{
_ = RefreshCacheSyncTimestampsAsync();
}
[RelayCommand]
@ -124,15 +202,32 @@ public partial class SettingsViewModel : ObservableObject
return;
}
if (string.Equals(SelectedAppMode, "Client", StringComparison.OrdinalIgnoreCase) &&
string.IsNullOrWhiteSpace(BackendBaseUrl?.Trim()))
{
SaveMessage = "Client mode requires Backend base URL (e.g. http://192.168.1.10:5000).";
IsError = true;
IsSaving = false;
return;
}
_configService.SetAppMode(string.Equals(SelectedAppMode, "Client", StringComparison.OrdinalIgnoreCase)
? AppMode.Client
: AppMode.Server);
_configService.SetCentralServerBaseUrl(BackendBaseUrl?.Trim() ?? string.Empty);
_configService.SetLocalServerListenUrls(LocalServerListenUrls?.Trim() ?? string.Empty);
_configService.SetSyncApiEndpoint(SyncApiEndpoint?.Trim() ?? string.Empty);
_configService.SetScanIntervalDays(days);
_configService.SetScanIntervalHours(hours);
_configService.SetScanIntervalMinutes(minutes);
_configService.SetScanIntervalSeconds(seconds);
_configService.SetAdminCardId(AdminCardId?.Trim() ?? string.Empty);
SaveMessage = "Settings saved.";
SaveMessage = "Settings saved. Restart the app after changing App mode or listen URLs.";
IsError = false;
IsSaving = false;
OnPropertyChanged(nameof(EffectiveBackendUrlDisplay));
OnPropertyChanged(nameof(HasBackendUrl));
}
[RelayCommand]
@ -141,13 +236,58 @@ public partial class SettingsViewModel : ObservableObject
_navigation.NavigateBackFromSettings(GetDashboardTimeout());
}
/// <summary>Opens Meal Schedules admin screen (Phase 2.5).</summary>
[RelayCommand]
private void OpenMealSchedules()
{
_navigation.NavigateToMealSchedules();
}
[RelayCommand]
private async Task SyncEmployeeAndMenuCacheNow()
{
if (IsSyncingCache)
return;
SaveMessage = "Syncing cache...";
IsError = false;
IsSyncingCache = true;
try
{
if (string.IsNullOrWhiteSpace(_configService.GetBackendBaseUrl()))
{
SaveMessage = "Backend URL is not configured.";
IsError = true;
return;
}
if (!await _backendApi.HealthCheckAsync().ConfigureAwait(true))
{
SaveMessage = "Cannot reach backend. Ensure the central server app is running and API is listening.";
IsError = true;
return;
}
var result = await _backendApi.SyncCacheNowAsync().ConfigureAwait(true);
await RefreshCacheSyncTimestampsAsync().ConfigureAwait(true);
SaveMessage = result.Message;
IsError = !result.Success;
if (!string.IsNullOrWhiteSpace(result.Details) && result.Success)
SaveMessage += " " + result.Details;
}
catch (Exception ex)
{
Logger.Log(ex, "SettingsViewModel.SyncEmployeeAndMenuCacheNow");
SaveMessage = "Cache sync failed: " + ex.Message;
IsError = true;
}
finally
{
IsSyncingCache = false;
}
}
[RelayCommand]
private async Task PostDataNow()
{
@ -160,21 +300,30 @@ public partial class SettingsViewModel : ObservableObject
try
{
// If not configured, do not attempt sync.
if (string.IsNullOrWhiteSpace(_configService.GetMySqlConnectionString()))
if (string.IsNullOrWhiteSpace(_configService.GetBackendBaseUrl()))
{
SaveMessage = "MySQL connection string not configured.";
SaveMessage = "Backend URL is not configured.";
IsError = true;
return;
}
await _syncService.SyncNowAsync().ConfigureAwait(false);
SaveMessage = "Posted data to production.";
IsError = false;
if (!await _backendApi.HealthCheckAsync().ConfigureAwait(true))
{
SaveMessage = "Cannot reach backend. Ensure the central server app is running.";
IsError = true;
return;
}
var result = await _backendApi.SyncOrdersNowAsync().ConfigureAwait(true);
SaveMessage = result.Message;
IsError = !result.Success;
if (!string.IsNullOrWhiteSpace(result.Details) && result.Success)
SaveMessage += " " + result.Details;
}
catch
catch (Exception ex)
{
SaveMessage = "Failed to post data to production.";
Logger.Log(ex, "SettingsViewModel.PostDataNow");
SaveMessage = "Failed to post pending orders: " + ex.Message;
IsError = true;
}
finally
@ -183,5 +332,12 @@ public partial class SettingsViewModel : ObservableObject
}
}
private static string FormatSyncTime(DateTime? utc)
{
if (utc == null)
return "Never";
return utc.Value.ToLocalTime().ToString("MM/dd/yyyy, hh:mm:ss tt");
}
private TimeSpan GetDashboardTimeout() => _configService.GetScanInterval();
}

View File

@ -2,6 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis" />
<SolidColorBrush x:Key="AppBackground" Color="#F0F2F5"/>
<SolidColorBrush x:Key="CardBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="PrimaryText" Color="#2D3748"/>
@ -273,46 +274,82 @@
Foreground="{StaticResource MutedText}"
Margin="0,0,0,20" />
<!--<TextBlock Text="Sync API Endpoint (UIND)" FontSize="16" Foreground="{StaticResource PrimaryText}" FontWeight="SemiBold" />
<TextBox Text="{Binding SyncApiEndpoint, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,8" />
<TextBlock Text="Endpoint used to sync scan records."
<TextBlock Text="Application mode" FontSize="16" Foreground="{StaticResource PrimaryText}" FontWeight="SemiBold" />
<TextBlock Text="Server: this PC hosts SQLite, sync jobs, and the local HTTP API for scanners. Client: this PC only sends scans to the central server."
FontSize="14"
Foreground="{StaticResource MutedText}"
Margin="0,0,0,20" />-->
<TextBlock Text="Sync Data To (UIND)"
FontSize="16"
Foreground="{StaticResource PrimaryText}"
FontWeight="SemiBold" />
Margin="0,4,0,8" />
<ComboBox ItemsSource="{Binding AppModeOptions}"
SelectedItem="{Binding SelectedAppMode, Mode=TwoWay}"
MinHeight="44"
FontSize="16"
Margin="0,0,0,12"
Padding="12,8"/>
<Grid Margin="0,8,0,8">
<TextBlock Text="Backend base URL" FontSize="16" Foreground="{StaticResource PrimaryText}" FontWeight="SemiBold" />
<TextBox Text="{Binding BackendBaseUrl, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,4" />
<TextBlock Text="Client: central server IP (e.g. http://192.168.1.10:5000). Server: leave blank to use http://localhost:5000 from listen URL below."
FontSize="14"
Foreground="{StaticResource MutedText}"
Margin="0,0,0,4" />
<TextBlock Text="{Binding EffectiveBackendUrlDisplay, StringFormat=API calls use: {0}}"
FontSize="14"
Foreground="{StaticResource MutedText}"
Margin="0,0,0,12" />
<TextBlock Text="Local API listen URL(s) (central server only)" FontSize="16" Foreground="{StaticResource PrimaryText}" FontWeight="SemiBold" />
<TextBox Text="{Binding LocalServerListenUrls, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,4" />
<TextBlock Text="Example: http://0.0.0.0:5000 — Kestrel bind address on the server PC."
FontSize="14"
Foreground="{StaticResource MutedText}"
Margin="0,0,0,20" />
<TextBlock Text="Backend jobs (calls central API — same on server and client)" FontSize="16" Foreground="{StaticResource PrimaryText}" FontWeight="SemiBold" Margin="0,0,0,8" />
<TextBlock Text="Download employee RFID tags, meal schedules, and menu data to central SQLite (HRMS → backend)."
FontSize="14"
Foreground="{StaticResource MutedText}"
Margin="0,0,0,8" />
<Button Content="{Binding SyncCacheButtonText}"
Command="{Binding SyncEmployeeAndMenuCacheNowCommand}"
Style="{StaticResource PrimaryButtonStyle}"
MinWidth="280"
HorizontalAlignment="Left"
Margin="0,0,0,8"
IsEnabled="{Binding CanSyncCacheNow}" />
<TextBlock Text="{Binding LastEmployeeRfidCacheSyncDisplay, StringFormat=Last employee RFID cache sync: {0}}"
FontSize="14"
Foreground="{StaticResource MutedText}"
Margin="0,0,0,4" />
<TextBlock Text="{Binding LastMealMenuCacheSyncDisplay, StringFormat=Last meal/menu cache sync: {0}}"
FontSize="14"
Foreground="{StaticResource MutedText}"
Margin="0,0,0,12" />
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="16" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="Post data to production"
Text="Post pending orders to production (UIND)"
FontSize="18"
Foreground="{StaticResource PrimaryText}"
VerticalAlignment="Center"
Margin="4,0,0,0" />
<!-- Post button right next to textbox -->
<Button Grid.Column="2"
Content="{Binding PostButtonText}"
Command="{Binding PostDataNowCommand}"
Style="{StaticResource PrimaryButtonStyle}"
MinWidth="150"
IsEnabled="{Binding CanPostNow}" />
Content="{Binding PostButtonText}"
Command="{Binding PostDataNowCommand}"
Style="{StaticResource PrimaryButtonStyle}"
MinWidth="200"
IsEnabled="{Binding CanPostNow}" />
</Grid>
<TextBlock Text=""
FontSize="14"
Foreground="{StaticResource MutedText}"
Margin="0,0,0,20" />
<!-- Message area -->
<Border Margin="0,0,0,20" Padding="16" CornerRadius="8">
<Border.Style>
@ -396,7 +433,8 @@
Command="{Binding OpenMealSchedulesCommand}"
Style="{StaticResource OutlineButtonStyle}"
MinWidth="120"
Margin="0,0,16,0" />
Margin="0,0,16,0"
Visibility="{Binding IsCentralServerMode, Converter={StaticResource BoolToVis}}" />
<Button Content="Back"
Command="{Binding BackCommand}"
Style="{StaticResource OutlineButtonStyle}"

View File

@ -18,5 +18,6 @@ public partial class SettingsView : UserControl
{
InitializeComponent();
DataContext = viewModel;
Loaded += (_, _) => viewModel.RefreshCacheSyncTimestamps();
}
}

40
scripts/clean-build.ps1 Normal file
View File

@ -0,0 +1,40 @@
$ErrorActionPreference = "Continue"
Write-Host "Stopping .NET build servers..."
dotnet build-server shutdown
Write-Host "Killing running app/dotnet processes..."
taskkill /F /IM dotnet.exe 2>$null
taskkill /F /IM UtopiaCanteen.Client.exe 2>$null
taskkill /F /IM UtopiaCanteen.BackendService.exe 2>$null
taskkill /F /IM UtopiaCanteenSystem.exe 2>$null
$root = Split-Path -Parent $PSScriptRoot
Set-Location $root
$me = "$env:USERDOMAIN\$env:USERNAME"
$targets = @(
"obj",
"bin",
"UtopiaCanteen.BackendService\obj",
"UtopiaCanteen.BackendService\bin",
"UtopiaCanteen.Client\obj",
"UtopiaCanteen.Client\bin",
"UtopiaCanteen.Backend\obj",
"UtopiaCanteen.Backend\bin",
"UtopiaCanteen.Shared\obj",
"UtopiaCanteen.Shared\bin"
)
foreach ($t in $targets) {
if (Test-Path $t) {
Write-Host "Taking ownership and removing: $t"
takeown /F $t /R /D Y | Out-Null
icacls $t /grant "${me}:(OI)(CI)F" /T /C | Out-Null
attrib -R $t /S /D
Remove-Item -LiteralPath $t -Recurse -Force -ErrorAction SilentlyContinue
}
}
Write-Host "Clean complete."

View File

@ -0,0 +1,83 @@
# Install or reinstall the Utopia Canteen backend as a Windows Service (requires Administrator).
param(
[string]$InstallPath = "C:\UtopiaCanteenBackend",
[string]$ServiceName = "UtopiaCanteenBackend",
[string]$DisplayName = "Utopia Canteen Backend",
[ValidateSet("Debug", "Release")]
[string]$Configuration = "Release",
[switch]$SkipPublish,
[switch]$AddUrlAcl,
[string]$UrlAclUser = "Everyone"
)
$ErrorActionPreference = "Stop"
function Test-Administrator {
$current = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($current)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
if (-not (Test-Administrator)) {
Write-Error "Run this script in an elevated PowerShell (Run as administrator)."
exit 1
}
$ScriptDir = $PSScriptRoot
if (-not $SkipPublish) {
& (Join-Path $ScriptDir "publish-backend.ps1") -OutputPath $InstallPath -Configuration $Configuration
}
$ExePath = Join-Path $InstallPath "UtopiaCanteen.BackendService.exe"
if (-not (Test-Path $ExePath)) {
throw "Executable not found: $ExePath. Run publish-backend.ps1 first."
}
# HttpListener on http://+:5000/ often needs a URL reservation.
if ($AddUrlAcl) {
Write-Host "Adding URL ACL for http://+:5000/ (user: $UrlAclUser)"
netsh http add urlacl url=http://+:5000/ user=$UrlAclUser 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Warning "urlacl may already exist or failed (exit $LASTEXITCODE). Continue if the service binds OK."
}
}
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existing) {
Write-Host "Stopping and removing existing service: $ServiceName"
if ($existing.Status -eq "Running") {
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
}
sc.exe delete $ServiceName | Out-Null
Start-Sleep -Seconds 2
}
$binPath = "`"$ExePath`""
Write-Host "Creating service $ServiceName -> $ExePath"
sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= $DisplayName
if ($LASTEXITCODE -ne 0) {
throw "sc create failed with exit code $LASTEXITCODE"
}
sc.exe description $ServiceName "Central canteen API, SQLite, and HRMS/UIND sync for RFID scanners."
sc.exe failure $ServiceName reset= 86400 actions= restart/60000/restart/60000/restart/60000
Write-Host "Starting service..."
sc.exe start $ServiceName
if ($LASTEXITCODE -ne 0) {
Write-Warning "sc start returned $LASTEXITCODE. Check Event Viewer and appsettings.json."
} else {
Write-Host "Service started. Health check: http://localhost:5000/api/health"
}
Write-Host @"
Installed:
Path: $InstallPath
Service: $ServiceName (auto-start)
Data: %LocalAppData%\UtopiaCanteenBackend\
Firewall: allow inbound TCP 5000 on this PC for scanner clients.
"@

View File

@ -0,0 +1,20 @@
$ErrorActionPreference = "Stop"
$ProjectPath = ".\UtopiaCanteen.BackendService\UtopiaCanteen.BackendService.csproj"
$OutputPath = "C:\Users\Public\UtopiaCanteenBackend"
Write-Host "Cleaning old backend publish folder..."
if (Test-Path $OutputPath) {
Remove-Item $OutputPath -Recurse -Force
}
Write-Host "Publishing backend as self-contained..."
dotnet publish $ProjectPath `
-c Release `
-r win-x64 `
--self-contained true `
-p:PublishSingleFile=false `
-o $OutputPath
Write-Host "Backend self-contained publish completed:"
Write-Host $OutputPath

View File

@ -0,0 +1,57 @@
# Publish UtopiaCanteen.BackendService to a folder (default C:\UtopiaCanteenBackend).
# Default: self-contained win-x64 (no .NET runtime required on target PC).
param(
[string]$OutputPath = "C:\UtopiaCanteenBackend",
[ValidateSet("Debug", "Release")]
[string]$Configuration = "Release",
[string]$Runtime = "win-x64",
[switch]$FrameworkDependent,
[switch]$SelfContained
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
$Project = Join-Path $Root "UtopiaCanteen.BackendService\UtopiaCanteen.BackendService.csproj"
if (-not (Test-Path $Project)) {
throw "Project not found: $Project"
}
# Self-contained unless -FrameworkDependent is passed.
$isSelfContained = -not $FrameworkDependent
if ($SelfContained) {
$isSelfContained = $true
}
$publishArgs = @(
"publish", $Project,
"-c", $Configuration,
"-o", $OutputPath
)
if ($isSelfContained) {
$publishArgs += @("-r", $Runtime, "--self-contained", "true")
Write-Host "Publishing backend ($Configuration, self-contained $Runtime) -> $OutputPath"
} else {
$publishArgs += @("--self-contained", "false")
Write-Host "Publishing backend ($Configuration, framework-dependent) -> $OutputPath"
Write-Host "Target PC must have .NET 8 Desktop Runtime installed."
}
dotnet @publishArgs
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE"
}
$appsettings = Join-Path $OutputPath "appsettings.json"
if (Test-Path $appsettings) {
Write-Host "Edit connection strings in: $appsettings"
}
Write-Host @"
Done. Executable: $(Join-Path $OutputPath 'UtopiaCanteen.BackendService.exe')
Deploy: copy the entire folder '$OutputPath' to the server PC.
$(if ($isSelfContained) { "Self-contained: .NET runtime is included; no separate install needed." } else { "Framework-dependent: install .NET 8 Runtime on the server." })
"@

View File

@ -0,0 +1,20 @@
$ErrorActionPreference = "Stop"
$ProjectPath = ".\UtopiaCanteen.Client\UtopiaCanteen.Client.csproj"
$OutputPath = "C:\Users\Public\UtopiaCanteenClient"
Write-Host "Cleaning old client publish folder..."
if (Test-Path $OutputPath) {
Remove-Item $OutputPath -Recurse -Force
}
Write-Host "Publishing client as self-contained..."
dotnet publish $ProjectPath `
-c Release `
-r win-x64 `
--self-contained true `
-p:PublishSingleFile=false `
-o $OutputPath
Write-Host "Client self-contained publish completed:"
Write-Host $OutputPath

View File

@ -0,0 +1,54 @@
# Publish UtopiaCanteen.Client (WPF scanner) to a folder (default C:\UtopiaCanteenClient).
# Default: self-contained win-x64 (no .NET runtime required on target PC).
param(
[string]$OutputPath = "C:\UtopiaCanteenClient",
[ValidateSet("Debug", "Release")]
[string]$Configuration = "Release",
[string]$Runtime = "win-x64",
[switch]$FrameworkDependent,
[switch]$SelfContained
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
$Project = Join-Path $Root "UtopiaCanteen.Client\UtopiaCanteen.Client.csproj"
if (-not (Test-Path $Project)) {
throw "Project not found: $Project"
}
$isSelfContained = -not $FrameworkDependent
if ($SelfContained) {
$isSelfContained = $true
}
$publishArgs = @(
"publish", $Project,
"-c", $Configuration,
"-o", $OutputPath
)
if ($isSelfContained) {
$publishArgs += @("-r", $Runtime, "--self-contained", "true")
Write-Host "Publishing client ($Configuration, self-contained $Runtime) -> $OutputPath"
} else {
$publishArgs += @("--self-contained", "false")
Write-Host "Publishing client ($Configuration, framework-dependent) -> $OutputPath"
Write-Host "Target PC must have .NET 8 Desktop Runtime installed."
}
dotnet @publishArgs
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE"
}
Write-Host @"
Done. Run: $(Join-Path $OutputPath 'UtopiaCanteen.Client.exe')
Deploy: copy the entire folder '$OutputPath' to each scanner PC (or your file share).
$(if ($isSelfContained) { "Self-contained: .NET runtime is included; no separate install needed." } else { "Framework-dependent: install .NET 8 Desktop Runtime on each PC." })
First run: open Settings and set Backend base URL (e.g. http://192.168.1.10:5000).
Config file: %LocalAppData%\UtopiaCanteenClient\appsettings.json
"@