1 Commits

Author SHA1 Message Date
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
9 changed files with 282 additions and 38 deletions

View File

@@ -4,11 +4,19 @@ namespace Hc900Crawler.Core.Application.DTOs;
public sealed class MetricRequestDto
{
public string Column { get; set; } = "C-6111";
public string Metric { get; set; } = ""; // energy_efficiency | yield | control_residual
public string Metric { get; set; } = ""; // energy_efficiency | yield | control_residual | raw
public DateTime PeriodDateKst { get; set; } // 운전원이 고른 KST 날짜(00:00 기준)
public string Period { get; set; } = "DAILY"; // DAILY | MONTHLY | YEARLY — 윈도 = PeriodDateKst가 속한 일/월/연
public string SourceTable { get; set; } = "history_table"; // | fast_record
public int? SessionId { get; set; } // fast_record일 때
// ── 원시 태그 직독(metric="raw") ──
public string? Tag { get; set; } // 예: "LI-6100.PV" (속성 없으면 엔진이 .PV 부여)
public string Agg { get; set; } = "last"; // last|first|avg|min|max|sum|delta (구간 집계)
// ── 명시적 윈도 override(사용자 구간·생산일 시작시각 일보). 둘 다 있으면 Period/PeriodDateKst 대신 사용 ──
public DateTime? FromUtc { get; set; } // 구간 시작(UTC, 포함)
public DateTime? ToUtc { get; set; } // 구간 끝(UTC, 미포함)
}
/// <summary>템플릿 목록 항목(관리 UI). 생성이력 집계 포함.</summary>

View File

@@ -5,6 +5,7 @@ using Hc900Crawler.Infrastructure.Database;
using Hc900Crawler.Infrastructure.Reporting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace Hc900Crawler.Web.Controllers;
@@ -17,14 +18,15 @@ public class ReportController : ControllerBase
private readonly IReportTemplateStore _store;
private readonly ReportColumnMap _map;
private readonly Hc900DbContext _db;
private readonly IConfiguration _config;
// 웹 대시보드 기본 메트릭 세트
private static readonly string[] SUMMARY_METRICS =
{ "production_total", "yield_qv", "energy_intensity_qv", "mass_balance_closure", "control_residual" };
public ReportController(IReportMetricService metrics, ReportFillService fill,
IReportTemplateStore store, ReportColumnMap map, Hc900DbContext db)
{ _metrics = metrics; _fill = fill; _store = store; _map = map; _db = db; }
IReportTemplateStore store, ReportColumnMap map, Hc900DbContext db, IConfiguration config)
{ _metrics = metrics; _fill = fill; _store = store; _map = map; _db = db; _config = config; }
/// <summary>온라인 KPI(live_kpi) 직독 — 누적기가 history_1s에서 갱신한 당일 실시간 값.</summary>
[HttpGet("live")]
@@ -192,19 +194,42 @@ GROUP BY b ORDER BY b";
return ok ? Ok(new { Updated = id }) : NotFound(new { Error = $"템플릿 {id} 없음" });
}
/// <summary>★템플릿+날짜 → 채워진 xlsx 다운로드.</summary>
/// <summary>
/// ★템플릿 → 채워진 xlsx 다운로드. 두 갈래:
/// • Fixed: period=DAILY|MONTHLY|YEARLY + date(기준일). 일보는 생산일 시작시각(config) 반영.
/// • User : period=CUSTOM + from/to(KST 임의 구간). Shift·임의 기간.
/// </summary>
[HttpGet("generate")]
public async Task<IActionResult> Generate(int templateId, DateTime date,
public async Task<IActionResult> Generate(int templateId, DateTime? date = null,
string period = "DAILY", DateTime? from = null, DateTime? to = null,
string source = "history_table", int? sessionId = null, CancellationToken ct = default)
{
var tpl = await _store.GetBlobAsync(templateId, ct);
if (tpl == null) return NotFound(new { Error = $"템플릿 {templateId} 없음" });
var (xlsx, cells, status) = await _fill.FillAsync(tpl, date, source, sessionId, ct);
await _store.RecordRunAsync(templateId, "DAILY", date, source, status, cells, xlsx, ct);
period = (period ?? "DAILY").Trim().ToUpperInvariant();
ReportWindow window;
if (period == "CUSTOM")
{
if (from == null || to == null) return BadRequest(new { Error = "CUSTOM은 from·to(KST) 필요" });
if (to <= from) return BadRequest(new { Error = "to는 from보다 뒤여야 함" });
window = WindowResolver.Custom(from.Value, to.Value);
}
else if (period is "DAILY" or "MONTHLY" or "YEARLY")
{
var anchor = (date ?? DateTime.UtcNow.AddHours(9).AddDays(-1)).Date; // 기본=어제(KST)
int startHour = _config.GetValue("Report:ProductionDayStartHour", 0);
window = WindowResolver.Fixed(period, anchor, startHour);
}
else return BadRequest(new { Error = $"period는 DAILY|MONTHLY|YEARLY|CUSTOM (받음: {period})" });
var (xlsx, cells, status) = await _fill.FillAsync(tpl, window, source, sessionId, ct);
await _store.RecordRunAsync(templateId, window.Kind, window.AnchorKst, source, status, cells, xlsx, ct);
Response.Headers["X-Report-Status"] = status;
var fname = $"report_{templateId}_{date:yyyyMMdd}.xlsx";
var fname = period == "CUSTOM"
? $"report_{templateId}_{from:yyyyMMddHHmm}-{to:yyyyMMddHHmm}.xlsx"
: $"report_{templateId}_{window.Kind}_{window.AnchorKst:yyyyMMdd}.xlsx";
return File(xlsx, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fname);
}
}

