diff --git a/src/Core/Application/DTOs/ReportDtos.cs b/src/Core/Application/DTOs/ReportDtos.cs index e844c39..61bcdac 100644 --- a/src/Core/Application/DTOs/ReportDtos.cs +++ b/src/Core/Application/DTOs/ReportDtos.cs @@ -11,6 +11,17 @@ public sealed class MetricRequestDto public int? SessionId { get; set; } // fast_record일 때 } +/// 템플릿 목록 항목(관리 UI). 생성이력 집계 포함. +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) +} + /// 결정론 메트릭 1건 결과 + 해상도 메타(필수, 사과-오렌지 방지). /// 프로퍼티로 선언해야 System.Text.Json이 직렬화함(필드는 기본 미직렬화). public sealed class MetricResultDto diff --git a/src/Core/Application/Interfaces/IReportTemplateStore.cs b/src/Core/Application/Interfaces/IReportTemplateStore.cs index 7620324..21105e1 100644 --- a/src/Core/Application/Interfaces/IReportTemplateStore.cs +++ b/src/Core/Application/Interfaces/IReportTemplateStore.cs @@ -1,3 +1,5 @@ +using Hc900Crawler.Core.Application.DTOs; + namespace Hc900Crawler.Core.Application.Interfaces; /// report_template / report_run CRUD (raw SQL). @@ -8,4 +10,6 @@ public interface IReportTemplateStore Task RecordRunAsync(int templateId, string periodKind, DateTime periodDate, string sourceTable, string status, object cells, byte[] outBlob, CancellationToken ct = default); + Task> ListAsync(CancellationToken ct = default); + Task DeleteAsync(int templateId, CancellationToken ct = default); // 존재 시 true (report_run은 CASCADE) } diff --git a/src/Hc900Crawler/Controllers/ReportController.cs b/src/Hc900Crawler/Controllers/ReportController.cs index 0f23ac3..a48c80c 100644 --- a/src/Hc900Crawler/Controllers/ReportController.cs +++ b/src/Hc900Crawler/Controllers/ReportController.cs @@ -138,6 +138,11 @@ GROUP BY b ORDER BY b"; return Ok(new { Column = column, Date = d.ToString("yyyy-MM-dd"), Source = source, Period = period, Metrics = results }); } + /// 저장된 템플릿 목록(관리 UI) — 생성이력 집계 포함. + [HttpGet("templates")] + public async Task Templates(CancellationToken ct) + => Ok(await _store.ListAsync(ct)); + /// 엑셀 템플릿 등록. [HttpPost("template")] public async Task Upload([FromForm] IFormFile file, [FromForm] string name, @@ -150,6 +155,11 @@ GROUP BY b ORDER BY b"; return Ok(new { Id = id }); } + /// 템플릿 삭제(생성이력 report_run은 CASCADE). + [HttpDelete("template/{id:int}")] + public async Task Delete(int id, CancellationToken ct) + => await _store.DeleteAsync(id, ct) ? Ok(new { Deleted = id }) : NotFound(new { Error = $"템플릿 {id} 없음" }); + /// ★템플릿+날짜 → 채워진 xlsx 다운로드. [HttpGet("generate")] public async Task Generate(int templateId, DateTime date, diff --git a/src/Hc900Crawler/wwwroot/js/reports.js b/src/Hc900Crawler/wwwroot/js/reports.js index 65daadc..0b71056 100644 --- a/src/Hc900Crawler/wwwroot/js/reports.js +++ b/src/Hc900Crawler/wwwroot/js/reports.js @@ -33,25 +33,71 @@ paneInit['reports'] = function () { } catch (e) { out.innerHTML = `❌ ${typeof esc === 'function' ? esc(e.message) : e.message}`; } }; - // ── ② 엑셀 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(); + + // 저장된 템플릿 목록 로드 + 선택/삭제 와이어링 (멱등) + async function loadTemplates() { + const box = $('rpTplList'); if (!box) return; + try { + const list = await fetch('/api/report/templates').then(r => r.json()); + $('rpTplCount').textContent = `(${list.length})`; + const sel = $('rpSelId'); + if (!list.length) { box.innerHTML = '등록된 템플릿이 없습니다 — 위에서 .xlsx를 등록하세요.'; sel.value = ''; return; } + const keep = list.some(t => String(t.Id) === sel.value) ? sel.value : String(list[0].Id); + sel.value = keep; + box.innerHTML = list.map(t => { + const last = t.LastRunAt ? new Date(t.LastRunAt).toLocaleString() : '미생성'; + return ``; + }).join(''); + box.querySelectorAll('input[name=rpTplPick]').forEach(r => r.onchange = () => sel.value = r.value); + 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(); + }); + } catch (e) { box.innerHTML = `목록 로드 실패: ${hesc(e.message)}`; } + } + loadTemplates(); + + // 등록 + 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; } }; }; diff --git a/src/Hc900Crawler/wwwroot/panes/reports.html b/src/Hc900Crawler/wwwroot/panes/reports.html index 735047b..0c6b0df 100644 --- a/src/Hc900Crawler/wwwroot/panes/reports.html +++ b/src/Hc900Crawler/wwwroot/panes/reports.html @@ -45,8 +45,22 @@

엑셀 템플릿 셀에 {{ metric=mass_balance_closure; column=C-6111 }} 형태 토큰을 박아두면 선택 날짜 값으로 채워 다운로드합니다.

-
+ +
+ + +
+ + +
+
저장된 템플릿
+
로딩…
+ +
+ + +
- +
diff --git a/src/Infrastructure/Reporting/ReportTemplateStore.cs b/src/Infrastructure/Reporting/ReportTemplateStore.cs index 3b8bcce..7d5fa51 100644 --- a/src/Infrastructure/Reporting/ReportTemplateStore.cs +++ b/src/Infrastructure/Reporting/ReportTemplateStore.cs @@ -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,41 @@ VALUES (@t, @pk, @pd, @src, @st, @cells::jsonb, @blob)"; await cmd.ExecuteNonQueryAsync(ct); } + public async Task> 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 +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 +ORDER BY t.created_at DESC"; + var list = new List(); + 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(3), + RunCount = Convert.ToInt32(rd.GetValue(4)), + LastRunAt = rd.IsDBNull(5) ? null : rd.GetFieldValue(5), + }); + return list; + } + + public async Task 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; + } + private async Task OpenAsync(CancellationToken ct) { var conn = _ctx.Database.GetDbConnection();