From d24f7541f1f792662b7b20212071ceaa43090258 Mon Sep 17 00:00:00 2001 From: chokchai Date: Mon, 4 May 2026 12:04:03 +0700 Subject: [PATCH] Deserialize into the shared C# model so master/detail bands render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Program.cs | 113 +++++++++++------------------------------ pwa_pdf_service.csproj | 7 +++ 2 files changed, 38 insertions(+), 82 deletions(-) diff --git a/Program.cs b/Program.cs index 1ead333..fdbc2bc 100644 --- a/Program.cs +++ b/Program.cs @@ -1,16 +1,12 @@ -using System.Collections.Generic; -using System.Data; +using System.Collections; +using System.Reflection; using FastReport; using FastReport.Export.Pdf; -using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using PdfSharpCore.Pdf; using PdfSharpCore.Pdf.IO; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddEndpointsApiExplorer(); - var app = builder.Build(); var templateRoot = Path.Combine( @@ -18,15 +14,22 @@ var templateRoot = Path.Combine( builder.Configuration["TemplateRoot"] ?? "wwwroot"); 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 })); // Render a single template + dataset to a PDF. // Body shape: // { -// "template": "report_6", // resolves to wwwroot/reports/report_6.frx +// "template": "report_6", // "dataSourceName": "abnormal_meter_not_send_report", -// "rows": [ { "field": "value", ... }, ... ], -// "fileName": "report.pdf" // optional, response Content-Disposition +// "typeName": "abnormal_meter_not_send_report", // class in shared report models +// "rows": [ { ...same JSON as the C# object would serialize... } ], +// "fileName": "report.pdf" // } app.MapPost("/render", async (HttpRequest req) => { @@ -34,12 +37,11 @@ app.MapPost("/render", async (HttpRequest req) => var bodyText = await sr.ReadToEndAsync(); var body = JsonConvert.DeserializeObject(bodyText) ?? 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"); }); // Render multiple sections and merge them into a single PDF. -// Body shape: { "fileName": "merged.pdf", "sections": [ , ... ] } app.MapPost("/render/multi", async (HttpRequest req) => { 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) 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); foreach (var s in streams) s.Dispose(); return Results.File(merged, "application/pdf", body.FileName ?? "merged.pdf"); @@ -61,7 +63,7 @@ return; // ---- helpers -------------------------------------------------------------- -static byte[] RenderSingle(string templateRoot, RenderRequest req) +static byte[] RenderSingle(string templateRoot, RenderRequest req, Dictionary typeIndex) { if (string.IsNullOrWhiteSpace(req.Template)) throw new ArgumentException("template is required"); @@ -73,18 +75,20 @@ static byte[] RenderSingle(string templateRoot, RenderRequest req) using var report = new Report(); 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"); - report.RegisterData(dt, dt.TableName); - report.GetDataSource(dt.TableName).Enabled = true; - } - else if (!string.IsNullOrEmpty(req.DataSourceName)) - { - // No rows but a name was given — register an empty table so the template - // renders its bands without the "data source not found" error. - var dt = new DataTable(req.DataSourceName); - report.RegisterData(dt, dt.TableName); + if (!typeIndex.TryGetValue(req.TypeName, out var elementType)) + throw new ArgumentException($"Unknown typeName: {req.TypeName}. Add the class to pwa_api/Modules/Report/Models/ so the sidecar can compile it in."); + + var listType = typeof(List<>).MakeGenericType(elementType); + var typedList = (IList?)JsonConvert.DeserializeObject(req.Rows.ToString(), listType); + if (typedList == null) + throw new ArgumentException("Failed to deserialize rows."); + + var dsName = req.DataSourceName ?? elementType.Name; + report.RegisterData((IEnumerable)typedList, dsName); + var ds = report.GetDataSource(dsName); + if (ds != null) ds.Enabled = true; } report.Prepare(); @@ -95,8 +99,6 @@ static byte[] RenderSingle(string templateRoot, RenderRequest req) 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) { if (name.EndsWith(".frx", StringComparison.OrdinalIgnoreCase)) @@ -109,61 +111,7 @@ static string ResolveTemplatePath(string root, string name) if (File.Exists(inReports)) return inReports; var inDocuments = Path.Combine(root, "documents", withExt); if (File.Exists(inDocuments)) return inDocuments; - return inReports; // let RenderSingle throw a clean FileNotFoundException -} - -// 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(); - } + return inReports; } static byte[] MergePdfs(IEnumerable sources) @@ -187,7 +135,8 @@ public class RenderRequest { public string Template { 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; } } diff --git a/pwa_pdf_service.csproj b/pwa_pdf_service.csproj index ae6587f..af977d7 100644 --- a/pwa_pdf_service.csproj +++ b/pwa_pdf_service.csproj @@ -15,6 +15,13 @@ + + + + +