using System; using System.Collections.Generic; using System.Data; using System.Globalization; using System.Linq; using System.Text.Json; using System.Threading.Tasks; using DevExpress.DataAccess.Json; using DevExpress.Drawing; using DevExpress.XtraPrinting; using DevExpress.XtraReports.UI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using Sozsoft.Platform; using Sozsoft.Platform.DynamicData; using Sozsoft.Platform.Entities; using Sozsoft.Platform.Enums; using Sozsoft.Platform.ListForms; using Sozsoft.Platform.Localization; using Sozsoft.Platform.Queries; using Volo.Abp.Users; namespace Sozsoft.Reports.PredefinedReports; public class DynamicTreeReport : XtraReport { public const string ReportName = nameof(DynamicTreeReport); private const int A4PortraitWidth = 827; private const int HorizontalMargin = 50; private const int LandscapeColumnThreshold = 5; private const string TreeLevelColumn = "__TreeLevel"; private const string TreeHasChildrenColumn = "__TreeHasChildren"; private const string TreePrefixColumn = "__TreePrefix"; private const string TreeDataSourceName = "DynamicTree"; private static readonly System.Drawing.Color TableBorderColor = System.Drawing.Color.FromArgb(226, 232, 240); private static IServiceProvider _serviceProvider; public static void SetServiceProvider(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; } public static async Task CreateAsync(IDictionary parameters) { var report = new DynamicTreeReport(); await report.BuildAsync(parameters); return report; } public DynamicTreeReport() { } private async Task BuildAsync(IDictionary parameters) { if (_serviceProvider == null) { throw new InvalidOperationException("DynamicTreeReport service provider is not configured."); } if (!parameters.TryGetValue("listFormCode", out var listFormCode) || string.IsNullOrWhiteSpace(listFormCode)) { throw new ArgumentException("listFormCode parameter is required."); } using var scope = _serviceProvider.CreateScope(); var authManager = scope.ServiceProvider.GetRequiredService(); var listFormManager = scope.ServiceProvider.GetRequiredService(); var listFormFieldManager = scope.ServiceProvider.GetRequiredService(); var customizationManager = scope.ServiceProvider.GetRequiredService(); var dynamicDataManager = scope.ServiceProvider.GetRequiredService(); var selectQueryManager = scope.ServiceProvider.GetRequiredService(); var defaultValueHelper = scope.ServiceProvider.GetRequiredService(); var localizer = scope.ServiceProvider.GetRequiredService>(); var configuration = scope.ServiceProvider.GetRequiredService(); var currentUser = scope.ServiceProvider.GetRequiredService(); var isSubReport = DynamicReportImageHelper.IsSubReport(parameters); if (!await authManager.CanAccess(listFormCode)) { throw new UnauthorizedAccessException($"No read permission for list form '{listFormCode}'."); } var listForm = await listFormManager.GetUserListForm(listFormCode); var treeOptions = GetTreeOptions(listForm); if (string.IsNullOrWhiteSpace(treeOptions?.KeyExpr) || string.IsNullOrWhiteSpace(treeOptions.ParentIdExpr)) { throw new InvalidOperationException($"Tree options are not configured for list form '{listFormCode}'."); } var fields = (await listFormFieldManager.GetUserListFormFields(listFormCode)) .Where(x => x.CanRead) .OrderBy(x => x.ListOrderNo ?? int.MaxValue) .ThenBy(x => x.CaptionName) .ToList(); var customizations = await customizationManager.GetUsersServerCustomizations(listFormCode); var (dynamicDataRepository, connectionString, dataSourceType) = await dynamicDataManager.GetAsync(listForm.IsTenant, listForm.DataSourceCode); var queryParams = new QueryParameters { ListFormCode = listFormCode, Filter = GetParameter(parameters, "filter"), Sort = GetParameter(parameters, "sort"), Group = GetParameter(parameters, "group"), GroupSummary = GetParameter(parameters, "groupSummary"), TotalSummary = GetParameter(parameters, "totalSummary"), Take = 0, Skip = 0 }; selectQueryManager.PrepareQueries(listForm, fields, dataSourceType, customizations, queryParams); var selectFieldsByName = DynamicReportImageHelper.CreateSelectFieldMap(selectQueryManager.SelectFields); var editorTypesByField = DynamicReportImageHelper.CreateEditorTypeMap(listForm); var visibleFields = fields .Where(x => !string.IsNullOrWhiteSpace(x.FieldName) && x.FieldName != "undefined") .Where(x => x.IsActive != false && x.Visible == true) .Where(x => selectFieldsByName.ContainsKey(x.FieldName)) .ToList(); if (visibleFields.Count == 0) { visibleFields = fields .Where(x => !string.IsNullOrWhiteSpace(x.FieldName) && x.FieldName != "undefined") .Where(x => x.IsActive != false) .Where(x => selectFieldsByName.ContainsKey(x.FieldName)) .Take(8) .ToList(); } var reportColumns = visibleFields .Select(field => { var selectField = selectFieldsByName[field.FieldName]; editorTypesByField.TryGetValue(field.FieldName, out var editorType); return CreateReportColumn( field, selectField, defaultValueHelper, editorType, DynamicReportImageHelper.IsImageEditor(editorType), editorType == PlatformConsts.EditorTypes.dxHtmlEditor); }) .Where(x => !string.IsNullOrWhiteSpace(x.BindingName) && x.BindingName != "undefined") .ToList(); var selectQuery = DynamicReportImageHelper.StripReportPaging(DynamicReportImageHelper.ApplyQueryParameters( selectQueryManager.SelectQuery, selectQueryManager.SelectQueryParameters, dataSourceType)); selectQuery = DynamicReportSqlBuilder.ApplyLookupTextColumns( selectQuery, reportColumns, defaultValueHelper, dataSourceType); var imageBaseUrl = DynamicReportImageHelper.GetImageBaseUrl(configuration); var criteriaText = DynamicReportImageHelper.BuildReportCriteriaText( queryParams.Filter, queryParams.Sort, fields, localizer); var sourceRows = await ReadRowsAsync(dynamicDataRepository, selectQuery, connectionString); var orderedRows = OrderTreeRows(sourceRows, treeOptions).ToList(); reportColumns = ApplyImageCounts(reportColumns, orderedRows, imageBaseUrl); ConfigurePage(reportColumns); ConfigureDataSource(orderedRows, reportColumns); BuildLayout(listForm, reportColumns, localizer, imageBaseUrl, currentUser.UserName, criteriaText, isSubReport); RequestParameters = false; } #pragma warning disable CA1416 private void ConfigurePage(IReadOnlyCollection visibleFields) { Margins = CreateReportMargins(HorizontalMargin, HorizontalMargin, 35, 35); PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4; var portraitContentWidth = A4PortraitWidth - Margins.Left - Margins.Right; var requestedContentWidth = visibleFields.Sum(x => x.IsImage ? GetImageColumnWidth(x) : GetPreferredColumnWidth(x.Field)); Landscape = visibleFields.Count >= LandscapeColumnThreshold || requestedContentWidth > portraitContentWidth; Font = new DXFont("Arial", 8.25F); } private static System.Drawing.Printing.Margins CreateReportMargins( int left, int right, int top, int bottom) { return new System.Drawing.Printing.Margins(left, right, top, bottom); } #pragma warning restore CA1416 private static async Task>> ReadRowsAsync( IDynamicDataRepository dynamicDataRepository, string selectQuery, string connectionString) { var rows = await dynamicDataRepository.QueryAsync(selectQuery, connectionString); return rows .OfType>() .Select(row => row.ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase)) .ToList(); } private static IEnumerable> OrderTreeRows( IReadOnlyList> rows, TreeOptionDto treeOptions) { var childrenByParent = rows .GroupBy(row => NormalizeTreeKey(GetRowValue(row, treeOptions.ParentIdExpr))) .ToDictionary(x => x.Key, x => x.ToList(), StringComparer.OrdinalIgnoreCase); var visited = new HashSet(StringComparer.OrdinalIgnoreCase); var rootKey = NormalizeTreeKey(treeOptions.RootValue); var roots = childrenByParent.TryGetValue(rootKey, out var configuredRoots) ? configuredRoots : rows.Where(row => IsRootValue(GetRowValue(row, treeOptions.ParentIdExpr), treeOptions.RootValue)).ToList(); foreach (var row in Traverse(roots, childrenByParent, treeOptions.KeyExpr, 0, visited)) { yield return row; } foreach (var row in rows) { var key = NormalizeTreeKey(GetRowValue(row, treeOptions.KeyExpr)); if (visited.Contains(key)) { continue; } foreach (var orphan in Traverse([row], childrenByParent, treeOptions.KeyExpr, 0, visited)) { yield return orphan; } } } private static IEnumerable> Traverse( IEnumerable> rows, IReadOnlyDictionary>> childrenByParent, string keyExpr, int level, ISet visited) { foreach (var row in rows) { var key = NormalizeTreeKey(GetRowValue(row, keyExpr)); if (!visited.Add(key)) { continue; } var hasChildren = childrenByParent.TryGetValue(key, out var children) && children.Count > 0; row[TreeLevelColumn] = level; row[TreeHasChildrenColumn] = hasChildren; row[TreePrefixColumn] = $"{new string(' ', level * 4)}{(hasChildren ? "[-] " : " ")}"; yield return row; if (!hasChildren) { continue; } foreach (var child in Traverse(children, childrenByParent, keyExpr, level + 1, visited)) { yield return child; } } } private static bool IsRootValue(object parentValue, object rootValue) { if (rootValue == null) { return parentValue == null || parentValue == DBNull.Value || string.IsNullOrWhiteSpace(Convert.ToString(parentValue, CultureInfo.InvariantCulture)); } return string.Equals( NormalizeTreeKey(parentValue), NormalizeTreeKey(rootValue), StringComparison.OrdinalIgnoreCase); } private static object GetRowValue(IDictionary row, string fieldName) => DynamicReportImageHelper.TryGetRowValue(row, fieldName, out var value) ? value : null; private static string NormalizeTreeKey(object value) { if (value == null || value == DBNull.Value) { return string.Empty; } return Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim() ?? string.Empty; } private void ConfigureDataSource( IEnumerable> rows, IReadOnlyList columns) { var normalizedRows = rows .Select(row => NormalizeJsonRow(row, columns)) .ToList(); if (normalizedRows.Count == 0) { normalizedRows.Add(NormalizeJsonRow(new Dictionary(), columns)); } var dataSource = new JsonDataSource { Name = TreeDataSourceName, JsonSource = new CustomJsonSource(JsonSerializer.Serialize(normalizedRows)) }; dataSource.Fill(); ComponentStorage.Add(dataSource); DataSource = dataSource; } private static Dictionary NormalizeJsonRow( IDictionary row, IReadOnlyList columns) { var normalized = row.ToDictionary( x => x.Key, x => NormalizeJsonValue(x.Value), StringComparer.OrdinalIgnoreCase); foreach (var column in columns) { normalized.TryAdd(column.BindingName, null); } normalized.TryAdd(TreeLevelColumn, 0); normalized.TryAdd(TreeHasChildrenColumn, false); normalized.TryAdd(TreePrefixColumn, string.Empty); return normalized; } private static object NormalizeJsonValue(object value) { if (value == null || value == DBNull.Value) { return null; } if (value is Guid guid) { return guid.ToString(); } if (value is DateTime dateTime) { return dateTime; } if (value is DateTimeOffset dateTimeOffset) { return dateTimeOffset; } if (value is byte[] bytes) { return Convert.ToBase64String(bytes); } return value; } private static List ApplyImageCounts( IReadOnlyList columns, IReadOnlyList> rows, string imageBaseUrl) { var imageColumns = columns.Where(x => x.IsImage).ToList(); if (imageColumns.Count == 0) { return columns.ToList(); } var imageCounts = imageColumns.ToDictionary(x => x.BindingName, _ => 1, StringComparer.OrdinalIgnoreCase); foreach (var row in rows) { foreach (var column in imageColumns) { var count = DynamicReportImageHelper.CountImageSources(GetImageRawValue(row, column), imageBaseUrl); if (count > imageCounts[column.BindingName]) { imageCounts[column.BindingName] = count; } } } return columns .Select(column => column.IsImage ? column with { ImageCount = imageCounts[column.BindingName] } : column) .ToList(); } private static object GetImageRawValue(IDictionary row, ReportColumn column) { if (DynamicReportImageHelper.TryGetRowValue(row, column.BindingName, out var value) && DynamicReportImageHelper.HasValue(value)) { return value; } return DynamicReportImageHelper.TryGetRowValue(row, column.Field.FieldName, out value) ? value : null; } private void BuildLayout( ListForm listForm, IReadOnlyList fields, IStringLocalizer localizer, string imageBaseUrl, string currentUserName, string criteriaText, bool isSubReport) { Bands.Clear(); var topMargin = new TopMarginBand { HeightF = Margins.Top }; var bottomMargin = new BottomMarginBand { HeightF = 32 }; var reportHeader = new ReportHeaderBand { HeightF = string.IsNullOrWhiteSpace(criteriaText) ? 32 : 50 }; var pageHeader = new PageHeaderBand { HeightF = 28 }; var detailHeight = fields.Any(x => x.IsImage) ? DynamicReportImageHelper.GetGridImageHeight() : fields.Any(x => x.IsHtml) ? 48 : 24; var detail = new DetailBand { HeightF = detailHeight }; var reportFooter = new ReportFooterBand { HeightF = HasAnySummary(fields) ? 28 : 0 }; Bands.AddRange([topMargin, reportHeader, pageHeader, detail, reportFooter, bottomMargin]); var pageWidth = PageWidth - Margins.Left - Margins.Right; reportHeader.Controls.Add(new XRLabel { Text = DynamicReportImageHelper.GetLocalizedReportTitle(listForm, localizer), BoundsF = new System.Drawing.RectangleF(0, 0, pageWidth, 30), Font = new DXFont("Arial", 14F, DXFontStyle.Bold), TextAlignment = TextAlignment.MiddleLeft }); if (!isSubReport) { DynamicReportImageHelper.AddGeneratedAtToHeader(reportHeader.Controls, pageWidth); } DynamicReportImageHelper.AddReportCriteriaToHeader(reportHeader.Controls, criteriaText, pageWidth); DynamicReportImageHelper.AddFooterTopLine(bottomMargin.Controls, pageWidth); DynamicReportImageHelper.AddCurrentUserNameToFooter(bottomMargin.Controls, currentUserName, pageWidth); bottomMargin.Controls.Add(new XRPageInfo { TextFormatString = "Sayfa {0} / {1}", BoundsF = new System.Drawing.RectangleF(pageWidth / 2, 8, pageWidth / 2, 18), Font = new DXFont("Arial", 8F), TextAlignment = TextAlignment.MiddleRight }); var widths = CalculateWidths(fields, pageWidth); var headerTable = CreateTable(pageWidth, 28); var headerRow = new XRTableRow { HeightF = 28 }; var detailTable = CreateTable(pageWidth, detailHeight); detailTable.CanGrow = true; var detailRow = new XRTableRow { HeightF = detailHeight, CanGrow = true }; var footerTable = CreateTable(pageWidth, 24); var footerRow = new XRTableRow { HeightF = 24 }; for (var i = 0; i < fields.Count; i++) { var field = fields[i]; headerRow.Cells.Add(CreateHeaderCell(field, widths[i], localizer)); detailRow.Cells.Add(CreateDetailCell(field, widths[i], imageBaseUrl, detailHeight, i == 0)); footerRow.Cells.Add(CreateFooterCell(field, widths[i])); } headerTable.Rows.Add(headerRow); detailTable.Rows.Add(detailRow); footerTable.Rows.Add(footerRow); pageHeader.Controls.Add(headerTable); detail.Controls.Add(detailTable); if (HasAnySummary(fields)) { reportFooter.Controls.Add(footerTable); } } private static XRTable CreateTable(float width, float height) { return new XRTable { BoundsF = new System.Drawing.RectangleF(0, 0, width, height), Borders = BorderSide.All, BorderColor = TableBorderColor, BorderWidth = 0.25F }; } private static XRTableCell CreateHeaderCell( ReportColumn column, float width, IStringLocalizer localizer) { return new XRTableCell { Text = DynamicReportImageHelper.GetLocalizedFieldCaption(column.Field, localizer), WidthF = width, Weight = width, Font = new DXFont("Arial", 8F, DXFontStyle.Bold), BackColor = System.Drawing.Color.FromArgb(238, 242, 247), Padding = new PaddingInfo(4, 4, 2, 2), TextAlignment = TextAlignment.MiddleCenter, Multiline = true }; } private static XRTableCell CreateDetailCell( ReportColumn column, float width, string imageBaseUrl, float height, bool isTreeColumn) { if (column.IsImage) { return DynamicReportImageHelper.CreateGridImageCell( column.BindingName, column.Field.FieldName, width, imageBaseUrl, column.ImageCount); } if (column.IsHtml) { return CreateHtmlDetailCell(column, width, height); } var cell = new XRTableCell { WidthF = width, Weight = width, Font = new DXFont("Segoe UI Emoji", 8F), Padding = new PaddingInfo(4, 4, 2, 2), TextAlignment = isTreeColumn ? TextAlignment.MiddleLeft : DynamicReportImageHelper.ToTextAlignment(column.Field.Alignment, column.Field.SourceDbType), Multiline = true, CanGrow = true }; var valueExpression = DynamicReportImageHelper.CreateFormatValueExpression($"[{column.BindingName}]", column.Field, column.EditorType); cell.ExpressionBindings.Add(new ExpressionBinding( "BeforePrint", "Text", isTreeColumn ? $"Concat([{TreePrefixColumn}], {valueExpression})" : valueExpression)); if (isTreeColumn) { cell.ExpressionBindings.Add(new ExpressionBinding( "BeforePrint", "Font.Bold", $"[{TreeHasChildrenColumn}]")); } return cell; } private static XRTableCell CreateHtmlDetailCell( ReportColumn column, float width, float height) { var cell = new XRTableCell { WidthF = width, Weight = width, Padding = new PaddingInfo(0, 0, 0, 0), CanGrow = true }; var richText = new XRRichText { BoundsF = new System.Drawing.RectangleF(0, 0, width, height), Font = new DXFont("Arial", 8F), Padding = new PaddingInfo(4, 4, 2, 2), CanGrow = true }; richText.ExpressionBindings.Add(new ExpressionBinding("BeforePrint", "Html", $"[{column.BindingName}]")); cell.Controls.Add(richText); return cell; } private static XRTableCell CreateFooterCell(ReportColumn column, float width) { var summaryType = GetSummaryType(column.Field.TotalSummaryJson); var cell = new XRTableCell { WidthF = width, Weight = width, Font = new DXFont("Arial", 8F, DXFontStyle.Bold), Padding = new PaddingInfo(4, 4, 2, 2), TextAlignment = DynamicReportImageHelper.ToTextAlignment(column.Field.Alignment, column.Field.SourceDbType) }; if (summaryType == null) { return cell; } cell.ExpressionBindings.Add(new ExpressionBinding( "BeforePrint", "Text", DynamicReportImageHelper.CreateFormatValueExpression($"sumSum([{column.BindingName}])", column.Field, column.EditorType))); cell.Summary = new XRSummary { Running = SummaryRunning.Report, Func = summaryType.Value, IgnoreNullValues = true }; return cell; } private static float[] CalculateWidths(IReadOnlyList fields, float pageWidth) { var raw = fields.Select(x => x.IsImage ? GetImageColumnWidth(x) : GetPreferredColumnWidth(x.Field)).ToArray(); var total = raw.Sum(); if (total <= 0) { return fields.Select(_ => pageWidth / fields.Count).ToArray(); } return raw.Select(x => pageWidth * x / total).ToArray(); } private static float GetPreferredColumnWidth(ListFormField field) { if (field.Width > 0) { return Math.Max(field.Width.Value, 60); } var caption = string.IsNullOrWhiteSpace(field.CaptionName) ? field.FieldName : field.CaptionName; var name = field.FieldName ?? string.Empty; var text = $"{caption} {name}".ToLowerInvariant(); if (field.SourceDbType == DbType.Boolean) { return 75; } if (DynamicReportImageHelper.IsNumeric(field.SourceDbType)) { return 70; } if (field.SourceDbType is DbType.Date or DbType.DateTime or DbType.DateTime2 or DbType.DateTimeOffset) { return 115; } if (text.Contains("description") || text.Contains("explanation") || text.Contains("note") || text.Contains("comment") || text.Contains("address") || text.Contains("content") || text.Contains("html") || text.Contains("text")) { return 220; } if (text.Contains("name") || text.Contains("title") || text.Contains("subject")) { return 160; } return Math.Clamp((caption?.Length ?? 0) * 9F + 36F, 85F, 180F); } private static bool HasAnySummary(IEnumerable fields) { return fields.Any(x => GetSummaryType(x.Field.TotalSummaryJson) != null); } private static float GetImageColumnWidth(ReportColumn column) { return DynamicReportImageHelper.GetGridImageColumnWidth(column.ImageCount); } private static ReportColumn CreateReportColumn( ListFormField field, SelectField selectField, DefaultValueHelper defaultValueHelper, string editorType, bool isImage, bool isHtml) { var bindingName = DynamicReportImageHelper.GetReportFieldName(selectField); var lookup = DynamicReportImageHelper.GetLookup(field); if (lookup == null || selectField?.JoinOptions != null) { return new ReportColumn(field, bindingName, null, null, isImage, isHtml, editorType); } if (DynamicReportSqlBuilder.IsReportQueryableLookup(lookup)) { return new ReportColumn(field, DynamicReportSqlBuilder.GetLookupBindingName(field), lookup, null, isImage, isHtml, editorType); } if (lookup.DataSourceType == UiLookupDataSourceTypeEnum.StaticData) { var staticLookupValues = DynamicReportImageHelper.GetStaticLookupValues(lookup, defaultValueHelper); return new ReportColumn( field, staticLookupValues?.Count > 0 ? DynamicReportSqlBuilder.GetLookupBindingName(field) : bindingName, lookup, staticLookupValues, isImage, isHtml, editorType); } return new ReportColumn(field, bindingName, null, null, isImage, isHtml, editorType); } private static TreeOptionDto GetTreeOptions(ListForm listForm) { if (string.IsNullOrWhiteSpace(listForm.TreeOptionJson)) { return null; } try { return JsonSerializer.Deserialize(listForm.TreeOptionJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); } catch (JsonException) { return null; } } private sealed record ReportColumn( ListFormField Field, string BindingName, LookupDto Lookup, Dictionary StaticLookupValues, bool IsImage, bool IsHtml, string EditorType, int ImageCount = 1) : DynamicReportSqlBuilder.IReportLookupColumn; private static SummaryFunc? GetSummaryType(string totalSummaryJson) { if (string.IsNullOrWhiteSpace(totalSummaryJson)) { return null; } var value = totalSummaryJson.ToLowerInvariant(); if (value.Contains("\"summarytype\":\"sum\"")) { return SummaryFunc.Sum; } if (value.Contains("\"summarytype\":\"avg\"")) { return SummaryFunc.Avg; } if (value.Contains("\"summarytype\":\"min\"")) { return SummaryFunc.Min; } if (value.Contains("\"summarytype\":\"max\"")) { return SummaryFunc.Max; } if (value.Contains("\"summarytype\":\"count\"")) { return SummaryFunc.Count; } return null; } private static string GetParameter(IDictionary parameters, string name) => DynamicReportImageHelper.GetParameter(parameters, name); }