View File

@@ -87,6 +87,7 @@
}
},
"Report": {
"ProductionDayStartHour": 6,
"Transfer": {
"C-9111": "C-10111",
"C-10111": "C-9111"

View File

@@ -38,6 +38,25 @@ paneInit['reports'] = function () {
const hesc = (s) => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
if ($('rpDate') && !$('rpDate').value) $('rpDate').value = yKst();
// ⓒ 기간 select → 입력 토글 + 기본값(KST)
(function rpPeriodInit() {
const p = $('rpPeriod'); if (!p) return;
const kstNow = new Date(Date.now() + 9 * 3600e3); // KST 벽시계
const kstYday = new Date(Date.now() + 9 * 3600e3 - 86400e3);
if ($('rpMonth') && !$('rpMonth').value) $('rpMonth').value = kstNow.toISOString().slice(0, 7);
if ($('rpYear') && !$('rpYear').value) $('rpYear').value = kstNow.getUTCFullYear();
if ($('rpFrom') && !$('rpFrom').value) $('rpFrom').value = kstYday.toISOString().slice(0, 16);
if ($('rpTo') && !$('rpTo').value) $('rpTo').value = kstNow.toISOString().slice(0, 16);
const show = (id, on) => { const w = $(id); if (w) w.style.display = on ? '' : 'none'; };
p.onchange = () => {
const v = p.value;
show('rpDateWrap', v === 'DAILY'); show('rpMonthWrap', v === 'MONTHLY');
show('rpYearWrap', v === 'YEARLY');
show('rpFromWrap', v === 'CUSTOM'); show('rpToWrap', v === 'CUSTOM');
};
p.onchange();
})();
// 선택된 템플릿의 스케줄 값을 편집기에 채움
let templates = [];
function fillSchedForm() {
@@ -139,15 +158,32 @@ paneInit['reports'] = function () {
if (gen) gen.onclick = async () => {
const id = $('rpSelId').value;
if (!id) { status.textContent = '⚠️ 생성할 템플릿을 선택하세요.'; return; }
const period = $('rpPeriod') ? $('rpPeriod').value : 'DAILY';
let url = `/api/report/generate?templateId=${id}&period=${period}&source=${$('rpSource').value}`;
let fnameTag;
if (period === 'CUSTOM') {
const f = $('rpFrom').value, t = $('rpTo').value;
if (!f || !t) { status.textContent = '⚠️ 시작·종료 시각을 입력하세요.'; return; }
if (t <= f) { status.textContent = '⚠️ 종료가 시작보다 뒤여야 합니다.'; return; }
url += `&from=${encodeURIComponent(f)}&to=${encodeURIComponent(t)}`;
fnameTag = `${f}_${t}`.replace(/[:T-]/g, '');
} else {
let date = $('rpDate').value;
if (period === 'MONTHLY') date = ($('rpMonth').value || '') + '-01';
else if (period === 'YEARLY') date = ($('rpYear').value || '') + '-01-01';
if (!date || date[0] === '-') { status.textContent = '⚠️ 기준 날짜/월/연을 입력하세요.'; return; }
url += `&date=${date}`;
fnameTag = `${period}_${date}`;
}
if ($('rpSource').value === 'fast_record' && $('rpSession').value) url += `&sessionId=${$('rpSession').value}`;
gen.disabled = true; status.textContent = '⏳ 생성 중...';
try {
let url = `/api/report/generate?templateId=${id}&date=${$('rpDate').value}&source=${$('rpSource').value}`;
if ($('rpSource').value === 'fast_record' && $('rpSession').value) url += `&sessionId=${$('rpSession').value}`;
const resp = await fetch(url);
if (!resp.ok) throw new Error('생성 실패 ' + resp.status);
const st = resp.headers.get('X-Report-Status') || '?';
const a = document.createElement('a'); a.href = URL.createObjectURL(await resp.blob());
a.download = `report_${id}_${$('rpDate').value}.xlsx`; a.click(); URL.revokeObjectURL(a.href);
a.download = `report_${id}_${fnameTag}.xlsx`; a.click(); URL.revokeObjectURL(a.href);
status.textContent = `✅ 완료 (상태=${st})`;
loadTemplates(); // 생성횟수·최근시각 갱신
} catch (e) { status.textContent = '❌ ' + e.message; } finally { gen.disabled = false; }

View File

@@ -80,8 +80,20 @@
</div>
<!-- ⓒ 생성 -->
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;max-width:640px;margin-top:14px;border-top:1px dashed var(--bd,#333);padding-top:14px">
<label>날짜(KST)<input type="date" id="rpDate" style="display:block;margin-top:4px"></label>
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;max-width:820px;margin-top:14px;border-top:1px dashed var(--bd,#333);padding-top:14px">
<label>기간
<select id="rpPeriod" style="display:block;margin-top:4px">
<option value="DAILY">일보</option>
<option value="MONTHLY">월보</option>
<option value="YEARLY">연보</option>
<option value="CUSTOM">사용자 구간</option>
</select>
</label>
<label id="rpDateWrap">기준일(KST)<input type="date" id="rpDate" style="display:block;margin-top:4px"></label>
<label id="rpMonthWrap" style="display:none">기준월(KST)<input type="month" id="rpMonth" style="display:block;margin-top:4px"></label>
<label id="rpYearWrap" style="display:none">기준연(KST)<input type="number" id="rpYear" min="2000" max="2100" placeholder="2026" style="display:block;margin-top:4px;width:90px"></label>
<label id="rpFromWrap" style="display:none">시작(KST)<input type="datetime-local" id="rpFrom" style="display:block;margin-top:4px"></label>
<label id="rpToWrap" style="display:none">종료(KST)<input type="datetime-local" id="rpTo" style="display:block;margin-top:4px"></label>
<label>③ 소스
<select id="rpSource" style="display:block;margin-top:4px">
<option value="history_table">history (60초)</option>

View File

@@ -23,6 +23,7 @@ public class Hc900ReportScheduleService : BackgroundService
private readonly bool _enabled;
private readonly int _intervalSec;
private readonly int _catchupDays, _catchupMonths, _catchupYears;
private readonly int _prodDayStartHour;
public Hc900ReportScheduleService(IServiceScopeFactory scopeFactory,
ILogger<Hc900ReportScheduleService> logger, IConfiguration config)
@@ -33,6 +34,7 @@ public class Hc900ReportScheduleService : BackgroundService
_catchupDays = Math.Max(1, config.GetValue("Report:Schedule:CatchupDays", 3));
_catchupMonths = Math.Max(1, config.GetValue("Report:Schedule:CatchupMonths", 1));
_catchupYears = Math.Max(1, config.GetValue("Report:Schedule:CatchupYears", 1));
_prodDayStartHour = config.GetValue("Report:ProductionDayStartHour", 0); // 일보 생산일 시작시각
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -74,7 +76,8 @@ public class Hc900ReportScheduleService : BackgroundService
var blob = await store.GetBlobAsync(t.Id, ct);
if (blob == null) { _logger.LogWarning("[ReportSchedule] #{Id} blob 없음 — 스킵", t.Id); continue; }
var (xlsx, cells, status) = await fill.FillAsync(blob, anchor, t.ScheduleSource, null, ct);
var window = WindowResolver.Fixed(period, anchor, _prodDayStartHour); // 일=생산일 시작시각, 월·연=달력
var (xlsx, cells, status) = await fill.FillAsync(blob, window, t.ScheduleSource, null, ct);
await store.RecordRunAsync(t.Id, period, anchor, t.ScheduleSource, status, cells, xlsx, ct);
_logger.LogInformation("[ReportSchedule] #{Id} {Period} {Date:yyyy-MM-dd} 생성 — {Status}", t.Id, period, anchor, status);
}

View File

@@ -6,17 +6,24 @@ using OfficeOpenXml;
namespace Hc900Crawler.Infrastructure.Reporting;
/// <summary>
/// 운전원 엑셀 템플릿의 `{{ metric=...; column=...; field=... }}` 토큰을 메트릭 값으로 치환.
/// 채운 셀엔 해상도 메타를 주석으로 부착. EPPlus(서버) — 의존성 기탑재.
/// 운전원 엑셀 템플릿 토큰을 값으로 치환. 두 문법 공존:
/// 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, DateTime periodKst, string sourceTable, int? sessionId, CancellationToken ct = default)
byte[] template, ReportWindow window, string sourceTable, int? sessionId, CancellationToken ct = default)
{
using var pkg = new ExcelPackage(new MemoryStream(template));
var cells = new List<object>();
@@ -30,23 +37,49 @@ public sealed class ReportFillService
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;
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 kv = ParseToken(mt.Groups["body"].Value);
var req = new MetricRequestDto
{
Metric = kv.GetValueOrDefault("metric", ""),
Column = kv.GetValueOrDefault("column", "C-6111"),
PeriodDateKst = periodKst,
Period = kv.GetValueOrDefault("period", "DAILY"), // 셀별 일/월/연 윈도(미지정=일)
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;
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; }
@@ -55,8 +88,7 @@ public sealed class ReportFillService
+ (m.Error != null ? $" | {m.Error}" : ""), "report");
cells.Add(new { sheet = ws.Name, r, c, m.Metric, m.Column, m.Period,
field = kv.GetValueOrDefault("field", ""), value = v, m.Status,
m.Source, m.SamplingMs, m.N, m.Unit });
field, value = v, m.Status, m.Source, m.SamplingMs, m.N, m.Unit });
}
}

View File

@@ -55,17 +55,21 @@ public sealed class ReportMetricService : IReportMetricService
string tbl = isFast ? "fast_record"
: SERIES_SOURCES.Contains(req.SourceTable) ? req.SourceTable : "history_table";
string period = string.IsNullOrWhiteSpace(req.Period) ? "DAILY" : req.Period.Trim().ToUpperInvariant();
// 명시 윈도(사용자 구간·생산일 시작시각 일보)면 Period/PeriodDateKst 대신 [FromUtc,ToUtc) 사용.
bool explicitWindow = req.FromUtc.HasValue && req.ToUtc.HasValue;
var res = new MetricResultDto
{
Metric = req.Metric, Column = req.Column, Period = period,
Source = tbl, SamplingMs = isFast ? 0 : (tbl == "history_1s" ? 1000 : 60000)
};
// 결정론 게이트: 오타 period를 daily로 묵인하면 틀린 값 → 명시적 에러.
if (!PERIODS.Contains(period))
// 결정론 게이트: 오타 period를 daily로 묵인하면 틀린 값 → 명시적 에러. (명시 윈도면 period 라벨 자유)
if (!explicitWindow && !PERIODS.Contains(period))
{ res.Status = "error"; res.Error = $"미지원 period: {req.Period} (DAILY|MONTHLY|YEARLY)"; res.Value = null; return res; }
// PeriodDateKst가 속한 일/월/연 [from, to) KST → UTC (recorded_at은 UTC). fast_record는 session 기준이라 윈도 무관.
var (fromUtc, toUtc) = PeriodWindowUtc(req.PeriodDateKst, period);
// 명시 윈도 우선; 없으면 PeriodDateKst가 속한 일/월/연 [from, to) KST → UTC. fast_record는 session 기준이라 윈도 무관.
var (fromUtc, toUtc) = explicitWindow
? (req.FromUtc!.Value, req.ToUtc!.Value)
: PeriodWindowUtc(req.PeriodDateKst, period);
try
{
@@ -73,7 +77,16 @@ public sealed class ReportMetricService : IReportMetricService
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
await _eu.EnsureLoadedAsync(ct); // 클린범위 EU레인지 캐시 선로딩(ReportColumnMap이 동기 조회)
if (req.Metric == "dynamics")
if (req.Metric == "raw")
{
if (string.IsNullOrWhiteSpace(req.Tag))
{ res.Status = "error"; res.Error = "raw 메트릭에 tag 없음"; res.Value = null; return res; }
var tag = NormTag(req.Tag.Trim());
res.Column = tag; // 결과/주석에 실제 태그 노출
await RawAsync(res, conn, tbl, tag, (req.Agg ?? "last").Trim().ToLowerInvariant(),
fromUtc, toUtc, isFast, req.SessionId, ct);
}
else if (req.Metric == "dynamics")
{
await DynamicsAsync(res, conn, tbl, req.Column, fromUtc, toUtc, isFast, req.SessionId, ct);
}
@@ -385,6 +398,82 @@ FROM good;";
else { res.Status = "no_data"; res.Value = null; }
}
// ── 원시 태그 직독: 구간 [from,to) 내 단일 태그 집계. 운전원 자유서식 [TAG]/[TAG:agg]용. ──
// last(기본,종료시각 스냅샷=재고/레벨)·first·avg·min·max·sum·delta(=적산 Δ, 리셋/wrap 처리).
private async Task RawAsync(MetricResultDto res, DbConnection conn, string tbl, string tag, string agg,
DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct)
{
var (exists, unit) = await TagMetaAsync(conn, tag, ct);
res.Unit = unit;
// delta = 적산값 증분(QvDeltaAsync 재사용: 양증분합, 999999 wrap·리셋 자동처리). .QV 적산태그용.
if (agg == "delta")
{
var (tot, n, _) = await QvDeltaAsync(conn, tbl, tag, null, fromUtc, toUtc, isFast, sid, ct);
if (tot is null || n == 0)
{ res.Status = exists ? "no_data" : "error"; res.Value = null;
res.Error = exists ? "구간 내 데이터 없음" : $"미등록 태그: {tag}"; return; }
res.Value = tot; res.N = n; return;
}
// 단순 집계(분 버킷 없이 원샘플 기준). 오타 집계 → 명시적 에러(결정론 게이트).
if (agg is not ("last" or "first" or "avg" or "min" or "max" or "sum"))
{ res.Status = "error"; res.Value = null;
res.Error = $"미지원 집계: {agg} (last|first|avg|min|max|sum|delta)"; return; }
string window = isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to";
await using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
WITH w AS (
SELECT recorded_at, value::float v
FROM hc900.{tbl}
WHERE tagname=@tag AND value ~ '{NUMERIC}' AND ({window})
)
SELECT
CASE @agg
WHEN 'last' THEN (SELECT v FROM w ORDER BY recorded_at DESC LIMIT 1)
WHEN 'first' THEN (SELECT v FROM w ORDER BY recorded_at ASC LIMIT 1)
WHEN 'avg' THEN (SELECT avg(v) FROM w)
WHEN 'min' THEN (SELECT min(v) FROM w)
WHEN 'max' THEN (SELECT max(v) FROM w)
WHEN 'sum' THEN (SELECT sum(v) FROM w)
END,
(SELECT count(*) FROM w)";
AddP(cmd, "@tag", tag); AddP(cmd, "@agg", agg);
if (isFast) AddP(cmd, "@sid", sid ?? -1); else { AddP(cmd, "@from", fromUtc); AddP(cmd, "@to", toUtc); }
await using var rd = await cmd.ExecuteReaderAsync(ct);
if (await rd.ReadAsync(ct))
{
long n = rd.IsDBNull(1) ? 0 : rd.GetInt64(1);
res.N = (int)n;
if (n == 0)
{ res.Status = exists ? "no_data" : "error"; res.Value = null;
res.Error = exists ? "구간 내 데이터 없음" : $"미등록 태그: {tag}"; return; }
if (rd.IsDBNull(0)) { res.Status = "no_data"; res.Value = null; res.Error = "집계 결과 NULL"; return; }
res.Value = rd.GetDouble(0);
}
else { res.Status = "no_data"; res.Value = null; }
}
/// <summary>태그 존재(tag_metadata.base_tag) + 단위(attribute='units'). raw 직독 검증·표시용.</summary>
private static async Task<(bool Exists, string? Unit)> TagMetaAsync(DbConnection conn, string tag, CancellationToken ct)
{
string baseTag = tag.Contains('.') ? tag[..tag.LastIndexOf('.')] : tag;
await using var cmd = conn.CreateCommand();
cmd.CommandText = @"SELECT
EXISTS(SELECT 1 FROM hc900.tag_metadata WHERE base_tag=@b),
(SELECT value FROM hc900.tag_metadata WHERE base_tag=@b AND attribute='units' LIMIT 1)";
AddP(cmd, "@b", baseTag);
await using var rd = await cmd.ExecuteReaderAsync(ct);
if (await rd.ReadAsync(ct))
return (!rd.IsDBNull(0) && rd.GetBoolean(0), rd.IsDBNull(1) ? null : rd.GetString(1));
return (false, null);
}
/// <summary>속성 없으면 .PV 부여(계기 기본 PV). 운전원이 [LI-6100]만 적어도 LI-6100.PV로 직독.</summary>
private static string NormTag(string tag) => tag.Contains('.') ? tag : tag + ".PV";
private static async Task<int> FastSamplingMsAsync(DbConnection conn, int sid, CancellationToken ct)
{
await using var c = conn.CreateCommand();

View File

@@ -0,0 +1,38 @@
namespace Hc900Crawler.Infrastructure.Reporting;
/// <summary>
/// 리포트 집계 구간 [FromUtc, ToUtc) + 메타. 모든 시각은 KST 기준 입력 → 내부 UTC 보관(recorded_at은 UTC).
/// Kind: DAILY|MONTHLY|YEARLY(Fixed) | CUSTOM(User). AnchorKst = 멱등 dedup·파일명·레거시 {{period=}} 토큰용.
/// </summary>
public sealed record ReportWindow(string Kind, DateTime AnchorKst, DateTime FromUtc, DateTime ToUtc);
/// <summary>
/// 리포트 구간 결정 단일 진실원. 두 갈래:
/// 1) Fixed — 일보(생산일 시작시각 가변 24h)/월보·연보(달력 경계).
/// 2) User — 임의 [start, end) KST.
/// 생산일 시작시각(ProductionDayStartHour, 0~23)은 일보에만 적용; 월·연은 달력 기준(요구사항).
/// </summary>
public static class WindowResolver
{
private static DateTime ToUtc(DateTime kst)
=> DateTime.SpecifyKind(kst, DateTimeKind.Unspecified).AddHours(-9);
/// <summary>Fixed 리포트 구간. period=DAILY|MONTHLY|YEARLY. anchorKst가 속한 기간.
/// 일보: [기준일 HH:00, 다음날 HH:00). 월·연: 달력 경계(시작시각 무시).</summary>
public static ReportWindow Fixed(string period, DateTime anchorKst, int productionDayStartHour)
{
int hh = Math.Clamp(productionDayStartHour, 0, 23);
var d = anchorKst.Date;
var (fromKst, toKst, kind) = (period?.Trim().ToUpperInvariant()) switch
{
"MONTHLY" => (new DateTime(d.Year, d.Month, 1), new DateTime(d.Year, d.Month, 1).AddMonths(1), "MONTHLY"),
"YEARLY" => (new DateTime(d.Year, 1, 1), new DateTime(d.Year, 1, 1).AddYears(1), "YEARLY"),
_ => (d.AddHours(hh), d.AddDays(1).AddHours(hh), "DAILY"),
};
return new ReportWindow(kind, anchorKst, ToUtc(fromKst), ToUtc(toKst));
}
/// <summary>User 임의 구간 [fromKst, toKst). AnchorKst = 시작일(파일명·dedup 키).</summary>
public static ReportWindow Custom(DateTime fromKst, DateTime toKst)
=> new("CUSTOM", fromKst.Date, ToUtc(fromKst), ToUtc(toKst));
}