Skip to content

Commit 99cff7c

Browse files
Merge pull request #440 from TransactionProcessing/task/#438_todays_sales_by_hour
Add hourly sales by day API with comparison support
2 parents 04ac4db + 4500e33 commit 99cff7c

11 files changed

Lines changed: 236 additions & 27 deletions

File tree

EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,5 @@ public record TransactionDetailReportQuery(Guid EstateId, TransactionDetailRepor
1313
public record TransactionSummaryByMerchantQuery(Guid EstateId, TransactionSummaryByMerchantRequest Request) : IRequest<Result<TransactionSummaryByMerchantResponse>>;
1414
public record TransactionSummaryByOperatorQuery(Guid EstateId, TransactionSummaryByOperatorRequest Request) : IRequest<Result<TransactionSummaryByOperatorResponse>>;
1515
public record ProductPerformanceQuery(Guid EstateId, DateTime StartDate, DateTime EndDate) : IRequest<Result<ProductPerformanceResponse>>;
16-
16+
public record TodaysSalesByHour(Guid estateId, DateTime comparisonDate) : IRequest<Result<List<Models.TodaysSalesByHour>>>;
1717
}

EstateReportingAPI.BusinessLogic/ReportingManager.cs

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ Task<Result<TransactionSummaryByOperatorResponse>> GetTransactionSummaryByOperat
5252
Task<Result<ProductPerformanceResponse>> GetProductPerformanceReport(TransactionQueries.ProductPerformanceQuery request,
5353
CancellationToken cancellationToken);
5454

55+
Task<Result<List<TodaysSalesByHour>>> GetTodaysSalesByHour(TransactionQueries.TodaysSalesByHour request,
56+
CancellationToken cancellationToken);
5557
#endregion
5658
}
5759

@@ -1449,22 +1451,68 @@ into g
14491451
return Result.Success(response);
14501452
}
14511453

1454+
public async Task<Result<List<TodaysSalesByHour>>> GetTodaysSalesByHour(TransactionQueries.TodaysSalesByHour request,
1455+
CancellationToken cancellationToken) {
1456+
using ResolvedDbContext<EstateManagementContext>? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.estateId.ToString());
1457+
await using EstateManagementContext context = resolvedContext.Context;
1458+
1459+
IQueryable<TodayTransaction> todaysSales = this.BuildTodaySalesQuery(context);
1460+
IQueryable<TransactionHistory> comparisonSales = this.BuildComparisonSalesQuery(context, request.comparisonDate);
1461+
1462+
// First we need to get a value of todays sales
1463+
var todaysSalesByHour = await (from t in todaysSales
1464+
group t.TransactionAmount by t.Hour into g select new { Hour = g.Key, TotalSalesCount = g.Count(), TotalSalesValue = g.Sum() }).ToListAsync(cancellationToken);
1465+
1466+
var comparisonSalesByHour = await (from t in comparisonSales group t.TransactionAmount by t.Hour into g select new { Hour = g.Key, TotalSalesCount = g.Count(), TotalSalesValue = g.Sum() }).ToListAsync(cancellationToken);
1467+
1468+
var response =
1469+
(
1470+
from today in todaysSalesByHour
1471+
join comparison in comparisonSalesByHour
1472+
on today.Hour equals comparison.Hour into compGroup
1473+
from comparison in compGroup.DefaultIfEmpty()
1474+
select new TodaysSalesByHour
1475+
{
1476+
Hour = today.Hour.Value,
1477+
TodaysSalesCount = today.TotalSalesCount,
1478+
TodaysSalesValue = today.TotalSalesValue,
1479+
ComparisonSalesCount = comparison?.TotalSalesCount ?? 0,
1480+
ComparisonSalesValue = comparison?.TotalSalesValue ?? 0
1481+
}
1482+
)
1483+
.Union
1484+
(
1485+
from comparison in comparisonSalesByHour
1486+
join today in todaysSalesByHour
1487+
on comparison.Hour equals today.Hour into todayGroup
1488+
from today in todayGroup.DefaultIfEmpty()
1489+
where today == null
1490+
select new TodaysSalesByHour
1491+
{
1492+
Hour = comparison.Hour.Value,
1493+
TodaysSalesCount = 0,
1494+
TodaysSalesValue = 0,
1495+
ComparisonSalesCount = comparison.TotalSalesCount,
1496+
ComparisonSalesValue = comparison.TotalSalesValue
1497+
}
1498+
)
1499+
.ToList();
1500+
1501+
1502+
return Result.Success(response);
1503+
}
1504+
14521505
private IQueryable<TodayTransaction> BuildTodaySalesQuery(EstateManagementContext context) {
1453-
return from t in context.TodayTransactions where t.IsAuthorised && t.TransactionType == "Sale" && t.TransactionDate == DateTime.Now.Date && t.TransactionTime <= DateTime.Now.TimeOfDay select t;
1506+
return from t in context.TodayTransactions where t.IsAuthorised && t.TransactionType == "Sale"
1507+
&& t.TransactionDate == DateTime.Now.Date && t.TransactionTime <= DateTime.Now.TimeOfDay
1508+
select t;
14541509
}
14551510

