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(); if (string.IsNullOrWhiteSpace(hrmsConnStr)) { System.Diagnostics.Debug.WriteLine("HRMS lookup connection string not configured; skipping sync (need both for lunch_order)."); return; } 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/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(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; } } /// /// 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); } }