Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

## Why it is built well (talking points)

Expand Down
23 changes: 23 additions & 0 deletions src/Reva.Core/Contracts.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Reva.Core.Documents;
using Reva.Core.Export;
using Reva.Core.Reinsurance;

namespace Reva.Core.Contracts;
Expand Down Expand Up @@ -68,4 +69,26 @@ public sealed record ExportRecord(
IReadOnlyDictionary<string, string> Fields,
DateTimeOffset ExportedAt);

// One output column: the header the company wants, and the canonical field or table column
// it pulls from.
public sealed record ExportColumn(string Header, string Source);

// A reusable, customizable export layout. Built-in templates ship with the app and cannot be
// deleted; user templates are fully editable.
public sealed record ExportTemplate(
Guid Id,
string Name,
ExportFormat Format,
IReadOnlyList<ExportColumn> Columns,
bool IsBuiltIn);

// The editable shape used to create or update a template.
public sealed record ExportTemplateDraft(string Name, ExportFormat Format, IReadOnlyList<ExportColumn> Columns);

// A small rendered sample of what a template will produce, for the live preview.
public sealed record ExportPreview(IReadOnlyList<string> Headers, IReadOnlyList<IReadOnlyList<string>> Rows);

// A rendered export ready to download.
public sealed record ExportFile(byte[] Content, string ContentType, string FileName);


9 changes: 9 additions & 0 deletions src/Reva.Core/Export/ExportFormat.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Reva.Core.Export;

// The file shape an export template produces.
public enum ExportFormat
{
Csv,
Excel,
Json
}
142 changes: 142 additions & 0 deletions src/Reva.Infrastructure/Export/DocumentExporter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
using System.Text;
using System.Text.Json;
using ClosedXML.Excel;
using Reva.Core.Contracts;
using Reva.Core.Export;

namespace Reva.Infrastructure.Export;

// Applies an export template to a document's canonical data. A column whose source matches a
// line-item table header is pulled per row; otherwise it is a document-level field repeated on
// every row. If no column references the table, the export is a single document-level row.
public sealed class DocumentExporter : IDocumentExporter
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };

public ExportPreview Preview(DocumentDetail document, ExportTemplate layout, int maxRows = 6)
{
var (headers, rows) = Render(document, layout);
return new ExportPreview(headers, [.. rows.Take(Math.Max(0, maxRows))]);
}

