Compare commits
30 Commits
main
...
feature/ce
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
193
App.xaml.cs
193
App.xaml.cs
|
|
@ -1,21 +1,27 @@
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using UtopiaCanteenSystem.Api;
|
||||||
using UtopiaCanteenSystem.Data;
|
using UtopiaCanteenSystem.Data;
|
||||||
|
using UtopiaCanteenSystem.Models;
|
||||||
using UtopiaCanteenSystem.Services;
|
using UtopiaCanteenSystem.Services;
|
||||||
using UtopiaCanteenSystem.ViewModels;
|
using UtopiaCanteenSystem.ViewModels;
|
||||||
|
|
||||||
namespace UtopiaCanteenSystem;
|
namespace UtopiaCanteenSystem;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
private static Mutex _mutex;
|
private static Mutex _mutex = null!;
|
||||||
private System.Timers.Timer? _syncTimer;
|
private System.Timers.Timer? _syncTimer;
|
||||||
|
private System.Timers.Timer? _offlineCacheSyncTimer;
|
||||||
private int _isSyncRunning;
|
private int _isSyncRunning;
|
||||||
|
private int _isOfflineCacheSyncRunning;
|
||||||
|
private CancellationTokenSource? _apiHostCts;
|
||||||
|
private Task? _apiHostTask;
|
||||||
|
|
||||||
protected override void OnStartup(StartupEventArgs e)
|
protected override void OnStartup(StartupEventArgs e)
|
||||||
{
|
{
|
||||||
|
|
@ -28,73 +34,134 @@ public partial class App : Application
|
||||||
Current.Shutdown();
|
Current.Shutdown();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnStartup(e);
|
base.OnStartup(e);
|
||||||
|
|
||||||
// Build services (simple composition; no DI container)
|
var configService = new ConfigService();
|
||||||
var dbFactory = new DbContextFactory();
|
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())
|
using (var db = dbFactory.CreateDbContext())
|
||||||
{
|
|
||||||
db.EnsureDatabaseCreated();
|
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 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 adminAuditService = new AdminAuditService(dbFactory);
|
||||||
var session = new AppSession();
|
var session = new AppSession();
|
||||||
var authenticationUrl = "https://portal.utopiaindustries.pk/uind/rest/auth/user/";
|
var authService = new AuthService("https://portal.utopiaindustries.pk/uind/rest/auth/user/");
|
||||||
var authService = new AuthService(authenticationUrl);
|
|
||||||
|
|
||||||
// NavigationService: declare first so lambdas can capture it, then assign (avoids "used before declared")
|
|
||||||
NavigationService navigationService = null!;
|
NavigationService navigationService = null!;
|
||||||
navigationService = new NavigationService(
|
navigationService = new NavigationService(
|
||||||
session,
|
session,
|
||||||
() => new AdminLoginViewModel(authService, session, navigationService, configService, adminAuditService, employeeLookupService),
|
() => new AdminLoginViewModel(authService, session, navigationService, configService, adminAuditService, employeeLookupService, backendApiClient),
|
||||||
() => new ScannerDashboardViewModel(rfidService, navigationService, session, configService, menuLookupService, employeePhotoService),
|
() => new ScannerDashboardViewModel(scannerRfidService, navigationService, session, configService, menuLookupService, employeePhotoService),
|
||||||
() => new MainDashboardViewModel(navigationService, rfidService, configService, session),
|
() => new MainDashboardViewModel(navigationService, scannerRfidService, configService, session),
|
||||||
() => new AdminSettingsAuthViewModel(authService, session, navigationService, configService, employeeLookupService),
|
() => new AdminSettingsAuthViewModel(authService, session, navigationService, configService, employeeLookupService, backendApiClient),
|
||||||
() => new SettingsViewModel(configService, navigationService, adminAuditService, syncService),
|
() => new SettingsViewModel(configService, navigationService, adminAuditService, backendApiClient),
|
||||||
() => new MealSchedulesViewModel(mealScheduleService, navigationService, configService));
|
() => new MealSchedulesViewModel(
|
||||||
|
mealScheduleService ?? new ProductionMealScheduleService(configService),
|
||||||
|
navigationService,
|
||||||
|
configService));
|
||||||
|
|
||||||
var mainViewModel = new MainViewModel(navigationService);
|
var mainWindow = new MainWindow { DataContext = new MainViewModel(navigationService) };
|
||||||
|
|
||||||
var mainWindow = new MainWindow
|
|
||||||
{
|
|
||||||
DataContext = mainViewModel
|
|
||||||
};
|
|
||||||
// Set the window to open maximized
|
|
||||||
mainWindow.WindowState = WindowState.Maximized;
|
mainWindow.WindowState = WindowState.Maximized;
|
||||||
mainWindow.Show();
|
mainWindow.Show();
|
||||||
|
|
||||||
// Background sync: every 15 minutes, POST unsynced lunch_order_transactions to API
|
if (isServer && backendServices != null)
|
||||||
if (configService.GetSyncServiceEnabled())
|
|
||||||
{
|
{
|
||||||
_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
|
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
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Log(ex, "App.CanteenBackendHost");
|
||||||
|
}
|
||||||
|
}, 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
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -104,12 +171,46 @@ public partial class App : Application
|
||||||
_syncTimer.Start();
|
_syncTimer.Start();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnExit(ExitEventArgs e)
|
protected override void OnExit(ExitEventArgs e)
|
||||||
{
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_apiHostCts?.Cancel();
|
||||||
|
_apiHostTask?.Wait(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort
|
||||||
|
}
|
||||||
|
|
||||||
_mutex.ReleaseMutex();
|
_mutex.ReleaseMutex();
|
||||||
_syncTimer?.Stop();
|
_syncTimer?.Stop();
|
||||||
_syncTimer?.Dispose();
|
_syncTimer?.Dispose();
|
||||||
|
_offlineCacheSyncTimer?.Stop();
|
||||||
|
_offlineCacheSyncTimer?.Dispose();
|
||||||
|
_apiHostCts?.Dispose();
|
||||||
base.OnExit(e);
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ public class AppDbContext : DbContext
|
||||||
public DbSet<Labour> Labour { get; set; }
|
public DbSet<Labour> Labour { get; set; }
|
||||||
public DbSet<ScanRecord> LunchOrderTransactions { get; set; }
|
public DbSet<ScanRecord> LunchOrderTransactions { get; set; }
|
||||||
public DbSet<AdminLoginRecord> AdminLoginRecords { get; set; }
|
public DbSet<AdminLoginRecord> AdminLoginRecords { get; set; }
|
||||||
|
public DbSet<EmployeeRfidTagCache> EmployeeRfidTagCache { get; set; }
|
||||||
|
public DbSet<MealScheduleCache> MealScheduleCache { get; set; }
|
||||||
|
public DbSet<LunchMenuWeekCache> LunchMenuWeekCache { get; set; }
|
||||||
|
public DbSet<LunchMenuItemCache> LunchMenuItemCache { get; set; }
|
||||||
|
public DbSet<MenuItemCache> MenuItemCache { get; set; }
|
||||||
|
|
||||||
public AppDbContext() { }
|
public AppDbContext() { }
|
||||||
|
|
||||||
|
|
@ -52,6 +57,46 @@ public class AppDbContext : DbContext
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
e.HasIndex(x => x.LoginTimeUtc);
|
e.HasIndex(x => x.LoginTimeUtc);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<EmployeeRfidTagCache>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("employee_rfid_tag_cache");
|
||||||
|
e.HasKey(x => x.LocalId);
|
||||||
|
e.HasIndex(x => x.HrmsId).IsUnique();
|
||||||
|
e.HasIndex(x => x.ManufacturerSerial);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<MealScheduleCache>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("meal_schedule_cache");
|
||||||
|
e.HasKey(x => x.LocalId);
|
||||||
|
e.HasIndex(x => x.HrmsId).IsUnique();
|
||||||
|
e.HasIndex(x => x.LocationSiteId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<LunchMenuWeekCache>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("lunch_menu_week_cache");
|
||||||
|
e.HasKey(x => x.LocalId);
|
||||||
|
e.HasIndex(x => x.HrmsId).IsUnique();
|
||||||
|
e.HasIndex(x => x.LocationSiteId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<LunchMenuItemCache>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("lunch_menu_item_cache");
|
||||||
|
e.HasKey(x => x.LocalId);
|
||||||
|
e.HasIndex(x => x.HrmsId).IsUnique();
|
||||||
|
e.HasIndex(x => x.LunchMenuWeekHrmsId);
|
||||||
|
e.HasIndex(x => x.MenuDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<MenuItemCache>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("menu_item_cache");
|
||||||
|
e.HasKey(x => x.LocalId);
|
||||||
|
e.HasIndex(x => x.HrmsId).IsUnique();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -63,6 +108,8 @@ public class AppDbContext : DbContext
|
||||||
MigrateScanRecordsToLunchOrderTransactionsIfNeeded();
|
MigrateScanRecordsToLunchOrderTransactionsIfNeeded();
|
||||||
UpgradeLunchOrderTransactionsSchemaIfNeeded();
|
UpgradeLunchOrderTransactionsSchemaIfNeeded();
|
||||||
EnsureAdminLoginTableExists();
|
EnsureAdminLoginTableExists();
|
||||||
|
EnsureEmployeeRfidTagCacheTableExists();
|
||||||
|
EnsureMealMenuCacheTablesExist();
|
||||||
RemoveLegacyMealRelatedSchemaIfNeeded();
|
RemoveLegacyMealRelatedSchemaIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -401,6 +448,152 @@ public class AppDbContext : DbContext
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lightweight creation of employee_rfid_tag_cache for existing databases (no EF migrations).
|
||||||
|
/// </summary>
|
||||||
|
private void EnsureEmployeeRfidTagCacheTableExists()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var conn = Database.GetDbConnection();
|
||||||
|
if (conn.State != ConnectionState.Open)
|
||||||
|
conn.Open();
|
||||||
|
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
"CREATE TABLE IF NOT EXISTS employee_rfid_tag_cache (" +
|
||||||
|
"LocalId INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||||
|
"HrmsId INTEGER NOT NULL, " +
|
||||||
|
"ManufacturerSerial TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"UindSerial TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"Secret TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"ParentDocumentType TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"ParentDocumentId TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"DateTimeCreated TEXT, " +
|
||||||
|
"CreatedBy TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"GradeId INTEGER, " +
|
||||||
|
"GradeType TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"ShiftId INTEGER, " +
|
||||||
|
"LocationSiteId TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"RfidLocationSiteId TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"FunctionId INTEGER NOT NULL DEFAULT 0, " +
|
||||||
|
"DepartmentId INTEGER NOT NULL DEFAULT 0, " +
|
||||||
|
"ReportingManagerIds TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"EmployeeSerialNumber TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"EmployeeConcatenatedName TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"DepartmentTitle TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"DepartmentType TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"LastSyncedAtUtc TEXT NOT NULL)";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
|
||||||
|
using var idxHrms = conn.CreateCommand();
|
||||||
|
idxHrms.CommandText =
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS IX_employee_rfid_tag_cache_HrmsId ON employee_rfid_tag_cache(HrmsId)";
|
||||||
|
idxHrms.ExecuteNonQuery();
|
||||||
|
|
||||||
|
using var idxSerial = conn.CreateCommand();
|
||||||
|
idxSerial.CommandText =
|
||||||
|
"CREATE INDEX IF NOT EXISTS IX_employee_rfid_tag_cache_ManufacturerSerial ON employee_rfid_tag_cache(ManufacturerSerial)";
|
||||||
|
idxSerial.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore; table may already exist or DB may be read-only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureMealMenuCacheTablesExist()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var conn = Database.GetDbConnection();
|
||||||
|
if (conn.State != ConnectionState.Open)
|
||||||
|
conn.Open();
|
||||||
|
|
||||||
|
using (var cmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText =
|
||||||
|
"CREATE TABLE IF NOT EXISTS meal_schedule_cache (" +
|
||||||
|
"LocalId INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||||
|
"HrmsId INTEGER NOT NULL, " +
|
||||||
|
"MealName TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"StartTime TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"EndTime TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"CreatedAt TEXT, " +
|
||||||
|
"UpdatedAt TEXT, " +
|
||||||
|
"LocationSiteId INTEGER NOT NULL DEFAULT 0, " +
|
||||||
|
"LastSyncedAtUtc TEXT NOT NULL)";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var cmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText =
|
||||||
|
"CREATE TABLE IF NOT EXISTS lunch_menu_week_cache (" +
|
||||||
|
"LocalId INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||||
|
"HrmsId INTEGER NOT NULL, " +
|
||||||
|
"WeekStartDate TEXT, " +
|
||||||
|
"WeekEndDate TEXT, " +
|
||||||
|
"CreatedBy TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"CreatedAt TEXT, " +
|
||||||
|
"LocationSiteId INTEGER NOT NULL DEFAULT 0, " +
|
||||||
|
"LastSyncedAtUtc TEXT NOT NULL)";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var cmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText =
|
||||||
|
"CREATE TABLE IF NOT EXISTS lunch_menu_item_cache (" +
|
||||||
|
"LocalId INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||||
|
"HrmsId INTEGER NOT NULL, " +
|
||||||
|
"LunchMenuWeekHrmsId INTEGER NOT NULL DEFAULT 0, " +
|
||||||
|
"DayOfWeek TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"MealName TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"MenuItemHrmsId INTEGER NOT NULL DEFAULT 0, " +
|
||||||
|
"CreatedAt TEXT, " +
|
||||||
|
"MenuDate TEXT, " +
|
||||||
|
"LastSyncedAtUtc TEXT NOT NULL)";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var cmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText =
|
||||||
|
"CREATE TABLE IF NOT EXISTS menu_item_cache (" +
|
||||||
|
"LocalId INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||||
|
"HrmsId INTEGER NOT NULL, " +
|
||||||
|
"ItemName TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"ItemType TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"Price REAL NOT NULL DEFAULT 0, " +
|
||||||
|
"ItemFor TEXT NOT NULL DEFAULT '', " +
|
||||||
|
"LocationSiteId INTEGER NOT NULL DEFAULT 0, " +
|
||||||
|
"LastSyncedAtUtc TEXT NOT NULL)";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
CreateIndexIfNotExists(conn, "IX_meal_schedule_cache_HrmsId", "meal_schedule_cache", "HrmsId", unique: true);
|
||||||
|
CreateIndexIfNotExists(conn, "IX_meal_schedule_cache_LocationSiteId", "meal_schedule_cache", "LocationSiteId", unique: false);
|
||||||
|
CreateIndexIfNotExists(conn, "IX_lunch_menu_week_cache_HrmsId", "lunch_menu_week_cache", "HrmsId", unique: true);
|
||||||
|
CreateIndexIfNotExists(conn, "IX_lunch_menu_week_cache_LocationSiteId", "lunch_menu_week_cache", "LocationSiteId", unique: false);
|
||||||
|
CreateIndexIfNotExists(conn, "IX_lunch_menu_item_cache_HrmsId", "lunch_menu_item_cache", "HrmsId", unique: true);
|
||||||
|
CreateIndexIfNotExists(conn, "IX_lunch_menu_item_cache_WeekId", "lunch_menu_item_cache", "LunchMenuWeekHrmsId", unique: false);
|
||||||
|
CreateIndexIfNotExists(conn, "IX_menu_item_cache_HrmsId", "menu_item_cache", "HrmsId", unique: true);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore; table may already exist or DB may be read-only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CreateIndexIfNotExists(DbConnection conn, string indexName, string tableName, string columnName, bool unique)
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
var uniqueSql = unique ? "UNIQUE " : string.Empty;
|
||||||
|
cmd.CommandText = $"CREATE {uniqueSql}INDEX IF NOT EXISTS {indexName} ON {tableName}({columnName})";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes legacy meal-related tables and columns that should never be present in production.
|
/// Removes legacy meal-related tables and columns that should never be present in production.
|
||||||
/// This is a best-effort cleanup that runs on every startup for existing SQLite databases.
|
/// This is a best-effort cleanup that runs on every startup for existing SQLite databases.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using UtopiaCanteenSystem.Services.Logging;
|
||||||
|
|
||||||
namespace UtopiaCanteenSystem.Data;
|
namespace UtopiaCanteenSystem.Data;
|
||||||
|
|
||||||
|
|
@ -10,6 +11,27 @@ public static class DatabasePath
|
||||||
{
|
{
|
||||||
private static string? _appDataFolder;
|
private static string? _appDataFolder;
|
||||||
private static string? _dbPath;
|
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>
|
/// <summary>
|
||||||
/// Folder under LocalApplicationData for DB and config. Created on first use.
|
/// Folder under LocalApplicationData for DB and config. Created on first use.
|
||||||
|
|
@ -19,13 +41,65 @@ public static class DatabasePath
|
||||||
if (_appDataFolder != null)
|
if (_appDataFolder != null)
|
||||||
return _appDataFolder;
|
return _appDataFolder;
|
||||||
|
|
||||||
_appDataFolder = Path.Combine(
|
var localAppDataRoot = ResolveLocalAppDataRoot();
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
_appDataFolder = Path.Combine(localAppDataRoot, _productFolderName);
|
||||||
"UtopiaCanteenSystem");
|
|
||||||
Directory.CreateDirectory(_appDataFolder);
|
Directory.CreateDirectory(_appDataFolder);
|
||||||
|
MigrateBackendSystemProfileStorageIfNeeded(_appDataFolder);
|
||||||
return _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>
|
/// <summary>
|
||||||
/// Full path to the SQLite database file. If a DB exists in the legacy app-directory
|
/// 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.
|
/// location, it is copied to the new location once.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
namespace UtopiaCanteenSystem.Models;
|
namespace UtopiaCanteenSystem.Models;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public class HrmsEmployeeInfo
|
public class HrmsEmployeeInfo
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -32,9 +32,10 @@ public class AdminAuditService : IAdminAuditService
|
||||||
db.AdminLoginRecords.Add(record);
|
db.AdminLoginRecords.Add(record);
|
||||||
await db.SaveChangesAsync().ConfigureAwait(false);
|
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)
|
.OrderByDescending(x => x.LoginTimeUtc)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
Logger.Log(ex, "AdminAuditService.GetLastLogin");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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) + "…";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using UtopiaCanteenSystem.Data;
|
using UtopiaCanteenSystem.Data;
|
||||||
|
using UtopiaCanteenSystem.Models;
|
||||||
|
|
||||||
namespace UtopiaCanteenSystem.Services;
|
namespace UtopiaCanteenSystem.Services;
|
||||||
|
|
||||||
|
|
@ -128,23 +129,30 @@ public class ConfigService : IConfigService
|
||||||
|
|
||||||
public string GetDeviceId()
|
public string GetDeviceId()
|
||||||
{
|
{
|
||||||
//var id = _config.DeviceId ?? string.Empty;
|
if (!string.IsNullOrWhiteSpace(_config.DeviceId))
|
||||||
var id = Environment.MachineName;
|
return _config.DeviceId.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(id))
|
|
||||||
{
|
_config.DeviceId = ResolveMachineDeviceId();
|
||||||
id = Guid.NewGuid().ToString("N");
|
|
||||||
_config.DeviceId = id;
|
|
||||||
SaveConfig();
|
SaveConfig();
|
||||||
}
|
return _config.DeviceId;
|
||||||
return id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetDeviceId(string deviceId)
|
public void SetDeviceId(string deviceId)
|
||||||
{
|
{
|
||||||
_config.DeviceId = deviceId ?? string.Empty;
|
_config.DeviceId = string.IsNullOrWhiteSpace(deviceId)
|
||||||
|
? ResolveMachineDeviceId()
|
||||||
|
: deviceId.Trim();
|
||||||
SaveConfig();
|
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 bool GetRememberAdminCredentials() => _config.RememberAdminCredentials;
|
||||||
|
|
||||||
public void SetRememberAdminCredentials(bool remember)
|
public void SetRememberAdminCredentials(bool remember)
|
||||||
|
|
@ -217,17 +225,10 @@ public class ConfigService : IConfigService
|
||||||
SaveConfig();
|
SaveConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
//public string GetHrmsLookupConnectionString() => (_config.HrmsLookupConnectionString ?? string.Empty).Trim();
|
public string GetHrmsLookupConnectionString() =>
|
||||||
|
ConfigConnectionHelper.GetHrmsOrProductionConnectionString(
|
||||||
public string GetHrmsLookupConnectionString()
|
_config.HrmsLookupConnectionString,
|
||||||
{
|
_config.MySqlConnectionString);
|
||||||
var hrms = (_config.HrmsLookupConnectionString ?? string.Empty).Trim();
|
|
||||||
if (!string.IsNullOrWhiteSpace(hrms))
|
|
||||||
return hrms;
|
|
||||||
|
|
||||||
// fallback to production/AWS connection string
|
|
||||||
return GetMySqlConnectionString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetHrmsLookupConnectionString(string connectionString)
|
public void SetHrmsLookupConnectionString(string connectionString)
|
||||||
{
|
{
|
||||||
|
|
@ -235,6 +236,89 @@ public class ConfigService : IConfigService
|
||||||
SaveConfig();
|
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()
|
private AppConfig LoadConfig()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
@ -251,6 +335,8 @@ public class ConfigService : IConfigService
|
||||||
var json = File.ReadAllText(_configPath);
|
var json = File.ReadAllText(_configPath);
|
||||||
var config = JsonSerializer.Deserialize<AppConfig>(json);
|
var config = JsonSerializer.Deserialize<AppConfig>(json);
|
||||||
config ??= new AppConfig();
|
config ??= new AppConfig();
|
||||||
|
if (string.IsNullOrWhiteSpace(config.BackendBaseUrl) && !string.IsNullOrWhiteSpace(config.CentralServerBaseUrl))
|
||||||
|
config.BackendBaseUrl = config.CentralServerBaseUrl;
|
||||||
MigrateScanIntervalFromSecondsIfNeeded(config);
|
MigrateScanIntervalFromSecondsIfNeeded(config);
|
||||||
var changed = EnsureConnectionStringDefaults(config);
|
var changed = EnsureConnectionStringDefaults(config);
|
||||||
if (changed)
|
if (changed)
|
||||||
|
|
@ -392,5 +478,20 @@ public class ConfigService : IConfigService
|
||||||
|
|
||||||
// Local HRMS MySQL for employee lookup by RFID. Separate from production sync.
|
// Local HRMS MySQL for employee lookup by RFID. Separate from production sync.
|
||||||
public string HrmsLookupConnectionString { get; set; } = string.Empty;
|
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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,41 @@
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using UtopiaCanteenSystem.Data;
|
||||||
using UtopiaCanteenSystem.Models;
|
using UtopiaCanteenSystem.Models;
|
||||||
|
|
||||||
namespace UtopiaCanteenSystem.Services;
|
namespace UtopiaCanteenSystem.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves current meal session from production (MySQL meal_schedule). Caches schedules per site for 60 seconds.
|
/// Resolves current meal session from local SQLite <c>meal_schedule_cache</c>.
|
||||||
/// No SQLite; scan validation uses production data only.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DbMealSessionResolver : IMealSessionResolver
|
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 TimeSpan _cacheTtl = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
private readonly Dictionary<string, (List<MealSchedule> Schedules, DateTime ExpiryUtc)> _cache = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, (List<MealSchedule> Schedules, DateTime ExpiryUtc)> _cache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly object _cacheLock = new();
|
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)
|
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)
|
public ResolvedMealSession? GetCurrentSession(DateTime nowLocal, string siteId)
|
||||||
{
|
{
|
||||||
var normalizedSite = (siteId ?? string.Empty).Trim();
|
var normalizedSite = SiteIdHelper.NormalizeSiteId(siteId);
|
||||||
if (string.IsNullOrEmpty(normalizedSite))
|
|
||||||
normalizedSite = "01";
|
|
||||||
if (normalizedSite.Length == 1 && char.IsDigit(normalizedSite[0]))
|
|
||||||
normalizedSite = normalizedSite.PadLeft(2, '0');
|
|
||||||
|
|
||||||
var schedules = GetSchedulesForSiteCached(normalizedSite);
|
var schedules = GetSchedulesForSiteCached(normalizedSite);
|
||||||
var t = nowLocal.TimeOfDay;
|
var t = nowLocal.TimeOfDay;
|
||||||
|
|
||||||
|
|
@ -106,7 +83,6 @@ public class DbMealSessionResolver : IMealSessionResolver
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private List<MealSchedule> GetSchedulesForSiteCached(string siteId)
|
private List<MealSchedule> GetSchedulesForSiteCached(string siteId)
|
||||||
{
|
{
|
||||||
lock (_cacheLock)
|
lock (_cacheLock)
|
||||||
|
|
@ -115,7 +91,7 @@ public class DbMealSessionResolver : IMealSessionResolver
|
||||||
return entry.Schedules;
|
return entry.Schedules;
|
||||||
}
|
}
|
||||||
|
|
||||||
var list = _mealScheduleService.GetActiveSchedulesForSite(siteId).ToList();
|
var list = LoadSchedulesForSiteFromCache(siteId);
|
||||||
|
|
||||||
lock (_cacheLock)
|
lock (_cacheLock)
|
||||||
{
|
{
|
||||||
|
|
@ -125,6 +101,35 @@ public class DbMealSessionResolver : IMealSessionResolver
|
||||||
return list;
|
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)
|
private static bool TryParseTime(string value, out TimeSpan time)
|
||||||
{
|
{
|
||||||
time = TimeSpan.Zero;
|
time = TimeSpan.Zero;
|
||||||
|
|
|
||||||
|
|
@ -1,77 +1,45 @@
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MySqlConnector;
|
using MySqlConnector;
|
||||||
|
using UtopiaCanteenSystem.Data;
|
||||||
using UtopiaCanteenSystem.Models;
|
using UtopiaCanteenSystem.Models;
|
||||||
|
|
||||||
namespace UtopiaCanteenSystem.Services;
|
namespace UtopiaCanteenSystem.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up employee from local HRMS MySQL: employee_rfid_tag.manufacturer_serial → employee (parent_document_id = serial_number) → department.
|
/// Looks up employee by RFID from local SQLite <c>employee_rfid_tag_cache</c> (synced from production HRMS).
|
||||||
/// Uses IConfigService.GetHrmsLookupConnectionString(); separate from production sync connection.
|
/// Menu authorization and admin site lookup still use HRMS when configured.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class EmployeeLookupService : IEmployeeLookupService
|
public class EmployeeLookupService : IEmployeeLookupService
|
||||||
{
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
private readonly IConfigService _configService;
|
private readonly IConfigService _configService;
|
||||||
|
|
||||||
public EmployeeLookupService(IConfigService configService)
|
public EmployeeLookupService(IDbContextFactory<AppDbContext> dbFactory, IConfigService configService)
|
||||||
{
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<HrmsEmployeeInfo?> GetEmployeeByRfidAsync(string rfid, CancellationToken cancellationToken = default)
|
public async Task<HrmsEmployeeInfo?> GetEmployeeByRfidAsync(string rfid, CancellationToken cancellationToken = default)
|
||||||
|
|
||||||
{
|
{
|
||||||
var connectionString = _configService.GetHrmsLookupConnectionString();
|
var cardId = rfid?.Trim() ?? string.Empty;
|
||||||
if (string.IsNullOrWhiteSpace(connectionString))
|
if (string.IsNullOrEmpty(cardId))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
const string sql = @"
|
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
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";
|
|
||||||
|
|
||||||
await using var conn = new MySqlConnection(connectionString);
|
var tag = await db.EmployeeRfidTagCache
|
||||||
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
.AsNoTracking()
|
||||||
await using var cmd = new MySqlCommand(sql, conn);
|
.Where(x => x.ManufacturerSerial == cardId)
|
||||||
cmd.Parameters.AddWithValue("@rfid", rfid?.Trim() ?? string.Empty);
|
.Where(x => x.ParentDocumentType == "Employee")
|
||||||
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
if (tag == null)
|
||||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var parentDocumentId = GetString(reader, 0);
|
return MapToHrmsEmployeeInfo(tag);
|
||||||
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 />
|
/// <inheritdoc />
|
||||||
|
|
@ -81,30 +49,21 @@ public class EmployeeLookupService : IEmployeeLookupService
|
||||||
if (string.IsNullOrEmpty(serial))
|
if (string.IsNullOrEmpty(serial))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var connectionString = _configService.GetHrmsLookupConnectionString();
|
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
if (string.IsNullOrWhiteSpace(connectionString))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
const string sql = @"
|
var siteId = await db.EmployeeRfidTagCache
|
||||||
SELECT location_site_id
|
.AsNoTracking()
|
||||||
FROM employee
|
.Where(x => x.ParentDocumentType == "Employee")
|
||||||
WHERE serial_number = @serial
|
.Where(x => x.EmployeeSerialNumber == serial)
|
||||||
LIMIT 1";
|
.Where(x => !string.IsNullOrEmpty(x.LocationSiteId))
|
||||||
|
.Select(x => x.LocationSiteId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await using var conn = new MySqlConnection(connectionString);
|
if (!string.IsNullOrWhiteSpace(siteId))
|
||||||
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
return siteId.Trim();
|
||||||
await using var cmd = new MySqlCommand(sql, conn);
|
|
||||||
cmd.Parameters.AddWithValue("@serial", serial);
|
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
return await GetLocationSiteIdFromHrmsAsync(serial, 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
@ -136,42 +95,51 @@ public class EmployeeLookupService : IEmployeeLookupService
|
||||||
return exists != null && exists != DBNull.Value;
|
return exists != null && exists != DBNull.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetString(MySqlDataReader reader, int ordinal)
|
private static HrmsEmployeeInfo MapToHrmsEmployeeInfo(EmployeeRfidTagCache tag)
|
||||||
{
|
{
|
||||||
if (reader.IsDBNull(ordinal)) return string.Empty;
|
return new HrmsEmployeeInfo
|
||||||
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)
|
private async Task<string?> GetLocationSiteIdFromHrmsAsync(string serial, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
var connectionString = _configService.GetHrmsLookupConnectionString();
|
||||||
{
|
if (string.IsNullOrWhiteSpace(connectionString))
|
||||||
if (reader.IsDBNull(ordinal)) return 0;
|
return null;
|
||||||
var v = reader.GetValue(ordinal);
|
|
||||||
if (v is int i) return i;
|
|
||||||
if (v is long l) return (int)l;
|
|
||||||
return int.TryParse(v?.ToString(), out var parsed) ? parsed : 0;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DateTime? GetDateTimeNullable(MySqlDataReader reader, int ordinal)
|
const string sql = @"
|
||||||
{
|
SELECT location_site_id
|
||||||
try
|
FROM employee
|
||||||
{
|
WHERE serial_number = @serial
|
||||||
if (reader.IsDBNull(ordinal)) return null;
|
LIMIT 1";
|
||||||
var v = reader.GetValue(ordinal);
|
|
||||||
if (v is DateTime dt) return dt;
|
await using var conn = new MySqlConnection(connectionString);
|
||||||
if (DateTime.TryParse(v?.ToString(), out var parsed)) return parsed;
|
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;
|
return null;
|
||||||
}
|
|
||||||
catch
|
if (reader.IsDBNull(0))
|
||||||
{
|
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
var raw = reader.GetValue(0)?.ToString()?.Trim();
|
||||||
|
return string.IsNullOrEmpty(raw) ? null : raw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>());
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using UtopiaCanteenSystem.Models;
|
||||||
|
|
||||||
namespace UtopiaCanteenSystem.Services;
|
namespace UtopiaCanteenSystem.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -42,7 +44,30 @@ public interface IConfigService
|
||||||
string GetMySqlConnectionString();
|
string GetMySqlConnectionString();
|
||||||
void SetMySqlConnectionString(string connectionString);
|
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();
|
string GetHrmsLookupConnectionString();
|
||||||
void SetHrmsLookupConnectionString(string connectionString);
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,13 @@ using UtopiaCanteenSystem.Models;
|
||||||
namespace UtopiaCanteenSystem.Services;
|
namespace UtopiaCanteenSystem.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up employee info from local HRMS MySQL by RFID (manufacturer_serial).
|
/// Employee lookup by RFID from local SQLite cache; menu authorization may still use HRMS.
|
||||||
/// Uses a separate connection from production sync.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IEmployeeLookupService
|
public interface IEmployeeLookupService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
Task<HrmsEmployeeInfo?> GetEmployeeByRfidAsync(string rfid, CancellationToken cancellationToken = default);
|
Task<HrmsEmployeeInfo?> GetEmployeeByRfidAsync(string rfid, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -7,9 +7,17 @@ namespace UtopiaCanteenSystem.Services;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IMealSessionResolver
|
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>
|
/// <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>
|
/// </summary>
|
||||||
//MealSession GetCurrentSession(DateTime nowLocal, string siteId);
|
|
||||||
ResolvedMealSession? GetCurrentSession(DateTime nowLocal, string siteId);
|
ResolvedMealSession? GetCurrentSession(DateTime nowLocal, string siteId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ using UtopiaCanteenSystem.Models;
|
||||||
namespace UtopiaCanteenSystem.Services;
|
namespace UtopiaCanteenSystem.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <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.
|
/// Uses same HRMS connection as employee lookup.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IMenuLookupService
|
public interface IMenuLookupService
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,6 @@ namespace UtopiaCanteenSystem.Services;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ISyncService
|
public interface ISyncService
|
||||||
{
|
{
|
||||||
/// <summary>Runs one sync: POST unsynced records to API and mark as synced on success.</summary>
|
/// <summary>Runs one sync: POST unsynced records to production/HRMS and mark as synced on success.</summary>
|
||||||
Task SyncNowAsync(CancellationToken cancellationToken = default);
|
Task<SyncNowResult> SyncNowAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,52 @@
|
||||||
using System;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using UtopiaCanteenSystem.Services.Logging;
|
||||||
|
|
||||||
namespace UtopiaCanteenSystem.Services;
|
namespace UtopiaCanteenSystem.Services;
|
||||||
|
|
||||||
public static class Logger
|
public static class Logger
|
||||||
{
|
{
|
||||||
private static readonly object _lock = new();
|
|
||||||
|
|
||||||
public static void Log(Exception ex, string context = "")
|
public static void Log(Exception ex, string context = "")
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (ex == null) return;
|
if (ex == null)
|
||||||
|
return;
|
||||||
|
|
||||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
if (FileLogger.IsConfigured)
|
||||||
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
|
|
||||||
{
|
{
|
||||||
|
FileLogger.Error(context, ex.Message, ex);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
WriteLegacyErrorLog(ex, context);
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// never throw from logger
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace UtopiaCanteenSystem.Services.Logging;
|
||||||
|
|
||||||
|
public enum FileLogTarget
|
||||||
|
{
|
||||||
|
Backend,
|
||||||
|
Client
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,281 +1,75 @@
|
||||||
using MySqlConnector;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using UtopiaCanteenSystem.Data;
|
||||||
using UtopiaCanteenSystem.Models;
|
using UtopiaCanteenSystem.Models;
|
||||||
|
|
||||||
namespace UtopiaCanteenSystem.Services;
|
namespace UtopiaCanteenSystem.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetches lunch menu from HRMS: lunch_menu_week (by location_site_id) → lunch_menu_item → menu_item.
|
/// Fetches lunch menu from local SQLite cache (lunch_menu_week → lunch_menu_item → menu_item).
|
||||||
/// Uses IConfigService.GetHrmsLookupConnectionString(). Only current week is considered.
|
|
||||||
/// </summary>
|
/// </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
|
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)
|
public async Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAsync(int siteIdNumeric, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Backwards-compatible: use today's local date.
|
return await GetMenuItemsForSiteAndDateAsync(
|
||||||
return await GetMenuItemsForSiteAndDateAsync(siteIdNumeric, DateTime.Today, "NonManagement","no-active-meal-session", cancellationToken).ConfigureAwait(false);
|
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(
|
public async Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAndDateAsync(
|
||||||
int siteIdNumeric,
|
int siteIdNumeric,
|
||||||
DateTime menuDateLocal,
|
DateTime menuDateLocal,
|
||||||
string gradeType,
|
string gradeType,
|
||||||
string mealName, // Add mealName parameter
|
string mealName,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var connectionString = _configService.GetHrmsLookupConnectionString();
|
if (siteIdNumeric <= 0)
|
||||||
if (string.IsNullOrWhiteSpace(connectionString))
|
|
||||||
return Array.Empty<HrmsMenuItem>();
|
return Array.Empty<HrmsMenuItem>();
|
||||||
|
|
||||||
// Updated SQL to include meal_name filter and select meal_name
|
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
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;";
|
|
||||||
|
|
||||||
|
if (!await db.MenuItemCache.AnyAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
return Array.Empty<HrmsMenuItem>();
|
||||||
|
|
||||||
|
var menuDate = menuDateLocal.Date;
|
||||||
var itemFor = HrmsMenuItemForMapping.FromGradeType(gradeType);
|
var itemFor = HrmsMenuItemForMapping.FromGradeType(gradeType);
|
||||||
|
var meal = mealName?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
await using var conn = new MySqlConnection(connectionString);
|
var query =
|
||||||
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
from w in db.LunchMenuWeekCache.AsNoTracking()
|
||||||
await using var cmd = new MySqlCommand(sql, conn);
|
join li in db.LunchMenuItemCache.AsNoTracking() on w.HrmsId equals li.LunchMenuWeekHrmsId
|
||||||
cmd.Parameters.AddWithValue("@siteId", siteIdNumeric);
|
join mi in db.MenuItemCache.AsNoTracking() on li.MenuItemHrmsId equals mi.HrmsId
|
||||||
cmd.Parameters.AddWithValue("@menuDate", menuDateLocal.Date);
|
where w.LocationSiteId == siteIdNumeric
|
||||||
cmd.Parameters.AddWithValue("@mealName", mealName); // Add mealName parameter
|
&& w.WeekStartDate != null
|
||||||
cmd.Parameters.AddWithValue("@itemFor", itemFor);
|
&& 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 = (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
|
||||||
|
};
|
||||||
|
|
||||||
var list = new List<HrmsMenuItem>();
|
return await query.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
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 = GetString(reader, 6) // Map meal_name from column index 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
using UtopiaCanteenSystem.ViewModels;
|
using UtopiaCanteenSystem.ViewModels;
|
||||||
|
|
||||||
|
using UtopiaCanteenSystem.Services.Logging;
|
||||||
|
|
||||||
namespace UtopiaCanteenSystem.Services;
|
namespace UtopiaCanteenSystem.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -28,7 +30,7 @@ public class NavigationService : INavigationService
|
||||||
private readonly Func<ScannerDashboardViewModel> _scannerVm;
|
private readonly Func<ScannerDashboardViewModel> _scannerVm;
|
||||||
private readonly Func<MainDashboardViewModel> _dashboardVm;
|
private readonly Func<MainDashboardViewModel> _dashboardVm;
|
||||||
private readonly Func<AdminSettingsAuthViewModel> _adminSettingsAuthVm;
|
private readonly Func<AdminSettingsAuthViewModel> _adminSettingsAuthVm;
|
||||||
private readonly Func<SettingsViewModel> _settingsVm;
|
private readonly Func<object> _settingsVm;
|
||||||
private readonly Func<MealSchedulesViewModel> _mealSchedulesVm;
|
private readonly Func<MealSchedulesViewModel> _mealSchedulesVm;
|
||||||
|
|
||||||
public NavigationService(
|
public NavigationService(
|
||||||
|
|
@ -37,7 +39,7 @@ public class NavigationService : INavigationService
|
||||||
Func<ScannerDashboardViewModel> scannerVm,
|
Func<ScannerDashboardViewModel> scannerVm,
|
||||||
Func<MainDashboardViewModel> dashboardVm,
|
Func<MainDashboardViewModel> dashboardVm,
|
||||||
Func<AdminSettingsAuthViewModel> adminSettingsAuthVm,
|
Func<AdminSettingsAuthViewModel> adminSettingsAuthVm,
|
||||||
Func<SettingsViewModel> settingsVm,
|
Func<object> settingsVm,
|
||||||
Func<MealSchedulesViewModel> mealSchedulesVm)
|
Func<MealSchedulesViewModel> mealSchedulesVm)
|
||||||
{
|
{
|
||||||
_session = session;
|
_session = session;
|
||||||
|
|
@ -105,6 +107,7 @@ public class NavigationService : INavigationService
|
||||||
NavigateToAdminLogin();
|
NavigateToAdminLogin();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
FileLogger.Info("ClientNav", "Meal Schedules screen opened.");
|
||||||
CurrentViewModel = _mealSchedulesVm();
|
CurrentViewModel = _mealSchedulesVm();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
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))
|
if (string.IsNullOrWhiteSpace(cardId))
|
||||||
return new ScanResult(false, "Card ID cannot be empty.", 0);
|
return new ScanResult(false, "Card ID cannot be empty.", 0);
|
||||||
|
|
||||||
cardId = cardId.Trim();
|
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
|
// HRMS lookup: reject if card not registered
|
||||||
var employee = _employeeLookup.GetEmployeeByRfidAsync(cardId).GetAwaiter().GetResult();
|
var employee = _employeeLookup.GetEmployeeByRfidAsync(cardId).GetAwaiter().GetResult();
|
||||||
if (employee == null)
|
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 nowUtc = DateTime.UtcNow;
|
||||||
var nowLocal = DateTime.Now;
|
var nowLocal = DateTime.Now;
|
||||||
|
|
||||||
// SITE VERIFICATION: Check if employee is assigned to this site
|
// SITE VERIFICATION: Check if employee is assigned to this site
|
||||||
var currentSiteId = _configService.GetSiteId();
|
var currentSiteId = siteForKiosk;
|
||||||
var normalizedCurrentSite = NormalizeSiteId(currentSiteId);
|
var normalizedCurrentSite = NormalizeSiteId(currentSiteId);
|
||||||
|
|
||||||
var employeeSiteId = !string.IsNullOrWhiteSpace(employee.LocationSiteId)
|
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)
|
var siteIdForSession = !string.IsNullOrWhiteSpace(employee.LocationSiteId)
|
||||||
? employee.LocationSiteId.Trim()
|
? employee.LocationSiteId.Trim()
|
||||||
: _configService.GetSiteId();
|
: siteForKiosk;
|
||||||
|
|
||||||
|
if (!_mealSessionResolver.IsOfflineMealDataAvailable())
|
||||||
|
return new ScanResult(false, DbMealSessionResolver.OfflineCacheMissingMessage, 0, employee);
|
||||||
|
|
||||||
var resolvedSession = _mealSessionResolver.GetCurrentSession(nowLocal, siteIdForSession);
|
var resolvedSession = _mealSessionResolver.GetCurrentSession(nowLocal, siteIdForSession);
|
||||||
if (resolvedSession == null || resolvedSession.Session == MealSession.None)
|
if (resolvedSession == null || resolvedSession.Session == MealSession.None)
|
||||||
|
|
@ -277,24 +303,29 @@ public class RfidService : IRfidService
|
||||||
|
|
||||||
using var db = _dbFactory.CreateDbContext();
|
using var db = _dbFactory.CreateDbContext();
|
||||||
|
|
||||||
// Once-per-session-per-day rule: same card, same session, same local day is not allowed.
|
var 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 startOfTodayLocal = DateTime.Today;
|
||||||
var startOfTomorrowLocal = startOfTodayLocal.AddDays(1);
|
var startOfTomorrowLocal = startOfTodayLocal.AddDays(1);
|
||||||
var startUtc = startOfTodayLocal.ToUniversalTime();
|
var startUtc = startOfTodayLocal.ToUniversalTime();
|
||||||
var endUtc = startOfTomorrowLocal.ToUniversalTime();
|
var endUtc = startOfTomorrowLocal.ToUniversalTime();
|
||||||
|
|
||||||
var alreadyScannedThisSessionToday = db.LunchOrderTransactions
|
var dupCandidates = db.LunchOrderTransactions
|
||||||
.Where(r => r.CardId == cardId)
|
.Where(r => r.CardId == cardId)
|
||||||
.Where(r => r.MealSessionCode == sessionCode)
|
.Where(r => r.MealSessionCode == sessionCode)
|
||||||
.Where(r => r.ScanTime >= startUtc && r.ScanTime < endUtc)
|
.Where(r => r.ScanTime >= startUtc && r.ScanTime < endUtc)
|
||||||
.OrderByDescending(r => r.ScanTime)
|
.OrderByDescending(r => r.ScanTime)
|
||||||
.FirstOrDefault();
|
.ToList();
|
||||||
|
|
||||||
|
var alreadyScannedThisSessionToday = dupCandidates.FirstOrDefault(r =>
|
||||||
|
string.Equals(NormalizeSiteId(r.SiteId), normalizedSiteForDup, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
if (alreadyScannedThisSessionToday != null)
|
if (alreadyScannedThisSessionToday != null)
|
||||||
{
|
return new ScanResult(false, "Meal already taken.", 0, employee, session);
|
||||||
var sessionName = resolvedSession.MealName;
|
|
||||||
return new ScanResult(false, $"This card is already scanned for {sessionName} today.", 0, employee, session);
|
|
||||||
}
|
|
||||||
|
|
||||||
var interval = _configService.GetScanInterval();
|
var interval = _configService.GetScanInterval();
|
||||||
var timeoutSeconds = (int)Math.Max(1, interval.TotalSeconds);
|
var timeoutSeconds = (int)Math.Max(1, interval.TotalSeconds);
|
||||||
|
|
@ -302,11 +333,14 @@ public class RfidService : IRfidService
|
||||||
var windowStart = nowUtc.AddSeconds(-timeoutSeconds);
|
var windowStart = nowUtc.AddSeconds(-timeoutSeconds);
|
||||||
|
|
||||||
// Safety debounce: short cooldown per card to prevent accidental double-tap.
|
// 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.CardId == cardId)
|
||||||
.Where(r => r.ScanTime >= windowStart)
|
.Where(r => r.ScanTime >= windowStart)
|
||||||
.OrderByDescending(r => r.ScanTime)
|
.OrderByDescending(r => r.ScanTime)
|
||||||
.FirstOrDefault();
|
.ToList();
|
||||||
|
|
||||||
|
var lastInWindow = windowCandidates.FirstOrDefault(r =>
|
||||||
|
string.Equals(NormalizeSiteId(r.SiteId), normalizedSiteForDup, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
if (lastInWindow != null)
|
if (lastInWindow != null)
|
||||||
{
|
{
|
||||||
|
|
@ -319,19 +353,7 @@ public class RfidService : IRfidService
|
||||||
session);
|
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 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
|
// Resolve menu items for this scan
|
||||||
var mealItemsDisplay = string.Empty;
|
var mealItemsDisplay = string.Empty;
|
||||||
|
|
@ -346,38 +368,10 @@ public class RfidService : IRfidService
|
||||||
.GetAwaiter()
|
.GetAwaiter()
|
||||||
.GetResult();
|
.GetResult();
|
||||||
|
|
||||||
var authorized = menuItems
|
var matching = menuItems
|
||||||
.Where(i => i.MenuItemId > 0)
|
.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();
|
.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
|
var names = matching
|
||||||
.Select(i => i.ItemName)
|
.Select(i => i.ItemName)
|
||||||
.Where(n => !string.IsNullOrWhiteSpace(n))
|
.Where(n => !string.IsNullOrWhiteSpace(n))
|
||||||
|
|
@ -402,8 +396,8 @@ public class RfidService : IRfidService
|
||||||
ScanTime = nowUtc,
|
ScanTime = nowUtc,
|
||||||
IsSynced = false,
|
IsSynced = false,
|
||||||
SiteId = siteId,
|
SiteId = siteId,
|
||||||
DeviceId = _configService.GetDeviceId(),
|
DeviceId = deviceId,
|
||||||
IpAddress = GetLocalIpAddress(),
|
IpAddress = ipAddress,
|
||||||
MealSessionCode = sessionCode,
|
MealSessionCode = sessionCode,
|
||||||
ParentDocumentId = employee.ParentDocumentId ?? string.Empty,
|
ParentDocumentId = employee.ParentDocumentId ?? string.Empty,
|
||||||
EmployeeId = employee.EmployeeId ?? string.Empty,
|
EmployeeId = employee.EmployeeId ?? string.Empty,
|
||||||
|
|
@ -425,17 +419,17 @@ public class RfidService : IRfidService
|
||||||
|
|
||||||
// If we can reach production now, try posting immediately.
|
// If we can reach production now, try posting immediately.
|
||||||
// On success (or duplicate already in production), SyncService will mark IsSynced=1.
|
// On success (or duplicate already in production), SyncService will mark IsSynced=1.
|
||||||
if (CanReachProduction())
|
// if (CanReachProduction())
|
||||||
{
|
// {
|
||||||
try
|
// try
|
||||||
{
|
// {
|
||||||
_syncService.SyncNowAsync().GetAwaiter().GetResult();
|
// _syncService.SyncNowAsync().GetAwaiter().GetResult();
|
||||||
}
|
// }
|
||||||
catch (Exception ex)
|
// catch (Exception ex)
|
||||||
{
|
// {
|
||||||
Logger.Log(ex, "RfidService.ProcessScanDetailed immediate sync");
|
// Logger.Log(ex, "RfidService.ProcessScanDetailed immediate sync");
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
return new ScanResult(true, "Order recorded successfully.", 0, employee, session, normalizedEmployeeSite, normalizedCurrentSite);
|
return new ScanResult(true, "Order recorded successfully.", 0, employee, session, normalizedEmployeeSite, normalizedCurrentSite);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||||
using MySqlConnector;
|
using MySqlConnector;
|
||||||
using UtopiaCanteenSystem.Data;
|
using UtopiaCanteenSystem.Data;
|
||||||
using UtopiaCanteenSystem.Models;
|
using UtopiaCanteenSystem.Models;
|
||||||
|
using UtopiaCanteenSystem.Services.Logging;
|
||||||
|
|
||||||
namespace UtopiaCanteenSystem.Services;
|
namespace UtopiaCanteenSystem.Services;
|
||||||
|
|
||||||
|
|
@ -20,21 +21,16 @@ public class SyncService : ISyncService
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SyncNowAsync(CancellationToken cancellationToken = default)
|
public async Task<SyncNowResult> SyncNowAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var productionConnStr = _configService.GetMySqlConnectionString();
|
var productionConnStr = _configService.GetMySqlConnectionString();
|
||||||
if (string.IsNullOrWhiteSpace(productionConnStr))
|
if (string.IsNullOrWhiteSpace(productionConnStr))
|
||||||
{
|
{
|
||||||
System.Diagnostics.Debug.WriteLine("MySQL connection string (production) not configured; skipping sync.");
|
FileLogger.Warn("OrderSync", "MySQL connection string is not configured; skipping order sync.");
|
||||||
return;
|
return new SyncNowResult { SkippedNoConnection = true };
|
||||||
}
|
}
|
||||||
|
|
||||||
var hrmsConnStr = _configService.GetHrmsLookupConnectionString();
|
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;
|
List<ScanRecord> toSync;
|
||||||
using (var db = _dbFactory.CreateDbContext())
|
using (var db = _dbFactory.CreateDbContext())
|
||||||
|
|
@ -46,11 +42,20 @@ public class SyncService : ISyncService
|
||||||
.ConfigureAwait(false);
|
.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.
|
// Always run day-end cleanup (remove synced rows from previous days), even when there's nothing to sync.
|
||||||
await CleanupOldSyncedRowsAsync(cancellationToken).ConfigureAwait(false);
|
await CleanupOldSyncedRowsAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (toSync.Count == 0)
|
if (toSync.Count == 0)
|
||||||
return;
|
{
|
||||||
|
FileLogger.Info("OrderSync", "Order sync completed. No pending orders.");
|
||||||
|
return new SyncNowResult();
|
||||||
|
}
|
||||||
|
|
||||||
// Production: lunch_order_transactions (GetMySqlConnectionString)
|
// Production: lunch_order_transactions (GetMySqlConnectionString)
|
||||||
const string insertTxnSql = @"
|
const string insertTxnSql = @"
|
||||||
|
|
@ -59,7 +64,7 @@ public class SyncService : ISyncService
|
||||||
VALUES
|
VALUES
|
||||||
(@ScanTimeUtc, @SiteId, @DeviceId, @CardId, @IpAddress, UTC_TIMESTAMP(3))";
|
(@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 = @"
|
const string existsOrderSql = @"
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM lunch_order
|
FROM lunch_order
|
||||||
|
|
@ -140,6 +145,7 @@ public class SyncService : ISyncService
|
||||||
if (await IsDuplicateMealInProductionAsync(record, hrmsConnStr, cancellationToken).ConfigureAwait(false))
|
if (await IsDuplicateMealInProductionAsync(record, hrmsConnStr, cancellationToken).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
syncedIds.Add(record.Id);
|
syncedIds.Add(record.Id);
|
||||||
|
duplicatesSkipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -279,11 +285,12 @@ public class SyncService : ISyncService
|
||||||
}
|
}
|
||||||
|
|
||||||
syncedIds.Add(record.Id);
|
syncedIds.Add(record.Id);
|
||||||
|
posted++;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
System.Diagnostics.Debug.WriteLine($"Sync record failed (SQLite Id={record.Id}): {ex.Message}");
|
failed++;
|
||||||
// Leave unsynced; will retry later.
|
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)
|
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)
|
private static string GenerateLunchOrderCode(long id, DateTime date)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 0–365.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!int.TryParse(ScanIntervalHours, out var hours) || hours < 0 || hours > 23)
|
||||||
|
{
|
||||||
|
SetSaveError("Scan interval Hours must be 0–23.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!int.TryParse(ScanIntervalMinutes, out var minutes) || minutes < 0 || minutes > 59)
|
||||||
|
{
|
||||||
|
SetSaveError("Scan interval Minutes must be 0–59.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!int.TryParse(ScanIntervalSeconds, out var seconds) || seconds < 0 || seconds > 59)
|
||||||
|
{
|
||||||
|
SetSaveError("Scan interval Seconds must be 0–59.");
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
@ -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="" 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="" 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="" 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 & 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="" FontFamily="Segoe MDL2 Assets" FontSize="16"
|
||||||
|
Foreground="{StaticResource PrimaryAccent}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<StackPanel VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Application Mode & 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="" 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="" 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="" 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="" 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="" 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>
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace UtopiaCanteen.Shared;
|
||||||
|
|
||||||
|
public sealed class CacheStatusResponse
|
||||||
|
{
|
||||||
|
public DateTime? LastEmployeeRfidCacheSyncUtc { get; set; }
|
||||||
|
public DateTime? LastMealMenuCacheSyncUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace UtopiaCanteen.Shared;
|
||||||
|
|
||||||
|
public sealed class EmployeeLocationSiteResponse
|
||||||
|
{
|
||||||
|
public string? LocationSiteId { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace UtopiaCanteen.Shared;
|
||||||
|
|
||||||
|
public sealed class StatsDto
|
||||||
|
{
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
|
<EnableDefaultCompileItems>true</EnableDefaultCompileItems>
|
||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
|
@ -13,6 +14,13 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<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="CommunityToolkit.Mvvm" Version="8.2.2" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using UtopiaCanteenSystem.Services;
|
using UtopiaCanteenSystem.Services;
|
||||||
|
using UtopiaCanteenSystem.Services.Logging;
|
||||||
|
|
||||||
namespace UtopiaCanteenSystem.ViewModels;
|
namespace UtopiaCanteenSystem.ViewModels;
|
||||||
|
|
||||||
|
|
@ -12,6 +13,7 @@ public partial class AdminLoginViewModel : ObservableObject
|
||||||
private readonly IConfigService _config;
|
private readonly IConfigService _config;
|
||||||
private readonly IAdminAuditService _adminAudit;
|
private readonly IAdminAuditService _adminAudit;
|
||||||
private readonly IEmployeeLookupService _employeeLookup;
|
private readonly IEmployeeLookupService _employeeLookup;
|
||||||
|
private readonly ICanteenBackendApiClient? _backendApi;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _username = string.Empty;
|
private string _username = string.Empty;
|
||||||
|
|
@ -35,7 +37,8 @@ public partial class AdminLoginViewModel : ObservableObject
|
||||||
INavigationService navigation,
|
INavigationService navigation,
|
||||||
IConfigService config,
|
IConfigService config,
|
||||||
IAdminAuditService adminAudit,
|
IAdminAuditService adminAudit,
|
||||||
IEmployeeLookupService employeeLookup)
|
IEmployeeLookupService employeeLookup,
|
||||||
|
ICanteenBackendApiClient? backendApi = null)
|
||||||
{
|
{
|
||||||
_authService = authService;
|
_authService = authService;
|
||||||
_session = session;
|
_session = session;
|
||||||
|
|
@ -43,6 +46,7 @@ public partial class AdminLoginViewModel : ObservableObject
|
||||||
_config = config;
|
_config = config;
|
||||||
_adminAudit = adminAudit;
|
_adminAudit = adminAudit;
|
||||||
_employeeLookup = employeeLookup;
|
_employeeLookup = employeeLookup;
|
||||||
|
_backendApi = backendApi;
|
||||||
|
|
||||||
RememberCredentials = _config.GetRememberAdminCredentials();
|
RememberCredentials = _config.GetRememberAdminCredentials();
|
||||||
if (RememberCredentials)
|
if (RememberCredentials)
|
||||||
|
|
@ -81,20 +85,21 @@ public partial class AdminLoginViewModel : ObservableObject
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
ErrorMessage = "Invalid username or password.";
|
ErrorMessage = "Invalid username or password.";
|
||||||
|
FileLogger.Warn("ClientLogin", $"Admin login failed for user '{user}'.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_session.SetAdminAuthenticated(user, result.EmployeeId);
|
_session.SetAdminAuthenticated(user, result.EmployeeId);
|
||||||
|
|
||||||
try
|
await AdminLoginHelper.ApplyAdminLoginDefaultsAsync(
|
||||||
{
|
result.EmployeeId,
|
||||||
var siteId = await _employeeLookup.GetLocationSiteIdByEmployeeSerialAsync(result.EmployeeId).ConfigureAwait(true);
|
_config,
|
||||||
_config.ApplyLocationSiteIdFromAuth(siteId);
|
_employeeLookup,
|
||||||
}
|
_backendApi).ConfigureAwait(true);
|
||||||
catch
|
|
||||||
{
|
FileLogger.Info(
|
||||||
// HRMS lookup optional; login still succeeds.
|
"ClientLogin",
|
||||||
}
|
$"Admin login succeeded. EmployeeId={result.EmployeeId}, AdminCardId saved, SiteId={_config.GetSiteId()}");
|
||||||
|
|
||||||
// Persist who logged in (for audit / reporting).
|
// Persist who logged in (for audit / reporting).
|
||||||
await _adminAudit.RecordLoginAsync(user, result.EmployeeId).ConfigureAwait(false);
|
await _adminAudit.RecordLoginAsync(user, result.EmployeeId).ConfigureAwait(false);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ public partial class AdminSettingsAuthViewModel : ObservableObject
|
||||||
private readonly INavigationService _navigation;
|
private readonly INavigationService _navigation;
|
||||||
private readonly IConfigService _config;
|
private readonly IConfigService _config;
|
||||||
private readonly IEmployeeLookupService _employeeLookup;
|
private readonly IEmployeeLookupService _employeeLookup;
|
||||||
|
private readonly ICanteenBackendApiClient? _backendApi;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _username = string.Empty;
|
private string _username = string.Empty;
|
||||||
|
|
@ -33,13 +34,15 @@ public partial class AdminSettingsAuthViewModel : ObservableObject
|
||||||
AppSession session,
|
AppSession session,
|
||||||
INavigationService navigation,
|
INavigationService navigation,
|
||||||
IConfigService config,
|
IConfigService config,
|
||||||
IEmployeeLookupService employeeLookup)
|
IEmployeeLookupService employeeLookup,
|
||||||
|
ICanteenBackendApiClient? backendApi = null)
|
||||||
{
|
{
|
||||||
_authService = authService;
|
_authService = authService;
|
||||||
_session = session;
|
_session = session;
|
||||||
_navigation = navigation;
|
_navigation = navigation;
|
||||||
_config = config;
|
_config = config;
|
||||||
_employeeLookup = employeeLookup;
|
_employeeLookup = employeeLookup;
|
||||||
|
_backendApi = backendApi;
|
||||||
|
|
||||||
RememberCredentials = _config.GetRememberAdminCredentials();
|
RememberCredentials = _config.GetRememberAdminCredentials();
|
||||||
if (RememberCredentials)
|
if (RememberCredentials)
|
||||||
|
|
@ -90,15 +93,11 @@ public partial class AdminSettingsAuthViewModel : ObservableObject
|
||||||
// Refresh session details (who authenticated for Settings).
|
// Refresh session details (who authenticated for Settings).
|
||||||
_session.SetAdminAuthenticated(user, result.EmployeeId);
|
_session.SetAdminAuthenticated(user, result.EmployeeId);
|
||||||
|
|
||||||
try
|
await AdminLoginHelper.ApplyAdminLoginDefaultsAsync(
|
||||||
{
|
result.EmployeeId,
|
||||||
var siteId = await _employeeLookup.GetLocationSiteIdByEmployeeSerialAsync(result.EmployeeId).ConfigureAwait(true);
|
_config,
|
||||||
_config.ApplyLocationSiteIdFromAuth(siteId);
|
_employeeLookup,
|
||||||
}
|
_backendApi).ConfigureAwait(true);
|
||||||
catch
|
|
||||||
{
|
|
||||||
// HRMS lookup optional; auth still succeeds.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist credentials only if user opted in.
|
// Persist credentials only if user opted in.
|
||||||
_config.SetRememberAdminCredentials(RememberCredentials);
|
_config.SetRememberAdminCredentials(RememberCredentials);
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||||
using MySqlConnector;
|
using MySqlConnector;
|
||||||
using UtopiaCanteenSystem.Models;
|
using UtopiaCanteenSystem.Models;
|
||||||
using UtopiaCanteenSystem.Services;
|
using UtopiaCanteenSystem.Services;
|
||||||
|
using UtopiaCanteenSystem.Services.Logging;
|
||||||
|
|
||||||
namespace UtopiaCanteenSystem.ViewModels;
|
namespace UtopiaCanteenSystem.ViewModels;
|
||||||
|
|
||||||
|
|
@ -289,6 +290,7 @@ public partial class MealSchedulesViewModel : ObservableObject
|
||||||
_navigation = navigation;
|
_navigation = navigation;
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
_currentSiteId = (_configService.GetSiteId() ?? string.Empty).Trim();
|
_currentSiteId = (_configService.GetSiteId() ?? string.Empty).Trim();
|
||||||
|
FileLogger.Info("MealSchedules", "Meal Schedules view opened; loading schedules from backend.");
|
||||||
_ = LoadSchedulesAsync();
|
_ = LoadSchedulesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -352,12 +354,13 @@ public partial class MealSchedulesViewModel : ObservableObject
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
IsError = true;
|
IsError = true;
|
||||||
|
FileLogger.Error("MealSchedules", "Could not load schedules from backend.", ex);
|
||||||
|
|
||||||
var msg = ex is MySqlException mysql
|
var msg = ex is MySqlException mysql
|
||||||
? $"{mysql.Message}{Environment.NewLine}{Environment.NewLine}{mysql.StackTrace}"
|
? $"{mysql.Message}{Environment.NewLine}{Environment.NewLine}{mysql.StackTrace}"
|
||||||
: $"{ex.Message}{Environment.NewLine}{Environment.NewLine}{ex.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);
|
MessageBox.Show(msg, "Meal Schedules – Load Error", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using UtopiaCanteenSystem.Models;
|
using UtopiaCanteenSystem.Models;
|
||||||
using UtopiaCanteenSystem.Services;
|
using UtopiaCanteenSystem.Services;
|
||||||
|
using UtopiaCanteenSystem.Services.Logging;
|
||||||
|
|
||||||
namespace UtopiaCanteenSystem.ViewModels;
|
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.Tick += (_, _) => CurrentTime = DateTime.Now.ToString("MM/dd/yyyy, hh:mm:ss tt");
|
||||||
_clockTimer.Start();
|
_clockTimer.Start();
|
||||||
|
|
||||||
// Load initial site from config.
|
RefreshSiteFromConfig();
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RefreshScannerStatus();
|
RefreshScannerStatus();
|
||||||
_ = RefreshDashboardAsync();
|
_ = RefreshDashboardAsync();
|
||||||
|
|
@ -361,6 +347,13 @@ public partial class ScannerDashboardViewModel : ObservableObject
|
||||||
return localDate.ToString("MMM d");
|
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)
|
partial void OnSiteNumberChanged(string value)
|
||||||
{
|
{
|
||||||
// Numeric only: filter to digits so display stays valid.
|
// Numeric only: filter to digits so display stays valid.
|
||||||
|
|
@ -578,9 +571,11 @@ public partial class ScannerDashboardViewModel : ObservableObject
|
||||||
if (string.IsNullOrWhiteSpace(cardId))
|
if (string.IsNullOrWhiteSpace(cardId))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
FileLogger.Info("ClientScan", $"Scan started. CardId={LogMasking.MaskCardId(cardId)}");
|
||||||
var result = _rfidService.ProcessScanDetailed(cardId);
|
var result = _rfidService.ProcessScanDetailed(cardId);
|
||||||
IsSuccess = result.Success;
|
IsSuccess = result.Success;
|
||||||
Message = result.Message;
|
Message = result.Message;
|
||||||
|
FileLogger.Info("ClientScan", $"Scan UI message. Success={result.Success}, Message={result.Message}");
|
||||||
|
|
||||||
// Check if this is a site mismatch error
|
// Check if this is a site mismatch error
|
||||||
if (!result.Success && result.Message.Contains("not allowed to scan here"))
|
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)
|
private static string BuildCsvFromScanRecords(IReadOnlyList<ScanRecord> records)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
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)
|
foreach (var r in records)
|
||||||
{
|
{
|
||||||
var localScanTime = r.ScanTime.ToLocalTime();
|
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(r.Id);
|
||||||
sb.Append(',');
|
sb.Append(',');
|
||||||
sb.Append(ToExcelText(r.CardId));
|
sb.Append(ToExcelText(r.CardId));
|
||||||
|
|
@ -957,7 +956,9 @@ public partial class ScannerDashboardViewModel : ObservableObject
|
||||||
sb.Append(',');
|
sb.Append(',');
|
||||||
sb.Append(EscapeCsv(r.Department ?? string.Empty));
|
sb.Append(EscapeCsv(r.Department ?? string.Empty));
|
||||||
sb.Append(',');
|
sb.Append(',');
|
||||||
sb.Append(EscapeCsv(r.MealItems ?? string.Empty));
|
sb.Append(EscapeCsv(mealSession));
|
||||||
|
sb.Append(',');
|
||||||
|
sb.Append(EscapeCsv(meal));
|
||||||
sb.Append(',');
|
sb.Append(',');
|
||||||
sb.Append(r.TotalPrice.ToString("0.##", CultureInfo.InvariantCulture));
|
sb.Append(r.TotalPrice.ToString("0.##", CultureInfo.InvariantCulture));
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
|
|
@ -1052,4 +1053,3 @@ public partial class ScannerDashboardViewModel : ObservableObject
|
||||||
_debounceTimer.Stop();
|
_debounceTimer.Stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,19 @@
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using UtopiaCanteenSystem.Models;
|
||||||
using UtopiaCanteenSystem.Services;
|
using UtopiaCanteenSystem.Services;
|
||||||
|
|
||||||
namespace UtopiaCanteenSystem.ViewModels;
|
namespace UtopiaCanteenSystem.ViewModels;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public partial class SettingsViewModel : ObservableObject
|
public partial class SettingsViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly IConfigService _configService;
|
private readonly IConfigService _configService;
|
||||||
private readonly INavigationService _navigation;
|
private readonly INavigationService _navigation;
|
||||||
private readonly IAdminAuditService _adminAudit;
|
private readonly IAdminAuditService _adminAudit;
|
||||||
private readonly ISyncService _syncService;
|
private readonly ICanteenBackendApiClient _backendApi;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _syncApiEndpoint = string.Empty;
|
private string _syncApiEndpoint = string.Empty;
|
||||||
|
|
@ -44,9 +45,40 @@ public partial class SettingsViewModel : ObservableObject
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _isPosting;
|
private bool _isPosting;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isSyncingCache;
|
||||||
|
|
||||||
public bool CanPostNow => !IsPosting;
|
[ObservableProperty]
|
||||||
public string PostButtonText => IsPosting ? "Posting..." : "Post Data";
|
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)
|
partial void OnIsPostingChanged(bool value)
|
||||||
{
|
{
|
||||||
|
|
@ -54,20 +86,41 @@ public partial class SettingsViewModel : ObservableObject
|
||||||
OnPropertyChanged(nameof(CanPostNow));
|
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(
|
public SettingsViewModel(
|
||||||
IConfigService configService,
|
IConfigService configService,
|
||||||
INavigationService navigation,
|
INavigationService navigation,
|
||||||
IAdminAuditService adminAudit,
|
IAdminAuditService adminAudit,
|
||||||
ISyncService syncService)
|
ICanteenBackendApiClient backendApi)
|
||||||
{
|
{
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
_navigation = navigation;
|
_navigation = navigation;
|
||||||
_adminAudit = adminAudit;
|
_adminAudit = adminAudit;
|
||||||
_syncService = syncService;
|
_backendApi = backendApi;
|
||||||
LoadFromConfig();
|
LoadFromConfig();
|
||||||
|
_ = RefreshCacheSyncTimestampsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Loads config from service.</summary>
|
|
||||||
public void LoadFromConfig()
|
public void LoadFromConfig()
|
||||||
{
|
{
|
||||||
SyncApiEndpoint = _configService.GetSyncApiEndpoint();
|
SyncApiEndpoint = _configService.GetSyncApiEndpoint();
|
||||||
|
|
@ -76,12 +129,37 @@ public partial class SettingsViewModel : ObservableObject
|
||||||
ScanIntervalMinutes = _configService.GetScanIntervalMinutes().ToString();
|
ScanIntervalMinutes = _configService.GetScanIntervalMinutes().ToString();
|
||||||
ScanIntervalSeconds = _configService.GetScanIntervalSeconds().ToString();
|
ScanIntervalSeconds = _configService.GetScanIntervalSeconds().ToString();
|
||||||
|
|
||||||
// Always prefer the latest admin login from the database.
|
AdminCardId = AdminLoginHelper.ResolveAdminCardIdForDisplay(_configService, adminAudit: _adminAudit);
|
||||||
var last = _adminAudit.GetLastLogin();
|
|
||||||
if (last != null && !string.IsNullOrWhiteSpace(last.EmployeeId))
|
SelectedAppMode = _configService.GetAppMode() == AppMode.Client ? "Client" : "Server";
|
||||||
AdminCardId = last.EmployeeId;
|
BackendBaseUrl = string.IsNullOrWhiteSpace(_configService.GetCentralServerBaseUrl())
|
||||||
else
|
? _configService.GetBackendBaseUrl()
|
||||||
AdminCardId = "ADMIN";
|
: _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]
|
[RelayCommand]
|
||||||
|
|
@ -124,15 +202,32 @@ public partial class SettingsViewModel : ObservableObject
|
||||||
return;
|
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.SetSyncApiEndpoint(SyncApiEndpoint?.Trim() ?? string.Empty);
|
||||||
_configService.SetScanIntervalDays(days);
|
_configService.SetScanIntervalDays(days);
|
||||||
_configService.SetScanIntervalHours(hours);
|
_configService.SetScanIntervalHours(hours);
|
||||||
_configService.SetScanIntervalMinutes(minutes);
|
_configService.SetScanIntervalMinutes(minutes);
|
||||||
_configService.SetScanIntervalSeconds(seconds);
|
_configService.SetScanIntervalSeconds(seconds);
|
||||||
_configService.SetAdminCardId(AdminCardId?.Trim() ?? string.Empty);
|
_configService.SetAdminCardId(AdminCardId?.Trim() ?? string.Empty);
|
||||||
SaveMessage = "Settings saved.";
|
SaveMessage = "Settings saved. Restart the app after changing App mode or listen URLs.";
|
||||||
IsError = false;
|
IsError = false;
|
||||||
IsSaving = false;
|
IsSaving = false;
|
||||||
|
OnPropertyChanged(nameof(EffectiveBackendUrlDisplay));
|
||||||
|
OnPropertyChanged(nameof(HasBackendUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
|
|
@ -141,13 +236,58 @@ public partial class SettingsViewModel : ObservableObject
|
||||||
_navigation.NavigateBackFromSettings(GetDashboardTimeout());
|
_navigation.NavigateBackFromSettings(GetDashboardTimeout());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Opens Meal Schedules admin screen (Phase 2.5).</summary>
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenMealSchedules()
|
private void OpenMealSchedules()
|
||||||
{
|
{
|
||||||
_navigation.NavigateToMealSchedules();
|
_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]
|
[RelayCommand]
|
||||||
private async Task PostDataNow()
|
private async Task PostDataNow()
|
||||||
{
|
{
|
||||||
|
|
@ -160,21 +300,30 @@ public partial class SettingsViewModel : ObservableObject
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// If not configured, do not attempt sync.
|
if (string.IsNullOrWhiteSpace(_configService.GetBackendBaseUrl()))
|
||||||
if (string.IsNullOrWhiteSpace(_configService.GetMySqlConnectionString()))
|
|
||||||
{
|
{
|
||||||
SaveMessage = "MySQL connection string not configured.";
|
SaveMessage = "Backend URL is not configured.";
|
||||||
IsError = true;
|
IsError = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _syncService.SyncNowAsync().ConfigureAwait(false);
|
if (!await _backendApi.HealthCheckAsync().ConfigureAwait(true))
|
||||||
SaveMessage = "Posted data to production.";
|
|
||||||
IsError = false;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
{
|
||||||
SaveMessage = "Failed to post data to production.";
|
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 (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Log(ex, "SettingsViewModel.PostDataNow");
|
||||||
|
SaveMessage = "Failed to post pending orders: " + ex.Message;
|
||||||
IsError = true;
|
IsError = true;
|
||||||
}
|
}
|
||||||
finally
|
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();
|
private TimeSpan GetDashboardTimeout() => _configService.GetScanInterval();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
<UserControl.Resources>
|
<UserControl.Resources>
|
||||||
|
<BooleanToVisibilityConverter x:Key="BoolToVis" />
|
||||||
<SolidColorBrush x:Key="AppBackground" Color="#F0F2F5"/>
|
<SolidColorBrush x:Key="AppBackground" Color="#F0F2F5"/>
|
||||||
<SolidColorBrush x:Key="CardBackground" Color="#FFFFFF"/>
|
<SolidColorBrush x:Key="CardBackground" Color="#FFFFFF"/>
|
||||||
<SolidColorBrush x:Key="PrimaryText" Color="#2D3748"/>
|
<SolidColorBrush x:Key="PrimaryText" Color="#2D3748"/>
|
||||||
|
|
@ -273,46 +274,82 @@
|
||||||
Foreground="{StaticResource MutedText}"
|
Foreground="{StaticResource MutedText}"
|
||||||
Margin="0,0,0,20" />
|
Margin="0,0,0,20" />
|
||||||
|
|
||||||
<!--<TextBlock Text="Sync API Endpoint (UIND)" FontSize="16" Foreground="{StaticResource PrimaryText}" FontWeight="SemiBold" />
|
<TextBlock Text="Application mode" FontSize="16" Foreground="{StaticResource PrimaryText}" FontWeight="SemiBold" />
|
||||||
<TextBox Text="{Binding SyncApiEndpoint, UpdateSourceTrigger=PropertyChanged}"
|
<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."
|
||||||
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,8" />
|
|
||||||
<TextBlock Text="Endpoint used to sync scan records."
|
|
||||||
FontSize="14"
|
FontSize="14"
|
||||||
Foreground="{StaticResource MutedText}"
|
Foreground="{StaticResource MutedText}"
|
||||||
Margin="0,0,0,20" />-->
|
Margin="0,4,0,8" />
|
||||||
<TextBlock Text="Sync Data To (UIND)"
|
<ComboBox ItemsSource="{Binding AppModeOptions}"
|
||||||
|
SelectedItem="{Binding SelectedAppMode, Mode=TwoWay}"
|
||||||
|
MinHeight="44"
|
||||||
FontSize="16"
|
FontSize="16"
|
||||||
Foreground="{StaticResource PrimaryText}"
|
Margin="0,0,0,12"
|
||||||
FontWeight="SemiBold" />
|
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>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="16" />
|
<ColumnDefinition Width="16" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<TextBlock Grid.Column="0"
|
<TextBlock Grid.Column="0"
|
||||||
Text="Post data to production"
|
Text="Post pending orders to production (UIND)"
|
||||||
FontSize="18"
|
FontSize="18"
|
||||||
Foreground="{StaticResource PrimaryText}"
|
Foreground="{StaticResource PrimaryText}"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Margin="4,0,0,0" />
|
Margin="4,0,0,0" />
|
||||||
|
|
||||||
<!-- Post button right next to textbox -->
|
|
||||||
<Button Grid.Column="2"
|
<Button Grid.Column="2"
|
||||||
Content="{Binding PostButtonText}"
|
Content="{Binding PostButtonText}"
|
||||||
Command="{Binding PostDataNowCommand}"
|
Command="{Binding PostDataNowCommand}"
|
||||||
Style="{StaticResource PrimaryButtonStyle}"
|
Style="{StaticResource PrimaryButtonStyle}"
|
||||||
MinWidth="150"
|
MinWidth="200"
|
||||||
IsEnabled="{Binding CanPostNow}" />
|
IsEnabled="{Binding CanPostNow}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<TextBlock Text=""
|
|
||||||
FontSize="14"
|
|
||||||
Foreground="{StaticResource MutedText}"
|
|
||||||
Margin="0,0,0,20" />
|
|
||||||
|
|
||||||
<!-- Message area -->
|
<!-- Message area -->
|
||||||
<Border Margin="0,0,0,20" Padding="16" CornerRadius="8">
|
<Border Margin="0,0,0,20" Padding="16" CornerRadius="8">
|
||||||
<Border.Style>
|
<Border.Style>
|
||||||
|
|
@ -396,7 +433,8 @@
|
||||||
Command="{Binding OpenMealSchedulesCommand}"
|
Command="{Binding OpenMealSchedulesCommand}"
|
||||||
Style="{StaticResource OutlineButtonStyle}"
|
Style="{StaticResource OutlineButtonStyle}"
|
||||||
MinWidth="120"
|
MinWidth="120"
|
||||||
Margin="0,0,16,0" />
|
Margin="0,0,16,0"
|
||||||
|
Visibility="{Binding IsCentralServerMode, Converter={StaticResource BoolToVis}}" />
|
||||||
<Button Content="Back"
|
<Button Content="Back"
|
||||||
Command="{Binding BackCommand}"
|
Command="{Binding BackCommand}"
|
||||||
Style="{StaticResource OutlineButtonStyle}"
|
Style="{StaticResource OutlineButtonStyle}"
|
||||||
|
|
|
||||||
|
|
@ -18,5 +18,6 @@ public partial class SettingsView : UserControl
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
DataContext = viewModel;
|
DataContext = viewModel;
|
||||||
|
Loaded += (_, _) => viewModel.RefreshCacheSyncTimestamps();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
"@
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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." })
|
||||||
|
"@
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
"@
|
||||||
Loading…
Reference in New Issue