Files
HC900-Crawler/src/Hc900Crawler/Controllers/ReportController.cs
windpacer 27586baf07 feat(report): 연계운전 통합 폐합 메트릭 mass_balance_closure_linked
이송 배관으로 묶인 두 컬럼(C-9111↔C-10111)을 하나의 envelope로 보는 통합 물질수지.
한쪽 feed≈0(인출/이송)인 연계운전일엔 단독 폐합이 수학적으로 N/A지만, 양 컬럼의
외부 feed 합 / 최종 출력 합으로 보면 닫힌다. 내부 이송 스트림은 어느 컬럼 계량에도
안 잡혀(비계량) envelope에서 자동 상쇄.

- Report:Transfer로 파트너 해석(없으면 미정의 → 단독컬럼엔 error). C-9111↔C-10111만 설정.
- cleaning 마스크 미적용(연계 시 feed≈0이 정상 → drawdown 마스크 쓰면 전구간 제외).
- summary는 TransferPartner 있는 컬럼에만 추가, 웹에 통합 폐합 신뢰블록, 토큰 치트시트.

검증(2026-05-15 C-9111): IN 23,975.9 / OUT 23,923.8 / 폐합 99.78% (수동검증 일치).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:18:03 +09:00

211 lines
11 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 metricNames = new List<string>(SUMMARY_METRICS);
if (_map.TransferPartner(column) is not null) // 이송 연계 컬럼(C-9111↔C-10111)만 통합 폐합 추가
metricNames.Add("mass_balance_closure_linked");
var results = new List<MetricResultDto>();
foreach (var m in metricNames)
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>저장된 템플릿 목록(관리 UI) — 생성이력 집계 포함.</summary>
[HttpGet("templates")]
public async Task<IActionResult> Templates(CancellationToken ct)
=> Ok(await _store.ListAsync(ct));
/// <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>템플릿 삭제(생성이력 report_run은 CASCADE).</summary>
[HttpDelete("template/{id:int}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
=> await _store.DeleteAsync(id, ct) ? Ok(new { Deleted = id }) : NotFound(new { Error = $"템플릿 {id} 없음" });
/// <summary>템플릿 생성 이력(다운로드 UI). 최신순.</summary>
[HttpGet("template/{id:int}/runs")]
public async Task<IActionResult> Runs(int id, int limit = 30, CancellationToken ct = default)
=> Ok(await _store.ListRunsAsync(id, limit, ct));
/// <summary>과거 생성본(report_run.out_blob) 다운로드.</summary>
[HttpGet("run/{runId:long}/download")]
public async Task<IActionResult> DownloadRun(long runId, CancellationToken ct)
{
var blob = await _store.GetRunBlobAsync(runId, ct);
if (blob == null) return NotFound(new { Error = $"생성본 {runId} 없음(또는 blob 미보관)" });
return File(blob, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", $"report_run_{runId}.xlsx");
}
public sealed class ScheduleDto
{ public bool Enabled { get; set; } public string Period { get; set; } = "DAILY"; public int Hour { get; set; } = 1; public string Source { get; set; } = "history_table"; }
/// <summary>템플릿 자동생성 스케줄 설정.</summary>
[HttpPut("template/{id:int}/schedule")]
public async Task<IActionResult> Schedule(int id, [FromBody] ScheduleDto dto, CancellationToken ct)
{
var period = (dto.Period ?? "DAILY").Trim().ToUpperInvariant();
if (period != "DAILY" && period != "MONTHLY" && period != "YEARLY")
return BadRequest(new { Error = $"period는 DAILY|MONTHLY|YEARLY (받음: {dto.Period})" });
if (dto.Hour < 0 || dto.Hour > 23) return BadRequest(new { Error = "hour는 0~23" });
var ok = await _store.UpdateScheduleAsync(id, dto.Enabled, period, dto.Hour, dto.Source ?? "history_table", ct);
return ok ? Ok(new { Updated = id }) : NotFound(new { Error = $"템플릿 {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);
}
}