145 lines
6.5 KiB
C#
145 lines
6.5 KiB
C#
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);
|
||
}
|