14561511
private IQueryable<TransactionHistory> BuildComparisonSalesQuery(EstateManagementContext context,
14571512
DateTime comparisonDate) {
14581513
return from t in context.TransactionHistory where t.IsAuthorised && t.TransactionType == "Sale" && t.TransactionDate == comparisonDate && t.TransactionTime <= DateTime.Now.TimeOfDay select t;
14591514
}
14601515

1461-
1462-
1463-
1464-
1465-
1466-
1467-
14681516
#endregion
14691517
}
14701518

EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ public class TransactionRequestHandler : IRequestHandler<TransactionQueries.Toda
1010
IRequestHandler<TransactionQueries.TransactionDetailReportQuery, Result<TransactionDetailReportResponse>>,
1111
IRequestHandler<TransactionQueries.TransactionSummaryByMerchantQuery, Result<TransactionSummaryByMerchantResponse>>,
1212
IRequestHandler<TransactionQueries.TransactionSummaryByOperatorQuery, Result<TransactionSummaryByOperatorResponse>>,
13-
IRequestHandler<TransactionQueries.ProductPerformanceQuery, Result<ProductPerformanceResponse>>
13+
IRequestHandler<TransactionQueries.ProductPerformanceQuery, Result<ProductPerformanceResponse>>,
14+
IRequestHandler<TransactionQueries.TodaysSalesByHour, Result<List<TodaysSalesByHour>>>
15+
1416
{
1517
private readonly IReportingManager Manager;
1618

@@ -49,4 +51,9 @@ public async Task<Result<ProductPerformanceResponse>> Handle(TransactionQueries.
4951
CancellationToken cancellationToken) {
5052
return await this.Manager.GetProductPerformanceReport(request, cancellationToken);
5153
}
54+
55+
public async Task<Result<List<TodaysSalesByHour>>> Handle(TransactionQueries.TodaysSalesByHour request,
56+
CancellationToken cancellationToken) {
57+
return await this.Manager.GetTodaysSalesByHour(request, cancellationToken);
58+
}
5259
}

EstateReportingAPI.DataTrasferObjects/TodaysSalesValueByHour.cs renamed to EstateReportingAPI.DataTrasferObjects/TodaysSalesByHour.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22
using Newtonsoft.Json;
33
using System;
44

5-
public class TodaysSalesValueByHour{
5+
public class TodaysSalesByHour{
66
[JsonProperty("hour")]
77
public Int32 Hour{ get; set; }
88
[JsonProperty("todays_sales_value")]
99
public Decimal TodaysSalesValue { get; set; }
1010
[JsonProperty("comparison_sales_value")]
1111
public Decimal ComparisonSalesValue { get; set; }
12+
[JsonProperty("todays_sales_count")]
13+
public Int32 TodaysSalesCount { get; set; }
14+
[JsonProperty("comparison_sales_count")]
15+
public Int32 ComparisonSalesCount { get; set; }
1216
}
1317
}

