diff --git a/.github/workflows/codecoverage.yml b/.github/workflows/codecoverage.yml index ffd8e0a..62a48b4 100644 --- a/.github/workflows/codecoverage.yml +++ b/.github/workflows/codecoverage.yml @@ -32,8 +32,8 @@ jobs: - name: Run Unit Tests run: | echo "ASPNETCORE_ENVIRONMENT are > ${ASPNETCORE_ENVIRONMENT}" - dotnet run --project "EstateReportingAPI.Tests\EstateReportingAPI.Tests.csproj" --configuration Release --no-build -- --coverage --coverage-output-format cobertura --coverage-output coverage.cobertura.xml --results-directory "./coverage/unit" - dotnet run --project "EstateReportingAPI.BusinessLogic.UnitTests\EstateReportingAPI.BusinessLogic.UnitTests.csproj" --configuration Release --no-build -- --coverage --coverage-output-format cobertura --coverage-output coverage.cobertura.xml --results-directory "./coverage/unit" + dotnet run --project "EstateReportingAPI.Tests\EstateReportingAPI.Tests.csproj" --configuration Release --no-build -- --coverage --coverage-output-format cobertura --coverage-output coverage.cobertura.xml --results-directory "./coverage/unit/api" + dotnet run --project "EstateReportingAPI.BusinessLogic.UnitTests\EstateReportingAPI.BusinessLogic.UnitTests.csproj" --configuration Release --no-build -- --coverage --coverage-output-format cobertura --coverage-output coverage.cobertura.xml --results-directory "./coverage/unit/business-logic" - name: Build Docker Image run: docker build . --file EstateReportingAPI/Dockerfile --tag estatereportingapi:latest diff --git a/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerExtensionsTests.cs b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerExtensionsTests.cs index 8fcdc42..e492ff5 100644 --- a/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerExtensionsTests.cs +++ b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerExtensionsTests.cs @@ -9,7 +9,7 @@ namespace EstateReportingAPI.BusinessLogic.UnitTests; -public sealed class ReportingManagerExtensionsTests +public sealed class ReportingQueryExtensionsTests { [Fact] public void TodayTransactionFilters_HandleEmptyAndSelectedValues() diff --git a/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs index 93d3410..c501dde 100644 --- a/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs +++ b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs @@ -8,10 +8,11 @@ using TransactionProcessor.Database.Contexts; using Db = TransactionProcessor.Database.Entities; using BalanceState = TransactionProcessor.ProjectionEngine.Database.Database.Entities.MerchantBalanceProjectionState; +using EstateReportingAPI.BusinessLogic.Services; namespace EstateReportingAPI.BusinessLogic.UnitTests; -public sealed class ReportingManagerReportTests +public sealed class ReportingServiceReportTests { private static readonly Guid EstateId = Guid.Parse("F64241E7-F778-4F77-8A64-099CB51BF4CE"); @@ -22,7 +23,7 @@ public async Task GetCalendarComparisonDates_ReturnsCalendarRows() database.AddCalendar(new DateTime(2026, 9, 1)); await database.SaveAsync(); - var result = await database.Manager.GetCalendarComparisonDates( + var result = await database.EstateService.GetCalendarComparisonDates( new CalendarQueries.GetComparisonDatesQuery(EstateId), CancellationToken.None); result.IsSuccess.ShouldBeTrue(); @@ -37,7 +38,7 @@ public async Task GetCalendarDates_ReturnsCalendarRows() database.AddCalendar(new DateTime(2026, 9, 1)); await database.SaveAsync(); - var result = await database.Manager.GetCalendarDates( + var result = await database.EstateService.GetCalendarDates( new CalendarQueries.GetAllDatesQuery(EstateId), CancellationToken.None); result.IsSuccess.ShouldBeTrue(); @@ -51,7 +52,7 @@ public async Task GetContract_ReturnsContractWithProducts() database.AddContractFees(); await database.SaveAsync(); - var result = await database.Manager.GetContract( + var result = await database.MerchantService.GetContract( new ContractQueries.GetContractQuery(EstateId, database.ContractId), CancellationToken.None); result.IsSuccess.ShouldBeTrue(); @@ -67,7 +68,7 @@ public async Task GetRecentContracts_ReturnsRecentContracts() { await using TestDatabase database = await TestDatabase.CreateAsync(); - var result = await database.Manager.GetRecentContracts( + var result = await database.MerchantService.GetRecentContracts( new ContractQueries.GetRecentContractsQuery(EstateId), CancellationToken.None); result.IsSuccess.ShouldBeTrue(); @@ -82,7 +83,7 @@ public async Task GetEstate_ReturnsEstateAndRelatedData() database.AddEstateUser(); await database.SaveAsync(); - var result = await database.Manager.GetEstate( + var result = await database.EstateService.GetEstate( new EstateQueries.GetEstateQuery(EstateId), CancellationToken.None); result.IsSuccess.ShouldBeTrue(); @@ -99,7 +100,7 @@ public async Task GetEstateOperators_ReturnsEstateOperators() database.AddEstateOperator(); await database.SaveAsync(); - var result = await database.Manager.GetEstateOperators( + var result = await database.EstateService.GetEstateOperators( new EstateQueries.GetEstateOperatorsQuery(EstateId), CancellationToken.None); result.IsSuccess.ShouldBeTrue(); @@ -115,7 +116,7 @@ public async Task GetRecentMerchants_ReturnsMerchantsWithBalances() database.AddBalance(database.SecondMerchantId, 20m); await database.SaveAsync(); - var result = await database.Manager.GetRecentMerchants( + var result = await database.MerchantService.GetRecentMerchants( new MerchantQueries.GetRecentMerchantsQuery(EstateId), CancellationToken.None); result.IsSuccess.ShouldBeTrue(); @@ -130,7 +131,7 @@ public async Task GetMerchantsTransactionKpis_ReturnsKpis() database.AddBalance(database.MerchantId, 10m); await database.SaveAsync(); - var result = await database.Manager.GetMerchantsTransactionKpis( + var result = await database.MerchantService.GetMerchantsTransactionKpis( new MerchantQueries.GetTransactionKpisQuery(EstateId), CancellationToken.None); result.IsSuccess.ShouldBeTrue(); @@ -142,7 +143,7 @@ public async Task GetOperators_ReturnsOperators() { await using TestDatabase database = await TestDatabase.CreateAsync(); - var result = await database.Manager.GetOperators( + var result = await database.MerchantService.GetOperators( new OperatorQueries.GetOperatorsQuery(EstateId), CancellationToken.None); result.IsSuccess.ShouldBeTrue(); @@ -154,7 +155,7 @@ public async Task GetOperator_ReturnsOperator() { await using TestDatabase database = await TestDatabase.CreateAsync(); - var result = await database.Manager.GetOperator( + var result = await database.MerchantService.GetOperator( new OperatorQueries.GetOperatorQuery(EstateId, database.OperatorId), CancellationToken.None); result.IsSuccess.ShouldBeTrue(); @@ -166,7 +167,7 @@ public async Task GetFileImportLogList_WhenNoLogs_ReturnsEmptyList() { await using TestDatabase database = await TestDatabase.CreateAsync(); - var result = await database.Manager.GetFileImportLogList( + var result = await database.FileImportService.GetFileImportLogList( new FileImportLogQueries.GetFileImportLogListQuery(EstateId, null, DateTime.Today.AddDays(-1), DateTime.Today), CancellationToken.None); @@ -181,7 +182,7 @@ public async Task GetFileImportLogList_WithFileDataBuildsHierarchicalResult() Guid logId = database.AddFileImportData(); await database.SaveAsync(); - var result = await database.Manager.GetFileImportLogList( + var result = await database.FileImportService.GetFileImportLogList( new FileImportLogQueries.GetFileImportLogListQuery(EstateId, null, DateTime.Today.AddDays(-1), DateTime.Today), CancellationToken.None); @@ -197,7 +198,7 @@ public async Task GetFileImportLog_WhenLogDoesNotExist_ReturnsNotFound() { await using TestDatabase database = await TestDatabase.CreateAsync(); - var result = await database.Manager.GetFileImportLog( + var result = await database.FileImportService.GetFileImportLog( new FileImportLogQueries.GetFileImportLogQuery(EstateId, null, Guid.NewGuid()), CancellationToken.None); result.IsSuccess.ShouldBeFalse(); @@ -210,7 +211,7 @@ public async Task GetFileImportLog_WithFileDataBuildsHierarchicalResult() Guid logId = database.AddFileImportData(); await database.SaveAsync(); - var result = await database.Manager.GetFileImportLog( + var result = await database.FileImportService.GetFileImportLog( new FileImportLogQueries.GetFileImportLogQuery(EstateId, null, logId), CancellationToken.None); result.IsSuccess.ShouldBeTrue(); @@ -224,7 +225,7 @@ public async Task GetFileProfileConfigurationList_WhenNoConfigurations_ReturnsEm { await using TestDatabase database = await TestDatabase.CreateAsync(); - var result = await database.Manager.GetFileProfileConfigurationList( + var result = await database.FileImportService.GetFileProfileConfigurationList( new FileProfileConfigurationQueries.GetFileProfileConfigurationListQuery(EstateId), CancellationToken.None); result.IsSuccess.ShouldBeTrue(); @@ -240,7 +241,7 @@ public async Task GetTransactionSummaryByMerchantReport_AverageUsesGroupedTotals database.AddSale(database.MerchantId, database.SecondOperatorId, 30m, new DateTime(2026, 9, 1)); await database.SaveAsync(); - var result = await database.Manager.GetTransactionSummaryByMerchantReport( + var result = await database.TransactionService.GetTransactionSummaryByMerchantReport( new TransactionQueries.TransactionSummaryByMerchantQuery( EstateId, new TransactionSummaryByMerchantRequest { @@ -263,7 +264,7 @@ public async Task GetTransactionSummaryByOperatorReport_SeparatesAuthorisedAndDe database.AddSale(database.SecondMerchantId, database.OperatorId, 30m, new DateTime(2026, 9, 1), authorised: true); await database.SaveAsync(); - var result = await database.Manager.GetTransactionSummaryByOperatorReport( + var result = await database.TransactionService.GetTransactionSummaryByOperatorReport( new TransactionQueries.TransactionSummaryByOperatorQuery( EstateId, new TransactionSummaryByOperatorRequest { @@ -290,7 +291,7 @@ public async Task GetTransactionDetailReport_AppliesMerchantProductAndOperatorFi database.AddSale(database.SecondMerchantId, database.SecondOperatorId, 20m, new DateTime(2026, 9, 1)); await database.SaveAsync(); - var result = await database.Manager.GetTransactionDetailReport( + var result = await database.TransactionService.GetTransactionDetailReport( new TransactionQueries.TransactionDetailReportQuery( EstateId, new TransactionDetailReportRequest { @@ -315,7 +316,7 @@ public async Task GetTransactionSummaryByOperatorReport_AppliesMerchantAndOperat database.AddSale(database.SecondMerchantId, database.SecondOperatorId, 20m, new DateTime(2026, 9, 1)); await database.SaveAsync(); - var result = await database.Manager.GetTransactionSummaryByOperatorReport( + var result = await database.TransactionService.GetTransactionSummaryByOperatorReport( new TransactionQueries.TransactionSummaryByOperatorQuery( EstateId, new TransactionSummaryByOperatorRequest { @@ -335,7 +336,7 @@ public async Task GetTransactionSummaryByMerchantReport_WhenNoSales_ReturnsZeroR { await using TestDatabase database = await TestDatabase.CreateAsync(); - var result = await database.Manager.GetTransactionSummaryByMerchantReport( + var result = await database.TransactionService.GetTransactionSummaryByMerchantReport( new TransactionQueries.TransactionSummaryByMerchantQuery( EstateId, new TransactionSummaryByMerchantRequest { @@ -355,7 +356,7 @@ public async Task GetTransactionMixSummary_WhenDateRangeIsInvalid_ReturnsFailure { await using TestDatabase database = await TestDatabase.CreateAsync(); - var result = await database.Manager.GetTransactionMixSummary( + var result = await database.TransactionService.GetTransactionMixSummary( new TransactionQueries.TransactionMixSummaryQuery( EstateId, new TransactionMixSummaryRequest { @@ -374,7 +375,7 @@ public async Task GetTransactionMixSummary_WhenBreakdownIsUnsupported_ReturnsFai { await using TestDatabase database = await TestDatabase.CreateAsync(); - var result = await database.Manager.GetTransactionMixSummary( + var result = await database.TransactionService.GetTransactionMixSummary( new TransactionQueries.TransactionMixSummaryQuery( EstateId, new TransactionMixSummaryRequest { @@ -393,7 +394,7 @@ public async Task GetRecentActivityReceiptReport_NormalizesInvalidPagingAndRetur { await using TestDatabase database = await TestDatabase.CreateAsync(); - var result = await database.Manager.GetRecentActivityReceiptReport( + var result = await database.TransactionService.GetRecentActivityReceiptReport( new TransactionQueries.GetRecentActivityReceiptReportQuery( EstateId, new GetRecentActivityReceiptReportRequest { @@ -419,7 +420,7 @@ public async Task GetRecentActivityReceiptReport_PaginatesOrderedRowsAndPreserve database.AddSale(database.MerchantId, database.OperatorId, 30m, new DateTime(2026, 9, 1, 11, 0, 0)); await database.SaveAsync(); - var result = await database.Manager.GetRecentActivityReceiptReport( + var result = await database.TransactionService.GetRecentActivityReceiptReport( new TransactionQueries.GetRecentActivityReceiptReportQuery( EstateId, new GetRecentActivityReceiptReportRequest { @@ -444,7 +445,7 @@ public async Task GetTransactionDetailReport_IncludesFeesAndZeroFeeForUnsettledR database.AddSettlementFee(transactionWithFee, 0.5m); await database.SaveAsync(); - var result = await database.Manager.GetTransactionDetailReport( + var result = await database.TransactionService.GetTransactionDetailReport( new TransactionQueries.TransactionDetailReportQuery( EstateId, new TransactionDetailReportRequest { @@ -470,7 +471,7 @@ public async Task GetTransactionDetailReport_IncludesBothDateBoundaries() database.AddSale(database.MerchantId, database.OperatorId, 30m, new DateTime(2026, 8, 31)); await database.SaveAsync(); - var result = await database.Manager.GetTransactionDetailReport( + var result = await database.TransactionService.GetTransactionDetailReport( new TransactionQueries.TransactionDetailReportQuery( EstateId, new TransactionDetailReportRequest { @@ -489,7 +490,7 @@ public async Task GetTodaysSettlement_WhenNoSettlementRows_ReturnsZeroTotals() { await using TestDatabase database = await TestDatabase.CreateAsync(); - var result = await database.Manager.GetTodaysSettlement( + var result = await database.SettlementService.GetTodaysSettlement( new SettlementQueries.TodaysSettlementQuery(EstateId, DateTime.Today.AddDays(-1)), CancellationToken.None); @@ -508,7 +509,7 @@ public async Task GetTodaysSales_WhenReadModelsAreEmpty_ReturnsZeroTotals() await using TestDatabase database = await TestDatabase.CreateAsync(); DateTime comparisonDate = DateTime.Today.AddDays(-1); - var result = await database.Manager.GetTodaysSales( + var result = await database.TransactionService.GetTodaysSales( new TransactionQueries.TodaysSalesQuery(EstateId, 20, 10, comparisonDate), CancellationToken.None); @@ -527,7 +528,7 @@ public async Task GetTodaysFailedSales_WhenReadModelsAreEmpty_ReturnsZeroTotals( await using TestDatabase database = await TestDatabase.CreateAsync(); DateTime comparisonDate = DateTime.Today.AddDays(-1); - var result = await database.Manager.GetTodaysFailedSales( + var result = await database.TransactionService.GetTodaysFailedSales( new TransactionQueries.TodaysFailedSales(EstateId, comparisonDate, "1000"), CancellationToken.None); @@ -549,7 +550,7 @@ public async Task GetMerchantDailyPerformanceSummary_ReturnsMetricsTopProductAnd database.AddSale(database.MerchantId, database.OperatorId, 30m, new DateTime(2026, 9, 2, 11, 0, 0), authorised: true); await database.SaveAsync(); - var result = await database.Manager.GetMerchantDailyPerformanceSummary( + var result = await database.TransactionService.GetMerchantDailyPerformanceSummary( new TransactionQueries.MerchantDailyPerformanceSummaryQuery( EstateId, new MerchantDailyPerformanceSummaryRequest { @@ -578,7 +579,7 @@ public async Task GetMerchantDailyPerformanceSummary_WhenNoTransactions_ReturnsB { await using TestDatabase database = await TestDatabase.CreateAsync(); - var result = await database.Manager.GetMerchantDailyPerformanceSummary( + var result = await database.TransactionService.GetMerchantDailyPerformanceSummary( new TransactionQueries.MerchantDailyPerformanceSummaryQuery( EstateId, new MerchantDailyPerformanceSummaryRequest { @@ -602,7 +603,7 @@ public async Task GetMerchants_AppliesAllQueryFilters() database.AddBalance(database.SecondMerchantId, 20m); await database.SaveAsync(); - var result = await database.Manager.GetMerchants( + var result = await database.MerchantService.GetMerchants( new MerchantQueries.GetMerchantsQuery( EstateId, new MerchantQueries.MerchantQueryOptions("Merchant 1", "M1", 2, "Region", "P1")), @@ -621,7 +622,7 @@ public async Task GetMerchant_MapsAddressContactOpeningHoursAndBalance() database.AddMerchantRelatedData(); await database.SaveAsync(); - var result = await database.Manager.GetMerchant( + var result = await database.MerchantService.GetMerchant( new MerchantQueries.GetMerchantQuery(EstateId, database.MerchantId), CancellationToken.None); @@ -643,7 +644,7 @@ public async Task GetProductPerformanceReport_WithSalesMapsDetailsAndSummary() database.AddSale(database.MerchantId, database.OperatorId, 30m, new DateTime(2026, 9, 1)); await database.SaveAsync(); - var result = await database.Manager.GetProductPerformanceReport( + var result = await database.TransactionService.GetProductPerformanceReport( new TransactionQueries.ProductPerformanceQuery(EstateId, new DateTime(2026, 9, 1), new DateTime(2026, 9, 1)), CancellationToken.None); @@ -665,7 +666,7 @@ public async Task GetMerchantOperators_MapsConfiguredRows() database.AddMerchantRelatedData(); await database.SaveAsync(); - var operators = await database.Manager.GetMerchantOperators( + var operators = await database.MerchantService.GetMerchantOperators( new MerchantQueries.GetMerchantOperatorsQuery(EstateId, database.MerchantId), CancellationToken.None); operators.IsSuccess.ShouldBeTrue(); @@ -679,7 +680,7 @@ public async Task GetMerchantContracts_MapsConfiguredRows() database.AddMerchantRelatedData(); await database.SaveAsync(); - var contracts = await database.Manager.GetMerchantContracts( + var contracts = await database.MerchantService.GetMerchantContracts( new MerchantQueries.GetMerchantContractsQuery(EstateId, database.MerchantId), CancellationToken.None); contracts.IsSuccess.ShouldBeTrue(); @@ -694,7 +695,7 @@ public async Task GetMerchantDevices_MapsConfiguredRows() database.AddMerchantRelatedData(); await database.SaveAsync(); - var devices = await database.Manager.GetMerchantDevices( + var devices = await database.MerchantService.GetMerchantDevices( new MerchantQueries.GetMerchantDevicesQuery(EstateId, database.MerchantId), CancellationToken.None); devices.IsSuccess.ShouldBeTrue(); @@ -708,7 +709,7 @@ public async Task GetMerchantOpeningHours_MapsAllDays() database.AddMerchantRelatedData(); await database.SaveAsync(); - var openingHours = await database.Manager.GetMerchantOpeningHours( + var openingHours = await database.MerchantService.GetMerchantOpeningHours( new MerchantQueries.GetMerchantOpeningHoursQuery(EstateId, database.MerchantId), CancellationToken.None); openingHours.IsSuccess.ShouldBeTrue(); @@ -723,7 +724,7 @@ public async Task GetMerchantSchedule_ParsesClosedDays() database.AddMerchantRelatedData(); await database.SaveAsync(); - var schedule = await database.Manager.GetMerchantSchedule( + var schedule = await database.MerchantService.GetMerchantSchedule( new MerchantQueries.GetMerchantScheduleQuery(EstateId, database.MerchantId, 2026), CancellationToken.None); schedule.IsSuccess.ShouldBeTrue(); @@ -736,7 +737,7 @@ public async Task GetTodaysSalesByHour_WhenReadModelsAreEmpty_ReturnsEmptyResult { await using TestDatabase database = await TestDatabase.CreateAsync(); - var result = await database.Manager.GetTodaysSalesByHour( + var result = await database.TransactionService.GetTodaysSalesByHour( new TransactionQueries.TodaysSalesByHour(EstateId, DateTime.Today.AddDays(-1)), CancellationToken.None); @@ -751,7 +752,7 @@ public async Task GetContracts_MapsProductsAndOnlyEnabledFees() database.AddContractFees(); await database.SaveAsync(); - var result = await database.Manager.GetContracts( + var result = await database.MerchantService.GetContracts( new ContractQueries.GetContractsQuery(EstateId), CancellationToken.None); @@ -771,7 +772,7 @@ public async Task GetTransactionMixSummary_GroupsByStatusAndOrdersByValue() database.AddSale(database.MerchantId, database.OperatorId, 25m, new DateTime(2026, 9, 1), authorised: false); await database.SaveAsync(); - var result = await database.Manager.GetTransactionMixSummary( + var result = await database.TransactionService.GetTransactionMixSummary( new TransactionQueries.TransactionMixSummaryQuery( EstateId, new TransactionMixSummaryRequest { @@ -801,7 +802,7 @@ public async Task GetRecentActivityReceiptReport_AppliesMerchantAndSearchFilters database.AddSale(database.SecondMerchantId, database.OperatorId, 20m, new DateTime(2026, 9, 1, 10, 0, 0)); await database.SaveAsync(); - var result = await database.Manager.GetRecentActivityReceiptReport( + var result = await database.TransactionService.GetRecentActivityReceiptReport( new TransactionQueries.GetRecentActivityReceiptReportQuery( EstateId, new GetRecentActivityReceiptReportRequest { @@ -821,7 +822,7 @@ public async Task GetProductPerformanceReport_WhenNoSales_ReturnsZeroSummary() { await using TestDatabase database = await TestDatabase.CreateAsync(); - var result = await database.Manager.GetProductPerformanceReport( + var result = await database.TransactionService.GetProductPerformanceReport( new TransactionQueries.ProductPerformanceQuery(EstateId, new DateTime(2026, 9, 1), new DateTime(2026, 9, 1)), CancellationToken.None); @@ -836,7 +837,7 @@ public async Task GetMerchants_WhenBalanceIsMissing_ReturnsFailureResult() { await using TestDatabase database = await TestDatabase.CreateAsync(); - var result = await database.Manager.GetMerchants( + var result = await database.MerchantService.GetMerchants( new MerchantQueries.GetMerchantsQuery(EstateId, new MerchantQueries.MerchantQueryOptions("", "", null, "", "")), CancellationToken.None); @@ -851,7 +852,7 @@ public async Task GetMerchants_WhenBalanceIsDuplicated_ReturnsFailureResult() database.AddBalance(database.MerchantId, 20m); await database.SaveAsync(); - var result = await database.Manager.GetMerchants( + var result = await database.MerchantService.GetMerchants( new MerchantQueries.GetMerchantsQuery(EstateId, new MerchantQueries.MerchantQueryOptions("", "", null, "", "")), CancellationToken.None); @@ -863,12 +864,16 @@ private sealed class TestDatabase : IAsyncDisposable private readonly ServiceProvider services; private readonly EstateManagementContext seedContext; - private TestDatabase(ServiceProvider services, EstateManagementContext seedContext, ReportingManager manager, + private TestDatabase(ServiceProvider services, EstateManagementContext seedContext, IEstateReportingService estateService, IMerchantReportingService merchantService, IFileImportReportingService fileImportService, ITransactionReportingService transactionService, ISettlementReportingService settlementService, Guid merchantId, Guid secondMerchantId, Guid operatorId, Guid secondOperatorId, Guid contractId, Guid productId) { this.services = services; this.seedContext = seedContext; - Manager = manager; + EstateService = estateService; + MerchantService = merchantService; + FileImportService = fileImportService; + TransactionService = transactionService; + SettlementService = settlementService; MerchantId = merchantId; SecondMerchantId = secondMerchantId; OperatorId = operatorId; @@ -877,7 +882,11 @@ private TestDatabase(ServiceProvider services, EstateManagementContext seedConte ProductId = productId; } - public ReportingManager Manager { get; } + public IEstateReportingService EstateService { get; } + public IMerchantReportingService MerchantService { get; } + public IFileImportReportingService FileImportService { get; } + public ITransactionReportingService TransactionService { get; } + public ISettlementReportingService SettlementService { get; } public Guid MerchantId { get; } public Guid SecondMerchantId { get; } public Guid OperatorId { get; } @@ -932,7 +941,7 @@ public static async Task CreateAsync() resolver.Resolve(Arg.Any(), Arg.Any()) .Returns(new ResolvedDbContext(services.CreateScope())); - return new TestDatabase(services, seedContext, new ReportingManager(resolver.Instance()), merchantId, secondMerchantId, operatorId, + return new TestDatabase(services, seedContext, new EstateReportingService(resolver.Instance()), new MerchantReportingService(resolver.Instance()), new FileImportReportingService(resolver.Instance()), new TransactionReportingService(resolver.Instance()), new SettlementReportingService(resolver.Instance()), merchantId, secondMerchantId, operatorId, secondOperatorId, contractId, productId); } @@ -1149,3 +1158,4 @@ public async ValueTask DisposeAsync() } } } + diff --git a/EstateReportingAPI.BusinessLogic.UnitTests/ReportingQueryExecutorTests.cs b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingQueryExecutorTests.cs new file mode 100644 index 0000000..e3345d8 --- /dev/null +++ b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingQueryExecutorTests.cs @@ -0,0 +1,83 @@ +using EstateReportingAPI.BusinessLogic; +using Microsoft.EntityFrameworkCore; +using Shouldly; +using SimpleResults; +using TransactionProcessor.Database.Contexts; + +namespace EstateReportingAPI.BusinessLogic.UnitTests; + +public sealed class ReportingQueryExecutorTests +{ + [Fact] + public async Task QueryOperations_ReturnSuccessfulResults() + { + await using EstateManagementContext context = CreateContext(); + await context.Calendar.AddRangeAsync( + CreateCalendar(new DateTime(2025, 1, 1), 2025), + CreateCalendar(new DateTime(2026, 1, 1), 2026)); + await context.SaveChangesAsync(); + + Result> list = await ReportingQueryExecutor.ToListAsync( + context.Calendar.Select(calendar => calendar.Year), CancellationToken.None); + Result count = await ReportingQueryExecutor.CountAsync( + context.Calendar, CancellationToken.None); + Result sum = await ReportingQueryExecutor.SumAsync( + context.Calendar.Select(calendar => calendar.Year), CancellationToken.None); + Result single = await ReportingQueryExecutor.SingleOrDefaultAsync( + context.Calendar.Where(calendar => calendar.Year == 2025), CancellationToken.None, "Calendar not found"); + + list.Data.ShouldBe([2025, 2026]); + count.Data.ShouldBe(2); + sum.Data.ShouldBe(4051); + single.Data.Year.ShouldBe(2025); + } + + [Fact] + public async Task SingleOrDefaultAsync_ReturnsNotFoundWhenNoItemExists() + { + await using EstateManagementContext context = CreateContext(); + + Result result = await ReportingQueryExecutor.SingleOrDefaultAsync( + context.Calendar, CancellationToken.None, "Calendar not found"); + + result.IsSuccess.ShouldBeFalse(); + result.Message.ShouldBe("Calendar not found"); + } + + [Fact] + public async Task ToListAsync_ReturnsFailureWhenQueryThrows() + { + await using EstateManagementContext context = CreateContext(); + await context.DisposeAsync(); + + Result> result = await ReportingQueryExecutor.ToListAsync( + context.Calendar, CancellationToken.None, "Error retrieving calendars"); + + result.IsFailed.ShouldBeTrue(); + result.Message.ShouldContain("Error retrieving calendars"); + } + + private static EstateManagementContext CreateContext() + { + DbContextOptions options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new EstateManagementContext(options); + } + + private static TransactionProcessor.Database.Entities.Calendar CreateCalendar(DateTime date, int year) + { + return new TransactionProcessor.Database.Entities.Calendar + { + Date = date, + Year = year, + DayOfWeek = "Monday", + DayOfWeekShort = "Mon", + MonthNameLong = "January", + MonthNameShort = "Jan", + WeekNumberString = "01", + YearWeekNumber = $"{year}01" + }; + } +} diff --git a/EstateReportingAPI.BusinessLogic.UnitTests/RequestHandlerTests.cs b/EstateReportingAPI.BusinessLogic.UnitTests/RequestHandlerTests.cs index 1202750..6ba11c6 100644 --- a/EstateReportingAPI.BusinessLogic.UnitTests/RequestHandlerTests.cs +++ b/EstateReportingAPI.BusinessLogic.UnitTests/RequestHandlerTests.cs @@ -1,12 +1,16 @@ -using EstateReportingAPI.BusinessLogic; using EstateReportingAPI.BusinessLogic.Queries; using EstateReportingAPI.BusinessLogic.RequestHandlers; +using EstateReportingAPI.BusinessLogic.Services; using EstateReportingAPI.Models; using Imposter.Abstractions; using Shouldly; using SimpleResults; -[assembly: GenerateImposter(typeof(IReportingManager))] +[assembly: GenerateImposter(typeof(IMerchantReportingService))] +[assembly: GenerateImposter(typeof(ITransactionReportingService))] +[assembly: GenerateImposter(typeof(IEstateReportingService))] +[assembly: GenerateImposter(typeof(ISettlementReportingService))] +[assembly: GenerateImposter(typeof(IFileImportReportingService))] namespace EstateReportingAPI.BusinessLogic.UnitTests; @@ -15,7 +19,7 @@ public sealed class RequestHandlerTests [Fact] public async Task SettlementRequestHandler_ForwardsRequestAndCancellationToken() { - var manager = new IReportingManagerImposter(); + var manager = new ISettlementReportingServiceImposter(); var expected = Result.Success(new TodaysSettlement()); var request = new SettlementQueries.TodaysSettlementQuery(Guid.NewGuid(), DateTime.Today); var cancellationToken = new CancellationTokenSource().Token; @@ -30,7 +34,7 @@ public async Task SettlementRequestHandler_ForwardsRequestAndCancellationToken() [Fact] public async Task CalendarRequestHandler_ForwardsRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IEstateReportingServiceImposter(); var request = new CalendarQueries.GetYearsQuery(Guid.NewGuid()); manager.GetCalendarYears(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new List()))); @@ -43,7 +47,7 @@ public async Task CalendarRequestHandler_ForwardsRequest() [Fact] public async Task CalendarRequestHandler_ForwardsAllDatesRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IEstateReportingServiceImposter(); var request = new CalendarQueries.GetAllDatesQuery(Guid.NewGuid()); manager.GetCalendarDates(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new List()))); @@ -56,7 +60,7 @@ public async Task CalendarRequestHandler_ForwardsAllDatesRequest() [Fact] public async Task CalendarRequestHandler_ForwardsComparisonDatesRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IEstateReportingServiceImposter(); var request = new CalendarQueries.GetComparisonDatesQuery(Guid.NewGuid()); manager.GetCalendarComparisonDates(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new List()))); @@ -69,7 +73,7 @@ public async Task CalendarRequestHandler_ForwardsComparisonDatesRequest() [Fact] public async Task CalendarRequestHandler_WhenComparisonDatesFails_ReturnsFailure() { - var manager = new IReportingManagerImposter(); + var manager = new IEstateReportingServiceImposter(); var request = new CalendarQueries.GetComparisonDatesQuery(Guid.NewGuid()); manager.GetCalendarComparisonDates(Arg.Any(), Arg.Any()) .Returns(Task.FromResult>>(Result.Failure("Calendar lookup failed."))); @@ -82,7 +86,7 @@ public async Task CalendarRequestHandler_WhenComparisonDatesFails_ReturnsFailure [Fact] public async Task ContractRequestHandler_ForwardsRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IMerchantReportingServiceImposter(); var request = new ContractQueries.GetContractsQuery(Guid.NewGuid()); manager.GetContracts(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new List()))); @@ -95,7 +99,7 @@ public async Task ContractRequestHandler_ForwardsRequest() [Fact] public async Task ContractRequestHandler_ForwardsRecentContractsRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IMerchantReportingServiceImposter(); var request = new ContractQueries.GetRecentContractsQuery(Guid.NewGuid()); manager.GetRecentContracts(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new List()))); @@ -108,7 +112,7 @@ public async Task ContractRequestHandler_ForwardsRecentContractsRequest() [Fact] public async Task ContractRequestHandler_ForwardsContractRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IMerchantReportingServiceImposter(); var request = new ContractQueries.GetContractQuery(Guid.NewGuid(), Guid.NewGuid()); manager.GetContract(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new Contract()))); @@ -121,7 +125,7 @@ public async Task ContractRequestHandler_ForwardsContractRequest() [Fact] public async Task EstateRequestHandler_ForwardsRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IEstateReportingServiceImposter(); var request = new EstateQueries.GetEstateQuery(Guid.NewGuid()); manager.GetEstate(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new Estate()))); @@ -134,7 +138,7 @@ public async Task EstateRequestHandler_ForwardsRequest() [Fact] public async Task EstateRequestHandler_ForwardsEstateOperatorsRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IEstateReportingServiceImposter(); var request = new EstateQueries.GetEstateOperatorsQuery(Guid.NewGuid()); manager.GetEstateOperators(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new List()))); @@ -147,7 +151,7 @@ public async Task EstateRequestHandler_ForwardsEstateOperatorsRequest() [Fact] public async Task FileImportLogRequestHandler_ForwardsRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IFileImportReportingServiceImposter(); var request = new FileImportLogQueries.GetFileImportLogListQuery(Guid.NewGuid(), null, DateTime.Today, DateTime.Today); manager.GetFileImportLogList(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new List()))); @@ -160,7 +164,7 @@ public async Task FileImportLogRequestHandler_ForwardsRequest() [Fact] public async Task FileImportLogRequestHandler_ForwardsSingleLogRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IFileImportReportingServiceImposter(); var request = new FileImportLogQueries.GetFileImportLogQuery(Guid.NewGuid(), null, Guid.NewGuid()); manager.GetFileImportLog(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new FileImportLog()))); @@ -173,7 +177,7 @@ public async Task FileImportLogRequestHandler_ForwardsSingleLogRequest() [Fact] public async Task FileProfileConfigurationRequestHandler_ForwardsRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IFileImportReportingServiceImposter(); var request = new FileProfileConfigurationQueries.GetFileProfileConfigurationListQuery(Guid.NewGuid()); manager.GetFileProfileConfigurationList(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new List()))); @@ -186,7 +190,7 @@ public async Task FileProfileConfigurationRequestHandler_ForwardsRequest() [Fact] public async Task MerchantRequestHandler_ForwardsRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IMerchantReportingServiceImposter(); var request = new MerchantQueries.GetMerchantQuery(Guid.NewGuid(), Guid.NewGuid()); manager.GetMerchant(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new Merchant()))); @@ -199,7 +203,7 @@ public async Task MerchantRequestHandler_ForwardsRequest() [Fact] public async Task MerchantRequestHandler_ForwardsRecentMerchantsRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IMerchantReportingServiceImposter(); var request = new MerchantQueries.GetRecentMerchantsQuery(Guid.NewGuid()); manager.GetRecentMerchants(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new List()))); @@ -212,7 +216,7 @@ public async Task MerchantRequestHandler_ForwardsRecentMerchantsRequest() [Fact] public async Task MerchantRequestHandler_ForwardsTransactionKpisRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IMerchantReportingServiceImposter(); var request = new MerchantQueries.GetTransactionKpisQuery(Guid.NewGuid()); manager.GetMerchantsTransactionKpis(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new MerchantKpi()))); @@ -225,7 +229,7 @@ public async Task MerchantRequestHandler_ForwardsTransactionKpisRequest() [Fact] public async Task MerchantRequestHandler_ForwardsMerchantsRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IMerchantReportingServiceImposter(); var request = new MerchantQueries.GetMerchantsQuery(Guid.NewGuid(), new MerchantQueries.MerchantQueryOptions("", "", null, "", "")); manager.GetMerchants(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new List()))); @@ -238,7 +242,7 @@ public async Task MerchantRequestHandler_ForwardsMerchantsRequest() [Fact] public async Task MerchantRequestHandler_ForwardsMerchantOperatorsRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IMerchantReportingServiceImposter(); var request = new MerchantQueries.GetMerchantOperatorsQuery(Guid.NewGuid(), Guid.NewGuid()); manager.GetMerchantOperators(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new List()))); @@ -251,7 +255,7 @@ public async Task MerchantRequestHandler_ForwardsMerchantOperatorsRequest() [Fact] public async Task MerchantRequestHandler_ForwardsMerchantContractsRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IMerchantReportingServiceImposter(); var request = new MerchantQueries.GetMerchantContractsQuery(Guid.NewGuid(), Guid.NewGuid()); manager.GetMerchantContracts(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new List()))); @@ -264,7 +268,7 @@ public async Task MerchantRequestHandler_ForwardsMerchantContractsRequest() [Fact] public async Task MerchantRequestHandler_ForwardsMerchantDevicesRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IMerchantReportingServiceImposter(); var request = new MerchantQueries.GetMerchantDevicesQuery(Guid.NewGuid(), Guid.NewGuid()); manager.GetMerchantDevices(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new List()))); @@ -277,7 +281,7 @@ public async Task MerchantRequestHandler_ForwardsMerchantDevicesRequest() [Fact] public async Task MerchantRequestHandler_ForwardsMerchantOpeningHoursRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IMerchantReportingServiceImposter(); var request = new MerchantQueries.GetMerchantOpeningHoursQuery(Guid.NewGuid(), Guid.NewGuid()); manager.GetMerchantOpeningHours(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new List()))); @@ -290,7 +294,7 @@ public async Task MerchantRequestHandler_ForwardsMerchantOpeningHoursRequest() [Fact] public async Task MerchantRequestHandler_ForwardsMerchantScheduleRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IMerchantReportingServiceImposter(); var request = new MerchantQueries.GetMerchantScheduleQuery(Guid.NewGuid(), Guid.NewGuid(), 2026); manager.GetMerchantSchedule(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new MerchantScheduleResponse()))); @@ -303,7 +307,7 @@ public async Task MerchantRequestHandler_ForwardsMerchantScheduleRequest() [Fact] public async Task OperatorRequestHandler_ForwardsRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IMerchantReportingServiceImposter(); var request = new OperatorQueries.GetOperatorsQuery(Guid.NewGuid()); manager.GetOperators(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new List()))); @@ -316,7 +320,7 @@ public async Task OperatorRequestHandler_ForwardsRequest() [Fact] public async Task OperatorRequestHandler_ForwardsOperatorRequest() { - var manager = new IReportingManagerImposter(); + var manager = new IMerchantReportingServiceImposter(); var request = new OperatorQueries.GetOperatorQuery(Guid.NewGuid(), Guid.NewGuid()); manager.GetOperator(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new Operator()))); @@ -329,7 +333,7 @@ public async Task OperatorRequestHandler_ForwardsOperatorRequest() [Fact] public async Task TransactionRequestHandler_ForwardsRequest() { - var manager = new IReportingManagerImposter(); + var manager = new ITransactionReportingServiceImposter(); var request = new TransactionQueries.ProductPerformanceQuery(Guid.NewGuid(), DateTime.Today, DateTime.Today); manager.GetProductPerformanceReport(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new ProductPerformanceResponse()))); @@ -342,7 +346,7 @@ public async Task TransactionRequestHandler_ForwardsRequest() [Fact] public async Task TransactionRequestHandler_ForwardsFailedSalesRequest() { - var manager = new IReportingManagerImposter(); + var manager = new ITransactionReportingServiceImposter(); var request = new TransactionQueries.TodaysFailedSales(Guid.NewGuid(), DateTime.Today, "1000"); manager.GetTodaysFailedSales(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new TodaysSales()))); @@ -355,7 +359,7 @@ public async Task TransactionRequestHandler_ForwardsFailedSalesRequest() [Fact] public async Task TransactionRequestHandler_ForwardsTodaysSalesRequest() { - var manager = new IReportingManagerImposter(); + var manager = new ITransactionReportingServiceImposter(); var request = new TransactionQueries.TodaysSalesQuery(Guid.NewGuid(), 1, 2, DateTime.Today); manager.GetTodaysSales(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new TodaysSales()))); @@ -368,7 +372,7 @@ public async Task TransactionRequestHandler_ForwardsTodaysSalesRequest() [Fact] public async Task TransactionRequestHandler_ForwardsTransactionDetailRequest() { - var manager = new IReportingManagerImposter(); + var manager = new ITransactionReportingServiceImposter(); var request = new TransactionQueries.TransactionDetailReportQuery(Guid.NewGuid(), new TransactionDetailReportRequest()); manager.GetTransactionDetailReport(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new TransactionDetailReportResponse()))); @@ -381,7 +385,7 @@ public async Task TransactionRequestHandler_ForwardsTransactionDetailRequest() [Fact] public async Task TransactionRequestHandler_ForwardsSummaryByMerchantRequest() { - var manager = new IReportingManagerImposter(); + var manager = new ITransactionReportingServiceImposter(); var request = new TransactionQueries.TransactionSummaryByMerchantQuery(Guid.NewGuid(), new TransactionSummaryByMerchantRequest()); manager.GetTransactionSummaryByMerchantReport(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new TransactionSummaryByMerchantResponse()))); @@ -394,7 +398,7 @@ public async Task TransactionRequestHandler_ForwardsSummaryByMerchantRequest() [Fact] public async Task TransactionRequestHandler_ForwardsSummaryByOperatorRequest() { - var manager = new IReportingManagerImposter(); + var manager = new ITransactionReportingServiceImposter(); var request = new TransactionQueries.TransactionSummaryByOperatorQuery(Guid.NewGuid(), new TransactionSummaryByOperatorRequest()); manager.GetTransactionSummaryByOperatorReport(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new TransactionSummaryByOperatorResponse()))); @@ -407,7 +411,7 @@ public async Task TransactionRequestHandler_ForwardsSummaryByOperatorRequest() [Fact] public async Task TransactionRequestHandler_ForwardsTransactionMixRequest() { - var manager = new IReportingManagerImposter(); + var manager = new ITransactionReportingServiceImposter(); var request = new TransactionQueries.TransactionMixSummaryQuery(Guid.NewGuid(), new TransactionMixSummaryRequest()); manager.GetTransactionMixSummary(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new TransactionMixSummaryResponse()))); @@ -420,7 +424,7 @@ public async Task TransactionRequestHandler_ForwardsTransactionMixRequest() [Fact] public async Task TransactionRequestHandler_ForwardsRecentActivityRequest() { - var manager = new IReportingManagerImposter(); + var manager = new ITransactionReportingServiceImposter(); var request = new TransactionQueries.GetRecentActivityReceiptReportQuery(Guid.NewGuid(), new GetRecentActivityReceiptReportRequest()); manager.GetRecentActivityReceiptReport(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new GetRecentActivityReceiptReportResponse()))); @@ -433,7 +437,7 @@ public async Task TransactionRequestHandler_ForwardsRecentActivityRequest() [Fact] public async Task TransactionRequestHandler_ForwardsSalesByHourRequest() { - var manager = new IReportingManagerImposter(); + var manager = new ITransactionReportingServiceImposter(); var request = new TransactionQueries.TodaysSalesByHour(Guid.NewGuid(), DateTime.Today); manager.GetTodaysSalesByHour(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new List()))); @@ -446,7 +450,7 @@ public async Task TransactionRequestHandler_ForwardsSalesByHourRequest() [Fact] public async Task TransactionRequestHandler_ForwardsMerchantDailyPerformanceRequest() { - var manager = new IReportingManagerImposter(); + var manager = new ITransactionReportingServiceImposter(); var request = new TransactionQueries.MerchantDailyPerformanceSummaryQuery(Guid.NewGuid(), new MerchantDailyPerformanceSummaryRequest()); manager.GetMerchantDailyPerformanceSummary(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success(new MerchantDailyPerformanceSummaryResponse()))); diff --git a/EstateReportingAPI.BusinessLogic.UnitTests/UnitTest1.cs b/EstateReportingAPI.BusinessLogic.UnitTests/UnitTest1.cs index d20a4f8..b644e82 100644 --- a/EstateReportingAPI.BusinessLogic.UnitTests/UnitTest1.cs +++ b/EstateReportingAPI.BusinessLogic.UnitTests/UnitTest1.cs @@ -10,9 +10,10 @@ namespace EstateReportingAPI.BusinessLogic.UnitTests { + using EstateReportingAPI.BusinessLogic.Services; using Shouldly; - public class ReportingManagerTests + public class ReportingServiceTests { //Task> GetCalendarComparisonDates(Guid estateId, CancellationToken cancellationToken); //Task> GetCalendarDates(Guid estateId, CancellationToken cancellationToken); @@ -30,7 +31,7 @@ public class TestData{ } [Fact] - public async Task ReportingManager_GetCalendarYears_YearsAreReturned(){ + public async Task ReportingService_GetCalendarYears_YearsAreReturned(){ //Required properties '{'DayOfWeek', 'DayOfWeekShort', 'MonthNameLong', 'MonthNameShort', 'WeekNumberString', 'YearWeekNumber'}' var options = new DbContextOptionsBuilder() @@ -79,9 +80,9 @@ await context.Calendar.AddRangeAsync(new Calendar{ var resolver = new IDbContextResolverImposter(); resolver.Resolve(Arg.Any(), Arg.Any()).Returns(new ResolvedDbContext(services.CreateScope())); - var manager = new ReportingManager(resolver.Instance()); + var reportingService = new EstateReportingService(resolver.Instance()); - var years = await manager.GetCalendarYears(new CalendarQueries.GetYearsQuery(TestData.EstateId), CancellationToken.None); + var years = await reportingService.GetCalendarYears(new CalendarQueries.GetYearsQuery(TestData.EstateId), CancellationToken.None); years.Data.Count.ShouldBe(3); years.Data.ShouldContain(2021); diff --git a/EstateReportingAPI.BusinessLogic/ReportingManager.cs b/EstateReportingAPI.BusinessLogic/ReportingManager.cs deleted file mode 100644 index 3a41d53..0000000 --- a/EstateReportingAPI.BusinessLogic/ReportingManager.cs +++ /dev/null @@ -1,2339 +0,0 @@ -using EstateReportingAPI.BusinessLogic.Queries; -using SimpleResults; -using System.Linq.Expressions; -using Microsoft.EntityFrameworkCore.DynamicLinq; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion.Internal; -using TransactionProcessor.Database.Contexts; -using TransactionProcessor.Database.Entities; -using TransactionProcessor.Database.Entities.Summary; - -namespace EstateReportingAPI.BusinessLogic; - -using Microsoft.EntityFrameworkCore; -using Models; -using Shared.EntityFramework; -using Shared.Results; -using System; -using System.Linq; -using System.Threading; -using static Microsoft.EntityFrameworkCore.DbLoggerCategory; -using Calendar = Models.Calendar; -using Merchant = Models.Merchant; -using MerchantBalanceProjectionState = TransactionProcessor.ProjectionEngine.Database.Database.Entities.MerchantBalanceProjectionState; - -public interface IReportingManager -{ - Task>> GetCalendarComparisonDates(CalendarQueries.GetComparisonDatesQuery request, CancellationToken cancellationToken); - Task>> GetCalendarDates(CalendarQueries.GetAllDatesQuery request, CancellationToken cancellationToken); - Task>> GetCalendarYears(CalendarQueries.GetYearsQuery request, CancellationToken cancellationToken); - Task>> GetRecentMerchants(MerchantQueries.GetRecentMerchantsQuery request, CancellationToken cancellationToken); - Task>> GetRecentContracts(ContractQueries.GetRecentContractsQuery request, CancellationToken cancellationToken); - Task>> GetContracts(ContractQueries.GetContractsQuery request, CancellationToken cancellationToken); - Task> GetContract(ContractQueries.GetContractQuery request, CancellationToken cancellationToken); - Task> GetTodaysFailedSales(TransactionQueries.TodaysFailedSales request, CancellationToken cancellationToken); - Task> GetTodaysSales(TransactionQueries.TodaysSalesQuery request, CancellationToken cancellationToken); - Task> GetEstate(EstateQueries.GetEstateQuery request, CancellationToken cancellationToken); - Task>> GetEstateOperators(EstateQueries.GetEstateOperatorsQuery request, CancellationToken cancellationToken); - Task> GetMerchantsTransactionKpis(MerchantQueries.GetTransactionKpisQuery request, CancellationToken cancellationToken); - Task>> GetOperators(OperatorQueries.GetOperatorsQuery request, CancellationToken cancellationToken); - Task>> GetMerchants(MerchantQueries.GetMerchantsQuery request, CancellationToken cancellationToken); - Task> GetMerchant(MerchantQueries.GetMerchantQuery request, CancellationToken cancellationToken); - Task>> GetMerchantOperators(MerchantQueries.GetMerchantOperatorsQuery request, CancellationToken cancellationToken); - Task>> GetMerchantContracts(MerchantQueries.GetMerchantContractsQuery request, CancellationToken cancellationToken); - Task>> GetMerchantDevices(MerchantQueries.GetMerchantDevicesQuery request, CancellationToken cancellationToken); - Task>> GetMerchantOpeningHours(MerchantQueries.GetMerchantOpeningHoursQuery request, CancellationToken cancellationToken); - Task> GetOperator(OperatorQueries.GetOperatorQuery request, CancellationToken cancellationToken); - Task> GetTransactionDetailReport(TransactionQueries.TransactionDetailReportQuery request, - CancellationToken cancellationToken); - Task> GetTransactionSummaryByMerchantReport(TransactionQueries.TransactionSummaryByMerchantQuery request, - CancellationToken cancellationToken); - Task> GetTransactionSummaryByOperatorReport(TransactionQueries.TransactionSummaryByOperatorQuery request, - CancellationToken cancellationToken); - Task> GetProductPerformanceReport(TransactionQueries.ProductPerformanceQuery request, - CancellationToken cancellationToken); - Task> GetTransactionMixSummary(TransactionQueries.TransactionMixSummaryQuery request, - CancellationToken cancellationToken); - Task> GetRecentActivityReceiptReport(TransactionQueries.GetRecentActivityReceiptReportQuery request, - CancellationToken cancellationToken); - - Task>> GetTodaysSalesByHour(TransactionQueries.TodaysSalesByHour request, - CancellationToken cancellationToken); - Task> GetTodaysSettlement(SettlementQueries.TodaysSettlementQuery request, - CancellationToken cancellationToken); - - Task> GetMerchantSchedule(MerchantQueries.GetMerchantScheduleQuery request, - CancellationToken cancellationToken); - - Task>> GetFileImportLogList(FileImportLogQueries.GetFileImportLogListQuery request, - CancellationToken cancellationToken); - Task> GetFileImportLog(FileImportLogQueries.GetFileImportLogQuery request, - CancellationToken cancellationToken); - Task>> GetFileProfileConfigurationList(FileProfileConfigurationQueries.GetFileProfileConfigurationListQuery request, - CancellationToken cancellationToken); - - Task> GetMerchantDailyPerformanceSummary(TransactionQueries.MerchantDailyPerformanceSummaryQuery request, - CancellationToken cancellationToken); -} - -public class ReportingManager : IReportingManager { - private readonly IDbContextResolver Resolver; - - private Guid Id; - private static readonly String EstateManagementDatabaseName = "TransactionProcessorReadModel"; - - public ReportingManager(IDbContextResolver resolver) { - this.Resolver = resolver; - } - - private static async Task> ExecuteQuerySafeSum(IQueryable query, - CancellationToken cancellationToken, - string contextMessage = null) { - try { - T item = await query.SumAsync(cancellationToken); - return Result.Success(item); - } - catch (Exception ex) { - string msg = contextMessage == null ? $"Error executing query: {ex.Message}" : $"{contextMessage}: {ex.Message}"; - return Result.Failure(msg); - } - } - - private static async Task>> ExecuteQuerySafeToList(IQueryable query, - CancellationToken cancellationToken, - string contextMessage = null) { - try { - List items = await query.ToListAsync(cancellationToken); - return Result.Success(items); - } - catch (Exception ex) { - string msg = contextMessage == null ? $"Error executing query: {ex.Message}" : $"{contextMessage}: {ex.Message}"; - return Result.Failure(msg); - } - } - - private static async Task> ExecuteQuerySafeCount(IQueryable query, - CancellationToken cancellationToken, - string contextMessage = null) { - try { - int count = await query.CountAsync(cancellationToken); - return Result.Success(count); - } - catch (Exception ex) { - string msg = contextMessage == null ? $"Error executing query: {ex.Message}" : $"{contextMessage}: {ex.Message}"; - return Result.Failure(msg); - } - } - - private static async Task> ExecuteQuerySafeSingleOrDefault(IQueryable query, - CancellationToken cancellationToken, - string contextMessage = null) { - try { - T item = await query.SingleOrDefaultAsync(cancellationToken); - - if (EqualityComparer.Default.Equals(item, default(T))) - return Result.NotFound(contextMessage); - - return Result.Success(item); - } - catch (Exception ex) { - string msg = contextMessage == null ? $"Error executing query: {ex.Message}" : $"{contextMessage}: {ex.Message}"; - return Result.Failure(msg); - } - } - - public async Task>> GetCalendarComparisonDates(CalendarQueries.GetComparisonDatesQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - DateTime today = DateTime.Today; - DateTime startDate = today.AddYears(-1); - DateTime endDate = today.AddDays(-1); // yesterday - - var result = await ExecuteQuerySafeToList(context.Calendar.Where(c => c.Date >= startDate && c.Date < endDate).OrderByDescending(c => c.Date), cancellationToken, "Error retrieving calendar comparison dates"); - - if (result.IsFailed) - return ResultHelpers.CreateFailure(result); - - var entities = result.Data; - - if (entities.Any() == false) - return Result.NotFound("No calendar dates found"); - - List response = new(); - foreach (TransactionProcessor.Database.Entities.Calendar calendar in entities) - response.Add(new Calendar { - Date = calendar.Date, - DayOfWeek = calendar.DayOfWeek, - Year = calendar.Year, - DayOfWeekNumber = calendar.DayOfWeekNumber, - DayOfWeekShort = calendar.DayOfWeekShort, - MonthNameLong = calendar.MonthNameLong, - MonthNameShort = calendar.MonthNameShort, - MonthNumber = calendar.MonthNumber, - WeekNumber = calendar.WeekNumber, - WeekNumberString = calendar.WeekNumberString, - YearWeekNumber = calendar.YearWeekNumber - }); - - return Result.Success(response); - } - - public async Task>> GetCalendarDates(CalendarQueries.GetAllDatesQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - - var result = await ExecuteQuerySafeToList(context.Calendar.Where(c => c.Date <= DateTime.Now.Date), cancellationToken, "Error retrieving calendar dates"); - - if (result.IsFailed) - return ResultHelpers.CreateFailure(result); - - var entities = result.Data; - - List response = new(); - foreach (TransactionProcessor.Database.Entities.Calendar calendar in entities) - response.Add(new Calendar { - Date = calendar.Date, - DayOfWeek = calendar.DayOfWeek, - Year = calendar.Year, - DayOfWeekNumber = calendar.DayOfWeekNumber, - DayOfWeekShort = calendar.DayOfWeekShort, - MonthNameLong = calendar.MonthNameLong, - MonthNameShort = calendar.MonthNameShort, - MonthNumber = calendar.MonthNumber, - WeekNumber = calendar.WeekNumber, - WeekNumberString = calendar.WeekNumberString, - YearWeekNumber = calendar.YearWeekNumber - }); - - return Result.Success(response); - } - - public async Task>> GetCalendarYears(CalendarQueries.GetYearsQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var result = await ExecuteQuerySafeToList(context.Calendar.Where(c => c.Date <= DateTime.Now.Date).GroupBy(c => c.Year).Select(y => y.Key), cancellationToken, "Error retrieving calendar years"); - - if (result.IsFailed) - return ResultHelpers.CreateFailure(result); - - return result; - } - - public async Task>> GetContracts(ContractQueries.GetContractsQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var baseContractsQuery = (from c in context.Contracts - join o in context.Operators on c.OperatorId equals o.OperatorId into ops - from o in ops - join e in context.Estates on c.EstateId equals e.EstateId into estates - from e in estates - select new ContractBaseData { - EstateReportingId = e.EstateReportingId, - ContractId = c.ContractId, - ContractReportingId = c.ContractReportingId, - Description = c.Description, - EstateId = c.EstateId, - OperatorId = c.OperatorId, - OperatorReportingId = o.OperatorReportingId, - OperatorName = o.Name - }).OrderByDescending(x => x.Description); - - var baseContractsResult = await ExecuteQuerySafeToList(baseContractsQuery, cancellationToken, "Error retrieving contracts - Step 1"); - if (baseContractsResult.IsFailed) - return ResultHelpers.CreateFailure(baseContractsResult); - - var baseContracts = baseContractsResult.Data; - if (!baseContracts.Any()) - return new List(); - - var contractIds = baseContracts.Select(b => b.ContractId).ToList(); - var productsResult = await LoadProductsAsync(context, contractIds, cancellationToken, "Error retrieving contracts - Step 2"); - if (productsResult.IsFailed) - return ResultHelpers.CreateFailure(productsResult); - - var productIds = productsResult.Data.Select(p => p.ContractProductId).ToList(); - var feesResult = await LoadFeesAsync(context, productIds, cancellationToken, "Error retrieving contracts - Step 3"); - if (feesResult.IsFailed) - return ResultHelpers.CreateFailure(feesResult); - - return Result.Success(BuildContracts(baseContracts, productsResult.Data, feesResult.Data)); - } - - public async Task> GetContract(ContractQueries.GetContractQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - // Step 1: load contract with operator name - var baseContractQuery = (from c in context.Contracts - join o in context.Operators on c.OperatorId equals o.OperatorId into ops - from o in ops - join e in context.Estates on c.EstateId equals e.EstateId into estates - from e in estates - where c.ContractId == request.ContractId - select new { - e.EstateReportingId, - c.ContractId, - c.ContractReportingId, - c.Description, - c.EstateId, - c.OperatorId, - o.OperatorReportingId, - OperatorName = o.Name }).OrderByDescending(x => x.Description); - - var baseContractQueryResult = await ExecuteQuerySafeSingleOrDefault(baseContractQuery, cancellationToken, "Error retrieving contract - Step 1"); - - if (baseContractQueryResult.IsFailed) - return ResultHelpers.CreateFailure(baseContractQueryResult); - - var baseContract = baseContractQueryResult.Data; - - // Steps 2 & 3: load products and fees, then assemble - var productsResult = await this.LoadProductsWithFees(context, baseContract.ContractId, cancellationToken); - - if (productsResult.IsFailed) - return ResultHelpers.CreateFailure(productsResult); - - return Result.Success(new Contract { - EstateReportingId = baseContract.EstateReportingId, - ContractId = baseContract.ContractId, - ContractReportingId = baseContract.ContractReportingId, - Description = baseContract.Description, - EstateId = baseContract.EstateId, - OperatorName = baseContract.OperatorName, - OperatorId = baseContract.OperatorId, - OperatorReportingId = baseContract.OperatorReportingId, - Products = productsResult.Data - }); - } - - private async Task>> LoadProductsWithFees(EstateManagementContext context, - Guid contractId, - CancellationToken cancellationToken) { - var productsQuery = context.ContractProducts.Where(cp => cp.ContractId == contractId).Select(cp => new { - cp.ContractProductId, - cp.ContractProductReportingId, - cp.ContractId, - cp.DisplayText, - cp.ProductName, - cp.ProductType, - cp.Value - }); - - var productsQueryResult = await ExecuteQuerySafeToList(productsQuery, cancellationToken, "Error retrieving contract - Step 2"); - - if (productsQueryResult.IsFailed) - return ResultHelpers.CreateFailure(productsQueryResult); - - var products = productsQueryResult.Data; - var productIds = products.Select(p => p.ContractProductId).ToList(); - - var feesQuery = context.ContractProductTransactionFees.Where(tf => productIds.Contains(tf.ContractProductId)).Select(tf => new { - tf.CalculationType, - tf.ContractProductTransactionFeeId, - tf.ContractProductTransactionFeeReportingId, - tf.FeeType, - tf.Value, - tf.ContractProductId, - tf.Description - }); - - var feesQueryResult = await ExecuteQuerySafeToList(feesQuery, cancellationToken, "Error retrieving contract - Step 3"); - - if (feesQueryResult.IsFailed) - return ResultHelpers.CreateFailure(feesQueryResult); - - var fees = feesQueryResult.Data; - var feesLookup = fees.ToLookup(f => f.ContractProductId); - - return Result.Success(products.Select(p => new Models.ContractProduct { - ContractId = p.ContractId, - ProductId = p.ContractProductId, - DisplayText = p.DisplayText, - ProductName = p.ProductName, - ProductType = p.ProductType, - Value = p.Value, - ContractProductReportingId = p.ContractProductReportingId, - TransactionFees = feesLookup[p.ContractProductId].Select(f => new ContractProductTransactionFee { - Description = f.Description, - Value = f.Value, - CalculationType = f.CalculationType, - FeeType = f.FeeType, - TransactionFeeId = f.ContractProductTransactionFeeId, - ContractProductTransactionFeeReportingId = f.ContractProductTransactionFeeReportingId - }).ToList() - }).ToList()); - } - - public async Task>> GetRecentContracts(ContractQueries.GetRecentContractsQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var contractsQuery = (from c in context.Contracts - join o in context.Operators on c.OperatorId equals o.OperatorId into ops - from o in ops - join e in context.Estates on c.EstateId equals e.EstateId into estates - from e in estates - select new - { - e.EstateReportingId, - c.ContractId, - c.ContractReportingId, - c.Description, - c.EstateId, - c.OperatorId, - o.OperatorReportingId, - OperatorName = o.Name - }).OrderByDescending(x => x.ContractReportingId).Take(3); - - var result = await ExecuteQuerySafeToList(contractsQuery, cancellationToken, "Error retrieving recent contracts"); - - if (result.IsFailed) - return ResultHelpers.CreateFailure(result); - - List response = new List(); - foreach (var contract in result.Data) { - response.Add(new Contract { - EstateId = contract.EstateId, - EstateReportingId = contract.EstateReportingId, - ContractId = contract.ContractId, - ContractReportingId = contract.ContractReportingId, - Description = contract.Description, - OperatorName = contract.OperatorName, - OperatorId = contract.OperatorId, - OperatorReportingId = contract.OperatorReportingId, - }); - } - - return response; - } - - public async Task> GetTodaysFailedSales(TransactionQueries.TodaysFailedSales request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - IQueryable todaysSalesQuery = this.BuildTodaysFailedSalesQuery(context, request.ResponseCode); - IQueryable comparisonSalesQuery = this.BuildComparisonFailedSalesQuery(context, request.ComparisonDate, request.ResponseCode); - - var todaysSalesQueryResult = await ExecuteQuerySafeToList(todaysSalesQuery, cancellationToken, "Error retrieving todays failed sales"); - if (todaysSalesQueryResult.IsFailed) - return ResultHelpers.CreateFailure(todaysSalesQueryResult); - var comparisonSalesQueryResult = await ExecuteQuerySafeToList(comparisonSalesQuery, cancellationToken, "Error retrieving comparison failed sales"); - if (comparisonSalesQueryResult.IsFailed) - return ResultHelpers.CreateFailure(comparisonSalesQueryResult); - var todaysSales = todaysSalesQueryResult.Data; - var comparisonSales = comparisonSalesQueryResult.Data; - - TodaysSales response = new() { - ComparisonSalesCount = comparisonSales.Count, - ComparisonSalesValue = comparisonSales.Sum(), - ComparisonAverageSalesValue = this.SafeDivide(comparisonSales.Sum(), comparisonSales.Count), - TodaysSalesCount = todaysSales.Count, - TodaysSalesValue = todaysSales.Sum(), - TodaysAverageSalesValue = this.SafeDivide(todaysSales.Sum(), todaysSales.Count) - }; - return Result.Success(response); - } - - public async Task> GetTodaysSales(TransactionQueries.TodaysSalesQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - IQueryable todaysSales = this.BuildTodaySalesQuery(context); - IQueryable comparisonSales = this.BuildComparisonSalesQuery(context, request.ComparisonDate); - - todaysSales = todaysSales.ApplyMerchantFilter(request.MerchantReportingId).ApplyOperatorFilter(request.OperatorReportingId); - comparisonSales = comparisonSales.ApplyMerchantFilter(request.MerchantReportingId).ApplyOperatorFilter(request.OperatorReportingId); - - var todaysSalesQueryResult = await ExecuteQuerySafeToList(todaysSales, cancellationToken, "Error retrieving todays failed sales"); - if (todaysSalesQueryResult.IsFailed) - return ResultHelpers.CreateFailure(todaysSalesQueryResult); - var comparisonSalesQueryResult = await ExecuteQuerySafeToList(comparisonSales, cancellationToken, "Error retrieving comparison failed sales"); - if (comparisonSalesQueryResult.IsFailed) - return ResultHelpers.CreateFailure(comparisonSalesQueryResult); - - - Decimal todaysSalesValue = await todaysSales.SumAsync(t => t.TransactionAmount, cancellationToken); - Int32 todaysSalesCount = await todaysSales.CountAsync(cancellationToken); - Decimal comparisonSalesValue = await comparisonSales.SumAsync(t => t.TransactionAmount, cancellationToken); - Int32 comparisonSalesCount = await comparisonSales.CountAsync(cancellationToken); - - TodaysSales response = new() { - ComparisonSalesCount = comparisonSalesCount, - ComparisonSalesValue = comparisonSalesValue, - TodaysSalesCount = todaysSalesCount, - TodaysSalesValue = todaysSalesValue, - TodaysAverageSalesValue = SafeDivide(todaysSalesValue, todaysSalesCount), - ComparisonAverageSalesValue = SafeDivide(comparisonSalesValue, comparisonSalesCount) - }; - return Result.Success(response); - } - - public async Task>> GetEstateOperators(EstateQueries.GetEstateOperatorsQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - - - var operatorQuery = context.EstateOperators.Join(context.Operators, eo => new { eo.OperatorId, eo.EstateId }, op => new { op.OperatorId, op.EstateId }, (eo, - op) => new { EstateOperator = eo, Operator = op }).Where(e => e.EstateOperator.EstateId == request.EstateId && (e.EstateOperator.IsDeleted ?? false) == false); - - var operatorQueryResult = await ExecuteQuerySafeToList(operatorQuery, cancellationToken, "Error retrieving estate operators"); - if (operatorQueryResult.IsFailed) - return ResultHelpers.CreateFailure(operatorQueryResult); - - var operatorEntities = operatorQueryResult.Data; - List operators = new(); - - foreach (var operatorEntity in operatorEntities) { - operators.Add(new EstateOperator() { Name = operatorEntity.Operator.Name, OperatorId = operatorEntity.EstateOperator.OperatorId }); - } - - return Result.Success(operators); - } - - public async Task> GetEstate(EstateQueries.GetEstateQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var estateQueryResult = await ExecuteQuerySafeSingleOrDefault(context.Estates.Where(e => e.EstateId == request.EstateId), cancellationToken, "Error retrieving estate"); - - if (estateQueryResult.IsFailed) - return ResultHelpers.CreateFailure(estateQueryResult); - var estate = estateQueryResult.Data; - - // Operators - var operatorsListQueryResult = await ExecuteQuerySafeToList(context.Operators.Where(e => e.EstateId == request.EstateId), cancellationToken, "Error retrieving estate operators"); - if (operatorsListQueryResult.IsFailed) - return ResultHelpers.CreateFailure(operatorsListQueryResult); - var operators = operatorsListQueryResult.Data; - - // Users - var usersListQueryResult = await ExecuteQuerySafeToList(context.EstateSecurityUsers.Where(e => e.EstateId == request.EstateId), cancellationToken, "Error retrieving estate users"); - if (usersListQueryResult.IsFailed) - return ResultHelpers.CreateFailure(usersListQueryResult); - var users = usersListQueryResult.Data; - - // Merchants - var merchantsListQueryResult = await ExecuteQuerySafeToList(context.Merchants.Where(e => e.EstateId == request.EstateId), cancellationToken, "Error retrieving merchants"); - if (merchantsListQueryResult.IsFailed) - return ResultHelpers.CreateFailure(merchantsListQueryResult); - var merchants = merchantsListQueryResult.Data; - - // Contracts - var contractsListQueryResult = await ExecuteQuerySafeToList(context.Contracts.Where(e => e.EstateId == request.EstateId), cancellationToken, "Error retrieving contracts"); - if (contractsListQueryResult.IsFailed) - return ResultHelpers.CreateFailure(contractsListQueryResult); - var contracts = contractsListQueryResult.Data; - - Estate result = new() { - EstateId = estate.EstateId, - EstateName = estate.Name, - Reference = estate.Reference, - Operators = operators.Select(o => new Models.EstateOperator { OperatorId = o.OperatorId, Name = o.Name, }).ToList(), - Users = users.Select(u => new Models.EstateUser { UserId = u.SecurityUserId, EmailAddress = u.EmailAddress, CreatedDateTime = u.CreatedDateTime }).ToList(), - Merchants = merchants.Select(m => new Models.EstateMerchant { MerchantId = m.MerchantId, Name = m.Name, Reference = m.Reference }).ToList(), - Contracts = contracts.Select(c => new Models.EstateContract { ContractId = c.ContractId, Name = c.Description, }).ToList() - }; - - return result; - } - - - private Int32 SafeDivide(Int32 number, - Int32 divisor) { - if (divisor == 0) return number; - - return number / divisor; - } - - private Decimal SafeDivide(Decimal number, - Int32 divisor) { - if (divisor == 0) return number; - - return number / divisor; - } - - public async Task>> GetRecentMerchants(MerchantQueries.GetRecentMerchantsQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var merchantsQuery = context.Merchants.Select(m => new { - MerchantReportingId = m.MerchantReportingId, - Name = m.Name, - CreatedDateTime = m.CreatedDateTime, - MerchantId = m.MerchantId, - Reference = m.Reference, - AddressInfo = context.MerchantAddresses.Where(ma => ma.MerchantId == m.MerchantId) - .OrderByDescending(ma => ma.CreatedDateTime) - .Select(ma => new { ma.AddressId, ma.AddressLine1, ma.AddressLine2, ma.Country, ma.PostalCode, ma.Region, ma.Town }) - .FirstOrDefault(), - ContactInfo = context.MerchantContacts.Where(mc => mc.MerchantId == m.MerchantId) - .OrderByDescending(mc => mc.CreatedDateTime) - .Select(mc => new { mc.ContactId, mc.Name, mc.EmailAddress, mc.PhoneNumber }) - .FirstOrDefault(), - BalanceState = context.MerchantBalanceProjectionState.Where(mb => mb.MerchantId == m.MerchantId).SingleOrDefault() - }).OrderByDescending(m => m.CreatedDateTime).Take(3); - - var recentMerchantsResult = await ExecuteQuerySafeToList(merchantsQuery, cancellationToken, "Error retrieving recent merchants"); - if (recentMerchantsResult.IsFailed) - return ResultHelpers.CreateFailure(recentMerchantsResult); - - List merchantList = recentMerchantsResult.Data.Select(merchant => { - Merchant model = new() { - MerchantId = merchant.MerchantId, - Name = merchant.Name, - Reference = merchant.Reference, - MerchantReportingId = merchant.MerchantReportingId, - CreatedDateTime = merchant.CreatedDateTime, - Balance = merchant.BalanceState.Balance - }; - if (merchant.AddressInfo != null) { - model.AddressId = merchant.AddressInfo.AddressId; - model.AddressLine1 = merchant.AddressInfo.AddressLine1; - model.AddressLine2 = merchant.AddressInfo.AddressLine2; - model.Country = merchant.AddressInfo.Country; - model.PostCode = merchant.AddressInfo.PostalCode; - model.Town = merchant.AddressInfo.Town; - model.Region = merchant.AddressInfo.Region; - } - if (merchant.ContactInfo != null) { - model.ContactId = merchant.ContactInfo.ContactId; - - model.ContactName = merchant.ContactInfo.Name; - model.ContactEmail = merchant.ContactInfo.EmailAddress; - model.ContactPhone = merchant.ContactInfo.PhoneNumber; - } - return model; - }).ToList(); - - return Result.Success(merchantList); - } - - public async Task> GetMerchantsTransactionKpis(MerchantQueries.GetTransactionKpisQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var merchantsQuery = context.MerchantBalanceProjectionState.Select(m => new { m.MerchantName, m.LastSale }); - var merchantsQueryResult = await ExecuteQuerySafeToList(merchantsQuery, cancellationToken, "Error retrieving merchants for KPI's"); - if (merchantsQueryResult.IsFailed) - return ResultHelpers.CreateFailure(merchantsQueryResult); - - var merchants = merchantsQueryResult.Data; - - DateTime now = DateTime.Now; - - Int32 merchantsWithSaleInLastHour = (from m in merchants where m.LastSale >= now.AddHours(-1) && m.LastSale <= now select m).Count(); - - Int32 merchantsWithNoSaleToday = (from m in merchants where m.LastSale >= now.AddDays(-7) && m.LastSale <= now.AddDays(-1) select m).Count(); - - Int32 merchantsWithNoSaleInLast7Days = (from m in merchants where m.LastSale <= now.AddDays(-7) select m).Count(); - - MerchantKpi response = new() { MerchantsWithSaleInLastHour = merchantsWithSaleInLastHour, MerchantsWithNoSaleToday = merchantsWithNoSaleToday, MerchantsWithNoSaleInLast7Days = merchantsWithNoSaleInLast7Days }; - - return Result.Success(response); - } - - public async Task>> GetOperators(OperatorQueries.GetOperatorsQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var operatorQuery = (from o in context.Operators - select new { - Name = o.Name, - EstateReportingId = context.Estates.Single(e => e.EstateId == o.EstateId).EstateReportingId, - OperatorId = o.OperatorId, - OperatorReportingId = o.OperatorReportingId, - RequireCustomMerchantNumber = o.RequireCustomMerchantNumber, - RequireCustomTerminalNumber = o.RequireCustomTerminalNumber - }); - - var operatorResult = await ExecuteQuerySafeToList(operatorQuery, cancellationToken, "Error retrieving operator"); - - if (operatorResult.IsFailed) - return ResultHelpers.CreateFailure(operatorResult); - - List operators = new List(); - foreach (var op in operatorResult.Data) { - operators.Add(new Operator { - Name = op.Name, - EstateReportingId = op.EstateReportingId, - OperatorId = op.OperatorId, - OperatorReportingId = op.OperatorReportingId, - RequireCustomMerchantNumber = op.RequireCustomMerchantNumber, - RequireCustomTerminalNumber = op.RequireCustomTerminalNumber - }); - } - - return Result.Success(operators); - } - - public async Task>> GetMerchantOpeningHours(MerchantQueries.GetMerchantOpeningHoursQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var openingHoursQuery = (from mo in context.MerchantOpeningHours - where mo.MerchantId == request.MerchantId - select new { - SundayOpen = mo.SundayOpening, - SundayClose = mo.SundayClosing, - MondayOpen = mo.MondayOpening, - MondayClose = mo.MondayClosing, - TuesdayOpen = mo.TuesdayOpening, - TuesdayClose = mo.TuesdayClosing, - WednesdayOpen = mo.WednesdayOpening, - WednesdayClose = mo.WednesdayClosing, - ThursdayOpen = mo.ThursdayOpening, - ThursdayClose = mo.ThursdayClosing, - FridayOpen = mo.FridayOpening, - FridayClose = mo.FridayClosing, - SaturdayOpen = mo.SaturdayOpening, - SaturdayClose = mo.SaturdayClosing, - }); - - var openingHoursResult = await ExecuteQuerySafeSingleOrDefault(openingHoursQuery, cancellationToken, "Error retrieving merchant opening hours"); - - if (openingHoursResult.IsFailed) - return ResultHelpers.CreateFailure(openingHoursResult); - - List openingHours = new List(); - openingHours.Add(new MerchantOpeningHour {OpeningTime = openingHoursResult.Data.SundayOpen, ClosingTime = openingHoursResult.Data.SundayClose, DayOfWeek = DayOfWeek.Sunday, MerchantId = request.MerchantId}); - openingHours.Add(new MerchantOpeningHour { OpeningTime = openingHoursResult.Data.MondayOpen, ClosingTime = openingHoursResult.Data.MondayClose, DayOfWeek = DayOfWeek.Monday, MerchantId = request.MerchantId }); - openingHours.Add(new MerchantOpeningHour { OpeningTime = openingHoursResult.Data.TuesdayOpen, ClosingTime = openingHoursResult.Data.TuesdayClose, DayOfWeek = DayOfWeek.Tuesday, MerchantId = request.MerchantId }); - openingHours.Add(new MerchantOpeningHour { OpeningTime = openingHoursResult.Data.WednesdayOpen, ClosingTime = openingHoursResult.Data.WednesdayClose, DayOfWeek = DayOfWeek.Wednesday, MerchantId = request.MerchantId }); - openingHours.Add(new MerchantOpeningHour { OpeningTime = openingHoursResult.Data.ThursdayOpen, ClosingTime = openingHoursResult.Data.ThursdayClose, DayOfWeek = DayOfWeek.Thursday, MerchantId = request.MerchantId }); - openingHours.Add(new MerchantOpeningHour { OpeningTime = openingHoursResult.Data.FridayOpen, ClosingTime = openingHoursResult.Data.FridayClose, DayOfWeek = DayOfWeek.Friday, MerchantId = request.MerchantId }); - openingHours.Add(new MerchantOpeningHour { OpeningTime = openingHoursResult.Data.SaturdayOpen, ClosingTime = openingHoursResult.Data.SaturdayClose, DayOfWeek = DayOfWeek.Saturday, MerchantId = request.MerchantId }); - - return Result.Success(openingHours); - } - - public async Task> GetOperator(OperatorQueries.GetOperatorQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var operatorQuery = (from o in context.Operators - where o.OperatorId == request.OperatorId - select new { - Name = o.Name, - EstateReportingId = context.Estates.Single(e => e.EstateId == o.EstateId).EstateReportingId, - OperatorId = o.OperatorId, - OperatorReportingId = o.OperatorReportingId, - RequireCustomMerchantNumber = o.RequireCustomMerchantNumber, - RequireCustomTerminalNumber = o.RequireCustomTerminalNumber - }); - - var operatorResult = await ExecuteQuerySafeSingleOrDefault(operatorQuery, cancellationToken, "Error retrieving operator"); - - if (operatorResult.IsFailed) - return ResultHelpers.CreateFailure(operatorResult); - - var @operator = new Operator { - Name = operatorResult.Data.Name, - EstateReportingId = operatorResult.Data.EstateReportingId, - OperatorId = operatorResult.Data.OperatorId, - OperatorReportingId = operatorResult.Data.OperatorReportingId, - RequireCustomMerchantNumber = operatorResult.Data.RequireCustomMerchantNumber, - RequireCustomTerminalNumber = operatorResult.Data.RequireCustomTerminalNumber - }; - - return Result.Success(@operator); - } - - public async Task> GetTransactionDetailReport(TransactionQueries.TransactionDetailReportQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var query = ApplyTransactionDetailFilters(BuildTransactionDetailBaseQuery(context, request.Request), request.Request); - var queryResult = await ExecuteQuerySafeToList(query, cancellationToken, "Error retrieving transaction details report"); - - if (queryResult.IsFailed) - return ResultHelpers.CreateFailure(queryResult); - - var queryResults = queryResult.Data; - - if (queryResults.Any() == false) - return new TransactionDetailReportResponse { Summary = new TransactionDetailSummary(), Transactions = new List() }; - - return Result.Success(MapToTransactionDetailResponse(queryResults)); - } - - private static IQueryable BuildTransactionDetailBaseQuery(EstateManagementContext context, - TransactionDetailReportRequest request) { - return from t in context.Transactions - join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } - join m in context.Merchants on t.MerchantId equals m.MerchantId - join o in context.Operators on t.OperatorId equals o.OperatorId - join msf in context.MerchantSettlementFees on t.TransactionId equals msf.TransactionId into msfJoin - from msf in msfJoin.DefaultIfEmpty() - // left join Settlements (msf may be null) - join s in context.Settlements on msf.SettlementId equals s.SettlementId into sJoin - from s in sJoin.DefaultIfEmpty() - where t.TransactionType != "Logon" && t.TransactionDate >= request.StartDate && t.TransactionDate <= request.EndDate - select new TransactionDetailQueryResult { - TransactionId = t.TransactionId, - TransactionDateTime = t.TransactionDateTime, - MerchantId = m.MerchantId, - MerchantReportingId = m.MerchantReportingId, - MerchantName = m.Name, - OperatorId = o.OperatorId, - OperatorReportingId = o.OperatorReportingId, - OperatorName = o.Name, - ProductName = cp.ProductName, - ContractProductId = cp.ContractProductId, - ContractProductReportingId = cp.ContractProductReportingId, - TransactionType = t.TransactionType, - Status = t.IsAuthorised ? "Authorised" : "Declined", - Value = t.TransactionAmount, - FeeValue = msf != null ? msf.FeeValue : 0m, - SettlementId = s != null ? s.SettlementId : Guid.Empty, - TransactionNumber = Int32.Parse(t.TransactionNumber) - }; - } - - private static IQueryable ApplyTransactionDetailFilters(IQueryable query, - TransactionDetailReportRequest request) { - if (request.Merchants != null && request.Merchants.Any()) - query = query.Where(q => request.Merchants.Contains(q.MerchantReportingId)); - if (request.Products != null && request.Products.Any()) - query = query.Where(q => request.Products.Contains(q.ContractProductReportingId)); - if (request.Operators != null && request.Operators.Any()) - query = query.Where(q => request.Operators.Contains(q.OperatorReportingId)); - return query; - } - - private static TransactionDetailReportResponse MapToTransactionDetailResponse(List queryResults) { - return new TransactionDetailReportResponse { - Transactions = queryResults.Select(q => new TransactionDetail { - Id = q.TransactionId, - DateTime = q.TransactionDateTime, - MerchantId = q.MerchantId, - MerchantReportingId = q.MerchantReportingId, - Merchant = q.MerchantName, - OperatorId = q.OperatorId, - OperatorReportingId = q.OperatorReportingId, - Operator = q.OperatorName, - Product = q.ProductName, - ProductId = q.ContractProductId, - ProductReportingId = q.ContractProductReportingId, - Type = q.TransactionType, - Status = q.Status, - Value = q.Value, - TotalFees = q.FeeValue, - SettlementReference = q.SettlementId.ToString(), - TransactionNumber = q.TransactionNumber - }).ToList(), - Summary = new TransactionDetailSummary { TransactionCount = queryResults.Count(), TotalValue = queryResults.Sum(q => q.Value), TotalFees = queryResults.Sum(q => q.FeeValue) } - }; - } - - public async Task> GetTransactionSummaryByMerchantReport(TransactionQueries.TransactionSummaryByMerchantQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var query = BuildMerchantTransactionBaseQuery(context, request.Request.StartDate, request.Request.EndDate); - - if (request.Request.Merchants != null && request.Request.Merchants.Any()) - query = query.Where(q => request.Request.Merchants.Contains(q.MerchantReportingId)); - - if (request.Request.Operators != null && request.Request.Operators.Any()) - query = query.Where(q => request.Request.Operators.Contains(q.OperatorReportingId)); - - var finalQuery = BuildMerchantTransactionFinalQuery(query); - - var queryResult = await ExecuteQuerySafeToList(finalQuery, cancellationToken, "Error retrieving transaction summary by merchant report"); - - if (queryResult.IsFailed) - return ResultHelpers.CreateFailure(queryResult); - - var queryResults = queryResult.Data; - - if (queryResults.Any() == false) - return new TransactionSummaryByMerchantResponse { Summary = new MerchantDetailSummary(), Merchants = new List() }; - - return new TransactionSummaryByMerchantResponse { - Merchants = queryResults.Select(q => new MerchantDetail { - MerchantId = q.MerchantId, - MerchantReportingId = q.MerchantReportingId, - MerchantName = q.MerchantName, - AuthorisedCount = q.AuthorisedCount, - AuthorisedPercentage = q.AuthorisedPercentage, - AverageValue = q.AverageValue, - DeclinedCount = q.DeclinedCount, - TotalCount = q.TotalCount, - TotalValue = q.TotalValue - }).ToList(), - Summary = new MerchantDetailSummary { - TotalCount = queryResults.Sum(q => q.TotalCount), - TotalValue = queryResults.Sum(q => q.TotalValue), - AverageValue = this.SafeDivide(queryResults.Sum(q => q.TotalValue), queryResults.Sum(q => q.TotalCount)), - TotalMerchants = queryResults.Count() - } - }; - } - - private static IQueryable BuildMerchantTransactionBaseQuery(EstateManagementContext context, - DateTime startDate, - DateTime endDate) { - return from t in context.Transactions - join m in context.Merchants on t.MerchantId equals m.MerchantId - join o in context.Operators on t.OperatorId equals o.OperatorId - where t.TransactionType == "Sale" && t.TransactionDate >= startDate && t.TransactionDate <= endDate - group t by new { t.MerchantId, m.MerchantReportingId, MerchantName = m.Name, t.OperatorId, o.OperatorReportingId } - into g - select new MerchantTransactionGroupProjection { - MerchantId = g.Key.MerchantId, - MerchantReportingId = g.Key.MerchantReportingId, - MerchantName = g.Key.MerchantName, - OperatorId = g.Key.OperatorId, - OperatorReportingId = g.Key.OperatorReportingId, - TotalCount = g.Count(), - TotalValue = g.Sum(x => x.TransactionAmount), - AuthorisedCount = g.Sum(x => x.IsAuthorised ? 1 : 0), - DeclinedCount = g.Sum(x => x.IsAuthorised ? 0 : 1) - }; - } - - private static IQueryable BuildMerchantTransactionFinalQuery(IQueryable query) { - return from x in query - group x by new { x.MerchantId, x.MerchantReportingId, x.MerchantName } - into g - select new MerchantTransactionFinalProjection { - MerchantId = g.Key.MerchantId, - MerchantReportingId = g.Key.MerchantReportingId, - MerchantName = g.Key.MerchantName, - TotalCount = g.Sum(x => x.TotalCount), - TotalValue = g.Sum(x => x.TotalValue), - AverageValue = g.Count() > 0 ? g.Sum(x => x.TotalValue) / g.Count() : 0m, - AuthorisedCount = g.Sum(x => x.AuthorisedCount), - DeclinedCount = g.Sum(x => x.DeclinedCount), - AuthorisedPercentage = g.Sum(x => x.TotalCount) > 0 ? (decimal)g.Sum(x => x.AuthorisedCount) / (decimal)g.Sum(x => x.TotalCount) : 0m - }; - } - - public async Task> GetTransactionSummaryByOperatorReport(TransactionQueries.TransactionSummaryByOperatorQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var baseQuery = BuildOperatorTransactionBaseQuery(context, request); - var finalQuery = BuildOperatorFinalSummaryQuery(baseQuery); - - var queryResult = await ExecuteQuerySafeToList(finalQuery, cancellationToken, "Error retrieving transaction summary by operator report"); - - if (queryResult.IsFailed) - return ResultHelpers.CreateFailure(queryResult); - - var queryResults = queryResult.Data; - - if (queryResults.Any() == false) - return new TransactionSummaryByOperatorResponse { Summary = new OperatorDetailSummary(), Operators = new List() }; - - return BuildOperatorSummaryResponse(queryResults); - } - - private static IQueryable BuildOperatorTransactionBaseQuery(EstateManagementContext context, - TransactionQueries.TransactionSummaryByOperatorQuery request) { - var query = from t in context.Transactions - join m in context.Merchants on t.MerchantId equals m.MerchantId - join o in context.Operators on t.OperatorId equals o.OperatorId - where t.TransactionType == "Sale" && t.TransactionDate >= request.Request.StartDate && t.TransactionDate <= request.Request.EndDate - group t by new { - t.MerchantId, - m.MerchantReportingId, - MerchantName = m.Name, - t.OperatorId, - o.OperatorReportingId, - OperatorName = o.Name - } - into g - select new OperatorTransactionData { - MerchantId = g.Key.MerchantId, - MerchantReportingId = g.Key.MerchantReportingId, - MerchantName = g.Key.MerchantName, - OperatorId = g.Key.OperatorId, - OperatorReportingId = g.Key.OperatorReportingId, - OperatorName = g.Key.OperatorName, - TotalCount = g.Count(), - TotalValue = g.Sum(x => x.TransactionAmount), - AuthorisedCount = g.Sum(x => x.IsAuthorised ? 1 : 0), - DeclinedCount = g.Sum(x => x.IsAuthorised ? 0 : 1) - }; - - return ApplyOperatorTransactionFilters(query, request.Request); - } - - private static IQueryable ApplyOperatorTransactionFilters(IQueryable query, - Models.TransactionSummaryByOperatorRequest request) { - if (request.Merchants != null && request.Merchants.Any()) - query = query.Where(q => request.Merchants.Contains(q.MerchantReportingId)); - - if (request.Operators != null && request.Operators.Any()) - query = query.Where(q => request.Operators.Contains(q.OperatorReportingId)); - - return query; - } - - private static IQueryable BuildOperatorFinalSummaryQuery(IQueryable query) { - return from x in query - group x by new { x.OperatorId, x.OperatorReportingId, x.OperatorName } - into g - select new OperatorSummaryData { - OperatorId = g.Key.OperatorId, - OperatorReportingId = g.Key.OperatorReportingId, - OperatorName = g.Key.OperatorName, - TotalCount = g.Sum(x => x.TotalCount), - TotalValue = g.Sum(x => x.TotalValue), - AverageValue = g.Count() > 0 ? g.Sum(x => x.TotalValue) / g.Count() : 0m, - AuthorisedCount = g.Sum(x => x.AuthorisedCount), - DeclinedCount = g.Sum(x => x.DeclinedCount), - AuthorisedPercentage = g.Sum(x => x.TotalCount) > 0 ? (decimal)g.Sum(x => x.AuthorisedCount) / (decimal)g.Sum(x => x.TotalCount) : 0m - }; - } - - private TransactionSummaryByOperatorResponse BuildOperatorSummaryResponse(List queryResults) { - return new TransactionSummaryByOperatorResponse { - Operators = queryResults.Select(q => new OperatorDetail { - OperatorId = q.OperatorId, - OperatorReportingId = q.OperatorReportingId, - OperatorName = q.OperatorName, - AuthorisedCount = q.AuthorisedCount, - AuthorisedPercentage = q.AuthorisedPercentage, - AverageValue = q.AverageValue, - DeclinedCount = q.DeclinedCount, - TotalCount = q.TotalCount, - TotalValue = q.TotalValue - }).ToList(), - Summary = new OperatorDetailSummary { - TotalCount = queryResults.Sum(q => q.TotalCount), - TotalValue = queryResults.Sum(q => q.TotalValue), - AverageValue = this.SafeDivide(queryResults.Sum(q => q.TotalValue), queryResults.Sum(q => q.TotalCount)), - TotalOperators = queryResults.Count() - } - }; - } - - public async Task>> GetMerchants(MerchantQueries.GetMerchantsQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var merchantWithAddresses = ApplyMerchantFilters(BuildMerchantWithAddressQuery(context, request.EstateId), request.QueryOptions); - - var queryResults = await ExecuteQuerySafeToList(merchantWithAddresses, cancellationToken, "Error retrieving merchants"); - - if (queryResults.IsFailed) - return ResultHelpers.CreateFailure(queryResults); - - var merchants = queryResults.Data; - - var merchantBalancesQuery = context.MerchantBalanceProjectionState.Where(mb => merchants.Select(m => m.Merchant.MerchantId).Contains(mb.MerchantId)); - - var balanceQueryResults = await ExecuteQuerySafeToList(merchantBalancesQuery, cancellationToken, "Error retrieving merchant balances"); - - if (balanceQueryResults.IsFailed) - return ResultHelpers.CreateFailure(balanceQueryResults); - - try { - Dictionary balanceLookup = BuildMerchantBalanceLookup(balanceQueryResults.Data); - List response = merchants.Select(merchant => - ModelFactory.ConvertFrom(merchant, GetMerchantBalance(balanceLookup, merchant.Merchant.MerchantId))) - .ToList(); - - return Result.Success(response); - } - catch (InvalidOperationException ex) { - return Result.Failure(ex.Message); - } - } - - public async Task> GetMerchant(MerchantQueries.GetMerchantQuery request, CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - IQueryable merchantQuery = BuildMerchantQuery(context, request.MerchantId); - Result merchantQueryResult = await ExecuteQuerySafeSingleOrDefault(merchantQuery, cancellationToken, "Error getting merchant"); - if (merchantQueryResult.IsFailed) { - return ResultHelpers.CreateFailure(merchantQueryResult); - } - - Result> openingHoursQueryResult = await ExecuteQuerySafeToList(context.MerchantOpeningHours.Where(m => m.MerchantId == request.MerchantId), cancellationToken, "Error getting merchant opening hours"); - if (openingHoursQueryResult.IsFailed) return ResultHelpers.CreateFailure(openingHoursQueryResult); - - Result merchantStateQueryResult = await ExecuteQuerySafeSingleOrDefault(context.MerchantBalanceProjectionState.Where(ms => ms.MerchantId == request.MerchantId), cancellationToken, "Error getting merchant state"); - if (merchantStateQueryResult.IsFailed) return ResultHelpers.CreateFailure(merchantStateQueryResult); - - return Result.Success(ModelFactory.ConvertFrom(merchantQueryResult.Data, merchantStateQueryResult.Data.Balance, openingHoursQueryResult.Data)); - } - - private static IQueryable BuildMerchantQuery(EstateManagementContext context, - Guid merchantId) { - return context.Merchants - .Select(m => new MerchantData { - MerchantReportingId = m.MerchantReportingId, - Name = m.Name, - CreatedDateTime = m.CreatedDateTime, - MerchantId = m.MerchantId, - Reference = m.Reference, - SettlementSchedule = m.SettlementSchedule, - AddressInfo = context.MerchantAddresses.Where(ma => ma.MerchantId == m.MerchantId) - .OrderByDescending(ma => ma.CreatedDateTime) - .Select(ma => new MerchantAddressData { - AddressId = ma.AddressId, - AddressLine1 = ma.AddressLine1, - AddressLine2 = ma.AddressLine2, - Country = ma.Country, - PostalCode = ma.PostalCode, - Region = ma.Region, - Town = ma.Town - }) - .FirstOrDefault(), - ContactInfo = context.MerchantContacts.Where(mc => mc.MerchantId == m.MerchantId) - .OrderByDescending(mc => mc.CreatedDateTime) - .Select(mc => new MerchantContactData { - ContactId = mc.ContactId, - Name = mc.Name, - EmailAddress = mc.EmailAddress, - PhoneNumber = mc.PhoneNumber - }) - .FirstOrDefault() - }) - .Where(m => m.MerchantId == merchantId); - } - - private static IQueryable BuildMerchantWithAddressQuery(EstateManagementContext context, - Guid estateId) { - return context.Merchants - .Where(m => m.EstateId == estateId) - .Select(m => new MerchantWithAddressAndContactData { - Merchant = m, - MerchantAddress = context.MerchantAddresses.Where(ma => ma.MerchantId == m.MerchantId) - .OrderByDescending(ma => ma.CreatedDateTime) - .First(), - MerchantContact = context.MerchantContacts.Where(mc => mc.MerchantId == m.MerchantId) - .OrderByDescending(mc => mc.CreatedDateTime) - .First() - }); - } - - private static IQueryable ApplyMerchantFilters(IQueryable query, - MerchantQueries.MerchantQueryOptions queryOptions) { - if (String.IsNullOrEmpty(queryOptions.Name) == false) - query = query.Where(m => m.Merchant.Name.Contains(queryOptions.Name)); - - if (String.IsNullOrEmpty(queryOptions.Reference) == false) - query = query.Where(m => m.Merchant.Reference == queryOptions.Reference); - - if (queryOptions.SettlementSchedule > 0) - query = query.Where(m => m.Merchant.SettlementSchedule == queryOptions.SettlementSchedule); - - if (String.IsNullOrEmpty(queryOptions.Region) == false) - query = query.Where(m => m.MerchantAddress.Region.Contains(queryOptions.Region)); - - if (String.IsNullOrEmpty(queryOptions.PostCode) == false) - query = query.Where(m => m.MerchantAddress.PostalCode == queryOptions.PostCode); - - return query; - } - - private static Dictionary BuildMerchantBalanceLookup(List balances) { - Dictionary balanceLookup = new(); - foreach (var balance in balances) { - if (balanceLookup.TryAdd(balance.MerchantId, balance.Balance) == false) - throw new InvalidOperationException($"Duplicate balance entry found for merchant {balance.MerchantId}"); - } - - return balanceLookup; - } - - private static decimal GetMerchantBalance(Dictionary balanceLookup, - Guid merchantId) { - if (balanceLookup.TryGetValue(merchantId, out var balance)) - return balance; - - throw new InvalidOperationException($"No balance entry found for merchant {merchantId}"); - } - - public async Task>> GetMerchantOperators(MerchantQueries.GetMerchantOperatorsQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var merchantOperatorsQuery = context.MerchantOperators.Where(mo => mo.MerchantId == request.MerchantId && mo.IsDeleted == false); - - var merchantOperatorsQueryResult = await ExecuteQuerySafeToList(merchantOperatorsQuery, cancellationToken, "Error getting merchant devices"); - - if (merchantOperatorsQueryResult.IsFailed) - return ResultHelpers.CreateFailure(merchantOperatorsQueryResult); - - var merchantOperators = merchantOperatorsQueryResult.Data; - - List result = new(); - foreach (TransactionProcessor.Database.Entities.MerchantOperator merchantOperator in merchantOperators) { - result.Add(new MerchantOperator { - OperatorId = merchantOperator.OperatorId, - IsDeleted = merchantOperator.IsDeleted, - MerchantId = merchantOperator.MerchantId, - MerchantNumber = merchantOperator.MerchantNumber, - OperatorName = merchantOperator.Name, - TerminalNumber = merchantOperator.TerminalNumber - }); - } - - return Result.Success(result); - } - - public async Task>> GetMerchantContracts(MerchantQueries.GetMerchantContractsQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var merchantContractsQuery = context.MerchantContracts.Where(mo => mo.MerchantId == request.MerchantId && mo.IsDeleted == false).Select(mc => new { - mc.ContractId, - mc.IsDeleted, - mc.MerchantId, - ContractInfo = context.Contracts.Where(c => c.ContractId == mc.ContractId).Select(ma => new { - ma.Description, - OperatorName = context.Operators.Where(o => o.OperatorId == ma.OperatorId).Select(p => p.Name).Single(), - Products = context.ContractProducts.Where(cp => cp.ContractId == mc.ContractId).Select(cp => new { - cp.DisplayText, - cp.ProductName, - cp.ContractProductId, - cp.ProductType, - cp.Value - }).ToList() - // Add more properties as needed - }).SingleOrDefault() - }); - - var merchantContractsQueryResult = await ExecuteQuerySafeToList(merchantContractsQuery, cancellationToken, "Error getting merchant devices"); - - if (merchantContractsQueryResult.IsFailed) - return ResultHelpers.CreateFailure(merchantContractsQueryResult); - - var merchantContracts = merchantContractsQueryResult.Data; - - List result = new(); - foreach (var merchantContract in merchantContracts) { - var c = new MerchantContract { - ContractId = merchantContract.ContractId, - ContractName = merchantContract.ContractInfo.Description, - IsDeleted = merchantContract.IsDeleted, - MerchantId = merchantContract.MerchantId, - OperatorName = merchantContract.ContractInfo.OperatorName, - ContractProducts = new List() - }; - - foreach (var product in merchantContract.ContractInfo.Products) { - c.ContractProducts.Add(new MerchantContractProduct { - ContractId = merchantContract.ContractId, - DisplayText = product.DisplayText, - MerchantId = merchantContract.MerchantId, - ProductName = product.ProductName, - ProductId = product.ContractProductId, - ProductType = product.ProductType, - Value = product.Value - }); - } - - result.Add(c); - } - - return Result.Success(result); - } - - public async Task>> GetMerchantDevices(MerchantQueries.GetMerchantDevicesQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var merchantDevicesQuery = context.MerchantDevices.Where(mo => mo.MerchantId == request.MerchantId); - var merchantDevicesQueryResult = await ExecuteQuerySafeToList(merchantDevicesQuery, cancellationToken, "Error getting merchant devices"); - - if (merchantDevicesQueryResult.IsFailed) - return ResultHelpers.CreateFailure(merchantDevicesQueryResult); - - var merchantDevices = merchantDevicesQueryResult.Data; - - List result = new(); - foreach (TransactionProcessor.Database.Entities.MerchantDevice merchantDevice in merchantDevices) { - result.Add(new MerchantDevice { DeviceId = merchantDevice.DeviceId, DeviceIdentifier = merchantDevice.DeviceIdentifier, IsDeleted = false, MerchantId = merchantDevice.MerchantId }); - } - - return Result.Success(result); - } - - public async Task> GetProductPerformanceReport(TransactionQueries.ProductPerformanceQuery request, - CancellationToken cancellationToken) { - - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var grandTotalAmountQuery = (from t in context.Transactions where t.TransactionType == "Sale" && t.TransactionDate >= request.StartDate && t.TransactionDate <= request.EndDate select t.TransactionAmount); - var grandTotalAmountResult = await ExecuteQuerySafeSum(grandTotalAmountQuery, cancellationToken); - if (grandTotalAmountResult.IsFailed) - return ResultHelpers.CreateFailure(grandTotalAmountResult); - var grandTotalAmount = grandTotalAmountResult.Data; - - var query = BuildProductPerformanceQuery(context, request.StartDate, request.EndDate, grandTotalAmount); - var queryResult = await ExecuteQuerySafeToList(query, cancellationToken); - if (queryResult.IsFailed) - return ResultHelpers.CreateFailure(queryResult); - var queryResults = queryResult.Data; - if (queryResults.Any() == false) - return Result.Success(new ProductPerformanceResponse() { Summary = new ProductPerformanceSummary(), ProductDetails = new List() }); - - return Result.Success(BuildProductPerformanceResponse(queryResults)); - } - - public async Task> GetTransactionMixSummary(TransactionQueries.TransactionMixSummaryQuery request, - CancellationToken cancellationToken) - { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - if (request.Request.StartDate > request.Request.EndDate) - return Result.Failure("StartDate must be less than or equal to EndDate."); - - if (!Enum.IsDefined(typeof(TransactionMixBreakdown), request.Request.Breakdown)) - return Result.Failure("Unsupported transaction mix breakdown."); - - if (!Enum.IsDefined(typeof(TransactionMixMeasure), request.Request.Measure)) - return Result.Failure("Unsupported transaction mix measure."); - - var detailRequest = new TransactionDetailReportRequest - { - Merchants = request.Request.MerchantReportingId.HasValue ? new List { request.Request.MerchantReportingId.Value } : [], - Operators = [], - Products = [], - StartDate = request.Request.StartDate, - EndDate = request.Request.EndDate - }; - - var query = ApplyTransactionDetailFilters(BuildTransactionDetailBaseQuery(context, detailRequest), detailRequest); - var queryResult = await ExecuteQuerySafeToList(query, cancellationToken, "Error retrieving transaction mix summary report"); - - if (queryResult.IsFailed) - return ResultHelpers.CreateFailure(queryResult); - - var queryResults = queryResult.Data; - - if (queryResults.Any() == false) - { - return Result.Success(new TransactionMixSummaryResponse - { - FromDate = request.Request.StartDate, - ToDate = request.Request.EndDate, - Breakdown = request.Request.Breakdown, - Measure = request.Request.Measure, - TotalCount = 0, - TotalValue = 0m, - Groups = [], - Transactions = [] - }); - } - - return Result.Success(BuildTransactionMixSummaryResponse(queryResults, request.Request)); - } - - public async Task> GetRecentActivityReceiptReport(TransactionQueries.GetRecentActivityReceiptReportQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - RecentActivityReceiptRequestParameters parameters = BuildRecentActivityReceiptRequestParameters(request.Request); - IQueryable query = BuildRecentActivityReceiptQuery(context, parameters); - - var totalCountResult = await ExecuteQuerySafeCount(query, cancellationToken, "Error counting recent activity receipt report rows"); - if (totalCountResult.IsFailed) - return ResultHelpers.CreateFailure(totalCountResult); - - int totalCount = totalCountResult.Data; - if (totalCount == 0) - return Result.Success(BuildEmptyRecentActivityReceiptReportResponse(parameters)); - - var pageQuery = query.Skip((parameters.PageNumber - 1) * parameters.PageSize).Take(parameters.PageSize); - var pageResult = await ExecuteQuerySafeToList(pageQuery, cancellationToken, "Error retrieving recent activity receipt report"); - if (pageResult.IsFailed) - return ResultHelpers.CreateFailure(pageResult); - - return Result.Success(BuildRecentActivityReceiptReportResponse(parameters, totalCount, pageResult.Data)); - } - - private static RecentActivityReceiptRequestParameters BuildRecentActivityReceiptRequestParameters(GetRecentActivityReceiptReportRequest request) { - return new RecentActivityReceiptRequestParameters { - ReportDate = request.ReportDate.Date, - MerchantReportingId = request.MerchantReportingId, - SearchText = request.SearchText?.Trim(), - PageNumber = request.PageNumber > 0 ? request.PageNumber : 1, - PageSize = request.PageSize > 0 ? request.PageSize : 10 - }; - } - - private static IQueryable BuildRecentActivityReceiptQuery(EstateManagementContext context, - RecentActivityReceiptRequestParameters parameters) { - IQueryable query = from t in context.Transactions - join m in context.Merchants on t.MerchantId equals m.MerchantId - join o in context.Operators on t.OperatorId equals o.OperatorId - join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } - where t.TransactionDate == parameters.ReportDate - select new RecentActivityReceiptQueryResult { - TransactionDateTime = t.TransactionDateTime, - MerchantReportingId = m.MerchantReportingId, - Reference = t.TransactionNumber, - TransactionType = t.TransactionType, - Product = cp.ProductName, - Operator = o.Name, - Status = t.IsAuthorised ? "Successful" : "Failed", - Amount = t.TransactionAmount, - ReceiptReference = t.TransactionReference - }; - - return ApplyRecentActivityReceiptFilters(query, parameters).OrderByDescending(q => q.TransactionDateTime); - } - - private static IQueryable ApplyRecentActivityReceiptFilters(IQueryable query, - RecentActivityReceiptRequestParameters parameters) { - if (parameters.MerchantReportingId.HasValue) - query = query.Where(q => q.MerchantReportingId == parameters.MerchantReportingId.Value); - - if (string.IsNullOrWhiteSpace(parameters.SearchText) == false) - query = query.Where(q => - q.Reference.Contains(parameters.SearchText) || - q.TransactionType.Contains(parameters.SearchText) || - q.Product.Contains(parameters.SearchText) || - q.Operator.Contains(parameters.SearchText) || - q.Status.Contains(parameters.SearchText) || - q.ReceiptReference.Contains(parameters.SearchText)); - - return query; - } - - private static GetRecentActivityReceiptReportResponse BuildEmptyRecentActivityReceiptReportResponse(RecentActivityReceiptRequestParameters parameters) { - return new GetRecentActivityReceiptReportResponse { - ReportDate = parameters.ReportDate, - PageNumber = parameters.PageNumber, - PageSize = parameters.PageSize, - TotalCount = 0, - Items = [] - }; - } - - private static GetRecentActivityReceiptReportResponse BuildRecentActivityReceiptReportResponse(RecentActivityReceiptRequestParameters parameters, - int totalCount, - List queryResults) { - return new GetRecentActivityReceiptReportResponse { - ReportDate = parameters.ReportDate, - PageNumber = parameters.PageNumber, - PageSize = parameters.PageSize, - TotalCount = totalCount, - Items = queryResults.Select(MapRecentActivityReceiptItem).ToList() - }; - } - - private static RecentActivityReceiptItemDto MapRecentActivityReceiptItem(RecentActivityReceiptQueryResult item) { - return new RecentActivityReceiptItemDto { - Reference = item.Reference, - TransactionType = item.TransactionType, - Product = item.Product, - Operator = item.Operator, - Status = item.Status, - Amount = item.Amount, - TransactionDateTime = item.TransactionDateTime, - ReceiptReference = item.ReceiptReference - }; - } - - private static IQueryable BuildProductPerformanceQuery(EstateManagementContext context, - DateTime startDate, - DateTime endDate, - decimal grandTotalAmount) { - return from t in context.Transactions - join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } - join c in context.Contracts on t.ContractId equals c.ContractId - where t.TransactionType == "Sale" && t.TransactionDate >= startDate && t.TransactionDate <= endDate - group t by new { cp.ProductName, cp.ContractProductId, cp.ContractProductReportingId, c.ContractId, c.ContractReportingId } - into g - select new ProductPerformanceItemData { - ProductName = g.Key.ProductName, - ContractProductId = g.Key.ContractProductId, - ContractProductReportingId = g.Key.ContractProductReportingId, - ContractId = g.Key.ContractId, - ContractReportingId = g.Key.ContractReportingId, - TransactionCount = g.Count(), - TotalAmount = g.Sum(x => x.TransactionAmount), - PercentOfTotalAmount = grandTotalAmount == 0 ? 0 : 100.0m * g.Sum(x => x.TransactionAmount) / grandTotalAmount - }; - } - - private ProductPerformanceResponse BuildProductPerformanceResponse(List queryResults) { - return new ProductPerformanceResponse { - ProductDetails = queryResults.Select(q => new ProductPerformanceDetail { - ProductName = q.ProductName, - ProductId = q.ContractProductId, - ProductReportingId = q.ContractProductReportingId, - ContractId = q.ContractId, - ContractReportingId = q.ContractReportingId, - TransactionCount = q.TransactionCount, - TransactionValue = q.TotalAmount, - PercentageOfTotal = q.PercentOfTotalAmount - }).ToList(), - Summary = new ProductPerformanceSummary { TotalCount = queryResults.Sum(q => q.TransactionCount), TotalValue = queryResults.Sum(q => q.TotalAmount), AveragePerProduct = this.SafeDivide(queryResults.Sum(q => q.TotalAmount), queryResults.Count), TotalProducts = queryResults.Count() } - }; - } - - private static TransactionMixSummaryResponse BuildTransactionMixSummaryResponse(List queryResults, - TransactionMixSummaryRequest request) - { - var groupedResults = queryResults - .GroupBy(q => request.Breakdown switch - { - TransactionMixBreakdown.Product => q.ContractProductReportingId.ToString(), - TransactionMixBreakdown.TransactionType => q.TransactionType ?? string.Empty, - TransactionMixBreakdown.Operator => q.OperatorReportingId.ToString(), - TransactionMixBreakdown.Status => q.Status ?? string.Empty, - _ => string.Empty - }) - .Select(g => - { - var first = g.First(); - string groupName = request.Breakdown switch - { - TransactionMixBreakdown.Product => first.ProductName ?? string.Empty, - TransactionMixBreakdown.TransactionType => first.TransactionType ?? string.Empty, - TransactionMixBreakdown.Operator => first.OperatorName ?? string.Empty, - TransactionMixBreakdown.Status => first.Status ?? string.Empty, - _ => string.Empty - }; - - return new TransactionMixSummaryGroup - { - GroupKey = g.Key, - GroupName = groupName, - TransactionCount = g.Count(), - TransactionValue = g.Sum(x => x.Value) - }; - }); - - var orderedGroups = request.Measure == TransactionMixMeasure.Count - ? groupedResults.OrderByDescending(g => g.TransactionCount).ThenBy(g => g.GroupName) - : groupedResults.OrderByDescending(g => g.TransactionValue).ThenBy(g => g.GroupName); - - int topN = request.TopN > 0 ? request.TopN : 5; - - return new TransactionMixSummaryResponse - { - FromDate = request.StartDate, - ToDate = request.EndDate, - Breakdown = request.Breakdown, - Measure = request.Measure, - TotalCount = queryResults.Count, - TotalValue = queryResults.Sum(q => q.Value), - Groups = orderedGroups.Take(topN).ToList(), - Transactions = queryResults - .OrderByDescending(q => q.TransactionDateTime) - .Select(q => new TransactionMixSummaryTransaction - { - Id = q.TransactionId, - DateTime = q.TransactionDateTime, - Merchant = q.MerchantName, - MerchantId = q.MerchantId, - MerchantReportingId = q.MerchantReportingId, - Operator = q.OperatorName, - OperatorId = q.OperatorId, - OperatorReportingId = q.OperatorReportingId, - Product = q.ProductName, - ProductId = q.ContractProductId, - ProductReportingId = q.ContractProductReportingId, - Type = q.TransactionType, - Status = q.Status, - Value = q.Value, - TotalFees = q.FeeValue, - SettlementReference = q.SettlementId == Guid.Empty ? null : q.SettlementId.ToString(), - TransactionNumber = q.TransactionNumber - }) - .ToList() - }; - } - - public async Task>> GetTodaysSalesByHour(TransactionQueries.TodaysSalesByHour request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.estateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - IQueryable todaysSales = this.BuildTodaySalesQuery(context); - IQueryable comparisonSales = this.BuildComparisonSalesQuery(context, request.comparisonDate); - - // First we need to get a value of todays sales - var todaysSalesByHourQuery = (from t in todaysSales group t.TransactionAmount by t.Hour into g select new { Hour = g.Key, TotalSalesCount = g.Count(), TotalSalesValue = g.Sum() }); - var todaysSalesByHourQueryResult = await ExecuteQuerySafeToList(todaysSalesByHourQuery, cancellationToken); - if (todaysSalesByHourQueryResult.IsFailed) - return ResultHelpers.CreateFailure(todaysSalesByHourQueryResult); - var todaysSalesByHour = todaysSalesByHourQueryResult.Data; - - var comparisonSalesByHourQuery = (from t in comparisonSales group t.TransactionAmount by t.Hour into g select new { Hour = g.Key, TotalSalesCount = g.Count(), TotalSalesValue = g.Sum() }); - var comparisonSalesByHourQueryResult = await ExecuteQuerySafeToList(comparisonSalesByHourQuery, cancellationToken); - if (comparisonSalesByHourQueryResult.IsFailed) - return ResultHelpers.CreateFailure(comparisonSalesByHourQueryResult); - var comparisonSalesByHour = comparisonSalesByHourQueryResult.Data; - - var response = (from today in todaysSalesByHour - join comparison in comparisonSalesByHour on today.Hour equals comparison.Hour into compGroup - from comparison in compGroup.DefaultIfEmpty() - select new TodaysSalesByHour { - Hour = today.Hour.Value, - TodaysSalesCount = today.TotalSalesCount, - TodaysSalesValue = today.TotalSalesValue, - ComparisonSalesCount = comparison?.TotalSalesCount ?? 0, - ComparisonSalesValue = comparison?.TotalSalesValue ?? 0 - }).Union(from comparison in comparisonSalesByHour - join today in todaysSalesByHour on comparison.Hour equals today.Hour into todayGroup - from today in todayGroup.DefaultIfEmpty() - where today == null - select new TodaysSalesByHour { - Hour = comparison.Hour.Value, - TodaysSalesCount = 0, - TodaysSalesValue = 0, - ComparisonSalesCount = comparison.TotalSalesCount, - ComparisonSalesValue = comparison.TotalSalesValue - }).ToList(); - - - return Result.Success(response); - } - - public async Task> GetTodaysSettlement(SettlementQueries.TodaysSettlementQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - IQueryable todaySettlementData = this.BuildTodaySettlementQuery(context, DateTime.Now); - IQueryable comparisonSettlementData = this.BuildComparisonSettlementQuery(context, request.ComparisonDate); - - DatabaseProjections.SettlementGroupProjection todaySettlement = await this.GetSettlementSummary(todaySettlementData, cancellationToken); - DatabaseProjections.SettlementGroupProjection comparisonSettlement = await this.GetSettlementSummary(comparisonSettlementData, cancellationToken); - - TodaysSettlement response = new() - { - ComparisonSettlementCount = comparisonSettlement.SettledCount, - ComparisonSettlementValue = comparisonSettlement.SettledValue, - ComparisonPendingSettlementCount = comparisonSettlement.UnSettledCount, - ComparisonPendingSettlementValue = comparisonSettlement.UnSettledValue, - TodaysSettlementCount = todaySettlement.SettledCount, - TodaysSettlementValue = todaySettlement.SettledValue, - TodaysPendingSettlementCount = todaySettlement.UnSettledCount, - TodaysPendingSettlementValue = todaySettlement.UnSettledValue - }; - - return response; - } - - public async Task> GetMerchantSchedule(MerchantQueries.GetMerchantScheduleQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var merchantScheduleQuery = from s in context.MerchantSchedules - join d in context.MerchantScheduleMonths on s.MerchantScheduleId equals d.MerchantScheduleId - where s.MerchantId == request.MerchantId && s.Year == request.Year - select new { - s.MerchantId, - s.MerchantScheduleId, - s.Year, - d.Month, - d.ClosedDays - }; - - var merchantScheduleQueryResult = await ExecuteQuerySafeToList(merchantScheduleQuery, cancellationToken, "Error getting merchant schedule"); - if (merchantScheduleQueryResult.IsFailed) - return ResultHelpers.CreateFailure(merchantScheduleQueryResult); - - MerchantScheduleResponse response = new MerchantScheduleResponse(); - foreach (var item in merchantScheduleQueryResult.Data) { - - List closedDaysList = item.ClosedDays - .Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(s => int.TryParse(s, out var n) ? n : (int?)null) - .Where(n => n.HasValue) - .Select(n => n.Value) - .ToList(); - - MerchantScheduleMonthResponse? monthSchedule = new() { ClosedDays = closedDaysList, Month = item.Month }; - - response.Months.Add(monthSchedule); - } - response.Year = request.Year; - - return response; - } - - public async Task>> GetFileImportLogList(FileImportLogQueries.GetFileImportLogListQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - // Build flat projection of the required data then assemble into hierarchical models in-memory - var flatQuery = from fil in context.FileImportLogs - join f in context.Files on fil.FileImportLogId equals f.FileImportLogId - join esu in context.EstateSecurityUsers on f.UserId equals esu.SecurityUserId into esuJoin - from esu in esuJoin.DefaultIfEmpty() - join m in context.Merchants on f.MerchantId equals m.MerchantId into mJoin - from m in mJoin.DefaultIfEmpty() - join fl in context.FileLines on f.FileId equals fl.FileId - where fil.ImportLogDate >= request.StartDate && fil.ImportLogDate <= request.EndDate - && (request.MerchantId == null || f.MerchantId == request.MerchantId) - select new FileImportFlatData { - FileImportLogId = fil.FileImportLogId, - ImportLogDateTime = fil.ImportLogDateTime, - FileId = f.FileId, - FileName = f.FileLocation, - FileProfileId = f.FileProfileId, - DateTimeUploaded = f.FileReceivedDateTime, - UserId = f.UserId, - UploadedBy = esu != null ? esu.EmailAddress : null, - MerchantId = f.MerchantId, - MerchantName = m != null ? m.Name : null, - LineNumber = fl.LineNumber, - LineContents = fl.FileLineData, - LineStatus = fl.Status - }; - - var flatResult = await ExecuteQuerySafeToList(flatQuery, cancellationToken, "Error retrieving file import logs"); - - if (flatResult.IsFailed) - return ResultHelpers.CreateFailure(flatResult); - - var flatItems = flatResult.Data; - - // assemble hierarchical model - var fileImportLogs = flatItems - .GroupBy(x => new { x.FileImportLogId, x.ImportLogDateTime }) - .Select(g => new FileImportLog { - FileImportLogId = g.Key.FileImportLogId, - ImportLogDateTime = g.Key.ImportLogDateTime, - FileDetailsList = g.GroupBy(f => new { f.FileId, f.FileName, f.FileProfileId, f.DateTimeUploaded, f.UserId, f.UploadedBy, f.MerchantId, f.MerchantName }) - .Select(fg => new FileDetails { - FileId = fg.Key.FileId, - FileName = fg.Key.FileName, - FileProfile = fg.Key.FileProfileId != null ? fg.Key.FileProfileId.ToString() : null, - DateTimeUploaded = fg.Key.DateTimeUploaded, - UserId = fg.Key.UserId, - UploadedBy = fg.Key.UploadedBy, - MerchantId = fg.Key.MerchantId, - MerchantName = fg.Key.MerchantName, - FileLines = fg.Select(fl => new FileLine { - LineNumber = fl.LineNumber, - LineContents = fl.LineContents, - LineStatus = fl.LineStatus - }).ToList() - }).ToList() - }).ToList(); - - return Result.Success(fileImportLogs); - } - - public async Task> GetFileImportLog(FileImportLogQueries.GetFileImportLogQuery request, - CancellationToken cancellationToken) - { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - // Build flat projection of the required data then assemble into hierarchical models in-memory - var flatQuery = from fil in context.FileImportLogs - join f in context.Files on fil.FileImportLogId equals f.FileImportLogId - join esu in context.EstateSecurityUsers on f.UserId equals esu.SecurityUserId into esuJoin - from esu in esuJoin.DefaultIfEmpty() - join m in context.Merchants on f.MerchantId equals m.MerchantId into mJoin - from m in mJoin.DefaultIfEmpty() - join fl in context.FileLines on f.FileId equals fl.FileId - //join fp in context.FileProfileConfigurations on f.FileProfileId equals fp.FileProfileId - where fil.FileImportLogId == request.FileImportLogId - && (request.MerchantId == null || f.MerchantId == request.MerchantId) - select new FileImportFlatData - { - FileImportLogId = fil.FileImportLogId, - ImportLogDateTime = fil.ImportLogDateTime, - FileId = f.FileId, - FileName = f.FileLocation, - FileProfileId = f.FileProfileId, - DateTimeUploaded = f.FileReceivedDateTime, - UserId = f.UserId, - UploadedBy = esu != null ? esu.EmailAddress : null, - MerchantId = f.MerchantId, - MerchantName = m != null ? m.Name : null, - LineNumber = fl.LineNumber, - LineContents = fl.FileLineData, - LineStatus = fl.Status - }; - - var flatResult = await ExecuteQuerySafeToList(flatQuery, cancellationToken, "Error retrieving file import logs"); - - if (flatResult.IsFailed) - return ResultHelpers.CreateFailure(flatResult); - - var flatItems = flatResult.Data; - - if (flatItems.Count == 0) - return Result.NotFound(); - - var fileImportLogs = flatItems - .GroupBy(x => new { x.FileImportLogId, x.ImportLogDateTime }) - .Select(g => new FileImportLog - { - FileImportLogId = g.Key.FileImportLogId, - ImportLogDateTime = g.Key.ImportLogDateTime, - FileDetailsList = g.GroupBy(f => new { f.FileId, f.FileName, f.FileProfileId, f.DateTimeUploaded, f.UserId, f.UploadedBy, f.MerchantId, f.MerchantName }) - .Select(fg => new FileDetails - { - FileId = fg.Key.FileId, - FileName = fg.Key.FileName, - FileProfile = fg.Key.FileProfileId != null ? fg.Key.FileProfileId.ToString() : null, - DateTimeUploaded = fg.Key.DateTimeUploaded, - UserId = fg.Key.UserId, - UploadedBy = fg.Key.UploadedBy, - MerchantId = fg.Key.MerchantId, - MerchantName = fg.Key.MerchantName, - FileLines = fg.Select(fl => new FileLine - { - LineNumber = fl.LineNumber, - LineContents = fl.LineContents, - LineStatus = fl.LineStatus - }).ToList() - }).ToList() - }).SingleOrDefault(); - - return Result.Success(fileImportLogs); - } - - public async Task>> GetFileProfileConfigurationList(FileProfileConfigurationQueries.GetFileProfileConfigurationListQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - var query = from c in context.FileProfileConfigurations - join h in context.FileFormatHandlers on c.FileFormatHandlerId equals h.FileFormatHandlerId - join r in context.RequestTypes on c.RequestTypeId equals r.RequestTypeId - join o in context.Operators on c.OperatorId equals o.OperatorId - select new FileProfileConfiguration - { - FileProfileId = c.FileProfileId, - Name = c.Name, - ListeningDirectory = c.ListeningDirectory, - RequestType = r.Name, - OperatorName = o.Name, - LineTerminator = c.LineTerminator, - FileFormatHandler = h.Name - }; - - Result> result = await ExecuteQuerySafeToList(query, cancellationToken, "Error retrieving file import logs"); - - if (result.IsFailed) - return ResultHelpers.CreateFailure(result); - - return Result.Success(result.Data); - } - - public async Task> GetMerchantDailyPerformanceSummary(TransactionQueries.MerchantDailyPerformanceSummaryQuery request, - CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - DateTime startDate = request.Request.StartDate.Date; - DateTime endDate = request.Request.EndDate.Date; - int dayCount = Math.Max((endDate - startDate).Days + 1, 1); - - var groupedTransactionsResult = await LoadMerchantDailyPerformanceGroups(context, request, startDate, endDate, cancellationToken); - if (groupedTransactionsResult.IsFailed) - return ResultHelpers.CreateFailure(groupedTransactionsResult); - - var groupedTransactions = groupedTransactionsResult.Data; - if (groupedTransactions.Any() == false) { - return Result.Success(BuildEmptyMerchantDailyPerformanceSummaryResponse()); - } - - List metrics = BuildMerchantDailyPerformanceMetrics(groupedTransactions, dayCount); - - var recentSalesResult = await LoadMerchantDailyPerformanceRecentSales(context, request, startDate, endDate, cancellationToken); - if (recentSalesResult.IsFailed) - return ResultHelpers.CreateFailure(recentSalesResult); - - return Result.Success(new MerchantDailyPerformanceSummaryResponse { - Metrics = metrics, - DrillDownTransactions = MapMerchantDailyPerformanceRecentSales(recentSalesResult.Data) - }); - } - - private async Task GetSettlementSummary(IQueryable query, - CancellationToken cancellationToken) { - // Get the settleed fees summary - DatabaseProjections.SettlementGroupProjection summary = await BuildSettlementSummaryQuery(query).SingleOrDefaultAsync(cancellationToken); - - if (summary == null) - return new DatabaseProjections.SettlementGroupProjection(); - - return new DatabaseProjections.SettlementGroupProjection { SettledCount = summary.SettledCount, SettledValue = summary.SettledValue, UnSettledCount = summary.UnSettledCount, UnSettledValue = summary.UnSettledValue }; - } - - private async Task GetSettlementSummary( - IQueryable query, - CancellationToken cancellationToken) - { - // Get the settleed fees summary - DatabaseProjections.SettlementGroupProjection summary = await BuildSettlementSummaryQuery(query).SingleOrDefaultAsync(cancellationToken); - - if (summary == null) - return new DatabaseProjections.SettlementGroupProjection(); - - return new DatabaseProjections.SettlementGroupProjection - { - SettledCount = summary.SettledCount, - SettledValue = summary.SettledValue, - UnSettledCount = summary.UnSettledCount, - UnSettledValue = summary.UnSettledValue - }; - } - - private static IQueryable BuildSettlementSummaryQuery(IQueryable query) { - return query.GroupBy(_ => 1).Select(g => new DatabaseProjections.SettlementGroupProjection { SettledCount = g.Count(x => x.Fee.IsSettled), SettledValue = g.Where(x => x.Fee.IsSettled).Sum(x => x.Fee.CalculatedValue), UnSettledCount = g.Count(x => !x.Fee.IsSettled), UnSettledValue = g.Where(x => !x.Fee.IsSettled).Sum(x => x.Fee.CalculatedValue) }); - } - - private static IQueryable BuildSettlementSummaryQuery(IQueryable query) { - return query.GroupBy(_ => 1).Select(g => new DatabaseProjections.SettlementGroupProjection { SettledCount = g.Count(x => x.Fee.IsSettled), SettledValue = g.Where(x => x.Fee.IsSettled).Sum(x => x.Fee.CalculatedValue), UnSettledCount = g.Count(x => !x.Fee.IsSettled), UnSettledValue = g.Where(x => !x.Fee.IsSettled).Sum(x => x.Fee.CalculatedValue) }); - } - - private IQueryable BuildTodaySettlementQuery(EstateManagementContext context, - DateTime settlementDate) { - IQueryable settlementData = from s in context.Settlements join f in context.MerchantSettlementFees on s.SettlementId equals f.SettlementId join t in context.TodayTransactions on f.TransactionId equals t.TransactionId where s.SettlementDate == settlementDate select new DatabaseProjections.TodaySettlementTransactionProjection { Fee = f, Txn = t }; - return settlementData; - } - - private IQueryable BuildComparisonSettlementQuery(EstateManagementContext context, - DateTime settlementDate) { - IQueryable settlementData = from s in context.Settlements join f in context.MerchantSettlementFees on s.SettlementId equals f.SettlementId join t in context.TransactionHistory on f.TransactionId equals t.TransactionId where s.SettlementDate == settlementDate.Date select new DatabaseProjections.ComparisonSettlementTransactionProjection { Fee = f, Txn = t }; - return settlementData; - } - - private IQueryable BuildTodaySalesQuery(EstateManagementContext context) { - return from t in context.TodayTransactions where t.IsAuthorised && t.TransactionType == "Sale" && t.TransactionDate == DateTime.Now.Date && t.TransactionTime <= DateTime.Now.TimeOfDay select t; - } - - private IQueryable BuildTodaysFailedSalesQuery(EstateManagementContext context, - String responseCode) { - return from t in context.TodayTransactions - where t.IsAuthorised == false && t.TransactionType == "Sale" && t.ResponseCode == responseCode - select t.TransactionAmount; - } - - private IQueryable BuildComparisonSalesQuery(EstateManagementContext context, - DateTime comparisonDate) { - return from t in context.TransactionHistory where t.IsAuthorised && t.TransactionType == "Sale" && t.TransactionDate == comparisonDate && t.TransactionTime <= DateTime.Now.TimeOfDay select t; - } - - private IQueryable BuildComparisonFailedSalesQuery(EstateManagementContext context, - DateTime comparisonDate, - String responseCode) { - return from t in context.TransactionHistory - where t.IsAuthorised == false && t.TransactionType == "Sale" && t.TransactionDate == comparisonDate && t.TransactionTime <= DateTime.Now.TimeOfDay && t.ResponseCode == responseCode - select t.TransactionAmount; - } - - private static async Task>> LoadProductsAsync(EstateManagementContext context, - List contractIds, - CancellationToken cancellationToken, - string stepName) { - var query = context.ContractProducts.Where(cp => contractIds.Contains(cp.ContractId)).Select(cp => new ContractProductData { - ContractProductId = cp.ContractProductId, - ContractProductReportingId = cp.ContractProductReportingId, - ContractId = cp.ContractId, - DisplayText = cp.DisplayText, - ProductName = cp.ProductName, - ProductType = cp.ProductType, - Value = cp.Value - }); - return await ExecuteQuerySafeToList(query, cancellationToken, stepName); - } - - private static async Task>> LoadFeesAsync(EstateManagementContext context, - List productIds, - CancellationToken cancellationToken, - string stepName) { - var query = context.ContractProductTransactionFees.Where(tf => productIds.Contains(tf.ContractProductId)).Select(tf => new ContractFeeData { - ContractProductTransactionFeeId = tf.ContractProductTransactionFeeId, - ContractProducTransactionFeeReportingId = tf.ContractProductTransactionFeeReportingId, - ContractProductId = tf.ContractProductId, - Description = tf.Description, - CalculationType = tf.CalculationType, - FeeType = tf.FeeType, - Value = tf.Value, - IsEnabled = tf.IsEnabled - }); - return await ExecuteQuerySafeToList(query, cancellationToken, stepName); - } - - private static List BuildContracts(List baseContracts, - List products, - List fees) { - return baseContracts.Select(b => new Contract { - ContractId = b.ContractId, - ContractReportingId = b.ContractReportingId, - Description = b.Description, - EstateId = b.EstateId, - EstateReportingId = b.EstateReportingId, - OperatorName = b.OperatorName, - OperatorId = b.OperatorId, - OperatorReportingId = b.OperatorReportingId, - Products = products.Where(p => p.ContractId == b.ContractId).Select(p => new Models.ContractProduct { - ContractId = p.ContractId, - ProductId = p.ContractProductId, - ContractProductReportingId = p.ContractProductReportingId, - DisplayText = p.DisplayText, - ProductName = p.ProductName, - ProductType = p.ProductType, - Value = p.Value, - TransactionFees = fees.Where(f => f.ContractProductId == p.ContractProductId && f.IsEnabled).Select(f => new ContractProductTransactionFee { - Description = f.Description, - Value = f.Value, - CalculationType = f.CalculationType, - FeeType = f.FeeType, - TransactionFeeId = f.ContractProductTransactionFeeId, - ContractProductTransactionFeeReportingId = f.ContractProducTransactionFeeReportingId - }).ToList() - }).ToList() - }).ToList(); - } - - private sealed class MerchantTransactionGroupProjection { - public Guid MerchantId { get; init; } - public int MerchantReportingId { get; init; } - public string MerchantName { get; init; } - public Guid OperatorId { get; init; } - public int OperatorReportingId { get; init; } - public int TotalCount { get; init; } - public decimal TotalValue { get; init; } - public int AuthorisedCount { get; init; } - public int DeclinedCount { get; init; } - } - - private sealed class MerchantTransactionFinalProjection { - public Guid MerchantId { get; init; } - public int MerchantReportingId { get; init; } - public string MerchantName { get; init; } - public int TotalCount { get; init; } - public decimal TotalValue { get; init; } - public decimal AverageValue { get; init; } - public int AuthorisedCount { get; init; } - public int DeclinedCount { get; init; } - public decimal AuthorisedPercentage { get; init; } - } - - private sealed class MerchantDailyPerformanceGroupProjection { - public int ContractProductReportingId { get; init; } - public string? ProductName { get; init; } - public string OperatorName { get; init; } - public bool IsAuthorised { get; init; } - public int SalesCount { get; init; } - public decimal SalesValue { get; init; } - } - - private sealed class MerchantDailyPerformanceRecentSaleProjection { - public string Reference { get; init; } - public string? Product { get; init; } - public String Operator { get; init; } - public string Status { get; init; } - public decimal Amount { get; init; } - public DateTime TransactionDateTime { get; init; } - } - - private static MerchantDailyPerformanceSummaryResponse BuildEmptyMerchantDailyPerformanceSummaryResponse() { - return new MerchantDailyPerformanceSummaryResponse { - Metrics = BuildMerchantDailyPerformanceBaseMetrics() - }; - } - - private static List BuildMerchantDailyPerformanceBaseMetrics() { - return new List { - new() { Title = "Total Sales Count", Value = 0, Description = "All sales transactions in the range", Category = 1, Type = 0 }, - new() { Title = "Total Sales Value", Value = 0, Description = "All sales value in the range", Category = 1, Type = 1 }, - new() { Title = "Successful Sales Count", Value = 0, Description = "Authorised sales count in the range", Category = 2, Type = 2 }, - new() { Title = "Successful Sales Value", Value = 0, Description = "Authorised sales value in the range", Category = 2, Type = 3 }, - new() { Title = "Failed Sales Count", Value = 0, Description = "Declined sales count in the range", Category = 3, Type = 4 }, - new() { Title = "Failed Sales Value", Value = 0, Description = "Declined sales value in the range", Category = 3, Type = 5 }, - new() { Title = "Average Sales Count", Value = 0, Description = "Average sales count per day in the range", Category = 4, Type = 6 }, - new() { Title = "Average Sales Value", Value = 0, Description = "Average value per sale in the range", Category = 4, Type = 7 } - }; - } - - private static List BuildMerchantDailyPerformanceMetrics(List groupedTransactions, - int dayCount) { - int totalSalesCount = groupedTransactions.Sum(x => x.SalesCount); - decimal totalSalesValue = groupedTransactions.Sum(x => x.SalesValue); - int successfulSalesCount = groupedTransactions.Where(x => x.IsAuthorised).Sum(x => x.SalesCount); - decimal successfulSalesValue = groupedTransactions.Where(x => x.IsAuthorised).Sum(x => x.SalesValue); - int failedSalesCount = groupedTransactions.Where(x => !x.IsAuthorised).Sum(x => x.SalesCount); - decimal failedSalesValue = groupedTransactions.Where(x => !x.IsAuthorised).Sum(x => x.SalesValue); - decimal averageSalesCount = (decimal)totalSalesCount / dayCount; - decimal averageSalesValue = totalSalesCount == 0 ? 0m : totalSalesValue / totalSalesCount; - - List metrics = new() { - new() { Title = "Total Sales Count", Value = totalSalesCount, Description = "All sales transactions in the range", Category = 1, Type = 0}, - new() { Title = "Total Sales Value", Value = totalSalesValue, Description = "All sales value in the range", Category = 1, Type = 1 }, - new() { Title = "Successful Sales Count", Value = successfulSalesCount, Description = "Authorised sales count in the range", Category = 2, Type = 2 }, - new() { Title = "Successful Sales Value", Value = successfulSalesValue, Description = "Authorised sales value in the range", Category = 2, Type = 3 }, - new() { Title = "Failed Sales Count", Value = failedSalesCount, Description = "Declined sales count in the range", Category = 3, Type = 4 }, - new() { Title = "Failed Sales Value", Value = failedSalesValue, Description = "Declined sales value in the range", Category = 3, Type = 5 }, - new() { Title = "Average Sales Count", Value = averageSalesCount, Description = "Average sales count per day in the range", Category = 4, Type = 6 }, - new() { Title = "Average Sales Value", Value = averageSalesValue, Description = "Average value per sale in the range", Category = 4, Type = 7 } - }; - - MetricItem? topProductMetric = BuildTopProductMetric(groupedTransactions); - if (topProductMetric != null) - metrics.Add(topProductMetric); - - return metrics; - } - - private static MetricItem? BuildTopProductMetric(List groupedTransactions) { - var topProduct = groupedTransactions - .GroupBy(x => new { x.ContractProductReportingId, x.OperatorName, x.ProductName }) - .Select(g => new { - g.Key.ContractProductReportingId, - g.Key.ProductName, - g.Key.OperatorName, - SalesCount = g.Sum(x => x.SalesCount), - SalesValue = g.Sum(x => x.SalesValue) - }) - .OrderByDescending(x => x.SalesCount) - .ThenBy(x => x.ProductName) - .FirstOrDefault(); - - if (topProduct == null) - return null; - - return new MetricItem { - Title = "Top Product Sales Count", - Value = topProduct.SalesCount, - Description = string.IsNullOrWhiteSpace(topProduct.OperatorName) || string.IsNullOrWhiteSpace(topProduct.ProductName) - ? "Unknown product" - : $"{topProduct.OperatorName} {topProduct.ProductName}", - Category = 5, - Type = 8 - }; - } - - private async Task>> LoadMerchantDailyPerformanceGroups(EstateManagementContext context, - TransactionQueries.MerchantDailyPerformanceSummaryQuery request, - DateTime startDate, - DateTime endDate, - CancellationToken cancellationToken) { - var query = - from t in context.Transactions - join m in context.Merchants on t.MerchantId equals m.MerchantId - join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } - join op in context.Operators on t.OperatorId equals op.OperatorId into opJoin - from op in opJoin.DefaultIfEmpty() - where t.TransactionType == "Sale" - && t.TransactionDate >= startDate - && t.TransactionDate <= endDate - && m.MerchantReportingId == request.Request.MerchantReportingId - group t by new - { - cp.ContractProductReportingId, - OperatorName = op == null ? string.Empty : op.Name, - cp.ProductName, - t.IsAuthorised - } - into g - select new MerchantDailyPerformanceGroupProjection - { - ContractProductReportingId = g.Key.ContractProductReportingId, - ProductName = g.Key.ProductName, - OperatorName = g.Key.OperatorName, - IsAuthorised = g.Key.IsAuthorised, - SalesCount = g.Count(), - SalesValue = g.Sum(x => x.TransactionAmount) - }; - - return await ExecuteQuerySafeToList(query, cancellationToken, "Error retrieving merchant daily performance summary"); - } - - private async Task>> LoadMerchantDailyPerformanceRecentSales(EstateManagementContext context, - TransactionQueries.MerchantDailyPerformanceSummaryQuery request, - DateTime startDate, - DateTime endDate, - CancellationToken cancellationToken) { - var query = - (from t in context.Transactions - join m in context.Merchants on t.MerchantId equals m.MerchantId - join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } - join op in context.Operators on t.OperatorId equals op.OperatorId into opJoin - from op in opJoin.DefaultIfEmpty() - where t.TransactionType == "Sale" - && t.TransactionDate >= startDate - && t.TransactionDate <= endDate - && m.MerchantReportingId == request.Request.MerchantReportingId - orderby t.TransactionDateTime descending - select new MerchantDailyPerformanceRecentSaleProjection - { - Reference = t.TransactionNumber, - Product = cp.ProductName, - Operator = op == null ? string.Empty : op.Name, - Status = t.IsAuthorised ? "Successful" : "Failed", - Amount = t.TransactionAmount, - TransactionDateTime = t.TransactionDateTime - }).Take(5); - - return await ExecuteQuerySafeToList(query, cancellationToken, "Error retrieving merchant recent sales"); - } - - private static List MapMerchantDailyPerformanceRecentSales(List recentSales) { - return recentSales.Select(x => new DrillDownTransaction { - Reference = x.Reference, - Product = x.Product, - Operator = x.Operator, - Status = x.Status, - Amount = x.Amount, - TransactionDateTime = x.TransactionDateTime - }).ToList(); - } - - private sealed class RecentActivityReceiptQueryResult { - public DateTime TransactionDateTime { get; init; } - public int MerchantReportingId { get; init; } - public string Reference { get; init; } - public string? TransactionType { get; init; } - public string? Product { get; init; } - public string? Operator { get; init; } - public string? Status { get; init; } - public decimal Amount { get; init; } - public string? ReceiptReference { get; init; } - } - - private sealed class RecentActivityReceiptRequestParameters { - public DateTime ReportDate { get; init; } - public int? MerchantReportingId { get; init; } - public string? SearchText { get; init; } - public int PageNumber { get; init; } - public int PageSize { get; init; } - } - - private sealed class TransactionDetailQueryResult { - public Guid TransactionId { get; init; } - public DateTime TransactionDateTime { get; init; } - public Guid MerchantId { get; init; } - public int MerchantReportingId { get; init; } - public string? MerchantName { get; init; } - public Guid OperatorId { get; init; } - public int OperatorReportingId { get; init; } - public string? OperatorName { get; init; } - public string? ProductName { get; init; } - public Guid ContractProductId { get; init; } - public int ContractProductReportingId { get; init; } - public string? TransactionType { get; init; } - public string? Status { get; init; } - public decimal Value { get; init; } - public decimal FeeValue { get; init; } - public Guid SettlementId { get; init; } - public Int32 TransactionNumber { get; init; } - } - - private sealed class OperatorTransactionData { - public Guid MerchantId { get; init; } - public int MerchantReportingId { get; init; } - public string? MerchantName { get; init; } - public Guid OperatorId { get; init; } - public int OperatorReportingId { get; init; } - public string? OperatorName { get; init; } - public int TotalCount { get; init; } - public decimal TotalValue { get; init; } - public int AuthorisedCount { get; init; } - public int DeclinedCount { get; init; } - } - - private sealed class OperatorSummaryData { - public Guid OperatorId { get; init; } - public int OperatorReportingId { get; init; } - public string? OperatorName { get; init; } - public int TotalCount { get; init; } - public decimal TotalValue { get; init; } - public decimal AverageValue { get; init; } - public int AuthorisedCount { get; init; } - public int DeclinedCount { get; init; } - public decimal AuthorisedPercentage { get; init; } - } - - private sealed class ContractBaseData { - public Guid ContractId { get; init; } - public int ContractReportingId { get; init; } - public string? Description { get; init; } - public Guid EstateId { get; init; } - public int EstateReportingId { get; init; } - public Guid OperatorId { get; init; } - public int OperatorReportingId { get; init; } - public string? OperatorName { get; init; } - } - - private sealed class ContractProductData { - public Guid ContractProductId { get; init; } - public int ContractProductReportingId { get; init; } - public Guid ContractId { get; init; } - public string? DisplayText { get; init; } - public string? ProductName { get; init; } - public int ProductType { get; init; } - public decimal? Value { get; init; } - } - - private sealed class ContractFeeData { - public Guid ContractProductTransactionFeeId { get; init; } - public int ContractProducTransactionFeeReportingId { get; init; } - public Guid ContractProductId { get; init; } - public string? Description { get; init; } - public int CalculationType { get; init; } - public int FeeType { get; init; } - public decimal Value { get; init; } - public bool IsEnabled { get; init; } - } - - private sealed class ProductPerformanceItemData { - public string? ProductName { get; init; } - public Guid ContractProductId { get; init; } - public int ContractProductReportingId { get; init; } - public Guid ContractId { get; init; } - public int ContractReportingId { get; init; } - public int TransactionCount { get; init; } - public decimal TotalAmount { get; init; } - public decimal PercentOfTotalAmount { get; init; } - } - - private sealed class FileImportFlatData { - public Guid FileImportLogId { get; init; } - public DateTime ImportLogDateTime { get; init; } - public Guid FileId { get; init; } - public string? FileName { get; init; } - public Guid? FileProfileId { get; init; } - public DateTime DateTimeUploaded { get; init; } - public Guid UserId { get; init; } - public string? UploadedBy { get; init; } - public Guid MerchantId { get; init; } - public string? MerchantName { get; init; } - public int LineNumber { get; init; } - public string? LineContents { get; init; } - public string? LineStatus { get; init; } - } -} diff --git a/EstateReportingAPI.BusinessLogic/ReportingManagerExtensions.cs b/EstateReportingAPI.BusinessLogic/ReportingManagerExtensions.cs index e23bfaf..886a7cb 100644 --- a/EstateReportingAPI.BusinessLogic/ReportingManagerExtensions.cs +++ b/EstateReportingAPI.BusinessLogic/ReportingManagerExtensions.cs @@ -4,7 +4,7 @@ namespace EstateReportingAPI.BusinessLogic; -public static class ReportingManagerExtensions{ +public static class ReportingQueryExtensions{ public static IQueryable ApplyMerchantFilter(this IQueryable query, List merchantReportingIds) { if (merchantReportingIds == null || merchantReportingIds.Count == 0) @@ -359,4 +359,4 @@ public static IQueryable ApplyPagination(this IQueryable records, _ => records }; } -} \ No newline at end of file +} diff --git a/EstateReportingAPI.BusinessLogic/ReportingQueryExecutor.cs b/EstateReportingAPI.BusinessLogic/ReportingQueryExecutor.cs new file mode 100644 index 0000000..b201c18 --- /dev/null +++ b/EstateReportingAPI.BusinessLogic/ReportingQueryExecutor.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.DynamicLinq; +using SimpleResults; + +namespace EstateReportingAPI.BusinessLogic; + +public static class ReportingQueryExecutor +{ + public static async Task> SumAsync(IQueryable query, + CancellationToken cancellationToken, + string? contextMessage = null) + { + try + { + T item = await query.SumAsync(cancellationToken); + return Result.Success(item); + } + catch (Exception ex) + { + return Result.Failure(BuildErrorMessage(ex, contextMessage)); + } + } + + public static async Task>> ToListAsync(IQueryable query, + CancellationToken cancellationToken, + string? contextMessage = null) + { + try + { + List items = await query.ToListAsync(cancellationToken); + return Result.Success(items); + } + catch (Exception ex) + { + return Result.Failure(BuildErrorMessage(ex, contextMessage)); + } + } + + public static async Task> CountAsync(IQueryable query, + CancellationToken cancellationToken, + string? contextMessage = null) + { + try + { + int count = await query.CountAsync(cancellationToken); + return Result.Success(count); + } + catch (Exception ex) + { + return Result.Failure(BuildErrorMessage(ex, contextMessage)); + } + } + + public static async Task> SingleOrDefaultAsync(IQueryable query, + CancellationToken cancellationToken, + string? contextMessage = null) + { + try + { + T item = await query.SingleOrDefaultAsync(cancellationToken); + + if (EqualityComparer.Default.Equals(item, default)) + return Result.NotFound(contextMessage); + + return Result.Success(item); + } + catch (Exception ex) + { + return Result.Failure(BuildErrorMessage(ex, contextMessage)); + } + } + + private static string BuildErrorMessage(Exception exception, string? contextMessage) + { + return contextMessage == null + ? $"Error executing query: {exception.Message}" + : $"{contextMessage}: {exception.Message}"; + } +} diff --git a/EstateReportingAPI.BusinessLogic/RequestHandlers/CalendarRequestHandler.cs b/EstateReportingAPI.BusinessLogic/RequestHandlers/CalendarRequestHandler.cs index ba2b226..af7ebe6 100644 --- a/EstateReportingAPI.BusinessLogic/RequestHandlers/CalendarRequestHandler.cs +++ b/EstateReportingAPI.BusinessLogic/RequestHandlers/CalendarRequestHandler.cs @@ -1,4 +1,5 @@ using EstateReportingAPI.BusinessLogic.Queries; +using EstateReportingAPI.BusinessLogic.Services; using EstateReportingAPI.Models; using MediatR; using Shared.Results; @@ -8,18 +9,18 @@ namespace EstateReportingAPI.BusinessLogic.RequestHandlers; public class CalendarRequestHandler : IRequestHandler>>, IRequestHandler>>, IRequestHandler>> { - private readonly IReportingManager Manager; - public CalendarRequestHandler(IReportingManager manager) { - this.Manager = manager; + private readonly IEstateReportingService Service; + public CalendarRequestHandler(IEstateReportingService service) { + this.Service = service; } public async Task>> Handle(CalendarQueries.GetAllDatesQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetCalendarDates(request, cancellationToken); + return await this.Service.GetCalendarDates(request, cancellationToken); } public async Task>> Handle(CalendarQueries.GetComparisonDatesQuery request, CancellationToken cancellationToken) { - Result> result = await this.Manager.GetCalendarComparisonDates(request, cancellationToken); + Result> result = await this.Service.GetCalendarComparisonDates(request, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -29,6 +30,6 @@ public async Task>> Handle(CalendarQueries.GetComparisonDa public async Task>> Handle(CalendarQueries.GetYearsQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetCalendarYears(request, cancellationToken); + return await this.Service.GetCalendarYears(request, cancellationToken); } - } \ No newline at end of file + } diff --git a/EstateReportingAPI.BusinessLogic/RequestHandlers/ContractRequestHandler.cs b/EstateReportingAPI.BusinessLogic/RequestHandlers/ContractRequestHandler.cs index d763de1..cecdd5d 100644 --- a/EstateReportingAPI.BusinessLogic/RequestHandlers/ContractRequestHandler.cs +++ b/EstateReportingAPI.BusinessLogic/RequestHandlers/ContractRequestHandler.cs @@ -1,4 +1,5 @@ using EstateReportingAPI.BusinessLogic.Queries; +using EstateReportingAPI.BusinessLogic.Services; using EstateReportingAPI.Models; using MediatR; using SimpleResults; @@ -10,27 +11,27 @@ public class ContractRequestHandler : IRequestHandler> { - private readonly IReportingManager Manager; + private readonly IMerchantReportingService ReportingService; - public ContractRequestHandler(IReportingManager manager) + public ContractRequestHandler(IMerchantReportingService reportingService) { - this.Manager = manager; + this.ReportingService = reportingService; } public async Task>> Handle(ContractQueries.GetRecentContractsQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetRecentContracts(request, cancellationToken); + return await this.ReportingService.GetRecentContracts(request, cancellationToken); } public async Task>> Handle(ContractQueries.GetContractsQuery request, CancellationToken cancellationToken) { - var result = await this.Manager.GetContracts(request, cancellationToken); + var result = await this.ReportingService.GetContracts(request, cancellationToken); return result; } public async Task> Handle(ContractQueries.GetContractQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetContract(request, cancellationToken); + return await this.ReportingService.GetContract(request, cancellationToken); } -} \ No newline at end of file +} diff --git a/EstateReportingAPI.BusinessLogic/RequestHandlers/EstateRequestHandler.cs b/EstateReportingAPI.BusinessLogic/RequestHandlers/EstateRequestHandler.cs index 44437c1..5815a92 100644 --- a/EstateReportingAPI.BusinessLogic/RequestHandlers/EstateRequestHandler.cs +++ b/EstateReportingAPI.BusinessLogic/RequestHandlers/EstateRequestHandler.cs @@ -1,4 +1,5 @@ using EstateReportingAPI.BusinessLogic.Queries; +using EstateReportingAPI.BusinessLogic.Services; using EstateReportingAPI.Models; using MediatR; using SimpleResults; @@ -9,20 +10,20 @@ public class EstateRequestHandler : IRequestHandler>> { - private readonly IReportingManager Manager; + private readonly IEstateReportingService Service; - public EstateRequestHandler(IReportingManager manager) + public EstateRequestHandler(IEstateReportingService service) { - this.Manager = manager; + this.Service = service; } public async Task> Handle(EstateQueries.GetEstateQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetEstate(request, cancellationToken); + return await this.Service.GetEstate(request, cancellationToken); } public async Task>> Handle(EstateQueries.GetEstateOperatorsQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetEstateOperators(request, cancellationToken); + return await this.Service.GetEstateOperators(request, cancellationToken); } -} \ No newline at end of file +} diff --git a/EstateReportingAPI.BusinessLogic/RequestHandlers/FileImportLogRequestHandler.cs b/EstateReportingAPI.BusinessLogic/RequestHandlers/FileImportLogRequestHandler.cs index 68d2a53..a5f4e39 100644 --- a/EstateReportingAPI.BusinessLogic/RequestHandlers/FileImportLogRequestHandler.cs +++ b/EstateReportingAPI.BusinessLogic/RequestHandlers/FileImportLogRequestHandler.cs @@ -1,4 +1,5 @@ using EstateReportingAPI.BusinessLogic.Queries; +using EstateReportingAPI.BusinessLogic.Services; using EstateReportingAPI.Models; using MediatR; using SimpleResults; @@ -7,36 +8,36 @@ namespace EstateReportingAPI.BusinessLogic.RequestHandlers; public class FileImportLogRequestHandler : IRequestHandler>>, IRequestHandler> { - private readonly IReportingManager Manager; - public FileImportLogRequestHandler(IReportingManager manager) + private readonly IFileImportReportingService Service; + public FileImportLogRequestHandler(IFileImportReportingService service) { - this.Manager = manager; + this.Service = service; } public async Task>> Handle(FileImportLogQueries.GetFileImportLogListQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetFileImportLogList(request, cancellationToken); + return await this.Service.GetFileImportLogList(request, cancellationToken); } public async Task> Handle(FileImportLogQueries.GetFileImportLogQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetFileImportLog(request, cancellationToken); + return await this.Service.GetFileImportLog(request, cancellationToken); } } public class FileProfileConfigurationRequestHandler : IRequestHandler>> { - private readonly IReportingManager Manager; - public FileProfileConfigurationRequestHandler(IReportingManager manager) + private readonly IFileImportReportingService Service; + public FileProfileConfigurationRequestHandler(IFileImportReportingService service) { - this.Manager = manager; + this.Service = service; } public async Task>> Handle(FileProfileConfigurationQueries.GetFileProfileConfigurationListQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetFileProfileConfigurationList(request, cancellationToken); + return await this.Service.GetFileProfileConfigurationList(request, cancellationToken); } -} \ No newline at end of file +} diff --git a/EstateReportingAPI.BusinessLogic/RequestHandlers/MerchantRequestHandler.cs b/EstateReportingAPI.BusinessLogic/RequestHandlers/MerchantRequestHandler.cs index 13607e0..0935ee6 100644 --- a/EstateReportingAPI.BusinessLogic/RequestHandlers/MerchantRequestHandler.cs +++ b/EstateReportingAPI.BusinessLogic/RequestHandlers/MerchantRequestHandler.cs @@ -1,4 +1,5 @@ using EstateReportingAPI.BusinessLogic.Queries; +using EstateReportingAPI.BusinessLogic.Services; using EstateReportingAPI.Models; using MediatR; using SimpleResults; @@ -15,54 +16,54 @@ public class MerchantRequestHandler : IRequestHandler>>, IRequestHandler> { - private readonly IReportingManager Manager; - public MerchantRequestHandler(IReportingManager manager) + private readonly IMerchantReportingService ReportingService; + public MerchantRequestHandler(IMerchantReportingService reportingService) { - this.Manager = manager; + this.ReportingService = reportingService; } public async Task>> Handle(MerchantQueries.GetRecentMerchantsQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetRecentMerchants(request, cancellationToken); + return await this.ReportingService.GetRecentMerchants(request, cancellationToken); } public async Task> Handle(MerchantQueries.GetTransactionKpisQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetMerchantsTransactionKpis(request, cancellationToken); + return await this.ReportingService.GetMerchantsTransactionKpis(request, cancellationToken); } public async Task>> Handle(MerchantQueries.GetMerchantsQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetMerchants(request, cancellationToken); + return await this.ReportingService.GetMerchants(request, cancellationToken); } public async Task> Handle(MerchantQueries.GetMerchantQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetMerchant(request, cancellationToken); + return await this.ReportingService.GetMerchant(request, cancellationToken); } public async Task>> Handle(MerchantQueries.GetMerchantContractsQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetMerchantContracts(request, cancellationToken); + return await this.ReportingService.GetMerchantContracts(request, cancellationToken); } public async Task>> Handle(MerchantQueries.GetMerchantOperatorsQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetMerchantOperators(request, cancellationToken); + return await this.ReportingService.GetMerchantOperators(request, cancellationToken); } public async Task>> Handle(MerchantQueries.GetMerchantDevicesQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetMerchantDevices(request, cancellationToken); + return await this.ReportingService.GetMerchantDevices(request, cancellationToken); } public async Task>> Handle(MerchantQueries.GetMerchantOpeningHoursQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetMerchantOpeningHours(request, cancellationToken); + return await this.ReportingService.GetMerchantOpeningHours(request, cancellationToken); } public async Task> Handle(MerchantQueries.GetMerchantScheduleQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetMerchantSchedule(request, cancellationToken); + return await this.ReportingService.GetMerchantSchedule(request, cancellationToken); } -} \ No newline at end of file +} diff --git a/EstateReportingAPI.BusinessLogic/RequestHandlers/OperatorRequestHandler.cs b/EstateReportingAPI.BusinessLogic/RequestHandlers/OperatorRequestHandler.cs index 41384c7..3fb8a8d 100644 --- a/EstateReportingAPI.BusinessLogic/RequestHandlers/OperatorRequestHandler.cs +++ b/EstateReportingAPI.BusinessLogic/RequestHandlers/OperatorRequestHandler.cs @@ -1,4 +1,5 @@ using EstateReportingAPI.BusinessLogic.Queries; +using EstateReportingAPI.BusinessLogic.Services; using EstateReportingAPI.Models; using MediatR; using SimpleResults; @@ -9,19 +10,19 @@ public class OperatorRequestHandler : IRequestHandler> { - private readonly IReportingManager Manager; + private readonly IMerchantReportingService ReportingService; - public OperatorRequestHandler(IReportingManager manager) { - this.Manager = manager; + public OperatorRequestHandler(IMerchantReportingService reportingService) { + this.ReportingService = reportingService; } public async Task>> Handle(OperatorQueries.GetOperatorsQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetOperators(request, cancellationToken); + return await this.ReportingService.GetOperators(request, cancellationToken); } public async Task> Handle(OperatorQueries.GetOperatorQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetOperator(request, cancellationToken); + return await this.ReportingService.GetOperator(request, cancellationToken); } -} \ No newline at end of file +} diff --git a/EstateReportingAPI.BusinessLogic/RequestHandlers/SettlementRequestHandler.cs b/EstateReportingAPI.BusinessLogic/RequestHandlers/SettlementRequestHandler.cs index 2bd87d6..1c0e0f7 100644 --- a/EstateReportingAPI.BusinessLogic/RequestHandlers/SettlementRequestHandler.cs +++ b/EstateReportingAPI.BusinessLogic/RequestHandlers/SettlementRequestHandler.cs @@ -1,4 +1,5 @@ using EstateReportingAPI.BusinessLogic.Queries; +using EstateReportingAPI.BusinessLogic.Services; using EstateReportingAPI.Models; using MediatR; using SimpleResults; @@ -7,12 +8,12 @@ namespace EstateReportingAPI.BusinessLogic.RequestHandlers; public class SettlementRequestHandler : IRequestHandler> { - private readonly IReportingManager Manager; - public SettlementRequestHandler(IReportingManager manager) { - this.Manager = manager; + private readonly ISettlementReportingService Service; + public SettlementRequestHandler(ISettlementReportingService service) { + this.Service = service; } public async Task> Handle(SettlementQueries.TodaysSettlementQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetTodaysSettlement(request, cancellationToken); + return await this.Service.GetTodaysSettlement(request, cancellationToken); } -} \ No newline at end of file +} diff --git a/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs b/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs index c5ac3f8..c455a0e 100644 --- a/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs +++ b/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs @@ -1,4 +1,5 @@ using EstateReportingAPI.BusinessLogic.Queries; +using EstateReportingAPI.BusinessLogic.Services; using EstateReportingAPI.Models; using MediatR; using SimpleResults; @@ -17,63 +18,63 @@ public class TransactionRequestHandler : IRequestHandler> { - private readonly IReportingManager Manager; + private readonly ITransactionReportingService Service; - public TransactionRequestHandler(IReportingManager manager) { - this.Manager = manager; + public TransactionRequestHandler(ITransactionReportingService service) { + this.Service = service; } public async Task> Handle(TransactionQueries.TodaysFailedSales request, CancellationToken cancellationToken) { - return await this.Manager.GetTodaysFailedSales(request, cancellationToken); + return await this.Service.GetTodaysFailedSales(request, cancellationToken); } public async Task> Handle(TransactionQueries.TodaysSalesQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetTodaysSales(request, cancellationToken); + return await this.Service.GetTodaysSales(request, cancellationToken); } public async Task> Handle(TransactionQueries.TransactionDetailReportQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetTransactionDetailReport(request, cancellationToken); + return await this.Service.GetTransactionDetailReport(request, cancellationToken); } public async Task> Handle(TransactionQueries.TransactionSummaryByMerchantQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetTransactionSummaryByMerchantReport(request, cancellationToken); + return await this.Service.GetTransactionSummaryByMerchantReport(request, cancellationToken); } public async Task> Handle(TransactionQueries.TransactionSummaryByOperatorQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetTransactionSummaryByOperatorReport(request, cancellationToken); + return await this.Service.GetTransactionSummaryByOperatorReport(request, cancellationToken); } public async Task> Handle(TransactionQueries.ProductPerformanceQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetProductPerformanceReport(request, cancellationToken); + return await this.Service.GetProductPerformanceReport(request, cancellationToken); } public async Task> Handle(TransactionQueries.TransactionMixSummaryQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetTransactionMixSummary(request, cancellationToken); + return await this.Service.GetTransactionMixSummary(request, cancellationToken); } public async Task> Handle(TransactionQueries.GetRecentActivityReceiptReportQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetRecentActivityReceiptReport(request, cancellationToken); + return await this.Service.GetRecentActivityReceiptReport(request, cancellationToken); } public async Task>> Handle(TransactionQueries.TodaysSalesByHour request, CancellationToken cancellationToken) { - return await this.Manager.GetTodaysSalesByHour(request, cancellationToken); + return await this.Service.GetTodaysSalesByHour(request, cancellationToken); } public async Task> Handle(TransactionQueries.MerchantDailyPerformanceSummaryQuery request, CancellationToken cancellationToken) { - return await this.Manager.GetMerchantDailyPerformanceSummary(request, cancellationToken); + return await this.Service.GetMerchantDailyPerformanceSummary(request, cancellationToken); } } diff --git a/EstateReportingAPI.BusinessLogic/Services/EstateReportingService.cs b/EstateReportingAPI.BusinessLogic/Services/EstateReportingService.cs new file mode 100644 index 0000000..35fe469 --- /dev/null +++ b/EstateReportingAPI.BusinessLogic/Services/EstateReportingService.cs @@ -0,0 +1,184 @@ +using EstateReportingAPI.BusinessLogic.Queries; +using EstateReportingAPI.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.DynamicLinq; +using SimpleResults; +using TransactionProcessor.Database.Contexts; +using Shared.EntityFramework; +using Shared.Results; + +namespace EstateReportingAPI.BusinessLogic.Services; + +public interface IEstateReportingService +{ + Task>> GetCalendarComparisonDates(CalendarQueries.GetComparisonDatesQuery request, CancellationToken cancellationToken); + Task>> GetCalendarDates(CalendarQueries.GetAllDatesQuery request, CancellationToken cancellationToken); + Task>> GetCalendarYears(CalendarQueries.GetYearsQuery request, CancellationToken cancellationToken); + Task>> GetEstateOperators(EstateQueries.GetEstateOperatorsQuery request, CancellationToken cancellationToken); + Task> GetEstate(EstateQueries.GetEstateQuery request, CancellationToken cancellationToken); +} +public sealed class EstateReportingService : IEstateReportingService +{ + private readonly IDbContextResolver Resolver; + private const string EstateManagementDatabaseName = "TransactionProcessorReadModel"; + + public EstateReportingService(IDbContextResolver resolver) + { + Resolver = resolver; + } + + public async Task>> GetCalendarComparisonDates(CalendarQueries.GetComparisonDatesQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + DateTime today = DateTime.Today; + DateTime startDate = today.AddYears(-1); + DateTime endDate = today.AddDays(-1); // yesterday + + var result = await ReportingQueryExecutor.ToListAsync(context.Calendar.Where(c => c.Date >= startDate && c.Date < endDate).OrderByDescending(c => c.Date), cancellationToken, "Error retrieving calendar comparison dates"); + + if (result.IsFailed) + return ResultHelpers.CreateFailure(result); + + var entities = result.Data; + + if (entities.Any() == false) + return Result.NotFound("No calendar dates found"); + + List response = new(); + foreach (TransactionProcessor.Database.Entities.Calendar calendar in entities) + response.Add(new Calendar { + Date = calendar.Date, + DayOfWeek = calendar.DayOfWeek, + Year = calendar.Year, + DayOfWeekNumber = calendar.DayOfWeekNumber, + DayOfWeekShort = calendar.DayOfWeekShort, + MonthNameLong = calendar.MonthNameLong, + MonthNameShort = calendar.MonthNameShort, + MonthNumber = calendar.MonthNumber, + WeekNumber = calendar.WeekNumber, + WeekNumberString = calendar.WeekNumberString, + YearWeekNumber = calendar.YearWeekNumber + }); + + return Result.Success(response); + } + + public async Task>> GetCalendarDates(CalendarQueries.GetAllDatesQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + + var result = await ReportingQueryExecutor.ToListAsync(context.Calendar.Where(c => c.Date <= DateTime.Now.Date), cancellationToken, "Error retrieving calendar dates"); + + if (result.IsFailed) + return ResultHelpers.CreateFailure(result); + + var entities = result.Data; + + List response = new(); + foreach (TransactionProcessor.Database.Entities.Calendar calendar in entities) + response.Add(new Calendar { + Date = calendar.Date, + DayOfWeek = calendar.DayOfWeek, + Year = calendar.Year, + DayOfWeekNumber = calendar.DayOfWeekNumber, + DayOfWeekShort = calendar.DayOfWeekShort, + MonthNameLong = calendar.MonthNameLong, + MonthNameShort = calendar.MonthNameShort, + MonthNumber = calendar.MonthNumber, + WeekNumber = calendar.WeekNumber, + WeekNumberString = calendar.WeekNumberString, + YearWeekNumber = calendar.YearWeekNumber + }); + + return Result.Success(response); + } + + public async Task>> GetCalendarYears(CalendarQueries.GetYearsQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var result = await ReportingQueryExecutor.ToListAsync(context.Calendar.Where(c => c.Date <= DateTime.Now.Date).GroupBy(c => c.Year).Select(y => y.Key), cancellationToken, "Error retrieving calendar years"); + + if (result.IsFailed) + return ResultHelpers.CreateFailure(result); + + return result; + } + + public async Task>> GetEstateOperators(EstateQueries.GetEstateOperatorsQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + + + var operatorQuery = context.EstateOperators.Join(context.Operators, eo => new { eo.OperatorId, eo.EstateId }, op => new { op.OperatorId, op.EstateId }, (eo, + op) => new { EstateOperator = eo, Operator = op }).Where(e => e.EstateOperator.EstateId == request.EstateId && (e.EstateOperator.IsDeleted ?? false) == false); + + var operatorQueryResult = await ReportingQueryExecutor.ToListAsync(operatorQuery, cancellationToken, "Error retrieving estate operators"); + if (operatorQueryResult.IsFailed) + return ResultHelpers.CreateFailure(operatorQueryResult); + + var operatorEntities = operatorQueryResult.Data; + List operators = new(); + + foreach (var operatorEntity in operatorEntities) { + operators.Add(new EstateOperator() { Name = operatorEntity.Operator.Name, OperatorId = operatorEntity.EstateOperator.OperatorId }); + } + + return Result.Success(operators); + } + + public async Task> GetEstate(EstateQueries.GetEstateQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var estateQueryResult = await ReportingQueryExecutor.SingleOrDefaultAsync(context.Estates.Where(e => e.EstateId == request.EstateId), cancellationToken, "Error retrieving estate"); + + if (estateQueryResult.IsFailed) + return ResultHelpers.CreateFailure(estateQueryResult); + var estate = estateQueryResult.Data; + + // Operators + var operatorsListQueryResult = await ReportingQueryExecutor.ToListAsync(context.Operators.Where(e => e.EstateId == request.EstateId), cancellationToken, "Error retrieving estate operators"); + if (operatorsListQueryResult.IsFailed) + return ResultHelpers.CreateFailure(operatorsListQueryResult); + var operators = operatorsListQueryResult.Data; + + // Users + var usersListQueryResult = await ReportingQueryExecutor.ToListAsync(context.EstateSecurityUsers.Where(e => e.EstateId == request.EstateId), cancellationToken, "Error retrieving estate users"); + if (usersListQueryResult.IsFailed) + return ResultHelpers.CreateFailure(usersListQueryResult); + var users = usersListQueryResult.Data; + + // Merchants + var merchantsListQueryResult = await ReportingQueryExecutor.ToListAsync(context.Merchants.Where(e => e.EstateId == request.EstateId), cancellationToken, "Error retrieving merchants"); + if (merchantsListQueryResult.IsFailed) + return ResultHelpers.CreateFailure(merchantsListQueryResult); + var merchants = merchantsListQueryResult.Data; + + // Contracts + var contractsListQueryResult = await ReportingQueryExecutor.ToListAsync(context.Contracts.Where(e => e.EstateId == request.EstateId), cancellationToken, "Error retrieving contracts"); + if (contractsListQueryResult.IsFailed) + return ResultHelpers.CreateFailure(contractsListQueryResult); + var contracts = contractsListQueryResult.Data; + + Estate result = new() { + EstateId = estate.EstateId, + EstateName = estate.Name, + Reference = estate.Reference, + Operators = operators.Select(o => new Models.EstateOperator { OperatorId = o.OperatorId, Name = o.Name, }).ToList(), + Users = users.Select(u => new Models.EstateUser { UserId = u.SecurityUserId, EmailAddress = u.EmailAddress, CreatedDateTime = u.CreatedDateTime }).ToList(), + Merchants = merchants.Select(m => new Models.EstateMerchant { MerchantId = m.MerchantId, Name = m.Name, Reference = m.Reference }).ToList(), + Contracts = contracts.Select(c => new Models.EstateContract { ContractId = c.ContractId, Name = c.Description, }).ToList() + }; + + return result; +} +} diff --git a/EstateReportingAPI.BusinessLogic/Services/FileImportReportingService.cs b/EstateReportingAPI.BusinessLogic/Services/FileImportReportingService.cs new file mode 100644 index 0000000..b5adc47 --- /dev/null +++ b/EstateReportingAPI.BusinessLogic/Services/FileImportReportingService.cs @@ -0,0 +1,202 @@ +using EstateReportingAPI.BusinessLogic.Queries; +using EstateReportingAPI.Models; +using Microsoft.EntityFrameworkCore; +using SimpleResults; +using TransactionProcessor.Database.Contexts; +using Shared.EntityFramework; +using Shared.Results; + +namespace EstateReportingAPI.BusinessLogic.Services; + +public interface IFileImportReportingService +{ + Task>> GetFileImportLogList(FileImportLogQueries.GetFileImportLogListQuery request, CancellationToken cancellationToken); + Task> GetFileImportLog(FileImportLogQueries.GetFileImportLogQuery request, CancellationToken cancellationToken); + Task>> GetFileProfileConfigurationList(FileProfileConfigurationQueries.GetFileProfileConfigurationListQuery request, CancellationToken cancellationToken); +} + +public sealed class FileImportReportingService : IFileImportReportingService +{ + private readonly IDbContextResolver Resolver; + private const string EstateManagementDatabaseName = "TransactionProcessorReadModel"; + public FileImportReportingService(IDbContextResolver resolver) => Resolver = resolver; + + public async Task>> GetFileImportLogList(FileImportLogQueries.GetFileImportLogListQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + // Build flat projection of the required data then assemble into hierarchical models in-memory + var flatQuery = from fil in context.FileImportLogs + join f in context.Files on fil.FileImportLogId equals f.FileImportLogId + join esu in context.EstateSecurityUsers on f.UserId equals esu.SecurityUserId into esuJoin + from esu in esuJoin.DefaultIfEmpty() + join m in context.Merchants on f.MerchantId equals m.MerchantId into mJoin + from m in mJoin.DefaultIfEmpty() + join fl in context.FileLines on f.FileId equals fl.FileId + where fil.ImportLogDate >= request.StartDate && fil.ImportLogDate <= request.EndDate + && (request.MerchantId == null || f.MerchantId == request.MerchantId) + select new FileImportFlatData { + FileImportLogId = fil.FileImportLogId, + ImportLogDateTime = fil.ImportLogDateTime, + FileId = f.FileId, + FileName = f.FileLocation, + FileProfileId = f.FileProfileId, + DateTimeUploaded = f.FileReceivedDateTime, + UserId = f.UserId, + UploadedBy = esu != null ? esu.EmailAddress : null, + MerchantId = f.MerchantId, + MerchantName = m != null ? m.Name : null, + LineNumber = fl.LineNumber, + LineContents = fl.FileLineData, + LineStatus = fl.Status + }; + + var flatResult = await ReportingQueryExecutor.ToListAsync(flatQuery, cancellationToken, "Error retrieving file import logs"); + + if (flatResult.IsFailed) + return ResultHelpers.CreateFailure(flatResult); + + var flatItems = flatResult.Data; + + // assemble hierarchical model + var fileImportLogs = flatItems + .GroupBy(x => new { x.FileImportLogId, x.ImportLogDateTime }) + .Select(g => new FileImportLog { + FileImportLogId = g.Key.FileImportLogId, + ImportLogDateTime = g.Key.ImportLogDateTime, + FileDetailsList = g.GroupBy(f => new { f.FileId, f.FileName, f.FileProfileId, f.DateTimeUploaded, f.UserId, f.UploadedBy, f.MerchantId, f.MerchantName }) + .Select(fg => new FileDetails { + FileId = fg.Key.FileId, + FileName = fg.Key.FileName, + FileProfile = fg.Key.FileProfileId != null ? fg.Key.FileProfileId.ToString() : null, + DateTimeUploaded = fg.Key.DateTimeUploaded, + UserId = fg.Key.UserId, + UploadedBy = fg.Key.UploadedBy, + MerchantId = fg.Key.MerchantId, + MerchantName = fg.Key.MerchantName, + FileLines = fg.Select(fl => new FileLine { + LineNumber = fl.LineNumber, + LineContents = fl.LineContents, + LineStatus = fl.LineStatus + }).ToList() + }).ToList() + }).ToList(); + + return Result.Success(fileImportLogs); + } + + public async Task> GetFileImportLog(FileImportLogQueries.GetFileImportLogQuery request, + CancellationToken cancellationToken) + { + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + // Build flat projection of the required data then assemble into hierarchical models in-memory + var flatQuery = from fil in context.FileImportLogs + join f in context.Files on fil.FileImportLogId equals f.FileImportLogId + join esu in context.EstateSecurityUsers on f.UserId equals esu.SecurityUserId into esuJoin + from esu in esuJoin.DefaultIfEmpty() + join m in context.Merchants on f.MerchantId equals m.MerchantId into mJoin + from m in mJoin.DefaultIfEmpty() + join fl in context.FileLines on f.FileId equals fl.FileId + //join fp in context.FileProfileConfigurations on f.FileProfileId equals fp.FileProfileId + where fil.FileImportLogId == request.FileImportLogId + && (request.MerchantId == null || f.MerchantId == request.MerchantId) + select new FileImportFlatData + { + FileImportLogId = fil.FileImportLogId, + ImportLogDateTime = fil.ImportLogDateTime, + FileId = f.FileId, + FileName = f.FileLocation, + FileProfileId = f.FileProfileId, + DateTimeUploaded = f.FileReceivedDateTime, + UserId = f.UserId, + UploadedBy = esu != null ? esu.EmailAddress : null, + MerchantId = f.MerchantId, + MerchantName = m != null ? m.Name : null, + LineNumber = fl.LineNumber, + LineContents = fl.FileLineData, + LineStatus = fl.Status + }; + + var flatResult = await ReportingQueryExecutor.ToListAsync(flatQuery, cancellationToken, "Error retrieving file import logs"); + + if (flatResult.IsFailed) + return ResultHelpers.CreateFailure(flatResult); + + var flatItems = flatResult.Data; + + if (flatItems.Count == 0) + return Result.NotFound(); + + var fileImportLogs = flatItems + .GroupBy(x => new { x.FileImportLogId, x.ImportLogDateTime }) + .Select(g => new FileImportLog + { + FileImportLogId = g.Key.FileImportLogId, + ImportLogDateTime = g.Key.ImportLogDateTime, + FileDetailsList = g.GroupBy(f => new { f.FileId, f.FileName, f.FileProfileId, f.DateTimeUploaded, f.UserId, f.UploadedBy, f.MerchantId, f.MerchantName }) + .Select(fg => new FileDetails + { + FileId = fg.Key.FileId, + FileName = fg.Key.FileName, + FileProfile = fg.Key.FileProfileId != null ? fg.Key.FileProfileId.ToString() : null, + DateTimeUploaded = fg.Key.DateTimeUploaded, + UserId = fg.Key.UserId, + UploadedBy = fg.Key.UploadedBy, + MerchantId = fg.Key.MerchantId, + MerchantName = fg.Key.MerchantName, + FileLines = fg.Select(fl => new FileLine + { + LineNumber = fl.LineNumber, + LineContents = fl.LineContents, + LineStatus = fl.LineStatus + }).ToList() + }).ToList() + }).SingleOrDefault(); + + return Result.Success(fileImportLogs); + } + + public async Task>> GetFileProfileConfigurationList(FileProfileConfigurationQueries.GetFileProfileConfigurationListQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var query = from c in context.FileProfileConfigurations + join h in context.FileFormatHandlers on c.FileFormatHandlerId equals h.FileFormatHandlerId + join r in context.RequestTypes on c.RequestTypeId equals r.RequestTypeId + join o in context.Operators on c.OperatorId equals o.OperatorId + select new FileProfileConfiguration + { + FileProfileId = c.FileProfileId, + Name = c.Name, + ListeningDirectory = c.ListeningDirectory, + RequestType = r.Name, + OperatorName = o.Name, + LineTerminator = c.LineTerminator, + FileFormatHandler = h.Name + }; + + Result> result = await ReportingQueryExecutor.ToListAsync(query, cancellationToken, "Error retrieving file import logs"); + + if (result.IsFailed) + return ResultHelpers.CreateFailure(result); + + return Result.Success(result.Data); + } + private sealed class FileImportFlatData { + public Guid FileImportLogId { get; init; } + public DateTime ImportLogDateTime { get; init; } + public Guid FileId { get; init; } + public string? FileName { get; init; } + public Guid? FileProfileId { get; init; } + public DateTime DateTimeUploaded { get; init; } + public Guid UserId { get; init; } + public string? UploadedBy { get; init; } + public Guid MerchantId { get; init; } + public string? MerchantName { get; init; } + public int LineNumber { get; init; } + public string? LineContents { get; init; } + public string? LineStatus { get; init; } + } +} diff --git a/EstateReportingAPI.BusinessLogic/Services/MerchantReportingService.cs b/EstateReportingAPI.BusinessLogic/Services/MerchantReportingService.cs new file mode 100644 index 0000000..1195049 --- /dev/null +++ b/EstateReportingAPI.BusinessLogic/Services/MerchantReportingService.cs @@ -0,0 +1,881 @@ +using EstateReportingAPI.BusinessLogic.Queries; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.DynamicLinq; +using SimpleResults; +using System; +using System.Linq; +using System.Threading; +using TransactionProcessor.Database.Contexts; +using TransactionProcessor.Database.Entities; +using TransactionProcessor.Database.Entities.Summary; +using Shared.EntityFramework; +using Shared.Results; +using EstateReportingAPI.Models; +using Contract = EstateReportingAPI.Models.Contract; +using Merchant = EstateReportingAPI.Models.Merchant; +using MerchantContract = EstateReportingAPI.Models.MerchantContract; +using MerchantDevice = EstateReportingAPI.Models.MerchantDevice; +using MerchantOperator = EstateReportingAPI.Models.MerchantOperator; +using Operator = EstateReportingAPI.Models.Operator; +using ContractProductTransactionFee = EstateReportingAPI.Models.ContractProductTransactionFee; +using MerchantBalanceProjectionState = TransactionProcessor.ProjectionEngine.Database.Database.Entities.MerchantBalanceProjectionState; + +namespace EstateReportingAPI.BusinessLogic.Services; + +public interface IMerchantReportingService +{ + Task>> GetRecentContracts(ContractQueries.GetRecentContractsQuery request, CancellationToken cancellationToken); + Task>> GetContracts(ContractQueries.GetContractsQuery request, CancellationToken cancellationToken); + Task> GetContract(ContractQueries.GetContractQuery request, CancellationToken cancellationToken); + Task>> GetRecentMerchants(MerchantQueries.GetRecentMerchantsQuery request, CancellationToken cancellationToken); + Task> GetMerchantsTransactionKpis(MerchantQueries.GetTransactionKpisQuery request, CancellationToken cancellationToken); + Task>> GetOperators(OperatorQueries.GetOperatorsQuery request, CancellationToken cancellationToken); + Task>> GetMerchantOpeningHours(MerchantQueries.GetMerchantOpeningHoursQuery request, CancellationToken cancellationToken); + Task> GetOperator(OperatorQueries.GetOperatorQuery request, CancellationToken cancellationToken); + Task>> GetMerchants(MerchantQueries.GetMerchantsQuery request, CancellationToken cancellationToken); + Task> GetMerchant(MerchantQueries.GetMerchantQuery request, CancellationToken cancellationToken); + Task>> GetMerchantOperators(MerchantQueries.GetMerchantOperatorsQuery request, CancellationToken cancellationToken); + Task>> GetMerchantContracts(MerchantQueries.GetMerchantContractsQuery request, CancellationToken cancellationToken); + Task>> GetMerchantDevices(MerchantQueries.GetMerchantDevicesQuery request, CancellationToken cancellationToken); + Task> GetMerchantSchedule(MerchantQueries.GetMerchantScheduleQuery request, CancellationToken cancellationToken); +} +public sealed class MerchantReportingService : IMerchantReportingService +{ + private readonly IDbContextResolver Resolver; + private const string EstateManagementDatabaseName = "TransactionProcessorReadModel"; + + public MerchantReportingService(IDbContextResolver resolver) + { + Resolver = resolver; + } + + private static async Task> ExecuteQuerySafeSum(IQueryable query, + CancellationToken cancellationToken, + string contextMessage = null) { + try { + T item = await query.SumAsync(cancellationToken); + return Result.Success(item); + } + catch (Exception ex) { + string msg = contextMessage == null ? $"Error executing query: {ex.Message}" : $"{contextMessage}: {ex.Message}"; + return Result.Failure(msg); + } + } + + private static async Task>> ExecuteQuerySafeToList(IQueryable query, + CancellationToken cancellationToken, + string contextMessage = null) { + try { + List items = await query.ToListAsync(cancellationToken); + return Result.Success(items); + } + catch (Exception ex) { + string msg = contextMessage == null ? $"Error executing query: {ex.Message}" : $"{contextMessage}: {ex.Message}"; + return Result.Failure(msg); + } + } + + private static async Task> ExecuteQuerySafeCount(IQueryable query, + CancellationToken cancellationToken, + string contextMessage = null) { + try { + int count = await query.CountAsync(cancellationToken); + return Result.Success(count); + } + catch (Exception ex) { + string msg = contextMessage == null ? $"Error executing query: {ex.Message}" : $"{contextMessage}: {ex.Message}"; + return Result.Failure(msg); + } + } + + private static async Task> ExecuteQuerySafeSingleOrDefault(IQueryable query, + CancellationToken cancellationToken, + string contextMessage = null) { + try { + T item = await query.SingleOrDefaultAsync(cancellationToken); + + if (EqualityComparer.Default.Equals(item, default(T))) + return Result.NotFound(contextMessage); + + return Result.Success(item); + } + catch (Exception ex) { + string msg = contextMessage == null ? $"Error executing query: {ex.Message}" : $"{contextMessage}: {ex.Message}"; + return Result.Failure(msg); + } + } + + public async Task>> GetContracts(ContractQueries.GetContractsQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var baseContractsQuery = (from c in context.Contracts + join o in context.Operators on c.OperatorId equals o.OperatorId into ops + from o in ops + join e in context.Estates on c.EstateId equals e.EstateId into estates + from e in estates + select new ContractBaseData { + EstateReportingId = e.EstateReportingId, + ContractId = c.ContractId, + ContractReportingId = c.ContractReportingId, + Description = c.Description, + EstateId = c.EstateId, + OperatorId = c.OperatorId, + OperatorReportingId = o.OperatorReportingId, + OperatorName = o.Name + }).OrderByDescending(x => x.Description); + + var baseContractsResult = await ExecuteQuerySafeToList(baseContractsQuery, cancellationToken, "Error retrieving contracts - Step 1"); + if (baseContractsResult.IsFailed) + return ResultHelpers.CreateFailure(baseContractsResult); + + var baseContracts = baseContractsResult.Data; + if (!baseContracts.Any()) + return new List(); + + var contractIds = baseContracts.Select(b => b.ContractId).ToList(); + var productsResult = await LoadProductsAsync(context, contractIds, cancellationToken, "Error retrieving contracts - Step 2"); + if (productsResult.IsFailed) + return ResultHelpers.CreateFailure(productsResult); + + var productIds = productsResult.Data.Select(p => p.ContractProductId).ToList(); + var feesResult = await LoadFeesAsync(context, productIds, cancellationToken, "Error retrieving contracts - Step 3"); + if (feesResult.IsFailed) + return ResultHelpers.CreateFailure(feesResult); + + return Result.Success(BuildContracts(baseContracts, productsResult.Data, feesResult.Data)); + } + + public async Task> GetContract(ContractQueries.GetContractQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + // Step 1: load contract with operator name + var baseContractQuery = (from c in context.Contracts + join o in context.Operators on c.OperatorId equals o.OperatorId into ops + from o in ops + join e in context.Estates on c.EstateId equals e.EstateId into estates + from e in estates + where c.ContractId == request.ContractId + select new { + e.EstateReportingId, + c.ContractId, + c.ContractReportingId, + c.Description, + c.EstateId, + c.OperatorId, + o.OperatorReportingId, + OperatorName = o.Name }).OrderByDescending(x => x.Description); + + var baseContractQueryResult = await ExecuteQuerySafeSingleOrDefault(baseContractQuery, cancellationToken, "Error retrieving contract - Step 1"); + + if (baseContractQueryResult.IsFailed) + return ResultHelpers.CreateFailure(baseContractQueryResult); + + var baseContract = baseContractQueryResult.Data; + + // Steps 2 & 3: load products and fees, then assemble + var productsResult = await this.LoadProductsWithFees(context, baseContract.ContractId, cancellationToken); + + if (productsResult.IsFailed) + return ResultHelpers.CreateFailure(productsResult); + + return Result.Success(new Contract { + EstateReportingId = baseContract.EstateReportingId, + ContractId = baseContract.ContractId, + ContractReportingId = baseContract.ContractReportingId, + Description = baseContract.Description, + EstateId = baseContract.EstateId, + OperatorName = baseContract.OperatorName, + OperatorId = baseContract.OperatorId, + OperatorReportingId = baseContract.OperatorReportingId, + Products = productsResult.Data + }); + } + + private async Task>> LoadProductsWithFees(EstateManagementContext context, + Guid contractId, + CancellationToken cancellationToken) { + var productsQuery = context.ContractProducts.Where(cp => cp.ContractId == contractId).Select(cp => new { + cp.ContractProductId, + cp.ContractProductReportingId, + cp.ContractId, + cp.DisplayText, + cp.ProductName, + cp.ProductType, + cp.Value + }); + + var productsQueryResult = await ExecuteQuerySafeToList(productsQuery, cancellationToken, "Error retrieving contract - Step 2"); + + if (productsQueryResult.IsFailed) + return ResultHelpers.CreateFailure(productsQueryResult); + + var products = productsQueryResult.Data; + var productIds = products.Select(p => p.ContractProductId).ToList(); + + var feesQuery = context.ContractProductTransactionFees.Where(tf => productIds.Contains(tf.ContractProductId)).Select(tf => new { + tf.CalculationType, + tf.ContractProductTransactionFeeId, + tf.ContractProductTransactionFeeReportingId, + tf.FeeType, + tf.Value, + tf.ContractProductId, + tf.Description + }); + + var feesQueryResult = await ExecuteQuerySafeToList(feesQuery, cancellationToken, "Error retrieving contract - Step 3"); + + if (feesQueryResult.IsFailed) + return ResultHelpers.CreateFailure(feesQueryResult); + + var fees = feesQueryResult.Data; + var feesLookup = fees.ToLookup(f => f.ContractProductId); + + return Result.Success(products.Select(p => new Models.ContractProduct { + ContractId = p.ContractId, + ProductId = p.ContractProductId, + DisplayText = p.DisplayText, + ProductName = p.ProductName, + ProductType = p.ProductType, + Value = p.Value, + ContractProductReportingId = p.ContractProductReportingId, + TransactionFees = feesLookup[p.ContractProductId].Select(f => new ContractProductTransactionFee { + Description = f.Description, + Value = f.Value, + CalculationType = f.CalculationType, + FeeType = f.FeeType, + TransactionFeeId = f.ContractProductTransactionFeeId, + ContractProductTransactionFeeReportingId = f.ContractProductTransactionFeeReportingId + }).ToList() + }).ToList()); + } + + public async Task>> GetRecentContracts(ContractQueries.GetRecentContractsQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var contractsQuery = (from c in context.Contracts + join o in context.Operators on c.OperatorId equals o.OperatorId into ops + from o in ops + join e in context.Estates on c.EstateId equals e.EstateId into estates + from e in estates + select new + { + e.EstateReportingId, + c.ContractId, + c.ContractReportingId, + c.Description, + c.EstateId, + c.OperatorId, + o.OperatorReportingId, + OperatorName = o.Name + }).OrderByDescending(x => x.ContractReportingId).Take(3); + + var result = await ExecuteQuerySafeToList(contractsQuery, cancellationToken, "Error retrieving recent contracts"); + + if (result.IsFailed) + return ResultHelpers.CreateFailure(result); + + List response = new List(); + foreach (var contract in result.Data) { + response.Add(new Contract { + EstateId = contract.EstateId, + EstateReportingId = contract.EstateReportingId, + ContractId = contract.ContractId, + ContractReportingId = contract.ContractReportingId, + Description = contract.Description, + OperatorName = contract.OperatorName, + OperatorId = contract.OperatorId, + OperatorReportingId = contract.OperatorReportingId, + }); + } + + return response; + } + + + public async Task>> GetRecentMerchants(MerchantQueries.GetRecentMerchantsQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var merchantsQuery = context.Merchants.Select(m => new { + MerchantReportingId = m.MerchantReportingId, + Name = m.Name, + CreatedDateTime = m.CreatedDateTime, + MerchantId = m.MerchantId, + Reference = m.Reference, + AddressInfo = context.MerchantAddresses.Where(ma => ma.MerchantId == m.MerchantId) + .OrderByDescending(ma => ma.CreatedDateTime) + .Select(ma => new { ma.AddressId, ma.AddressLine1, ma.AddressLine2, ma.Country, ma.PostalCode, ma.Region, ma.Town }) + .FirstOrDefault(), + ContactInfo = context.MerchantContacts.Where(mc => mc.MerchantId == m.MerchantId) + .OrderByDescending(mc => mc.CreatedDateTime) + .Select(mc => new { mc.ContactId, mc.Name, mc.EmailAddress, mc.PhoneNumber }) + .FirstOrDefault(), + BalanceState = context.MerchantBalanceProjectionState.Where(mb => mb.MerchantId == m.MerchantId).SingleOrDefault() + }).OrderByDescending(m => m.CreatedDateTime).Take(3); + + var recentMerchantsResult = await ExecuteQuerySafeToList(merchantsQuery, cancellationToken, "Error retrieving recent merchants"); + if (recentMerchantsResult.IsFailed) + return ResultHelpers.CreateFailure(recentMerchantsResult); + + List merchantList = recentMerchantsResult.Data.Select(merchant => { + Merchant model = new() { + MerchantId = merchant.MerchantId, + Name = merchant.Name, + Reference = merchant.Reference, + MerchantReportingId = merchant.MerchantReportingId, + CreatedDateTime = merchant.CreatedDateTime, + Balance = merchant.BalanceState.Balance + }; + if (merchant.AddressInfo != null) { + model.AddressId = merchant.AddressInfo.AddressId; + model.AddressLine1 = merchant.AddressInfo.AddressLine1; + model.AddressLine2 = merchant.AddressInfo.AddressLine2; + model.Country = merchant.AddressInfo.Country; + model.PostCode = merchant.AddressInfo.PostalCode; + model.Town = merchant.AddressInfo.Town; + model.Region = merchant.AddressInfo.Region; + } + if (merchant.ContactInfo != null) { + model.ContactId = merchant.ContactInfo.ContactId; + + model.ContactName = merchant.ContactInfo.Name; + model.ContactEmail = merchant.ContactInfo.EmailAddress; + model.ContactPhone = merchant.ContactInfo.PhoneNumber; + } + return model; + }).ToList(); + + return Result.Success(merchantList); + } + + public async Task> GetMerchantsTransactionKpis(MerchantQueries.GetTransactionKpisQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var merchantsQuery = context.MerchantBalanceProjectionState.Select(m => new { m.MerchantName, m.LastSale }); + var merchantsQueryResult = await ExecuteQuerySafeToList(merchantsQuery, cancellationToken, "Error retrieving merchants for KPI's"); + if (merchantsQueryResult.IsFailed) + return ResultHelpers.CreateFailure(merchantsQueryResult); + + var merchants = merchantsQueryResult.Data; + + DateTime now = DateTime.Now; + + Int32 merchantsWithSaleInLastHour = (from m in merchants where m.LastSale >= now.AddHours(-1) && m.LastSale <= now select m).Count(); + + Int32 merchantsWithNoSaleToday = (from m in merchants where m.LastSale >= now.AddDays(-7) && m.LastSale <= now.AddDays(-1) select m).Count(); + + Int32 merchantsWithNoSaleInLast7Days = (from m in merchants where m.LastSale <= now.AddDays(-7) select m).Count(); + + MerchantKpi response = new() { MerchantsWithSaleInLastHour = merchantsWithSaleInLastHour, MerchantsWithNoSaleToday = merchantsWithNoSaleToday, MerchantsWithNoSaleInLast7Days = merchantsWithNoSaleInLast7Days }; + + return Result.Success(response); + } + + public async Task>> GetOperators(OperatorQueries.GetOperatorsQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var operatorQuery = (from o in context.Operators + select new { + Name = o.Name, + EstateReportingId = context.Estates.Single(e => e.EstateId == o.EstateId).EstateReportingId, + OperatorId = o.OperatorId, + OperatorReportingId = o.OperatorReportingId, + RequireCustomMerchantNumber = o.RequireCustomMerchantNumber, + RequireCustomTerminalNumber = o.RequireCustomTerminalNumber + }); + + var operatorResult = await ExecuteQuerySafeToList(operatorQuery, cancellationToken, "Error retrieving operator"); + + if (operatorResult.IsFailed) + return ResultHelpers.CreateFailure(operatorResult); + + List operators = new List(); + foreach (var op in operatorResult.Data) { + operators.Add(new Operator { + Name = op.Name, + EstateReportingId = op.EstateReportingId, + OperatorId = op.OperatorId, + OperatorReportingId = op.OperatorReportingId, + RequireCustomMerchantNumber = op.RequireCustomMerchantNumber, + RequireCustomTerminalNumber = op.RequireCustomTerminalNumber + }); + } + + return Result.Success(operators); + } + + public async Task>> GetMerchantOpeningHours(MerchantQueries.GetMerchantOpeningHoursQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var openingHoursQuery = (from mo in context.MerchantOpeningHours + where mo.MerchantId == request.MerchantId + select new { + SundayOpen = mo.SundayOpening, + SundayClose = mo.SundayClosing, + MondayOpen = mo.MondayOpening, + MondayClose = mo.MondayClosing, + TuesdayOpen = mo.TuesdayOpening, + TuesdayClose = mo.TuesdayClosing, + WednesdayOpen = mo.WednesdayOpening, + WednesdayClose = mo.WednesdayClosing, + ThursdayOpen = mo.ThursdayOpening, + ThursdayClose = mo.ThursdayClosing, + FridayOpen = mo.FridayOpening, + FridayClose = mo.FridayClosing, + SaturdayOpen = mo.SaturdayOpening, + SaturdayClose = mo.SaturdayClosing, + }); + + var openingHoursResult = await ExecuteQuerySafeSingleOrDefault(openingHoursQuery, cancellationToken, "Error retrieving merchant opening hours"); + + if (openingHoursResult.IsFailed) + return ResultHelpers.CreateFailure(openingHoursResult); + + List openingHours = new List(); + openingHours.Add(new MerchantOpeningHour {OpeningTime = openingHoursResult.Data.SundayOpen, ClosingTime = openingHoursResult.Data.SundayClose, DayOfWeek = DayOfWeek.Sunday, MerchantId = request.MerchantId}); + openingHours.Add(new MerchantOpeningHour { OpeningTime = openingHoursResult.Data.MondayOpen, ClosingTime = openingHoursResult.Data.MondayClose, DayOfWeek = DayOfWeek.Monday, MerchantId = request.MerchantId }); + openingHours.Add(new MerchantOpeningHour { OpeningTime = openingHoursResult.Data.TuesdayOpen, ClosingTime = openingHoursResult.Data.TuesdayClose, DayOfWeek = DayOfWeek.Tuesday, MerchantId = request.MerchantId }); + openingHours.Add(new MerchantOpeningHour { OpeningTime = openingHoursResult.Data.WednesdayOpen, ClosingTime = openingHoursResult.Data.WednesdayClose, DayOfWeek = DayOfWeek.Wednesday, MerchantId = request.MerchantId }); + openingHours.Add(new MerchantOpeningHour { OpeningTime = openingHoursResult.Data.ThursdayOpen, ClosingTime = openingHoursResult.Data.ThursdayClose, DayOfWeek = DayOfWeek.Thursday, MerchantId = request.MerchantId }); + openingHours.Add(new MerchantOpeningHour { OpeningTime = openingHoursResult.Data.FridayOpen, ClosingTime = openingHoursResult.Data.FridayClose, DayOfWeek = DayOfWeek.Friday, MerchantId = request.MerchantId }); + openingHours.Add(new MerchantOpeningHour { OpeningTime = openingHoursResult.Data.SaturdayOpen, ClosingTime = openingHoursResult.Data.SaturdayClose, DayOfWeek = DayOfWeek.Saturday, MerchantId = request.MerchantId }); + + return Result.Success(openingHours); + } + + public async Task> GetOperator(OperatorQueries.GetOperatorQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var operatorQuery = (from o in context.Operators + where o.OperatorId == request.OperatorId + select new { + Name = o.Name, + EstateReportingId = context.Estates.Single(e => e.EstateId == o.EstateId).EstateReportingId, + OperatorId = o.OperatorId, + OperatorReportingId = o.OperatorReportingId, + RequireCustomMerchantNumber = o.RequireCustomMerchantNumber, + RequireCustomTerminalNumber = o.RequireCustomTerminalNumber + }); + + var operatorResult = await ExecuteQuerySafeSingleOrDefault(operatorQuery, cancellationToken, "Error retrieving operator"); + + if (operatorResult.IsFailed) + return ResultHelpers.CreateFailure(operatorResult); + + var @operator = new Operator { + Name = operatorResult.Data.Name, + EstateReportingId = operatorResult.Data.EstateReportingId, + OperatorId = operatorResult.Data.OperatorId, + OperatorReportingId = operatorResult.Data.OperatorReportingId, + RequireCustomMerchantNumber = operatorResult.Data.RequireCustomMerchantNumber, + RequireCustomTerminalNumber = operatorResult.Data.RequireCustomTerminalNumber + }; + + return Result.Success(@operator); + } + + public async Task>> GetMerchants(MerchantQueries.GetMerchantsQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var merchantWithAddresses = ApplyMerchantFilters(BuildMerchantWithAddressQuery(context, request.EstateId), request.QueryOptions); + + var queryResults = await ExecuteQuerySafeToList(merchantWithAddresses, cancellationToken, "Error retrieving merchants"); + + if (queryResults.IsFailed) + return ResultHelpers.CreateFailure(queryResults); + + var merchants = queryResults.Data; + + var merchantBalancesQuery = context.MerchantBalanceProjectionState.Where(mb => merchants.Select(m => m.Merchant.MerchantId).Contains(mb.MerchantId)); + + var balanceQueryResults = await ExecuteQuerySafeToList(merchantBalancesQuery, cancellationToken, "Error retrieving merchant balances"); + + if (balanceQueryResults.IsFailed) + return ResultHelpers.CreateFailure(balanceQueryResults); + + try { + Dictionary balanceLookup = BuildMerchantBalanceLookup(balanceQueryResults.Data); + List response = merchants.Select(merchant => + ModelFactory.ConvertFrom(merchant, GetMerchantBalance(balanceLookup, merchant.Merchant.MerchantId))) + .ToList(); + + return Result.Success(response); + } + catch (InvalidOperationException ex) { + return Result.Failure(ex.Message); + } + } + + public async Task> GetMerchant(MerchantQueries.GetMerchantQuery request, CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + IQueryable merchantQuery = BuildMerchantQuery(context, request.MerchantId); + Result merchantQueryResult = await ExecuteQuerySafeSingleOrDefault(merchantQuery, cancellationToken, "Error getting merchant"); + if (merchantQueryResult.IsFailed) { + return ResultHelpers.CreateFailure(merchantQueryResult); + } + + Result> openingHoursQueryResult = await ExecuteQuerySafeToList(context.MerchantOpeningHours.Where(m => m.MerchantId == request.MerchantId), cancellationToken, "Error getting merchant opening hours"); + if (openingHoursQueryResult.IsFailed) return ResultHelpers.CreateFailure(openingHoursQueryResult); + + Result merchantStateQueryResult = await ExecuteQuerySafeSingleOrDefault(context.MerchantBalanceProjectionState.Where(ms => ms.MerchantId == request.MerchantId), cancellationToken, "Error getting merchant state"); + if (merchantStateQueryResult.IsFailed) return ResultHelpers.CreateFailure(merchantStateQueryResult); + + return Result.Success(ModelFactory.ConvertFrom(merchantQueryResult.Data, merchantStateQueryResult.Data.Balance, openingHoursQueryResult.Data)); + } + + private static IQueryable BuildMerchantQuery(EstateManagementContext context, + Guid merchantId) { + return context.Merchants + .Select(m => new MerchantData { + MerchantReportingId = m.MerchantReportingId, + Name = m.Name, + CreatedDateTime = m.CreatedDateTime, + MerchantId = m.MerchantId, + Reference = m.Reference, + SettlementSchedule = m.SettlementSchedule, + AddressInfo = context.MerchantAddresses.Where(ma => ma.MerchantId == m.MerchantId) + .OrderByDescending(ma => ma.CreatedDateTime) + .Select(ma => new MerchantAddressData { + AddressId = ma.AddressId, + AddressLine1 = ma.AddressLine1, + AddressLine2 = ma.AddressLine2, + Country = ma.Country, + PostalCode = ma.PostalCode, + Region = ma.Region, + Town = ma.Town + }) + .FirstOrDefault(), + ContactInfo = context.MerchantContacts.Where(mc => mc.MerchantId == m.MerchantId) + .OrderByDescending(mc => mc.CreatedDateTime) + .Select(mc => new MerchantContactData { + ContactId = mc.ContactId, + Name = mc.Name, + EmailAddress = mc.EmailAddress, + PhoneNumber = mc.PhoneNumber + }) + .FirstOrDefault() + }) + .Where(m => m.MerchantId == merchantId); + } + + private static IQueryable BuildMerchantWithAddressQuery(EstateManagementContext context, + Guid estateId) { + return context.Merchants + .Where(m => m.EstateId == estateId) + .Select(m => new MerchantWithAddressAndContactData { + Merchant = m, + MerchantAddress = context.MerchantAddresses.Where(ma => ma.MerchantId == m.MerchantId) + .OrderByDescending(ma => ma.CreatedDateTime) + .First(), + MerchantContact = context.MerchantContacts.Where(mc => mc.MerchantId == m.MerchantId) + .OrderByDescending(mc => mc.CreatedDateTime) + .First() + }); + } + + private static IQueryable ApplyMerchantFilters(IQueryable query, + MerchantQueries.MerchantQueryOptions queryOptions) { + if (String.IsNullOrEmpty(queryOptions.Name) == false) + query = query.Where(m => m.Merchant.Name.Contains(queryOptions.Name)); + + if (String.IsNullOrEmpty(queryOptions.Reference) == false) + query = query.Where(m => m.Merchant.Reference == queryOptions.Reference); + + if (queryOptions.SettlementSchedule > 0) + query = query.Where(m => m.Merchant.SettlementSchedule == queryOptions.SettlementSchedule); + + if (String.IsNullOrEmpty(queryOptions.Region) == false) + query = query.Where(m => m.MerchantAddress.Region.Contains(queryOptions.Region)); + + if (String.IsNullOrEmpty(queryOptions.PostCode) == false) + query = query.Where(m => m.MerchantAddress.PostalCode == queryOptions.PostCode); + + return query; + } + + private static Dictionary BuildMerchantBalanceLookup(List balances) { + Dictionary balanceLookup = new(); + foreach (var balance in balances) { + if (balanceLookup.TryAdd(balance.MerchantId, balance.Balance) == false) + throw new InvalidOperationException($"Duplicate balance entry found for merchant {balance.MerchantId}"); + } + + return balanceLookup; + } + + private static decimal GetMerchantBalance(Dictionary balanceLookup, + Guid merchantId) { + if (balanceLookup.TryGetValue(merchantId, out var balance)) + return balance; + + throw new InvalidOperationException($"No balance entry found for merchant {merchantId}"); + } + + public async Task>> GetMerchantOperators(MerchantQueries.GetMerchantOperatorsQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var merchantOperatorsQuery = context.MerchantOperators.Where(mo => mo.MerchantId == request.MerchantId && mo.IsDeleted == false); + + var merchantOperatorsQueryResult = await ExecuteQuerySafeToList(merchantOperatorsQuery, cancellationToken, "Error getting merchant devices"); + + if (merchantOperatorsQueryResult.IsFailed) + return ResultHelpers.CreateFailure(merchantOperatorsQueryResult); + + var merchantOperators = merchantOperatorsQueryResult.Data; + + List result = new(); + foreach (TransactionProcessor.Database.Entities.MerchantOperator merchantOperator in merchantOperators) { + result.Add(new MerchantOperator { + OperatorId = merchantOperator.OperatorId, + IsDeleted = merchantOperator.IsDeleted, + MerchantId = merchantOperator.MerchantId, + MerchantNumber = merchantOperator.MerchantNumber, + OperatorName = merchantOperator.Name, + TerminalNumber = merchantOperator.TerminalNumber + }); + } + + return Result.Success(result); + } + + public async Task>> GetMerchantContracts(MerchantQueries.GetMerchantContractsQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var merchantContractsQuery = context.MerchantContracts.Where(mo => mo.MerchantId == request.MerchantId && mo.IsDeleted == false).Select(mc => new { + mc.ContractId, + mc.IsDeleted, + mc.MerchantId, + ContractInfo = context.Contracts.Where(c => c.ContractId == mc.ContractId).Select(ma => new { + ma.Description, + OperatorName = context.Operators.Where(o => o.OperatorId == ma.OperatorId).Select(p => p.Name).Single(), + Products = context.ContractProducts.Where(cp => cp.ContractId == mc.ContractId).Select(cp => new { + cp.DisplayText, + cp.ProductName, + cp.ContractProductId, + cp.ProductType, + cp.Value + }).ToList() + // Add more properties as needed + }).SingleOrDefault() + }); + + var merchantContractsQueryResult = await ExecuteQuerySafeToList(merchantContractsQuery, cancellationToken, "Error getting merchant devices"); + + if (merchantContractsQueryResult.IsFailed) + return ResultHelpers.CreateFailure(merchantContractsQueryResult); + + var merchantContracts = merchantContractsQueryResult.Data; + + List result = new(); + foreach (var merchantContract in merchantContracts) { + var c = new MerchantContract { + ContractId = merchantContract.ContractId, + ContractName = merchantContract.ContractInfo.Description, + IsDeleted = merchantContract.IsDeleted, + MerchantId = merchantContract.MerchantId, + OperatorName = merchantContract.ContractInfo.OperatorName, + ContractProducts = new List() + }; + + foreach (var product in merchantContract.ContractInfo.Products) { + c.ContractProducts.Add(new MerchantContractProduct { + ContractId = merchantContract.ContractId, + DisplayText = product.DisplayText, + MerchantId = merchantContract.MerchantId, + ProductName = product.ProductName, + ProductId = product.ContractProductId, + ProductType = product.ProductType, + Value = product.Value + }); + } + + result.Add(c); + } + + return Result.Success(result); + } + + + public async Task>> GetMerchantDevices(MerchantQueries.GetMerchantDevicesQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var merchantDevicesQuery = context.MerchantDevices.Where(mo => mo.MerchantId == request.MerchantId); + var merchantDevicesQueryResult = await ExecuteQuerySafeToList(merchantDevicesQuery, cancellationToken, "Error getting merchant devices"); + + if (merchantDevicesQueryResult.IsFailed) + return ResultHelpers.CreateFailure(merchantDevicesQueryResult); + + var merchantDevices = merchantDevicesQueryResult.Data; + + List result = new(); + foreach (TransactionProcessor.Database.Entities.MerchantDevice merchantDevice in merchantDevices) { + result.Add(new MerchantDevice { DeviceId = merchantDevice.DeviceId, DeviceIdentifier = merchantDevice.DeviceIdentifier, IsDeleted = false, MerchantId = merchantDevice.MerchantId }); + } + + return Result.Success(result); + } + + public async Task> GetMerchantSchedule(MerchantQueries.GetMerchantScheduleQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var merchantScheduleQuery = from s in context.MerchantSchedules + join d in context.MerchantScheduleMonths on s.MerchantScheduleId equals d.MerchantScheduleId + where s.MerchantId == request.MerchantId && s.Year == request.Year + select new { + s.MerchantId, + s.MerchantScheduleId, + s.Year, + d.Month, + d.ClosedDays + }; + + var merchantScheduleQueryResult = await ExecuteQuerySafeToList(merchantScheduleQuery, cancellationToken, "Error getting merchant schedule"); + if (merchantScheduleQueryResult.IsFailed) + return ResultHelpers.CreateFailure(merchantScheduleQueryResult); + + MerchantScheduleResponse response = new MerchantScheduleResponse(); + foreach (var item in merchantScheduleQueryResult.Data) { + + List closedDaysList = item.ClosedDays + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(s => int.TryParse(s, out var n) ? n : (int?)null) + .Where(n => n.HasValue) + .Select(n => n.Value) + .ToList(); + + MerchantScheduleMonthResponse? monthSchedule = new() { ClosedDays = closedDaysList, Month = item.Month }; + + response.Months.Add(monthSchedule); + } + response.Year = request.Year; + + return response; + } + + private static async Task>> LoadProductsAsync(EstateManagementContext context, + List contractIds, + CancellationToken cancellationToken, + string stepName) { + var query = context.ContractProducts.Where(cp => contractIds.Contains(cp.ContractId)).Select(cp => new ContractProductData { + ContractProductId = cp.ContractProductId, + ContractProductReportingId = cp.ContractProductReportingId, + ContractId = cp.ContractId, + DisplayText = cp.DisplayText, + ProductName = cp.ProductName, + ProductType = cp.ProductType, + Value = cp.Value + }); + return await ExecuteQuerySafeToList(query, cancellationToken, stepName); + } + + private static async Task>> LoadFeesAsync(EstateManagementContext context, + List productIds, + CancellationToken cancellationToken, + string stepName) { + var query = context.ContractProductTransactionFees.Where(tf => productIds.Contains(tf.ContractProductId)).Select(tf => new ContractFeeData { + ContractProductTransactionFeeId = tf.ContractProductTransactionFeeId, + ContractProducTransactionFeeReportingId = tf.ContractProductTransactionFeeReportingId, + ContractProductId = tf.ContractProductId, + Description = tf.Description, + CalculationType = tf.CalculationType, + FeeType = tf.FeeType, + Value = tf.Value, + IsEnabled = tf.IsEnabled + }); + return await ExecuteQuerySafeToList(query, cancellationToken, stepName); + } + + private static List BuildContracts(List baseContracts, + List products, + List fees) { + return baseContracts.Select(b => new Contract { + ContractId = b.ContractId, + ContractReportingId = b.ContractReportingId, + Description = b.Description, + EstateId = b.EstateId, + EstateReportingId = b.EstateReportingId, + OperatorName = b.OperatorName, + OperatorId = b.OperatorId, + OperatorReportingId = b.OperatorReportingId, + Products = products.Where(p => p.ContractId == b.ContractId).Select(p => new Models.ContractProduct { + ContractId = p.ContractId, + ProductId = p.ContractProductId, + ContractProductReportingId = p.ContractProductReportingId, + DisplayText = p.DisplayText, + ProductName = p.ProductName, + ProductType = p.ProductType, + Value = p.Value, + TransactionFees = fees.Where(f => f.ContractProductId == p.ContractProductId && f.IsEnabled).Select(f => new ContractProductTransactionFee { + Description = f.Description, + Value = f.Value, + CalculationType = f.CalculationType, + FeeType = f.FeeType, + TransactionFeeId = f.ContractProductTransactionFeeId, + ContractProductTransactionFeeReportingId = f.ContractProducTransactionFeeReportingId + }).ToList() + }).ToList() + }).ToList(); + } + + private sealed class ContractBaseData { + public Guid ContractId { get; init; } + public int ContractReportingId { get; init; } + public string? Description { get; init; } + public Guid EstateId { get; init; } + public int EstateReportingId { get; init; } + public Guid OperatorId { get; init; } + public int OperatorReportingId { get; init; } + public string? OperatorName { get; init; } + } + + private sealed class ContractProductData { + public Guid ContractProductId { get; init; } + public int ContractProductReportingId { get; init; } + public Guid ContractId { get; init; } + public string? DisplayText { get; init; } + public string? ProductName { get; init; } + public int ProductType { get; init; } + public decimal? Value { get; init; } + } + + private sealed class ContractFeeData { + public Guid ContractProductTransactionFeeId { get; init; } + public int ContractProducTransactionFeeReportingId { get; init; } + public Guid ContractProductId { get; init; } + public string? Description { get; init; } + public int CalculationType { get; init; } + public int FeeType { get; init; } + public decimal Value { get; init; } + public bool IsEnabled { get; init; } + } + + +} + diff --git a/EstateReportingAPI.BusinessLogic/Services/SettlementReportingService.cs b/EstateReportingAPI.BusinessLogic/Services/SettlementReportingService.cs new file mode 100644 index 0000000..62b06c1 --- /dev/null +++ b/EstateReportingAPI.BusinessLogic/Services/SettlementReportingService.cs @@ -0,0 +1,95 @@ +using EstateReportingAPI.BusinessLogic.Queries; +using EstateReportingAPI.Models; +using Microsoft.EntityFrameworkCore; +using SimpleResults; +using TransactionProcessor.Database.Contexts; +using Shared.EntityFramework; + +namespace EstateReportingAPI.BusinessLogic.Services; + +public interface ISettlementReportingService +{ + Task> GetTodaysSettlement(SettlementQueries.TodaysSettlementQuery request, CancellationToken cancellationToken); +} + +public sealed class SettlementReportingService : ISettlementReportingService +{ + private readonly IDbContextResolver Resolver; + private const string EstateManagementDatabaseName = "TransactionProcessorReadModel"; + public SettlementReportingService(IDbContextResolver resolver) => Resolver = resolver; + + public async Task> GetTodaysSettlement(SettlementQueries.TodaysSettlementQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + IQueryable todaySettlementData = this.BuildTodaySettlementQuery(context, DateTime.Now); + IQueryable comparisonSettlementData = this.BuildComparisonSettlementQuery(context, request.ComparisonDate); + + DatabaseProjections.SettlementGroupProjection todaySettlement = await this.GetSettlementSummary(todaySettlementData, cancellationToken); + DatabaseProjections.SettlementGroupProjection comparisonSettlement = await this.GetSettlementSummary(comparisonSettlementData, cancellationToken); + + TodaysSettlement response = new() + { + ComparisonSettlementCount = comparisonSettlement.SettledCount, + ComparisonSettlementValue = comparisonSettlement.SettledValue, + ComparisonPendingSettlementCount = comparisonSettlement.UnSettledCount, + ComparisonPendingSettlementValue = comparisonSettlement.UnSettledValue, + TodaysSettlementCount = todaySettlement.SettledCount, + TodaysSettlementValue = todaySettlement.SettledValue, + TodaysPendingSettlementCount = todaySettlement.UnSettledCount, + TodaysPendingSettlementValue = todaySettlement.UnSettledValue + }; + + return response; + } + private async Task GetSettlementSummary(IQueryable query, + CancellationToken cancellationToken) { + // Get the settleed fees summary + DatabaseProjections.SettlementGroupProjection summary = await BuildSettlementSummaryQuery(query).SingleOrDefaultAsync(cancellationToken); + + if (summary == null) + return new DatabaseProjections.SettlementGroupProjection(); + + return new DatabaseProjections.SettlementGroupProjection { SettledCount = summary.SettledCount, SettledValue = summary.SettledValue, UnSettledCount = summary.UnSettledCount, UnSettledValue = summary.UnSettledValue }; + } + + private async Task GetSettlementSummary( + IQueryable query, + CancellationToken cancellationToken) + { + // Get the settleed fees summary + DatabaseProjections.SettlementGroupProjection summary = await BuildSettlementSummaryQuery(query).SingleOrDefaultAsync(cancellationToken); + + if (summary == null) + return new DatabaseProjections.SettlementGroupProjection(); + + return new DatabaseProjections.SettlementGroupProjection + { + SettledCount = summary.SettledCount, + SettledValue = summary.SettledValue, + UnSettledCount = summary.UnSettledCount, + UnSettledValue = summary.UnSettledValue + }; + } + + private static IQueryable BuildSettlementSummaryQuery(IQueryable query) { + return query.GroupBy(_ => 1).Select(g => new DatabaseProjections.SettlementGroupProjection { SettledCount = g.Count(x => x.Fee.IsSettled), SettledValue = g.Where(x => x.Fee.IsSettled).Sum(x => x.Fee.CalculatedValue), UnSettledCount = g.Count(x => !x.Fee.IsSettled), UnSettledValue = g.Where(x => !x.Fee.IsSettled).Sum(x => x.Fee.CalculatedValue) }); + } + + private static IQueryable BuildSettlementSummaryQuery(IQueryable query) { + return query.GroupBy(_ => 1).Select(g => new DatabaseProjections.SettlementGroupProjection { SettledCount = g.Count(x => x.Fee.IsSettled), SettledValue = g.Where(x => x.Fee.IsSettled).Sum(x => x.Fee.CalculatedValue), UnSettledCount = g.Count(x => !x.Fee.IsSettled), UnSettledValue = g.Where(x => !x.Fee.IsSettled).Sum(x => x.Fee.CalculatedValue) }); + } + + private IQueryable BuildTodaySettlementQuery(EstateManagementContext context, + DateTime settlementDate) { + IQueryable settlementData = from s in context.Settlements join f in context.MerchantSettlementFees on s.SettlementId equals f.SettlementId join t in context.TodayTransactions on f.TransactionId equals t.TransactionId where s.SettlementDate == settlementDate select new DatabaseProjections.TodaySettlementTransactionProjection { Fee = f, Txn = t }; + return settlementData; + } + + private IQueryable BuildComparisonSettlementQuery(EstateManagementContext context, + DateTime settlementDate) { + IQueryable settlementData = from s in context.Settlements join f in context.MerchantSettlementFees on s.SettlementId equals f.SettlementId join t in context.TransactionHistory on f.TransactionId equals t.TransactionId where s.SettlementDate == settlementDate.Date select new DatabaseProjections.ComparisonSettlementTransactionProjection { Fee = f, Txn = t }; + return settlementData; + } +} \ No newline at end of file diff --git a/EstateReportingAPI.BusinessLogic/Services/TransactionReportingService.cs b/EstateReportingAPI.BusinessLogic/Services/TransactionReportingService.cs new file mode 100644 index 0000000..864f130 --- /dev/null +++ b/EstateReportingAPI.BusinessLogic/Services/TransactionReportingService.cs @@ -0,0 +1,1055 @@ +using EstateReportingAPI.BusinessLogic.Queries; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.DynamicLinq; +using SimpleResults; +using System; +using System.Linq; +using System.Threading; +using TransactionProcessor.Database.Contexts; +using TransactionProcessor.Database.Entities; +using TransactionProcessor.Database.Entities.Summary; +using Shared.EntityFramework; +using Shared.Results; +using EstateReportingAPI.Models; +using MerchantBalanceProjectionState = TransactionProcessor.ProjectionEngine.Database.Database.Entities.MerchantBalanceProjectionState; + +namespace EstateReportingAPI.BusinessLogic.Services; + +public interface ITransactionReportingService +{ + Task> GetTodaysFailedSales(TransactionQueries.TodaysFailedSales request, CancellationToken cancellationToken); + Task> GetTodaysSales(TransactionQueries.TodaysSalesQuery request, CancellationToken cancellationToken); + Task> GetTransactionDetailReport(TransactionQueries.TransactionDetailReportQuery request, CancellationToken cancellationToken); + Task> GetTransactionSummaryByMerchantReport(TransactionQueries.TransactionSummaryByMerchantQuery request, CancellationToken cancellationToken); + Task> GetTransactionSummaryByOperatorReport(TransactionQueries.TransactionSummaryByOperatorQuery request, CancellationToken cancellationToken); + Task> GetProductPerformanceReport(TransactionQueries.ProductPerformanceQuery request, CancellationToken cancellationToken); + Task> GetTransactionMixSummary(TransactionQueries.TransactionMixSummaryQuery request, CancellationToken cancellationToken); + Task> GetRecentActivityReceiptReport(TransactionQueries.GetRecentActivityReceiptReportQuery request, CancellationToken cancellationToken); + Task>> GetTodaysSalesByHour(TransactionQueries.TodaysSalesByHour request, CancellationToken cancellationToken); + Task> GetMerchantDailyPerformanceSummary(TransactionQueries.MerchantDailyPerformanceSummaryQuery request, CancellationToken cancellationToken); +} + +public sealed class TransactionReportingService : ITransactionReportingService +{ + private readonly IDbContextResolver Resolver; + private const string EstateManagementDatabaseName = "TransactionProcessorReadModel"; + + public TransactionReportingService(IDbContextResolver resolver) + { + Resolver = resolver; +} + + public async Task> GetTodaysFailedSales(TransactionQueries.TodaysFailedSales request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + IQueryable todaysSalesQuery = this.BuildTodaysFailedSalesQuery(context, request.ResponseCode); + IQueryable comparisonSalesQuery = this.BuildComparisonFailedSalesQuery(context, request.ComparisonDate, request.ResponseCode); + + var todaysSalesQueryResult = await ReportingQueryExecutor.ToListAsync(todaysSalesQuery, cancellationToken, "Error retrieving todays failed sales"); + if (todaysSalesQueryResult.IsFailed) + return ResultHelpers.CreateFailure(todaysSalesQueryResult); + var comparisonSalesQueryResult = await ReportingQueryExecutor.ToListAsync(comparisonSalesQuery, cancellationToken, "Error retrieving comparison failed sales"); + if (comparisonSalesQueryResult.IsFailed) + return ResultHelpers.CreateFailure(comparisonSalesQueryResult); + var todaysSales = todaysSalesQueryResult.Data; + var comparisonSales = comparisonSalesQueryResult.Data; + + TodaysSales response = new() { + ComparisonSalesCount = comparisonSales.Count, + ComparisonSalesValue = comparisonSales.Sum(), + ComparisonAverageSalesValue = SafeDivide(comparisonSales.Sum(), comparisonSales.Count), + TodaysSalesCount = todaysSales.Count, + TodaysSalesValue = todaysSales.Sum(), + TodaysAverageSalesValue = SafeDivide(todaysSales.Sum(), todaysSales.Count) + }; + return Result.Success(response); + } + + public async Task> GetTodaysSales(TransactionQueries.TodaysSalesQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + IQueryable todaysSales = this.BuildTodaySalesQuery(context); + IQueryable comparisonSales = this.BuildComparisonSalesQuery(context, request.ComparisonDate); + + todaysSales = todaysSales.ApplyMerchantFilter(request.MerchantReportingId).ApplyOperatorFilter(request.OperatorReportingId); + comparisonSales = comparisonSales.ApplyMerchantFilter(request.MerchantReportingId).ApplyOperatorFilter(request.OperatorReportingId); + + var todaysSalesQueryResult = await ReportingQueryExecutor.ToListAsync(todaysSales, cancellationToken, "Error retrieving todays failed sales"); + if (todaysSalesQueryResult.IsFailed) + return ResultHelpers.CreateFailure(todaysSalesQueryResult); + var comparisonSalesQueryResult = await ReportingQueryExecutor.ToListAsync(comparisonSales, cancellationToken, "Error retrieving comparison failed sales"); + if (comparisonSalesQueryResult.IsFailed) + return ResultHelpers.CreateFailure(comparisonSalesQueryResult); + + + Decimal todaysSalesValue = await todaysSales.SumAsync(t => t.TransactionAmount, cancellationToken); + Int32 todaysSalesCount = await todaysSales.CountAsync(cancellationToken); + Decimal comparisonSalesValue = await comparisonSales.SumAsync(t => t.TransactionAmount, cancellationToken); + Int32 comparisonSalesCount = await comparisonSales.CountAsync(cancellationToken); + + TodaysSales response = new() { + ComparisonSalesCount = comparisonSalesCount, + ComparisonSalesValue = comparisonSalesValue, + TodaysSalesCount = todaysSalesCount, + TodaysSalesValue = todaysSalesValue, + TodaysAverageSalesValue = SafeDivide(todaysSalesValue, todaysSalesCount), + ComparisonAverageSalesValue = SafeDivide(comparisonSalesValue, comparisonSalesCount) + }; + return Result.Success(response); + } + + private Int32 SafeDivide(Int32 number, + Int32 divisor) { + if (divisor == 0) return number; + + return number / divisor; + } + + private Decimal SafeDivide(Decimal number, + Int32 divisor) { + if (divisor == 0) return number; + + return number / divisor; + } + public async Task> GetTransactionDetailReport(TransactionQueries.TransactionDetailReportQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var query = ApplyTransactionDetailFilters(BuildTransactionDetailBaseQuery(context, request.Request), request.Request); + var queryResult = await ReportingQueryExecutor.ToListAsync(query, cancellationToken, "Error retrieving transaction details report"); + + if (queryResult.IsFailed) + return ResultHelpers.CreateFailure(queryResult); + + var queryResults = queryResult.Data; + + if (queryResults.Any() == false) + return new TransactionDetailReportResponse { Summary = new TransactionDetailSummary(), Transactions = new List() }; + + return Result.Success(MapToTransactionDetailResponse(queryResults)); + } + + private static IQueryable BuildTransactionDetailBaseQuery(EstateManagementContext context, + TransactionDetailReportRequest request) { + return from t in context.Transactions + join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } + join m in context.Merchants on t.MerchantId equals m.MerchantId + join o in context.Operators on t.OperatorId equals o.OperatorId + join msf in context.MerchantSettlementFees on t.TransactionId equals msf.TransactionId into msfJoin + from msf in msfJoin.DefaultIfEmpty() + // left join Settlements (msf may be null) + join s in context.Settlements on msf.SettlementId equals s.SettlementId into sJoin + from s in sJoin.DefaultIfEmpty() + where t.TransactionType != "Logon" && t.TransactionDate >= request.StartDate && t.TransactionDate <= request.EndDate + select new TransactionDetailQueryResult { + TransactionId = t.TransactionId, + TransactionDateTime = t.TransactionDateTime, + MerchantId = m.MerchantId, + MerchantReportingId = m.MerchantReportingId, + MerchantName = m.Name, + OperatorId = o.OperatorId, + OperatorReportingId = o.OperatorReportingId, + OperatorName = o.Name, + ProductName = cp.ProductName, + ContractProductId = cp.ContractProductId, + ContractProductReportingId = cp.ContractProductReportingId, + TransactionType = t.TransactionType, + Status = t.IsAuthorised ? "Authorised" : "Declined", + Value = t.TransactionAmount, + FeeValue = msf != null ? msf.FeeValue : 0m, + SettlementId = s != null ? s.SettlementId : Guid.Empty, + TransactionNumber = Int32.Parse(t.TransactionNumber) + }; + } + + private static IQueryable ApplyTransactionDetailFilters(IQueryable query, + TransactionDetailReportRequest request) { + if (request.Merchants != null && request.Merchants.Any()) + query = query.Where(q => request.Merchants.Contains(q.MerchantReportingId)); + if (request.Products != null && request.Products.Any()) + query = query.Where(q => request.Products.Contains(q.ContractProductReportingId)); + if (request.Operators != null && request.Operators.Any()) + query = query.Where(q => request.Operators.Contains(q.OperatorReportingId)); + return query; + } + + private static TransactionDetailReportResponse MapToTransactionDetailResponse(List queryResults) { + return new TransactionDetailReportResponse { + Transactions = queryResults.Select(q => new TransactionDetail { + Id = q.TransactionId, + DateTime = q.TransactionDateTime, + MerchantId = q.MerchantId, + MerchantReportingId = q.MerchantReportingId, + Merchant = q.MerchantName, + OperatorId = q.OperatorId, + OperatorReportingId = q.OperatorReportingId, + Operator = q.OperatorName, + Product = q.ProductName, + ProductId = q.ContractProductId, + ProductReportingId = q.ContractProductReportingId, + Type = q.TransactionType, + Status = q.Status, + Value = q.Value, + TotalFees = q.FeeValue, + SettlementReference = q.SettlementId.ToString(), + TransactionNumber = q.TransactionNumber + }).ToList(), + Summary = new TransactionDetailSummary { TransactionCount = queryResults.Count(), TotalValue = queryResults.Sum(q => q.Value), TotalFees = queryResults.Sum(q => q.FeeValue) } + }; + } + + public async Task> GetTransactionSummaryByMerchantReport(TransactionQueries.TransactionSummaryByMerchantQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var query = BuildMerchantTransactionBaseQuery(context, request.Request.StartDate, request.Request.EndDate); + + if (request.Request.Merchants != null && request.Request.Merchants.Any()) + query = query.Where(q => request.Request.Merchants.Contains(q.MerchantReportingId)); + + if (request.Request.Operators != null && request.Request.Operators.Any()) + query = query.Where(q => request.Request.Operators.Contains(q.OperatorReportingId)); + + var finalQuery = BuildMerchantTransactionFinalQuery(query); + + var queryResult = await ReportingQueryExecutor.ToListAsync(finalQuery, cancellationToken, "Error retrieving transaction summary by merchant report"); + + if (queryResult.IsFailed) + return ResultHelpers.CreateFailure(queryResult); + + var queryResults = queryResult.Data; + + if (queryResults.Any() == false) + return new TransactionSummaryByMerchantResponse { Summary = new MerchantDetailSummary(), Merchants = new List() }; + + return new TransactionSummaryByMerchantResponse { + Merchants = queryResults.Select(q => new MerchantDetail { + MerchantId = q.MerchantId, + MerchantReportingId = q.MerchantReportingId, + MerchantName = q.MerchantName, + AuthorisedCount = q.AuthorisedCount, + AuthorisedPercentage = q.AuthorisedPercentage, + AverageValue = q.AverageValue, + DeclinedCount = q.DeclinedCount, + TotalCount = q.TotalCount, + TotalValue = q.TotalValue + }).ToList(), + Summary = new MerchantDetailSummary { + TotalCount = queryResults.Sum(q => q.TotalCount), + TotalValue = queryResults.Sum(q => q.TotalValue), + AverageValue = SafeDivide(queryResults.Sum(q => q.TotalValue), queryResults.Sum(q => q.TotalCount)), + TotalMerchants = queryResults.Count() + } + }; + } + + private static IQueryable BuildMerchantTransactionBaseQuery(EstateManagementContext context, + DateTime startDate, + DateTime endDate) { + return from t in context.Transactions + join m in context.Merchants on t.MerchantId equals m.MerchantId + join o in context.Operators on t.OperatorId equals o.OperatorId + where t.TransactionType == "Sale" && t.TransactionDate >= startDate && t.TransactionDate <= endDate + group t by new { t.MerchantId, m.MerchantReportingId, MerchantName = m.Name, t.OperatorId, o.OperatorReportingId } + into g + select new MerchantTransactionGroupProjection { + MerchantId = g.Key.MerchantId, + MerchantReportingId = g.Key.MerchantReportingId, + MerchantName = g.Key.MerchantName, + OperatorId = g.Key.OperatorId, + OperatorReportingId = g.Key.OperatorReportingId, + TotalCount = g.Count(), + TotalValue = g.Sum(x => x.TransactionAmount), + AuthorisedCount = g.Sum(x => x.IsAuthorised ? 1 : 0), + DeclinedCount = g.Sum(x => x.IsAuthorised ? 0 : 1) + }; + } + + private static IQueryable BuildMerchantTransactionFinalQuery(IQueryable query) { + return from x in query + group x by new { x.MerchantId, x.MerchantReportingId, x.MerchantName } + into g + select new MerchantTransactionFinalProjection { + MerchantId = g.Key.MerchantId, + MerchantReportingId = g.Key.MerchantReportingId, + MerchantName = g.Key.MerchantName, + TotalCount = g.Sum(x => x.TotalCount), + TotalValue = g.Sum(x => x.TotalValue), + AverageValue = g.Count() > 0 ? g.Sum(x => x.TotalValue) / g.Count() : 0m, + AuthorisedCount = g.Sum(x => x.AuthorisedCount), + DeclinedCount = g.Sum(x => x.DeclinedCount), + AuthorisedPercentage = g.Sum(x => x.TotalCount) > 0 ? (decimal)g.Sum(x => x.AuthorisedCount) / (decimal)g.Sum(x => x.TotalCount) : 0m + }; + } + + public async Task> GetTransactionSummaryByOperatorReport(TransactionQueries.TransactionSummaryByOperatorQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var baseQuery = BuildOperatorTransactionBaseQuery(context, request); + var finalQuery = BuildOperatorFinalSummaryQuery(baseQuery); + + var queryResult = await ReportingQueryExecutor.ToListAsync(finalQuery, cancellationToken, "Error retrieving transaction summary by operator report"); + + if (queryResult.IsFailed) + return ResultHelpers.CreateFailure(queryResult); + + var queryResults = queryResult.Data; + + if (queryResults.Any() == false) + return new TransactionSummaryByOperatorResponse { Summary = new OperatorDetailSummary(), Operators = new List() }; + + return BuildOperatorSummaryResponse(queryResults); + } + + private static IQueryable BuildOperatorTransactionBaseQuery(EstateManagementContext context, + TransactionQueries.TransactionSummaryByOperatorQuery request) { + var query = from t in context.Transactions + join m in context.Merchants on t.MerchantId equals m.MerchantId + join o in context.Operators on t.OperatorId equals o.OperatorId + where t.TransactionType == "Sale" && t.TransactionDate >= request.Request.StartDate && t.TransactionDate <= request.Request.EndDate + group t by new { + t.MerchantId, + m.MerchantReportingId, + MerchantName = m.Name, + t.OperatorId, + o.OperatorReportingId, + OperatorName = o.Name + } + into g + select new OperatorTransactionData { + MerchantId = g.Key.MerchantId, + MerchantReportingId = g.Key.MerchantReportingId, + MerchantName = g.Key.MerchantName, + OperatorId = g.Key.OperatorId, + OperatorReportingId = g.Key.OperatorReportingId, + OperatorName = g.Key.OperatorName, + TotalCount = g.Count(), + TotalValue = g.Sum(x => x.TransactionAmount), + AuthorisedCount = g.Sum(x => x.IsAuthorised ? 1 : 0), + DeclinedCount = g.Sum(x => x.IsAuthorised ? 0 : 1) + }; + + return ApplyOperatorTransactionFilters(query, request.Request); + } + + private static IQueryable ApplyOperatorTransactionFilters(IQueryable query, + Models.TransactionSummaryByOperatorRequest request) { + if (request.Merchants != null && request.Merchants.Any()) + query = query.Where(q => request.Merchants.Contains(q.MerchantReportingId)); + + if (request.Operators != null && request.Operators.Any()) + query = query.Where(q => request.Operators.Contains(q.OperatorReportingId)); + + return query; + } + + private static IQueryable BuildOperatorFinalSummaryQuery(IQueryable query) { + return from x in query + group x by new { x.OperatorId, x.OperatorReportingId, x.OperatorName } + into g + select new OperatorSummaryData { + OperatorId = g.Key.OperatorId, + OperatorReportingId = g.Key.OperatorReportingId, + OperatorName = g.Key.OperatorName, + TotalCount = g.Sum(x => x.TotalCount), + TotalValue = g.Sum(x => x.TotalValue), + AverageValue = g.Count() > 0 ? g.Sum(x => x.TotalValue) / g.Count() : 0m, + AuthorisedCount = g.Sum(x => x.AuthorisedCount), + DeclinedCount = g.Sum(x => x.DeclinedCount), + AuthorisedPercentage = g.Sum(x => x.TotalCount) > 0 ? (decimal)g.Sum(x => x.AuthorisedCount) / (decimal)g.Sum(x => x.TotalCount) : 0m + }; + } + + private TransactionSummaryByOperatorResponse BuildOperatorSummaryResponse(List queryResults) { + return new TransactionSummaryByOperatorResponse { + Operators = queryResults.Select(q => new OperatorDetail { + OperatorId = q.OperatorId, + OperatorReportingId = q.OperatorReportingId, + OperatorName = q.OperatorName, + AuthorisedCount = q.AuthorisedCount, + AuthorisedPercentage = q.AuthorisedPercentage, + AverageValue = q.AverageValue, + DeclinedCount = q.DeclinedCount, + TotalCount = q.TotalCount, + TotalValue = q.TotalValue + }).ToList(), + Summary = new OperatorDetailSummary { + TotalCount = queryResults.Sum(q => q.TotalCount), + TotalValue = queryResults.Sum(q => q.TotalValue), + AverageValue = SafeDivide(queryResults.Sum(q => q.TotalValue), queryResults.Sum(q => q.TotalCount)), + TotalOperators = queryResults.Count() + } + }; + } + public async Task> GetProductPerformanceReport(TransactionQueries.ProductPerformanceQuery request, + CancellationToken cancellationToken) { + + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + var grandTotalAmountQuery = (from t in context.Transactions where t.TransactionType == "Sale" && t.TransactionDate >= request.StartDate && t.TransactionDate <= request.EndDate select t.TransactionAmount); + var grandTotalAmountResult = await ReportingQueryExecutor.SumAsync(grandTotalAmountQuery, cancellationToken); + if (grandTotalAmountResult.IsFailed) + return ResultHelpers.CreateFailure(grandTotalAmountResult); + var grandTotalAmount = grandTotalAmountResult.Data; + + var query = BuildProductPerformanceQuery(context, request.StartDate, request.EndDate, grandTotalAmount); + var queryResult = await ReportingQueryExecutor.ToListAsync(query, cancellationToken); + if (queryResult.IsFailed) + return ResultHelpers.CreateFailure(queryResult); + var queryResults = queryResult.Data; + if (queryResults.Any() == false) + return Result.Success(new ProductPerformanceResponse() { Summary = new ProductPerformanceSummary(), ProductDetails = new List() }); + + return Result.Success(BuildProductPerformanceResponse(queryResults)); + } + + public async Task> GetTransactionMixSummary(TransactionQueries.TransactionMixSummaryQuery request, + CancellationToken cancellationToken) + { + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + if (request.Request.StartDate > request.Request.EndDate) + return Result.Failure("StartDate must be less than or equal to EndDate."); + + if (!Enum.IsDefined(typeof(TransactionMixBreakdown), request.Request.Breakdown)) + return Result.Failure("Unsupported transaction mix breakdown."); + + if (!Enum.IsDefined(typeof(TransactionMixMeasure), request.Request.Measure)) + return Result.Failure("Unsupported transaction mix measure."); + + var detailRequest = new TransactionDetailReportRequest + { + Merchants = request.Request.MerchantReportingId.HasValue ? new List { request.Request.MerchantReportingId.Value } : [], + Operators = [], + Products = [], + StartDate = request.Request.StartDate, + EndDate = request.Request.EndDate + }; + + var query = ApplyTransactionDetailFilters(BuildTransactionDetailBaseQuery(context, detailRequest), detailRequest); + var queryResult = await ReportingQueryExecutor.ToListAsync(query, cancellationToken, "Error retrieving transaction mix summary report"); + + if (queryResult.IsFailed) + return ResultHelpers.CreateFailure(queryResult); + + var queryResults = queryResult.Data; + + if (queryResults.Any() == false) + { + return Result.Success(new TransactionMixSummaryResponse + { + FromDate = request.Request.StartDate, + ToDate = request.Request.EndDate, + Breakdown = request.Request.Breakdown, + Measure = request.Request.Measure, + TotalCount = 0, + TotalValue = 0m, + Groups = [], + Transactions = [] + }); + } + + return Result.Success(BuildTransactionMixSummaryResponse(queryResults, request.Request)); + } + + public async Task> GetRecentActivityReceiptReport(TransactionQueries.GetRecentActivityReceiptReportQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + RecentActivityReceiptRequestParameters parameters = BuildRecentActivityReceiptRequestParameters(request.Request); + IQueryable query = BuildRecentActivityReceiptQuery(context, parameters); + + var totalCountResult = await ReportingQueryExecutor.CountAsync(query, cancellationToken, "Error counting recent activity receipt report rows"); + if (totalCountResult.IsFailed) + return ResultHelpers.CreateFailure(totalCountResult); + + int totalCount = totalCountResult.Data; + if (totalCount == 0) + return Result.Success(BuildEmptyRecentActivityReceiptReportResponse(parameters)); + + var pageQuery = query.Skip((parameters.PageNumber - 1) * parameters.PageSize).Take(parameters.PageSize); + var pageResult = await ReportingQueryExecutor.ToListAsync(pageQuery, cancellationToken, "Error retrieving recent activity receipt report"); + if (pageResult.IsFailed) + return ResultHelpers.CreateFailure(pageResult); + + return Result.Success(BuildRecentActivityReceiptReportResponse(parameters, totalCount, pageResult.Data)); + } + + private static RecentActivityReceiptRequestParameters BuildRecentActivityReceiptRequestParameters(GetRecentActivityReceiptReportRequest request) { + return new RecentActivityReceiptRequestParameters { + ReportDate = request.ReportDate.Date, + MerchantReportingId = request.MerchantReportingId, + SearchText = request.SearchText?.Trim(), + PageNumber = request.PageNumber > 0 ? request.PageNumber : 1, + PageSize = request.PageSize > 0 ? request.PageSize : 10 + }; + } + + private static IQueryable BuildRecentActivityReceiptQuery(EstateManagementContext context, + RecentActivityReceiptRequestParameters parameters) { + IQueryable query = from t in context.Transactions + join m in context.Merchants on t.MerchantId equals m.MerchantId + join o in context.Operators on t.OperatorId equals o.OperatorId + join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } + where t.TransactionDate == parameters.ReportDate + select new RecentActivityReceiptQueryResult { + TransactionDateTime = t.TransactionDateTime, + MerchantReportingId = m.MerchantReportingId, + Reference = t.TransactionNumber, + TransactionType = t.TransactionType, + Product = cp.ProductName, + Operator = o.Name, + Status = t.IsAuthorised ? "Successful" : "Failed", + Amount = t.TransactionAmount, + ReceiptReference = t.TransactionReference + }; + + return ApplyRecentActivityReceiptFilters(query, parameters).OrderByDescending(q => q.TransactionDateTime); + } + + private static IQueryable ApplyRecentActivityReceiptFilters(IQueryable query, + RecentActivityReceiptRequestParameters parameters) { + if (parameters.MerchantReportingId.HasValue) + query = query.Where(q => q.MerchantReportingId == parameters.MerchantReportingId.Value); + + if (string.IsNullOrWhiteSpace(parameters.SearchText) == false) + query = query.Where(q => + q.Reference.Contains(parameters.SearchText) || + q.TransactionType.Contains(parameters.SearchText) || + q.Product.Contains(parameters.SearchText) || + q.Operator.Contains(parameters.SearchText) || + q.Status.Contains(parameters.SearchText) || + q.ReceiptReference.Contains(parameters.SearchText)); + + return query; + } + + private static GetRecentActivityReceiptReportResponse BuildEmptyRecentActivityReceiptReportResponse(RecentActivityReceiptRequestParameters parameters) { + return new GetRecentActivityReceiptReportResponse { + ReportDate = parameters.ReportDate, + PageNumber = parameters.PageNumber, + PageSize = parameters.PageSize, + TotalCount = 0, + Items = [] + }; + } + + private static GetRecentActivityReceiptReportResponse BuildRecentActivityReceiptReportResponse(RecentActivityReceiptRequestParameters parameters, + int totalCount, + List queryResults) { + return new GetRecentActivityReceiptReportResponse { + ReportDate = parameters.ReportDate, + PageNumber = parameters.PageNumber, + PageSize = parameters.PageSize, + TotalCount = totalCount, + Items = queryResults.Select(MapRecentActivityReceiptItem).ToList() + }; + } + + private static RecentActivityReceiptItemDto MapRecentActivityReceiptItem(RecentActivityReceiptQueryResult item) { + return new RecentActivityReceiptItemDto { + Reference = item.Reference, + TransactionType = item.TransactionType, + Product = item.Product, + Operator = item.Operator, + Status = item.Status, + Amount = item.Amount, + TransactionDateTime = item.TransactionDateTime, + ReceiptReference = item.ReceiptReference + }; + } + + private static IQueryable BuildProductPerformanceQuery(EstateManagementContext context, + DateTime startDate, + DateTime endDate, + decimal grandTotalAmount) { + return from t in context.Transactions + join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } + join c in context.Contracts on t.ContractId equals c.ContractId + where t.TransactionType == "Sale" && t.TransactionDate >= startDate && t.TransactionDate <= endDate + group t by new { cp.ProductName, cp.ContractProductId, cp.ContractProductReportingId, c.ContractId, c.ContractReportingId } + into g + select new ProductPerformanceItemData { + ProductName = g.Key.ProductName, + ContractProductId = g.Key.ContractProductId, + ContractProductReportingId = g.Key.ContractProductReportingId, + ContractId = g.Key.ContractId, + ContractReportingId = g.Key.ContractReportingId, + TransactionCount = g.Count(), + TotalAmount = g.Sum(x => x.TransactionAmount), + PercentOfTotalAmount = grandTotalAmount == 0 ? 0 : 100.0m * g.Sum(x => x.TransactionAmount) / grandTotalAmount + }; + } + + private ProductPerformanceResponse BuildProductPerformanceResponse(List queryResults) { + return new ProductPerformanceResponse { + ProductDetails = queryResults.Select(q => new ProductPerformanceDetail { + ProductName = q.ProductName, + ProductId = q.ContractProductId, + ProductReportingId = q.ContractProductReportingId, + ContractId = q.ContractId, + ContractReportingId = q.ContractReportingId, + TransactionCount = q.TransactionCount, + TransactionValue = q.TotalAmount, + PercentageOfTotal = q.PercentOfTotalAmount + }).ToList(), + Summary = new ProductPerformanceSummary { TotalCount = queryResults.Sum(q => q.TransactionCount), TotalValue = queryResults.Sum(q => q.TotalAmount), AveragePerProduct = SafeDivide(queryResults.Sum(q => q.TotalAmount), queryResults.Count), TotalProducts = queryResults.Count() } + }; + } + + private static TransactionMixSummaryResponse BuildTransactionMixSummaryResponse(List queryResults, + TransactionMixSummaryRequest request) + { + var groupedResults = queryResults + .GroupBy(q => request.Breakdown switch + { + TransactionMixBreakdown.Product => q.ContractProductReportingId.ToString(), + TransactionMixBreakdown.TransactionType => q.TransactionType ?? string.Empty, + TransactionMixBreakdown.Operator => q.OperatorReportingId.ToString(), + TransactionMixBreakdown.Status => q.Status ?? string.Empty, + _ => string.Empty + }) + .Select(g => + { + var first = g.First(); + string groupName = request.Breakdown switch + { + TransactionMixBreakdown.Product => first.ProductName ?? string.Empty, + TransactionMixBreakdown.TransactionType => first.TransactionType ?? string.Empty, + TransactionMixBreakdown.Operator => first.OperatorName ?? string.Empty, + TransactionMixBreakdown.Status => first.Status ?? string.Empty, + _ => string.Empty + }; + + return new TransactionMixSummaryGroup + { + GroupKey = g.Key, + GroupName = groupName, + TransactionCount = g.Count(), + TransactionValue = g.Sum(x => x.Value) + }; + }); + + var orderedGroups = request.Measure == TransactionMixMeasure.Count + ? groupedResults.OrderByDescending(g => g.TransactionCount).ThenBy(g => g.GroupName) + : groupedResults.OrderByDescending(g => g.TransactionValue).ThenBy(g => g.GroupName); + + int topN = request.TopN > 0 ? request.TopN : 5; + + return new TransactionMixSummaryResponse + { + FromDate = request.StartDate, + ToDate = request.EndDate, + Breakdown = request.Breakdown, + Measure = request.Measure, + TotalCount = queryResults.Count, + TotalValue = queryResults.Sum(q => q.Value), + Groups = orderedGroups.Take(topN).ToList(), + Transactions = queryResults + .OrderByDescending(q => q.TransactionDateTime) + .Select(q => new TransactionMixSummaryTransaction + { + Id = q.TransactionId, + DateTime = q.TransactionDateTime, + Merchant = q.MerchantName, + MerchantId = q.MerchantId, + MerchantReportingId = q.MerchantReportingId, + Operator = q.OperatorName, + OperatorId = q.OperatorId, + OperatorReportingId = q.OperatorReportingId, + Product = q.ProductName, + ProductId = q.ContractProductId, + ProductReportingId = q.ContractProductReportingId, + Type = q.TransactionType, + Status = q.Status, + Value = q.Value, + TotalFees = q.FeeValue, + SettlementReference = q.SettlementId == Guid.Empty ? null : q.SettlementId.ToString(), + TransactionNumber = q.TransactionNumber + }) + .ToList() + }; + } + + public async Task>> GetTodaysSalesByHour(TransactionQueries.TodaysSalesByHour request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.estateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + IQueryable todaysSales = this.BuildTodaySalesQuery(context); + IQueryable comparisonSales = this.BuildComparisonSalesQuery(context, request.comparisonDate); + + // First we need to get a value of todays sales + var todaysSalesByHourQuery = (from t in todaysSales group t.TransactionAmount by t.Hour into g select new { Hour = g.Key, TotalSalesCount = g.Count(), TotalSalesValue = g.Sum() }); + var todaysSalesByHourQueryResult = await ReportingQueryExecutor.ToListAsync(todaysSalesByHourQuery, cancellationToken); + if (todaysSalesByHourQueryResult.IsFailed) + return ResultHelpers.CreateFailure(todaysSalesByHourQueryResult); + var todaysSalesByHour = todaysSalesByHourQueryResult.Data; + + var comparisonSalesByHourQuery = (from t in comparisonSales group t.TransactionAmount by t.Hour into g select new { Hour = g.Key, TotalSalesCount = g.Count(), TotalSalesValue = g.Sum() }); + var comparisonSalesByHourQueryResult = await ReportingQueryExecutor.ToListAsync(comparisonSalesByHourQuery, cancellationToken); + if (comparisonSalesByHourQueryResult.IsFailed) + return ResultHelpers.CreateFailure(comparisonSalesByHourQueryResult); + var comparisonSalesByHour = comparisonSalesByHourQueryResult.Data; + + var response = (from today in todaysSalesByHour + join comparison in comparisonSalesByHour on today.Hour equals comparison.Hour into compGroup + from comparison in compGroup.DefaultIfEmpty() + select new TodaysSalesByHour { + Hour = today.Hour.Value, + TodaysSalesCount = today.TotalSalesCount, + TodaysSalesValue = today.TotalSalesValue, + ComparisonSalesCount = comparison?.TotalSalesCount ?? 0, + ComparisonSalesValue = comparison?.TotalSalesValue ?? 0 + }).Union(from comparison in comparisonSalesByHour + join today in todaysSalesByHour on comparison.Hour equals today.Hour into todayGroup + from today in todayGroup.DefaultIfEmpty() + where today == null + select new TodaysSalesByHour { + Hour = comparison.Hour.Value, + TodaysSalesCount = 0, + TodaysSalesValue = 0, + ComparisonSalesCount = comparison.TotalSalesCount, + ComparisonSalesValue = comparison.TotalSalesValue + }).ToList(); + + + return Result.Success(response); + } + + public async Task> GetMerchantDailyPerformanceSummary(TransactionQueries.MerchantDailyPerformanceSummaryQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + DateTime startDate = request.Request.StartDate.Date; + DateTime endDate = request.Request.EndDate.Date; + int dayCount = Math.Max((endDate - startDate).Days + 1, 1); + + var groupedTransactionsResult = await LoadMerchantDailyPerformanceGroups(context, request, startDate, endDate, cancellationToken); + if (groupedTransactionsResult.IsFailed) + return ResultHelpers.CreateFailure(groupedTransactionsResult); + + var groupedTransactions = groupedTransactionsResult.Data; + if (groupedTransactions.Any() == false) { + return Result.Success(BuildEmptyMerchantDailyPerformanceSummaryResponse()); + } + + List metrics = BuildMerchantDailyPerformanceMetrics(groupedTransactions, dayCount); + + var recentSalesResult = await LoadMerchantDailyPerformanceRecentSales(context, request, startDate, endDate, cancellationToken); + if (recentSalesResult.IsFailed) + return ResultHelpers.CreateFailure(recentSalesResult); + + return Result.Success(new MerchantDailyPerformanceSummaryResponse { + Metrics = metrics, + DrillDownTransactions = MapMerchantDailyPerformanceRecentSales(recentSalesResult.Data) + }); + } + + private IQueryable BuildTodaySalesQuery(EstateManagementContext context) { + return from t in context.TodayTransactions where t.IsAuthorised && t.TransactionType == "Sale" && t.TransactionDate == DateTime.Now.Date && t.TransactionTime <= DateTime.Now.TimeOfDay select t; + } + + private IQueryable BuildTodaysFailedSalesQuery(EstateManagementContext context, + String responseCode) { + return from t in context.TodayTransactions + where t.IsAuthorised == false && t.TransactionType == "Sale" && t.ResponseCode == responseCode + select t.TransactionAmount; + } + + private IQueryable BuildComparisonSalesQuery(EstateManagementContext context, + DateTime comparisonDate) { + return from t in context.TransactionHistory where t.IsAuthorised && t.TransactionType == "Sale" && t.TransactionDate == comparisonDate && t.TransactionTime <= DateTime.Now.TimeOfDay select t; + } + + private IQueryable BuildComparisonFailedSalesQuery(EstateManagementContext context, + DateTime comparisonDate, + String responseCode) { + return from t in context.TransactionHistory + where t.IsAuthorised == false && t.TransactionType == "Sale" && t.TransactionDate == comparisonDate && t.TransactionTime <= DateTime.Now.TimeOfDay && t.ResponseCode == responseCode + select t.TransactionAmount; + } + + + private sealed class MerchantTransactionGroupProjection { + public Guid MerchantId { get; init; } + public int MerchantReportingId { get; init; } + public string MerchantName { get; init; } + public Guid OperatorId { get; init; } + public int OperatorReportingId { get; init; } + public int TotalCount { get; init; } + public decimal TotalValue { get; init; } + public int AuthorisedCount { get; init; } + public int DeclinedCount { get; init; } + } + + private sealed class MerchantTransactionFinalProjection { + public Guid MerchantId { get; init; } + public int MerchantReportingId { get; init; } + public string MerchantName { get; init; } + public int TotalCount { get; init; } + public decimal TotalValue { get; init; } + public decimal AverageValue { get; init; } + public int AuthorisedCount { get; init; } + public int DeclinedCount { get; init; } + public decimal AuthorisedPercentage { get; init; } + } + + private sealed class MerchantDailyPerformanceGroupProjection { + public int ContractProductReportingId { get; init; } + public string? ProductName { get; init; } + public string OperatorName { get; init; } + public bool IsAuthorised { get; init; } + public int SalesCount { get; init; } + public decimal SalesValue { get; init; } + } + + private sealed class MerchantDailyPerformanceRecentSaleProjection { + public string Reference { get; init; } + public string? Product { get; init; } + public String Operator { get; init; } + public string Status { get; init; } + public decimal Amount { get; init; } + public DateTime TransactionDateTime { get; init; } + } + + private static MerchantDailyPerformanceSummaryResponse BuildEmptyMerchantDailyPerformanceSummaryResponse() { + return new MerchantDailyPerformanceSummaryResponse { + Metrics = BuildMerchantDailyPerformanceBaseMetrics() + }; + } + + private static List BuildMerchantDailyPerformanceBaseMetrics() { + return new List { + new() { Title = "Total Sales Count", Value = 0, Description = "All sales transactions in the range", Category = 1, Type = 0 }, + new() { Title = "Total Sales Value", Value = 0, Description = "All sales value in the range", Category = 1, Type = 1 }, + new() { Title = "Successful Sales Count", Value = 0, Description = "Authorised sales count in the range", Category = 2, Type = 2 }, + new() { Title = "Successful Sales Value", Value = 0, Description = "Authorised sales value in the range", Category = 2, Type = 3 }, + new() { Title = "Failed Sales Count", Value = 0, Description = "Declined sales count in the range", Category = 3, Type = 4 }, + new() { Title = "Failed Sales Value", Value = 0, Description = "Declined sales value in the range", Category = 3, Type = 5 }, + new() { Title = "Average Sales Count", Value = 0, Description = "Average sales count per day in the range", Category = 4, Type = 6 }, + new() { Title = "Average Sales Value", Value = 0, Description = "Average value per sale in the range", Category = 4, Type = 7 } + }; + } + + private static List BuildMerchantDailyPerformanceMetrics(List groupedTransactions, + int dayCount) { + int totalSalesCount = groupedTransactions.Sum(x => x.SalesCount); + decimal totalSalesValue = groupedTransactions.Sum(x => x.SalesValue); + int successfulSalesCount = groupedTransactions.Where(x => x.IsAuthorised).Sum(x => x.SalesCount); + decimal successfulSalesValue = groupedTransactions.Where(x => x.IsAuthorised).Sum(x => x.SalesValue); + int failedSalesCount = groupedTransactions.Where(x => !x.IsAuthorised).Sum(x => x.SalesCount); + decimal failedSalesValue = groupedTransactions.Where(x => !x.IsAuthorised).Sum(x => x.SalesValue); + decimal averageSalesCount = (decimal)totalSalesCount / dayCount; + decimal averageSalesValue = totalSalesCount == 0 ? 0m : totalSalesValue / totalSalesCount; + + List metrics = new() { + new() { Title = "Total Sales Count", Value = totalSalesCount, Description = "All sales transactions in the range", Category = 1, Type = 0}, + new() { Title = "Total Sales Value", Value = totalSalesValue, Description = "All sales value in the range", Category = 1, Type = 1 }, + new() { Title = "Successful Sales Count", Value = successfulSalesCount, Description = "Authorised sales count in the range", Category = 2, Type = 2 }, + new() { Title = "Successful Sales Value", Value = successfulSalesValue, Description = "Authorised sales value in the range", Category = 2, Type = 3 }, + new() { Title = "Failed Sales Count", Value = failedSalesCount, Description = "Declined sales count in the range", Category = 3, Type = 4 }, + new() { Title = "Failed Sales Value", Value = failedSalesValue, Description = "Declined sales value in the range", Category = 3, Type = 5 }, + new() { Title = "Average Sales Count", Value = averageSalesCount, Description = "Average sales count per day in the range", Category = 4, Type = 6 }, + new() { Title = "Average Sales Value", Value = averageSalesValue, Description = "Average value per sale in the range", Category = 4, Type = 7 } + }; + + MetricItem? topProductMetric = BuildTopProductMetric(groupedTransactions); + if (topProductMetric != null) + metrics.Add(topProductMetric); + + return metrics; + } + + private static MetricItem? BuildTopProductMetric(List groupedTransactions) { + var topProduct = groupedTransactions + .GroupBy(x => new { x.ContractProductReportingId, x.OperatorName, x.ProductName }) + .Select(g => new { + g.Key.ContractProductReportingId, + g.Key.ProductName, + g.Key.OperatorName, + SalesCount = g.Sum(x => x.SalesCount), + SalesValue = g.Sum(x => x.SalesValue) + }) + .OrderByDescending(x => x.SalesCount) + .ThenBy(x => x.ProductName) + .FirstOrDefault(); + + if (topProduct == null) + return null; + + return new MetricItem { + Title = "Top Product Sales Count", + Value = topProduct.SalesCount, + Description = string.IsNullOrWhiteSpace(topProduct.OperatorName) || string.IsNullOrWhiteSpace(topProduct.ProductName) + ? "Unknown product" + : $"{topProduct.OperatorName} {topProduct.ProductName}", + Category = 5, + Type = 8 + }; + } + + private async Task>> LoadMerchantDailyPerformanceGroups(EstateManagementContext context, + TransactionQueries.MerchantDailyPerformanceSummaryQuery request, + DateTime startDate, + DateTime endDate, + CancellationToken cancellationToken) { + var query = + from t in context.Transactions + join m in context.Merchants on t.MerchantId equals m.MerchantId + join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } + join op in context.Operators on t.OperatorId equals op.OperatorId into opJoin + from op in opJoin.DefaultIfEmpty() + where t.TransactionType == "Sale" + && t.TransactionDate >= startDate + && t.TransactionDate <= endDate + && m.MerchantReportingId == request.Request.MerchantReportingId + group t by new + { + cp.ContractProductReportingId, + OperatorName = op == null ? string.Empty : op.Name, + cp.ProductName, + t.IsAuthorised + } + into g + select new MerchantDailyPerformanceGroupProjection + { + ContractProductReportingId = g.Key.ContractProductReportingId, + ProductName = g.Key.ProductName, + OperatorName = g.Key.OperatorName, + IsAuthorised = g.Key.IsAuthorised, + SalesCount = g.Count(), + SalesValue = g.Sum(x => x.TransactionAmount) + }; + + return await ReportingQueryExecutor.ToListAsync(query, cancellationToken, "Error retrieving merchant daily performance summary"); + } + + private async Task>> LoadMerchantDailyPerformanceRecentSales(EstateManagementContext context, + TransactionQueries.MerchantDailyPerformanceSummaryQuery request, + DateTime startDate, + DateTime endDate, + CancellationToken cancellationToken) { + var query = + (from t in context.Transactions + join m in context.Merchants on t.MerchantId equals m.MerchantId + join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } + join op in context.Operators on t.OperatorId equals op.OperatorId into opJoin + from op in opJoin.DefaultIfEmpty() + where t.TransactionType == "Sale" + && t.TransactionDate >= startDate + && t.TransactionDate <= endDate + && m.MerchantReportingId == request.Request.MerchantReportingId + orderby t.TransactionDateTime descending + select new MerchantDailyPerformanceRecentSaleProjection + { + Reference = t.TransactionNumber, + Product = cp.ProductName, + Operator = op == null ? string.Empty : op.Name, + Status = t.IsAuthorised ? "Successful" : "Failed", + Amount = t.TransactionAmount, + TransactionDateTime = t.TransactionDateTime + }).Take(5); + + return await ReportingQueryExecutor.ToListAsync(query, cancellationToken, "Error retrieving merchant recent sales"); + } + + private static List MapMerchantDailyPerformanceRecentSales(List recentSales) { + return recentSales.Select(x => new DrillDownTransaction { + Reference = x.Reference, + Product = x.Product, + Operator = x.Operator, + Status = x.Status, + Amount = x.Amount, + TransactionDateTime = x.TransactionDateTime + }).ToList(); + } + + private sealed class RecentActivityReceiptQueryResult { + public DateTime TransactionDateTime { get; init; } + public int MerchantReportingId { get; init; } + public string Reference { get; init; } + public string? TransactionType { get; init; } + public string? Product { get; init; } + public string? Operator { get; init; } + public string? Status { get; init; } + public decimal Amount { get; init; } + public string? ReceiptReference { get; init; } + } + + private sealed class RecentActivityReceiptRequestParameters { + public DateTime ReportDate { get; init; } + public int? MerchantReportingId { get; init; } + public string? SearchText { get; init; } + public int PageNumber { get; init; } + public int PageSize { get; init; } + } + + private sealed class TransactionDetailQueryResult { + public Guid TransactionId { get; init; } + public DateTime TransactionDateTime { get; init; } + public Guid MerchantId { get; init; } + public int MerchantReportingId { get; init; } + public string? MerchantName { get; init; } + public Guid OperatorId { get; init; } + public int OperatorReportingId { get; init; } + public string? OperatorName { get; init; } + public string? ProductName { get; init; } + public Guid ContractProductId { get; init; } + public int ContractProductReportingId { get; init; } + public string? TransactionType { get; init; } + public string? Status { get; init; } + public decimal Value { get; init; } + public decimal FeeValue { get; init; } + public Guid SettlementId { get; init; } + public Int32 TransactionNumber { get; init; } + } + + private sealed class OperatorTransactionData { + public Guid MerchantId { get; init; } + public int MerchantReportingId { get; init; } + public string? MerchantName { get; init; } + public Guid OperatorId { get; init; } + public int OperatorReportingId { get; init; } + public string? OperatorName { get; init; } + public int TotalCount { get; init; } + public decimal TotalValue { get; init; } + public int AuthorisedCount { get; init; } + public int DeclinedCount { get; init; } + } + + private sealed class OperatorSummaryData { + public Guid OperatorId { get; init; } + public int OperatorReportingId { get; init; } + public string? OperatorName { get; init; } + public int TotalCount { get; init; } + public decimal TotalValue { get; init; } + public decimal AverageValue { get; init; } + public int AuthorisedCount { get; init; } + public int DeclinedCount { get; init; } + public decimal AuthorisedPercentage { get; init; } + } + + private sealed class ProductPerformanceItemData { + public string? ProductName { get; init; } + public Guid ContractProductId { get; init; } + public int ContractProductReportingId { get; init; } + public Guid ContractId { get; init; } + public int ContractReportingId { get; init; } + public int TransactionCount { get; init; } + public decimal TotalAmount { get; init; } + public decimal PercentOfTotalAmount { get; init; } + } +} diff --git a/EstateReportingAPI.IntegrationTests/CustomWebApplicationFactory.cs b/EstateReportingAPI.IntegrationTests/CustomWebApplicationFactory.cs index 2775dcf..1620885 100644 --- a/EstateReportingAPI.IntegrationTests/CustomWebApplicationFactory.cs +++ b/EstateReportingAPI.IntegrationTests/CustomWebApplicationFactory.cs @@ -66,9 +66,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) .Build(); IDbContextResolver resolver = new DbContextResolver(serviceProvider, configuration); - IReportingManager manager = new ReportingManager(resolver); - - containerBuilder.AddSingleton(manager); + containerBuilder.AddSingleton(resolver); containerBuilder.Configure(options => { diff --git a/EstateReportingAPI.Tests/EstateReportingAPI.Tests.csproj b/EstateReportingAPI.Tests/EstateReportingAPI.Tests.csproj index 1a152fa..7a49a89 100644 --- a/EstateReportingAPI.Tests/EstateReportingAPI.Tests.csproj +++ b/EstateReportingAPI.Tests/EstateReportingAPI.Tests.csproj @@ -4,7 +4,7 @@ net10.0 Exe true - Full + None false diff --git a/EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs b/EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs index aaf93cb..7932655 100644 --- a/EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs +++ b/EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs @@ -4,6 +4,7 @@ namespace EstateReportingAPI.Bootstrapper; using BusinessLogic; +using EstateReportingAPI.BusinessLogic.Services; using Lamar; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Internal; @@ -15,10 +16,11 @@ namespace EstateReportingAPI.Bootstrapper; [ExcludeFromCodeCoverage] public class RepositoryRegistry : ServiceRegistry{ public RepositoryRegistry() { - String? inTestMode = Environment.GetEnvironmentVariable("InTestMode"); - if (String.Compare(inTestMode, Boolean.TrueString, StringComparison.InvariantCultureIgnoreCase) != 0) { - this.AddSingleton(); - } + this.AddSingleton(); + this.AddSingleton(); + this.AddSingleton(); + this.AddSingleton(); + this.AddSingleton(); this.AddSingleton(); this.AddSingleton(typeof(IDbContextResolver<>), typeof(DbContextResolverX<>)); @@ -50,4 +52,4 @@ public RepositoryRegistry() { } } } -} \ No newline at end of file +} diff --git a/docs/superpowers/plans/2026-09-28-split-reporting-manager.md b/docs/superpowers/plans/2026-09-28-split-reporting-manager.md new file mode 100644 index 0000000..c7572aa --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-split-reporting-manager.md @@ -0,0 +1,259 @@ +# Split ReportingManager into Focused Reporting Services Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the 2,339-line `ReportingManager` boundary with focused reporting services injected directly into MediatR handlers, without changing API or MediatR request/response contracts. + +**Architecture:** Each reporting concern gets an interface and EF-backed implementation. Existing handlers retain their request types and `Handle` signatures but inject the relevant service directly. The old `IReportingManager` and `ReportingManager` are removed after all behavior and tests have moved to the services. + +**Tech Stack:** ASP.NET Core, Lamar DI, MediatR, Entity Framework Core, SimpleResults, xUnit, Shouldly, Imposter. + +**Spec:** `docs/superpowers/specs/2026-09-28-split-reporting-manager-design.md` + +## Global Constraints + +- Preserve every existing MediatR request type, handler response type, endpoint route, serialized response, result status, error message, query filter, ordering, date boundary, and cancellation-token use. +- Do not introduce a general-purpose repository abstraction or redesign database queries for performance. +- Keep focused services behind interfaces and register them in `EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs`. +- Remove `IReportingManager` and `ReportingManager` only after all production and test consumers have migrated. +- Keep the existing integration and report coverage; add service-focused coverage without requiring the old manager. + +## Review Focus + +- Handler wiring: every request is sent to the same service method with the same request and cancellation token. +- Result compatibility: EF failures, not-found results, empty lists, and error messages remain unchanged. +- Calculation reports: transaction summaries, averages, totals, and grouping preserve existing behavior. +- Cross-cutting lookups: estate IDs, date filters, ordering, and related merchant/operator/contract joins remain unchanged. +- Test composition: integration fixtures and generated imposters no longer depend on the removed manager interface. + +--- + +### Task 1: Add shared query execution helpers and service contracts + +**Files:** +- Create: `EstateReportingAPI.BusinessLogic/ReportingQueryExecutor.cs` +- Create: `EstateReportingAPI.BusinessLogic/ITransactionReportingService.cs` +- Create: `EstateReportingAPI.BusinessLogic/IMerchantReportingService.cs` +- Create: `EstateReportingAPI.BusinessLogic/IEstateReportingService.cs` +- Create: `EstateReportingAPI.BusinessLogic/ISettlementReportingService.cs` +- Create: `EstateReportingAPI.BusinessLogic/IFileImportReportingService.cs` +- Test: `EstateReportingAPI.BusinessLogic.UnitTests/ReportingQueryExecutorTests.cs` + +**Interfaces:** +- Produces service method signatures equivalent to the current `IReportingManager` methods, grouped by reporting concern. +- `ReportingQueryExecutor` preserves the current safe `SumAsync`, `ToListAsync`, `CountAsync`, and `SingleOrDefaultAsync` result/error behavior. + +- [ ] **Step 1: Write failing helper tests** + +Test successful list/count/sum/single results, not-found for a missing single result, and exception-to-failure conversion with and without a context message. + +- [ ] **Step 2: Run the focused tests and verify they fail** + +Run: `dotnet test EstateReportingAPI.BusinessLogic.UnitTests/EstateReportingAPI.BusinessLogic.UnitTests.csproj --filter FullyQualifiedName~ReportingQueryExecutorTests` + +Expected: FAIL because the helper does not exist. + +- [ ] **Step 3: Implement the helper and service interfaces** + +Copy the existing semantics from `ReportingManager.cs` without changing messages or result status. Define asynchronous methods accepting the existing query type and `CancellationToken`, returning the existing `Result` types. + +- [ ] **Step 4: Run the focused tests** + +Run the same focused test command. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add EstateReportingAPI.BusinessLogic EstateReportingAPI.BusinessLogic.UnitTests/ReportingQueryExecutorTests.cs +git commit -m "refactor: add reporting service contracts" +``` + +### Task 2: Extract transaction reporting and migrate transaction handlers + +**Files:** +- Create: `EstateReportingAPI.BusinessLogic/TransactionReportingService.cs` +- Modify: `EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs` +- Modify: `EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs` +- Modify: `EstateReportingAPI.BusinessLogic.UnitTests/RequestHandlerTests.cs` +- Create or modify: `EstateReportingAPI.BusinessLogic.UnitTests/TransactionReportingServiceTests.cs` + +**Interfaces:** +- Consumes: `ITransactionReportingService`, `ReportingQueryExecutor`, `IDbContextResolver`. +- Produces: all ten transaction methods currently beginning at `ReportingManager.cs:419`, `:447`, `:760`, `:848`, `:933`, `:1285`, `:1308`, `:1358`, `:1578`, and `:1853`. + +- [ ] **Step 1: Add focused failing service tests** + +Move or duplicate calculation-focused cases for transaction detail, merchant/operator summaries, product performance, transaction mix, and daily performance so they instantiate `TransactionReportingService` directly and assert the existing response values. + +- [ ] **Step 2: Run focused tests and verify the new service tests fail** + +Run: `dotnet test EstateReportingAPI.BusinessLogic.UnitTests/EstateReportingAPI.BusinessLogic.UnitTests.csproj --filter FullyQualifiedName~TransactionReportingServiceTests` + +- [ ] **Step 3: Extract the transaction query implementations** + +Move the method bodies unchanged into `TransactionReportingService`; replace private helper calls with `ReportingQueryExecutor` and retain all projection/calculation logic. + +- [ ] **Step 4: Change `TransactionRequestHandler` to inject the transaction service** + +Each `Handle` method calls the corresponding service method directly and returns its result unchanged. Keep all `IRequestHandler` declarations and method signatures unchanged. + +- [ ] **Step 5: Run transaction and handler tests** + +Run the focused service tests and `dotnet test EstateReportingAPI.BusinessLogic.UnitTests/EstateReportingAPI.BusinessLogic.UnitTests.csproj --filter FullyQualifiedName~RequestHandlerTests`. + +Expected: PASS with no `IReportingManager` dependency for transaction handlers. + +- [ ] **Step 6: Commit** + +```bash +git add EstateReportingAPI.BusinessLogic/TransactionReportingService.cs EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs EstateReportingAPI.BusinessLogic.UnitTests +git commit -m "refactor: extract transaction reporting service" +``` + +### Task 3: Extract estate, calendar, merchant, operator, and contract reporting + +**Files:** +- Create: `EstateReportingAPI.BusinessLogic/EstateReportingService.cs` +- Create: `EstateReportingAPI.BusinessLogic/MerchantReportingService.cs` +- Modify: `EstateReportingAPI.BusinessLogic/RequestHandlers/CalendarRequestHandler.cs` +- Modify: `EstateReportingAPI.BusinessLogic/RequestHandlers/EstateRequestHandler.cs` +- Modify: `EstateReportingAPI.BusinessLogic/RequestHandlers/MerchantRequestHandler.cs` +- Modify: `EstateReportingAPI.BusinessLogic/RequestHandlers/OperatorRequestHandler.cs` +- Modify: `EstateReportingAPI.BusinessLogic/RequestHandlers/ContractRequestHandler.cs` +- Modify: `EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs` +- Modify: `EstateReportingAPI.BusinessLogic.UnitTests/UnitTest1.cs` +- Modify: `EstateReportingAPI.BusinessLogic.UnitTests/RequestHandlerTests.cs` +- Create or modify: `EstateReportingAPI.BusinessLogic.UnitTests/EstateReportingServiceTests.cs` +- Create or modify: `EstateReportingAPI.BusinessLogic.UnitTests/MerchantReportingServiceTests.cs` + +**Interfaces:** +- `IEstateReportingService` owns calendar, estate, and estate-operator methods. +- `IMerchantReportingService` owns merchant, operator, contract, relationship, schedule, opening-hour, device, and KPI methods. + +- [ ] **Step 1: Add focused failing service tests** + +Move existing calendar, estate, contract, merchant, and operator assertions from manager-based setup to the appropriate service. Preserve date filters, ordering, related data, and empty/not-found expectations. + +- [ ] **Step 2: Run focused tests and verify they fail** + +Run both new service-test filters. Expected: FAIL until implementations exist. + +- [ ] **Step 3: Extract estate and merchant query implementations** + +Move the method bodies from `ReportingManager.cs:144-758` and `:1035-1284` into the two services, preserving joins, projections, helper calls, and result propagation. + +- [ ] **Step 4: Migrate the five lookup handlers** + +Inject `IEstateReportingService` into calendar/estate handlers and `IMerchantReportingService` into merchant/operator/contract handlers. Preserve each existing handler interface declaration and `Handle` signature. + +- [ ] **Step 5: Run focused service and handler tests** + +Run the new service filters and the full `RequestHandlerTests` filter. Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add EstateReportingAPI.BusinessLogic EstateReportingAPI.BusinessLogic.UnitTests +git commit -m "refactor: extract estate and merchant reporting services" +``` + +### Task 4: Extract settlement and file-import reporting + +**Files:** +- Create: `EstateReportingAPI.BusinessLogic/SettlementReportingService.cs` +- Create: `EstateReportingAPI.BusinessLogic/FileImportReportingService.cs` +- Modify: `EstateReportingAPI.BusinessLogic/RequestHandlers/SettlementRequestHandler.cs` +- Modify: `EstateReportingAPI.BusinessLogic/RequestHandlers/FileImportLogRequestHandler.cs` +- Modify: `EstateReportingAPI.BusinessLogic.UnitTests/RequestHandlerTests.cs` +- Modify or create focused service tests for settlement and file-import operations. + +**Interfaces:** +- `ISettlementReportingService` owns `GetTodaysSettlement`. +- `IFileImportReportingService` owns file-import log and file-profile configuration methods. + +- [ ] **Step 1: Add failing service tests** + +Cover settlement result propagation and file-import list/detail/configuration responses, including not-found and empty-list behavior currently provided by the manager. + +- [ ] **Step 2: Extract the implementations** + +Move the existing bodies from `ReportingManager.cs:1624-1852` into the two focused services and route safe query calls through the shared helper. + +- [ ] **Step 3: Migrate settlement and file-import handlers** + +Inject the focused interfaces directly and remove their `IReportingManager` dependencies. + +- [ ] **Step 4: Run focused tests and the handler tests** + +Run the service filters and `RequestHandlerTests`. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add EstateReportingAPI.BusinessLogic EstateReportingAPI.BusinessLogic.UnitTests +git commit -m "refactor: extract settlement and file import services" +``` + +### Task 5: Register services and remove the manager boundary + +**Files:** +- Modify: `EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs` +- Modify: `EstateReportingAPI.IntegrationTests/CustomWebApplicationFactory.cs` +- Modify: `EstateReportingAPI.BusinessLogic.UnitTests/RequestHandlerTests.cs` +- Modify: `EstateReportingAPI.BusinessLogic.UnitTests/UnitTest1.cs` +- Modify: `EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs` +- Delete: `EstateReportingAPI.BusinessLogic/ReportingManager.cs` + +- [ ] **Step 1: Update DI and test composition** + +Register each focused interface/implementation with the current manager lifetime. Update integration and unit fixtures to resolve or construct focused services directly. Remove generated `IReportingManager` imposter usage and replace it with focused service imposters. + +- [ ] **Step 2: Remove the old manager files and references** + +Delete `ReportingManager.cs` after `rg -n "ReportingManager|IReportingManager"` shows only intended historical documentation references. Remove obsolete usings and project references if any. + +- [ ] **Step 3: Run compile and focused regression tests** + +Run: `dotnet build EstateReportingAPI.sln --no-restore` + +Run: `dotnet test EstateReportingAPI.BusinessLogic.UnitTests/EstateReportingAPI.BusinessLogic.UnitTests.csproj --no-build` + +Expected: build succeeds and all business-logic unit tests pass. + +- [ ] **Step 4: Run integration tests** + +Run: `dotnet test EstateReportingAPI.IntegrationTests/EstateReportingAPI.IntegrationTests.csproj --no-restore` + +Expected: all existing integration tests pass with unchanged API responses. + +- [ ] **Step 5: Commit** + +```bash +git add EstateReportingAPI EstateReportingAPI.BusinessLogic EstateReportingAPI.BusinessLogic.UnitTests EstateReportingAPI.IntegrationTests +git commit -m "refactor: inject focused reporting services into handlers" +``` + +### Task 6: Final verification and review + +**Files:** +- Modify only if verification exposes a regression. + +- [ ] **Step 1: Run the complete solution test suite** + +Run: `dotnet test EstateReportingAPI.sln --no-restore` + +Expected: exit code 0 with zero failed tests. + +- [ ] **Step 2: Verify no old boundary remains** + +Run: `rg -n "IReportingManager|ReportingManager" EstateReportingAPI EstateReportingAPI.BusinessLogic EstateReportingAPI.BusinessLogic.UnitTests EstateReportingAPI.IntegrationTests`. + +Expected: no production or test references remain. + +- [ ] **Step 3: Review the final diff** + +Run: `git diff HEAD~5..HEAD --check` and inspect the complete diff for contract, registration, and test-composition changes. + +- [ ] **Step 4: Commit any required verification-only fixes** + +Use a focused commit message describing the verified regression fix; do not broaden scope. diff --git a/docs/superpowers/specs/2026-09-28-split-reporting-manager-design.md b/docs/superpowers/specs/2026-09-28-split-reporting-manager-design.md new file mode 100644 index 0000000..8115bae --- /dev/null +++ b/docs/superpowers/specs/2026-09-28-split-reporting-manager-design.md @@ -0,0 +1,87 @@ +# Split ReportingManager into Focused Reporting Services + +## Status + +Approved conversational design for issue #598. + +## Goal + +Remove the reporting god object by making MediatR request handlers depend directly on focused reporting services while preserving all existing request behavior, public API contracts, response shapes, result semantics, and endpoint wiring. + +## Current Context + +`ReportingManager` is a 2,339-line class implementing 29 reporting operations across calendar, estate, merchant, operator, contract, transaction, settlement, and file-import concerns. All request handlers depend on `IReportingManager`, and integration tests construct the concrete manager directly. Existing report tests also use the manager as their entry point. + +The refactor may change internal handler constructor dependencies, but must preserve every MediatR request type, handler response type, endpoint route, serialized response, and result behavior. `IReportingManager` and `ReportingManager` are implementation details that can be removed once all consumers and tests have moved to focused services. + +## Design + +### Handler boundaries + +Change each MediatR request handler to inject only the focused service or services required by the requests it handles. Keep request types and handler `Handle` signatures unchanged. Handlers remain thin adapters: they pass the request and cancellation token to the service and return the service result unchanged. + +Remove `IReportingManager` and `ReportingManager` after all production consumers, test fixtures, and generated test doubles have been migrated. No compatibility façade is required because the interface is internal application wiring rather than an HTTP/API contract. + +### Focused services + +Introduce focused interfaces and implementations in `EstateReportingAPI.BusinessLogic`: + +1. `ITransactionReportingService` / `TransactionReportingService` + - Today's sales and failed sales. + - Transaction detail, summary-by-merchant, summary-by-operator, product performance, transaction mix, recent activity receipt, sales-by-hour, and merchant daily performance reports. +2. `IMerchantReportingService` / `MerchantReportingService` + - Merchant and operator lookups and merchant-related relationships, schedules, opening hours, devices, contracts, and KPIs. + - Contract operations may be kept in this service because they are consumed as merchant/operator reporting lookups and share the same query dependencies; if code inspection shows a clean independent boundary, they may be split into `IContractReportingService`. +3. `IEstateReportingService` / `EstateReportingService` + - Estate, estate operators, and calendar comparison/date/year lookups. + - File-profile configuration lookup is included here only if its existing query dependencies are configuration-oriented; otherwise it remains with file-import reporting. +4. `ISettlementReportingService` / `SettlementReportingService` + - Today's settlement reporting. +5. `IFileImportReportingService` / `FileImportReportingService` + - File-import log list/detail operations and file-profile configuration operations not assigned to estate configuration. + +The final assignment of contract and file-profile methods must follow the existing method dependencies and avoid duplicating query logic. The acceptance criterion is cohesive responsibility, not a fixed number of classes. + +### Shared query execution + +Move the existing safe EF query wrappers (`SumAsync`, `ToListAsync`, `CountAsync`, and `SingleOrDefaultAsync`) into a small internal helper or service-local shared utility. Preserve their current failure conversion and `NotFound` behavior. Do not introduce a new general-purpose repository abstraction as part of this issue. + +### Dependency injection and construction + +Register each focused interface and implementation in `RepositoryRegistry` with the same lifetime as the current manager unless the implementation requires a different lifetime based on its dependencies. Remove the `IReportingManager`/`ReportingManager` registration after all handlers are migrated. + +Update handler tests, generated imposters, direct test construction, and integration fixtures to compose the focused services directly. Do not retain duplicate production implementations or a compatibility façade solely to keep old constructors working. + +### Testing strategy + +- Preserve and run the existing unit and integration suites to detect behavior and contract regressions. +- Add focused unit coverage for at least one calculation-heavy transaction-report service without constructing `ReportingManager`. +- Add focused coverage for handler-to-service wiring where practical. +- Move existing manager-based report setup to the extracted service under test without reducing endpoint or query coverage. + +## Data flow + +```text +HTTP endpoint -> MediatR request -> existing request handler + -> focused reporting service + -> EstateManagementContext via IDbContextResolver + -> existing Models + SimpleResults response contract +``` + +## Error handling and compatibility + +The extracted methods must preserve current result propagation, including failed query messages, not-found responses, empty-list behavior, ordering, date boundaries, and cancellation-token use. Handlers must return the delegated result unchanged. No endpoint route, MediatR query, DTO, model, or serialized response is changed. + +## Scope boundaries + +In scope: replacing the manager with focused interfaces/implementations, updating handler dependencies and DI wiring, moving or adapting tests, and adding focused service tests. + +Out of scope: changing API contracts, redesigning database queries for performance, changing result/error semantics, replacing MediatR, or broad repository abstractions. + +## Acceptance criteria mapping + +- Each extracted service has one cohesive reporting responsibility and an interface. +- Each MediatR handler depends directly on the focused service(s) required by its requests. +- Existing handlers, endpoints, response contracts, and result semantics remain unchanged. +- Existing integration tests continue to pass. +- At least one calculation-focused unit test exercises an extracted service without the entire manager.