Use offline cache for RFID employee, session, and menu lookup
Switches RFID employee lookup, meal session resolution, and menu lookup from live HRMS queries to the local SQLite cache. Adds cache availability checks and shared site ID normalization for scan validation.feature/centralized-offline-canteen
parent
1d427b6e18
commit
37f52bc3c6
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,124 +1,83 @@
|
||||||
|
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 IConfigService _configService;
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
private readonly IConfigService _configService;
|
||||||
|
|
||||||
public EmployeeLookupService(IConfigService configService)
|
public EmployeeLookupService(IDbContextFactory<AppDbContext> dbFactory, IConfigService configService)
|
||||||
{
|
{
|
||||||
_configService = configService;
|
_dbFactory = dbFactory;
|
||||||
}
|
_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 cardId = rfid?.Trim() ?? string.Empty;
|
||||||
var connectionString = _configService.GetHrmsLookupConnectionString();
|
if (string.IsNullOrEmpty(cardId))
|
||||||
if (string.IsNullOrWhiteSpace(connectionString))
|
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 />
|
||||||
public async Task<string?> GetLocationSiteIdByEmployeeSerialAsync(string employeeSerial, CancellationToken cancellationToken = default)
|
public async Task<string?> GetLocationSiteIdByEmployeeSerialAsync(string employeeSerial, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var serial = employeeSerial?.Trim() ?? string.Empty;
|
var serial = employeeSerial?.Trim() ?? string.Empty;
|
||||||
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))
|
/// <inheritdoc />
|
||||||
return null;
|
public async Task<bool> IsMenuItemAuthorizedForRfidAsync(string rfid, int menuItemId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var card = rfid?.Trim() ?? string.Empty;
|
||||||
|
if (string.IsNullOrEmpty(card) || menuItemId <= 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
var raw = reader.GetValue(0)?.ToString()?.Trim();
|
var connectionString = _configService.GetHrmsLookupConnectionString();
|
||||||
return string.IsNullOrEmpty(raw) ? null : raw;
|
if (string.IsNullOrWhiteSpace(connectionString))
|
||||||
}
|
return false;
|
||||||
|
|
||||||
/// <inheritdoc />
|
const string sql = @"
|
||||||
public async Task<bool> IsMenuItemAuthorizedForRfidAsync(string rfid, int menuItemId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var card = rfid?.Trim() ?? string.Empty;
|
|
||||||
if (string.IsNullOrEmpty(card) || menuItemId <= 0)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var connectionString = _configService.GetHrmsLookupConnectionString();
|
|
||||||
if (string.IsNullOrWhiteSpace(connectionString))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
const string sql = @"
|
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM employee_rfid_tag r
|
FROM employee_rfid_tag r
|
||||||
JOIN employee_menu_item_tag em ON em.employee_rfid_tag_id = r.id
|
JOIN employee_menu_item_tag em ON em.employee_rfid_tag_id = r.id
|
||||||
|
|
@ -126,52 +85,61 @@ public class EmployeeLookupService : IEmployeeLookupService
|
||||||
AND em.item_id = @itemId
|
AND em.item_id = @itemId
|
||||||
LIMIT 1";
|
LIMIT 1";
|
||||||
|
|
||||||
await using var conn = new MySqlConnection(connectionString);
|
await using var conn = new MySqlConnection(connectionString);
|
||||||
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await using var cmd = new MySqlCommand(sql, conn);
|
await using var cmd = new MySqlCommand(sql, conn);
|
||||||
cmd.Parameters.AddWithValue("@rfid", card);
|
cmd.Parameters.AddWithValue("@rfid", card);
|
||||||
cmd.Parameters.AddWithValue("@itemId", menuItemId);
|
cmd.Parameters.AddWithValue("@itemId", menuItemId);
|
||||||
|
|
||||||
var exists = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
var exists = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||||
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)
|
||||||
|
{
|
||||||
|
return new HrmsEmployeeInfo
|
||||||
{
|
{
|
||||||
if (reader.IsDBNull(ordinal)) return string.Empty;
|
ParentDocumentId = tag.ParentDocumentId,
|
||||||
var v = reader.GetValue(ordinal);
|
EmployeeId = tag.EmployeeSerialNumber,
|
||||||
return v?.ToString() ?? string.Empty;
|
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);
|
||||||
return null;
|
await using var cmd = new MySqlCommand(sql, conn);
|
||||||
}
|
cmd.Parameters.AddWithValue("@serial", serial);
|
||||||
catch
|
|
||||||
{
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
return null;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
var list = new List<HrmsMenuItem>();
|
&& menuDate <= w.WeekEndDate.Value.Date
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
&& li.MenuDate != null
|
||||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
&& li.MenuDate.Value.Date == menuDate
|
||||||
{
|
&& li.MealName == meal
|
||||||
list.Add(new HrmsMenuItem
|
&& mi.ItemFor == itemFor
|
||||||
|
orderby mi.ItemName
|
||||||
|
select new HrmsMenuItem
|
||||||
{
|
{
|
||||||
MenuItemId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0),
|
MenuItemId = (int)mi.HrmsId,
|
||||||
ItemName = GetString(reader, 1),
|
ItemName = mi.ItemName,
|
||||||
ItemType = GetString(reader, 2),
|
ItemType = mi.ItemType,
|
||||||
Price = GetDecimal(reader, 3),
|
Price = mi.Price,
|
||||||
MenuDate = GetString(reader, 4),
|
MenuDate = li.MenuDate != null ? li.MenuDate.Value.ToString("yyyy-MM-dd") : string.Empty,
|
||||||
DayOfWeek = GetString(reader, 5),
|
DayOfWeek = li.DayOfWeek,
|
||||||
MealName = GetString(reader, 6) // Map meal_name from column index 6
|
MealName = li.MealName
|
||||||
});
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return list;
|
return await query.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue