Files
HC900-Crawler/src/Infrastructure/Reporting/ReportFillService.cs
windpacer ca556aebc5 feat(report): 원시 태그 자유서식 리포트 — [TAG]/[TAG:agg] in-place 치환 + 기간 일반화
생산팀이 전산팀 등록 없이 엑셀 폼을 직접 그려 일·월·연·임의구간 리포트를
즉시 생성. 셀에 [LI-6100]/[FICQ-6118.QV:delta] 대괄호 토큰을 쓰면 그 자리에
값 치환; 대괄호 없는 LI-6100 텍스트(타이틀/라벨)는 무시.

- MetricRequestDto: Tag/Agg + 명시 윈도 FromUtc/ToUtc 추가
- WindowResolver(ReportWindow.cs): 일보(생산일 시작시각 가변 24h)/월·연(달력)/
  CUSTOM(임의 from~to) → [from,to) UTC 단일 진실원
- ReportMetricService: raw 분기 + RawAsync(last/first/avg/min/max/sum/delta),
  tag_metadata 존재·단위 검증(미등록=error, 무데이터=no_data, 0 날조 금지)
- ReportFillService: [TAG] 셀 전체 토큰 정규식, 기존 {{metric}} 토큰과 공존
- ReportController.Generate: period(일/월/연/CUSTOM)+from/to, 생산일 시작시각 config
- Hc900ReportScheduleService: 자동생성 일보가 생산일 시작시각 반영
- reports.html/js: 기간 select + 날짜/월/연/구간 입력 토글
- appsettings: Report:ProductionDayStartHour=6

끝단 검증: LI-6111 일보 last=33.25/avg=35.71/min=30.9/max=40.5,
적산 FICQ-6118 일보 6648.6kg·월보 61236kg, 미등록 태그 ERR+사유 주석.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:33:50 +09:00

105 lines
5.2 KiB
C#

using System.Text.RegularExpressions;
using Hc900Crawler.Core.Application.DTOs;
using Hc900Crawler.Core.Application.Interfaces;
using OfficeOpenXml;
namespace Hc900Crawler.Infrastructure.Reporting;
/// <summary>
/// 운전원 엑셀 템플릿 토큰을 값으로 치환. 두 문법 공존:
/// 1) 메트릭 토큰 `{{ metric=...; column=...; field=...; period=... }}` — 엔지니어드 KPI.
/// 2) 원시 태그 토큰 `[LI-6100]` / `[FICQ-6118.QV:delta]` — 셀 전체가 대괄호일 때만, in-place 치환.
/// 대괄호 없는 `LI-6100`(문서 표시용 라벨)은 무시. 집계 접미사 미지정=last(종료시각 스냅샷).
/// 구간은 ReportWindow가 결정(일=생산일 시작시각/월·연=달력/CUSTOM=임의). 채운 셀엔 해상도 메타 주석.
/// </summary>
public sealed class ReportFillService
{
private static readonly Regex TOKEN = new(@"\{\{\s*(?<body>.+?)\s*\}\}", RegexOptions.Compiled);
// 셀 전체가 [TAG] 또는 [TAG:agg]. tag=영숫자/_/.- (계기·루프·.QV). agg=영문(last/avg/...).
private static readonly Regex BRACKET = new(
@"^\s*\[\s*(?<tag>[A-Za-z0-9_][A-Za-z0-9_.\-]*?)\s*(?::\s*(?<agg>[A-Za-z]+)\s*)?\]\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, ReportWindow window, 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 text = cell.Text;
MetricRequestDto req;
string field = "";
var mt = TOKEN.Match(text);
if (mt.Success)
{
var kv = ParseToken(mt.Groups["body"].Value);
bool cellPeriod = kv.ContainsKey("period"); // 셀이 직접 period 지정 시 레거시 달력윈도, 아니면 리포트 윈도
req = new MetricRequestDto
{
Metric = kv.GetValueOrDefault("metric", ""),
Column = kv.GetValueOrDefault("column", "C-6111"),
PeriodDateKst = window.AnchorKst,
Period = cellPeriod ? kv["period"] : window.Kind,
SourceTable = sourceTable,
SessionId = sessionId,
FromUtc = cellPeriod ? null : window.FromUtc,
ToUtc = cellPeriod ? null : window.ToUtc,
};
field = kv.GetValueOrDefault("field", "");
}
else
{
var bm = BRACKET.Match(text);
if (!bm.Success) continue; // 토큰도 대괄호도 아니면(라벨/숫자) 무시
req = new MetricRequestDto
{
Metric = "raw",
Tag = bm.Groups["tag"].Value,
Agg = bm.Groups["agg"].Success ? bm.Groups["agg"].Value : "last",
PeriodDateKst = window.AnchorKst,
Period = window.Kind,
SourceTable = sourceTable,
SessionId = sessionId,
FromUtc = window.FromUtc,
ToUtc = window.ToUtc,
};
}
anyToken = true;
var m = await _metrics.ComputeAsync(req, ct);
double? v = field.Length > 0 && m.Extra.TryGetValue(field, 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} | {m.Period} | 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, m.Period,
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());
}