Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca556aebc5 |
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
}
|
||||
},
|
||||
"Report": {
|
||||
"ProductionDayStartHour": 6,
|
||||
"Transfer": {
|
||||
"C-9111": "C-10111",
|
||||
"C-10111": "C-9111"
|
||||
|
||||
@@ -38,6 +38,25 @@ paneInit['reports'] = function () {
|
||||
const hesc = (s) => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
38
src/Infrastructure/Reporting/ReportWindow.cs
Normal file
38
src/Infrastructure/Reporting/ReportWindow.cs
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user