Push the rest
This commit is contained in:
106
backend/analytics/internal/export/xlsx.go
Normal file
106
backend/analytics/internal/export/xlsx.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var (
|
||||
exportTracer = otel.Tracer("otel-bi.export")
|
||||
exportMeter = otel.Meter("otel-bi.export")
|
||||
|
||||
exportRowsTotal, _ = exportMeter.Int64Counter(
|
||||
"export.rows_total",
|
||||
metric.WithDescription("Total rows exported to XLSX"),
|
||||
)
|
||||
exportSizeBytes, _ = exportMeter.Int64Histogram(
|
||||
"export.file_size_bytes",
|
||||
metric.WithDescription("XLSX file size in bytes"),
|
||||
metric.WithUnit("By"),
|
||||
)
|
||||
)
|
||||
|
||||
type Column struct {
|
||||
Key string
|
||||
Label string
|
||||
}
|
||||
|
||||
// ToXLSXBytes writes rows to a single-sheet Excel workbook using the given
|
||||
// column spec (controls header labels and order) and returns the raw bytes.
|
||||
func ToXLSXBytes(ctx context.Context, sheetName string, cols []Column, rows []map[string]any) ([]byte, error) {
|
||||
ctx, span := exportTracer.Start(ctx, "export.xlsx",
|
||||
trace.WithAttributes(attribute.String("sheet_name", sheetName)),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
|
||||
sheet := f.GetSheetName(0)
|
||||
if err := f.SetSheetName(sheet, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Header row
|
||||
for col, c := range cols {
|
||||
cell, _ := excelize.CoordinatesToCellName(col+1, 1)
|
||||
if err := f.SetCellValue(sheetName, cell, c.Label); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Data rows
|
||||
for rowIdx, row := range rows {
|
||||
for colIdx, c := range cols {
|
||||
cell, _ := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
|
||||
_ = f.SetCellValue(sheetName, cell, fmtCell(row[c.Key]))
|
||||
}
|
||||
}
|
||||
|
||||
buf, err := f.WriteToBuffer()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b := buf.Bytes()
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.Int("row_count", len(rows)),
|
||||
attribute.Int("file_size_bytes", len(b)),
|
||||
)
|
||||
exportRowsTotal.Add(ctx, int64(len(rows)), metric.WithAttributes(attribute.String("sheet", sheetName)))
|
||||
exportSizeBytes.Record(ctx, int64(len(b)), metric.WithAttributes(attribute.String("sheet", sheetName)))
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// GenericXLSX converts a slice of maps to XLSX with alphabetically-sorted headers.
|
||||
// Use ToXLSXBytes when column order matters.
|
||||
func GenericXLSX(ctx context.Context, sheetName string, rows []map[string]any) ([]byte, error) {
|
||||
if len(rows) == 0 {
|
||||
return ToXLSXBytes(ctx, sheetName, nil, nil)
|
||||
}
|
||||
keys := make([]string, 0, len(rows[0]))
|
||||
for k := range rows[0] {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
cols := make([]Column, len(keys))
|
||||
for i, k := range keys {
|
||||
cols[i] = Column{Key: k, Label: k}
|
||||
}
|
||||
return ToXLSXBytes(ctx, sheetName, cols, rows)
|
||||
}
|
||||
|
||||
func fmtCell(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
Reference in New Issue
Block a user