EstateReportingAPI.DataTrasferObjects/TodaysSalesCountByHour.cs

Lines changed: 0 additions & 14 deletions
This file was deleted.

EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -939,6 +939,135 @@ public async Task TransactionsEndpoint_ProductPerformanceReport_SummaryDataRetur
939939
productPerformanceResponseDetail.TransactionCount.ShouldBe(transactions.Count(t => t.ContractProductId == product.productId));
940940
productPerformanceResponseDetail.TransactionValue.ShouldBe(transactions.Where(t => t.ContractProductId == product.productId).Sum(t => t.TransactionAmount));
941941
}
942+
}
943+
944+
private static int RandomizeCount(int baseCount, Random rnd, double variability = 0.3)
945+
{
946+
if (baseCount <= 0) return 0;
947+
// factor in [-variability, +variability]
948+
double factor = 1.0 + (rnd.NextDouble() * 2.0 - 1.0) * variability;
949+
int result = (int)Math.Round(baseCount * factor);
950+
return Math.Max(0, result);
951+
}
952+
953+
[Fact]
954+
public async Task TransactionsEndpoint_TodaysSalesByHour_SummaryDataReturned()
955+
{
956+
Stopwatch sw = Stopwatch.StartNew();
957+
958+
List<Transaction> todaysTransactions = new List<Transaction>();
959+
List<Transaction> comparisonDateTransactions = new List<Transaction>();
960+
961+
Dictionary<string, int> transactionCounts = new() { { "Test Merchant 1", 3 }, { "Test Merchant 2", 6 }, { "Test Merchant 3", 2 }, { "Test Merchant 4", 0 } };
962+
963+
// TODO: make counts dynamic
964+
DateTime todaysDateTime = DateTime.Now;
965+
966+
for (int hour = 0; hour < 24; hour++)
967+
{
968+
List<Transaction> localList = new List<Transaction>();
969+
DateTime date = new DateTime(todaysDateTime.Year, todaysDateTime.Month, todaysDateTime.Day, hour, 0, 0);
970+
971+
// Seed per-hour RNG deterministically so test results are reproducible per-day/per-hour
972+
var hourSeed = todaysDateTime.Date.GetHashCode() ^ hour;
973+
var hourRnd = new Random(hourSeed);
942974

975+
foreach (var merchant in merchantsList)
976+
{
977+
foreach (var contract in contractList)
978+
{
979+
var productList = contractProducts.Single(cp => cp.Key == contract.contractId).Value;
980+
foreach ((Guid productId, String productName, Decimal? productValue, Int32 contractProductReportingId) product in productList)
981+
{
982+
var baseCount = transactionCounts.Single(m => m.Key == merchant.Name).Value;
983+
984+
// keep the original hour-based multipliers, but apply random variation to the final count
985+
int hourMultiplierCount = hour switch
986+
{
987+
_ when hour >= 9 && hour < 18 => baseCount * hour, // business hours
988+
_ when hour >= 18 && hour < 21 => baseCount * (24 - hour), // evening spike
989+
_ => baseCount // off hours
990+
};
991+
992+
int transactionCount = RandomizeCount(hourMultiplierCount, hourRnd, variability: 0.3);
993+
994+
for (int i = 0; i < transactionCount; i++)
995+
{
996+
Transaction transaction = await helper.BuildTransactionX(date, merchant.MerchantId, contract.operatorId, contract.contractId, product.productId, "0000", product.productValue);
997+
todaysTransactions.Add(transaction);
998+
}
999+
}
1000+
}
1001+
}
1002+
1003+
todaysTransactions.AddRange(localList);
1004+
}
1005+
1006+
await this.helper.AddTransactionsX(todaysTransactions);
1007+
1008+
sw.Stop();
1009+
this.TestOutputHelper.WriteLine($"Setup Todays Txns {sw.ElapsedMilliseconds}ms");
1010+
sw.Restart();
1011+
1012+
DateTime comparisonDate = todaysDateTime.AddDays(-1);
1013+
for (int hour = 0; hour < 24; hour++)
1014+
{
1015+
List<Transaction> localList = new List<Transaction>();
1016+
DateTime date = new DateTime(comparisonDate.Year, comparisonDate.Month, comparisonDate.Day, hour, 0, 0);
1017+
1018+
// Separate deterministic seed for comparison date hour
1019+
var compHourSeed = comparisonDate.Date.GetHashCode() ^ hour;
1020+
var compHourRnd = new Random(compHourSeed);
1021+
1022+
foreach (var merchant in merchantsList)
1023+
{
1024+
foreach (var contract in contractList)
1025+
{
1026+
var productList = contractProducts.Single(cp => cp.Key == contract.contractId).Value;
1027+
foreach (var product in productList)
1028+
{
1029+
var baseCount = transactionCounts.Single(m => m.Key == merchant.Name).Value;
1030+
1031+
int hourMultiplierCount = hour switch
1032+
{
1033+
_ when hour >= 12 && hour < 18 => baseCount * hour, // business hours
1034+
_ when hour >= 18 && hour < 21 => baseCount * (24 - hour), // evening spike
1035+
_ => baseCount // off hours
1036+
};
1037+
1038+
int transactionCount = RandomizeCount(hourMultiplierCount, compHourRnd, variability: 0.3);
1039+
1040+
for (int i = 0; i < transactionCount; i++)
1041+
{
1042+
Transaction transaction = await helper.BuildTransactionX(date, merchant.MerchantId, contract.operatorId, contract.contractId, product.productId, "0000", product.productValue);
1043+
comparisonDateTransactions.Add(transaction);
1044+
}
1045+
}
1046+
}
1047+
}
1048+
1049+
comparisonDateTransactions.AddRange(localList);
1050+
1051+
}
1052+
1053+
await this.helper.AddTransactionsX(comparisonDateTransactions);
1054+
1055+
await this.helper.RunTodaysTransactionsSummaryProcessing(comparisonDate.Date);
1056+
await this.helper.RunHistoricTransactionsSummaryProcessing(comparisonDate.Date);
1057+
await this.helper.RunTodaysTransactionsSummaryProcessing(todaysDateTime.Date);
1058+
1059+
var result = await this.CreateAndSendHttpRequestMessage<List<DataTransferObjects.TodaysSalesByHour>>($"{this.BaseRoute}/todayssalesbyhour?comparisondate={comparisonDate:yyyy-MM-dd}", CancellationToken.None);
1060+
result.IsSuccess.ShouldBeTrue();
1061+
var todaysSalesByHour = result.Data;
1062+
todaysSalesByHour.ShouldNotBeNull();
1063+
1064+
foreach (var hour in todaysSalesByHour) {
1065+
hour.ShouldNotBeNull();
1066+
hour.TodaysSalesCount.ShouldBe(todaysTransactions.Count(t => t.TransactionDateTime.Hour == hour.Hour && t.TransactionTime <= DateTime.Now.TimeOfDay), hour.Hour.ToString());
1067+
hour.TodaysSalesValue.ShouldBe(todaysTransactions.Where(t => t.TransactionDateTime.Hour == hour.Hour && t.TransactionTime <= DateTime.Now.TimeOfDay).Sum(t => t.TransactionAmount), hour.Hour.ToString());
1068+
hour.ComparisonSalesCount.ShouldBe(comparisonDateTransactions.Count(t => t.TransactionDateTime.Hour == hour.Hour && t.TransactionTime <= DateTime.Now.TimeOfDay), hour.Hour.ToString());
1069+
hour.ComparisonSalesValue.ShouldBe(comparisonDateTransactions.Where(t => t.TransactionDateTime.Hour == hour.Hour && t.TransactionTime <= DateTime.Now.TimeOfDay).Sum(t => t.TransactionAmount), hour.Hour.ToString());
1070+
1071+
}
9431072
}
9441073
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace EstateReportingAPI.Models;
2+
3+
public class TodaysSalesByHour
4+
{
5+
public Int32 Hour { get; set; }
6+
public Decimal TodaysSalesValue { get; set; }
7+
public Decimal ComparisonSalesValue { get; set; }
8+
public Int32 TodaysSalesCount { get; set; }
9+
public Int32 ComparisonSalesCount { get; set; }
10+
}

