@@ -40,6 +40,19 @@ public class DisbursementService : IDisbursementService
|
||||
var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet();
|
||||
var members = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id)).ToDictionaryAsync(m => m.Id);
|
||||
|
||||
// Category label per expense: the single line's category, or "Multiple" when it spans several.
|
||||
var expenseIds = rows.Select(r => r.Id).ToList();
|
||||
var lineGroups = await _db.ExpenseLines.AsNoTracking()
|
||||
.Where(l => expenseIds.Contains(l.ExpenseId))
|
||||
.OrderBy(l => l.Id)
|
||||
.Select(l => new { l.ExpenseId, l.CategoryGroupId })
|
||||
.ToListAsync();
|
||||
var categoryByExpense = lineGroups.GroupBy(l => l.ExpenseId).ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(l => l.CategoryGroupId).Distinct().Count() > 1
|
||||
? "Multiple / 多類別"
|
||||
: grpNames.GetValueOrDefault(g.First().CategoryGroupId, ""));
|
||||
|
||||
var groups = new Dictionary<string, PayeeGroupDto>();
|
||||
foreach (var e in rows)
|
||||
{
|
||||
@@ -77,7 +90,7 @@ public class DisbursementService : IDisbursementService
|
||||
ExpenseId = e.Id, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
|
||||
Description = e.Description, Amount = e.Amount,
|
||||
MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
|
||||
CategoryName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""),
|
||||
CategoryName = categoryByExpense.GetValueOrDefault(e.Id, ""),
|
||||
});
|
||||
g.TotalAmount += e.Amount;
|
||||
}
|
||||
|
||||
@@ -35,8 +35,9 @@ public class ExpenseService : IExpenseService
|
||||
{
|
||||
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 (subCategoryId.HasValue) query = query.Where(e => e.SubCategoryId == subCategoryId.Value);
|
||||
// Category filters now match against any line of the expense.
|
||||
if (categoryGroupId.HasValue) query = query.Where(e => e.Lines.Any(l => l.CategoryGroupId == categoryGroupId.Value));
|
||||
if (subCategoryId.HasValue) query = query.Where(e => e.Lines.Any(l => l.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))
|
||||
@@ -81,23 +82,36 @@ public class ExpenseService : IExpenseService
|
||||
|
||||
var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}");
|
||||
var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => $"{g.Name_en} / {g.Name_zh}");
|
||||
var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(s => s.Id, s => $"{s.Name_en} / {s.Name_zh}");
|
||||
var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet();
|
||||
var memNames = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id))
|
||||
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}");
|
||||
|
||||
var items = rows.Select(e => new ExpenseListItemDto
|
||||
// Line count + first line's category, per expense on this page.
|
||||
var expenseIds = rows.Select(r => r.Id).ToList();
|
||||
var lineRows = await _db.ExpenseLines.AsNoTracking()
|
||||
.Where(l => expenseIds.Contains(l.ExpenseId))
|
||||
.OrderBy(l => l.Id)
|
||||
.Select(l => new { l.ExpenseId, l.CategoryGroupId })
|
||||
.ToListAsync();
|
||||
var linesByExpense = lineRows.GroupBy(l => l.ExpenseId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var items = rows.Select(e =>
|
||||
{
|
||||
Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
|
||||
MinistryId = e.MinistryId, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
|
||||
CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""),
|
||||
SubCategoryId = e.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(e.SubCategoryId, ""),
|
||||
VendorName = e.VendorName, MemberId = e.MemberId,
|
||||
MemberName = e.MemberId != null ? memNames.GetValueOrDefault(e.MemberId.Value) : null,
|
||||
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
|
||||
HasReceipt = e.ReceiptBlobPath != null,
|
||||
CheckNumber = e.CheckNumber,
|
||||
FunctionalClass = e.FunctionalClass,
|
||||
linesByExpense.TryGetValue(e.Id, out var ls);
|
||||
var firstGroupId = ls is { Count: > 0 } ? ls[0].CategoryGroupId : 0;
|
||||
return new ExpenseListItemDto
|
||||
{
|
||||
Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
|
||||
MinistryId = e.MinistryId, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
|
||||
LineCount = ls?.Count ?? 0,
|
||||
PrimaryCategoryName = grpNames.GetValueOrDefault(firstGroupId, ""),
|
||||
VendorName = e.VendorName, MemberId = e.MemberId,
|
||||
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 };
|
||||
@@ -108,33 +122,66 @@ public class ExpenseService : IExpenseService
|
||||
var e = await _db.Expenses.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (e is null) return null;
|
||||
var minName = await _db.Ministries.Where(m => m.Id == e.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? "";
|
||||
var grpName = await _db.ExpenseCategoryGroups.Where(g => g.Id == e.CategoryGroupId).Select(g => g.Name_en).FirstOrDefaultAsync() ?? "";
|
||||
var subName = await _db.ExpenseSubCategories.Where(s => s.Id == e.SubCategoryId).Select(s => s.Name_en).FirstOrDefaultAsync() ?? "";
|
||||
string? memName = e.MemberId != null
|
||||
? await _db.Members.Where(m => m.Id == e.MemberId).Select(m => m.FirstName_en + " " + m.LastName_en).FirstOrDefaultAsync()
|
||||
: null;
|
||||
|
||||
var lines = await _db.ExpenseLines.AsNoTracking().Where(l => l.ExpenseId == id).OrderBy(l => l.Id).ToListAsync();
|
||||
var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name_en);
|
||||
var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(s => s.Id, s => s.Name_en);
|
||||
var lineDtos = lines.Select(l => new ExpenseLineItemDto
|
||||
{
|
||||
Id = l.Id, CategoryGroupId = l.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(l.CategoryGroupId, ""),
|
||||
SubCategoryId = l.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(l.SubCategoryId, ""),
|
||||
FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
|
||||
}).ToList();
|
||||
|
||||
return new ExpenseDto
|
||||
{
|
||||
Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
|
||||
MinistryId = e.MinistryId, MinistryName = minName,
|
||||
CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpName,
|
||||
SubCategoryId = e.SubCategoryId, SubCategoryName = subName,
|
||||
LineCount = lineDtos.Count,
|
||||
PrimaryCategoryName = lineDtos.Count > 0 ? lineDtos[0].CategoryGroupName : "",
|
||||
VendorName = e.VendorName, MemberId = e.MemberId, MemberName = memName,
|
||||
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null,
|
||||
CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes,
|
||||
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, ReviewedAt = e.ReviewedAt, PaidAt = e.PaidAt,
|
||||
FunctionalClass = e.FunctionalClass,
|
||||
Lines = lineDtos,
|
||||
};
|
||||
}
|
||||
|
||||
// Lines are the source of truth: ≥1 line, each with a category/subcategory and a positive amount.
|
||||
private static void ValidateLines(List<ExpenseLineInput> lines)
|
||||
{
|
||||
if (lines is null || lines.Count == 0)
|
||||
throw new InvalidOperationException("An expense must have at least one line.");
|
||||
foreach (var l in lines)
|
||||
{
|
||||
if (l.CategoryGroupId <= 0 || l.SubCategoryId <= 0)
|
||||
throw new InvalidOperationException("Each expense line needs a category group and subcategory.");
|
||||
if (l.Amount <= 0)
|
||||
throw new InvalidOperationException("Each expense line amount must be greater than zero.");
|
||||
if (l.FunctionalClass is not null && !FunctionalClasses.All.Contains(l.FunctionalClass))
|
||||
throw new InvalidOperationException($"Invalid functional class '{l.FunctionalClass}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ExpenseLine> BuildLines(List<ExpenseLineInput> inputs) =>
|
||||
inputs.Select(l => new ExpenseLine
|
||||
{
|
||||
CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId,
|
||||
FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
|
||||
}).ToList();
|
||||
|
||||
public async Task<int> CreateAsync(CreateExpenseRequest r, bool isFinance)
|
||||
{
|
||||
ValidateLines(r.Lines);
|
||||
var e = new Expense
|
||||
{
|
||||
MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId, SubCategoryId = r.SubCategoryId,
|
||||
Type = r.Type, Amount = r.Amount, Description = r.Description, VendorName = r.VendorName,
|
||||
MinistryId = r.MinistryId,
|
||||
Type = r.Type, Amount = r.Lines.Sum(l => l.Amount), Description = r.Description, VendorName = r.VendorName,
|
||||
CheckNumber = r.CheckNumber, ExpenseDate = r.ExpenseDate, Notes = r.Notes,
|
||||
FunctionalClass = r.FunctionalClass,
|
||||
Lines = BuildLines(r.Lines),
|
||||
};
|
||||
|
||||
if (r.Type == "VendorPayment")
|
||||
@@ -174,16 +221,21 @@ public class ExpenseService : IExpenseService
|
||||
|
||||
public async Task UpdateAsync(int id, UpdateExpenseRequest r, bool isFinance)
|
||||
{
|
||||
ValidateLines(r.Lines);
|
||||
// FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies.
|
||||
var e = await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id)
|
||||
var e = await _db.Expenses.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id)
|
||||
?? throw new KeyNotFoundException($"Expense {id} not found.");
|
||||
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
|
||||
throw new InvalidOperationException("You can only edit your own draft or pending reimbursements.");
|
||||
|
||||
e.MinistryId = r.MinistryId; e.CategoryGroupId = r.CategoryGroupId; e.SubCategoryId = r.SubCategoryId;
|
||||
e.Amount = r.Amount; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
|
||||
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; e.FunctionalClass = r.FunctionalClass;
|
||||
e.MinistryId = r.MinistryId; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
|
||||
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes;
|
||||
if (e.Type == "VendorPayment") e.VendorName = r.VendorName;
|
||||
|
||||
// Replace the line set wholesale (lines are owned by the header), recompute the total.
|
||||
_db.ExpenseLines.RemoveRange(e.Lines);
|
||||
e.Lines = BuildLines(r.Lines);
|
||||
e.Amount = r.Lines.Sum(l => l.Amount);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -53,17 +53,24 @@ public class FinanceDashboardService : IFinanceDashboardService
|
||||
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);
|
||||
if (ministryId.HasValue) q = q.Where(e => e.MinistryId == ministryId.Value);
|
||||
|
||||
// Group by the deepest level whose parent id is supplied.
|
||||
// Lines belonging to the scoped (Paid+Approved, optionally ministry-filtered) expenses.
|
||||
var scopedLines = from l in _db.ExpenseLines
|
||||
join e in q on l.ExpenseId equals e.Id
|
||||
select l;
|
||||
|
||||
// Group by the deepest level whose parent id is supplied. Category levels aggregate
|
||||
// over LINES (line amounts); the ministry level uses the header total to avoid
|
||||
// double-counting a multi-line expense across its lines.
|
||||
List<(int Id, decimal Amount)> grouped;
|
||||
if (categoryGroupId.HasValue)
|
||||
grouped = (await q.GroupBy(e => e.SubCategoryId)
|
||||
grouped = (await scopedLines.Where(l => l.CategoryGroupId == categoryGroupId.Value)
|
||||
.GroupBy(l => l.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)
|
||||
grouped = (await scopedLines.GroupBy(l => l.CategoryGroupId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
|
||||
.Select(x => (x.Id, x.Amount)).ToList();
|
||||
else
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ROLAC.API.Services;
|
||||
/// <summary>
|
||||
/// Read-only aggregation that produces the IRS Form 990 Part IX Statement of Functional
|
||||
/// Expenses. Expense scope matches FinanceDashboardService: Paid + Approved only.
|
||||
/// Single function per expense (direct-charge); no cost splitting.
|
||||
/// Each expense line is categorized independently, so one invoice can span multiple lines.
|
||||
/// </summary>
|
||||
public class Form990ReportService : IForm990ReportService
|
||||
{
|
||||
@@ -40,13 +40,14 @@ public class Form990ReportService : IForm990ReportService
|
||||
|
||||
var rows = await (
|
||||
from e in expenses
|
||||
join l in _db.ExpenseLines on e.Id equals l.ExpenseId
|
||||
join m in _db.Ministries on e.MinistryId equals m.Id
|
||||
join sub in _db.ExpenseSubCategories on e.SubCategoryId equals sub.Id
|
||||
join grp in _db.ExpenseCategoryGroups on e.CategoryGroupId equals grp.Id
|
||||
join sub in _db.ExpenseSubCategories on l.SubCategoryId equals sub.Id
|
||||
join grp in _db.ExpenseCategoryGroups on l.CategoryGroupId equals grp.Id
|
||||
select new
|
||||
{
|
||||
e.Amount,
|
||||
e.FunctionalClass,
|
||||
l.Amount,
|
||||
l.FunctionalClass,
|
||||
MinistryDefault = m.DefaultFunctionalClass,
|
||||
SubLineId = sub.Form990LineId,
|
||||
GroupLineId = grp.Form990LineId,
|
||||
|
||||
Reference in New Issue
Block a user