Utopia-Canteen-System/Services/SyncService.cs

253 lines
11 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";
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);
var alreadyExists = false;
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);
alreadyExists = existing != null && existing != DBNull.Value;
}
if (!alreadyExists)
{
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)
{
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);
}
}
}
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;
}
}
/// <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);
}
}