|
| 1 | +using System.Text; |
| 2 | +using System.Text.Json; |
| 3 | +using ClosedXML.Excel; |
| 4 | +using Reva.Core.Contracts; |
| 5 | +using Reva.Core.Export; |
| 6 | + |
| 7 | +namespace Reva.Infrastructure.Export; |
| 8 | + |
| 9 | +// Applies an export template to a document's canonical data. A column whose source matches a |
| 10 | +// line-item table header is pulled per row; otherwise it is a document-level field repeated on |
| 11 | +// every row. If no column references the table, the export is a single document-level row. |
| 12 | +public sealed class DocumentExporter : IDocumentExporter |
| 13 | +{ |
| 14 | + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; |
| 15 | + |
| 16 | + public ExportPreview Preview(DocumentDetail document, ExportTemplate layout, int maxRows = 6) |
| 17 | + { |
| 18 | + var (headers, rows) = Render(document, layout); |
| 19 | + return new ExportPreview(headers, [.. rows.Take(Math.Max(0, maxRows))]); |
| 20 | + } |
| 21 | + |
| 22 | + public ExportFile Export(DocumentDetail document, ExportTemplate layout) |
| 23 | + { |
| 24 | + var (headers, rows) = Render(document, layout); |
| 25 | + var stem = $"reva-{Slug(layout.Name)}-{document.Id:N}"; |
| 26 | + |
| 27 | + return layout.Format switch |
| 28 | + { |
| 29 | + ExportFormat.Excel => new ExportFile(BuildExcel(headers, rows), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", $"{stem}.xlsx"), |
| 30 | + ExportFormat.Json => new ExportFile(BuildJson(headers, rows), "application/json", $"{stem}.json"), |
| 31 | + _ => new ExportFile(Encoding.UTF8.GetBytes(BuildCsv(headers, rows)), "text/csv", $"{stem}.csv") |
| 32 | + }; |
| 33 | + } |
| 34 | + |
| 35 | + private static (IReadOnlyList<string> Headers, IReadOnlyList<IReadOnlyList<string>> Rows) Render(DocumentDetail document, ExportTemplate template) |
| 36 | + { |
| 37 | + var headers = template.Columns.Select(column => column.Header).ToList(); |
| 38 | + var fields = document.Fields.ToDictionary(field => field.Name, field => field.Value, StringComparer.OrdinalIgnoreCase); |
| 39 | + var table = document.Tables.FirstOrDefault(candidate => candidate.Rows.Count > 0); |
| 40 | + |
| 41 | + // Pre-resolve each column to either a table header (per-row) or a field (document-level). |
| 42 | + var resolved = template.Columns |
| 43 | + .Select(column => new |
| 44 | + { |
| 45 | + column.Source, |
| 46 | + TableHeader = table?.Headers.FirstOrDefault(header => header.Equals(column.Source, StringComparison.OrdinalIgnoreCase)) |
| 47 | + }) |
| 48 | + .ToList(); |
| 49 | + |
| 50 | + var usesTable = table is not null && resolved.Any(column => column.TableHeader is not null); |
| 51 | + |
| 52 | + if (usesTable) |
| 53 | + { |
| 54 | + var rows = table!.Rows |
| 55 | + .Select(row => (IReadOnlyList<string>)resolved |
| 56 | + .Select(column => column.TableHeader is not null |
| 57 | + ? (row.TryGetValue(column.TableHeader, out var cell) ? cell : string.Empty) |
| 58 | + : (fields.TryGetValue(column.Source, out var value) ? value : string.Empty)) |
| 59 | + .ToList()) |
| 60 | + .ToList(); |
| 61 | + return (headers, rows); |
| 62 | + } |
| 63 | + |
| 64 | + var single = (IReadOnlyList<string>)resolved |
| 65 | + .Select(column => fields.TryGetValue(column.Source, out var value) ? value : string.Empty) |
| 66 | + .ToList(); |
| 67 | + return (headers, [single]); |
| 68 | + } |
| 69 | + |
| 70 | + private static byte[] BuildExcel(IReadOnlyList<string> headers, IReadOnlyList<IReadOnlyList<string>> rows) |
| 71 | + { |
| 72 | + using var workbook = new XLWorkbook(); |
| 73 | + var sheet = workbook.AddWorksheet("Export"); |
| 74 | + for (var c = 0; c < headers.Count; c++) |
| 75 | + { |
| 76 | + sheet.Cell(1, c + 1).Value = headers[c]; |
| 77 | + } |
| 78 | + |
| 79 | + sheet.Row(1).Style.Font.Bold = true; |
| 80 | + |
| 81 | + for (var r = 0; r < rows.Count; r++) |
| 82 | + { |
| 83 | + var row = rows[r]; |
| 84 | + for (var c = 0; c < row.Count; c++) |
| 85 | + { |
| 86 | + sheet.Cell(r + 2, c + 1).Value = row[c]; |
| 87 | + } |
| 88 | + } |
| 89 | + |
| 90 | + sheet.Columns().AdjustToContents(); |
| 91 | + using var stream = new MemoryStream(); |
| 92 | + workbook.SaveAs(stream); |
| 93 | + return stream.ToArray(); |
| 94 | + } |
| 95 | + |
| 96 | + private static byte[] BuildJson(IReadOnlyList<string> headers, IReadOnlyList<IReadOnlyList<string>> rows) |
| 97 | + { |
| 98 | + var objects = rows |
| 99 | + .Select(row => |
| 100 | + { |
| 101 | + var item = new Dictionary<string, string>(StringComparer.Ordinal); |
| 102 | + for (var c = 0; c < headers.Count && c < row.Count; c++) |
| 103 | + { |
| 104 | + item[headers[c]] = row[c]; |
| 105 | + } |
| 106 | + |
| 107 | + return item; |
| 108 | + }) |
| 109 | + .ToList(); |
| 110 | + return JsonSerializer.SerializeToUtf8Bytes(objects, SerializerOptions); |
| 111 | + } |
| 112 | + |
| 113 | + private static string BuildCsv(IReadOnlyList<string> headers, IReadOnlyList<IReadOnlyList<string>> rows) |
| 114 | + { |
| 115 | + var builder = new StringBuilder(); |
| 116 | + builder.AppendLine(string.Join(",", headers.Select(EscapeCsv))); |
| 117 | + foreach (var row in rows) |
| 118 | + { |
| 119 | + builder.AppendLine(string.Join(",", row.Select(EscapeCsv))); |
| 120 | + } |
| 121 | + |
| 122 | + return builder.ToString(); |
| 123 | + } |
| 124 | + |
| 125 | + private static string EscapeCsv(string value) |
| 126 | + { |
| 127 | + if (!value.Contains(',', StringComparison.Ordinal) && !value.Contains('"', StringComparison.Ordinal) && !value.Contains('\n', StringComparison.Ordinal)) |
| 128 | + { |
| 129 | + return value; |
| 130 | + } |
| 131 | + |
| 132 | + return $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; |
| 133 | + } |
| 134 | + |
| 135 | + private static string Slug(string value) |
| 136 | + { |
| 137 | + var chars = value.Trim().ToLowerInvariant() |
| 138 | + .Select(ch => char.IsLetterOrDigit(ch) ? ch : '-') |
| 139 | + .ToArray(); |
| 140 | + return new string(chars).Trim('-'); |
| 141 | + } |
| 142 | +} |
0 commit comments