diff --git a/src/Core/Application/DTOs/ReportDtos.cs b/src/Core/Application/DTOs/ReportDtos.cs
index 6540287..2cf5799 100644
--- a/src/Core/Application/DTOs/ReportDtos.cs
+++ b/src/Core/Application/DTOs/ReportDtos.cs
@@ -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, 미포함)
}
/// 템플릿 목록 항목(관리 UI). 생성이력 집계 포함.
diff --git a/src/Hc900Crawler/Controllers/ReportController.cs b/src/Hc900Crawler/Controllers/ReportController.cs
index 476ea4c..70a8538 100644
--- a/src/Hc900Crawler/Controllers/ReportController.cs
+++ b/src/Hc900Crawler/Controllers/ReportController.cs
@@ -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; }
/// 온라인 KPI(live_kpi) 직독 — 누적기가 history_1s에서 갱신한 당일 실시간 값.
[HttpGet("live")]
@@ -192,19 +194,42 @@ GROUP BY b ORDER BY b";
return ok ? Ok(new { Updated = id }) : NotFound(new { Error = $"템플릿 {id} 없음" });
}
- /// ★템플릿+날짜 → 채워진 xlsx 다운로드.
+ ///
+ /// ★템플릿 → 채워진 xlsx 다운로드. 두 갈래:
+ /// • Fixed: period=DAILY|MONTHLY|YEARLY + date(기준일). 일보는 생산일 시작시각(config) 반영.
+ /// • User : period=CUSTOM + from/to(KST 임의 구간). Shift·임의 기간.
+ ///
[HttpGet("generate")]
- public async Task Generate(int templateId, DateTime date,
+ public async Task 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);
}
}
diff --git a/src/Hc900Crawler/appsettings.json b/src/Hc900Crawler/appsettings.json
index ba40910..1aeff00 100644
--- a/src/Hc900Crawler/appsettings.json
+++ b/src/Hc900Crawler/appsettings.json
@@ -87,6 +87,7 @@
}
},
"Report": {
+ "ProductionDayStartHour": 6,
"Transfer": {
"C-9111": "C-10111",
"C-10111": "C-9111"
diff --git a/src/Hc900Crawler/wwwroot/js/reports.js b/src/Hc900Crawler/wwwroot/js/reports.js
index b2fd4dc..26cc40b 100644
--- a/src/Hc900Crawler/wwwroot/js/reports.js
+++ b/src/Hc900Crawler/wwwroot/js/reports.js
@@ -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; }
diff --git a/src/Hc900Crawler/wwwroot/panes/reports.html b/src/Hc900Crawler/wwwroot/panes/reports.html
index 20c5859..40aaef6 100644
--- a/src/Hc900Crawler/wwwroot/panes/reports.html
+++ b/src/Hc900Crawler/wwwroot/panes/reports.html
@@ -80,8 +80,20 @@
-
-
+
+
+
+
+
+
+