Files
HC900-Crawler/src/Hc900Crawler/Controllers/ReportController.cs
windpacer 5f2780186e feat(report): P2-A 토큰 파서 일반화 — period=DAILY|MONTHLY|YEARLY 윈도 분기
- 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>
2026-06-17 21:07:03 +09:00

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);
}
}