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);
}
}