using Microsoft.EntityFrameworkCore;
using MySqlConnector;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
///
/// Scheduled sync: fetches unsynced lunch_order_transactions, inserts them into MySQL database,
/// marks them as synced in SQLite, and periodically cleans up old synced rows.
///
public class SyncService : ISyncService
{
private readonly IDbContextFactory _dbFactory;
private readonly IConfigService _configService;
public SyncService(IDbContextFactory dbFactory, IConfigService configService)
{
_dbFactory = dbFactory;
_configService = configService;
}
public async Task SyncNowAsync(CancellationToken cancellationToken = default)
{
var productionConnStr = _configService.GetMySqlConnectionString();
if (string.IsNullOrWhiteSpace(productionConnStr))
{
System.Diagnostics.Debug.WriteLine("MySQL connection string (production) not configured; skipping sync.");
return;
}
var hrmsConnStr = _configService.GetHrmsLookupConnectionString();
List toSync;
using (var db = _dbFactory.CreateDbContext())
{
toSync = await db.LunchOrderTransactions
.Where(r => !r.IsSynced)
.OrderBy(r => r.ScanTime)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
// Always run day-end cleanup (remove synced rows from previous days), even when there's nothing to sync.
await CleanupOldSyncedRowsAsync(cancellationToken).ConfigureAwait(false);
if (toSync.Count == 0)
return;
// Production: lunch_order_transactions (GetMySqlConnectionString)
const string insertTxnSql = @"
INSERT IGNORE INTO lunch_order_transactions
(scan_date, site_id, device_id, card_id, ip_address, received_date)
VALUES
(@ScanTimeUtc, @SiteId, @DeviceId, @CardId, @IpAddress, UTC_TIMESTAMP(3))";
// HRMS/UIND: lunch_order (HrmsLookup when set, else MySqlConnectionString)
const string existsOrderSql = @"
SELECT id
FROM lunch_order
WHERE employee_id = @EmployeeId
AND order_date = @OrderDate
AND total_cost = @TotalCost
AND created_at = @CreatedAt
AND meal_name = @MealName
LIMIT 1";
const string insertOrderSql = @"
INSERT INTO lunch_order
(employee_id, employee_serial_number, order_date, shift, created_at, total_cost, is_cancelled, cancelled_by, created_by, function_id, department_id, location_site_id, meal_name)
VALUES
(@EmployeeId, @EmployeeSerialNumber, @OrderDate, @Shift, @CreatedAt, @TotalCost, 0, @CancelledBy, @CreatedBy, @FunctionId, @DepartmentId, @LocationSiteId, @MealName)";
const string updateOrderCodeSql = @"
UPDATE lunch_order
SET code = @Code
WHERE id = @Id";
const string findLunchMenuItemSql = @"
SELECT
li.id AS lunch_menu_item_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 @MenuDate BETWEEN w.week_start_date AND w.week_end_date
AND li.menu_date = @MenuDate
AND li.meal_name = @MealName
AND mi.item_name = @ItemName
AND (@ItemFor = '' OR mi.item_for = @ItemFor)
LIMIT 1";
const string findLunchMenuItemFallbackSql = @"
SELECT
li.id AS lunch_menu_item_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 @MenuDate BETWEEN w.week_start_date AND w.week_end_date
AND li.menu_date = @MenuDate
AND mi.item_name = @ItemName
AND (@ItemFor = '' OR mi.item_for = @ItemFor)
LIMIT 1";
const string existsOrderItemSql = @"
SELECT id
FROM lunch_order_item
WHERE lunch_order_id = @LunchOrderId
AND lunch_menu_item_id = @LunchMenuItemId
LIMIT 1";
const string insertOrderItemSql = @"
INSERT INTO lunch_order_item
(lunch_order_id, lunch_menu_item_id, quantity, price_at_order_time, item_name, item_type)
VALUES
(@LunchOrderId, @LunchMenuItemId, @Quantity, @PriceAtOrderTime, @ItemName, @ItemType)";
var syncedIds = new List(capacity: toSync.Count);
foreach (var record in toSync)
{
try
{
// Duplicate-safe sync rule:
// if this meal is already present in production for the same employee serial/date/site/session,
// mark SQLite as synced and skip re-insert.
if (await IsDuplicateMealInProductionAsync(record, hrmsConnStr, cancellationToken).ConfigureAwait(false))
{
syncedIds.Add(record.Id);
continue;
}
// 1) Insert into lunch_order_transactions (production – MySqlConnectionString)
await using (var prodConn = new MySqlConnection(productionConnStr))
{
await prodConn.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new MySqlCommand(insertTxnSql, prodConn);
cmd.Parameters.AddWithValue("@ScanTimeUtc", record.ScanTime);
cmd.Parameters.AddWithValue("@SiteId", record.SiteId ?? string.Empty);
cmd.Parameters.AddWithValue("@DeviceId", record.DeviceId ?? string.Empty);
cmd.Parameters.AddWithValue("@CardId", record.CardId ?? string.Empty);
cmd.Parameters.AddWithValue("@IpAddress", record.IpAddress ?? string.Empty);
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
// 2) Insert into lunch_order (HRMS/local – HrmsLookupConnectionString)
if (string.IsNullOrWhiteSpace(record.ParentDocumentId) || record.FunctionId <= 0 || record.DepartmentId <= 0)
throw new InvalidOperationException("Missing required HRMS tag fields for lunch_order insert.");
if (!long.TryParse(record.ParentDocumentId.Trim(), out var employeeIdBigint) || employeeIdBigint <= 0)
throw new InvalidOperationException("Invalid ParentDocumentId for lunch_order.employee_id.");
var orderDateLocal = record.ScanTime.ToLocalTime().Date;
var totalCost = Math.Round((decimal)record.TotalPrice, 2, MidpointRounding.AwayFromZero);
//var createdAt = record.TagCreatedAtUtc ?? record.ScanTime;
var createdAt = record.ScanTime.ToLocalTime();
var locationSiteId = SiteIdStringToInt(record.SiteId ?? string.Empty);
var mealName = (record.MealLabel ?? string.Empty).Trim();
var shift = GetShiftFromMeal(mealName);
await using (var hrmsConn = new MySqlConnection(hrmsConnStr))
{
await hrmsConn.OpenAsync(cancellationToken).ConfigureAwait(false);
long lunchOrderId = 0;
await using (var existsCmd = new MySqlCommand(existsOrderSql, hrmsConn))
{
existsCmd.Parameters.AddWithValue("@EmployeeId", employeeIdBigint);
existsCmd.Parameters.AddWithValue("@OrderDate", orderDateLocal);
existsCmd.Parameters.AddWithValue("@TotalCost", totalCost);
existsCmd.Parameters.AddWithValue("@CreatedAt", createdAt);
existsCmd.Parameters.AddWithValue("@MealName", mealName);
var existing = await existsCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
if (existing != null && existing != DBNull.Value)
lunchOrderId = Convert.ToInt64(existing);
}
if (lunchOrderId <= 0)
{
await using var orderCmd = new MySqlCommand(insertOrderSql, hrmsConn);
orderCmd.Parameters.AddWithValue("@EmployeeId", employeeIdBigint);
orderCmd.Parameters.AddWithValue("@EmployeeSerialNumber", record.UindSerial ?? string.Empty);
orderCmd.Parameters.AddWithValue("@OrderDate", orderDateLocal);
orderCmd.Parameters.AddWithValue("@Shift", shift);
orderCmd.Parameters.AddWithValue("@CreatedAt", createdAt);
orderCmd.Parameters.AddWithValue("@TotalCost", totalCost);
orderCmd.Parameters.AddWithValue("@CancelledBy", string.Empty);
orderCmd.Parameters.AddWithValue("@CreatedBy", record.TagCreatedBy ?? string.Empty);
orderCmd.Parameters.AddWithValue("@FunctionId", record.FunctionId);
orderCmd.Parameters.AddWithValue("@DepartmentId", record.DepartmentId);
orderCmd.Parameters.AddWithValue("@LocationSiteId", locationSiteId);
orderCmd.Parameters.AddWithValue("@MealName", mealName);
await orderCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
var insertedId = orderCmd.LastInsertedId;
if (insertedId > 0)
{
lunchOrderId = insertedId;
var code = GenerateLunchOrderCode(insertedId, createdAt);
await using var updateCodeCmd = new MySqlCommand(updateOrderCodeSql, hrmsConn);
updateCodeCmd.Parameters.AddWithValue("@Code", code);
updateCodeCmd.Parameters.AddWithValue("@Id", insertedId);
await updateCodeCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
if (lunchOrderId > 0)
{
var mealItems = SplitMealItems(record.MealItems);
var itemFor = HrmsMenuItemForMapping.FromGradeType(record.grade_type);
foreach (var itemName in mealItems)
{
var menuLookup = await FindLunchMenuItemAsync(
hrmsConn,
findLunchMenuItemSql,
locationSiteId,
orderDateLocal,
mealName,
itemName,
itemFor,
cancellationToken).ConfigureAwait(false);
if (menuLookup is null)
{
menuLookup = await FindLunchMenuItemAsync(
hrmsConn,
findLunchMenuItemFallbackSql,
locationSiteId,
orderDateLocal,
mealName,
itemName,
itemFor,
cancellationToken).ConfigureAwait(false);
}
if (menuLookup is null)
{
System.Diagnostics.Debug.WriteLine($"Menu item mapping not found for '{itemName}' (site={locationSiteId}, date={orderDateLocal:yyyy-MM-dd}, meal={mealName}).");
continue;
}
var itemExists = false;
await using (var existsItemCmd = new MySqlCommand(existsOrderItemSql, hrmsConn))
{
existsItemCmd.Parameters.AddWithValue("@LunchOrderId", lunchOrderId);
existsItemCmd.Parameters.AddWithValue("@LunchMenuItemId", menuLookup.Value.LunchMenuItemId);
var existingItem = await existsItemCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
itemExists = existingItem != null && existingItem != DBNull.Value;
}
if (itemExists)
continue;
await using var insertItemCmd = new MySqlCommand(insertOrderItemSql, hrmsConn);
insertItemCmd.Parameters.AddWithValue("@LunchOrderId", lunchOrderId);
insertItemCmd.Parameters.AddWithValue("@LunchMenuItemId", menuLookup.Value.LunchMenuItemId);
insertItemCmd.Parameters.AddWithValue("@Quantity", 1);
insertItemCmd.Parameters.AddWithValue("@PriceAtOrderTime", menuLookup.Value.Price);
insertItemCmd.Parameters.AddWithValue("@ItemName", menuLookup.Value.ItemName);
insertItemCmd.Parameters.AddWithValue("@ItemType", menuLookup.Value.ItemType);
await insertItemCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
}
syncedIds.Add(record.Id);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Sync record failed (SQLite Id={record.Id}): {ex.Message}");
// Leave unsynced; will retry later.
}
}
if (syncedIds.Count > 0)
{
using var db = _dbFactory.CreateDbContext();
var records = await db.LunchOrderTransactions
.Where(r => syncedIds.Contains(r.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var r in records)
r.IsSynced = true;
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
try
{
await CleanupOldSyncedRowsAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Sync cleanup failed: {ex.Message}");
}
}
private static string GenerateLunchOrderCode(long id, DateTime date)
{
return $"LO-{date:yyyy-MM}-{id:D6}";
}
private static string GetShiftFromMeal(string mealName)
{
if (string.IsNullOrWhiteSpace(mealName))
return string.Empty;
mealName = mealName.Trim().ToLowerInvariant();
return mealName switch
{
"breakfast" => "MORNING",
"lunch" => "MORNING",
_ => "EVENING"
};
}
private static int SiteIdStringToInt(string siteId)
{
try
{
var s = (siteId ?? string.Empty).Trim();
if (string.IsNullOrEmpty(s)) return 0;
var digits = new string(s.Where(char.IsDigit).ToArray());
return int.TryParse(digits, out var n) ? n : 0;
}
catch
{
return 0;
}
}
private static IReadOnlyList SplitMealItems(string? mealItems)
{
if (string.IsNullOrWhiteSpace(mealItems))
return Array.Empty();
return mealItems
.Split('+', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static async Task<(long LunchMenuItemId, string ItemName, string ItemType, decimal Price)?> FindLunchMenuItemAsync(
MySqlConnection conn,
string sql,
int siteId,
DateTime menuDate,
string mealName,
string itemName,
string itemFor,
CancellationToken cancellationToken)
{
await using var cmd = new MySqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@SiteId", siteId);
cmd.Parameters.AddWithValue("@MenuDate", menuDate.Date);
cmd.Parameters.AddWithValue("@MealName", mealName);
cmd.Parameters.AddWithValue("@ItemName", itemName);
cmd.Parameters.AddWithValue("@ItemFor", itemFor);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
return null;
var lunchMenuItemId = reader.IsDBNull(0) ? 0 : Convert.ToInt64(reader.GetValue(0));
if (lunchMenuItemId <= 0)
return null;
var resolvedItemName = reader.IsDBNull(1) ? string.Empty : reader.GetValue(1)?.ToString() ?? string.Empty;
var itemType = reader.IsDBNull(2) ? string.Empty : reader.GetValue(2)?.ToString() ?? string.Empty;
var price = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3));
return (lunchMenuItemId, resolvedItemName, itemType, price);
}
private async Task IsDuplicateMealInProductionAsync(ScanRecord record, string hrmsConnStr, CancellationToken cancellationToken)
{
var employeeSerial = (record.EmployeeId ?? string.Empty).Trim();
var mealName = (record.MealLabel ?? string.Empty).Trim();
var siteId = SiteIdStringToInt(record.SiteId ?? string.Empty);
var orderDate = record.ScanTime.ToLocalTime().Date;
if (string.IsNullOrWhiteSpace(employeeSerial) || string.IsNullOrWhiteSpace(mealName) || siteId <= 0)
return false;
const string sql = @"
SELECT 1
FROM lunch_order
WHERE employee_serial_number = @EmployeeSerialNumber
AND order_date = @OrderDate
AND meal_name = @MealName
AND location_site_id = @LocationSiteId
LIMIT 1";
try
{
await using var conn = new MySqlConnection(hrmsConnStr);
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new MySqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@EmployeeSerialNumber", employeeSerial);
cmd.Parameters.AddWithValue("@OrderDate", orderDate);
cmd.Parameters.AddWithValue("@MealName", mealName);
cmd.Parameters.AddWithValue("@LocationSiteId", siteId);
var exists = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return exists != null && exists != DBNull.Value;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Duplicate check failed for SQLite Id={record.Id}: {ex.Message}");
return false;
}
}
///
/// Deletes only synced rows (IsSynced = 1) where ScanTime is before the start of today (local day).
///
private async Task CleanupOldSyncedRowsAsync(CancellationToken cancellationToken)
{
// Define "today" by the local calendar day, but ScanTime is stored as UTC.
var startOfTodayLocal = DateTime.Today;
var startUtc = startOfTodayLocal.ToUniversalTime();
using var db = _dbFactory.CreateDbContext();
var oldSynced = await db.LunchOrderTransactions
.Where(r => r.IsSynced && r.ScanTime < startUtc)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (oldSynced.Count == 0)
return;
db.LunchOrderTransactions.RemoveRange(oldSynced);
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}