using System.Collections; using GB5Shared.DTO.Report; namespace GB5Shared.Export { // Expands DTO array/collection properties (e.g. AccountRegisterDetailReportDTO. // VoucherInstrumentArray) into repeated sub-rows for PDF/HTML/Excel export, driven entirely by // MREPORTVIEWFIELDS — never by reflecting over every collection property found on the row type. // // Convention: a field whose ReportVsFieldsFieldName is a dotted path // ("VoucherInstrumentArray.InstrumentNumber") and whose ReportViewFieldsRowLevel > 0 belongs to // a sub-row group. The group key is the path with its last segment (the leaf field) removed // ("VoucherInstrumentArray"). A group whose key is itself a prefix of another group's key // ("VoucherInstrumentArray.NestedArray") is nested one level inside it — resolved against each // item of the parent group, not the row root — so arbitrarily embedded arrays "do similar" // recursively. Two groups that don't nest inside each other (e.g. "VoucherInstrumentArray.*" and // "VoucherBillAllocationArray.*") are unrelated top-level groups, rendered stacked one below the // other under the same parent row, per the user's own explicit request. // // A group with zero configured fields never gets built (there's nothing in reportViewFields to // find it from) — this is what makes expansion "only if some of those fields are referred in the // view," not automatic for every array property present on the DTO. A group whose resolved // collection is null/empty for a given row is simply omitted from that row's result — no empty // sub-table is ever rendered. public static class ReportSubRowResolver { public sealed class SubRowGroup { public string FullKey { get; init; } = ""; public string RelativeSegment { get; init; } = ""; public List Fields { get; init; } = new(); public List ChildGroups { get; init; } = new(); public int MinDisplaySlNo { get; init; } } public sealed class ResolvedSubRow { // FieldTitle -> already-formatted display value, in this group's own configured order. public Dictionary Values { get; init; } = new(StringComparer.OrdinalIgnoreCase); public List NestedGroups { get; init; } = new(); } public sealed class ResolvedGroupResult { public string GroupKey { get; init; } = ""; public List ColumnTitles { get; init; } = new(); public List Rows { get; init; } = new(); } // Same dotted-path/array-index/Dictionary/JObject walker already proven in ReportExport.cs's // own GetDynamicFieldValue — copied here (not referenced across projects) so this helper has // no dependency on GB5Shared.Export's other internals and stays trivially reusable from // GB5Shared.ExcelExport too. public static object? GetDynamicFieldValue(object? data, string fieldPath) { if (data == null || string.IsNullOrWhiteSpace(fieldPath)) return null; object current = data; var parts = fieldPath.Split('.'); foreach (var part in parts) { if (current == null) return null; string property = part; int? index = null; if (part.Contains('[')) { property = part.Substring(0, part.IndexOf('[')); var idx = part.Substring(part.IndexOf('[') + 1).Replace("]", ""); if (int.TryParse(idx, out var parsed)) index = parsed; } if (current is IDictionary dictNullable) { dictNullable.TryGetValue(property, out var nVal); current = nVal!; } else if (current is IDictionary dict) { dict.TryGetValue(property, out current!); } else if (current is Newtonsoft.Json.Linq.JObject jObj) { current = jObj[property]!; } else { var prop = current.GetType().GetProperty(property); if (prop == null) return null; current = prop.GetValue(current)!; } if (current == null) return null; if (index.HasValue && current is IEnumerable list) current = list.Cast().ElementAtOrDefault(index.Value)!; if (current == null) return null; } return current; } // Builds the group tree from the report view's own field catalog only — call once per // export, reuse across every row. public static List BuildGroupTree(IEnumerable reportViewFields) { var subFields = reportViewFields .Where(f => f.ReportViewFieldsRowLevel > 0 && !string.IsNullOrWhiteSpace(f.ReportVsFieldsFieldName) && f.ReportVsFieldsFieldName.Contains('.')) .ToList(); if (subFields.Count == 0) return new List(); var byGroupKey = subFields .GroupBy(f => f.ReportVsFieldsFieldName.Substring(0, f.ReportVsFieldsFieldName.LastIndexOf('.'))) .ToDictionary(g => g.Key, g => g.OrderBy(f => f.ReportViewFieldsDisplaySlNo).ToList()); var nodes = byGroupKey.ToDictionary( kvp => kvp.Key, kvp => new SubRowGroup { FullKey = kvp.Key, RelativeSegment = kvp.Key.Contains('.') ? kvp.Key.Substring(kvp.Key.LastIndexOf('.') + 1) : kvp.Key, Fields = kvp.Value, MinDisplaySlNo = kvp.Value.Min(f => f.ReportViewFieldsDisplaySlNo) }); var roots = new List(); foreach (var (key, node) in nodes) { var lastDot = key.LastIndexOf('.'); if (lastDot > 0 && nodes.TryGetValue(key.Substring(0, lastDot), out var parent)) parent.ChildGroups.Add(node); else roots.Add(node); } void SortRecursive(List groups) { groups.Sort((a, b) => a.MinDisplaySlNo.CompareTo(b.MinDisplaySlNo)); foreach (var g in groups) SortRecursive(g.ChildGroups); } SortRecursive(roots); return roots; } // Resolves every top-level group's data for one row. `formatValue` lets each renderer apply // its own existing date/decimal/CalFormat formatting (ReportExport.cs and ExcelExport.cs // each already have one) without this helper depending on either. public static List ResolveForRow( List rootGroups, object rowContext, Func formatValue) { var results = new List(); foreach (var group in rootGroups) { var rows = ResolveGroup(group, rowContext, formatValue); if (rows.Count > 0) { results.Add(new ResolvedGroupResult { GroupKey = group.FullKey, ColumnTitles = group.Fields.Select(f => f.ReportViewFieldsFieldTitle).ToList(), Rows = rows }); } } return results; } private static List ResolveGroup( SubRowGroup group, object context, Func formatValue) { var arrayValue = GetDynamicFieldValue(context, group.RelativeSegment); if (arrayValue is not IEnumerable enumerable || arrayValue is string) return new List(); var items = new List(); foreach (var childItem in enumerable) { if (childItem == null) continue; var values = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var field in group.Fields) { var leaf = field.ReportVsFieldsFieldName.Substring(field.ReportVsFieldsFieldName.LastIndexOf('.') + 1); var raw = GetDynamicFieldValue(childItem, leaf); values[field.ReportViewFieldsFieldTitle] = formatValue(raw, field); } var nested = new List(); foreach (var childGroup in group.ChildGroups) { var nestedRows = ResolveGroup(childGroup, childItem, formatValue); if (nestedRows.Count > 0) { nested.Add(new ResolvedGroupResult { GroupKey = childGroup.FullKey, ColumnTitles = childGroup.Fields.Select(f => f.ReportViewFieldsFieldTitle).ToList(), Rows = nestedRows }); } } items.Add(new ResolvedSubRow { Values = values, NestedGroups = nested }); } return items; } } }