update detail.
ci-cd-vm / ci-cd (push) Successful in 4m27s

This commit is contained in:
Chris Chen
2026-06-25 09:33:49 -07:00
parent 609ce6a439
commit 8bdb942a49
23 changed files with 698 additions and 271 deletions
+78 -26
View File
@@ -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();
}