Skip to content

Commit 5a01c52

Browse files
committed
feat(export): customizable export templates (Excel/CSV/JSON) with live preview
Lets the company define exactly how data leaves Reve. A template is an ordered set of columns, each mapping a canonical field or a line-item table column to a custom header, plus an output format. Built-in templates (Canonical CSV, Bordereau line items, Lloyd's CRS 5.2 core, Full JSON) ship seeded and read-only; users create, edit, duplicate, and delete their own. - Core: ExportFormat + ExportColumn/ExportTemplate/ExportTemplateDraft/ExportPreview/ExportFile. - Infrastructure: ExportTemplateRecord + migration; IExportTemplateStore (CRUD, idempotent built-in seeding by fixed Id); IDocumentExporter renders preview + CSV/Excel(ClosedXML)/JSON. A column matching a line-item header is pulled per row; document-level fields repeat. - Web: /api/templates CRUD + /export?templateId; an Export-templates page with a live preview against a real document; the review screen's Export menu lists templates; rail nav entry. - Tests: exporter (preview, line-item vs document scope, CSV/JSON/Excel) + template API CRUD + export-with-template. 25 unit + 9 integration green. Also fixes an API bug surfaced by the tests: the not-found re-execute now skips /api routes, so JSON 404s no longer surface as 405. Refs #28
1 parent ee3ecfd commit 5a01c52

24 files changed

Lines changed: 1731 additions & 4 deletions

docs/demo-script.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ notices. See `docs/research/reinsurance-landscape.md` for the domain grounding.
3737
Edit a field; it shows as *Reviewed* once approved.
3838
4. **Checks** — the Detected-vs-Expected reconciliation cards, computed from the data, ranked
3939
by how badly they disagree.
40-
5. **Export** — JSON or CSV in one click. (Customizable export templates + Lloyd's CRS 5.2 are
41-
on the roadmap, issues #28/#34.)
40+
5. **Export** — pick a template from the Export menu (Bordereau line items, Lloyd's CRS 5.2,
41+
Canonical CSV, Full JSON) and download as Excel/CSV/JSON. Build your own on the **Export
42+
templates** page: choose columns, rename headers, pick the format, watch the live preview.
4243

4344
## Why it is built well (talking points)
4445

src/Reva.Core/Contracts.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Reva.Core.Documents;
2+
using Reva.Core.Export;
23
using Reva.Core.Reinsurance;
34

45
namespace Reva.Core.Contracts;
@@ -68,4 +69,26 @@ public sealed record ExportRecord(
6869
IReadOnlyDictionary<string, string> Fields,
6970
DateTimeOffset ExportedAt);
7071

72+
// One output column: the header the company wants, and the canonical field or table column
73+
// it pulls from.
74+
public sealed record ExportColumn(string Header, string Source);
75+
76+
// A reusable, customizable export layout. Built-in templates ship with the app and cannot be
77+
// deleted; user templates are fully editable.
78+
public sealed record ExportTemplate(
79+
Guid Id,
80+
string Name,
81+
ExportFormat Format,
82+
IReadOnlyList<ExportColumn> Columns,
83+
bool IsBuiltIn);
84+
85+
// The editable shape used to create or update a template.
86+
public sealed record ExportTemplateDraft(string Name, ExportFormat Format, IReadOnlyList<ExportColumn> Columns);
87+
88+
// A small rendered sample of what a template will produce, for the live preview.
89+
public sealed record ExportPreview(IReadOnlyList<string> Headers, IReadOnlyList<IReadOnlyList<string>> Rows);
90+
91+
// A rendered export ready to download.
92+
public sealed record ExportFile(byte[] Content, string ContentType, string FileName);
93+
7194

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace Reva.Core.Export;
2+
3+
// The file shape an export template produces.
4+
public enum ExportFormat
5+
{
6+
Csv,
7+
Excel,
8+
Json
9+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
using Reva.Core.Contracts;
2+
using Reva.Core.Export;
3+
using Reva.Core.Reinsurance;
4+
5+
namespace Reva.Infrastructure.Export;
6+
7+
// The built-in export layouts seeded on first run. They cannot be deleted, but a user can
8+
// duplicate one and edit the copy. Ids are fixed so seeding is idempotent.
9+
public static class ExportTemplateDefaults
10+
{
11+
public static IReadOnlyList<ExportTemplate> All =>
12+
[
13+
new ExportTemplate(
14+
new Guid("11111111-1111-1111-1111-111111111111"),
15+
"Canonical fields (CSV)",
16+
ExportFormat.Csv,
17+
[.. ReinsuranceFieldNames.Canonical.Select(name => new ExportColumn(name, name))],
18+
IsBuiltIn: true),
19+
20+
new ExportTemplate(
21+
new Guid("22222222-2222-2222-2222-222222222222"),
22+
"Bordereau line items (Excel)",
23+
ExportFormat.Excel,
24+
[
25+
new ExportColumn("Cedent", ReinsuranceFieldNames.Cedent),
26+
new ExportColumn("Period", ReinsuranceFieldNames.Period),
27+
new ExportColumn("Member", "Member"),
28+
new ExportColumn("Line of Business", "Line of Business"),
29+
new ExportColumn("Premium", "Premium"),
30+
new ExportColumn("Claims", "Claims"),
31+
new ExportColumn("Commission", "Commission"),
32+
new ExportColumn("Net Ceded", "Net Ceded"),
33+
new ExportColumn("Cession %", "Cession %")
34+
],
35+
IsBuiltIn: true),
36+
37+
new ExportTemplate(
38+
new Guid("33333333-3333-3333-3333-333333333333"),
39+
"Lloyd's CRS 5.2 core (Excel)",
40+
ExportFormat.Excel,
41+
[
42+
new ExportColumn("Unique Market Reference", ReinsuranceFieldNames.ContractReference),
43+
new ExportColumn("Risk Reference", "Member"),
44+
new ExportColumn("Period of Cover", ReinsuranceFieldNames.Period),
45+
new ExportColumn("Original Currency", ReinsuranceFieldNames.Currency),
46+
new ExportColumn("Original Gross Premium", "Premium"),
47+
new ExportColumn("Commission", "Commission"),
48+
new ExportColumn("Cession %", "Cession %")
49+
],
50+
IsBuiltIn: true),
51+
52+
new ExportTemplate(
53+
new Guid("44444444-4444-4444-4444-444444444444"),
54+
"Full record (JSON)",
55+
ExportFormat.Json,
56+
[.. ReinsuranceFieldNames.Canonical.Select(name => new ExportColumn(name, name))],
57+
IsBuiltIn: true)
58+
];
59+
}

0 commit comments

Comments
 (0)