Files
jizhi/backend/MiaoJiZhang.Api/Services/BudgetPushService.cs
T

145 lines
6.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<BudgetExpenseChange> 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<CrossedBudget>();
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);
}