470 lines
21 KiB
C#
470 lines
21 KiB
C#
using Microsoft.EntityFrameworkCore;
|
||
using MySqlConnector;
|
||
using UtopiaCanteenSystem.Data;
|
||
using UtopiaCanteenSystem.Models;
|
||
using UtopiaCanteenSystem.Services.Logging;
|
||
|
||
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<SyncNowResult> SyncNowAsync(CancellationToken cancellationToken = default)
|
||
{
|
||
var productionConnStr = _configService.GetMySqlConnectionString();
|
||
if (string.IsNullOrWhiteSpace(productionConnStr))
|
||
{
|
||
FileLogger.Warn("OrderSync", "MySQL connection string is not configured; skipping order sync.");
|
||
return new SyncNowResult { SkippedNoConnection = true };
|
||
}
|
||
|
||
var hrmsConnStr = _configService.GetHrmsLookupConnectionString();
|
||
|
||
List<ScanRecord> toSync;
|
||
using (var db = _dbFactory.CreateDbContext())
|
||
{
|
||
toSync = await db.LunchOrderTransactions
|
||
.Where(r => !r.IsSynced)
|
||
.OrderBy(r => r.ScanTime)
|
||
.ToListAsync(cancellationToken)
|
||
.ConfigureAwait(false);
|
||
}
|
||
|
||
FileLogger.Info("OrderSync", $"Order sync started. PendingCount={toSync.Count}");
|
||
|
||
var posted = 0;
|
||
var duplicatesSkipped = 0;
|
||
var failed = 0;
|
||
|
||
// 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)
|
||
{
|
||
FileLogger.Info("OrderSync", "Order sync completed. No pending orders.");
|
||
return new SyncNowResult();
|
||
}
|
||
|
||
// 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<int>(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);
|
||
duplicatesSkipped++;
|
||
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);
|
||
posted++;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
failed++;
|
||
FileLogger.Error("OrderSync", $"UIND/HRMS post failed for SQLite Id={record.Id}, CardId={LogMasking.MaskCardId(record.CardId)}.", ex);
|
||
}
|
||
}
|
||
|
||
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)
|
||
{
|
||
FileLogger.Warn("OrderSync", "Sync cleanup failed.", ex);
|
||
}
|
||
|
||
var result = new SyncNowResult
|
||
{
|
||
PendingCount = toSync.Count,
|
||
PostedCount = posted,
|
||
DuplicatesSkippedCount = duplicatesSkipped,
|
||
FailedCount = failed
|
||
};
|
||
FileLogger.Info(
|
||
"OrderSync",
|
||
$"Order sync completed. Pending={result.PendingCount}, Posted={result.PostedCount}, " +
|
||
$"DuplicatesSkipped={result.DuplicatesSkippedCount}, Failed={result.FailedCount}.");
|
||
return result;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
private async Task<bool> 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;
|
||
}
|
||
}
|
||
|
||
/// <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);
|
||
}
|
||
}
|