P2 셀프서비스 리포트 — 기간 일반화·템플릿 관리·자동생성 스케줄·EU레인지 클린범위 #2
@@ -119,5 +119,19 @@ CREATE TABLE hc900.live_kpi (
|
||||
3. 합성 cleaning/drawdown 주입 시 상태 전이 + 알람 발화.
|
||||
4. 누적기 강제 재기동 → 버퍼 리플레이로 상태 동일 복구.
|
||||
|
||||
## 전환 후 "죽은코드" 점검 (2026-06-17)
|
||||
P1 도입은 **순수 추가형**(2계층을 기존 60초 경로 옆에 붙임, +650/−5)이라 전환으로 버려진 코드가 없음을 확인. 빌드 경고 0건(CS0169/0414/8321 없음). 정리 후보 2건을 추적한 결과:
|
||||
|
||||
| 대상 | 판정 | 근거 |
|
||||
|---|---|---|
|
||||
| `Hc900HistoryService` (60s → `history_table`) | **살아있음** | 메트릭 기본 소스 + 장기저장. UI 드롭다운 기본값 `history (60초)` |
|
||||
| `history_1s` / `Hc900FastHistoryService` | **살아있음** | 카드 스파크라인·dynamics·LiveKpi 기본 소스(JS 하드코딩 `source=history_1s`) |
|
||||
| `history_1min` / `history_1min_src` | **휴면이나 적재 중 — 제거 금지** | UI 미노출·기본 소스 아님이지만 **연속집계 정책이 스케줄대로 적재** → `history_1s` 14일 보존 DROP 전 1분 롤업하는 **유일한 장기보존 경로**. 지우면 큐레이션 태그 >14일 데이터 소실. `SERIES_SOURCES` 허용목록에 있어 `?source=history_1min_src` 직접 API도 유효 |
|
||||
| `scripts/sql/p1_historian.sql` | **중복 참조 SQL (코드 아님) — 보존 결정** | 자동 실행 참조처 0건. 두 서비스가 기동 시 동일 DDL 멱등 적용하므로 런타임 무의존. **삭제해도 런타임 무영향**이나, 신규 DB 부트스트랩/재해복구 시 일괄 적용 참조본으로 유용 → 의도적으로 남김 |
|
||||
|
||||
**결론**: 전환으로 생긴 제거 대상 죽은코드 **없음**. `history_1min`은 죽은 게 아니라 의도된 2계층 장기 tier. `p1_historian.sql`은 멱등 부트스트랩과 중복되지만 운영 참조용으로 유지.
|
||||
|
||||
> **DDL 단일 진실원**: 런타임 스키마는 서비스의 `EnsureSchema`가 멱등 적용한다. `p1_historian.sql`은 그 *사본*(수동/일괄 적용용)이므로, 스키마 변경 시 **서비스 코드와 이 SQL을 함께 갱신**할 것(드리프트 주의).
|
||||
|
||||
---
|
||||
*근거: P0 결정론 마스크/적산이 causal → 온라인 동치. TimescaleDB 하이퍼테이블/보존/연속집계(기존 history_table 동일 인프라), BackgroundService 패턴(Hc900RealtimeService/HistoryService). 관련 메모리: [[qv-cleaning-mask-and-column-links]], [[product-pivot-selfservice-reporting]].*
|
||||
|
||||
@@ -12,11 +12,17 @@ CREATE TABLE IF NOT EXISTS hc900.report_template (
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- P2-C: 템플릿별 자동생성 스케줄 (Hc900ReportScheduleService가 기동 시 동일 ALTER 멱등 적용)
|
||||
ALTER TABLE hc900.report_template ADD COLUMN IF NOT EXISTS schedule_enabled boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE hc900.report_template ADD COLUMN IF NOT EXISTS schedule_period text NOT NULL DEFAULT 'DAILY'; -- DAILY|MONTHLY|YEARLY
|
||||
ALTER TABLE hc900.report_template ADD COLUMN IF NOT EXISTS schedule_hour int NOT NULL DEFAULT 1; -- 기간 완료 후 발화 KST 시각
|
||||
ALTER TABLE hc900.report_template ADD COLUMN IF NOT EXISTS schedule_source text NOT NULL DEFAULT 'history_table';
|
||||
|
||||
-- 생성 이력(감사·재현). 어떤 정의로 어떤 기간을 뽑았는지 박제
|
||||
CREATE TABLE IF NOT EXISTS hc900.report_run (
|
||||
id bigserial PRIMARY KEY,
|
||||
template_id int REFERENCES hc900.report_template(id) ON DELETE CASCADE,
|
||||
period_kind text NOT NULL, -- 'DAILY'
|
||||
period_kind text NOT NULL, -- 'DAILY' | 'MONTHLY' | 'YEARLY'
|
||||
period_date date NOT NULL, -- KST 기준 날짜
|
||||
source_table text NOT NULL, -- 'history_table' | 'fast_record'
|
||||
generated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
@@ -6,10 +6,40 @@ public sealed class MetricRequestDto
|
||||
public string Column { get; set; } = "C-6111";
|
||||
public string Metric { get; set; } = ""; // energy_efficiency | yield | control_residual
|
||||
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일 때
|
||||
}
|
||||
|
||||
/// <summary>템플릿 목록 항목(관리 UI). 생성이력 집계 포함.</summary>
|
||||
public sealed class ReportTemplateInfo
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string? Owner { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public int RunCount { get; set; } // 이 템플릿으로 생성한 리포트 수
|
||||
public DateTime? LastRunAt { get; set; } // 마지막 생성 시각(없으면 null)
|
||||
|
||||
// ── 스케줄(템플릿별) ──
|
||||
public bool ScheduleEnabled { get; set; } // 자동생성 on/off
|
||||
public string SchedulePeriod { get; set; } = "DAILY"; // DAILY|MONTHLY|YEARLY — 완료 기간 단위로 anchor 선택
|
||||
public int ScheduleHour { get; set; } = 1; // 기간 완료 후 발화할 KST 시각[0-23]
|
||||
public string ScheduleSource { get; set; } = "history_table"; // 메트릭 소스
|
||||
}
|
||||
|
||||
/// <summary>생성 이력 1건(다운로드 UI). out_blob 보유 여부로 다운로드 가능 판정.</summary>
|
||||
public sealed class ReportRunInfo
|
||||
{
|
||||
public long RunId { get; set; }
|
||||
public string PeriodKind { get; set; } = "";
|
||||
public DateTime PeriodDate { get; set; }
|
||||
public string Source { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
public DateTime GeneratedAt { get; set; }
|
||||
public bool HasBlob { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>결정론 메트릭 1건 결과 + 해상도 메타(필수, 사과-오렌지 방지).
|
||||
/// 프로퍼티로 선언해야 System.Text.Json이 직렬화함(필드는 기본 미직렬화).</summary>
|
||||
public sealed class MetricResultDto
|
||||
@@ -23,6 +53,7 @@ public sealed class MetricResultDto
|
||||
|
||||
// ── 해상도-인지 메타 ──
|
||||
public string Source { get; set; } = ""; // history_table | fast_record
|
||||
public string Period { get; set; } = "DAILY"; // DAILY | MONTHLY | YEARLY (집계 윈도)
|
||||
public int SamplingMs { get; set; } // 60000(history) | fast_session.sampling_ms
|
||||
public int N { get; set; } // 클린 후 표본수
|
||||
public double CleanedFraction { get; set; } // 제거된 비율(0~1)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Hc900Crawler.Core.Application.DTOs;
|
||||
|
||||
namespace Hc900Crawler.Core.Application.Interfaces;
|
||||
|
||||
/// <summary>report_template / report_run CRUD (raw SQL).</summary>
|
||||
@@ -8,4 +10,10 @@ public interface IReportTemplateStore
|
||||
Task RecordRunAsync(int templateId, string periodKind, DateTime periodDate,
|
||||
string sourceTable, string status, object cells, byte[] outBlob,
|
||||
CancellationToken ct = default);
|
||||
Task<IReadOnlyList<ReportTemplateInfo>> ListAsync(CancellationToken ct = default);
|
||||
Task<bool> DeleteAsync(int templateId, CancellationToken ct = default); // 존재 시 true (report_run은 CASCADE)
|
||||
Task<bool> UpdateScheduleAsync(int templateId, bool enabled, string period, int hour, string source, CancellationToken ct = default);
|
||||
Task<bool> HasSuccessfulRunAsync(int templateId, string periodKind, DateTime periodDate, CancellationToken ct = default); // ok|partial 존재
|
||||
Task<IReadOnlyList<ReportRunInfo>> ListRunsAsync(int templateId, int limit, CancellationToken ct = default);
|
||||
Task<byte[]?> GetRunBlobAsync(long runId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -125,19 +125,24 @@ GROUP BY b ORDER BY b";
|
||||
/// <summary>웹에서 바로 보기 — 한 컬럼·날짜의 전 메트릭을 한 번에.</summary>
|
||||
[HttpGet("summary")]
|
||||
public async Task<IActionResult> Summary(string column = "C-6111", DateTime? date = null,
|
||||
string source = "history_table", int? sessionId = null, CancellationToken ct = default)
|
||||
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,
|
||||
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, Metrics = results });
|
||||
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,
|
||||
@@ -150,6 +155,40 @@ GROUP BY b ORDER BY b";
|
||||
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,
|
||||
|
||||
@@ -174,11 +174,14 @@ builder.Services.AddCors(opt =>
|
||||
builder.WebHost.UseUrls("http://0.0.0.0:5000");
|
||||
|
||||
// ── P0 셀프서비스 리포트 ──────────────────────────────────────────────────────
|
||||
builder.Services.AddSingleton<Hc900Crawler.Infrastructure.Reporting.EuRangeProvider>();
|
||||
builder.Services.AddSingleton<Hc900Crawler.Infrastructure.Reporting.ReportColumnMap>();
|
||||
// P1a: 1초 링버퍼 히스토리안 (history_1s, 보존정책으로 디스크 상한 고정)
|
||||
builder.Services.AddHostedService<Hc900Crawler.Infrastructure.Hc900.Hc900FastHistoryService>();
|
||||
// P1c: 온라인 KPI 누적기 (history_1s → live_kpi)
|
||||
builder.Services.AddHostedService<Hc900Crawler.Infrastructure.Hc900.Hc900LiveKpiService>();
|
||||
// P2-C: 템플릿별 리포트 자동생성 스케줄러 (멱등 catchup)
|
||||
builder.Services.AddHostedService<Hc900Crawler.Infrastructure.Hc900.Hc900ReportScheduleService>();
|
||||
builder.Services.AddScoped<Hc900Crawler.Core.Application.Interfaces.IReportMetricService,
|
||||
Hc900Crawler.Infrastructure.Reporting.ReportMetricService>();
|
||||
builder.Services.AddScoped<Hc900Crawler.Infrastructure.Reporting.ReportFillService>();
|
||||
|
||||
@@ -107,6 +107,13 @@
|
||||
"ClosureTolerancePct": 2.0,
|
||||
"AlertDebounceSec": 60
|
||||
},
|
||||
"Schedule": {
|
||||
"Enabled": true,
|
||||
"CheckIntervalSeconds": 300,
|
||||
"CatchupDays": 3,
|
||||
"CatchupMonths": 1,
|
||||
"CatchupYears": 1
|
||||
},
|
||||
"Cleaning": {
|
||||
"VacMax": 300,
|
||||
"ProductMin": 10,
|
||||
|
||||
@@ -25,7 +25,7 @@ paneInit['reports'] = function () {
|
||||
if (go) go.onclick = async () => {
|
||||
out.innerHTML = '<span class="mono">⏳ 조회 중...</span>';
|
||||
try {
|
||||
let url = `/api/report/summary?column=${encodeURIComponent(col.value)}&date=${date.value}&source=${$('rvSource').value}`;
|
||||
let url = `/api/report/summary?column=${encodeURIComponent(col.value)}&date=${date.value}&source=${$('rvSource').value}&period=${$('rvPeriod').value}`;
|
||||
if ($('rvSource').value === 'fast_record' && $('rvSess').value) url += `&sessionId=${$('rvSess').value}`;
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error('조회 실패 ' + r.status);
|
||||
@@ -33,25 +33,123 @@ paneInit['reports'] = function () {
|
||||
} catch (e) { out.innerHTML = `<span class="mono" style="color:#e66">❌ ${typeof esc === 'function' ? esc(e.message) : e.message}</span>`; }
|
||||
};
|
||||
|
||||
// ── ② 엑셀 export ──
|
||||
const tpl = $('rpTpl'), gen = $('rpGen'), status = $('rpStatus');
|
||||
// ── ② 엑셀 export (템플릿 등록 → 목록 선택 → 생성) ──
|
||||
const status = $('rpStatus');
|
||||
const hesc = (s) => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
if ($('rpDate') && !$('rpDate').value) $('rpDate').value = yKst();
|
||||
|
||||
// 선택된 템플릿의 스케줄 값을 편집기에 채움
|
||||
let templates = [];
|
||||
function fillSchedForm() {
|
||||
const t = templates.find(x => String(x.Id) === $('rpSelId').value);
|
||||
if (!t || !$('schEnabled')) return;
|
||||
$('schEnabled').checked = !!t.ScheduleEnabled;
|
||||
$('schPeriod').value = t.SchedulePeriod || 'DAILY';
|
||||
$('schHour').value = (t.ScheduleHour ?? 1);
|
||||
$('schSource').value = t.ScheduleSource || 'history_table';
|
||||
}
|
||||
|
||||
// 선택 템플릿의 생성 이력(과거본 다운로드)
|
||||
async function loadRuns() {
|
||||
const box = $('rpRuns'); if (!box) return;
|
||||
const id = $('rpSelId').value;
|
||||
if (!id) { box.textContent = '템플릿을 선택하세요.'; $('rpRunsCount').textContent = ''; return; }
|
||||
try {
|
||||
const runs = await fetch(`/api/report/template/${id}/runs`).then(r => r.json());
|
||||
$('rpRunsCount').textContent = `(${runs.length})`;
|
||||
if (!runs.length) { box.innerHTML = '<span style="color:var(--t2)">생성 이력이 없습니다.</span>'; return; }
|
||||
box.innerHTML = runs.map(r => {
|
||||
const dt = new Date(r.GeneratedAt).toLocaleString();
|
||||
const pd = String(r.PeriodDate || '').slice(0, 10);
|
||||
const st = r.Status === 'ok' ? '✅' : r.Status === 'partial' ? '⚠️' : '❌';
|
||||
const dl = r.HasBlob ? `<a href="/api/report/run/${r.RunId}/download">⬇ 다운로드</a>` : '<span style="color:var(--t2)">blob없음</span>';
|
||||
return `<div style="display:flex;gap:8px;padding:2px 0"><span style="flex:1">${st} ${r.PeriodKind} ${pd} · ${hesc(r.Source)} · ${dt}</span>${dl}</div>`;
|
||||
}).join('');
|
||||
} catch (e) { box.innerHTML = `<span style="color:#e66">이력 로드 실패: ${hesc(e.message)}</span>`; }
|
||||
}
|
||||
const onSel = () => { fillSchedForm(); loadRuns(); };
|
||||
|
||||
// 저장된 템플릿 목록 로드 + 선택/삭제 와이어링 (멱등)
|
||||
async function loadTemplates() {
|
||||
const box = $('rpTplList'); if (!box) return;
|
||||
try {
|
||||
templates = await fetch('/api/report/templates').then(r => r.json());
|
||||
$('rpTplCount').textContent = `(${templates.length})`;
|
||||
const sel = $('rpSelId');
|
||||
if (!templates.length) { box.innerHTML = '<span style="color:var(--t2)">등록된 템플릿이 없습니다 — 위에서 .xlsx를 등록하세요.</span>'; sel.value = ''; onSel(); return; }
|
||||
const keep = templates.some(t => String(t.Id) === sel.value) ? sel.value : String(templates[0].Id);
|
||||
sel.value = keep;
|
||||
box.innerHTML = templates.map(t => {
|
||||
const last = t.LastRunAt ? new Date(t.LastRunAt).toLocaleString() : '미생성';
|
||||
const sched = t.ScheduleEnabled
|
||||
? `<span style="color:var(--ok,#3a3)">⏰ ${t.SchedulePeriod}@${t.ScheduleHour}h·${hesc(t.ScheduleSource)}</span>`
|
||||
: '<span style="color:var(--t2)">수동</span>';
|
||||
return `<label style="display:flex;align-items:center;gap:8px;padding:3px 0">
|
||||
<input type="radio" name="rpTplPick" value="${t.Id}" ${String(t.Id) === keep ? 'checked' : ''}>
|
||||
<span style="flex:1">#${t.Id} <strong>${hesc(t.Name)}</strong> · 생성 ${t.RunCount}회 · 최근 ${hesc(last)} · ${sched}</span>
|
||||
<button class="btn" data-del="${t.Id}" style="padding:2px 8px">삭제</button></label>`;
|
||||
}).join('');
|
||||
box.querySelectorAll('input[name=rpTplPick]').forEach(r => r.onchange = () => { sel.value = r.value; onSel(); });
|
||||
box.querySelectorAll('button[data-del]').forEach(b => b.onclick = async () => {
|
||||
if (!confirm(`템플릿 #${b.dataset.del} 삭제? (생성이력도 함께 삭제됩니다)`)) return;
|
||||
const r = await fetch(`/api/report/template/${b.dataset.del}`, { method: 'DELETE' });
|
||||
status.textContent = r.ok ? `🗑️ #${b.dataset.del} 삭제됨` : `❌ 삭제 실패 ${r.status}`;
|
||||
loadTemplates();
|
||||
});
|
||||
onSel();
|
||||
} catch (e) { box.innerHTML = `<span style="color:#e66">목록 로드 실패: ${hesc(e.message)}</span>`; }
|
||||
}
|
||||
loadTemplates();
|
||||
|
||||
// 스케줄 저장
|
||||
const schSave = $('schSave');
|
||||
if (schSave) schSave.onclick = async () => {
|
||||
const id = $('rpSelId').value;
|
||||
if (!id) { status.textContent = '⚠️ 템플릿을 선택하세요.'; return; }
|
||||
schSave.disabled = true;
|
||||
try {
|
||||
const body = { Enabled: $('schEnabled').checked, Period: $('schPeriod').value, Hour: +$('schHour').value, Source: $('schSource').value };
|
||||
const r = await fetch(`/api/report/template/${id}/schedule`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).Error || ('HTTP ' + r.status));
|
||||
status.textContent = `✅ #${id} 스케줄 저장됨`;
|
||||
await loadTemplates();
|
||||
} catch (e) { status.textContent = '❌ ' + e.message; } finally { schSave.disabled = false; }
|
||||
};
|
||||
|
||||
// 등록
|
||||
const up = $('rpUpload');
|
||||
if (up) up.onclick = async () => {
|
||||
const f = $('rpTpl').files[0];
|
||||
if (!f) { status.textContent = '⚠️ .xlsx 파일을 선택하세요.'; return; }
|
||||
up.disabled = true; status.textContent = '⏳ 등록 중...';
|
||||
try {
|
||||
const fd = new FormData(); fd.append('file', f); fd.append('name', $('rpTplName').value || f.name);
|
||||
const r = await fetch('/api/report/template', { method: 'POST', body: fd });
|
||||
if (!r.ok) throw new Error('등록 실패 ' + r.status);
|
||||
$('rpTpl').value = ''; $('rpTplName').value = '';
|
||||
status.textContent = '✅ 등록됨';
|
||||
await loadTemplates();
|
||||
} catch (e) { status.textContent = '❌ ' + e.message; } finally { up.disabled = false; }
|
||||
};
|
||||
|
||||
// 생성 (선택된 저장 템플릿)
|
||||
const gen = $('rpGen');
|
||||
if (gen) gen.onclick = async () => {
|
||||
if (!tpl.files[0]) { status.textContent = '⚠️ 엑셀 템플릿을 선택하세요.'; return; }
|
||||
const id = $('rpSelId').value;
|
||||
if (!id) { status.textContent = '⚠️ 생성할 템플릿을 선택하세요.'; return; }
|
||||
gen.disabled = true; status.textContent = '⏳ 생성 중...';
|
||||
try {
|
||||
const fd = new FormData(); fd.append('file', tpl.files[0]); fd.append('name', tpl.files[0].name);
|
||||
const up = await fetch('/api/report/template', { method: 'POST', body: fd });
|
||||
if (!up.ok) throw new Error('업로드 실패 ' + up.status);
|
||||
const { Id } = await up.json();
|
||||
let url = `/api/report/generate?templateId=${Id}&date=${$('rpDate').value}&source=${$('rpSource').value}`;
|
||||
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_${$('rpDate').value}.xlsx`; a.click(); URL.revokeObjectURL(a.href);
|
||||
a.download = `report_${id}_${$('rpDate').value}.xlsx`; a.click(); URL.revokeObjectURL(a.href);
|
||||
status.textContent = `✅ 완료 (상태=${st})`;
|
||||
loadTemplates(); // 생성횟수·최근시각 갱신
|
||||
} catch (e) { status.textContent = '❌ ' + e.message; } finally { gen.disabled = false; }
|
||||
};
|
||||
};
|
||||
@@ -73,7 +171,8 @@ function rpVal(m, kind) { return m.Status === 'ok' && m.Value != null ? rpFmt(m.
|
||||
function renderSummary(d) {
|
||||
const by = {}; (d.Metrics || []).forEach(m => by[m.Metric] = m);
|
||||
const cl = by['mass_balance_closure'];
|
||||
let html = `<div class="mono" style="margin-bottom:10px;color:var(--t2)">${d.Column} · ${d.Date} · ${d.Source}</div>`;
|
||||
const pTxt = { DAILY: '일', MONTHLY: '월', YEARLY: '연' }[d.Period] || d.Period || '일';
|
||||
let html = `<div class="mono" style="margin-bottom:10px;color:var(--t2)">${d.Column} · ${d.Date} · ${pTxt} · ${d.Source}</div>`;
|
||||
|
||||
// 물질수지 신뢰블록
|
||||
if (cl) {
|
||||
|
||||
@@ -20,6 +20,13 @@
|
||||
<div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap">
|
||||
<label>컬럼<br><select id="rvCol" style="margin-top:4px"></select></label>
|
||||
<label>날짜(KST)<br><input type="date" id="rvDate" style="margin-top:4px"></label>
|
||||
<label>기간<br>
|
||||
<select id="rvPeriod" style="margin-top:4px">
|
||||
<option value="DAILY">일</option>
|
||||
<option value="MONTHLY">월</option>
|
||||
<option value="YEARLY">연</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>소스<br>
|
||||
<select id="rvSource" style="margin-top:4px">
|
||||
<option value="history_table">history (60초)</option>
|
||||
@@ -38,8 +45,42 @@
|
||||
<p style="color:var(--t2);font-size:13px;margin-top:0">
|
||||
엑셀 템플릿 셀에 <code>{{ metric=mass_balance_closure; column=C-6111 }}</code> 형태 토큰을 박아두면 선택 날짜 값으로 채워 다운로드합니다.
|
||||
</p>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;max-width:560px">
|
||||
<!-- ⓐ 템플릿 등록 -->
|
||||
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;max-width:640px">
|
||||
<label>① 엑셀 템플릿(.xlsx)<input type="file" id="rpTpl" accept=".xlsx,.xlsm" style="display:block;margin-top:4px"></label>
|
||||
<label>이름(선택)<input type="text" id="rpTplName" placeholder="미입력=파일명" style="display:block;margin-top:4px"></label>
|
||||
<button id="rpUpload" class="btn">등록</button>
|
||||
</div>
|
||||
|
||||
<!-- ⓑ 저장된 템플릿 목록 (선택) -->
|
||||
<div style="margin-top:14px">
|
||||
<div style="font-size:13px;color:var(--t2);margin-bottom:6px">저장된 템플릿 <span id="rpTplCount"></span></div>
|
||||
<div id="rpTplList" class="mono" style="font-size:13px">로딩…</div>
|
||||
<input type="hidden" id="rpSelId">
|
||||
</div>
|
||||
|
||||
<!-- ⓑ' 선택 템플릿 자동생성 스케줄 -->
|
||||
<div id="rpSchedWrap" style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;max-width:680px;margin-top:10px;font-size:13px">
|
||||
<label><input type="checkbox" id="schEnabled"> 자동생성</label>
|
||||
<label>기간<br>
|
||||
<select id="schPeriod" style="margin-top:4px">
|
||||
<option value="DAILY">일(전일)</option>
|
||||
<option value="MONTHLY">월(전월)</option>
|
||||
<option value="YEARLY">연(전년)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>발화 KST시각<br><input type="number" id="schHour" min="0" max="23" value="1" style="margin-top:4px;width:70px"></label>
|
||||
<label>소스<br>
|
||||
<select id="schSource" style="margin-top:4px">
|
||||
<option value="history_table">history (60초)</option>
|
||||
<option value="history_1min_src">history (1분)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="schSave" class="btn">스케줄 저장</button>
|
||||
</div>
|
||||
|
||||
<!-- ⓒ 생성 -->
|
||||
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;max-width:640px;margin-top:14px;border-top:1px dashed var(--bd,#333);padding-top:14px">
|
||||
<label>② 날짜(KST)<input type="date" id="rpDate" style="display:block;margin-top:4px"></label>
|
||||
<label>③ 소스
|
||||
<select id="rpSource" style="display:block;margin-top:4px">
|
||||
@@ -48,10 +89,16 @@
|
||||
</select>
|
||||
</label>
|
||||
<label id="rpSessionWrap" style="display:none">session id<input type="number" id="rpSession" style="display:block;margin-top:4px"></label>
|
||||
<button id="rpGen" class="btn" style="align-self:flex-start">리포트 생성·다운로드</button>
|
||||
<button id="rpGen" class="btn">선택 템플릿 생성·다운로드</button>
|
||||
</div>
|
||||
<div id="rpStatus" class="mono" style="margin-top:12px;white-space:pre-wrap"></div>
|
||||
|
||||
<!-- ⓓ 선택 템플릿 생성 이력 (과거본 다운로드) -->
|
||||
<details id="rpRunsWrap" style="margin-top:14px">
|
||||
<summary style="cursor:pointer;color:var(--t2)">생성 이력 (과거본 다운로드) <span id="rpRunsCount"></span></summary>
|
||||
<div id="rpRuns" class="mono" style="font-size:12px;margin-top:8px">템플릿을 선택하세요.</div>
|
||||
</details>
|
||||
|
||||
<details style="margin-top:14px">
|
||||
<summary style="cursor:pointer;color:var(--t2)">토큰 치트시트</summary>
|
||||
<pre style="font-size:12px;background:var(--bg2,#1a1a1a);padding:10px;border-radius:6px">
|
||||
@@ -67,6 +114,10 @@
|
||||
■ PV 기반 (근사)
|
||||
{{ metric=control_residual; column=C-6111 }} → 평균 잔차(PV-SP)
|
||||
{{ metric=control_residual; column=C-6111; field=sd }} → 잔차 표준편차
|
||||
|
||||
■ 집계 기간 (period, 미지정=DAILY) — 선택 날짜가 속한 일/월/연 전체
|
||||
{{ metric=production_total; column=C-6111; period=MONTHLY }} → 그 달 생산량 적산
|
||||
{{ metric=production_total; column=C-6111; period=YEARLY }} → 그 해 생산량 적산
|
||||
</pre>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
131
src/Infrastructure/Hc900/Hc900ReportScheduleService.cs
Normal file
131
src/Infrastructure/Hc900/Hc900ReportScheduleService.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using System.Data;
|
||||
using Hc900Crawler.Core.Application.Interfaces;
|
||||
using Hc900Crawler.Infrastructure.Database;
|
||||
using Hc900Crawler.Infrastructure.Reporting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Hc900Crawler.Infrastructure.Hc900;
|
||||
|
||||
/// <summary>
|
||||
/// P2-C 템플릿별 리포트 자동생성. 매 N초 활성 템플릿마다 "완료됐어야 할 기간(anchor)"을
|
||||
/// lookback 내에서 산출 → report_run에 성공기록 없으면 생성. 멱등(중복 0)·다운타임 자동복구.
|
||||
/// 기간 윈도는 셀 토큰(period=)이 결정; 스케줄은 anchor 선택 + 발화 타이밍만 담당.
|
||||
/// 60초 Hc900HistoryService 패턴.
|
||||
/// </summary>
|
||||
public class Hc900ReportScheduleService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<Hc900ReportScheduleService> _logger;
|
||||
private readonly bool _enabled;
|
||||
private readonly int _intervalSec;
|
||||
private readonly int _catchupDays, _catchupMonths, _catchupYears;
|
||||
|
||||
public Hc900ReportScheduleService(IServiceScopeFactory scopeFactory,
|
||||
ILogger<Hc900ReportScheduleService> logger, IConfiguration config)
|
||||
{
|
||||
_scopeFactory = scopeFactory; _logger = logger;
|
||||
_enabled = config.GetValue("Report:Schedule:Enabled", true);
|
||||
_intervalSec = Math.Max(30, config.GetValue("Report:Schedule:CheckIntervalSeconds", 300));
|
||||
_catchupDays = Math.Max(1, config.GetValue("Report:Schedule:CatchupDays", 3));
|
||||
_catchupMonths = Math.Max(1, config.GetValue("Report:Schedule:CatchupMonths", 1));
|
||||
_catchupYears = Math.Max(1, config.GetValue("Report:Schedule:CatchupYears", 1));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!_enabled) { _logger.LogInformation("[ReportSchedule] 비활성"); return; }
|
||||
try { await EnsureSchemaAsync(stoppingToken); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[ReportSchedule] 스케줄 컬럼 준비 실패 — 서비스 중단"); return; }
|
||||
_logger.LogInformation("[ReportSchedule] 시작 — 간격 {Int}s, catchup {D}d/{M}m/{Y}y", _intervalSec, _catchupDays, _catchupMonths, _catchupYears);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(_intervalSec), stoppingToken);
|
||||
await TickAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[ReportSchedule] tick 실패"); }
|
||||
}
|
||||
_logger.LogInformation("[ReportSchedule] 종료");
|
||||
}
|
||||
|
||||
private async Task TickAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var store = scope.ServiceProvider.GetRequiredService<IReportTemplateStore>();
|
||||
var fill = scope.ServiceProvider.GetRequiredService<ReportFillService>();
|
||||
|
||||
var nowKst = DateTime.UtcNow.AddHours(9);
|
||||
foreach (var t in await store.ListAsync(ct))
|
||||
{
|
||||
if (!t.ScheduleEnabled) continue;
|
||||
var period = string.IsNullOrWhiteSpace(t.SchedulePeriod) ? "DAILY" : t.SchedulePeriod.Trim().ToUpperInvariant();
|
||||
|
||||
foreach (var anchor in DueAnchors(period, nowKst, t.ScheduleHour))
|
||||
{
|
||||
if (await store.HasSuccessfulRunAsync(t.Id, period, anchor, ct)) continue; // 이미 생성됨
|
||||
|
||||
var blob = await store.GetBlobAsync(t.Id, ct);
|
||||
if (blob == null) { _logger.LogWarning("[ReportSchedule] #{Id} blob 없음 — 스킵", t.Id); continue; }
|
||||
|
||||
var (xlsx, cells, status) = await fill.FillAsync(blob, anchor, t.ScheduleSource, null, ct);
|
||||
await store.RecordRunAsync(t.Id, period, anchor, t.ScheduleSource, status, cells, xlsx, ct);
|
||||
_logger.LogInformation("[ReportSchedule] #{Id} {Period} {Date:yyyy-MM-dd} 생성 — {Status}", t.Id, period, anchor, status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>완료됐고 발화시각(KST hour)이 지난 기간들의 anchor 날짜(KST). lookback 만큼만, 멱등 dedup은 호출측.</summary>
|
||||
private IEnumerable<DateTime> DueAnchors(string period, DateTime nowKst, int hour)
|
||||
{
|
||||
hour = Math.Clamp(hour, 0, 23);
|
||||
if (period == "MONTHLY")
|
||||
{
|
||||
var thisMonth = new DateTime(nowKst.Year, nowKst.Month, 1);
|
||||
for (int i = 1; i <= _catchupMonths; i++)
|
||||
{
|
||||
var anchor = thisMonth.AddMonths(-i); // 완료된 달의 1일
|
||||
if (nowKst >= anchor.AddMonths(1).AddHours(hour)) yield return anchor;
|
||||
}
|
||||
}
|
||||
else if (period == "YEARLY")
|
||||
{
|
||||
var thisYear = new DateTime(nowKst.Year, 1, 1);
|
||||
for (int i = 1; i <= _catchupYears; i++)
|
||||
{
|
||||
var anchor = thisYear.AddYears(-i); // 완료된 해의 1/1
|
||||
if (nowKst >= anchor.AddYears(1).AddHours(hour)) yield return anchor;
|
||||
}
|
||||
}
|
||||
else // DAILY
|
||||
{
|
||||
var today = nowKst.Date;
|
||||
for (int i = 1; i <= _catchupDays; i++)
|
||||
{
|
||||
var anchor = today.AddDays(-i); // 완료된 날
|
||||
if (nowKst >= anchor.AddDays(1).AddHours(hour)) yield return anchor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>스케줄 컬럼 멱등 추가(기존 DB 호환).</summary>
|
||||
private async Task EnsureSchemaAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var conn = scope.ServiceProvider.GetRequiredService<Hc900DbContext>().Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
ALTER TABLE hc900.report_template ADD COLUMN IF NOT EXISTS schedule_enabled boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE hc900.report_template ADD COLUMN IF NOT EXISTS schedule_period text NOT NULL DEFAULT 'DAILY';
|
||||
ALTER TABLE hc900.report_template ADD COLUMN IF NOT EXISTS schedule_hour int NOT NULL DEFAULT 1;
|
||||
ALTER TABLE hc900.report_template ADD COLUMN IF NOT EXISTS schedule_source text NOT NULL DEFAULT 'history_table';";
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
}
|
||||
69
src/Infrastructure/Reporting/EuRangeProvider.cs
Normal file
69
src/Infrastructure/Reporting/EuRangeProvider.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using Hc900Crawler.Infrastructure.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Hc900Crawler.Infrastructure.Reporting;
|
||||
|
||||
/// <summary>
|
||||
/// tag_metadata(EAV)의 eulo/euhi(계기 EU레인지)를 base_tag별로 1회 로드·캐시.
|
||||
/// 메트릭 클린범위(스파이크/드롭아웃 = 계기 물리범위 밖) 자동도출용. 없으면 호출측이 역할 기본값으로 폴백.
|
||||
/// 운전모드 제외는 별개(cleaning 마스크); 여기선 물리적 유효범위만.
|
||||
/// </summary>
|
||||
public sealed class EuRangeProvider
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<EuRangeProvider> _logger;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private volatile Dictionary<string, (double Lo, double Hi)>? _cache;
|
||||
|
||||
public EuRangeProvider(IServiceScopeFactory scopeFactory, ILogger<EuRangeProvider> logger)
|
||||
{ _scopeFactory = scopeFactory; _logger = logger; }
|
||||
|
||||
/// <summary>최초 1회 tag_metadata에서 eulo/euhi 로드(멱등, thread-safe). 실패 시 빈 캐시(전부 폴백).</summary>
|
||||
public async Task EnsureLoadedAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_cache != null) return;
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_cache != null) return;
|
||||
var map = new Dictionary<string, (double, double)>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var conn = scope.ServiceProvider.GetRequiredService<Hc900DbContext>().Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT base_tag, attribute, value FROM hc900.tag_metadata WHERE attribute IN ('eulo','euhi')";
|
||||
var lo = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
var hi = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await rd.ReadAsync(ct))
|
||||
{
|
||||
if (rd.IsDBNull(2)) continue;
|
||||
var tag = rd.GetString(0); var attr = rd.GetString(1);
|
||||
if (!double.TryParse(rd.GetString(2), NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) continue;
|
||||
(attr == "eulo" ? lo : hi)[tag] = v;
|
||||
}
|
||||
foreach (var t in lo.Keys)
|
||||
if (hi.TryGetValue(t, out var h) && h > lo[t]) map[t] = (lo[t], h); // 유효 범위만
|
||||
_logger.LogInformation("[EuRange] {N}개 태그 EU레인지 로드", map.Count);
|
||||
}
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "[EuRange] 로드 실패 — 역할 기본값 폴백"); }
|
||||
_cache = map;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>base_tag(속성 무관) EU레인지. 미로드/미존재면 false → 호출측 폴백.</summary>
|
||||
public bool TryGet(string baseTag, out double lo, out double hi)
|
||||
{
|
||||
lo = hi = 0;
|
||||
var c = _cache;
|
||||
if (c != null && c.TryGetValue(baseTag, out var r)) { lo = r.Lo; hi = r.Hi; return true; }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -16,18 +16,26 @@ public sealed record CleaningSpec(string? VacTag, double VacMax, string ProductT
|
||||
|
||||
/// <summary>
|
||||
/// 컬럼→태그 매핑. 기존 appsettings `SteamAdvisor:Columns`(Feed/Product/TC/SteamOp/SteamFlow)를
|
||||
/// 단일 진실원으로 재사용한다(멀티컬럼 무료). 클린범위는 역할별 기본값(향후 tag_metadata EU레인지로 대체 P2).
|
||||
/// 단일 진실원으로 재사용한다(멀티컬럼 무료). 클린범위(스파이크/드롭아웃 경계)는 tag_metadata
|
||||
/// EU레인지(eulo/euhi)를 우선 사용하고, 메타 없으면 역할별 기본값으로 폴백한다.
|
||||
/// </summary>
|
||||
public sealed class ReportColumnMap
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
public ReportColumnMap(IConfiguration config) => _config = config;
|
||||
private readonly EuRangeProvider _eu;
|
||||
public ReportColumnMap(IConfiguration config, EuRangeProvider eu) { _config = config; _eu = eu; }
|
||||
|
||||
// 역할별 기본 클린범위 (오늘 검증에서 0/드롭아웃/스파이크 제거에 쓴 값)
|
||||
// 역할별 기본 클린범위 — tag_metadata EU레인지 없을 때만 폴백
|
||||
private static readonly (double lo, double hi) TEMP = (60, 95);
|
||||
private static readonly (double lo, double hi) STEAM = (50, 3000);
|
||||
private static readonly (double lo, double hi) FLOW = (100, 1500);
|
||||
|
||||
/// <summary>태그의 클린범위 = EU레인지(있으면) 우선, 없으면 역할 기본값. 캐시는 ComputeAsync에서 선로딩.</summary>
|
||||
private MetricTag Tag(string tag, (double lo, double hi) def)
|
||||
=> _eu.TryGet(StripAttr(tag), out var lo, out var hi)
|
||||
? new MetricTag(tag, lo, hi)
|
||||
: new MetricTag(tag, def.lo, def.hi);
|
||||
|
||||
/// <summary>설정된 컬럼 키 목록(SteamAdvisor:Columns).</summary>
|
||||
public IReadOnlyList<string> Columns()
|
||||
=> _config.GetSection("SteamAdvisor:Columns").GetChildren().Select(c => c.Key).ToList();
|
||||
@@ -111,24 +119,18 @@ public sealed class ReportColumnMap
|
||||
{
|
||||
case "energy_efficiency":
|
||||
if (steam is null || product is null) return false;
|
||||
spec = new MetricSpec("kg스팀/kg제품",
|
||||
new MetricTag(steam, STEAM.lo, STEAM.hi),
|
||||
new MetricTag(product, FLOW.lo, FLOW.hi));
|
||||
spec = new MetricSpec("kg스팀/kg제품", Tag(steam, STEAM), Tag(product, FLOW));
|
||||
return true;
|
||||
|
||||
case "yield":
|
||||
if (product is null || feed is null) return false;
|
||||
spec = new MetricSpec("제품/원료",
|
||||
new MetricTag(product, FLOW.lo, FLOW.hi),
|
||||
new MetricTag(feed, FLOW.lo, FLOW.hi));
|
||||
spec = new MetricSpec("제품/원료", Tag(product, FLOW), Tag(feed, FLOW));
|
||||
return true;
|
||||
|
||||
case "control_residual":
|
||||
if (steamOp is null) return false;
|
||||
var loopBase = StripAttr(steamOp); // TICA-6111A.OP → TICA-6111A
|
||||
spec = new MetricSpec("degC",
|
||||
new MetricTag(loopBase + ".PV", TEMP.lo, TEMP.hi),
|
||||
new MetricTag(loopBase + ".SP", TEMP.lo, TEMP.hi));
|
||||
spec = new MetricSpec("degC", Tag(loopBase + ".PV", TEMP), Tag(loopBase + ".SP", TEMP));
|
||||
return true;
|
||||
|
||||
default:
|
||||
|
||||
@@ -40,6 +40,7 @@ public sealed class ReportFillService
|
||||
Metric = kv.GetValueOrDefault("metric", ""),
|
||||
Column = kv.GetValueOrDefault("column", "C-6111"),
|
||||
PeriodDateKst = periodKst,
|
||||
Period = kv.GetValueOrDefault("period", "DAILY"), // 셀별 일/월/연 윈도(미지정=일)
|
||||
SourceTable = sourceTable,
|
||||
SessionId = sessionId
|
||||
};
|
||||
@@ -50,10 +51,10 @@ public sealed class ReportFillService
|
||||
else { cell.Value = m.Status == "no_data" ? "N/A" : "ERR"; anyErr = true; }
|
||||
|
||||
if (cell.Comment == null)
|
||||
cell.AddComment($"{m.Metric} | src={m.Source} {m.SamplingMs}ms | n={m.N} | keep={1 - m.CleanedFraction:P0} | {m.Unit}"
|
||||
cell.AddComment($"{m.Metric} | {m.Period} | src={m.Source} {m.SamplingMs}ms | n={m.N} | keep={1 - m.CleanedFraction:P0} | {m.Unit}"
|
||||
+ (m.Error != null ? $" | {m.Error}" : ""), "report");
|
||||
|
||||
cells.Add(new { sheet = ws.Name, r, c, m.Metric, m.Column,
|
||||
cells.Add(new { sheet = ws.Name, r, c, m.Metric, m.Column, m.Period,
|
||||
field = kv.GetValueOrDefault("field", ""), value = v, m.Status,
|
||||
m.Source, m.SamplingMs, m.N, m.Unit });
|
||||
}
|
||||
|
||||
@@ -18,10 +18,11 @@ public sealed class ReportMetricService : IReportMetricService
|
||||
private readonly Hc900DbContext _ctx;
|
||||
private readonly ILogger<ReportMetricService> _logger;
|
||||
private readonly ReportColumnMap _map;
|
||||
private readonly EuRangeProvider _eu;
|
||||
private const string NUMERIC = "^-?[0-9]+(\\.[0-9]+)?$";
|
||||
|
||||
public ReportMetricService(Hc900DbContext ctx, ILogger<ReportMetricService> logger, ReportColumnMap map)
|
||||
{ _ctx = ctx; _logger = logger; _map = map; }
|
||||
public ReportMetricService(Hc900DbContext ctx, ILogger<ReportMetricService> logger, ReportColumnMap map, EuRangeProvider eu)
|
||||
{ _ctx = ctx; _logger = logger; _map = map; _eu = eu; }
|
||||
|
||||
private static readonly HashSet<string> QV_METRICS =
|
||||
new() { "production_total", "yield_qv", "energy_intensity_qv", "mass_balance_closure" };
|
||||
@@ -30,26 +31,47 @@ public sealed class ReportMetricService : IReportMetricService
|
||||
private static readonly HashSet<string> SERIES_SOURCES =
|
||||
new() { "history_table", "history_1s", "history_1min_src" };
|
||||
|
||||
private static readonly HashSet<string> PERIODS =
|
||||
new(StringComparer.OrdinalIgnoreCase) { "DAILY", "MONTHLY", "YEARLY" };
|
||||
|
||||
/// <summary>KST 날짜가 속한 일/월/연 경계 [from, to) 를 UTC로. recorded_at(UTC) 비교용.</summary>
|
||||
private static (DateTime fromUtc, DateTime toUtc) PeriodWindowUtc(DateTime dateKst, string period)
|
||||
{
|
||||
var d = dateKst.Date;
|
||||
var (fromKst, toKst) = period switch
|
||||
{
|
||||
"MONTHLY" => (new DateTime(d.Year, d.Month, 1), new DateTime(d.Year, d.Month, 1).AddMonths(1)),
|
||||
"YEARLY" => (new DateTime(d.Year, 1, 1), new DateTime(d.Year, 1, 1).AddYears(1)),
|
||||
_ => (d, d.AddDays(1)),
|
||||
};
|
||||
return (DateTime.SpecifyKind(fromKst, DateTimeKind.Unspecified).AddHours(-9),
|
||||
DateTime.SpecifyKind(toKst, DateTimeKind.Unspecified).AddHours(-9));
|
||||
}
|
||||
|
||||
public async Task<MetricResultDto> ComputeAsync(MetricRequestDto req, CancellationToken ct = default)
|
||||
{
|
||||
bool isFast = req.SourceTable == "fast_record";
|
||||
// history_table(60s) | history_1s(1s 버퍼) | history_1min_src(연속집계) | fast_record. 미지정/미허용→history_table.
|
||||
string tbl = isFast ? "fast_record"
|
||||
: SERIES_SOURCES.Contains(req.SourceTable) ? req.SourceTable : "history_table";
|
||||
string period = string.IsNullOrWhiteSpace(req.Period) ? "DAILY" : req.Period.Trim().ToUpperInvariant();
|
||||
var res = new MetricResultDto
|
||||
{
|
||||
Metric = req.Metric, Column = req.Column,
|
||||
Metric = req.Metric, Column = req.Column, Period = period,
|
||||
Source = tbl, SamplingMs = isFast ? 0 : (tbl == "history_1s" ? 1000 : 60000)
|
||||
};
|
||||
// 결정론 게이트: 오타 period를 daily로 묵인하면 틀린 값 → 명시적 에러.
|
||||
if (!PERIODS.Contains(period))
|
||||
{ res.Status = "error"; res.Error = $"미지원 period: {req.Period} (DAILY|MONTHLY|YEARLY)"; res.Value = null; return res; }
|
||||
|
||||
// KST 날짜 [00:00, +1d) → UTC (recorded_at은 UTC)
|
||||
var fromUtc = DateTime.SpecifyKind(req.PeriodDateKst.Date, DateTimeKind.Unspecified).AddHours(-9);
|
||||
var toUtc = fromUtc.AddDays(1);
|
||||
// PeriodDateKst가 속한 일/월/연 [from, to) KST → UTC (recorded_at은 UTC). fast_record는 session 기준이라 윈도 무관.
|
||||
var (fromUtc, toUtc) = PeriodWindowUtc(req.PeriodDateKst, period);
|
||||
|
||||
try
|
||||
{
|
||||
var conn = _ctx.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
await _eu.EnsureLoadedAsync(ct); // 클린범위 EU레인지 캐시 선로딩(ReportColumnMap이 동기 조회)
|
||||
|
||||
if (req.Metric == "dynamics")
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Text.Json;
|
||||
using Hc900Crawler.Core.Application.DTOs;
|
||||
using Hc900Crawler.Core.Application.Interfaces;
|
||||
using Hc900Crawler.Infrastructure.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -53,6 +54,104 @@ VALUES (@t, @pk, @pd, @src, @st, @cells::jsonb, @blob)";
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ReportTemplateInfo>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
var conn = await OpenAsync(ct);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
SELECT t.id, t.name, t.owner, t.created_at,
|
||||
count(r.id) AS run_count, max(r.generated_at) AS last_run,
|
||||
t.schedule_enabled, t.schedule_period, t.schedule_hour, t.schedule_source
|
||||
FROM hc900.report_template t
|
||||
LEFT JOIN hc900.report_run r ON r.template_id = t.id
|
||||
GROUP BY t.id, t.name, t.owner, t.created_at,
|
||||
t.schedule_enabled, t.schedule_period, t.schedule_hour, t.schedule_source
|
||||
ORDER BY t.created_at DESC";
|
||||
var list = new List<ReportTemplateInfo>();
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await rd.ReadAsync(ct))
|
||||
list.Add(new ReportTemplateInfo
|
||||
{
|
||||
Id = rd.GetInt32(0),
|
||||
Name = rd.GetString(1),
|
||||
Owner = rd.IsDBNull(2) ? null : rd.GetString(2),
|
||||
CreatedAt = rd.GetFieldValue<DateTime>(3),
|
||||
RunCount = Convert.ToInt32(rd.GetValue(4)),
|
||||
LastRunAt = rd.IsDBNull(5) ? null : rd.GetFieldValue<DateTime>(5),
|
||||
ScheduleEnabled = !rd.IsDBNull(6) && rd.GetBoolean(6),
|
||||
SchedulePeriod = rd.IsDBNull(7) ? "DAILY" : rd.GetString(7),
|
||||
ScheduleHour = rd.IsDBNull(8) ? 1 : rd.GetInt32(8),
|
||||
ScheduleSource = rd.IsDBNull(9) ? "history_table" : rd.GetString(9),
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateScheduleAsync(int templateId, bool enabled, string period, int hour, string source, CancellationToken ct = default)
|
||||
{
|
||||
var conn = await OpenAsync(ct);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"UPDATE hc900.report_template
|
||||
SET schedule_enabled=@e, schedule_period=@p, schedule_hour=@h, schedule_source=@s, updated_at=now()
|
||||
WHERE id=@id";
|
||||
AddP(cmd, "@e", enabled); AddP(cmd, "@p", period); AddP(cmd, "@h", hour);
|
||||
AddP(cmd, "@s", source); AddP(cmd, "@id", templateId);
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> HasSuccessfulRunAsync(int templateId, string periodKind, DateTime periodDate, CancellationToken ct = default)
|
||||
{
|
||||
var conn = await OpenAsync(ct);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"SELECT 1 FROM hc900.report_run
|
||||
WHERE template_id=@t AND period_kind=@pk AND period_date=@pd AND status IN ('ok','partial') LIMIT 1";
|
||||
AddP(cmd, "@t", templateId); AddP(cmd, "@pk", periodKind); AddP(cmd, "@pd", periodDate.Date);
|
||||
return await cmd.ExecuteScalarAsync(ct) != null;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int templateId, CancellationToken ct = default)
|
||||
{
|
||||
var conn = await OpenAsync(ct);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM hc900.report_template WHERE id=@id"; // report_run은 FK ON DELETE CASCADE
|
||||
AddP(cmd, "@id", templateId);
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ReportRunInfo>> ListRunsAsync(int templateId, int limit, CancellationToken ct = default)
|
||||
{
|
||||
var conn = await OpenAsync(ct);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
SELECT id, period_kind, period_date, source_table, status, generated_at, (out_blob IS NOT NULL)
|
||||
FROM hc900.report_run WHERE template_id=@t
|
||||
ORDER BY generated_at DESC LIMIT @lim";
|
||||
AddP(cmd, "@t", templateId); AddP(cmd, "@lim", Math.Clamp(limit, 1, 200));
|
||||
var list = new List<ReportRunInfo>();
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await rd.ReadAsync(ct))
|
||||
list.Add(new ReportRunInfo
|
||||
{
|
||||
RunId = rd.GetInt64(0),
|
||||
PeriodKind = rd.GetString(1),
|
||||
PeriodDate = rd.GetFieldValue<DateTime>(2),
|
||||
Source = rd.GetString(3),
|
||||
Status = rd.GetString(4),
|
||||
GeneratedAt = rd.GetFieldValue<DateTime>(5),
|
||||
HasBlob = rd.GetBoolean(6),
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
public async Task<byte[]?> GetRunBlobAsync(long runId, CancellationToken ct = default)
|
||||
{
|
||||
var conn = await OpenAsync(ct);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT out_blob FROM hc900.report_run WHERE id=@id";
|
||||
AddP(cmd, "@id", runId);
|
||||
var o = await cmd.ExecuteScalarAsync(ct);
|
||||
return o is byte[] b ? b : null;
|
||||
}
|
||||
|
||||
private async Task<DbConnection> OpenAsync(CancellationToken ct)
|
||||
{
|
||||
var conn = _ctx.Database.GetDbConnection();
|
||||
|
||||
Reference in New Issue
Block a user