생산팀이 전산팀 등록 없이 엑셀 폼을 직접 그려 일·월·연·임의구간 리포트를
즉시 생성. 셀에 [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>
105 lines
5.2 KiB
C#
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());
|
|
}
|