feat(report): P2-C 템플릿별 자동생성 스케줄러 — 멱등 catchup BackgroundService
- report_template에 schedule_enabled/period/hour/source 컬럼(ALTER IF NOT EXISTS,
p0_report.sql + 서비스 EnsureSchema 멱등).
- Hc900ReportScheduleService: 매 N초 활성 템플릿마다 완료된 기간(anchor)을
lookback 내 산출 → report_run 성공기록 없으면 FillAsync 생성·기록.
중복 0(HasSuccessfulRun dedup)·다운타임 자동복구. 윈도는 셀 토큰 period=가 결정.
- IReportTemplateStore.UpdateSchedule/HasSuccessfulRun, ListAsync에 스케줄 컬럼.
- PUT /api/report/template/{id}/schedule (period/hour 검증).
- UI: 목록에 스케줄 상태 표시 + 선택 템플릿 스케줄 편집기.
- appsettings Report:Schedule (간격·catchup) 추가.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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"; // 메트릭 소스
|
||||
}
|
||||
|
||||
/// <summary>결정론 메트릭 1건 결과 + 해상도 메타(필수, 사과-오렌지 방지).
|
||||
|
||||
@@ -12,4 +12,6 @@ public interface IReportTemplateStore
|
||||
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 존재
|
||||
}
|
||||
|
||||
@@ -160,6 +160,21 @@ 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} 없음" });
|
||||
|
||||
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,
|
||||
|
||||
@@ -179,6 +179,8 @@ builder.Services.AddSingleton<Hc900Crawler.Infrastructure.Reporting.ReportColumn
|
||||
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,
|
||||
|
||||
@@ -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 = '<span style="color:var(--t2)">등록된 템플릿이 없습니다 — 위에서 .xlsx를 등록하세요.</span>'; sel.value = ''; return; }
|
||||
const keep = list.some(t => String(t.Id) === sel.value) ? sel.value : String(list[0].Id);
|
||||
if (!templates.length) { box.innerHTML = '<span style="color:var(--t2)">등록된 템플릿이 없습니다 — 위에서 .xlsx를 등록하세요.</span>'; 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
|
||||
? `<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)}</span>
|
||||
<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);
|
||||
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 = `<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 () => {
|
||||
|
||||
@@ -59,6 +59,26 @@
|
||||
<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>
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<ReportTemplateInfo>();
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||
@@ -76,10 +78,36 @@ ORDER BY t.created_at DESC";
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user