feat(expense): map category group/subcategory to Form 990 lines
This commit is contained in:
@@ -58,4 +58,20 @@ public class ExpenseCategoryServiceTests
|
|||||||
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
|
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
|
||||||
svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" }));
|
svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAndGet_RoundTrips_Form990LineId()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
db.Form990ExpenseLines.Add(new ROLAC.API.Entities.Form990ExpenseLine { Id = 7, LineCode = "7", Name_en = "Salaries" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var svc = new ExpenseCategoryService(db);
|
||||||
|
var gid = await svc.CreateGroupAsync(new CreateExpenseGroupRequest { Name_en = "Personnel", Form990LineId = 24 });
|
||||||
|
var sid = await svc.CreateSubCategoryAsync(new CreateExpenseSubCategoryRequest { GroupId = gid, Name_en = "Salary & Wages", Form990LineId = 7 });
|
||||||
|
|
||||||
|
var all = await svc.GetAllAsync(includeInactive: true);
|
||||||
|
var sub = all.Single(g => g.Id == gid).SubCategories.Single(s => s.Id == sid);
|
||||||
|
Assert.Equal(7, sub.Form990LineId);
|
||||||
|
Assert.Equal(24, all.Single(g => g.Id == gid).Form990LineId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ public class ExpenseSubCategoryDto
|
|||||||
public string? Name_zh { get; set; }
|
public string? Name_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
|
public string? Form990LineCode { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ExpenseCategoryGroupDto
|
public class ExpenseCategoryGroupDto
|
||||||
@@ -18,6 +20,8 @@ public class ExpenseCategoryGroupDto
|
|||||||
public string? Name_zh { get; set; }
|
public string? Name_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
|
public string? Form990LineCode { get; set; }
|
||||||
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
|
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +30,7 @@ public class CreateExpenseGroupRequest
|
|||||||
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
||||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
}
|
}
|
||||||
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
|
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
|
||||||
{
|
{
|
||||||
@@ -38,6 +43,7 @@ public class CreateExpenseSubCategoryRequest
|
|||||||
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
||||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
}
|
}
|
||||||
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
|
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -221,6 +221,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
||||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
entity.HasOne(e => e.Form990Line).WithMany()
|
||||||
|
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── ExpenseSubCategory ───────────────────────────────────────────────
|
// ── ExpenseSubCategory ───────────────────────────────────────────────
|
||||||
@@ -232,6 +234,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
entity.HasOne(e => e.Group).WithMany(g => g.SubCategories)
|
entity.HasOne(e => e.Group).WithMany(g => g.SubCategories)
|
||||||
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(e => e.Form990Line).WithMany()
|
||||||
|
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Expense ──────────────────────────────────────────────────────────
|
// ── Expense ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -9,5 +9,8 @@ public class ExpenseCategoryGroup : AuditableEntity, IAuditable
|
|||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
|
public Form990ExpenseLine? Form990Line { get; set; }
|
||||||
|
|
||||||
public List<ExpenseSubCategory> SubCategories { get; set; } = [];
|
public List<ExpenseSubCategory> SubCategories { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,8 @@ public class ExpenseSubCategory : AuditableEntity, IAuditable
|
|||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
|
public Form990ExpenseLine? Form990Line { get; set; }
|
||||||
|
|
||||||
public ExpenseCategoryGroup? Group { get; set; }
|
public ExpenseCategoryGroup? Group { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,21 +22,28 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
|||||||
.OrderBy(s => s.SortOrder).ThenBy(s => s.Name_en)
|
.OrderBy(s => s.SortOrder).ThenBy(s => s.Name_en)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
var lineCodes = await _db.Form990ExpenseLines.AsNoTracking()
|
||||||
|
.ToDictionaryAsync(l => l.Id, l => l.LineCode);
|
||||||
|
|
||||||
return groups.Select(g => new ExpenseCategoryGroupDto
|
return groups.Select(g => new ExpenseCategoryGroupDto
|
||||||
{
|
{
|
||||||
Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh,
|
Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh,
|
||||||
SortOrder = g.SortOrder, IsActive = g.IsActive,
|
SortOrder = g.SortOrder, IsActive = g.IsActive,
|
||||||
|
Form990LineId = g.Form990LineId,
|
||||||
|
Form990LineCode = g.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(g.Form990LineId.Value) : null,
|
||||||
SubCategories = subs.Where(s => s.GroupId == g.Id).Select(s => new ExpenseSubCategoryDto
|
SubCategories = subs.Where(s => s.GroupId == g.Id).Select(s => new ExpenseSubCategoryDto
|
||||||
{
|
{
|
||||||
Id = s.Id, GroupId = s.GroupId, Name_en = s.Name_en, Name_zh = s.Name_zh,
|
Id = s.Id, GroupId = s.GroupId, Name_en = s.Name_en, Name_zh = s.Name_zh,
|
||||||
SortOrder = s.SortOrder, IsActive = s.IsActive,
|
SortOrder = s.SortOrder, IsActive = s.IsActive,
|
||||||
|
Form990LineId = s.Form990LineId,
|
||||||
|
Form990LineCode = s.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(s.Form990LineId.Value) : null,
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> CreateGroupAsync(CreateExpenseGroupRequest r)
|
public async Task<int> CreateGroupAsync(CreateExpenseGroupRequest r)
|
||||||
{
|
{
|
||||||
var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true };
|
var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId };
|
||||||
_db.ExpenseCategoryGroups.Add(g);
|
_db.ExpenseCategoryGroups.Add(g);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
return g.Id;
|
return g.Id;
|
||||||
@@ -46,7 +53,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
|||||||
{
|
{
|
||||||
var g = await _db.ExpenseCategoryGroups.FindAsync(id)
|
var g = await _db.ExpenseCategoryGroups.FindAsync(id)
|
||||||
?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found.");
|
?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found.");
|
||||||
g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive;
|
g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; g.Form990LineId = r.Form990LineId;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +69,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
|||||||
{
|
{
|
||||||
var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId);
|
var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId);
|
||||||
if (!exists) throw new KeyNotFoundException($"ExpenseCategoryGroup {r.GroupId} not found.");
|
if (!exists) throw new KeyNotFoundException($"ExpenseCategoryGroup {r.GroupId} not found.");
|
||||||
var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true };
|
var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId };
|
||||||
_db.ExpenseSubCategories.Add(s);
|
_db.ExpenseSubCategories.Add(s);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
return s.Id;
|
return s.Id;
|
||||||
@@ -72,7 +79,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
|||||||
{
|
{
|
||||||
var s = await _db.ExpenseSubCategories.FindAsync(id)
|
var s = await _db.ExpenseSubCategories.FindAsync(id)
|
||||||
?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found.");
|
?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found.");
|
||||||
s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive;
|
s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; s.Form990LineId = r.Form990LineId;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user