운전원이 올린 엑셀 템플릿의 {{ metric=...; column=... }} 토큰을, 선택한 날짜의
결정론 계산값으로 채워 다운로드하는 셀프서비스 리포트 레이어(P0).
- 메트릭 3종: energy_efficiency·yield·control_residual (분 버킷 피벗, 클린필터,
KST→UTC 경계). history_table(60s)·fast_record(s/min) 동일 long 포맷이라 source만 교체.
- 컬럼→태그 매핑은 기존 appsettings SteamAdvisor:Columns 재사용(7컬럼 무료).
- EPPlus 토큰 치환 + 셀 주석에 해상도 메타(source/sampling/n/keep) 부착.
- report_template/report_run 테이블 + cells_json 감사 박제.
- 리포트 탭(웹 UI) + 샘플 템플릿.
검증: 빌드 0에러. E2E 라운드트립 실동작(2026-05-15 C-6111 효율 0.778·수율 0.872·
잔차 mean 0.746 — 오프라인 검증값과 일치). 결정론 검증 게이트 준수(표본0=N/A, 0 날조 금지).
⚠️ EPPlus는 NonCommercial 컨텍스트 — 상용 출시 전 라이선스 정리 필요.
설계: docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
26 KiB
P0 MVP 상세설계 — 셀프서비스 결정론 리포트 (코딩 레벨)
2026-06-12. 상위:
작업플랜-셀프서비스-분석리포트-MVP.md→논의-AI운전원-제어-아이디어.md. P0 목표(한 문장): 운전원이 올린 엑셀 템플릿에{{ metric|... }}토큰을 박아두면, 날짜 하나 선택 시 C-6111 컬럼의 일일 효율·수율·제어잔차가 그 폼 그대로 채워져 다운로드된다. 소스=history_table(60s), 동일 코드로fast_record도 가능함을 1개 메트릭으로 실증.
0. P0 범위 (의도적 최소)
| 포함 | 제외(P1+) |
|---|---|
메트릭 3종: energy_efficiency, yield, control_residual |
dynamics(fast 전용), excursion, settling |
| 대상 1컬럼: C-6111 (KB 매핑 기존) | 멀티컬럼/멀티플랜트 |
소스: history_table(주) + fast_record(효율 1종만 실증) |
멀티소스 일반화 |
| Daily 1주기, 수동 트리거(날짜 선택→생성) | Monthly/Yearly, 스케줄 자동생성 |
| 엑셀 토큰 채움(EPPlus, 서버) + 다운로드 | 템플릿 업로드 UI(P2), 미리보기 |
| 결과 표 + 토큰맵 JSON | named-range 고급문법 |
0.1 기존 자산 재사용 (신규 의존성 0)
- EPPlus 7.4.2 —
Hc900Crawler.csproj에 이미 있음.PidExtractorService.cs:620에서new OfficeOpenXml.ExcelPackage()바로 사용 = 라이선스 컨텍스트 이미 설정됨(동일 패턴 재사용). - raw SQL —
FeedforwardAuditService패턴(_ctx.Database.GetDbConnection()+@param) 그대로. - 패널 로딩 —
index.htmldata-src="/panes/X.html"+ navdata-tab컨벤션. - 연결 —
appsettings.json:DefaultConnection(iiot_platform,Search Path=hc900). - KST 처리 —
KstClock/KoreanTimeRangeExtractor(Program.cs:70-71) 존재. recorded_at=UTC.
1. 데이터 모델 (DDL — 최소 2테이블)
hc900 스키마. 마이그레이션 SQL(scripts/sql/p0_report.sql):
-- 운전원이 올린 엑셀 템플릿 + 토큰맵
CREATE TABLE IF NOT EXISTS hc900.report_template (
id serial PRIMARY KEY,
name text NOT NULL,
owner text,
xlsx_blob bytea NOT NULL, -- 원본 템플릿(.xlsx)
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- 생성 이력(감사). 어떤 정의로 어떤 기간을 뽑았는지 박제
CREATE TABLE IF NOT EXISTS hc900.report_run (
id bigserial PRIMARY KEY,
template_id int REFERENCES hc900.report_template(id),
period_kind text NOT NULL, -- 'DAILY'
period_date date NOT NULL, -- KST 기준 날짜
source_table text NOT NULL, -- 'history_table' | 'fast_record'
generated_at timestamptz NOT NULL DEFAULT now(),
status text NOT NULL, -- 'ok' | 'partial' | 'error'
cells_json jsonb, -- 채운 셀/값/메타 박제(추적)
out_blob bytea -- 채워진 .xlsx (다운로드 캐시)
);
토큰맵은 별도 컬럼이 아니라 템플릿 셀 주석/값에 직접 박는다(§7). report_run.cells_json에 "어떤 셀=어떤 메트릭+파라미터+값+sampling메타"를 결정론적으로 박제 → 감사·재현.
2. 컬럼→메트릭 태그 매핑 (config, 하드코딩 금지)
KB(docs/kb/P6-1_플랜트_공정마스터.md)에서 도출. 파일 config/report-metric-map.json(런타임 로드, 편집 시 재배포 불필요):
{
"C-6111": {
"label": "6-1차 PGMEA 진공증류탑",
"metrics": {
"energy_efficiency": { "steam": "FIQ-6115.PV", "product": "FICQ-6118.PV", "unit": "kg스팀/kg제품" },
"yield": { "product": "FICQ-6118.PV", "feed": "FICQ-6101.PV", "unit": "비율" },
"control_residual": { "pv": "TICA-6111A.PV", "sp": "TICA-6111A.SP", "unit": "degC" }
},
"clean": {
"TICA-6111A.PV": [60,95], "TICA-6111A.SP": [60,95],
"FIQ-6115.PV": [50,3000], "FICQ-6118.PV": [100,1500], "FICQ-6101.PV": [100,1500]
}
}
}
clean범위 = 오늘 검증에서 0/드롭아웃/스파이크 제거에 쓴 값. 채널별 양자화/RTD레인지 주의(메모리). 향후 tag_metadata에서 EU레인지로 자동도출 가능(P2).
3. 메트릭 엔진 (C#)
3.1 DTO — src/Core/Application/DTOs/ReportDtos.cs
namespace Hc900Crawler.Core.Application.DTOs;
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 SourceTable { get; set; } = "history_table"; // | fast_record (+ session_id)
public int? SessionId { get; set; } // fast_record일 때
}
/// <summary>결정론 메트릭 1건 결과 + 해상도 메타(필수).</summary>
public sealed class MetricResultDto
{
public string Metric { get; set; } = "";
public string Column { get; set; } = "";
public double? Value { get; set; } // 대표값(없으면 null = 명시적 실패, 날조 금지)
public string? Unit { get; set; }
public string Status { get; set; } = "ok"; // ok | no_data | error
public string? Error { get; set; }
// ── 해상도-인지 메타 (사과-오렌지 방지) ──
public string Source { get; set; } = "";
public int SamplingMs { get; set; } // 60000(history) | fast_record.sampling_ms
public int N { get; set; } // 클린 후 표본수
public double CleanedFraction { get; set; } // 제거된 비율
public Dictionary<string, double?> Extra { get; set; } = new(); // sd/p95/이탈% 등
}
3.2 인터페이스 — src/Core/Application/Interfaces/IReportMetricService.cs
using Hc900Crawler.Core.Application.DTOs;
namespace Hc900Crawler.Core.Application.Interfaces;
public interface IReportMetricService
{
Task<MetricResultDto> ComputeAsync(MetricRequestDto req, CancellationToken ct = default);
}
3.3 구현 — src/Infrastructure/Reporting/ReportMetricService.cs
raw SQL은 FeedforwardAuditService 패턴(_ctx.Database.GetDbConnection())을 따른다. KST 날짜 → UTC 경계는 C#에서 계산(recorded_at=UTC).
using System.Data;
using System.Text.Json;
using Hc900Crawler.Core.Application.DTOs;
using Hc900Crawler.Core.Application.Interfaces;
using Hc900Crawler.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Hc900Crawler.Infrastructure.Reporting;
public sealed class ReportMetricService : IReportMetricService
{
private readonly Hc900DbContext _ctx;
private readonly ILogger<ReportMetricService> _logger;
private readonly MetricMap _map; // config/report-metric-map.json 로드
public ReportMetricService(Hc900DbContext ctx, ILogger<ReportMetricService> logger, MetricMap map)
{ _ctx = ctx; _logger = logger; _map = map; }
public async Task<MetricResultDto> ComputeAsync(MetricRequestDto req, CancellationToken ct = default)
{
var res = new MetricResultDto { Metric = req.Metric, Column = req.Column,
Source = req.SourceTable, SamplingMs = req.SourceTable == "fast_record" ? 0 : 60000 };
if (!_map.TryGet(req.Column, req.Metric, out var roles, out var unit, out var clean))
{ res.Status = "error"; res.Error = $"미정의 매핑: {req.Column}/{req.Metric}"; return res; }
res.Unit = unit;
// KST 날짜 [00:00, +1d) → UTC
var fromUtc = DateTime.SpecifyKind(req.PeriodDateKst.Date, DateTimeKind.Unspecified).AddHours(-9);
var toUtc = fromUtc.AddDays(1);
// fast_record는 세션 전체(기간무관) 또는 세션의 해당일. P0: 세션 전체.
bool isFast = req.SourceTable == "fast_record";
string tbl = isFast ? "fast_record" : "history_table";
try
{
res = req.Metric switch
{
"energy_efficiency" => await RatioMetric(res, tbl, roles["steam"], roles["product"], clean, fromUtc, toUtc, isFast, req.SessionId, ct),
"yield" => await RatioMetric(res, tbl, roles["product"], roles["feed"], clean, fromUtc, toUtc, isFast, req.SessionId, ct),
"control_residual" => await ResidualMetric(res, tbl, roles["pv"], roles["sp"], clean, fromUtc, toUtc, isFast, req.SessionId, ct),
_ => Fail(res, $"미구현 메트릭: {req.Metric}")
};
}
catch (Exception ex) { _logger.LogError(ex, "[Report] metric 실패 {M}", req.Metric); Fail(res, ex.Message); }
return res;
}
private static MetricResultDto Fail(MetricResultDto r, string msg) { r.Status="error"; r.Error=msg; r.Value=null; return r; }
// ── 비율 메트릭: median(numer)/median(denom). 분단위 피벗으로 정렬 후 robust 중앙값 ──
private async Task<MetricResultDto> RatioMetric(
MetricResultDto res, string tbl, string numerTag, string denomTag,
IReadOnlyDictionary<string,(double lo,double hi)> clean,
DateTime fromUtc, DateTime toUtc, bool isFast, int? sessionId, CancellationToken ct)
{
var (nlo,nhi) = clean[numerTag]; var (dlo,dhi) = clean[denomTag];
var conn = _ctx.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
await using var cmd = conn.CreateCommand();
// 분 버킷 피벗 → 둘 다 유효한 분만 → 비율의 중앙값/표본수 + 원천 표본수(클린율 산출)
cmd.CommandText = $@"
WITH raw AS (
SELECT date_trunc('minute', recorded_at) ts, tagname, value::float v
FROM hc900.{tbl}
WHERE tagname IN (@numer,@denom)
AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")})
), tot AS ( SELECT count(*) c FROM raw ),
piv AS (
SELECT ts,
max(v) FILTER (WHERE tagname=@numer) n,
max(v) FILTER (WHERE tagname=@denom) d
FROM raw GROUP BY ts
), good AS (
SELECT n, d FROM piv
WHERE n BETWEEN @nlo AND @nhi AND d BETWEEN @dlo AND @dhi AND d <> 0
)
SELECT
(SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY n/d) FROM good) AS ratio_med,
(SELECT count(*) FROM good) AS n_good,
(SELECT c FROM tot) AS n_raw;";
AddP(cmd,"@numer",numerTag); AddP(cmd,"@denom",denomTag);
AddP(cmd,"@nlo",nlo); AddP(cmd,"@nhi",nhi); AddP(cmd,"@dlo",dlo); AddP(cmd,"@dhi",dhi);
if (isFast) AddP(cmd,"@sid", sessionId ?? -1);
else { AddP(cmd,"@from",fromUtc); AddP(cmd,"@to",toUtc); }
await using var rd = await cmd.ExecuteReaderAsync(ct);
if (await rd.ReadAsync(ct) && !rd.IsDBNull(0))
{
res.Value = rd.GetDouble(0);
res.N = rd.GetInt32(1);
var nRaw = rd.GetInt64(2);
var nGood = res.N;
res.CleanedFraction = nRaw > 0 ? 1.0 - (2.0*nGood)/nRaw : 0; // 2태그라 분모 보정
if (isFast) res.SamplingMs = await FastSamplingMsAsync(conn, sessionId ?? -1, ct);
}
else { res.Status = "no_data"; res.Value = null; }
return res;
}
// ── 제어잔차: (PV-SP) 통계. 분 피벗 후 잔차의 mean/sd/p95/이탈% ──
private async Task<MetricResultDto> ResidualMetric(
MetricResultDto res, string tbl, string pvTag, string spTag,
IReadOnlyDictionary<string,(double lo,double hi)> clean,
DateTime fromUtc, DateTime toUtc, bool isFast, int? sessionId, CancellationToken ct)
{
var (plo,phi) = clean[pvTag]; var (slo,shi) = clean[spTag];
var conn = _ctx.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
WITH raw AS (
SELECT date_trunc('minute', recorded_at) ts, tagname, value::float v
FROM hc900.{tbl}
WHERE tagname IN (@pv,@sp)
AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")})
), piv AS (
SELECT ts, max(v) FILTER (WHERE tagname=@pv) pv, max(v) FILTER (WHERE tagname=@sp) sp
FROM raw GROUP BY ts
), good AS (
SELECT pv - sp AS e FROM piv
WHERE pv BETWEEN @plo AND @phi AND sp BETWEEN @slo AND @shi
)
SELECT avg(e), stddev(e), percentile_cont(0.95) WITHIN GROUP (ORDER BY abs(e)),
count(*), 100.0*count(*) FILTER (WHERE abs(e) > 0.5)/NULLIF(count(*),0)
FROM good;";
AddP(cmd,"@pv",pvTag); AddP(cmd,"@sp",spTag);
AddP(cmd,"@plo",plo); AddP(cmd,"@phi",phi); AddP(cmd,"@slo",slo); AddP(cmd,"@shi",shi);
if (isFast) AddP(cmd,"@sid", sessionId ?? -1);
else { AddP(cmd,"@from",fromUtc); AddP(cmd,"@to",toUtc); }
await using var rd = await cmd.ExecuteReaderAsync(ct);
if (await rd.ReadAsync(ct) && !rd.IsDBNull(3) && rd.GetInt64(3) > 0)
{
res.Value = rd.IsDBNull(0) ? null : rd.GetDouble(0); // 평균 잔차
res.Extra["sd"] = rd.IsDBNull(1) ? null : rd.GetDouble(1);
res.Extra["abs_p95"] = rd.IsDBNull(2) ? null : rd.GetDouble(2);
res.Extra["out_pct_0_5"]= rd.IsDBNull(4) ? null : rd.GetDouble(4);
res.N = (int)rd.GetInt64(3);
if (isFast) res.SamplingMs = await FastSamplingMsAsync(conn, sessionId ?? -1, ct);
}
else { res.Status = "no_data"; res.Value = null; }
return res;
}
private static async Task<int> FastSamplingMsAsync(System.Data.Common.DbConnection conn, int sid, CancellationToken ct)
{
await using var c = conn.CreateCommand();
c.CommandText = "SELECT sampling_ms FROM hc900.fast_session WHERE id=@id";
AddP(c,"@id",sid);
var o = await c.ExecuteScalarAsync(ct);
return o is int i ? i : (o is null ? 0 : Convert.ToInt32(o));
}
private static void AddP(System.Data.Common.DbCommand cmd, string name, object val)
{ var p = cmd.CreateParameter(); p.ParameterName = name; p.Value = val ?? DBNull.Value; cmd.Parameters.Add(p); }
}
결정론 검증 게이트 준수: 표본 0 →
Status=no_data,Value=null(빈칸을 0으로 날조하지 않음). 매핑 미정의 → 명시적 error. (메모리 deterministic-verification-gate)
3.4 매핑 로더 — src/Infrastructure/Reporting/MetricMap.cs
using System.Text.Json;
namespace Hc900Crawler.Infrastructure.Reporting;
public sealed class MetricMap
{
private readonly JsonElement _root;
public MetricMap(string jsonPath) => _root = JsonDocument.Parse(File.ReadAllText(jsonPath)).RootElement;
public bool TryGet(string column, string metric,
out Dictionary<string,string> roles, out string? unit,
out Dictionary<string,(double lo,double hi)> clean)
{
roles = new(); unit = null; clean = new();
if (!_root.TryGetProperty(column, out var col)) return false;
if (!col.GetProperty("metrics").TryGetProperty(metric, out var m)) return false;
foreach (var p in m.EnumerateObject())
if (p.Name == "unit") unit = p.Value.GetString(); else roles[p.Name] = p.Value.GetString()!;
foreach (var p in col.GetProperty("clean").EnumerateObject())
clean[p.Name] = (p.Value[0].GetDouble(), p.Value[1].GetDouble());
return true;
}
}
4. 엑셀 토큰 채움 (EPPlus, 서버) — src/Infrastructure/Reporting/ReportFillService.cs
토큰 = 셀 값 텍스트 {{ metric=energy_efficiency; column=C-6111 }}. 엔진이 전 시트 스캔→토큰 셀을 결과값으로 치환, 메타는 셀 주석으로.
using OfficeOpenXml;
using System.Text.RegularExpressions;
using Hc900Crawler.Core.Application.DTOs;
using Hc900Crawler.Core.Application.Interfaces;
namespace Hc900Crawler.Infrastructure.Reporting;
public sealed class ReportFillService
{
private static readonly Regex TOKEN = new(@"\{\{\s*(?<body>.+?)\s*\}\}", RegexOptions.Compiled);
private readonly IReportMetricService _metrics;
public ReportFillService(IReportMetricService metrics) => _metrics = metrics;
public async Task<(byte[] xlsx, List<object> cells, string status)> FillAsync(
byte[] template, DateTime periodKst, string sourceTable, int? sessionId, CancellationToken ct = default)
{
using var pkg = new ExcelPackage(new MemoryStream(template)); // 라이선스 컨텍스트 기설정(PidExtractorService 동일)
var cells = new List<object>(); var anyErr = false; var anyOk = false;
foreach (var ws in pkg.Workbook.Worksheets)
{
var dim = ws.Dimension; if (dim == null) continue;
for (int r = dim.Start.Row; r <= dim.End.Row; r++)
for (int c = dim.Start.Column; c <= dim.End.Column; c++)
{
var txt = ws.Cells[r, c].Text;
var mt = TOKEN.Match(txt); if (!mt.Success) continue;
var kv = ParseToken(mt.Groups["body"].Value); // metric=..; column=..; field=..(opt)
var req = new MetricRequestDto {
Metric = kv.GetValueOrDefault("metric",""), Column = kv.GetValueOrDefault("column","C-6111"),
PeriodDateKst = periodKst, SourceTable = sourceTable, SessionId = sessionId };
var m = await _metrics.ComputeAsync(req, ct);
double? v = kv.TryGetValue("field", out var f) && m.Extra.TryGetValue(f, out var ev) ? ev : m.Value;
if (m.Status == "ok" && v.HasValue) { ws.Cells[r,c].Value = v.Value; anyOk = true; }
else { ws.Cells[r,c].Value = m.Status == "no_data" ? "N/A" : "ERR"; anyErr = true; }
ws.Cells[r,c].AddComment($"{m.Metric} | src={m.Source} {m.SamplingMs}ms | n={m.N} | clean={1-m.CleanedFraction:P0} | {m.Unit}", "report");
cells.Add(new { sheet=ws.Name, r, c, m.Metric, m.Column, value=v, m.Status, m.Source, m.SamplingMs, m.N });
}
}
var status = anyErr ? (anyOk ? "partial" : "error") : "ok";
return (pkg.GetAsByteArray(), cells, status);
}
private static Dictionary<string,string> ParseToken(string body) =>
body.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(p => p.Split('=', 2)).Where(a => a.Length==2)
.ToDictionary(a => a[0].Trim().ToLowerInvariant(), a => a[1].Trim());
}
5. 컨트롤러 — src/Hc900Crawler/Controllers/ReportController.cs
using Hc900Crawler.Core.Application.DTOs;
using Hc900Crawler.Core.Application.Interfaces;
using Hc900Crawler.Infrastructure.Reporting;
using Microsoft.AspNetCore.Mvc;
namespace Hc900Crawler.Web.Controllers;
[ApiController]
[Route("api/report")]
public class ReportController : ControllerBase
{
private readonly IReportMetricService _metrics;
private readonly ReportFillService _fill;
private readonly IReportTemplateStore _store; // report_template/report_run CRUD (raw SQL, 패턴 동일)
public ReportController(IReportMetricService m, ReportFillService f, IReportTemplateStore s)
{ _metrics = m; _fill = f; _store = s; }
// 단건 메트릭(미리보기/디버그)
[HttpPost("metric")]
public async Task<IActionResult> Metric([FromBody] MetricRequestDto req, CancellationToken ct)
=> Ok(await _metrics.ComputeAsync(req, ct));
// 템플릿 업로드(P0: 단순 등록)
[HttpPost("template")]
public async Task<IActionResult> Upload([FromForm] IFormFile file, [FromForm] string name, [FromForm] string? owner)
{
using var ms = new MemoryStream(); await file.CopyToAsync(ms);
var id = await _store.CreateAsync(name, owner, ms.ToArray());
return Ok(new { Id = id });
}
// ★핵심: 템플릿+날짜 → 채워진 xlsx
[HttpGet("generate")]
public async Task<IActionResult> Generate(int templateId, DateTime date,
string source = "history_table", int? sessionId = null, CancellationToken ct = default)
{
var tpl = await _store.GetBlobAsync(templateId);
if (tpl == null) return NotFound();
var (xlsx, cells, status) = await _fill.FillAsync(tpl, date, source, sessionId, ct);
await _store.RecordRunAsync(templateId, "DAILY", date, source, status, cells, xlsx);
var fname = $"report_{templateId}_{date:yyyyMMdd}.xlsx";
Response.Headers["X-Report-Status"] = status;
return File(xlsx, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fname);
}
}
IReportTemplateStore(+ReportTemplateStore)는report_template/report_runraw SQL CRUD —FeedforwardAuditService패턴 복제(생략, §10 체크리스트 포함).
6. DI 등록 — src/Hc900Crawler/Program.cs (추가 라인)
// ── P0 Report ──
builder.Services.AddSingleton(new Hc900Crawler.Infrastructure.Reporting.MetricMap(
Path.Combine(builder.Environment.ContentRootPath, "config", "report-metric-map.json")));
builder.Services.AddScoped<Hc900Crawler.Core.Application.Interfaces.IReportMetricService,
Hc900Crawler.Infrastructure.Reporting.ReportMetricService>();
builder.Services.AddScoped<Hc900Crawler.Infrastructure.Reporting.ReportFillService>();
builder.Services.AddScoped<Hc900Crawler.Core.Application.Interfaces.IReportTemplateStore,
Hc900Crawler.Infrastructure.Reporting.ReportTemplateStore>();
7. 토큰 규약 (운전원이 엑셀에 입력)
| 토큰(셀에 그대로 입력) | 채워지는 값 |
|---|---|
{{ metric=energy_efficiency; column=C-6111 }} |
일일 스팀/제품 중앙값 |
{{ metric=yield; column=C-6111 }} |
일일 제품/원료 |
{{ metric=control_residual; column=C-6111 }} |
평균 잔차(PV−SP) |
{{ metric=control_residual; column=C-6111; field=sd }} |
잔차 표준편차 |
{{ metric=control_residual; column=C-6111; field=out_pct_0_5 }} |
|잔차|>0.5℃ 시간비율 |
- 채운 셀엔 주석으로
src/sampling/n/clean/unit자동 부착(해상도-인지). - 포맷 변경 = 운전원이 셀 옮기고 토큰 복붙. 전산팀 0, 리드타임 0.
8. 프론트엔드 (최소 패널)
index.html — nav + pane 등록(기존 컨벤션):
<li class="nav-item" data-tab="reports"><span class="nl">리포트</span></li>
...
<section class="pane" id="pane-reports" data-src="/panes/reports.html?v=20260612"></section>
wwwroot/panes/reports.html (요지):
<div class="report-pane">
<input type="file" id="rpTpl" accept=".xlsx">
<input type="date" id="rpDate">
<select id="rpSource"><option value="history_table">history(60s)</option>
<option value="fast_record">fastRecord</option></select>
<input type="number" id="rpSession" placeholder="fast session id" hidden>
<button id="rpGen">리포트 생성·다운로드</button>
<div id="rpStatus"></div>
</div>
<script src="/js/reports.js"></script>
wwwroot/js/reports.js (요지 — 업로드 후 generate 호출, blob 다운로드):
document.getElementById('rpGen').onclick = async () => {
const f = document.getElementById('rpTpl').files[0];
const fd = new FormData(); fd.append('file', f); fd.append('name', f.name);
const up = await fetch('/api/report/template', {method:'POST', body:fd}).then(r=>r.json());
const date = document.getElementById('rpDate').value;
const src = document.getElementById('rpSource').value;
const sid = document.getElementById('rpSession').value;
const url = `/api/report/generate?templateId=${up.Id}&date=${date}&source=${src}`+(sid?`&sessionId=${sid}`:'');
const resp = await fetch(url);
document.getElementById('rpStatus').textContent = '상태: ' + resp.headers.get('X-Report-Status');
const blob = await resp.blob();
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = `report_${date}.xlsx`; a.click();
};
9. 수용 기준 (Acceptance) · 데모 스크립트
- C-6111 효율 토큰 1개 엑셀 → 2026-05-15 선택 → 생성 → 셀에 ~0.79 값, 주석에
src=history_table 60000ms n=~1400. - 같은 토큰을
control_residual; field=sd로 → TICA-6111A 잔차 sd 채워짐. - 데이터 없는 날(예: 2026-01-01) → 셀
N/A(0 날조 아님),X-Report-Status: partial/error. - fastRecord 세션 1개 띄워(예: 1초·5분) →
source=fast_record&sessionId=N→ 동일 효율 토큰이 sampling 60000→1000ms로 주석 바뀌어 채워짐(해상도 가변 실증). - report_run에 cells_json 박제 확인(감사·재현).
검증용 결정론 쿼리(오늘 검증과 일치): 2026-05 저율 캠페인 효율 ≈ 0.79~0.83 (검증 3회차 seg4). 엔진 값이 이 범위면 OK.
10. 파일 체크리스트 / 작업량
| 파일 | 신규/수정 | 비고 |
|---|---|---|
scripts/sql/p0_report.sql |
신규 | 2테이블 DDL |
config/report-metric-map.json |
신규 | C-6111 매핑 |
Core/Application/DTOs/ReportDtos.cs |
신규 | 3 DTO |
Core/Application/Interfaces/IReportMetricService.cs |
신규 | |
Core/Application/Interfaces/IReportTemplateStore.cs |
신규 | |
Infrastructure/Reporting/ReportMetricService.cs |
신규 | 메트릭 3종 SQL |
Infrastructure/Reporting/MetricMap.cs |
신규 | config 로더 |
Infrastructure/Reporting/ReportFillService.cs |
신규 | EPPlus 토큰 채움 |
Infrastructure/Reporting/ReportTemplateStore.cs |
신규 | raw SQL CRUD(패턴복제) |
Hc900Crawler/Controllers/ReportController.cs |
신규 | 3 엔드포인트 |
Hc900Crawler/Program.cs |
수정 | DI 4줄 |
wwwroot/index.html |
수정 | nav+pane 2줄 |
wwwroot/panes/reports.html + js/reports.js |
신규 | 최소 UI |
추정: 백엔드 핵심(메트릭+채움+컨트롤러) ~1.5일, store/DDL ~0.5일, 프론트 ~0.5일, 검증/데모 ~0.5일 → 약 3일.
11. 다음(P1 훅)
dynamics메트릭(fast_record 전용, FOPDT/stiction) — 같은 인터페이스에 metric 1개 추가.- 토큰에
period=MONTHLY|YEARLY추가 → from/to 계산만 분기. - 스케줄 자동생성 BackgroundService(기존
Hc900HistoryService패턴). - 컬럼 매핑을 tag_metadata EU레인지에서 자동도출.
근거: 본 세션 검증 1~3회차, FastController/fast_record(동일 long 포맷), EPPlus 7.4.2 기탑재(PidExtractorService), raw SQL 패턴(FeedforwardAuditService), 패널 data-src 컨벤션(index.html). 관련 메모리: product-pivot-selfservice-reporting, deterministic-verification-gate, plant-knowledge-document-first.