Group booking-text charts by first word instead of full text

Bookings like "Narbonne Car" and "Narbonne Food" should roll up into
one series/slice instead of staying separate, so grouping now uses
the text up to the first space via TransactionDataset.GetTextGroup().
This commit is contained in:
Claudio Schaad 2026-08-07 18:23:22 +02:00
parent 7f284b1032
commit 5d6d49ed8b
4 changed files with 85 additions and 3 deletions

View file

@ -43,5 +43,20 @@ namespace Schaad.Accounting.Datasets
return GetValue(withFxRate); return GetValue(withFxRate);
} }
/// <summary>
/// First word of <see cref="Text"/>, used to group related bookings (e.g. "Narbonne Car"
/// and "Narbonne Food" both group under "Narbonne").
/// </summary>
public string GetTextGroup()
{
if (string.IsNullOrEmpty(Text))
{
return Text;
}
var spaceIndex = Text.IndexOf(' ');
return spaceIndex < 0 ? Text : Text[..spaceIndex];
}
} }
} }

View file

@ -108,7 +108,7 @@ namespace Schaad.Accounting.Services
var maxMonth = transactions.Max(t => t.ValueDate.Month); var maxMonth = transactions.Max(t => t.ValueDate.Month);
var list = new List<DataSerie>(); var list = new List<DataSerie>();
foreach (var grp in transactions.GroupBy(t => t.Text)) foreach (var grp in transactions.GroupBy(t => t.GetTextGroup()))
{ {
var groupedByMonth = grp var groupedByMonth = grp
.GroupBy(t => t.ValueDate.Month) .GroupBy(t => t.ValueDate.Month)
@ -132,7 +132,7 @@ namespace Schaad.Accounting.Services
return transactions return transactions
.Where(t => t.TargetAccountId == accountId) .Where(t => t.TargetAccountId == accountId)
.GroupBy(t => t.Text) .GroupBy(t => t.GetTextGroup())
.Select(g => new CategoryTotal(g.Key, g.Sum(t => t.GetValue(account.IsFxAccount, accountId)))) .Select(g => new CategoryTotal(g.Key, g.Sum(t => t.GetValue(account.IsFxAccount, accountId))))
.Where(c => c.Value != 0) .Where(c => c.Value != 0)
.ToList(); .ToList();

View file

@ -193,6 +193,24 @@ public class ChartServiceTestShould
coop.Y[1].ShouldBe(-20m); coop.Y[1].ShouldBe(-20m);
} }
[Fact]
public async Task GroupBookingTextsByFirstWordWhenBuildingMonthlySeriesForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Narbonne Car"),
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 1, 6), text: "Narbonne Food")
});
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
var narbonne = series.Single(s => s.Name == "Narbonne");
narbonne.Y[0].ShouldBe(-70m);
}
[Fact] [Fact]
public async Task ShowPositiveValueWhenAccountIsTargetOfBookingTextTransaction() public async Task ShowPositiveValueWhenAccountIsTargetOfBookingTextTransaction()
{ {
@ -245,6 +263,24 @@ public class ChartServiceTestShould
totals.ShouldContain(t => t.Name == "Coop" && t.Value == -20m); totals.ShouldContain(t => t.Name == "Coop" && t.Value == -20m);
} }
[Fact]
public async Task GroupBookingTextsByFirstWordWhenBuildingTotalsForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("groc").Returns(AsDataset(groceries));
viewService.GetTransactionViewListAsync("groc").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Narbonne Car"),
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 1, 6), text: "Narbonne Food")
});
var totals = await BuildService().GetBookingTextTotalsAsync("groc");
totals.Single().Name.ShouldBe("Narbonne");
totals.Single().Value.ShouldBe(70m);
}
[Fact] [Fact]
public async Task NetOutgoingAndIncomingBookingsWithTheSameTextWhenBuildingTotalsForAccount() public async Task NetOutgoingAndIncomingBookingsWithTheSameTextWhenBuildingTotalsForAccount()
{ {

View file

@ -8,7 +8,7 @@ public class TransactionDatasetTestShould
{ {
private static Account MakeAccount(string id) => new() { Id = id, Name = id, Currency = "CHF" }; private static Account MakeAccount(string id) => new() { Id = id, Name = id, Currency = "CHF" };
private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, decimal? fxRate = null) => private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, decimal? fxRate = null, string text = "") =>
new( new(
new Transaction new Transaction
{ {
@ -17,6 +17,7 @@ public class TransactionDatasetTestShould
TargetAccountId = target.Id, TargetAccountId = target.Id,
Value = value, Value = value,
FxRate = fxRate, FxRate = fxRate,
Text = text,
ValueDate = DateTime.Now, ValueDate = DateTime.Now,
BookingDate = DateTime.Now BookingDate = DateTime.Now
}, },
@ -51,4 +52,34 @@ public class TransactionDatasetTestShould
transaction.GetValue(withFxRate: true, accountId: "chk").ShouldBe(-200m); transaction.GetValue(withFxRate: true, accountId: "chk").ShouldBe(-200m);
} }
[Fact]
public void ReturnFirstWordWhenTextHasMultipleWords()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(checking, groceries, value: 50m, text: "Narbonne Car");
transaction.GetTextGroup().ShouldBe("Narbonne");
}
[Fact]
public void ReturnWholeTextWhenTextHasNoSpace()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(checking, groceries, value: 50m, text: "Migros");
transaction.GetTextGroup().ShouldBe("Migros");
}
[Fact]
public void ReturnEmptyStringWhenTextIsEmpty()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(checking, groceries, value: 50m, text: "");
transaction.GetTextGroup().ShouldBe("");
}
} }