using AccountsDAL.CustomCode.AccountReports.AccountTrialBalance; using AccountsDAL.DTO.AccountsReports; using AccountsDAL.DTO.Reports; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.Report; using GB5Shared.Export; using GB5Shared.Export.Pivot; using GB5Shared.Telemetry; namespace AccountsBLL.AccountReports.AccountTrialBalance { public class AccountTrialBalanceReportBLL : IAccountTrialBalanceReportBLL { private const int DEFAULT_MAX_RESULT_CAP = 500; // Dedicated MREPORTVIEW for the Periodic Pivot, seeded by // DB/Migrations/20260829_AccountTrialBalance_PeriodicPivot_ReportView_Seed_SqlServer.sql. // This report has exactly one fixed shape/view (not user-selectable), so the ID is a // constant here rather than a parameter threaded down from the SL layer. private const int PERIODIC_PIVOT_REPORT_VIEW_ID = 1000000805; private readonly IAccountTrialBalanceReportDAL _accountTrialBalanceReportDAL; private readonly IAccountReportAccessControlBLL _accessControlBLL; private readonly IDataPivotEngine _dataPivotEngine; private readonly IReportExport _reportExport; public AccountTrialBalanceReportBLL( IAccountTrialBalanceReportDAL accountTrialBalanceReportDAL, IAccountReportAccessControlBLL accessControlBLL, IDataPivotEngine dataPivotEngine, IReportExport reportExport) { _accountTrialBalanceReportDAL = accountTrialBalanceReportDAL; _accessControlBLL = accessControlBLL; _dataPivotEngine = dataPivotEngine; _reportExport = reportExport; } public async Task GetTrialBalanceReport( int firstNumber, int maxResult, CriteriaDTO criteriaDTO, List sortBy, int menuId, LoginDTO loginDTO) { try { GB5Trace.Step("parse-criteria-trial-balance", new { firstNumber, maxResult, menuId }); var criteria = AccountReportCriteriaParser.Parse(criteriaDTO, loginDTO); var cappedMaxResult = Math.Min(maxResult, DEFAULT_MAX_RESULT_CAP); await _accessControlBLL.EnforceForAsOnDateAsync(criteria, cappedMaxResult, menuId, loginDTO).ConfigureAwait(false); GB5Trace.Step("query-trial-balance", new { firstNumber, cappedMaxResult, criteria.GroupType }); return await _accountTrialBalanceReportDAL .GetTrialBalanceReport(firstNumber, cappedMaxResult, criteria, sortBy, loginDTO) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("account-trial-balance-failed", ex); throw; } } public async Task GetTrialBalanceDetailReport( int firstNumber, int maxResult, CriteriaDTO criteriaDTO, List sortBy, int menuId, LoginDTO loginDTO) { try { GB5Trace.Step("parse-criteria-trial-balance-detail", new { firstNumber, maxResult, menuId }); var criteria = AccountReportCriteriaParser.Parse(criteriaDTO, loginDTO); var cappedMaxResult = Math.Min(maxResult, DEFAULT_MAX_RESULT_CAP); await _accessControlBLL.EnforceForAsOnDateAsync(criteria, cappedMaxResult, menuId, loginDTO).ConfigureAwait(false); GB5Trace.Step("query-trial-balance-detail", new { firstNumber, cappedMaxResult, criteria.GroupType, criteria.PeriodType }); return await _accountTrialBalanceReportDAL .GetTrialBalanceDetailReport(firstNumber, cappedMaxResult, criteria, sortBy, loginDTO) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("account-trial-balance-detail-failed", ex); throw; } } public async Task GetTrialBalanceComparativeReport( int firstNumber, int maxResult, CriteriaDTO criteriaDTO, List sortBy, int menuId, LoginDTO loginDTO) { try { GB5Trace.Step("parse-criteria-trial-balance-comparative", new { firstNumber, maxResult, menuId }); var criteria = AccountReportCriteriaParser.Parse(criteriaDTO, loginDTO); var cappedMaxResult = Math.Min(maxResult, DEFAULT_MAX_RESULT_CAP); await _accessControlBLL.EnforceForAsOnDateAsync(criteria, cappedMaxResult, menuId, loginDTO).ConfigureAwait(false); GB5Trace.Step("query-trial-balance-comparative", new { firstNumber, cappedMaxResult, criteria.ComparativeAxis }); return await _accountTrialBalanceReportDAL .GetTrialBalanceComparativeReport(firstNumber, cappedMaxResult, criteria, sortBy, loginDTO) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("account-trial-balance-comparative-failed", ex); throw; } } public async Task GetTrialBalancePeriodicReport( int firstNumber, int maxResult, CriteriaDTO criteriaDTO, List sortBy, int menuId, LoginDTO loginDTO) { try { GB5Trace.Step("parse-criteria-trial-balance-periodic", new { firstNumber, maxResult, menuId }); var criteria = AccountReportCriteriaParser.Parse(criteriaDTO, loginDTO); // EnforceForAsOnDateAsync checks criteria.AsOnDate against the user's restricted // work-period floor; this report is driven by PeriodFromDate/PeriodToDate instead, so // AsOnDate may still be at its unset default here. Anchor it to PeriodToDate (or today, // if that's also unset) before enforcement so the check runs against a real date, not // DateTime.MinValue. if (criteria.AsOnDate == default) criteria.AsOnDate = criteria.PeriodToDate == default ? DateTime.Today : criteria.PeriodToDate; var cappedMaxResult = Math.Min(maxResult, DEFAULT_MAX_RESULT_CAP); await _accessControlBLL.EnforceForAsOnDateAsync(criteria, cappedMaxResult, menuId, loginDTO).ConfigureAwait(false); GB5Trace.Step("query-trial-balance-periodic", new { firstNumber, cappedMaxResult, criteria.PeriodType }); return await _accountTrialBalanceReportDAL .GetTrialBalancePeriodicReport(firstNumber, cappedMaxResult, criteria, sortBy, loginDTO) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("account-trial-balance-periodic-failed", ex); throw; } } // Row/column field names below (row dictionary keys built by PivotRowsAsync) must match // the FIELDNAME values seeded onto ReportVsFieldsId's MREPORTVSFIELDS rows one-for-one — // see the migration referenced above. Field config itself now comes from // IReportExport.GetPivotFieldConfig (MREPORTVIEWFIELDS), not a hardcoded list, so the // pivot shape can be customized per client the same way every other pivot report already is. private static async IAsyncEnumerable> PivotRowsAsync( IEnumerable items) { foreach (var row in items) { yield return new Dictionary(StringComparer.OrdinalIgnoreCase) { ["AccountScheduleTypeName"] = row.AccountScheduleTypeName, ["AccountScheduleName"] = row.AccountScheduleName, ["AccountGroupName"] = row.AccountGroupName, ["AccountCode"] = row.AccountCode, ["AccountName"] = row.AccountName, ["PeriodLabel"] = row.PeriodLabel, ["NetAmount"] = row.NetAmount, }; } await Task.CompletedTask; } // Shared by the pivoted (grid) and flat-rows (export) entry points below — criteria parsing, // AsOnDate anchoring, and access-control enforcement must stay identical for both, since // export must never bypass a restriction the grid view enforces. private async Task> FetchPeriodicPivotFlatRowsAsync( CriteriaDTO criteriaDTO, int menuId, LoginDTO loginDTO) { var criteria = AccountReportCriteriaParser.Parse(criteriaDTO, loginDTO); if (criteria.AsOnDate == default) criteria.AsOnDate = criteria.PeriodToDate == default ? DateTime.Today : criteria.PeriodToDate; await _accessControlBLL.EnforceForAsOnDateAsync(criteria, DEFAULT_MAX_RESULT_CAP, menuId, loginDTO).ConfigureAwait(false); // Pivoting/export both need the complete flat dataset up front (a missing page would // silently produce an incomplete/wrong result), so fetch unpaged rather than the usual // capped page. GB5Trace.Step("query-trial-balance-periodic-pivot", new { criteria.PeriodType }); var flatResult = await _accountTrialBalanceReportDAL .GetTrialBalancePeriodicReport(1, int.MaxValue, criteria, sortBy: null, loginDTO) .ConfigureAwait(false); return flatResult.Items; } public async Task GetTrialBalancePeriodicPivotReport( CriteriaDTO criteriaDTO, int menuId, LoginDTO loginDTO, CancellationToken ct) { try { GB5Trace.Step("parse-criteria-trial-balance-periodic-pivot", new { menuId }); GB5Trace.Step("fetch-pivot-field-config", new { ReportViewId = PERIODIC_PIVOT_REPORT_VIEW_ID }); var fieldConfig = (await _reportExport .GetPivotFieldConfig(PERIODIC_PIVOT_REPORT_VIEW_ID, loginDTO, ct) .ConfigureAwait(false)).ToList(); if (fieldConfig.Count == 0) throw new InvalidOperationException( $"No MREPORTVIEWFIELDS configured for Periodic Pivot ReportViewId {PERIODIC_PIVOT_REPORT_VIEW_ID}. " + "Apply DB/Migrations/20260829_AccountTrialBalance_PeriodicPivot_ReportView_Seed_SqlServer.sql on this tenant."); var items = await FetchPeriodicPivotFlatRowsAsync(criteriaDTO, menuId, loginDTO).ConfigureAwait(false); GB5Trace.Step("pivot-transform-trial-balance-periodic", new { }); return await _dataPivotEngine .TransformAsync(PivotRowsAsync(items), fieldConfig, ct: ct) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("account-trial-balance-periodic-pivot-failed", ex); throw; } } // Flat (pre-pivot) rows for export (Excel/PDF) — IReportExport's own pivot export path // (ExcelExport/PdfExport "pre-fetched data" overloads) re-derives the pivot itself via // GetPivotFieldConfig + IDataPivotEngine, exactly mirroring GetTrialBalancePeriodicPivotReport // above, so it needs the same flat rows PivotRowsAsync already shapes, not the pivoted result. public async Task>> GetTrialBalancePeriodicPivotFlatRows( CriteriaDTO criteriaDTO, int menuId, LoginDTO loginDTO, CancellationToken ct) { try { GB5Trace.Step("parse-criteria-trial-balance-periodic-pivot-export", new { menuId }); var items = await FetchPeriodicPivotFlatRowsAsync(criteriaDTO, menuId, loginDTO).ConfigureAwait(false); var rows = new List>(); await foreach (var row in PivotRowsAsync(items).WithCancellation(ct).ConfigureAwait(false)) rows.Add(row); return rows; } catch (Exception ex) { GB5Trace.MarkFailed("account-trial-balance-periodic-pivot-export-rows-failed", ex); throw; } } public async Task> GetTrialBalancePeriodicPivotFieldConfig( LoginDTO loginDTO, CancellationToken ct) { var fieldConfig = (await _reportExport .GetPivotFieldConfig(PERIODIC_PIVOT_REPORT_VIEW_ID, loginDTO, ct) .ConfigureAwait(false)).ToList(); if (fieldConfig.Count == 0) throw new InvalidOperationException( $"No MREPORTVIEWFIELDS configured for Periodic Pivot ReportViewId {PERIODIC_PIVOT_REPORT_VIEW_ID}. " + "Apply DB/Migrations/20260829_AccountTrialBalance_PeriodicPivot_ReportView_Seed_SqlServer.sql on this tenant."); return fieldConfig; } } }