using System.Globalization; using GB5Shared.DTO.Report; namespace GB5Shared.Export { /// Field-type/aggregation-type helpers shared by pivot and flat report exports. public static class AggregationTypeHelper { public static bool IsNumericFieldType(int fieldType) => fieldType is 0 or 2 or 6 or 9 or 10; // INT, LONG, DOUBLE, INT16, BYTE public static decimal? ToDecimal(object? val) { if (val == null) return null; return decimal.TryParse(val.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : (decimal?)null; } } /// /// Incremental single-column aggregator. Accumulates one value at a time so callers can stream rows /// (CSV/Excel export) or span multiple chunks (PDF export) without materializing every value. /// AggregationType codes match : /// 0=None(first non-null), 1=SUM, 3=AVG, 5=COUNT, 7=MAX, 8=MIN. /// public sealed class ColumnAggregator { private readonly int _aggregationType; private decimal _sum; private long _count; private bool _sawNumeric; private decimal? _numericExtreme; private string? _stringExtreme; private object? _firstNonNull; public ColumnAggregator(int aggregationType, int fieldType) { _aggregationType = aggregationType; // fieldType is intentionally unused for numeric detection: several reports (e.g. MM Register) // tag genuinely numeric columns (Rate, Qty, Basic Value, Item Plus/Minus, Bill Value) with // ReportVsFieldsFieldType == 1, the same code used for plain text columns. Trusting that flag // made SUM/AVG/MAX/MIN silently fall back to "first non-null value" for every affected column, // which is wrong both per-group and for the report-wide Grand Total. Numeric-ness is decided // per value instead (see Accumulate) — a value that parses as decimal is aggregated numerically // regardless of the declared field type; only genuinely unparsable values fall back to the old // first-non-null / string-extreme behavior, so real text columns are unaffected. } public void Accumulate(object? value) { if (value == null) return; if (_firstNonNull == null) _firstNonNull = value; switch (_aggregationType) { case 1: // SUM case 3: // AVG { var d = AggregationTypeHelper.ToDecimal(value); if (d.HasValue) { _sum += d.Value; _count++; _sawNumeric = true; } } break; case 5: // COUNT _count++; break; case 7: // MAX { var d = AggregationTypeHelper.ToDecimal(value); if (d.HasValue) { _sawNumeric = true; if (!_numericExtreme.HasValue || d.Value > _numericExtreme.Value) _numericExtreme = d.Value; } else { var s = value.ToString(); if (_stringExtreme == null || string.CompareOrdinal(s, _stringExtreme) > 0) _stringExtreme = s; } } break; case 8: // MIN { var d = AggregationTypeHelper.ToDecimal(value); if (d.HasValue) { _sawNumeric = true; if (!_numericExtreme.HasValue || d.Value < _numericExtreme.Value) _numericExtreme = d.Value; } else { var s = value.ToString(); if (_stringExtreme == null || string.CompareOrdinal(s, _stringExtreme) < 0) _stringExtreme = s; } } break; default: // 0 = None break; } } public object? Result => _aggregationType switch { 1 => _sawNumeric ? (object?)_sum : _firstNonNull, // SUM 3 => _sawNumeric ? (_count > 0 ? (object?)(_sum / _count) : null) : _firstNonNull, // AVG 5 => (object?)_count, // COUNT 7 or 8 => _sawNumeric ? (object?)_numericExtreme : _stringExtreme, // MAX/MIN _ => _firstNonNull // None }; } /// /// Tracks group boundaries (by ) and /// accumulates per-group subtotal columns () /// and whole-stream grand-total columns () /// for the flat (non-pivot) CSV/Excel export streams. Rows are assumed to already arrive ordered by the /// grouping fields, as is standard for a "subtotal by group" report. /// public sealed class GroupSubtotalTracker { private readonly List _groupFields; private readonly List _subTotalFields; private readonly List _grandTotalFields; private Dictionary _subTotalAggregators; private Dictionary _grandTotalAggregators; private string? _currentGroupKey; private bool _hasPendingGroup; public bool HasGrouping => _groupFields.Count > 0; public bool HasGrandTotal => _grandTotalFields.Count > 0; public GroupSubtotalTracker(List? reportViewFields) { reportViewFields ??= new List(); _groupFields = reportViewFields.Where(f => f.ReportViewFieldsIsGroupcolumn == 0).ToList(); _subTotalFields = reportViewFields.Where(f => f.ReportViewFieldsIsSubTotal == 0).ToList(); _grandTotalFields = reportViewFields.Where(f => f.ReportViewFieldsIsGrandTotal == 0).ToList(); _subTotalAggregators = CreateAggregators(_subTotalFields); _grandTotalAggregators = CreateAggregators(_grandTotalFields); } private static Dictionary CreateAggregators(List fields) => fields.ToDictionary( f => f.ReportVsFieldsFieldName, f => new ColumnAggregator(f.ReportViewFieldsAggregationType, f.ReportVsFieldsFieldType), StringComparer.OrdinalIgnoreCase); private string BuildGroupKey(Dictionary row) => string.Join("|", _groupFields.Select(f => row.TryGetValue(f.ReportVsFieldsFieldName, out var v) ? v?.ToString() ?? "" : "")); /// /// Call before processing a new row. Returns true with the completed subtotal row when the group /// key just changed (caller should write that row before writing the new data row). Always /// accumulates the new row into the (possibly-reset) subtotal aggregators and the grand-total /// aggregators. /// public bool ObserveRow(Dictionary row, out Dictionary? completedSubtotalRow) { completedSubtotalRow = null; bool emitted = false; if (HasGrouping) { var key = BuildGroupKey(row); if (_hasPendingGroup && key != _currentGroupKey) { completedSubtotalRow = FinalizeSubtotal(); emitted = true; } if (!_hasPendingGroup || key != _currentGroupKey) { _subTotalAggregators = CreateAggregators(_subTotalFields); _currentGroupKey = key; _hasPendingGroup = true; } } foreach (var (name, agg) in _subTotalAggregators) { row.TryGetValue(name, out var v); agg.Accumulate(v); } foreach (var (name, agg) in _grandTotalAggregators) { row.TryGetValue(name, out var v); agg.Accumulate(v); } return emitted; } private Dictionary FinalizeSubtotal() => _subTotalAggregators.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Result, StringComparer.OrdinalIgnoreCase); /// Flush the trailing group's subtotal row after the stream ends. Returns null if there is none. public Dictionary? FlushFinalSubtotal() { if (!HasGrouping || !_hasPendingGroup) return null; _hasPendingGroup = false; return FinalizeSubtotal(); } public Dictionary FinalizeGrandTotal() => _grandTotalAggregators.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Result, StringComparer.OrdinalIgnoreCase); } }