From ca556aebc5330ba9fbf658e50cf29497470ad66f Mon Sep 17 00:00:00 2001 From: windpacer Date: Wed, 24 Jun 2026 06:33:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(report):=20=EC=9B=90=EC=8B=9C=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=9E=90=EC=9C=A0=EC=84=9C=EC=8B=9D=20=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=E2=80=94=20[TAG]/[TAG:agg]=20in-place=20?= =?UTF-8?q?=EC=B9=98=ED=99=98=20+=20=EA=B8=B0=EA=B0=84=20=EC=9D=BC?= =?UTF-8?q?=EB=B0=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 생산팀이 전산팀 등록 없이 엑셀 폼을 직접 그려 일·월·연·임의구간 리포트를 즉시 생성. 셀에 [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 --- src/Core/Application/DTOs/ReportDtos.cs | 10 +- .../Controllers/ReportController.cs | 39 ++++++-- src/Hc900Crawler/appsettings.json | 1 + src/Hc900Crawler/wwwroot/js/reports.js | 42 +++++++- src/Hc900Crawler/wwwroot/panes/reports.html | 16 ++- .../Hc900/Hc900ReportScheduleService.cs | 5 +- .../Reporting/ReportFillService.cs | 70 +++++++++---- .../Reporting/ReportMetricService.cs | 99 ++++++++++++++++++- src/Infrastructure/Reporting/ReportWindow.cs | 38 +++++++ 9 files changed, 282 insertions(+), 38 deletions(-) create mode 100644 src/Infrastructure/Reporting/ReportWindow.cs 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 @@ -
- +
+ + + + + +