refactor finance.
This commit is contained in:
@@ -27,12 +27,24 @@ public class ExpenseService : IExpenseService
|
||||
|
||||
public async Task<PagedResult<ExpenseListItemDto>> GetPagedAsync(
|
||||
int page, int pageSize, string? search, int? ministryId,
|
||||
int? categoryGroupId, string? status, DateOnly? from, DateOnly? to)
|
||||
int? categoryGroupId, string? status, DateOnly? from, DateOnly? to,
|
||||
int? subCategoryId = null, string? statuses = null)
|
||||
{
|
||||
var query = _db.Expenses.AsNoTracking().AsQueryable();
|
||||
if (ministryId.HasValue) query = query.Where(e => e.MinistryId == ministryId.Value);
|
||||
if (categoryGroupId.HasValue) query = query.Where(e => e.CategoryGroupId == categoryGroupId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(status)) query = query.Where(e => e.Status == status);
|
||||
if (subCategoryId.HasValue) query = query.Where(e => e.SubCategoryId == subCategoryId.Value);
|
||||
// `statuses` (comma-separated) takes precedence over single `status`; lets the dashboard
|
||||
// request the Paid+Approved set in one call.
|
||||
if (!string.IsNullOrWhiteSpace(statuses))
|
||||
{
|
||||
var set = statuses.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
query = query.Where(e => set.Contains(e.Status));
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
query = query.Where(e => e.Status == status);
|
||||
}
|
||||
if (from.HasValue) query = query.Where(e => e.ExpenseDate >= from.Value);
|
||||
if (to.HasValue) query = query.Where(e => e.ExpenseDate <= to.Value);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
@@ -41,6 +53,7 @@ public class ExpenseService : IExpenseService
|
||||
query = query.Where(e =>
|
||||
e.Description.ToLower().Contains(s) ||
|
||||
(e.VendorName != null && e.VendorName.ToLower().Contains(s)) ||
|
||||
(e.CheckNumber != null && e.CheckNumber.ToLower().Contains(s)) ||
|
||||
(e.Member != null && (
|
||||
(e.Member.FirstName_en + " " + e.Member.LastName_en).ToLower().Contains(s) ||
|
||||
(e.Member.FirstName_zh != null && e.Member.FirstName_zh.Contains(term)) ||
|
||||
@@ -80,6 +93,7 @@ public class ExpenseService : IExpenseService
|
||||
MemberName = e.MemberId != null ? memNames.GetValueOrDefault(e.MemberId.Value) : null,
|
||||
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
|
||||
HasReceipt = e.ReceiptBlobPath != null,
|
||||
CheckNumber = e.CheckNumber,
|
||||
}).ToList();
|
||||
|
||||
return new PagedResult<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Finance;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only aggregation over Givings + Expenses for the Finance Dashboard.
|
||||
/// Expense scope is Paid+Approved everywhere (decided with the user); income is Givings only.
|
||||
/// </summary>
|
||||
public class FinanceDashboardService : IFinanceDashboardService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
public FinanceDashboardService(AppDbContext db) => _db = db;
|
||||
|
||||
// Paid+Approved expenses, optionally bounded by ExpenseDate. Shared by the
|
||||
// income/expense pie and the breakdown so the filter is identical in both.
|
||||
private IQueryable<Expense> PaidApproved(DateOnly? from, DateOnly? to)
|
||||
{
|
||||
var q = _db.Expenses.Where(e => e.Status == "Paid" || e.Status == "Approved");
|
||||
if (from.HasValue) q = q.Where(e => e.ExpenseDate >= from.Value);
|
||||
if (to.HasValue) q = q.Where(e => e.ExpenseDate <= to.Value);
|
||||
return q;
|
||||
}
|
||||
|
||||
public async Task<FinanceSummaryDto> GetSummaryAsync()
|
||||
{
|
||||
var income = await _db.Givings.SumAsync(g => (decimal?)g.Amount) ?? 0m;
|
||||
var expense = await PaidApproved(null, null).SumAsync(e => (decimal?)e.Amount) ?? 0m;
|
||||
return new FinanceSummaryDto
|
||||
{
|
||||
TotalIncome = income,
|
||||
TotalExpenses = expense,
|
||||
Balance = income - expense,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IncomeExpenseDto> GetIncomeExpenseAsync(DateOnly? from, DateOnly? to)
|
||||
{
|
||||
var giving = _db.Givings.AsQueryable();
|
||||
if (from.HasValue) giving = giving.Where(g => g.GivingDate >= from.Value);
|
||||
if (to.HasValue) giving = giving.Where(g => g.GivingDate <= to.Value);
|
||||
|
||||
return new IncomeExpenseDto
|
||||
{
|
||||
Income = await giving.SumAsync(g => (decimal?)g.Amount) ?? 0m,
|
||||
Expense = await PaidApproved(from, to).SumAsync(e => (decimal?)e.Amount) ?? 0m,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<BreakdownSliceDto>> GetExpenseBreakdownAsync(
|
||||
DateOnly? from, DateOnly? to, int? ministryId, int? categoryGroupId)
|
||||
{
|
||||
var q = PaidApproved(from, to);
|
||||
if (ministryId.HasValue) q = q.Where(e => e.MinistryId == ministryId.Value);
|
||||
if (categoryGroupId.HasValue) q = q.Where(e => e.CategoryGroupId == categoryGroupId.Value);
|
||||
|
||||
// Group by the deepest level whose parent id is supplied.
|
||||
List<(int Id, decimal Amount)> grouped;
|
||||
if (categoryGroupId.HasValue)
|
||||
grouped = (await q.GroupBy(e => e.SubCategoryId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
|
||||
.Select(x => (x.Id, x.Amount)).ToList();
|
||||
else if (ministryId.HasValue)
|
||||
grouped = (await q.GroupBy(e => e.CategoryGroupId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
|
||||
.Select(x => (x.Id, x.Amount)).ToList();
|
||||
else
|
||||
grouped = (await q.GroupBy(e => e.MinistryId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
|
||||
.Select(x => (x.Id, x.Amount)).ToList();
|
||||
|
||||
var ids = grouped.Select(x => x.Id).ToHashSet();
|
||||
Dictionary<int, (string En, string? Zh)> names = categoryGroupId.HasValue
|
||||
? await _db.ExpenseSubCategories.Where(s => ids.Contains(s.Id))
|
||||
.ToDictionaryAsync(s => s.Id, s => (s.Name_en, s.Name_zh))
|
||||
: ministryId.HasValue
|
||||
? await _db.ExpenseCategoryGroups.Where(g => ids.Contains(g.Id))
|
||||
.ToDictionaryAsync(g => g.Id, g => (g.Name_en, g.Name_zh))
|
||||
: await _db.Ministries.Where(m => ids.Contains(m.Id))
|
||||
.ToDictionaryAsync(m => m.Id, m => (m.Name_en, m.Name_zh));
|
||||
|
||||
return grouped.Select(x =>
|
||||
{
|
||||
names.TryGetValue(x.Id, out var n);
|
||||
return new BreakdownSliceDto { Id = x.Id, Name_en = n.En ?? "", Name_zh = n.Zh, Amount = x.Amount };
|
||||
}).OrderByDescending(s => s.Amount).ToList();
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ public interface IExpenseService
|
||||
{
|
||||
Task<PagedResult<ExpenseListItemDto>> GetPagedAsync(
|
||||
int page, int pageSize, string? search, int? ministryId,
|
||||
int? categoryGroupId, string? status, DateOnly? from, DateOnly? to);
|
||||
int? categoryGroupId, string? status, DateOnly? from, DateOnly? to,
|
||||
int? subCategoryId = null, string? statuses = null);
|
||||
Task<PagedResult<ExpenseListItemDto>> GetMineAsync(string userId, string? status, int page, int pageSize);
|
||||
Task<ExpenseDto?> GetByIdAsync(int id);
|
||||
Task<int> CreateAsync(CreateExpenseRequest r, bool isFinance);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using ROLAC.API.DTOs.Finance;
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
public interface IFinanceDashboardService
|
||||
{
|
||||
/// <summary>All-time balance: total Givings minus total Paid+Approved expenses.</summary>
|
||||
Task<FinanceSummaryDto> GetSummaryAsync();
|
||||
|
||||
/// <summary>Income (Givings) vs expense (Paid+Approved) totals within the date range.</summary>
|
||||
Task<IncomeExpenseDto> GetIncomeExpenseAsync(DateOnly? from, DateOnly? to);
|
||||
|
||||
/// <summary>
|
||||
/// Expense totals grouped by the drill level implied by the supplied ids:
|
||||
/// none -> by Ministry; ministryId -> by CategoryGroup; ministryId+categoryGroupId -> by SubCategory.
|
||||
/// </summary>
|
||||
Task<List<BreakdownSliceDto>> GetExpenseBreakdownAsync(
|
||||
DateOnly? from, DateOnly? to, int? ministryId, int? categoryGroupId);
|
||||
}
|
||||
Reference in New Issue
Block a user