Files
HC900-Crawler/src/Infrastructure/Reporting/ReportFillService.cs
windpacer 37a464ab96 feat(report): P0 셀프서비스 결정론 리포트 — 메트릭 3종·엑셀 토큰 채움·fastRecord 소스
운전원이 올린 엑셀 템플릿의 {{ metric=...; column=... }} 토큰을, 선택한 날짜의
결정론 계산값으로 채워 다운로드하는 셀프서비스 리포트 레이어(P0).

- 메트릭 3종: energy_efficiency·yield·control_residual (분 버킷 피벗, 클린필터,
  KST→UTC 경계). history_table(60s)·fast_record(s/min) 동일 long 포맷이라 source만 교체.
- 컬럼→태그 매핑은 기존 appsettings SteamAdvisor:Columns 재사용(7컬럼 무료).
- EPPlus 토큰 치환 + 셀 주석에 해상도 메타(source/sampling/n/keep) 부착.
- report_template/report_run 테이블 + cells_json 감사 박제.
- 리포트 탭(웹 UI) + 샘플 템플릿.

검증: 빌드 0에러. E2E 라운드트립 실동작(2026-05-15 C-6111 효율 0.778·수율 0.872·
잔차 mean 0.746 — 오프라인 검증값과 일치). 결정론 검증 게이트 준수(표본0=N/A, 0 날조 금지).

⚠️ EPPlus는 NonCommercial 컨텍스트 — 상용 출시 전 라이선스 정리 필요.

설계: docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:14:35 +09:00

72 lines
3.3 KiB
C#

using System.Text.RegularExpressions;
using Hc900Crawler.Core.Application.DTOs;
using Hc900Crawler.Core.Application.Interfaces;
using OfficeOpenXml;
namespace Hc900Crawler.Infrastructure.Reporting;
/// <summary>
/// 운전원 엑셀 템플릿의 `{{ metric=...; column=...; field=... }}` 토큰을 메트릭 값으로 치환.
/// 채운 셀엔 해상도 메타를 주석으로 부착. EPPlus(서버) — 의존성 기탑재.
/// </summary>
public sealed class ReportFillService
{
private static readonly Regex TOKEN = new(@"\{\{\s*(?<body>.+?)\s*\}\}", RegexOptions.Compiled);
private readonly IReportMetricService _metrics;
public ReportFillService(IReportMetricService metrics) => _metrics = metrics;
public async Task<(byte[] Xlsx, List<object> Cells, string Status)> FillAsync(
byte[] template, DateTime periodKst, string sourceTable, int? sessionId, CancellationToken ct = default)
{
using var pkg = new ExcelPackage(new MemoryStream(template));
var cells = new List<object>();
bool anyErr = false, anyOk = false, anyToken = false;
foreach (var ws in pkg.Workbook.Worksheets)
{
var dim = ws.Dimension;
if (dim == null) continue;
for (int r = dim.Start.Row; r <= dim.End.Row; r++)
for (int c = dim.Start.Column; c <= dim.End.Column; c++)
{
var cell = ws.Cells[r, c];
var mt = TOKEN.Match(cell.Text);
if (!mt.Success) continue;
anyToken = true;
var kv = ParseToken(mt.Groups["body"].Value);
var req = new MetricRequestDto
{
Metric = kv.GetValueOrDefault("metric", ""),
Column = kv.GetValueOrDefault("column", "C-6111"),
PeriodDateKst = periodKst,
SourceTable = sourceTable,
SessionId = sessionId
};
var m = await _metrics.ComputeAsync(req, ct);
double? v = kv.TryGetValue("field", out var f) && m.Extra.TryGetValue(f, out var ev) ? ev : m.Value;
if (m.Status == "ok" && v.HasValue) { cell.Value = v.Value; anyOk = true; }
else { cell.Value = m.Status == "no_data" ? "N/A" : "ERR"; anyErr = true; }
if (cell.Comment == null)
cell.AddComment($"{m.Metric} | src={m.Source} {m.SamplingMs}ms | n={m.N} | keep={1 - m.CleanedFraction:P0} | {m.Unit}"
+ (m.Error != null ? $" | {m.Error}" : ""), "report");
cells.Add(new { sheet = ws.Name, r, c, m.Metric, m.Column,
field = kv.GetValueOrDefault("field", ""), value = v, m.Status,
m.Source, m.SamplingMs, m.N, m.Unit });
}
}
string status = !anyToken ? "error" : anyErr ? (anyOk ? "partial" : "error") : "ok";
return (pkg.GetAsByteArray(), cells, status);
}
private static Dictionary<string, string> ParseToken(string body) =>
body.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(p => p.Split('=', 2))
.Where(a => a.Length == 2)
.ToDictionary(a => a[0].Trim().ToLowerInvariant(), a => a[1].Trim());
}