public ExportFile Export(DocumentDetail document, ExportTemplate layout)
{
var (headers, rows) = Render(document, layout);
var stem = $"reva-{Slug(layout.Name)}-{document.Id:N}";

return layout.Format switch
{
ExportFormat.Excel => new ExportFile(BuildExcel(headers, rows), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", $"{stem}.xlsx"),
ExportFormat.Json => new ExportFile(BuildJson(headers, rows), "application/json", $"{stem}.json"),
_ => new ExportFile(Encoding.UTF8.GetBytes(BuildCsv(headers, rows)), "text/csv", $"{stem}.csv")
};
}

private static (IReadOnlyList<string> Headers, IReadOnlyList<IReadOnlyList<string>> Rows) Render(DocumentDetail document, ExportTemplate template)
{
var headers = template.Columns.Select(column => column.Header).ToList();
var fields = document.Fields.ToDictionary(field => field.Name, field => field.Value, StringComparer.OrdinalIgnoreCase);
var table = document.Tables.FirstOrDefault(candidate => candidate.Rows.Count > 0);

// Pre-resolve each column to either a table header (per-row) or a field (document-level).
var resolved = template.Columns
.Select(column => new
{
column.Source,
TableHeader = table?.Headers.FirstOrDefault(header => header.Equals(column.Source, StringComparison.OrdinalIgnoreCase))
})
.ToList();

var usesTable = table is not null && resolved.Any(column => column.TableHeader is not null);

if (usesTable)
{
var rows = table!.Rows
.Select(row => (IReadOnlyList<string>)resolved
.Select(column => column.TableHeader is not null
? (row.TryGetValue(column.TableHeader, out var cell) ? cell : string.Empty)
: (fields.TryGetValue(column.Source, out var value) ? value : string.Empty))
.ToList())
.ToList();
return (headers, rows);
}

var single = (IReadOnlyList<string>)resolved
.Select(column => fields.TryGetValue(column.Source, out var value) ? value : string.Empty)
.ToList();
return (headers, [single]);
}

private static byte[] BuildExcel(IReadOnlyList<string> headers, IReadOnlyList<IReadOnlyList<string>> rows)
{
using var workbook = new XLWorkbook();
var sheet = workbook.AddWorksheet("Export");
for (var c = 0; c < headers.Count; c++)
{
sheet.Cell(1, c + 1).Value = headers[c];
}

sheet.Row(1).Style.Font.Bold = true;

for (var r = 0; r < rows.Count; r++)
{
var row = rows[r];
for (var c = 0; c < row.Count; c++)
{
sheet.Cell(r + 2, c + 1).Value = row[c];
}
}

sheet.Columns().AdjustToContents();
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return stream.ToArray();
}

private static byte[] BuildJson(IReadOnlyList<string> headers, IReadOnlyList<IReadOnlyList<string>> rows)
{
var objects = rows
.Select(row =>
{
var item = new Dictionary<string, string>(StringComparer.Ordinal);
for (var c = 0; c < headers.Count && c < row.Count; c++)
{
item[headers[c]] = row[c];
}

return item;
})
.ToList();
return JsonSerializer.SerializeToUtf8Bytes(objects, SerializerOptions);
}

private static string BuildCsv(IReadOnlyList<string> headers, IReadOnlyList<IReadOnlyList<string>> rows)
{
var builder = new StringBuilder();
builder.AppendLine(string.Join(",", headers.Select(EscapeCsv)));
foreach (var row in rows)
{
builder.AppendLine(string.Join(",", row.Select(EscapeCsv)));
}

return builder.ToString();
}

private static string EscapeCsv(string value)
{
if (!value.Contains(',', StringComparison.Ordinal) && !value.Contains('"', StringComparison.Ordinal) && !value.Contains('\n', StringComparison.Ordinal))
{
return value;
}

return $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
}

private static string Slug(string value)
{
var chars = value.Trim().ToLowerInvariant()
.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')
.ToArray();
return new string(chars).Trim('-');
}
}
59 changes: 59 additions & 0 deletions src/Reva.Infrastructure/Export/ExportTemplateDefaults.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using Reva.Core.Contracts;
using Reva.Core.Export;
using Reva.Core.Reinsurance;

namespace Reva.Infrastructure.Export;

// The built-in export layouts seeded on first run. They cannot be deleted, but a user can
// duplicate one and edit the copy. Ids are fixed so seeding is idempotent.
public static class ExportTemplateDefaults
{
public static IReadOnlyList<ExportTemplate> All =>
[
new ExportTemplate(
new Guid("11111111-1111-1111-1111-111111111111"),
"Canonical fields (CSV)",
ExportFormat.Csv,
[.. ReinsuranceFieldNames.Canonical.Select(name => new ExportColumn(name, name))],
IsBuiltIn: true),

new ExportTemplate(
new Guid("22222222-2222-2222-2222-222222222222"),
"Bordereau line items (Excel)",
ExportFormat.Excel,
[
new ExportColumn("Cedent", ReinsuranceFieldNames.Cedent),
new ExportColumn("Period", ReinsuranceFieldNames.Period),
new ExportColumn("Member", "Member"),
new ExportColumn("Line of Business", "Line of Business"),
new ExportColumn("Premium", "Premium"),
new ExportColumn("Claims", "Claims"),
new ExportColumn("Commission", "Commission"),
new ExportColumn("Net Ceded", "Net Ceded"),
new ExportColumn("Cession %", "Cession %")
],
IsBuiltIn: true),

new ExportTemplate(
new Guid("33333333-3333-3333-3333-333333333333"),
"Lloyd's CRS 5.2 core (Excel)",
ExportFormat.Excel,
[
new ExportColumn("Unique Market Reference", ReinsuranceFieldNames.ContractReference),
new ExportColumn("Risk Reference", "Member"),
new ExportColumn("Period of Cover", ReinsuranceFieldNames.Period),
new ExportColumn("Original Currency", ReinsuranceFieldNames.Currency),
new ExportColumn("Original Gross Premium", "Premium"),
new ExportColumn("Commission", "Commission"),
new ExportColumn("Cession %", "Cession %")
],
IsBuiltIn: true),

new ExportTemplate(
new Guid("44444444-4444-4444-4444-444444444444"),
"Full record (JSON)",
ExportFormat.Json,
[.. ReinsuranceFieldNames.Canonical.Select(name => new ExportColumn(name, name))],
IsBuiltIn: true)
];
}
Loading
Loading