EstateReportingAPI/Bootstrapper/MediatorRegistry.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public MediatorRegistry() {
2525
this.AddSingleton<IRequestHandler<TransactionQueries.TransactionSummaryByMerchantQuery, Result<TransactionSummaryByMerchantResponse>>, TransactionRequestHandler>();
2626
this.AddSingleton<IRequestHandler<TransactionQueries.TransactionSummaryByOperatorQuery, Result<TransactionSummaryByOperatorResponse>>, TransactionRequestHandler>();
2727
this.AddSingleton<IRequestHandler<TransactionQueries.ProductPerformanceQuery, Result<ProductPerformanceResponse>>, TransactionRequestHandler>();
28+
this.AddSingleton<IRequestHandler<TransactionQueries.TodaysSalesByHour, Result<List<TodaysSalesByHour>>>, TransactionRequestHandler>();
2829

2930
this.AddSingleton<IRequestHandler<CalendarQueries.GetYearsQuery, Result<List<Int32>>>, CalendarRequestHandler>();
3031
this.AddSingleton<IRequestHandler<CalendarQueries.GetAllDatesQuery, Result<List<Calendar>>>, CalendarRequestHandler>();
@@ -44,3 +45,4 @@ public MediatorRegistry() {
4445
}
4546
}
4647

