Utopia-Canteen-System/Services/SyncService.cs

404 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

using Microsoft.EntityFrameworkCore;
using MySqlConnector;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// 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.
/// </summary>
public class SyncService : ISyncService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly IConfigService _configService;
public SyncService(IDbContextFactory<AppDbContext> 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();
if (string.IsNullOrWhiteSpace(hrmsConnStr))
{
System.Diagnostics.Debug.WriteLine("HRMS lookup connection string not configured; skipping sync (need both for lunch_order).");
return;
}
List<ScanRecord> toSync;
using (var db = _dbFactory.CreateDbContext())
{
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/local: lunch_order (GetHrmsLookupConnectionString)
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<int>(capacity: toSync.Count);
foreach (var record in toSync)
{
try
{
// 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<string> SplitMealItems(string? mealItems)
{
if (string.IsNullOrWhiteSpace(mealItems))
return Array.Empty<string>();
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);
}
/// <summary>
/// Deletes only synced rows (IsSynced = 1) where ScanTime is before the start of today (local day).
/// </summary>
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);
}
}