d72b583319
[XP.ReportEngine] 模型与配置扩展:ReportOutputFormat 枚举:新增 Excel=1, Csv=2, Txt=3(显式序号,向后兼容);新增多格式相关配置加载。
337 lines
15 KiB
C#
337 lines
15 KiB
C#
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 行分隔符:CRLF(RFC 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();
|
||
}
|
||
}
|
||
}
|