Files
XplorePlane/XP.ReportEngine/Services/CsvReportGenerator.cs
T
QI Mingxuan d72b583319 [XP.ReportEngine] 新增 TXT/CSV/Excel 三种报告输出格式支持;Excel (.xlsx) 报告生成器,基于 ClosedXML,支持结构化生成和模板填充两种模式; 新增 XplorePlane_ReportTemplate.xlsx:Excel 报告模板文件。
[XP.ReportEngine] 模型与配置扩展:ReportOutputFormat 枚举:新增 Excel=1, Csv=2, Txt=3(显式序号,向后兼容);新增多格式相关配置加载。
2026-07-01 11:31:41 +08:00

337 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using XP.Common.Localization.Interfaces;
using XP.Common.Logging.Interfaces;
using XP.ReportEngine.Configs;
using XP.ReportEngine.Models;
namespace XP.ReportEngine.Services
{
/// <summary>
/// CSV(逗号分隔值)报告生成器 | CSV (comma-separated values) report generator
/// 将 <see cref="ReportContext"/> 渲染为遵循 RFC 4180 的结构化 CSV:采用「并集表头 + 分组标识列」
/// 策略,前置 ProcessorType / Classification 两列标识来源分组,其后为所有分组 Data 键与 TableRows
/// 列名的有序并集,末列 ImageRef 以可读文本承载图像路径引用或占位标识(排除任何图像二进制)。
/// Renders a <see cref="ReportContext"/> into RFC 4180-compliant structured CSV using a
/// "union header + group identifier columns" strategy: prefixed ProcessorType / Classification
/// columns identify the source group, followed by the ordered union of all groups' Data keys and
/// TableRows column names, with a trailing ImageRef column carrying readable image path references
/// or placeholders (excluding any image binary).
/// </summary>
/// <remarks>
/// 继承 <see cref="TextReportGeneratorBase"/> 复用 null 校验、日志、编码解析、原子写入与内存流
/// 返回等公共能力(需求 4、6、9)。CSV 仅要求 context 非空(需求 4.10),故不要求 Metadata 非空。
/// Inherits <see cref="TextReportGeneratorBase"/> to reuse common capabilities: null validation,
/// logging, encoding resolution, atomic write and memory-stream return (Requirements 4, 6, 9).
/// CSV only requires a non-null context (Requirement 4.10), so a non-null Metadata is not required.
/// </remarks>
public sealed class CsvReportGenerator : TextReportGeneratorBase
{
/// <summary>默认字段分隔符:半角逗号(需求 4.6| Default field separator: comma (Requirement 4.6)</summary>
private const char FieldSeparator = ',';
/// <summary>CSV 行分隔符:CRLFRFC 4180| CSV line separator: CRLF (RFC 4180)</summary>
private const string LineSeparator = "\r\n";
/// <summary>多图像引用连接符 | Separator for joining multiple image references</summary>
private const string ImageRefJoin = "; ";
/// <summary>
/// 构造函数 | Constructor
/// </summary>
/// <param name="logger">日志服务 | Logger service</param>
/// <param name="localization">多语言本地化服务 | Localization service</param>
/// <param name="config">报告引擎配置 | Report engine configuration</param>
public CsvReportGenerator(
ILoggerService logger,
ILocalizationService localization,
ReportConfig config)
: base(logger?.ForModule<CsvReportGenerator>(), localization, config)
{
}
/// <summary>格式名称 | Format name</summary>
protected override string FormatName => "CSV";
/// <summary>
/// CSV 仅要求 context 非空(需求 4.10),不强制 Metadata 非空
/// CSV only requires a non-null context (Requirement 4.10), not a non-null Metadata
/// </summary>
protected override bool RequireNonNullMetadata => false;
/// <summary>
/// 构建 CSV 报告内容 | Build the CSV report content
/// </summary>
/// <param name="context">报告上下文(已通过 null 校验)| Report context (already passed null validation)</param>
/// <returns>CSV 文本内容 | CSV text content</returns>
protected override Task<string> BuildContentAsync(ReportContext context)
{
// 一次性获取当前语言的固定标签,保证单次生成语言一致(需求 10.2)
// Obtain fixed labels for the current language once to keep a single generation language-consistent (Requirement 10.2)
var processorTypeLabel = Localization.GetString("Field_ProcessorType");
var classificationLabel = Localization.GetString("Field_Classification");
var imageRefLabel = Localization.GetString("Image_Reference");
var groups = context.ResultGroups;
// 计算 Data 键与 TableRows 列名的有序并集作为数据列(保留首次出现顺序,需求 4.2)
// Compute the ordered union of Data keys and TableRows column names as data columns
// (preserve first-seen order, Requirement 4.2)
var dataColumns = BuildDataColumnUnion(groups);
// 构造完整表头:ProcessorType | Classification | 数据列并集 | ImageRef
// Build full header: ProcessorType | Classification | union of data columns | ImageRef
var header = new List<string>(dataColumns.Count + 3)
{
processorTypeLabel,
classificationLabel
};
header.AddRange(dataColumns);
header.Add(imageRefLabel);
var sb = new StringBuilder();
// 首行输出表头(需求 4.2| Output the header row (Requirement 4.2)
AppendRecord(sb, header);
// 空结果:仅输出表头行(需求 4.9| Empty result: output header row only (Requirement 4.9)
if (groups == null || groups.Count == 0)
{
return Task.FromResult(sb.ToString());
}
// 图像引用文本(排除二进制,需求 6.2/6.4/6.5| Image reference texts (binaries excluded, Requirements 6.2/6.4/6.5)
var imageReferences = ExtractImageReferences(context).ToList();
var imageRefText = imageReferences.Count > 0
? string.Join(ImageRefJoin, imageReferences)
: string.Empty;
var imageRefEmitted = false;
foreach (var group in groups)
{
if (group == null)
{
continue;
}
var processorType = group.ProcessorType ?? string.Empty;
var classification = group.Classification ?? string.Empty;
var hasTable = group.TableRows != null && group.TableRows.Count > 0;
if (hasTable)
{
// 含 TableRows:每行表格数据各输出为一条记录(需求 4.4| With TableRows: each table row is a record (Requirement 4.4)
foreach (var tableRow in group.TableRows)
{
var record = BuildRecord(
processorType, classification, group.Data, tableRow, dataColumns);
AppendImageRefColumn(record, imageRefText, ref imageRefEmitted);
AppendRecord(sb, record);
}
}
else
{
// 无表格分组:Data 汇总为一行,保证每个分组至少一行(需求 4.3)
// Group without a table: Data summarized into one row, ensuring at least one row per group (Requirement 4.3)
var record = BuildRecord(
processorType, classification, group.Data, null, dataColumns);
AppendImageRefColumn(record, imageRefText, ref imageRefEmitted);
AppendRecord(sb, record);
}
}
return Task.FromResult(sb.ToString());
}
/// <summary>
/// 构造 Data 键与 TableRows 列名的有序并集 | Build the ordered union of Data keys and TableRows column names
/// 保留首次出现顺序,作为 CSV 数据列(不含标识列与 ImageRef 列)。
/// Preserves first-seen order as the CSV data columns (excluding identifier columns and the ImageRef column).
/// </summary>
private static List<string> BuildDataColumnUnion(List<InspectionResultGroup> groups)
{
var columns = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
if (groups == null)
{
return columns;
}
foreach (var group in groups)
{
if (group == null)
{
continue;
}
// Data 键 | Data keys
if (group.Data != null)
{
foreach (var key in group.Data.Keys)
{
if (key != null && seen.Add(key))
{
columns.Add(key);
}
}
}
// TableRows 列名 | TableRows column names
if (group.TableRows != null)
{
foreach (var row in group.TableRows)
{
if (row == null)
{
continue;
}
foreach (var key in row.Keys)
{
if (key != null && seen.Add(key))
{
columns.Add(key);
}
}
}
}
}
return columns;
}
/// <summary>
/// 构造一条数据记录(不含 ImageRef 列)| Build a single data record (excluding the ImageRef column)
/// 前置 ProcessorType / Classification 标识列,随后按数据列并集填充值:优先取 TableRows 行的值,
/// 否则回退取分组 Data 的值,缺失列留空(需求 4.2、4.3、4.4)。
/// Prefixes ProcessorType / Classification identifier columns, then fills values by the union of
/// data columns: prefer the TableRows row value, otherwise fall back to the group's Data value,
/// leaving missing columns empty (Requirements 4.2, 4.3, 4.4).
/// </summary>
private static List<string> BuildRecord(
string processorType,
string classification,
Dictionary<string, object> data,
Dictionary<string, object> tableRow,
List<string> dataColumns)
{
var record = new List<string>(dataColumns.Count + 3)
{
processorType,
classification
};
foreach (var column in dataColumns)
{
// 优先使用表格行的值 | Prefer the table row value
if (tableRow != null && tableRow.TryGetValue(column, out var tableValue))
{
record.Add(FormatValue(tableValue));
}
// 否则回退分组 Data 的值 | Otherwise fall back to the group's Data value
else if (data != null && data.TryGetValue(column, out var dataValue))
{
record.Add(FormatValue(dataValue));
}
// 缺失列留空 | Missing column left empty
else
{
record.Add(string.Empty);
}
}
return record;
}
/// <summary>
/// 向记录追加 ImageRef 列值 | Append the ImageRef column value to a record
/// 图像引用文本仅在首条数据记录承载一次,避免在大量表格行中重复(需求 6.2/6.4/6.5);
/// 其余记录以空值占位以保证列数与表头一致(需求 4.2)。
/// The image reference text is carried once on the first data record to avoid repetition across
/// many table rows (Requirements 6.2/6.4/6.5); other records use an empty placeholder to keep the
/// column count consistent with the header (Requirement 4.2).
/// </summary>
private static void AppendImageRefColumn(List<string> record, string imageRefText, ref bool imageRefEmitted)
{
if (!imageRefEmitted && !string.IsNullOrEmpty(imageRefText))
{
record.Add(imageRefText);
imageRefEmitted = true;
}
else
{
record.Add(string.Empty);
}
}
/// <summary>
/// 将一条记录的各字段转义后以分隔符连接并追加为一行 | Escape each field of a record, join with the separator, and append as one line
/// </summary>
private static void AppendRecord(StringBuilder sb, List<string> fields)
{
for (var i = 0; i < fields.Count; i++)
{
if (i > 0)
{
sb.Append(FieldSeparator);
}
sb.Append(EscapeCsvField(fields[i]));
}
sb.Append(LineSeparator);
}
/// <summary>
/// RFC 4180 字段转义 | RFC 4180 field escaping
/// 当字段值包含分隔符(<c>,</c>)、双引号(<c>"</c>)或换行符(CR/LF)时,使用双引号包裹整个字段,
/// 并将字段内的双引号转义为两个连续双引号(<c>""</c>)(需求 4.5、4.6)。
/// When a field value contains the separator (<c>,</c>), a double quote (<c>"</c>), or a line break
/// (CR/LF), the whole field is wrapped in double quotes and inner double quotes are escaped as two
/// consecutive double quotes (<c>""</c>) (Requirements 4.5, 4.6).
/// </summary>
/// <param name="field">原始字段值 | Raw field value</param>
/// <returns>转义后的字段值 | Escaped field value</returns>
internal static string EscapeCsvField(string field)
{
if (string.IsNullOrEmpty(field))
{
return string.Empty;
}
var mustQuote = field.IndexOfAny(new[] { FieldSeparator, '"', '\r', '\n' }) >= 0;
if (!mustQuote)
{
return field;
}
return "\"" + field.Replace("\"", "\"\"") + "\"";
}
/// <summary>
/// 格式化值为字符串,null 转空串 | Format a value to string, converting null to empty
/// 使用不变区域性以保证数值/日期的可预测格式(需求 5.5 中文由编码保证,此处仅数值格式)。
/// Uses the invariant culture for predictable numeric/date formatting.
/// </summary>
private static string FormatValue(object value)
{
if (value == null)
{
return string.Empty;
}
if (value is IFormattable formattable)
{
return formattable.ToString(null, CultureInfo.InvariantCulture);
}
return value.ToString();
}
}
}