using MiaoJiZhang.Domain.Entities; using MiaoJiZhang.Domain.Enums; using MiaoJiZhang.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace MiaoJiZhang.Api.Services; public sealed record BudgetExpenseChange( long LedgerId, long CategoryId, DateTime OccurredAt, decimal Delta); public sealed class BudgetPushService(AppDbContext db) { private static readonly int[] Thresholds = [80, 100]; public async Task EvaluateAsync( long userId, IEnumerable rawChanges, CancellationToken ct = default) { var changes = rawChanges .Where(change => change.Delta != 0) .Select(change => new { Change = change, Local = ChinaClock.ToLocal(change.OccurredAt), }) .Select(item => new ChangeWithPeriod( item.Change.LedgerId, item.Change.CategoryId, item.Local.Year * 100 + item.Local.Month, item.Change.Delta)) .ToList(); if (changes.Count == 0) return; var crossed = new List(); foreach (var group in changes.GroupBy(change => new { change.LedgerId, change.Period })) { var period = group.Key.Period; var year = period / 100; var month = period % 100; if (month is < 1 or > 12) continue; var rows = await db.Budgets .Where(budget => budget.UserId == userId && budget.LedgerId == group.Key.LedgerId && (budget.Period == period || budget.Period == 0) && budget.Amount > 0) .ToListAsync(ct); var budgets = rows.GroupBy(budget => budget.CategoryId) .Select(items => items.FirstOrDefault(item => item.Period == period) ?? items.First(item => item.Period == 0)) .ToList(); if (budgets.Count == 0) continue; var (start, end) = ChinaClock.MonthRangeUtc(year, month); var spent = await db.Transactions .Where(transaction => transaction.UserId == userId && transaction.LedgerId == group.Key.LedgerId && (transaction.Type == TransactionType.Expense || transaction.Type == TransactionType.Transfer && transaction.TransferDirection == TransferDirection.Out) && transaction.OccurredAt >= start && transaction.OccurredAt < end) .GroupBy(transaction => transaction.CategoryId) .Select(items => new { CategoryId = items.Key, Amount = items.Sum(item => item.Amount) }) .ToDictionaryAsync(item => item.CategoryId, item => item.Amount, ct); var existing = await db.BudgetNotificationReceipts .Where(receipt => receipt.UserId == userId && receipt.Period == period && budgets.Select(budget => budget.Id).Contains(receipt.BudgetId)) .Select(receipt => new { receipt.BudgetId, receipt.Threshold }) .ToListAsync(ct); var existingKeys = existing.Select(item => (item.BudgetId, item.Threshold)).ToHashSet(); foreach (var budget in budgets) { var currentSpent = budget.CategoryId.HasValue ? spent.GetValueOrDefault(budget.CategoryId.Value) : spent.Values.Sum(); var delta = budget.CategoryId.HasValue ? group.Where(change => change.CategoryId == budget.CategoryId.Value).Sum(change => change.Delta) : group.Sum(change => change.Delta); var previousSpent = currentSpent - delta; var highestCrossed = 0; foreach (var threshold in Thresholds) { if (currentSpent * 100 < budget.Amount * threshold || existingKeys.Contains((budget.Id, threshold))) continue; db.BudgetNotificationReceipts.Add(new BudgetNotificationReceipt { UserId = userId, BudgetId = budget.Id, Period = period, Threshold = threshold, CreatedAt = DateTime.UtcNow, }); existingKeys.Add((budget.Id, threshold)); if (delta > 0 && previousSpent * 100 < budget.Amount * threshold) highestCrossed = threshold; } if (highestCrossed > 0) crossed.Add(new CrossedBudget(budget.CategoryId, highestCrossed)); } } if (crossed.Count == 0) return; var notificationsEnabled = await db.UserPushPreferences.AnyAsync(preference => preference.UserId == userId && preference.Category == PushCategories.Budget && preference.IsEnabled, ct); if (!notificationsEnabled) return; var categoryIds = crossed.Where(item => item.CategoryId.HasValue) .Select(item => item.CategoryId!.Value).Distinct().ToList(); var names = await db.Categories.Where(category => categoryIds.Contains(category.Id)) .ToDictionaryAsync(category => category.Id, category => category.Name, ct); var details = crossed .OrderByDescending(item => item.Threshold) .ThenBy(item => item.CategoryId) .Select(item => $"{(item.CategoryId.HasValue ? names.GetValueOrDefault(item.CategoryId.Value, "分类预算") : "总预算")}" + (item.Threshold >= 100 ? "已用完" : "已使用 80%")) .Distinct() .ToList(); var body = string.Join(";", details); if (body.Length > 240) body = body[..237] + "..."; var now = DateTime.UtcNow; db.PushMessages.Add(new PushMessage { PublicId = Guid.NewGuid().ToString(), Source = "budget", State = PushMessageStates.Queued, Category = PushCategories.Budget, Title = crossed.Any(item => item.Threshold >= 100) ? "预算已达到上限" : "预算接近上限", Body = body, Action = PushActions.Budget, TargetUserId = userId, Flavor = "", TtlSeconds = 24 * 3600, CreatedAt = now, UpdatedAt = now, }); } private sealed record ChangeWithPeriod(long LedgerId, long CategoryId, int Period, decimal Delta); private sealed record CrossedBudget(long? CategoryId, int Threshold); }