Config system fixes: - Implement SavePump() — full XML serialization with insert/update by pump ID - Add CanBusParameter.ToPumpXml() for legacy P1-P6 pump param format - Fix LastRotationDirection never loaded in LoadSettings() - Add SaveAlarms() to ConfigurationService and IConfigurationService - Remove dead fields AppSettings.Clients and AppSettings.PumpIds PDF report redesign: - Professional layout with charts, verdict badges, and tolerance bands - Add ReportChartRenderer (SVG) and ReportTheme styling constants - Embed default_logo.png as fallback report logo Documentation: - Add gap analysis docs (config validation, ford unlock, missing features) - Update CLAUDE.md architecture, known gaps, and debt tracking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
546 lines
25 KiB
C#
546 lines
25 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using HC_APTBS.Models;
|
|
using QuestPDF.Fluent;
|
|
using QuestPDF.Helpers;
|
|
using QuestPDF.Infrastructure;
|
|
|
|
namespace HC_APTBS.Services.Impl
|
|
{
|
|
/// <summary>
|
|
/// Generates professional PDF test reports using QuestPDF.
|
|
///
|
|
/// <para>
|
|
/// Report layout:
|
|
/// <list type="bullet">
|
|
/// <item>Header (every page): company logo, company name, date, operator, client, report title.</item>
|
|
/// <item>Pump identification table: ID, serial, model, rotation, lock angle.</item>
|
|
/// <item>K-Line ECU data block: model reference, DFI, SW versions, fault codes.</item>
|
|
/// <item>Overall verdict section: large PASS/FAIL badge with summary statistics.</item>
|
|
/// <item>Per-test results: table with pass/fail rows, followed by measurement charts
|
|
/// showing sample values against target ± tolerance bands.</item>
|
|
/// <item>Footer (every page): software attribution and page numbers.</item>
|
|
/// </list>
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class PdfService : IPdfService
|
|
{
|
|
private readonly IConfigurationService _config;
|
|
private readonly byte[]? _defaultLogo;
|
|
|
|
/// <param name="configService">Provides company name, logo path, and report settings.</param>
|
|
public PdfService(IConfigurationService configService)
|
|
{
|
|
_config = configService;
|
|
|
|
// QuestPDF community licence — required for open-source use.
|
|
QuestPDF.Settings.License = LicenseType.Community;
|
|
|
|
// Load the embedded default logo as a fallback.
|
|
using var stream = typeof(PdfService).Assembly
|
|
.GetManifestResourceStream("HC_APTBS.Resources.Images.default_logo.png");
|
|
if (stream != null)
|
|
{
|
|
using var ms = new MemoryStream();
|
|
stream.CopyTo(ms);
|
|
_defaultLogo = ms.ToArray();
|
|
}
|
|
}
|
|
|
|
// ── IPdfService ───────────────────────────────────────────────────────────
|
|
|
|
/// <inheritdoc/>
|
|
public string GenerateReport(
|
|
PumpDefinition pump,
|
|
string operatorName,
|
|
string clientName,
|
|
string outputFolder)
|
|
{
|
|
Directory.CreateDirectory(outputFolder);
|
|
|
|
string fileName = SanitiseFileName(
|
|
$"{pump.Id}_{pump.SerialNumber}_{clientName}_{DateTime.Now:yyyy-MM-dd_HH-mm}.pdf");
|
|
string filePath = Path.Combine(outputFolder, fileName);
|
|
|
|
var reportDate = DateTime.Now;
|
|
|
|
var document = Document.Create(container =>
|
|
{
|
|
container.Page(page =>
|
|
{
|
|
page.Size(PageSizes.A4);
|
|
page.Margin(25, Unit.Millimetre);
|
|
page.DefaultTextStyle(x => x.FontSize(ReportTheme.BodySize).FontFamily(Fonts.Arial));
|
|
|
|
page.Header().Element(c => ComposeHeader(c, operatorName, clientName, reportDate));
|
|
page.Content().Element(c => ComposeContent(c, pump));
|
|
page.Footer().Element(ComposeFooter);
|
|
});
|
|
});
|
|
|
|
document.GeneratePdf(filePath);
|
|
return filePath;
|
|
}
|
|
|
|
// ── Header ────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Renders the page header: logo, company info, date/operator/client, title.</summary>
|
|
private void ComposeHeader(
|
|
IContainer container, string operatorName, string clientName, DateTime reportDate)
|
|
{
|
|
container.Column(outer =>
|
|
{
|
|
outer.Item().Row(row =>
|
|
{
|
|
// Company logo — prefer configured path, fall back to embedded default.
|
|
if (File.Exists(_config.Settings.ReportLogoPath))
|
|
{
|
|
row.ConstantItem(65).Height(45)
|
|
.Image(_config.Settings.ReportLogoPath)
|
|
.FitArea();
|
|
}
|
|
else if (_defaultLogo != null)
|
|
{
|
|
row.ConstantItem(65).Height(45)
|
|
.Image(_defaultLogo)
|
|
.FitArea();
|
|
}
|
|
|
|
// Company name and info.
|
|
row.RelativeItem().PaddingLeft(10).Column(col =>
|
|
{
|
|
col.Item().Text(_config.Settings.CompanyName)
|
|
.Bold().FontSize(ReportTheme.TitleSize);
|
|
col.Item().Text(_config.Settings.CompanyInfo)
|
|
.FontSize(ReportTheme.CaptionSize)
|
|
.FontColor(ReportTheme.HeaderGrey);
|
|
});
|
|
|
|
// Date / operator / client block.
|
|
row.ConstantItem(140).AlignRight().Column(col =>
|
|
{
|
|
col.Item().Text($"Date: {reportDate:dd/MM/yyyy HH:mm}")
|
|
.FontSize(ReportTheme.CaptionSize + 1);
|
|
col.Item().Text($"Operator: {operatorName}")
|
|
.FontSize(ReportTheme.CaptionSize + 1);
|
|
col.Item().Text($"Client: {clientName}")
|
|
.FontSize(ReportTheme.CaptionSize + 1).Bold();
|
|
});
|
|
});
|
|
|
|
// Divider line.
|
|
outer.Item().PaddingTop(4).LineHorizontal(1)
|
|
.LineColor(ReportTheme.DividerLine);
|
|
|
|
// Report title.
|
|
outer.Item().PaddingTop(4).PaddingBottom(2)
|
|
.AlignCenter()
|
|
.Text("VP44 INJECTION PUMP TEST REPORT")
|
|
.Bold().FontSize(ReportTheme.SectionHeaderSize)
|
|
.FontColor(ReportTheme.HeaderNavy);
|
|
});
|
|
}
|
|
|
|
// ── Footer ────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Renders the page footer: divider, attribution, and page numbers.</summary>
|
|
private static void ComposeFooter(IContainer container)
|
|
{
|
|
container.Column(col =>
|
|
{
|
|
col.Item().LineHorizontal(0.5f).LineColor(ReportTheme.DividerLine);
|
|
col.Item().PaddingTop(3).Row(row =>
|
|
{
|
|
row.RelativeItem().Text("Generated by HC-APTBS")
|
|
.FontSize(ReportTheme.FooterSize)
|
|
.FontColor(ReportTheme.HeaderGrey);
|
|
|
|
row.ConstantItem(100).AlignRight().Text(t =>
|
|
{
|
|
t.DefaultTextStyle(x => x.FontSize(ReportTheme.FooterSize));
|
|
t.Span("Page ");
|
|
t.CurrentPageNumber();
|
|
t.Span(" of ");
|
|
t.TotalPages();
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Content ───────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Composes the full report body: pump info, ECU data, verdict, test sections.</summary>
|
|
private static void ComposeContent(IContainer container, PumpDefinition pump)
|
|
{
|
|
container.PaddingTop(6).Column(col =>
|
|
{
|
|
// ── Pump identification ──────────────────────────────────────────
|
|
col.Item().PaddingBottom(ReportTheme.SectionGap)
|
|
.Element(c => ComposePumpInfoTable(c, pump));
|
|
|
|
// ── K-Line ECU data ──────────────────────────────────────────────
|
|
if (pump.KlineInfo.Count > 0)
|
|
col.Item().PaddingBottom(ReportTheme.SectionGap)
|
|
.Element(c => ComposeKlineTable(c, pump));
|
|
|
|
// ── Overall verdict ──────────────────────────────────────────────
|
|
col.Item().PaddingBottom(ReportTheme.SectionGap)
|
|
.Element(c => ComposeVerdictSection(c, pump));
|
|
|
|
// ── Test results — one section per test ──────────────────────────
|
|
foreach (var test in pump.Tests)
|
|
{
|
|
if (!test.HasResults()) continue;
|
|
col.Item().PaddingBottom(ReportTheme.SectionGap)
|
|
.Element(c => ComposeTestSection(c, test));
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Pump info table ───────────────────────────────────────────────────────
|
|
|
|
/// <summary>Renders the pump identification table with alternating row stripes.</summary>
|
|
private static void ComposePumpInfoTable(IContainer container, PumpDefinition pump)
|
|
{
|
|
container.Table(table =>
|
|
{
|
|
table.ColumnsDefinition(cols =>
|
|
{
|
|
cols.RelativeColumn(1);
|
|
cols.RelativeColumn(2);
|
|
cols.RelativeColumn(1);
|
|
cols.RelativeColumn(2);
|
|
});
|
|
|
|
table.Header(header =>
|
|
{
|
|
header.Cell().ColumnSpan(4)
|
|
.Background(ReportTheme.HeaderNavy)
|
|
.Padding(5)
|
|
.Text("PUMP IDENTIFICATION")
|
|
.FontColor(Colors.White).Bold()
|
|
.FontSize(ReportTheme.SectionHeaderSize);
|
|
});
|
|
|
|
int rowIndex = 0;
|
|
|
|
void AddRow(string label1, string value1, string label2, string value2)
|
|
{
|
|
string bg = (rowIndex++ % 2 == 1) ? ReportTheme.TableAltRow : "#FFFFFF";
|
|
table.Cell().Background(bg).Padding(ReportTheme.CellPad)
|
|
.Text(label1).Bold().FontSize(ReportTheme.BodySize);
|
|
table.Cell().Background(bg).Padding(ReportTheme.CellPad)
|
|
.Text(value1).FontSize(ReportTheme.BodySize);
|
|
table.Cell().Background(bg).Padding(ReportTheme.CellPad)
|
|
.Text(label2).Bold().FontSize(ReportTheme.BodySize);
|
|
table.Cell().Background(bg).Padding(ReportTheme.CellPad)
|
|
.Text(value2).FontSize(ReportTheme.BodySize);
|
|
}
|
|
|
|
AddRow("Pump ID:", pump.Id, "Model:", pump.Model);
|
|
AddRow("Serial No.:", pump.SerialNumber, "Injector:", pump.Injector);
|
|
AddRow("Tube:", pump.Tube, "Valve:", pump.Valve);
|
|
AddRow("Tension:", pump.Tension, "Rotation:", pump.Rotation);
|
|
AddRow("Lock Angle:", $"{pump.LockAngle:F2}\u00B0",
|
|
"Measured:", $"{pump.LockAngleResult:F2}\u00B0");
|
|
AddRow("Chaveta:", pump.Chaveta, "Pre-Inj.:", pump.HasPreInjection ? "Yes" : "No");
|
|
});
|
|
}
|
|
|
|
// ── K-Line table ──────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Renders the K-Line ECU data table with alternating row stripes.</summary>
|
|
private static void ComposeKlineTable(IContainer container, PumpDefinition pump)
|
|
{
|
|
container.Table(table =>
|
|
{
|
|
table.ColumnsDefinition(cols =>
|
|
{
|
|
cols.RelativeColumn(1);
|
|
cols.RelativeColumn(2);
|
|
cols.RelativeColumn(1);
|
|
cols.RelativeColumn(2);
|
|
});
|
|
|
|
table.Header(header =>
|
|
{
|
|
header.Cell().ColumnSpan(4)
|
|
.Background(ReportTheme.HeaderNavy)
|
|
.Padding(5)
|
|
.Text("ECU DATA (K-Line)")
|
|
.FontColor(Colors.White).Bold()
|
|
.FontSize(ReportTheme.SectionHeaderSize);
|
|
});
|
|
|
|
int rowIndex = 0;
|
|
|
|
void AddKv(string key)
|
|
{
|
|
if (!pump.KlineInfo.TryGetValue(key, out var val)) return;
|
|
|
|
string bg = (rowIndex++ % 2 == 1) ? ReportTheme.TableAltRow : "#FFFFFF";
|
|
table.Cell().Background(bg).Padding(ReportTheme.CellPad)
|
|
.Text(key + ":").Bold().FontSize(ReportTheme.BodySize);
|
|
table.Cell().ColumnSpan(3).Background(bg).Padding(ReportTheme.CellPad)
|
|
.Text(val).FontSize(ReportTheme.BodySize);
|
|
}
|
|
|
|
AddKv(KlineKeys.ModelReference);
|
|
AddKv(KlineKeys.DataRecord);
|
|
AddKv(KlineKeys.SwVersion1);
|
|
AddKv(KlineKeys.SwVersion2);
|
|
AddKv(KlineKeys.PumpControl);
|
|
AddKv(KlineKeys.Dfi);
|
|
AddKv(KlineKeys.SerialNumber);
|
|
AddKv(KlineKeys.Errors);
|
|
});
|
|
}
|
|
|
|
// ── Verdict section ───────────────────────────────────────────────────────
|
|
|
|
/// <summary>Renders the overall test result badge with summary statistics.</summary>
|
|
private static void ComposeVerdictSection(IContainer container, PumpDefinition pump)
|
|
{
|
|
// Compute summary statistics.
|
|
var testsWithResults = pump.Tests.Where(t => t.HasResults()).ToList();
|
|
int totalTests = pump.Tests.Count;
|
|
int testedCount = testsWithResults.Count;
|
|
|
|
int totalPhases = 0;
|
|
int passedPhases = 0;
|
|
foreach (var test in testsWithResults)
|
|
{
|
|
foreach (var phase in test.Phases)
|
|
{
|
|
if (!phase.Enabled || phase.Receives == null) continue;
|
|
foreach (var tp in phase.Receives)
|
|
{
|
|
if (tp.Result == null) continue;
|
|
totalPhases++;
|
|
if (tp.Result.Passed) passedPhases++;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool allPassed = totalPhases > 0 && passedPhases == totalPhases;
|
|
|
|
container.Border(1).BorderColor(ReportTheme.DividerLine)
|
|
.Background(ReportTheme.TableAltRow)
|
|
.Padding(8)
|
|
.Row(row =>
|
|
{
|
|
// PASS/FAIL verdict badge (SVG).
|
|
row.ConstantItem(100).Height(50)
|
|
.Svg(ReportChartRenderer.RenderVerdictBadge(100, 50, allPassed))
|
|
.FitArea();
|
|
|
|
// Summary statistics.
|
|
row.RelativeItem().PaddingLeft(12).Column(col =>
|
|
{
|
|
col.Item().Text("OVERALL TEST RESULT")
|
|
.Bold().FontSize(ReportTheme.SectionHeaderSize)
|
|
.FontColor(ReportTheme.HeaderNavy);
|
|
|
|
col.Item().PaddingTop(4).Text(
|
|
$"Tests executed: {testedCount} of {totalTests}")
|
|
.FontSize(ReportTheme.BodySize);
|
|
|
|
col.Item().Text(
|
|
$"Parameters evaluated: {passedPhases} / {totalPhases} passed")
|
|
.FontSize(ReportTheme.BodySize);
|
|
|
|
// Per-test mini indicators.
|
|
col.Item().PaddingTop(4).Row(indicators =>
|
|
{
|
|
foreach (var test in pump.Tests)
|
|
{
|
|
bool hasResults = test.HasResults();
|
|
bool testPassed = hasResults && test.Phases
|
|
.Where(p => p.Enabled && p.Receives != null)
|
|
.SelectMany(p => p.Receives)
|
|
.Where(tp => tp.Result != null)
|
|
.All(tp => tp.Result!.Passed);
|
|
|
|
string indicator = !hasResults ? "--"
|
|
: testPassed ? "\u2713" : "\u2717";
|
|
string color = !hasResults ? ReportTheme.HeaderGrey
|
|
: testPassed ? ReportTheme.PassGreen : ReportTheme.FailRed;
|
|
|
|
indicators.AutoItem().PaddingRight(8).Text(t =>
|
|
{
|
|
t.Span($"{test.Name}: ").FontSize(ReportTheme.BodySize);
|
|
t.Span(indicator).Bold().FontSize(ReportTheme.BodySize)
|
|
.FontColor(color);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Test results section ──────────────────────────────────────────────────
|
|
|
|
/// <summary>Renders a single test: results table followed by measurement charts.</summary>
|
|
private static void ComposeTestSection(IContainer container, TestDefinition test)
|
|
{
|
|
container.Column(col =>
|
|
{
|
|
// Section header bar.
|
|
col.Item()
|
|
.Background(ReportTheme.HeaderNavy)
|
|
.Padding(5)
|
|
.Text($"TEST: {test.Name}")
|
|
.FontColor(Colors.White).Bold()
|
|
.FontSize(ReportTheme.SectionHeaderSize);
|
|
|
|
// Results table.
|
|
col.Item().Element(c => ComposeResultsTable(c, test));
|
|
|
|
// Charts — one per parameter that has sample data.
|
|
col.Item().PaddingTop(ReportTheme.SubsectionGap)
|
|
.Element(c => ComposeTestCharts(c, test));
|
|
});
|
|
}
|
|
|
|
/// <summary>Renders the pass/fail results table for one test.</summary>
|
|
private static void ComposeResultsTable(IContainer container, TestDefinition test)
|
|
{
|
|
container.Table(table =>
|
|
{
|
|
table.ColumnsDefinition(cols =>
|
|
{
|
|
cols.RelativeColumn(2); // Phase
|
|
cols.RelativeColumn(1); // Parameter
|
|
cols.RelativeColumn(1); // Target
|
|
cols.RelativeColumn(1); // Tolerance
|
|
cols.RelativeColumn(1); // Average
|
|
cols.RelativeColumn(1); // Result
|
|
});
|
|
|
|
table.Header(header =>
|
|
{
|
|
foreach (var h in new[] { "Phase", "Parameter", "Target", "Tolerance \u00B1", "Average", "Result" })
|
|
header.Cell()
|
|
.Background(ReportTheme.AccentBlue)
|
|
.Padding(ReportTheme.CellPad)
|
|
.Text(h).FontColor(Colors.White).Bold()
|
|
.FontSize(ReportTheme.CaptionSize + 1);
|
|
});
|
|
|
|
int rowIndex = 0;
|
|
|
|
foreach (var phase in test.Phases)
|
|
{
|
|
if (!phase.Enabled || phase.Receives == null) continue;
|
|
|
|
foreach (var tp in phase.Receives)
|
|
{
|
|
if (tp.Result == null) continue;
|
|
|
|
bool passed = tp.Result.Passed;
|
|
string resultText = passed ? "PASS" : "FAIL";
|
|
|
|
// Alternating base row colour, tinted by pass/fail.
|
|
string bgColor = passed
|
|
? (rowIndex % 2 == 0 ? ReportTheme.PassGreenLight : "#D7ECD9")
|
|
: (rowIndex % 2 == 0 ? ReportTheme.FailRedLight : "#FFDBDB");
|
|
|
|
table.Cell().Background(bgColor).Padding(ReportTheme.CellPad)
|
|
.Text(phase.Name).FontSize(ReportTheme.CaptionSize + 1);
|
|
table.Cell().Background(bgColor).Padding(ReportTheme.CellPad)
|
|
.Text(tp.Name).FontSize(ReportTheme.CaptionSize + 1);
|
|
table.Cell().Background(bgColor).Padding(ReportTheme.CellPad)
|
|
.Text(tp.Value.ToString("F2")).FontSize(ReportTheme.CaptionSize + 1);
|
|
table.Cell().Background(bgColor).Padding(ReportTheme.CellPad)
|
|
.Text(tp.Tolerance.ToString("F2")).FontSize(ReportTheme.CaptionSize + 1);
|
|
table.Cell().Background(bgColor).Padding(ReportTheme.CellPad)
|
|
.Text(tp.Result.Average.ToString("F2")).FontSize(ReportTheme.CaptionSize + 1);
|
|
table.Cell().Background(bgColor).Padding(ReportTheme.CellPad)
|
|
.Text(resultText).Bold()
|
|
.FontColor(passed ? ReportTheme.PassGreen : ReportTheme.FailRed)
|
|
.FontSize(ReportTheme.CaptionSize + 1);
|
|
|
|
rowIndex++;
|
|
}
|
|
|
|
// Show any alarm bits that fired during this phase.
|
|
if (phase.ErrorBits?.Count > 0)
|
|
{
|
|
table.Cell().ColumnSpan(6)
|
|
.Background(ReportTheme.WarningBg)
|
|
.Padding(ReportTheme.CellPad)
|
|
.Text($" \u26A0 Error bits: {string.Join(", ", phase.ErrorBits)}")
|
|
.FontSize(ReportTheme.CaptionSize + 1)
|
|
.FontColor(ReportTheme.WarningText);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>Renders measurement charts for each parameter that has sample data.</summary>
|
|
private static void ComposeTestCharts(IContainer container, TestDefinition test)
|
|
{
|
|
container.Column(col =>
|
|
{
|
|
bool anyChart = false;
|
|
|
|
foreach (var phase in test.Phases)
|
|
{
|
|
if (!phase.Enabled || phase.Receives == null) continue;
|
|
|
|
foreach (var tp in phase.Receives)
|
|
{
|
|
if (tp.Result == null || tp.Result.Samples.Count < 2) continue;
|
|
|
|
anyChart = true;
|
|
string label = $"{phase.Name} \u2014 {tp.Name}";
|
|
|
|
// The chart itself (SVG rendered inline).
|
|
string chartSvg = ReportChartRenderer.RenderMeasurementChart(
|
|
480, ReportTheme.ChartHeight,
|
|
tp.Result.Samples,
|
|
tp.Value,
|
|
tp.Tolerance,
|
|
tp.Result.Average,
|
|
tp.Result.Passed,
|
|
label);
|
|
|
|
col.Item().PaddingBottom(2)
|
|
.Svg(chartSvg)
|
|
.FitWidth();
|
|
|
|
// Chart caption.
|
|
col.Item().PaddingBottom(ReportTheme.SubsectionGap)
|
|
.Text($"Samples: {tp.Result.Samples.Count} | " +
|
|
$"Target: {tp.Value:F2} \u00B1 {tp.Tolerance:F2} | " +
|
|
$"Average: {tp.Result.Average:F2} | " +
|
|
$"Result: {(tp.Result.Passed ? "PASS" : "FAIL")}")
|
|
.FontSize(ReportTheme.CaptionSize)
|
|
.FontColor(ReportTheme.HeaderGrey);
|
|
}
|
|
}
|
|
|
|
if (!anyChart)
|
|
{
|
|
col.Item().PaddingTop(2).PaddingBottom(4)
|
|
.Text("No sample data available for graphical display.")
|
|
.FontSize(ReportTheme.CaptionSize).Italic()
|
|
.FontColor(ReportTheme.HeaderGrey);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Sanitises a file name by replacing invalid characters with underscores.</summary>
|
|
private static string SanitiseFileName(string name)
|
|
{
|
|
foreach (char c in Path.GetInvalidFileNameChars())
|
|
name = name.Replace(c, '_');
|
|
return name;
|
|
}
|
|
}
|
|
}
|