Files
pwa_pdf_service/Program.cs
chokchai d24f7541f1 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>
2026-05-04 12:04:03 +07:00

148 lines
5.3 KiB
C#

using System.Collections;
using System.Reflection;
using FastReport;
using FastReport.Export.Pdf;
using Newtonsoft.Json;
using PdfSharpCore.Pdf;
using PdfSharpCore.Pdf.IO;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var templateRoot = Path.Combine(
AppContext.BaseDirectory,
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",
// "dataSourceName": "abnormal_meter_not_send_report",
// "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) =>
{
using var sr = new StreamReader(req.Body);
var bodyText = await sr.ReadToEndAsync();
var body = JsonConvert.DeserializeObject<RenderRequest>(bodyText)
?? throw new ArgumentException("Empty 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.
app.MapPost("/render/multi", async (HttpRequest req) =>
{
using var sr = new StreamReader(req.Body);
var bodyText = await sr.ReadToEndAsync();
var body = JsonConvert.DeserializeObject<MultiRenderRequest>(bodyText)
?? throw new ArgumentException("Empty body");
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, typeIndex))).ToList();
var merged = MergePdfs(streams);
foreach (var s in streams) s.Dispose();
return Results.File(merged, "application/pdf", body.FileName ?? "merged.pdf");
});
app.Run();
return;
// ---- helpers --------------------------------------------------------------
static byte[] RenderSingle(string templateRoot, RenderRequest req, Dictionary<string, Type> typeIndex)
{
if (string.IsNullOrWhiteSpace(req.Template))
throw new ArgumentException("template is required");
var path = ResolveTemplatePath(templateRoot, req.Template);
if (!File.Exists(path))
throw new FileNotFoundException($"Template not found: {req.Template}", path);
using var report = new Report();
report.Load(path);
if (req.Rows != null && !string.IsNullOrEmpty(req.TypeName))
{
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();
using var ms = new MemoryStream();
var pdfExport = new PDFExport();
report.Export(pdfExport, ms);
return ms.ToArray();
}
static string ResolveTemplatePath(string root, string name)
{
if (name.EndsWith(".frx", StringComparison.OrdinalIgnoreCase))
{
var direct = Path.Combine(root, name);
if (File.Exists(direct)) return direct;
}
var withExt = name.EndsWith(".frx") ? name : name + ".frx";
var inReports = Path.Combine(root, "reports", withExt);
if (File.Exists(inReports)) return inReports;
var inDocuments = Path.Combine(root, "documents", withExt);
if (File.Exists(inDocuments)) return inDocuments;
return inReports;
}
static byte[] MergePdfs(IEnumerable<MemoryStream> sources)
{
using var output = new PdfDocument();
foreach (var src in sources)
{
src.Position = 0;
using var input = PdfReader.Open(src, PdfDocumentOpenMode.Import);
for (var i = 0; i < input.PageCount; i++)
output.AddPage(input.Pages[i]);
}
using var ms = new MemoryStream();
output.Save(ms, false);
return ms.ToArray();
}
// ---- DTOs -----------------------------------------------------------------
public class RenderRequest
{
public string Template { get; set; } = "";
public string? DataSourceName { get; set; }
public string? TypeName { get; set; }
public Newtonsoft.Json.Linq.JArray? Rows { get; set; }
public string? FileName { get; set; }
}
public class MultiRenderRequest
{
public string? FileName { get; set; }
public List<RenderRequest>? Sections { get; set; }
}