diff --git a/scripts/sql/p0_report.sql b/scripts/sql/p0_report.sql index fa39eed..29aa05b 100644 --- a/scripts/sql/p0_report.sql +++ b/scripts/sql/p0_report.sql @@ -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(), diff --git a/src/Core/Application/DTOs/ReportDtos.cs b/src/Core/Application/DTOs/ReportDtos.cs index 61bcdac..96ab886 100644 --- a/src/Core/Application/DTOs/ReportDtos.cs +++ b/src/Core/Application/DTOs/ReportDtos.cs @@ -20,6 +20,12 @@ public sealed class ReportTemplateInfo 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"; // 메트릭 소스 } /// 결정론 메트릭 1건 결과 + 해상도 메타(필수, 사과-오렌지 방지). diff --git a/src/Core/Application/Interfaces/IReportTemplateStore.cs b/src/Core/Application/Interfaces/IReportTemplateStore.cs index 21105e1..ea47ca5 100644 --- a/src/Core/Application/Interfaces/IReportTemplateStore.cs +++ b/src/Core/Application/Interfaces/IReportTemplateStore.cs @@ -12,4 +12,6 @@ public interface IReportTemplateStore CancellationToken ct = default); Task> ListAsync(CancellationToken ct = default); Task DeleteAsync(int templateId, CancellationToken ct = default); // 존재 시 true (report_run은 CASCADE) + Task UpdateScheduleAsync(int templateId, bool enabled, string period, int hour, string source, CancellationToken ct = default); + Task HasSuccessfulRunAsync(int templateId, string periodKind, DateTime periodDate, CancellationToken ct = default); // ok|partial 존재 } diff --git a/src/Hc900Crawler/Controllers/ReportController.cs b/src/Hc900Crawler/Controllers/ReportController.cs index a48c80c..f4c70c4 100644 --- a/src/Hc900Crawler/Controllers/ReportController.cs +++ b/src/Hc900Crawler/Controllers/ReportController.cs @@ -160,6 +160,21 @@ GROUP BY b ORDER BY b"; public async Task Delete(int id, CancellationToken ct) => await _store.DeleteAsync(id, ct) ? Ok(new { Deleted = id }) : NotFound(new { Error = $"템플릿 {id} 없음" }); + 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"; } + + /// 템플릿 자동생성 스케줄 설정. + [HttpPut("template/{id:int}/schedule")] + public async Task 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} 없음" }); + } + /// ★템플릿+날짜 → 채워진 xlsx 다운로드. [HttpGet("generate")] public async Task Generate(int templateId, DateTime date, diff --git a/src/Hc900Crawler/Program.cs b/src/Hc900Crawler/Program.cs index 329d44d..d3c3d40 100644 --- a/src/Hc900Crawler/Program.cs +++ b/src/Hc900Crawler/Program.cs @@ -179,6 +179,8 @@ builder.Services.AddSingleton(); // P1c: 온라인 KPI 누적기 (history_1s → live_kpi) builder.Services.AddHostedService(); +// P2-C: 템플릿별 리포트 자동생성 스케줄러 (멱등 catchup) +builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/Hc900Crawler/appsettings.json b/src/Hc900Crawler/appsettings.json index f7a053e..ba40910 100644 --- a/src/Hc900Crawler/appsettings.json +++ b/src/Hc900Crawler/appsettings.json @@ -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, diff --git a/src/Hc900Crawler/wwwroot/js/reports.js b/src/Hc900Crawler/wwwroot/js/reports.js index 0b71056..c6b277f 100644 --- a/src/Hc900Crawler/wwwroot/js/reports.js +++ b/src/Hc900Crawler/wwwroot/js/reports.js @@ -38,34 +38,66 @@ paneInit['reports'] = function () { 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 loadTemplates() { const box = $('rpTplList'); if (!box) return; try { - const list = await fetch('/api/report/templates').then(r => r.json()); - $('rpTplCount').textContent = `(${list.length})`; + templates = await fetch('/api/report/templates').then(r => r.json()); + $('rpTplCount').textContent = `(${templates.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); + if (!templates.length) { box.innerHTML = '등록된 템플릿이 없습니다 — 위에서 .xlsx를 등록하세요.'; sel.value = ''; fillSchedForm(); return; } + const keep = templates.some(t => String(t.Id) === sel.value) ? sel.value : String(templates[0].Id); sel.value = keep; - box.innerHTML = list.map(t => { + box.innerHTML = templates.map(t => { const last = t.LastRunAt ? new Date(t.LastRunAt).toLocaleString() : '미생성'; + const sched = t.ScheduleEnabled + ? `⏰ ${t.SchedulePeriod}@${t.ScheduleHour}h·${hesc(t.ScheduleSource)}` + : '수동'; return ``; }).join(''); - box.querySelectorAll('input[name=rpTplPick]').forEach(r => r.onchange = () => sel.value = r.value); + box.querySelectorAll('input[name=rpTplPick]').forEach(r => r.onchange = () => { sel.value = r.value; fillSchedForm(); }); 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(); } catch (e) { box.innerHTML = `목록 로드 실패: ${hesc(e.message)}`; } } 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 () => { diff --git a/src/Hc900Crawler/wwwroot/panes/reports.html b/src/Hc900Crawler/wwwroot/panes/reports.html index 0c6b0df..a487246 100644 --- a/src/Hc900Crawler/wwwroot/panes/reports.html +++ b/src/Hc900Crawler/wwwroot/panes/reports.html @@ -59,6 +59,26 @@ + +
+ + + + + +
+
diff --git a/src/Infrastructure/Hc900/Hc900ReportScheduleService.cs b/src/Infrastructure/Hc900/Hc900ReportScheduleService.cs new file mode 100644 index 0000000..abd0e5c --- /dev/null +++ b/src/Infrastructure/Hc900/Hc900ReportScheduleService.cs @@ -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; + +/// +/// P2-C 템플릿별 리포트 자동생성. 매 N초 활성 템플릿마다 "완료됐어야 할 기간(anchor)"을 +/// lookback 내에서 산출 → report_run에 성공기록 없으면 생성. 멱등(중복 0)·다운타임 자동복구. +/// 기간 윈도는 셀 토큰(period=)이 결정; 스케줄은 anchor 선택 + 발화 타이밍만 담당. +/// 60초 Hc900HistoryService 패턴. +/// +public class Hc900ReportScheduleService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly bool _enabled; + private readonly int _intervalSec; + private readonly int _catchupDays, _catchupMonths, _catchupYears; + + public Hc900ReportScheduleService(IServiceScopeFactory scopeFactory, + ILogger 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(); + var fill = scope.ServiceProvider.GetRequiredService(); + + 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); + } + } + } + + /// 완료됐고 발화시각(KST hour)이 지난 기간들의 anchor 날짜(KST). lookback 만큼만, 멱등 dedup은 호출측. + private IEnumerable 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; + } + } + } + + /// 스케줄 컬럼 멱등 추가(기존 DB 호환). + private async Task EnsureSchemaAsync(CancellationToken ct) + { + using var scope = _scopeFactory.CreateScope(); + var conn = scope.ServiceProvider.GetRequiredService().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); + } +} diff --git a/src/Infrastructure/Reporting/ReportTemplateStore.cs b/src/Infrastructure/Reporting/ReportTemplateStore.cs index 7d5fa51..501e8d2 100644 --- a/src/Infrastructure/Reporting/ReportTemplateStore.cs +++ b/src/Infrastructure/Reporting/ReportTemplateStore.cs @@ -60,10 +60,12 @@ VALUES (@t, @pk, @pd, @src, @st, @cells::jsonb, @blob)"; 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 + 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 +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(); await using var rd = await cmd.ExecuteReaderAsync(ct); @@ -76,10 +78,36 @@ ORDER BY t.created_at DESC"; CreatedAt = rd.GetFieldValue(3), RunCount = Convert.ToInt32(rd.GetValue(4)), LastRunAt = rd.IsDBNull(5) ? null : rd.GetFieldValue(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 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 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 DeleteAsync(int templateId, CancellationToken ct = default) { var conn = await OpenAsync(ct);