质量模块: - 11 张工艺执行书表:封面、试产、选剥茧(含混茧明细)、试缫与大样、煮茧(含分车间配温、副煮车间)、自动缫(含大样)、后缫 - 10 张样茧试缫表:原料小样/综合样主表 + 选茧、解舒、茧幅、切剖、清洁洁净、茧丝百回纤度 - 服务与接口、主控指针回写(列表 √ 标记)、封面编号自增、报表数据包 - 前端 8 张报表打印版式、7 类工艺单据与 6 类调查录入弹窗、字段驱动表单/表格组件 - 菜单种子新增「质量」目录,样茧子表增加 YjssType 判别列,避免原料小样与综合样自增 Id 重号串数据 报表中心: - 报表模板管理、锐浪 grhtml5 加载与预览、全屏设计面板、数据源 Provider(落丝记录、职工花名册) 其他:同步工作进度 1-11 文档、开发数据库快照、.gitignore 忽略运行日志目录
653 lines
27 KiB
C#
653 lines
27 KiB
C#
using System.Collections.Concurrent;
|
||
using System.Data;
|
||
using System.Data.Common;
|
||
using System.Reflection;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using System.Text.RegularExpressions;
|
||
using FreeSql;
|
||
using F9WebAiTest.Application.Common;
|
||
using F9WebAiTest.Application.Common.Exceptions;
|
||
using F9WebAiTest.Application.Dtos.SYS.Reports;
|
||
using F9WebAiTest.Application.Interfaces.Reports;
|
||
using F9WebAiTest.Application.Interfaces.SnowflakeID;
|
||
using F9WebAiTest.Domain.Common;
|
||
using F9WebAiTest.Domain.Entities.SYS.RBAC;
|
||
using F9WebAiTest.Domain.Entities.SYS.Reports;
|
||
using F9WebAiTest.Domain.Enums.SYS.RBAC;
|
||
using MediatR;
|
||
using System.Data.SQLite;
|
||
|
||
namespace F9WebAiTest.Application.Services.Reports;
|
||
|
||
public class ReportService : IReportService
|
||
{
|
||
private readonly IRepository<Report> _reportRepo;
|
||
private readonly IRepository<ReportFile> _reportFileRepo;
|
||
private readonly IRepository<Permission> _permissionRepo;
|
||
private readonly IRepository<RolePermission> _rolePermissionRepo;
|
||
private readonly IFreeSql _fsql;
|
||
private readonly ISnowflakeIdGenerator _snowflake;
|
||
private readonly IMediator _mediator;
|
||
private readonly Dictionary<string, IReportDataProvider> _dataProviders;
|
||
|
||
public ReportService(
|
||
IRepository<Report> reportRepo,
|
||
IRepository<ReportFile> reportFileRepo,
|
||
IRepository<Permission> permissionRepo,
|
||
IRepository<RolePermission> rolePermissionRepo,
|
||
IFreeSql fsql,
|
||
ISnowflakeIdGenerator snowflake,
|
||
IMediator mediator,
|
||
IEnumerable<IReportDataProvider> dataProviders)
|
||
{
|
||
_reportRepo = reportRepo;
|
||
_reportFileRepo = reportFileRepo;
|
||
_permissionRepo = permissionRepo;
|
||
_rolePermissionRepo = rolePermissionRepo;
|
||
_fsql = fsql;
|
||
_snowflake = snowflake;
|
||
_mediator = mediator;
|
||
_dataProviders = dataProviders.ToDictionary(p => p.ReportName, StringComparer.OrdinalIgnoreCase);
|
||
}
|
||
|
||
// ===== 报表管理 =====
|
||
|
||
public async Task<PageResult<ReportListDto>> GetPagedAsync(string like, string category, bool? isActive, int pageIndex, int pageSize)
|
||
{
|
||
var query = _fsql.Select<Report>().Where(r => !r.IsDeleted);
|
||
if (!string.IsNullOrWhiteSpace(like))
|
||
query = query.Where(r => r.Name.Contains(like) || r.Code.Contains(like));
|
||
if (!string.IsNullOrWhiteSpace(category))
|
||
query = query.Where(r => r.Category == category);
|
||
if (isActive.HasValue)
|
||
query = query.Where(r => r.IsActive == isActive.Value);
|
||
|
||
var total = await query.CountAsync();
|
||
var rows = await query
|
||
.OrderBy(r => r.SortOrder)
|
||
.OrderByDescending(r => r.CreatedAt)
|
||
.Page(pageIndex, pageSize)
|
||
.ToListAsync();
|
||
|
||
var items = rows.Select(r => new ReportListDto(
|
||
r.Id, r.Code, r.Name, r.Category, r.Description, r.SortOrder, r.IsActive,
|
||
r.ReportFileId, HasTemplate(r), r.CreatedAt, r.UpdatedAt)).ToList();
|
||
return new PageResult<ReportListDto> { Items = items, Total = total, PageIndex = pageIndex, PageSize = pageSize };
|
||
}
|
||
|
||
public async Task<ReportDetailDto> GetByIdAsync(long id)
|
||
{
|
||
var r = await _reportRepo.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted)
|
||
?? throw new F9NotFoundException("报表不存在");
|
||
return ToDetail(r);
|
||
}
|
||
|
||
public async Task<long> CreateAsync(ReportCreateDto dto)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(dto.Code)) throw new F9OperationException("报表编码不能为空");
|
||
if (string.IsNullOrWhiteSpace(dto.Name)) throw new F9OperationException("报表名称不能为空");
|
||
ValidateCode(dto.Code);
|
||
ValidateConfig(dto.Config);
|
||
// 有 DsName 时允许不填 SQL;否则必须为合法 SELECT
|
||
if (GetConfigDsName(dto.Config) == null) ValidateSql(dto.DataSql);
|
||
|
||
if (await _reportRepo.AnyAsync(r => r.Code == dto.Code && !r.IsDeleted))
|
||
throw new F9OperationException($"报表编码已存在:{dto.Code}");
|
||
|
||
var entity = new Report(_snowflake.CreateId(), dto.Code.Trim(), dto.Name.Trim(),
|
||
dto.Category ?? "通用", dto.Description ?? "", dto.DataSql ?? "",
|
||
dto.DataParams ?? "[]", dto.SortOrder, dto.Config ?? "");
|
||
|
||
// 模板与配置一起保存(模板实例写入 ReportFile,Report 持有 ReportFileId)
|
||
if (!string.IsNullOrWhiteSpace(dto.TemplateJson))
|
||
await SaveTemplateAsync(entity, dto.TemplateJson);
|
||
|
||
await _reportRepo.AddAsync(entity);
|
||
await _reportRepo.SaveChangesAsync();
|
||
|
||
// 同步创建权限单元:report:{code}:visble
|
||
var perm = new Permission(_snowflake.CreateId(), Permission_Type_Enum.Visble,
|
||
Resource_Type_Enum.报表, entity.Code, entity.Id);
|
||
perm.SetCode($"report:{entity.Code}:visble");
|
||
await _permissionRepo.AddAsync(perm);
|
||
await _permissionRepo.SaveChangesAsync();
|
||
|
||
return entity.Id;
|
||
}
|
||
|
||
public async Task UpdateAsync(long id, ReportUpdateDto dto)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(dto.Code)) throw new F9OperationException("报表编码不能为空");
|
||
if (string.IsNullOrWhiteSpace(dto.Name)) throw new F9OperationException("报表名称不能为空");
|
||
ValidateCode(dto.Code);
|
||
ValidateConfig(dto.Config);
|
||
if (GetConfigDsName(dto.Config) == null) ValidateSql(dto.DataSql);
|
||
|
||
var entity = await _reportRepo.FirstOrDefaultAsync(r => r.Id == id && !r.IsDeleted)
|
||
?? throw new F9NotFoundException("报表不存在");
|
||
|
||
if (await _reportRepo.AnyAsync(r => r.Code == dto.Code && r.Id != id && !r.IsDeleted))
|
||
throw new F9OperationException($"报表编码已存在:{dto.Code}");
|
||
|
||
var oldCode = entity.Code;
|
||
entity.UpdateBasic(dto.Code.Trim(), dto.Name.Trim(), dto.Category ?? "通用",
|
||
dto.Description ?? "", dto.DataSql ?? "", dto.DataParams ?? "[]", dto.SortOrder, dto.Config ?? "");
|
||
entity.Activate();
|
||
|
||
// 模板与配置一起保存;不传模板时保留原模板
|
||
if (!string.IsNullOrWhiteSpace(dto.TemplateJson))
|
||
await SaveTemplateAsync(entity, dto.TemplateJson);
|
||
|
||
await _reportRepo.UpdateAsync(entity);
|
||
await _reportRepo.SaveChangesAsync();
|
||
|
||
// 同步更新权限 Code(编码变更时)
|
||
if (!string.Equals(oldCode, entity.Code, StringComparison.Ordinal))
|
||
{
|
||
var perm = await _permissionRepo.FirstOrDefaultAsync(p =>
|
||
p.ResourceType == Resource_Type_Enum.报表 && p.ResourceId == entity.Id && !p.IsDeleted);
|
||
if (perm != null)
|
||
{
|
||
perm.SetCode($"report:{entity.Code}:visble");
|
||
await _permissionRepo.UpdateAsync(perm);
|
||
await _permissionRepo.SaveChangesAsync();
|
||
}
|
||
}
|
||
}
|
||
|
||
public async Task DeleteAsync(long id)
|
||
{
|
||
var entity = await _reportRepo.FirstOrDefaultAsync(r => r.Id == id && !r.IsDeleted)
|
||
?? throw new F9NotFoundException("报表不存在");
|
||
await _reportRepo.DeleteAsync(entity);
|
||
await _reportRepo.SaveChangesAsync();
|
||
|
||
// 级联删除模板文件实例
|
||
var files = await _reportFileRepo.FindAsync(f => f.ReportId == id && !f.IsDeleted);
|
||
if (files.Count > 0)
|
||
{
|
||
await _reportFileRepo.DeleteRangeAsync(files);
|
||
await _reportFileRepo.SaveChangesAsync();
|
||
}
|
||
|
||
// 同步删除报表关联的权限单元与角色授权
|
||
var perms = await _permissionRepo.FindAsync(p => p.ResourceType == Resource_Type_Enum.报表 && p.ResourceId == id && !p.IsDeleted);
|
||
if (perms.Count > 0)
|
||
{
|
||
var permIds = perms.Select(p => p.Id).ToList();
|
||
await _rolePermissionRepo.HardDeleteRangeAsync(
|
||
(await _rolePermissionRepo.FindAsync(rp => permIds.Contains(rp.PermissionId) && !rp.IsDeleted)));
|
||
await _permissionRepo.HardDeleteRangeAsync(perms);
|
||
await _permissionRepo.SaveChangesAsync();
|
||
}
|
||
}
|
||
|
||
public async Task UploadTemplateAsync(long id, byte[] data, string fileName)
|
||
{
|
||
if (data == null || data.Length == 0) throw new F9OperationException("模板内容为空");
|
||
var entity = await _reportRepo.FirstOrDefaultAsync(r => r.Id == id && !r.IsDeleted)
|
||
?? throw new F9NotFoundException("报表不存在");
|
||
|
||
// .grf 可能是 GR6 时代的 UTF-8 JSON,也可能是 GR7 的 "_WR" 压缩文本,按 BOM 自动识别编码
|
||
var text = DecodeTemplateBytes(data);
|
||
await SaveTemplateAsync(entity, text);
|
||
await _reportRepo.UpdateAsync(entity);
|
||
await _reportRepo.SaveChangesAsync();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 直接保存模板文本(前端「模板源码」在线编辑后保存)。
|
||
/// JSON 格式模板做一次语法校验,避免保存后在设计器 / 渲染端打不开;
|
||
/// "_WR" 压缩模板无法在服务端校验,原样保存。
|
||
/// </summary>
|
||
public async Task<int> SaveTemplateContentAsync(long id, string content)
|
||
{
|
||
var entity = await _reportRepo.FirstOrDefaultAsync(r => r.Id == id && !r.IsDeleted)
|
||
?? throw new F9NotFoundException("报表不存在");
|
||
|
||
var text = NormalizeTemplateContent(content);
|
||
var head = text.TrimStart();
|
||
if (head.StartsWith('{') || head.StartsWith('['))
|
||
{
|
||
try { using var _ = JsonDocument.Parse(text); }
|
||
catch (JsonException ex) { throw new F9OperationException($"模板 JSON 不合法:{ex.Message}"); }
|
||
}
|
||
|
||
await SaveTemplateAsync(entity, text);
|
||
await _reportRepo.UpdateAsync(entity);
|
||
await _reportRepo.SaveChangesAsync();
|
||
return text.Length;
|
||
}
|
||
|
||
public async Task<string?> GetTemplateContentAsync(Report report)
|
||
{
|
||
var file = await FindTemplateFileAsync(report);
|
||
return file?.DesignContent;
|
||
}
|
||
|
||
/// <summary>查找报表的模板文件实例(优先 ReportFileId,兼容按 ReportId 查找)</summary>
|
||
private async Task<ReportFile?> FindTemplateFileAsync(Report report)
|
||
{
|
||
if (report.ReportFileId != 0)
|
||
{
|
||
var byId = await _reportFileRepo.FirstOrDefaultAsync(f => f.Id == report.ReportFileId && !f.IsDeleted);
|
||
if (byId != null) return byId;
|
||
}
|
||
return await _reportFileRepo.FirstOrDefaultAsync(f => f.ReportId == report.Id && !f.IsDeleted);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 保存模板:新建或更新 ReportFile.DesignContent,并把 Report.ReportFileId 绑定到该实例。
|
||
/// 模板与报表配置一起保存,前端无需再单独上传 .grf 文件。
|
||
/// 注意:只修改内存中的 report,不落库(由调用方统一保存)。
|
||
/// </summary>
|
||
private async Task SaveTemplateAsync(Report report, string templateContent)
|
||
{
|
||
var content = NormalizeTemplateContent(templateContent);
|
||
var file = await FindTemplateFileAsync(report);
|
||
if (file != null)
|
||
{
|
||
file.UpdateContent(content);
|
||
await _reportFileRepo.UpdateAsync(file);
|
||
await _reportFileRepo.SaveChangesAsync();
|
||
}
|
||
else
|
||
{
|
||
file = new ReportFile(_snowflake.CreateId(), report.Id, content);
|
||
await _reportFileRepo.AddAsync(file);
|
||
await _reportFileRepo.SaveChangesAsync();
|
||
}
|
||
|
||
if (report.ReportFileId != file.Id) report.BindReportFile(file.Id);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 规范化模板内容:仅去 BOM / 去掉首尾空白,不做格式校验。
|
||
/// .grf 有两种格式:GR6 的 UTF-8 JSON 文本,以及 GR7 的 "_WR" 前缀压缩文本;
|
||
/// 两者都由 Grid++Report 的 HTML5 渲染库自行识别(内部 decodeWR),服务端原样保存即可。
|
||
/// </summary>
|
||
private static string NormalizeTemplateContent(string? content)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(content)) throw new F9OperationException("报表模板内容为空");
|
||
|
||
var text = content.Trim();
|
||
if (text.Length > 0 && text[0] == '\uFEFF') text = text.Substring(1);
|
||
if (string.IsNullOrWhiteSpace(text)) throw new F9OperationException("报表模板内容为空");
|
||
return text;
|
||
}
|
||
|
||
/// <summary>按 BOM / 内容特征解码 .grf 字节:UTF-8(BOM) / UTF-16LE(BOM) / UTF-16BE(BOM) / 无 BOM 按 UTF-8</summary>
|
||
private static string DecodeTemplateBytes(byte[] data)
|
||
{
|
||
if (data.Length >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF)
|
||
return Encoding.UTF8.GetString(data, 3, data.Length - 3);
|
||
if (data.Length >= 2 && data[0] == 0xFF && data[1] == 0xFE)
|
||
return Encoding.Unicode.GetString(data, 2, data.Length - 2);
|
||
if (data.Length >= 2 && data[0] == 0xFE && data[1] == 0xFF)
|
||
return Encoding.BigEndianUnicode.GetString(data, 2, data.Length - 2);
|
||
|
||
// 无 BOM:先按 UTF-8 严格解码,失败则按 UTF-16LE 尝试
|
||
try
|
||
{
|
||
var strict = new UTF8Encoding(false, true);
|
||
return strict.GetString(data);
|
||
}
|
||
catch (DecoderFallbackException)
|
||
{
|
||
try { return Encoding.Unicode.GetString(data); }
|
||
catch { return Encoding.UTF8.GetString(data); }
|
||
}
|
||
}
|
||
|
||
// ===== 用户可见报表 =====
|
||
|
||
public async Task<List<ReportViewDto>> GetVisibleReportsAsync(long userId, bool isAdmin, IReadOnlyCollection<string> permissions)
|
||
{
|
||
var all = await _reportRepo.FindAsync(r => r.IsActive && !r.IsDeleted);
|
||
IEnumerable<Report> visible;
|
||
if (isAdmin)
|
||
{
|
||
visible = all;
|
||
}
|
||
else
|
||
{
|
||
visible = all.Where(r => permissions.Contains($"report:{r.Code}:visble"));
|
||
}
|
||
return visible
|
||
.OrderBy(r => r.SortOrder).ThenBy(r => r.Name)
|
||
.Select(r => new ReportViewDto(r.Id, r.Code, r.Name, r.Category, r.Description, r.SortOrder, HasTemplate(r)))
|
||
.ToList();
|
||
|
||
}
|
||
|
||
public async Task EnsureReportAccessAsync(long userId, bool isAdmin, IReadOnlyCollection<string> permissions, long reportId)
|
||
{
|
||
if (isAdmin) return;
|
||
var report = await _reportRepo.FirstOrDefaultAsync(r => r.Id == reportId && !r.IsDeleted)
|
||
?? throw new F9NotFoundException("报表不存在", 404);
|
||
if (!report.IsActive) throw new F9NotFoundException("报表已停用", 404);
|
||
if (!permissions.Contains($"report:{report.Code}:visble"))
|
||
throw new F9OperationException("无权访问该报表");
|
||
}
|
||
|
||
// ===== 数据查询 =====
|
||
|
||
public async Task<Dictionary<string, object?>> ExecuteDataAsync(Report report, IReadOnlyDictionary<string, object?> parameters)
|
||
{
|
||
// 1. 优先按 Config.DsName 解析到数据提供者(F9 IReportService 模式)
|
||
var dsName = GetConfigDsName(report.Config);
|
||
if (!string.IsNullOrWhiteSpace(dsName))
|
||
{
|
||
if (!_dataProviders.TryGetValue(dsName, out var provider))
|
||
throw new F9OperationException($"未找到数据源查询实现:{dsName}");
|
||
|
||
var pairs = new Dictionary<string, object>(StringComparer.Ordinal);
|
||
foreach (var kv in parameters) pairs[kv.Key] = kv.Value!;
|
||
|
||
var dataSource = await provider.GetData(pairs, _mediator);
|
||
return ToGridReportJson(dataSource);
|
||
}
|
||
|
||
// 2. 回退:DataSql + @参数名(仅绑定 SQL 中实际出现的参数,防注入)
|
||
if (string.IsNullOrWhiteSpace(report.DataSql))
|
||
throw new F9OperationException("报表未配置数据源(Config.DsName 或 DataSql)");
|
||
|
||
try
|
||
{
|
||
var sqlTokens = Regex.Matches(report.DataSql, @"@([A-Za-z_][A-Za-z0-9_]*)")
|
||
.Select(m => m.Groups[1].Value).Distinct().ToHashSet(StringComparer.Ordinal);
|
||
|
||
var dbParams = new List<DbParameter>();
|
||
foreach (var kv in parameters)
|
||
{
|
||
if (kv.Key.Length > 0 && sqlTokens.Contains(kv.Key) && kv.Value != null)
|
||
dbParams.Add(new SQLiteParameter("@" + kv.Key, kv.Value));
|
||
}
|
||
|
||
var dt = dbParams.Count > 0
|
||
? _fsql.Ado.ExecuteDataTable(report.DataSql, dbParams.ToArray())
|
||
: _fsql.Ado.ExecuteDataTable(report.DataSql);
|
||
|
||
var recordset = new List<Dictionary<string, object?>>();
|
||
foreach (DataRow row in dt.Rows)
|
||
{
|
||
var d = new Dictionary<string, object?>(dt.Columns.Count, StringComparer.Ordinal);
|
||
foreach (DataColumn col in dt.Columns)
|
||
{
|
||
var v = row[col];
|
||
d[col.ColumnName] = v == DBNull.Value ? null : v;
|
||
}
|
||
recordset.Add(d);
|
||
}
|
||
return new Dictionary<string, object?> { ["recordset"] = recordset };
|
||
}
|
||
catch (F9OperationException) { throw; }
|
||
catch (Exception ex)
|
||
{
|
||
throw new F9OperationException("报表数据查询失败:" + ex.Message);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将 F9 的 ReportDto_DataSource 转换为 Grid++Report 约定的 JSON 数据:
|
||
/// 单数据集 → { "recordset": [ {...} ] }(兼容无名记录集的模板)
|
||
/// 多数据集 → { "main": [ {...} ], "detail": [ {...} ] }(顶层键为数据集名)
|
||
/// 报表参数 → 附加 { "parameter": [ { 账期: "...", 车间: "..." } ] }(模板以 Parameter 方式引用)
|
||
/// </summary>
|
||
private static Dictionary<string, object?> ToGridReportJson(ReportDto_DataSource dataSource)
|
||
{
|
||
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||
|
||
var datasets = dataSource.Report_Data
|
||
.Where(kv => (kv.Value.Field_Data?.Count ?? 0) > 0)
|
||
.ToList();
|
||
|
||
if (datasets.Count <= 1)
|
||
{
|
||
// 0 行时也必须输出空 recordset:否则渲染库找不到主记录集,
|
||
// 报表参数(Parameter)会被错位渲染到明细区(实测 0 行报表参数值错乱)
|
||
result["recordset"] = datasets.Count == 0
|
||
? new List<object>()
|
||
: datasets[0].Value.Field_Data.Select(ToNameValueDict).Cast<object>().ToList();
|
||
}
|
||
else
|
||
{
|
||
foreach (var kv in datasets)
|
||
result[kv.Key] = kv.Value.Field_Data.Select(ToNameValueDict).Cast<object>().ToList();
|
||
}
|
||
|
||
// 报表参数:以 Parameter 数据集输出(键名须与 .grf 模板的 Parameter 节点一致,HTML5 渲染器取数组第一项)
|
||
var paramData = dataSource.Report_Data.Values
|
||
.FirstOrDefault(d => d.Parameter_Data != null)?.Parameter_Data;
|
||
if (paramData != null)
|
||
result["Parameter"] = new List<object> { ToNameValueDict(paramData) };
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 对象 → 字段名/值的字典。
|
||
/// 用字典承载是为了保证属性名原样输出(不受 ASP.NET Core 默认 camelCase 序列化策略影响),
|
||
/// 从而与 .grf 模板 DetailGrid.Recordset.Field 定义的字段名严格一致。
|
||
/// </summary>
|
||
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _rowPropsCache = new();
|
||
|
||
private static Dictionary<string, object?> ToNameValueDict(object row)
|
||
{
|
||
if (row is IDictionary<string, object> exists) return exists.ToDictionary(k => k.Key, v => (object?)v.Value);
|
||
|
||
var props = _rowPropsCache.GetOrAdd(row.GetType(), t => t.GetProperties());
|
||
var dict = new Dictionary<string, object?>(props.Length, StringComparer.Ordinal);
|
||
foreach (var p in props)
|
||
{
|
||
if (!p.CanRead) continue;
|
||
dict[p.Name] = p.GetValue(row);
|
||
}
|
||
return dict!;
|
||
}
|
||
|
||
private static string? GetConfigDsName(string configJson)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(configJson)) return null;
|
||
try
|
||
{
|
||
using var doc = JsonDocument.Parse(configJson);
|
||
if (doc.RootElement.TryGetProperty("DsName", out var n) && n.ValueKind == JsonValueKind.String)
|
||
return n.GetString();
|
||
}
|
||
catch { /* 配置 JSON 非法时忽略 */ }
|
||
return null;
|
||
}
|
||
|
||
public async Task<List<ReportListDto>> GetAllActiveAsync()
|
||
{
|
||
var list = await _reportRepo.FindAsync(r => r.IsActive && !r.IsDeleted);
|
||
return list
|
||
.OrderBy(r => r.SortOrder).ThenBy(r => r.Name)
|
||
.Select(r => new ReportListDto(
|
||
r.Id, r.Code, r.Name, r.Category, r.Description, r.SortOrder, r.IsActive,
|
||
r.ReportFileId, HasTemplate(r), r.CreatedAt, r.UpdatedAt))
|
||
.ToList();
|
||
}
|
||
|
||
// ===== 数据源清单 / 结构探测(新增报表时按查询结果自动识别明细列) =====
|
||
|
||
public Task<List<ReportDataSourceInfoDto>> GetDataSourcesAsync()
|
||
{
|
||
var list = _dataProviders.Values
|
||
.OrderBy(p => p.ReportName, StringComparer.OrdinalIgnoreCase)
|
||
.Select(p => new ReportDataSourceInfoDto { DsName = p.ReportName })
|
||
.ToList();
|
||
return Task.FromResult(list);
|
||
}
|
||
|
||
public async Task<ReportProbeResultDto> ProbeAsync(ReportProbeRequestDto request)
|
||
{
|
||
var dsName = (request.DsName ?? "").Trim();
|
||
var result = new ReportProbeResultDto { DsName = dsName };
|
||
|
||
_dataProviders.TryGetValue(dsName, out var provider);
|
||
|
||
// 探测参数只用前端带入的取值(参数本身由用户在报表配置里维护,不在这里生成控件)
|
||
var pairs = new Dictionary<string, object>(StringComparer.Ordinal);
|
||
foreach (var kv in request.Params)
|
||
if (kv.Value != null) pairs[kv.Key] = kv.Value;
|
||
|
||
try
|
||
{
|
||
if (provider != null)
|
||
await FillFromProviderAsync(result, provider, pairs);
|
||
else if (!string.IsNullOrWhiteSpace(request.DataSql))
|
||
FillFromSql(result, request.DataSql!, pairs);
|
||
else if (!string.IsNullOrWhiteSpace(dsName))
|
||
result.Error = $"未找到数据源实现:{dsName}";
|
||
else
|
||
result.Error = "请先选择数据源(DsName)或填写数据 SQL";
|
||
}
|
||
catch (F9OperationException ex) { result.Error = ex.Message; }
|
||
catch (Exception ex) { result.Error = "数据源探测失败:" + ex.Message; }
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>通过 IReportDataProvider 执行一次查询,取返回记录集的字段名作为明细列</summary>
|
||
private async Task FillFromProviderAsync(ReportProbeResultDto result, IReportDataProvider provider, Dictionary<string, object> pairs)
|
||
{
|
||
var dataSource = await provider.GetData(pairs, _mediator);
|
||
|
||
// 报表参数:来自 Provider 返回的 Parameter_Data(不是查询条件,也不是硬编码声明)
|
||
var paramDatum = dataSource.Report_Data.FirstOrDefault(kv => kv.Value?.Parameter_Data != null);
|
||
if (paramDatum.Value?.Parameter_Data != null)
|
||
{
|
||
result.Parameters = ToNameValueDict(paramDatum.Value.Parameter_Data)
|
||
.Select(kv => new ReportParamValueDto
|
||
{
|
||
Name = kv.Key,
|
||
Value = kv.Value?.ToString() ?? "",
|
||
})
|
||
.ToList();
|
||
}
|
||
|
||
var datums = dataSource.Report_Data
|
||
.Where(kv => kv.Value?.Field_Data is { Count: > 0 })
|
||
.ToList();
|
||
result.Total = datums.Sum(kv => kv.Value.Field_Data.Count);
|
||
|
||
if (datums.Count == 0)
|
||
{
|
||
result.Error = "查询未返回数据,无法识别明细列(可手工添加列)";
|
||
return;
|
||
}
|
||
|
||
var rows = datums[0].Value.Field_Data.Take(3).Select(ToNameValueDict).ToList();
|
||
result.Rows = rows;
|
||
result.Fields = rows[0].Select(kv => new ReportFieldDefDto
|
||
{
|
||
Name = kv.Key,
|
||
Label = kv.Key,
|
||
DataType = InferDataType(kv.Value),
|
||
}).ToList();
|
||
}
|
||
|
||
/// <summary>直接用 DataSql 执行一次查询(带 @参数),取 DataTable 列作为明细列</summary>
|
||
private void FillFromSql(ReportProbeResultDto result, string sql, Dictionary<string, object> pairs)
|
||
{
|
||
ValidateSql(sql);
|
||
|
||
var sqlTokens = Regex.Matches(sql, @"@([A-Za-z_][A-Za-z0-9_]*)")
|
||
.Select(m => m.Groups[1].Value).Distinct().ToHashSet(StringComparer.Ordinal);
|
||
var dbParams = new List<DbParameter>();
|
||
foreach (var kv in pairs)
|
||
if (sqlTokens.Contains(kv.Key) && kv.Value != null)
|
||
dbParams.Add(new SQLiteParameter("@" + kv.Key, kv.Value));
|
||
|
||
var dt = dbParams.Count > 0
|
||
? _fsql.Ado.ExecuteDataTable(sql, dbParams.ToArray())
|
||
: _fsql.Ado.ExecuteDataTable(sql);
|
||
|
||
result.Fields = dt.Columns.Cast<DataColumn>().Select(c => new ReportFieldDefDto
|
||
{
|
||
Name = c.ColumnName,
|
||
Label = c.ColumnName,
|
||
DataType = c.DataType.Name,
|
||
}).ToList();
|
||
|
||
result.Total = dt.Rows.Count;
|
||
result.Rows = dt.Rows.Cast<DataRow>().Take(3).Select(row =>
|
||
{
|
||
var d = new Dictionary<string, object?>(dt.Columns.Count, StringComparer.Ordinal);
|
||
foreach (DataColumn col in dt.Columns)
|
||
{
|
||
var v = row[col];
|
||
d[col.ColumnName] = v == DBNull.Value ? null : v;
|
||
}
|
||
return d;
|
||
}).ToList();
|
||
}
|
||
|
||
private static string InferDataType(object? value)
|
||
{
|
||
if (value == null) return "String";
|
||
if (value is JsonElement je)
|
||
{
|
||
return je.ValueKind switch
|
||
{
|
||
JsonValueKind.Number => "Number",
|
||
JsonValueKind.True or JsonValueKind.False => "Boolean",
|
||
_ => "String",
|
||
};
|
||
}
|
||
return value switch
|
||
{
|
||
int or long or short or byte or uint or ulong or ushort or sbyte => "Integer",
|
||
decimal or double or float => "Number",
|
||
DateTime or DateTimeOffset => "DateTime",
|
||
bool => "Boolean",
|
||
_ => "String",
|
||
};
|
||
}
|
||
|
||
// ===== 工具 =====
|
||
|
||
private static bool HasTemplate(Report r) => r.ReportFileId != 0;
|
||
|
||
private static ReportDetailDto ToDetail(Report r) => new(
|
||
r.Id, r.Code, r.Name, r.Category, r.Description, r.DataSql, r.DataParams, r.Config,
|
||
r.SortOrder, r.IsActive, r.ReportFileId, HasTemplate(r),
|
||
r.CreatedAt, r.UpdatedAt);
|
||
|
||
private static void ValidateCode(string code)
|
||
{
|
||
if (!Regex.IsMatch(code ?? "", "^[a-zA-Z0-9_]{2,40}$"))
|
||
throw new F9OperationException("报表编码仅允许字母/数字/下划线,长度 2-40");
|
||
}
|
||
|
||
private static void ValidateSql(string sql)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(sql)) throw new F9OperationException("数据集 SQL 不能为空");
|
||
var trimmed = sql.Trim();
|
||
if (!trimmed.StartsWith("select", StringComparison.OrdinalIgnoreCase))
|
||
throw new F9OperationException("数据集 SQL 必须以 SELECT 开头(仅允许查询语句)");
|
||
// 禁止分号后跟其他语句
|
||
if (trimmed.IndexOf(';') >= 0 && trimmed.IndexOf(';') < trimmed.Length - 1)
|
||
throw new F9OperationException("数据集 SQL 不允许包含多条语句");
|
||
}
|
||
|
||
private static void ValidateConfig(string? configJson)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(configJson)) return;
|
||
try
|
||
{
|
||
using var doc = JsonDocument.Parse(configJson);
|
||
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
||
throw new F9OperationException("查询配置(Config)必须是 JSON 对象");
|
||
}
|
||
catch (JsonException)
|
||
{
|
||
throw new F9OperationException("查询配置(Config)不是合法 JSON");
|
||
}
|
||
catch (F9OperationException) { throw; }
|
||
}
|
||
}
|