48+

EstateReportingAPI/Endpoints/TransactionEndpoints.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ public static void MapTransactionEndpoints(this IEndpointRouteBuilder app)
3030
.WithStandardProduces<TransactionSummaryByOperatorResponse>();
3131
group.MapGet("productperformancereport", TransactionHandler.ProductPerformanceReport)
3232
.WithStandardProduces<ProductPerformanceResponse>();
33+
group.MapGet("todayssalesbyhour", TransactionHandler.TodaysSalesByHour)
34+
.WithStandardProduces<List<TodaysSalesByHour>>();
3335

3436
}
3537
}

EstateReportingAPI/Handlers/TransactionHandler.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,4 +181,27 @@ TransactionSummaryByOperatorResponse SuccessFactory(Models.TransactionSummaryByO
181181
};
182182
return ResponseFactory.FromResult(result, SuccessFactory);
183183
}
184+
185+
public static async Task<IResult> TodaysSalesByHour([FromHeader] Guid estateId,
186+
[FromQuery] DateTime comparisonDate,
187+
IMediator mediator,
188+
CancellationToken cancellationToken)
189+
{
190+
var query = new TransactionQueries.TodaysSalesByHour(estateId, comparisonDate);
191+
var result = await mediator.Send(query, cancellationToken);
192+
193+
List<DataTransferObjects.TodaysSalesByHour> SuccessFactory(List<Models.TodaysSalesByHour> r) =>
194+
r.Select(item => new DataTransferObjects.TodaysSalesByHour
195+
{
196+
Hour = item.Hour,
197+
ComparisonSalesCount = item.ComparisonSalesCount,
198+
TodaysSalesCount = item.TodaysSalesCount,
199+
ComparisonSalesValue = item.ComparisonSalesValue,
200+
TodaysSalesValue = item.TodaysSalesValue
201+
}).ToList();
202+
203+
204+
return ResponseFactory.FromResult(result, SuccessFactory);
205+
}
206+
184207
}

0 commit comments

Comments
 (0)