Deserialize into the shared C# model so master/detail bands render

Templates like report_3.frx use FastReport BusinessObjectDataSource with
nested datasources (e.g. abnormally_high_water_levels_report contains
abnormally_high). FastReport discovers the nested list by reflecting on
the C# type — register a flat DataTable and the inner band stays empty,
which is why customers saw 26-page PDFs with the master headers but no
detail rows.

Pull the report DTOs into the sidecar via cross-project Compile Include
from pwa_api/Modules/Report/Models, take a typeName field in the request,
deserialize JSON into the same concrete type, and call RegisterData with
the typed list — so reflection sees the same shape it always did.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 12:04:03 +07:00
parent af48f51e4c
commit d24f7541f1
2 changed files with 38 additions and 82 deletions

View File

@@ -1,16 +1,12 @@
using System.Collections.Generic; using System.Collections;
using System.Data; using System.Reflection;
using FastReport; using FastReport;
using FastReport.Export.Pdf; using FastReport.Export.Pdf;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using PdfSharpCore.Pdf; using PdfSharpCore.Pdf;
using PdfSharpCore.Pdf.IO; using PdfSharpCore.Pdf.IO;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build(); var app = builder.Build();
var templateRoot = Path.Combine( var templateRoot = Path.Combine(
@@ -18,15 +14,22 @@ var templateRoot = Path.Combine(
builder.Configuration["TemplateRoot"] ?? "wwwroot"); builder.Configuration["TemplateRoot"] ?? "wwwroot");
app.Logger.LogInformation("Template root: {TemplateRoot}", templateRoot); app.Logger.LogInformation("Template root: {TemplateRoot}", templateRoot);
// Index every public type in this assembly by its short name so the JSON
// payload can refer to a model class by name (e.g. "abnormal_meter_not_send_report").
var typeIndex = Assembly.GetExecutingAssembly().GetTypes()
.Where(t => t.IsClass && t.IsPublic)
.ToDictionary(t => t.Name, t => t, StringComparer.Ordinal);
app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow })); app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow }));
// Render a single template + dataset to a PDF. // Render a single template + dataset to a PDF.
// Body shape: // Body shape:
// { // {
// "template": "report_6", // resolves to wwwroot/reports/report_6.frx // "template": "report_6",
// "dataSourceName": "abnormal_meter_not_send_report", // "dataSourceName": "abnormal_meter_not_send_report",
// "rows": [ { "field": "value", ... }, ... ], // "typeName": "abnormal_meter_not_send_report", // class in shared report models
// "fileName": "report.pdf" // optional, response Content-Disposition // "rows": [ { ...same JSON as the C# object would serialize... } ],
// "fileName": "report.pdf"
// } // }
app.MapPost("/render", async (HttpRequest req) => app.MapPost("/render", async (HttpRequest req) =>
{ {
@@ -34,12 +37,11 @@ app.MapPost("/render", async (HttpRequest req) =>
var bodyText = await sr.ReadToEndAsync(); var bodyText = await sr.ReadToEndAsync();
var body = JsonConvert.DeserializeObject<RenderRequest>(bodyText) var body = JsonConvert.DeserializeObject<RenderRequest>(bodyText)
?? throw new ArgumentException("Empty body"); ?? throw new ArgumentException("Empty body");
var pdf = RenderSingle(templateRoot, body); var pdf = RenderSingle(templateRoot, body, typeIndex);
return Results.File(pdf, "application/pdf", body.FileName ?? $"{body.Template}.pdf"); return Results.File(pdf, "application/pdf", body.FileName ?? $"{body.Template}.pdf");
}); });
// Render multiple sections and merge them into a single PDF. // Render multiple sections and merge them into a single PDF.
// Body shape: { "fileName": "merged.pdf", "sections": [ <RenderRequest>, ... ] }
app.MapPost("/render/multi", async (HttpRequest req) => app.MapPost("/render/multi", async (HttpRequest req) =>
{ {
using var sr = new StreamReader(req.Body); using var sr = new StreamReader(req.Body);
@@ -50,7 +52,7 @@ app.MapPost("/render/multi", async (HttpRequest req) =>
if (body.Sections == null || body.Sections.Count == 0) if (body.Sections == null || body.Sections.Count == 0)
return Results.BadRequest(new { error = "sections must contain at least one entry" }); return Results.BadRequest(new { error = "sections must contain at least one entry" });
var streams = body.Sections.Select(s => new MemoryStream(RenderSingle(templateRoot, s))).ToList(); var streams = body.Sections.Select(s => new MemoryStream(RenderSingle(templateRoot, s, typeIndex))).ToList();
var merged = MergePdfs(streams); var merged = MergePdfs(streams);
foreach (var s in streams) s.Dispose(); foreach (var s in streams) s.Dispose();
return Results.File(merged, "application/pdf", body.FileName ?? "merged.pdf"); return Results.File(merged, "application/pdf", body.FileName ?? "merged.pdf");
@@ -61,7 +63,7 @@ return;
// ---- helpers -------------------------------------------------------------- // ---- helpers --------------------------------------------------------------
static byte[] RenderSingle(string templateRoot, RenderRequest req) static byte[] RenderSingle(string templateRoot, RenderRequest req, Dictionary<string, Type> typeIndex)
{ {
if (string.IsNullOrWhiteSpace(req.Template)) if (string.IsNullOrWhiteSpace(req.Template))
throw new ArgumentException("template is required"); throw new ArgumentException("template is required");
@@ -73,18 +75,20 @@ static byte[] RenderSingle(string templateRoot, RenderRequest req)
using var report = new Report(); using var report = new Report();
report.Load(path); report.Load(path);
if (req.Rows != null && req.Rows.Count > 0) if (req.Rows != null && !string.IsNullOrEmpty(req.TypeName))
{ {
var dt = JArrayToDataTable(req.Rows, req.DataSourceName ?? "data"); if (!typeIndex.TryGetValue(req.TypeName, out var elementType))
report.RegisterData(dt, dt.TableName); throw new ArgumentException($"Unknown typeName: {req.TypeName}. Add the class to pwa_api/Modules/Report/Models/ so the sidecar can compile it in.");
report.GetDataSource(dt.TableName).Enabled = true;
} var listType = typeof(List<>).MakeGenericType(elementType);
else if (!string.IsNullOrEmpty(req.DataSourceName)) var typedList = (IList?)JsonConvert.DeserializeObject(req.Rows.ToString(), listType);
{ if (typedList == null)
// No rows but a name was given — register an empty table so the template throw new ArgumentException("Failed to deserialize rows.");
// renders its bands without the "data source not found" error.
var dt = new DataTable(req.DataSourceName); var dsName = req.DataSourceName ?? elementType.Name;
report.RegisterData(dt, dt.TableName); report.RegisterData((IEnumerable)typedList, dsName);
var ds = report.GetDataSource(dsName);
if (ds != null) ds.Enabled = true;
} }
report.Prepare(); report.Prepare();
@@ -95,8 +99,6 @@ static byte[] RenderSingle(string templateRoot, RenderRequest req)
return ms.ToArray(); return ms.ToArray();
} }
// Resolves "report_6" → wwwroot/reports/report_6.frx, with "documents/uuid"
// supported for the empty-report templates that live under wwwroot/documents.
static string ResolveTemplatePath(string root, string name) static string ResolveTemplatePath(string root, string name)
{ {
if (name.EndsWith(".frx", StringComparison.OrdinalIgnoreCase)) if (name.EndsWith(".frx", StringComparison.OrdinalIgnoreCase))
@@ -109,61 +111,7 @@ static string ResolveTemplatePath(string root, string name)
if (File.Exists(inReports)) return inReports; if (File.Exists(inReports)) return inReports;
var inDocuments = Path.Combine(root, "documents", withExt); var inDocuments = Path.Combine(root, "documents", withExt);
if (File.Exists(inDocuments)) return inDocuments; if (File.Exists(inDocuments)) return inDocuments;
return inReports; // let RenderSingle throw a clean FileNotFoundException return inReports;
}
// Build a DataTable whose columns are the union of keys present across the
// JArray rows. FastReport binds .frx columns to DataTable columns by name.
static DataTable JArrayToDataTable(JArray rows, string tableName)
{
var dt = new DataTable(tableName);
foreach (var token in rows)
{
if (token is not JObject obj) continue;
foreach (var prop in obj.Properties())
{
if (dt.Columns.Contains(prop.Name)) continue;
dt.Columns.Add(prop.Name, InferColumnType(prop.Value));
}
}
foreach (var token in rows)
{
if (token is not JObject obj) continue;
var row = dt.NewRow();
foreach (DataColumn col in dt.Columns)
{
var jt = obj[col.ColumnName];
row[col.ColumnName] = JTokenToCell(jt, col.DataType);
}
dt.Rows.Add(row);
}
return dt;
}
static Type InferColumnType(JToken? value) => value?.Type switch
{
JTokenType.Integer => typeof(long),
JTokenType.Float => typeof(decimal),
JTokenType.Boolean => typeof(bool),
JTokenType.Date => typeof(DateTime),
_ => typeof(string),
};
static object JTokenToCell(JToken? jt, Type targetType)
{
if (jt == null || jt.Type == JTokenType.Null || jt.Type == JTokenType.Undefined)
return DBNull.Value;
try
{
if (targetType == typeof(string)) return jt.ToString();
return jt.ToObject(targetType) ?? (object)DBNull.Value;
}
catch
{
return jt.ToString();
}
} }
static byte[] MergePdfs(IEnumerable<MemoryStream> sources) static byte[] MergePdfs(IEnumerable<MemoryStream> sources)
@@ -187,7 +135,8 @@ public class RenderRequest
{ {
public string Template { get; set; } = ""; public string Template { get; set; } = "";
public string? DataSourceName { get; set; } public string? DataSourceName { get; set; }
public JArray? Rows { get; set; } public string? TypeName { get; set; }
public Newtonsoft.Json.Linq.JArray? Rows { get; set; }
public string? FileName { get; set; } public string? FileName { get; set; }
} }

View File

@@ -15,6 +15,13 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup> </ItemGroup>
<!-- Share the report DTO classes with pwa_api so the sidecar deserializes
JSON into the same concrete types. FastReport reflects on those types
to find nested List<T> properties (master/detail bands). -->
<ItemGroup>
<Compile Include="..\pwa_api\Modules\Report\Models\report_*.cs" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<!-- FastReport templates and fonts shared from main API --> <!-- FastReport templates and fonts shared from main API -->
<None Include="..\pwa_api\wwwroot\reports\**\*" Link="wwwroot\reports\%(RecursiveDir)%(Filename)%(Extension)"> <None Include="..\pwa_api\wwwroot\reports\**\*" Link="wwwroot\reports\%(RecursiveDir)%(Filename)%(Extension)">