feat(report): 생성본 과거 다운로드 — report_run.out_blob 이력 조회/다운로드

- IReportTemplateStore.ListRunsAsync/GetRunBlobAsync (+ ReportRunInfo DTO).
- GET /api/report/template/{id}/runs (최신순), GET /api/report/run/{runId}/download.
- UI: 선택 템플릿 '생성 이력' expander — 기간/소스/상태/시각 + 과거본 다운로드 링크.
  스케줄 자동생성본도 여기서 회수(기존엔 생성·기록만 되고 조회 불가).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-17 21:50:47 +09:00
parent 4ac35c3ce0
commit f8e6d5bf24
6 changed files with 92 additions and 3 deletions

View File

@@ -28,6 +28,18 @@ public sealed class ReportTemplateInfo
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

View File

@@ -14,4 +14,6 @@ public interface IReportTemplateStore
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);
}

View File

@@ -160,6 +160,20 @@ GROUP BY b ORDER BY b";
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"; }

View File

@@ -49,6 +49,26 @@ paneInit['reports'] = function () {
$('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;
@@ -56,7 +76,7 @@ paneInit['reports'] = function () {
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 = ''; fillSchedForm(); return; }
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 => {
@@ -69,14 +89,14 @@ paneInit['reports'] = function () {
<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; fillSchedForm(); });
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();
});
fillSchedForm();
onSel();
} catch (e) { box.innerHTML = `<span style="color:#e66">목록 로드 실패: ${hesc(e.message)}</span>`; }
}
loadTemplates();

View File

@@ -93,6 +93,12 @@
</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">

View File

@@ -117,6 +117,41 @@ ORDER BY t.created_at DESC";
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();