- MetricRequest/Result에 Period 추가, ReportMetricService.PeriodWindowUtc로 선택일이 속한 일/월/연 [from,to)를 KST→UTC 변환(DAILY는 기존 동작 동일). - 오타 period는 daily 묵인 대신 명시적 error(결정론 게이트). - ReportFillService 토큰 period= 파싱 + 셀주석/cells_json 박제. - summary 엔드포인트 period 파라미터, 웹 바로보기 기간 드롭다운 + 치트시트. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
169 lines
8.6 KiB
C#
169 lines
8.6 KiB
C#
using System.Data;
|
|
using Hc900Crawler.Core.Application.DTOs;
|
|
using Hc900Crawler.Core.Application.Interfaces;
|
|
using Hc900Crawler.Infrastructure.Database;
|
|
using Hc900Crawler.Infrastructure.Reporting;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Hc900Crawler.Web.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/report")]
|
|
public class ReportController : ControllerBase
|
|
{
|
|
private readonly IReportMetricService _metrics;
|
|
private readonly ReportFillService _fill;
|
|
private readonly IReportTemplateStore _store;
|
|
private readonly ReportColumnMap _map;
|
|
private readonly Hc900DbContext _db;
|
|
|
|
// 웹 대시보드 기본 메트릭 세트
|
|
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; }
|
|
|
|
/// <summary>온라인 KPI(live_kpi) 직독 — 누적기가 history_1s에서 갱신한 당일 실시간 값.</summary>
|
|
[HttpGet("live")]
|
|
public async Task<IActionResult> Live(string? column = null, CancellationToken ct = default)
|
|
{
|
|
var conn = _db.Database.GetDbConnection();
|
|
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
|
await using var cmd = conn.CreateCommand();
|
|
// (column_id,kpi)별 최신 window_start만 — 과거 날짜 잔여 행으로 인한 stale 표시 방지
|
|
cmd.CommandText = @"SELECT DISTINCT ON (column_id, kpi)
|
|
column_id, kpi, value, unit, state, excluded_min, status, window_start, updated_at
|
|
FROM hc900.live_kpi" + (column == null ? "" : " WHERE column_id=@col") +
|
|
" ORDER BY column_id, kpi, window_start DESC";
|
|
if (column != null) { var p = cmd.CreateParameter(); p.ParameterName = "@col"; p.Value = column; cmd.Parameters.Add(p); }
|
|
var items = new List<object>();
|
|
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
|
while (await rd.ReadAsync(ct))
|
|
items.Add(new {
|
|
Column = rd.GetString(0), Kpi = rd.GetString(1),
|
|
Value = rd.IsDBNull(2) ? (double?)null : rd.GetDouble(2),
|
|
Unit = rd.IsDBNull(3) ? null : rd.GetString(3),
|
|
State = rd.IsDBNull(4) ? null : rd.GetString(4),
|
|
ExcludedMin = rd.IsDBNull(5) ? (int?)null : rd.GetInt32(5),
|
|
Status = rd.IsDBNull(6) ? null : rd.GetString(6),
|
|
WindowStart = rd.GetFieldValue<DateTime>(7).ToString("yyyy-MM-dd"),
|
|
UpdatedAt = rd.GetFieldValue<DateTime>(8)
|
|
});
|
|
return Ok(new { Count = items.Count, Items = items });
|
|
}
|
|
|
|
/// <summary>카드 스파크라인 — 컬럼별 민감단 온도(TC) 최근 트렌드(history_1s 다운샘플).</summary>
|
|
[HttpGet("sparks")]
|
|
public async Task<IActionResult> Sparks(int minutes = 60, int points = 30, CancellationToken ct = default)
|
|
{
|
|
var conn = _db.Database.GetDbConnection();
|
|
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
|
int bsec = Math.Max(30, minutes * 60 / Math.Max(5, points));
|
|
var items = new List<object>();
|
|
foreach (var col in _map.Columns())
|
|
{
|
|
var tc = _map.TcTag(col);
|
|
var pts = new List<double>();
|
|
if (tc != null)
|
|
{
|
|
await using var cmd = conn.CreateCommand();
|
|
// 순수 SQL 버킷(Timescale 함수 미사용 — search_path 무관)
|
|
cmd.CommandText = @"
|
|
SELECT floor(extract(epoch FROM recorded_at)/@bsec) AS b, avg(value::float) v
|
|
FROM hc900.history_1s
|
|
WHERE tagname=@tc AND recorded_at > now() - @win::interval AND value ~ '^-?[0-9]+(\.[0-9]+)?$'
|
|
GROUP BY b ORDER BY b";
|
|
void P(string n, object v) { var p = cmd.CreateParameter(); p.ParameterName = n; p.Value = v; cmd.Parameters.Add(p); }
|
|
P("@tc", tc); P("@win", $"{minutes} minutes"); P("@bsec", bsec);
|
|
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
|
while (await rd.ReadAsync(ct)) if (!rd.IsDBNull(1)) pts.Add(rd.GetDouble(1));
|
|
}
|
|
items.Add(new { Column = col, Tag = tc, Points = pts });
|
|
}
|
|
return Ok(new { Minutes = minutes, Items = items });
|
|
}
|
|
|
|
/// <summary>활성 알람(kpi_alert) — cleaning/drawdown 진입·폐합 이탈. active=false면 해제 포함.</summary>
|
|
[HttpGet("alerts")]
|
|
public async Task<IActionResult> Alerts(bool activeOnly = true, CancellationToken ct = default)
|
|
{
|
|
var conn = _db.Database.GetDbConnection();
|
|
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
|
await using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = @"SELECT column_id, rule, severity, active, message, value, opened_at, updated_at, resolved_at
|
|
FROM hc900.kpi_alert" + (activeOnly ? " WHERE active" : "") +
|
|
" ORDER BY active DESC, opened_at DESC";
|
|
var items = new List<object>();
|
|
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
|
while (await rd.ReadAsync(ct))
|
|
items.Add(new {
|
|
Column = rd.GetString(0), Rule = rd.GetString(1),
|
|
Severity = rd.IsDBNull(2) ? null : rd.GetString(2),
|
|
Active = rd.GetBoolean(3),
|
|
Message = rd.IsDBNull(4) ? null : rd.GetString(4),
|
|
Value = rd.IsDBNull(5) ? (double?)null : rd.GetDouble(5),
|
|
OpenedAt = rd.GetFieldValue<DateTime>(6),
|
|
UpdatedAt = rd.GetFieldValue<DateTime>(7),
|
|
ResolvedAt = rd.IsDBNull(8) ? (DateTime?)null : rd.GetFieldValue<DateTime>(8)
|
|
});
|
|
return Ok(new { Count = items.Count, Items = items });
|
|
}
|
|
|
|
/// <summary>설정된 컬럼 목록(웹 UI 셀렉트용).</summary>
|
|
[HttpGet("columns")]
|
|
public IActionResult Columns()
|
|
=> Ok(_map.Columns().Select(c => new { Column = c, HasClosure = _map.HasClosure(c) }));
|
|
|
|
/// <summary>단건 메트릭(미리보기/디버그).</summary>
|
|
[HttpPost("metric")]
|
|
public async Task<IActionResult> Metric([FromBody] MetricRequestDto req, CancellationToken ct)
|
|
=> Ok(await _metrics.ComputeAsync(req, ct));
|
|
|
|
/// <summary>웹에서 바로 보기 — 한 컬럼·날짜의 전 메트릭을 한 번에.</summary>
|
|
[HttpGet("summary")]
|
|
public async Task<IActionResult> Summary(string column = "C-6111", DateTime? date = null,
|
|
string source = "history_table", string period = "DAILY", int? sessionId = null, CancellationToken ct = default)
|
|
{
|
|
var d = (date ?? DateTime.UtcNow.AddHours(9).AddDays(-1)).Date; // 기본 = 어제(KST)
|
|
var results = new List<MetricResultDto>();
|
|
foreach (var m in SUMMARY_METRICS)
|
|
results.Add(await _metrics.ComputeAsync(new MetricRequestDto
|
|
{
|
|
Column = column, Metric = m, PeriodDateKst = d, Period = period,
|
|
SourceTable = source, SessionId = sessionId
|
|
}, ct));
|
|
return Ok(new { Column = column, Date = d.ToString("yyyy-MM-dd"), Source = source, Period = period, Metrics = results });
|
|
}
|
|
|
|
/// <summary>엑셀 템플릿 등록.</summary>
|
|
[HttpPost("template")]
|
|
public async Task<IActionResult> Upload([FromForm] IFormFile file, [FromForm] string name,
|
|
[FromForm] string? owner, CancellationToken ct)
|
|
{
|
|
if (file == null || file.Length == 0) return BadRequest(new { Error = "파일 없음" });
|
|
using var ms = new MemoryStream();
|
|
await file.CopyToAsync(ms, ct);
|
|
var id = await _store.CreateAsync(name, owner, ms.ToArray(), ct);
|
|
return Ok(new { Id = id });
|
|
}
|
|
|
|
/// <summary>★템플릿+날짜 → 채워진 xlsx 다운로드.</summary>
|
|
[HttpGet("generate")]
|
|
public async Task<IActionResult> Generate(int templateId, DateTime date,
|
|
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);
|
|
|
|
Response.Headers["X-Report-Status"] = status;
|
|
var fname = $"report_{templateId}_{date:yyyyMMdd}.xlsx";
|
|
return File(xlsx, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fname);
|
|
}
|
|
}
|