POSA_LEAKSMS/php/monthly_report_print.php
cw2007 01ed3164a6 fix(report): SMS 신뢰성 게이지 색을 지표 자체 기준으로(종합 판정과 분리)
경영 요약의 SMS 신뢰성 게이지가 종합 판정 색($verdict_color)을 그대로 써서,
누수 등으로 판정이 '경보'면 SMS 전달 100%여도 적색 링으로 표시되던 문제 수정.
rc_reliability_color(pct, fail) 도입 — 실패 0=네이비(완전) / 경미=황(주의) /
저하=적(경보). 게이지·카드 액센트를 이 색으로. 판정 씰은 종합 판정 색 유지.
2026-07-11 17:01:28 +09:00

340 lines
19 KiB
PHP

<?php
// =============================================================================
// monthly_report_print.php - 월간 운영 보고서 · PDF 저장 전용 A4 문서 뷰
//
// monthly_report.php 의 ?format=print 분기에서 require 된다(단독 진입점 아님).
// 집계 변수는 monthly_report_data.php 가 이미 산출해 이 스코프에 있다:
// $month, $start_at, $end_at, $report_generated_at, $report_scope, $summary,
// $sensor_rows, $cal_days, $daily_temp, $daily_hum, $daily_metric_alerts,
// $daily_leak_alerts, $incidents, $has_incident_table, $unrecovered_leak,
// $verdict, $verdict_label, $verdict_reason
//
// app.css(다크 셸)는 링크하지 않는다 — assets/report-print.css 만 링크해 다크 토큰과
// 격리한다. 차트는 report_charts.php 의 인라인 SVG 서버렌더(폐쇄망: 외부 참조 0).
// 모든 동적 텍스트는 htmlspecialchars, 숫자는 fmt_num() 으로 출력한다.
// =============================================================================
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단(monthly_report.php 경유 전용)
require_once __DIR__ . '/report_charts.php';
// ── 표시용 파생값 ─────────────────────────────────────────────────────────────
$h = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
$month_dot = str_replace('-', '.', $month); // 2026-06 → 2026.06
$year_n = (int)substr($month, 0, 4);
$month_n = (int)substr($month, 5, 2);
$doc_no = 'ENV-' . $month; // 문서번호
try {
$dt_last = new DateTime($end_at);
$dt_last->modify('-1 day');
$period_end = $dt_last->format('Y-m-d');
} catch (Throwable $e) {
$period_end = substr($end_at, 0, 10);
}
$period_start = substr($start_at, 0, 10);
// 관제 대상(설정 기준)
$metric_ids = defined('METRIC_SENSOR_IDS') ? METRIC_SENSOR_IDS : [];
$leak_ids = defined('LEAK_SENSOR_IDS') ? LEAK_SENSOR_IDS : [];
$scope_total = count(array_unique(array_merge($metric_ids, $leak_ids)));
$total_alerts = $summary['threshold_alerts'] + $summary['leak_alert'];
$incident_count = $has_incident_table ? count($incidents) : $summary['leak_alert'];
// verdict 색 클래스(요약 카드/문장)
$verdict_cls = 'is-' . ($verdict === 'alert' ? 'alert' : ($verdict === 'warn' ? 'warn' : 'ok'));
$verdict_color = rc_verdict_color($verdict);
// 자동 총평 한 줄
$auto_summary = sprintf(
'당월 온습도 측정 %s건 · 임계 경보 %d건 · 누수 감지 %d건이 집계되었으며, SMS 발송 신뢰성은 %d%%(%d건 중 실패 %d건)입니다.',
number_format($summary['metrics']),
$summary['threshold_alerts'],
$summary['leak_alert'],
$summary['sms_reliability_pct'],
$summary['total_sms'],
$summary['sms_fail']
);
// 센서(지점)별 판정 — 데이터 층 verdict 규칙과 동일(미복구 누수는 월 단위라 여기선 누수감지로 근사)
$sensor_verdict = function (array $s): array {
if ($s['leak_alert'] > 0 || $s['sms_fail'] > 0) { return ['alert', '경보']; }
if ($s['threshold_alerts'] > 0 || $s['device_offline'] > 0) { return ['warn', '주의']; }
return ['ok', '안정'];
};
// 임계 기준 문자열
$rh_low_txt = (METRIC_RH_LOW >= 0) ? (rc_fmt((float)METRIC_RH_LOW) . '%') : '비활성';
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>정보시설 관제 월간 운영 보고서 - <?= $h($month) ?></title>
<link rel="stylesheet" href="assets/report-print.css">
</head>
<body>
<div class="report-canvas">
<!-- 화면 전용 안내 + 툴바(인쇄 시 자동 숨김) -->
<div class="screen-toolbar screen-only">
<a class="ghost" href="monthly_report.php?month=<?= $h($month) ?>">← 조회 화면</a>
<button type="button" onclick="window.print()">PDF로 저장 / 인쇄</button>
<a class="ghost" href="dashboard.php">대시보드</a>
</div>
<div class="print-note screen-only">
이 화면은 <b>PDF 저장 전용 A4 보고서</b> 미리보기입니다. <b>Ctrl+P</b>(⌘P) → 대상 <b>“PDF로 저장”</b>,
용지 <b>A4</b>, 여백 <b>기본</b>, <b>“배경 그래픽”</b> 옵션을 켜 주세요. 페이지 번호가 필요하면 인쇄
대화상자의 <b>머리글/바닥글</b> 옵션을 사용하세요(폐쇄망 정책상 문서 내 자동 페이지번호는 미탑재).
</div>
<!-- 러닝 헤더(매 장 반복) -->
<div class="run-header">
<span><b>정보시설 관제 월간 운영 보고서</b> · <?= $h($month_dot) ?></span>
<span class="cls">대외비 (CONFIDENTIAL)</span>
</div>
<!-- 러닝 푸터(매 장 반복 — 문서번호·생성일시로 추적성 확보) -->
<div class="run-footer">
<span>문서번호 <?= $h($doc_no) ?> · 생성 <?= $h($report_generated_at) ?></span>
<span>서버실·문서고 온습도/누수 관제 시스템 <?= $h(APP_VERSION) ?></span>
</div>
<div class="report-paper">
<div class="report-body">
<!-- ═══════════ 1. 표지 마스트헤드 ═══════════ -->
<section class="cover">
<div class="cover-seal">
<?= render_verdict_seal($verdict, $verdict_label) ?>
<div class="seal-cap">종합 판정</div>
</div>
<div class="cover-eyebrow">정보시설 관제 · 온습도 / 누수</div>
<h1 class="cover-title">정보시설 관제<br>월간 운영 보고서</h1>
<p class="cover-sub">서버실 · 문서고 온습도 및 누수 모니터링 / SMS 경보 운영 결과</p>
<div class="cover-month"><?= $h($month_dot) ?></div>
<div class="cover-period">대상 기간 <?= $h($period_start) ?> ~ <?= $h($period_end) ?> (<?= $year_n ?>년 <?= $month_n ?>월)</div>
<div class="cover-scope">
<div class="scope-item">
<div class="k">관제 지점</div>
<div class="v"><?= (int)$scope_total ?>개소</div>
<div class="t">온습도 <?= count($metric_ids) ?> · 누수 <?= count($leak_ids) ?></div>
</div>
<div class="scope-item">
<div class="k">정보기반</div>
<div class="v">서버실(안)</div>
<div class="t">온습도 계측</div>
</div>
<div class="scope-item">
<div class="k">조직혁신 · 고객센터</div>
<div class="v">문서고 · 서버실(밖)</div>
<div class="t">누수 감시</div>
</div>
</div>
<div class="doc-control">
<div><div class="dk">문서번호</div><div class="dv"><?= $h($doc_no) ?></div></div>
<div><div class="dk">생성 일시</div><div class="dv"><?= $h($report_generated_at) ?></div></div>
<div><div class="dk">문서 분류</div><div class="dv cls">대외비</div></div>
<div><div class="dk">보고 범위</div><div class="dv"><?= $h($report_scope) ?></div></div>
<div><div class="dk">데이터 출처</div><div class="dv">leak_sms_log · leak_sensor_metric</div></div>
<div><div class="dk">시스템</div><div class="dv">관제 <?= $h(APP_VERSION) ?></div></div>
</div>
</section>
<!-- ═══════════ 2. 경영 요약 ═══════════ -->
<section class="section page-break">
<div class="section-title"><span class="no">01</span> 경영 요약 <span class="en">EXECUTIVE SUMMARY</span></div>
<div class="kpi-row">
<?php
// SMS 신뢰성 게이지·카드는 종합 판정과 독립적으로 지표 자체 기준 색을 쓴다
// (누수 등으로 판정이 경보여도 SMS 전달이 100%면 네이비로 정확히 표현).
$rel_pct = (int)$summary['sms_reliability_pct'];
$rel_fail = (int)$summary['sms_fail'];
$rel_color = rc_reliability_color($rel_pct, $rel_fail);
$rel_class = $rel_fail <= 0 ? 'is-ok' : 'is-warn';
?>
<div class="kpi-card <?= $rel_class ?>">
<div class="kpi-gauge-wrap">
<?= render_uptime_gauge($rel_pct, $rel_color, $rel_pct . '%', 'SMS 신뢰성') ?>
</div>
</div>
<div class="kpi-card <?= $total_alerts > 0 ? 'is-warn' : 'is-ok' ?>">
<div class="kpi-k">총 경보</div>
<div class="kpi-v"><?= (int)$total_alerts ?></div>
<div class="kpi-s">임계 <?= (int)$summary['threshold_alerts'] ?> · 누수 <?= (int)$summary['leak_alert'] ?></div>
</div>
<div class="kpi-card <?= $summary['leak_alert'] > 0 ? 'is-alert' : 'is-ok' ?>">
<div class="kpi-k">누수 감지</div>
<div class="kpi-v"><?= (int)$summary['leak_alert'] ?></div>
<div class="kpi-s"><?= $has_incident_table ? ('사고 ' . (int)$incident_count . '건 · 미복구 ' . (int)$unrecovered_leak) : ('복구 ' . (int)$summary['leak_recovery'] . '건') ?></div>
</div>
<div class="kpi-card is-ok">
<div class="kpi-k">측정 건수</div>
<div class="kpi-v"><?= number_format($summary['metrics']) ?></div>
<div class="kpi-s">평균 <?= fmt_num($summary['avg_temp'], '℃') ?> / <?= fmt_num($summary['avg_humidity'], '%') ?></div>
</div>
</div>
<div class="exec-summary">
<div class="verdict-line <?= $verdict_cls ?>">종합 판정: <?= $h($verdict_label) ?><?= $verdict_reason !== '' ? ' — ' . $h($verdict_reason) : '' ?></div>
<p><?= $h($auto_summary) ?></p>
</div>
</section>
<!-- ═══════════ 3. 월간 지표 상세 ═══════════ -->
<section class="section">
<div class="section-title"><span class="no">02</span> 월간 지표 상세 <span class="en">MONTHLY METRICS</span></div>
<div class="metric-grid">
<div class="metric-block">
<h3>경보 · 이벤트</h3>
<table class="def">
<tr><th>고온 경보</th><td><?= (int)$summary['high_temp'] ?>건</td></tr>
<tr><th>저온 경보</th><td><?= (int)$summary['low_temp'] ?>건</td></tr>
<tr><th>고습 경보</th><td><?= (int)$summary['high_humidity'] ?>건</td></tr>
<tr><th>저습 경보</th><td><?= (int)$summary['low_humidity'] ?>건</td></tr>
<tr><th>임계 경보 합계</th><td><?= (int)$summary['threshold_alerts'] ?>건</td></tr>
<tr><th>누수 감지 / 복구</th><td class="<?= $summary['leak_alert'] > 0 ? 'is-alert' : '' ?>"><?= (int)$summary['leak_alert'] ?> / <?= (int)$summary['leak_recovery'] ?>건</td></tr>
<tr><th>정상 복귀</th><td><?= (int)$summary['recovery'] ?>건</td></tr>
<tr><th>장비 오프라인 / 복구</th><td><?= (int)$summary['device_offline'] ?> / <?= (int)$summary['device_online'] ?>건</td></tr>
<tr><th>SMS 실패 / 총 발송</th><td class="<?= $summary['sms_fail'] > 0 ? 'is-alert' : '' ?>"><?= (int)$summary['sms_fail'] ?> / <?= (int)$summary['total_sms'] ?>건</td></tr>
</table>
</div>
<div class="metric-block">
<h3>온습도 측정</h3>
<table class="def">
<tr><th>측정 건수</th><td><?= number_format($summary['metrics']) ?>건</td></tr>
<tr><th>평균 온도</th><td><?= fmt_num($summary['avg_temp'], '℃') ?></td></tr>
<tr><th>온도 최저 / 최고</th><td><?= fmt_num($summary['min_temp'], '℃') ?> / <?= fmt_num($summary['max_temp'], '℃') ?></td></tr>
<tr><th>평균 습도</th><td><?= fmt_num($summary['avg_humidity'], '%') ?></td></tr>
<tr><th>습도 최저 / 최고</th><td><?= fmt_num($summary['min_humidity'], '%') ?> / <?= fmt_num($summary['max_humidity'], '%') ?></td></tr>
<tr><th>SMS 신뢰성</th><td><?= (int)$summary['sms_reliability_pct'] ?>%</td></tr>
<tr><th>고온 / 저온 임계</th><td><?= $h(rc_fmt((float)METRIC_TEMP_HIGH_C)) ?>℃ / <?= $h(rc_fmt((float)METRIC_TEMP_LOW_C)) ?>℃</td></tr>
<tr><th>고습 / 저습 임계</th><td><?= $h(rc_fmt((float)METRIC_RH_HIGH)) ?>% / <?= $h($rh_low_txt) ?></td></tr>
</table>
</div>
</div>
</section>
<!-- ═══════════ 4. 월중 추이(차트) ═══════════ -->
<section class="section">
<div class="section-title"><span class="no">03</span> 월중 추이 <span class="en">DAILY TRENDS</span></div>
<div class="chart-panel">
<h3>일별 평균 온·습도</h3>
<p class="chart-cap">임계 밴드(고온 적 · 안전 녹 · 저온 청)를 배경에 인쇄. 온도 실선(좌축) · 습도 파선(우축). 결측일은 선이 끊깁니다.</p>
<?= render_daily_temp_hum_line($cal_days, $daily_temp, $daily_hum) ?>
</div>
<div class="chart-panel">
<h3>일별 경보 발생</h3>
<p class="chart-cap">온습도 임계 경보(네이비)와 누수 감지(적 · 사선)를 당월 1일~말일 축에 스택으로 표시합니다.</p>
<?= render_daily_alert_bars($cal_days, $daily_metric_alerts, $daily_leak_alerts) ?>
</div>
</section>
<!-- ═══════════ 5. 관제 지점별 상세 ═══════════ -->
<section class="section">
<div class="section-title"><span class="no">04</span> 관제 지점별 상세 <span class="en">BY LOCATION</span></div>
<?php if (empty($sensor_rows)): ?>
<div class="chart-empty">해당 월 측정·경보 내역이 없습니다.</div>
<?php else: ?>
<div class="loc-cards">
<?php foreach ($sensor_rows as $s): [$sv_cls, $sv_lab] = $sensor_verdict($s); ?>
<div class="loc-card">
<div class="lc-head">
<div>
<div class="lc-name"><?= $h($s['sensor_name']) ?></div>
<div class="lc-loc"><?= $h($s['device_location']) ?></div>
</div>
<span class="pill is-<?= $sv_cls ?>"><?= $h($sv_lab) ?></span>
</div>
<dl>
<dt>임계 경보</dt><dd><?= (int)$s['threshold_alerts'] ?>건</dd>
<dt>고온/저온</dt><dd><?= (int)$s['high_temp'] ?>/<?= (int)$s['low_temp'] ?></dd>
<dt>고습/저습</dt><dd><?= (int)$s['high_humidity'] ?>/<?= (int)$s['low_humidity'] ?></dd>
<dt>누수 감지/복구</dt><dd class="<?= $s['leak_alert'] > 0 ? 'is-alert' : '' ?>"><?= (int)$s['leak_alert'] ?>/<?= (int)$s['leak_recovery'] ?></dd>
<dt>오프라인/복구</dt><dd><?= (int)$s['device_offline'] ?>/<?= (int)$s['device_online'] ?></dd>
<dt>SMS 실패</dt><dd class="<?= $s['sms_fail'] > 0 ? 'is-alert' : '' ?>"><?= (int)$s['sms_fail'] ?></dd>
<dt>측정</dt><dd><?= number_format($s['metrics']) ?>건</dd>
<dt>평균 온/습</dt><dd><?= fmt_num($s['avg_temp'], '℃') ?> / <?= fmt_num($s['avg_humidity'], '%') ?></dd>
<dt>온도 최저/최고</dt><dd><?= fmt_num($s['min_temp'], '℃') ?> / <?= fmt_num($s['max_temp'], '℃') ?></dd>
<dt>습도 최저/최고</dt><dd><?= fmt_num($s['min_humidity'], '%') ?> / <?= fmt_num($s['max_humidity'], '%') ?></dd>
<dt>최근 측정</dt><dd><?= $h($s['last_measured_at'] ?: '-') ?></dd>
</dl>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<!-- ═══════════ 6. 누수 사고 로그 ═══════════ -->
<section class="section">
<div class="section-title"><span class="no">05</span> 누수 사고 로그 <span class="en">LEAK INCIDENTS</span></div>
<?php if (!$has_incident_table): ?>
<div class="chart-empty">누수 사고 이력(leak_incident) 테이블이 도입되지 않은 배포입니다. SMS 태그 집계로 대체합니다 — 당월 누수 감지 <?= (int)$summary['leak_alert'] ?>건 / 복구 <?= (int)$summary['leak_recovery'] ?>건.</div>
<?php elseif (empty($incidents)): ?>
<div class="incident-none">✓ 당월 누수 사고 없음 (0건) — 감시 구간 내 누수 미발생.</div>
<?php else: ?>
<table class="grid">
<thead><tr><th>발생 일시</th><th>지점</th><th>위치</th><th>지속</th><th>상태</th></tr></thead>
<tbody>
<?php foreach ($incidents as $inc): ?>
<tr>
<td class="l"><?= $h($inc['started_at']) ?></td>
<td class="l"><?= $h($inc['sensor_name']) ?></td>
<td class="l"><?= $h($inc['device_location']) ?></td>
<td><?php
if ($inc['ongoing']) { echo '진행중'; }
elseif ($inc['duration_min'] !== null) {
$mm = (int)$inc['duration_min'];
echo $mm >= 60 ? intdiv($mm, 60) . '시간 ' . ($mm % 60) . '분' : $mm . '분';
} else { echo '-'; }
?></td>
<td class="<?= $inc['ongoing'] ? 'is-alert' : '' ?>"><?= $inc['ongoing'] ? '미복구' : '복구' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</section>
<!-- ═══════════ 7. 기준 · 주석 및 서명 ═══════════ -->
<section class="section">
<div class="section-title"><span class="no">06</span> 기준 · 주석 및 서명 <span class="en">BASIS &amp; SIGN-OFF</span></div>
<div class="basis-grid">
<div class="basis-block">
<h3>판정 · 임계 기준</h3>
<ul>
<li><b>임계</b> 고온 <?= $h(rc_fmt((float)METRIC_TEMP_HIGH_C)) ?>℃ · 저온 <?= $h(rc_fmt((float)METRIC_TEMP_LOW_C)) ?>℃ · 고습 <?= $h(rc_fmt((float)METRIC_RH_HIGH)) ?>% · 저습 <?= $h($rh_low_txt) ?></li>
<li><b>판정 규칙</b> 미복구 누수 또는 SMS 실패 &gt; 0 → 경보 · 임계 경보 또는 오프라인 발생 → 주의 · 그 외 → 안정</li>
<li><b>오프라인 판정</b> <?= (int)(HEARTBEAT_TIMEOUT_SEC / 60) ?>분 무수신 · <b>경보 쿨다운</b> 온습도 <?= (int)(METRIC_ALERT_COOLDOWN_SEC / 60) ?>분 / 누수 <?= (int)(LEAK_ALERT_COOLDOWN_SEC / 60) ?>분</li>
</ul>
</div>
<div class="basis-block">
<h3>데이터 · 주석</h3>
<ul>
<li><b>출처</b> leak_sms_log(태그 접두 집계) · leak_sensor_metric(측정) · leak_sensor_log/leak_incident(누수)</li>
<li><b>집계 범위</b> <?= $h($period_start) ?> 00:00 ~ <?= $h($period_end) ?> 24:00 (센서는 sensor_id 기준, 표시명은 관리주체 라벨)</li>
<li><b>개인정보</b> 수신번호는 표시 단계에서 마스킹(mask_phone) · 본 문서 분류: <span style="color:#b11f1f;font-weight:700">대외비</span></li>
</ul>
</div>
</div>
<div class="sign-row">
<div class="sign-box"><div class="sk">작성</div><div class="sv"></div><div class="sn">직/성명 · 일자</div></div>
<div class="sign-box"><div class="sk">검토</div><div class="sv"></div><div class="sn">직/성명 · 일자</div></div>
<div class="sign-box"><div class="sk">승인</div><div class="sv"></div><div class="sn">직/성명 · 일자</div></div>
</div>
</section>
</div><!-- /report-body -->
</div><!-- /report-paper -->
</div><!-- /report-canvas -->
</body>
</html>