POSA_LEAKSMS/php/monthly_report.php
cw2007 06f96a8d09 feat(report): 월간 보고서 PDF 저장 전용 A4 문서 뷰
다크 대시보드를 그대로 인쇄하던 '단순 프린트지'를 화이트 A4 전문 보고서로 대체.
?format=print 로 브라우저 print-to-PDF 하면 표지·요약·차트·서명을 갖춘 정식 문서가
나온다. 설계 3안(경영진 브리프/보안증적/비주얼 애널리틱스) 병렬 → 종합 → 구현.
실제 브라우저 렌더로 6개 섹션 전부 검증.

구성:
- 표지 마스트헤드: 대형 대상월 + 관제 판정 씰(SVG 원형 도장 — 데이터 자동 판정
  安定/注意/警報)이 시그니처. 러닝 헤더/푸터(대외비·문서번호·시스템).
- 01 경영 요약: 헤드라인 KPI 4(SMS신뢰성 게이지·총경보·누수·측정) + 자동 총평 내러티브.
- 02 월간 지표: 경보·이벤트 / 온습도 측정 2열 정의형.
- 03 추이(인라인 SVG): 일별 평균 온·습도(안전/고온/저온 밴드·임계선), 일별 경보 스택
  막대(온습도=네이비 / 누수=적색 사선 해치 — 흑백 인쇄 구분).
- 04 관제 지점별 상세 카드, 05 누수 사고 로그(지속시간·복구), 06 기준·주석 및 서명란.

구현:
- 집계를 monthly_report_data.php(공유 include)로 추출 — HTML 조회·CSV·PDF 뷰가 단일
  소스 사용(회귀 0: HTML 표 3행·요약 11카드·CSV 3행 렌더 확인). 일별 추이·누수 사고·
  판정 파생은 인쇄 뷰 전용으로 추가.
- monthly_report_print.php(A4 뷰) + report_charts.php(인라인 SVG 차트) + assets/report-print.css
  (@page A4·여백·page-break·running header/footer·print-color-adjust). app.css 다크 토큰과
  격리(.report-paper 스코프, report-print.css 단독 링크).
- 폐쇄망 준수: 외부 CDN/JS/폰트/PDF 라이브러리 0, 시스템 폰트, 인라인 SVG.
- 보안: 신규 include 3파일 직접접근 가드 + .htaccess FilesMatch 차단(monthly_report.php
  본체는 진입 페이지라 제외). sensor_label 라벨·htmlspecialchars 이스케이프.
2026-07-11 16:56:02 +09:00

202 lines
12 KiB
PHP

<?php
// =============================================================================
// monthly_report.php - 월간 운영 보고서 (온습도 + 누수 통합)
//
// 집계 범위: 온습도 임계 경보(고온/저온/고습/저습), 누수 감지/복구, 정상복귀,
// 장비 오프라인/복구, SMS 실패, 측정 건수, 평균/최저/최고 온도·습도.
// 데이터 출처: leak_sms_log(message 태그 prefix 기준 — config.php sms_summary_select_sql),
// leak_sensor_metric(온습도 측정 이력).
// 센서 표시명/위치는 대시보드·SMS 와 동일하게 sensor_label()(SENSOR_LABELS 오버라이드,
// config.local.php)로 통일한다. 센서별 집계는 sensor_id 로만 묶어(과거 보고명이 달라도
// 한 센서가 여러 행으로 쪼개지지 않게) 현재 관리주체 라벨로 표시한다.
// =============================================================================
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/admin_security.php';
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1);
ini_set('session.cookie_samesite', 'Strict');
ini_set('session.use_strict_mode', 1);
session_start();
if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
header('Location: login.php');
exit;
}
admin_enforce_session_timeout(); // 유휴 + 절대 수명 타임아웃(대시보드 외 페이지에서도 강제)
// ── 집계 데이터 계층(단일 출처) ──────────────────────────────────────────────
// 헬퍼(month_range/round_or_null/fmt_num/csv_cell)와 $summary/$sensor_rows 및 인쇄
// 뷰 전용 파생(일별 추이·누수 사고·verdict)을 이 include 가 산출한다. HTML 조회 뷰,
// CSV 다운로드, PDF 인쇄 뷰가 동일 소스를 공유한다(집계 중복 0 — 회귀 방지).
require_once __DIR__ . '/monthly_report_data.php';
// ── CSV 다운로드 ─────────────────────────────────────────────────────────────
if (($_GET['format'] ?? '') === 'csv') {
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="sht30-report-' . $month . '.csv"');
$out = fopen('php://output', 'w');
fputcsv($out, ['month', $month]);
fputcsv($out, ['generated_at', $report_generated_at]);
fputcsv($out, ['scope', $report_scope]);
fputcsv($out, ['threshold_alerts', $summary['threshold_alerts']]);
fputcsv($out, ['high_temp_alerts', $summary['high_temp']]);
fputcsv($out, ['low_temp_alerts', $summary['low_temp']]);
fputcsv($out, ['high_humidity_alerts', $summary['high_humidity']]);
fputcsv($out, ['low_humidity_alerts', $summary['low_humidity']]);
fputcsv($out, ['leak_alert_sms', $summary['leak_alert']]);
fputcsv($out, ['leak_recovery_sms', $summary['leak_recovery']]);
fputcsv($out, ['recovery_sms', $summary['recovery']]);
fputcsv($out, ['device_offline_sms', $summary['device_offline']]);
fputcsv($out, ['device_online_sms', $summary['device_online']]);
fputcsv($out, ['sms_fail', $summary['sms_fail']]);
fputcsv($out, ['metrics', $summary['metrics']]);
fputcsv($out, ['avg_temp_c', $summary['avg_temp'] ?? '']);
fputcsv($out, ['min_temp_c', $summary['min_temp'] ?? '']);
fputcsv($out, ['max_temp_c', $summary['max_temp'] ?? '']);
fputcsv($out, ['avg_humidity_percent', $summary['avg_humidity'] ?? '']);
fputcsv($out, ['min_humidity_percent', $summary['min_humidity'] ?? '']);
fputcsv($out, ['max_humidity_percent', $summary['max_humidity'] ?? '']);
fputcsv($out, []);
fputcsv($out, [
'sensor_id', 'sensor_name', 'location',
'high_temp', 'low_temp', 'high_humidity', 'low_humidity', 'threshold_alerts',
'leak_alert', 'leak_recovery',
'recovery', 'device_offline', 'device_online', 'sms_fail',
'metrics', 'avg_temp_c', 'min_temp_c', 'max_temp_c',
'avg_humidity_percent', 'min_humidity_percent', 'max_humidity_percent',
'last_measured_at',
]);
foreach ($sensor_rows as $s) {
fputcsv($out, [
$s['sensor_id'], csv_cell($s['sensor_name']), csv_cell($s['device_location']),
$s['high_temp'], $s['low_temp'], $s['high_humidity'], $s['low_humidity'], $s['threshold_alerts'],
$s['leak_alert'], $s['leak_recovery'],
$s['recovery'], $s['device_offline'], $s['device_online'], $s['sms_fail'],
$s['metrics'],
$s['avg_temp'] ?? '', $s['min_temp'] ?? '', $s['max_temp'] ?? '',
$s['avg_humidity'] ?? '', $s['min_humidity'] ?? '', $s['max_humidity'] ?? '',
$s['last_measured_at'],
]);
}
fclose($out);
exit;
}
// ── PDF 저장 전용 A4 문서 뷰 ─────────────────────────────────────────────────
// 다크 대시보드(app.css)를 그대로 인쇄하던 단순 프린트지를 대체한다. 화이트 A4 전문
// 보고서(표지·경영요약·지표·추이·지점별·누수사고·서명)를 렌더한다. 집계는 위 include 가
// 이미 산출했으므로 여기서 뷰만 그린다(CSV·HTML 조회 뷰는 불변 — 회귀 없음).
if (($_GET['format'] ?? '') === 'print') {
require __DIR__ . '/monthly_report_print.php';
exit;
}
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>월간 운영 보고서 - <?= htmlspecialchars($month) ?></title>
<link rel="stylesheet" href="assets/app.css">
<style>
/* 월간 리포트 전용: 조회 툴바(공유 app.css 미포함). 값은 전부 토큰 — 생 hex 없음 */
.toolbar {
display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3);
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r);
box-shadow: var(--shadow); padding: var(--sp-3) var(--sp-4); margin-bottom: var(--sp-5);
}
.toolbar label { display: flex; align-items: center; gap: var(--sp-2); }
.toolbar input[type="month"] {
padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--r-sm);
font-family: inherit; font-size: .875rem; color: var(--ink); background: var(--surface);
}
.toolbar button, .toolbar .tool-btn {
padding: 8px 14px; border: 1px solid var(--primary); border-radius: var(--r-sm);
font-size: .875rem; font-weight: 700; cursor: pointer; text-decoration: none;
background: var(--primary); color: var(--on-primary);
}
.toolbar button:hover, .toolbar .tool-btn:hover {
background: var(--primary-600); border-color: var(--primary-600);
}
/* 표 빈 상태 셀(가운데 정렬·뮤트) — app.css 는 블록형 .empty-note 만 제공 */
.no-data { text-align: center; color: var(--ink-3); }
/* 툴바는 페이지 전용 요소 → app.css print 규칙에 없어 중복이 아님 */
@media print { .toolbar { display: none; } }
</style>
</head>
<body>
<header>
<h1><?= htmlspecialchars($month) ?> 월간 운영 보고서</h1>
<div class="top-actions">
<a href="dashboard.php">대시보드</a>
</div>
</header>
<main class="container">
<form class="toolbar" method="GET">
<label>월 <input type="month" name="month" value="<?= htmlspecialchars($month) ?>"></label>
<button type="submit">조회</button>
<a class="tool-btn" href="?month=<?= htmlspecialchars($month) ?>&format=print" target="_blank" rel="noopener">PDF 보고서</a>
<a class="tool-btn" href="?month=<?= htmlspecialchars($month) ?>&format=csv">CSV 다운로드</a>
</form>
<section class="panel">
<p class="caption">보고서 생성</p>
<strong><?= htmlspecialchars($report_generated_at) ?></strong>
<p class="caption">범위: <?= htmlspecialchars($report_scope) ?></p>
</section>
<h2>월간 요약</h2>
<section class="report-grid">
<div class="report-stat"><span>임계 경보(합계)</span><strong><?= $summary['threshold_alerts'] ?>건</strong></div>
<div class="report-stat"><span>고온/저온 경보</span><strong><?= $summary['high_temp'] ?>/<?= $summary['low_temp'] ?>건</strong></div>
<div class="report-stat"><span>고습/저습 경보</span><strong><?= $summary['high_humidity'] ?>/<?= $summary['low_humidity'] ?>건</strong></div>
<div class="report-stat"><span>누수 감지/복구</span><strong class="<?= $summary['leak_alert'] > 0 ? 'err-color' : '' ?>"><?= $summary['leak_alert'] ?>/<?= $summary['leak_recovery'] ?>건</strong></div>
<div class="report-stat"><span>정상복귀</span><strong><?= $summary['recovery'] ?>건</strong></div>
<div class="report-stat"><span>장비 오프라인/복구</span><strong><?= $summary['device_offline'] ?>/<?= $summary['device_online'] ?>건</strong></div>
<div class="report-stat"><span>SMS 실패</span><strong><?= $summary['sms_fail'] ?>건</strong></div>
<div class="report-stat"><span>측정 건수</span><strong><?= $summary['metrics'] ?>건</strong></div>
<div class="report-stat"><span>평균 온도/습도</span><strong><?= fmt_num($summary['avg_temp'], '℃') ?> / <?= fmt_num($summary['avg_humidity'], '%') ?></strong></div>
<div class="report-stat"><span>온도 최저/최고</span><strong><?= fmt_num($summary['min_temp'], '℃') ?> / <?= fmt_num($summary['max_temp'], '℃') ?></strong></div>
<div class="report-stat"><span>습도 최저/최고</span><strong><?= fmt_num($summary['min_humidity'], '%') ?> / <?= fmt_num($summary['max_humidity'], '%') ?></strong></div>
</section>
<h2>센서별 요약</h2>
<div class="table-wrap">
<table>
<thead><tr>
<th>센서</th><th>위치</th>
<th>임계 경보</th><th>고온/저온</th><th>고습/저습</th><th>누수 감지/복구</th>
<th>정상복귀</th><th>오프라인/복구</th><th>SMS 실패</th>
<th>측정</th><th>평균 온도/습도</th><th>온도 최저/최고</th><th>습도 최저/최고</th>
<th>최근 측정</th>
</tr></thead>
<tbody>
<?php foreach ($sensor_rows as $s): ?>
<tr>
<td><?= htmlspecialchars($s['sensor_name']) ?></td>
<td><?= htmlspecialchars($s['device_location']) ?></td>
<td><?= $s['threshold_alerts'] ?></td>
<td><?= $s['high_temp'] ?>/<?= $s['low_temp'] ?></td>
<td><?= $s['high_humidity'] ?>/<?= $s['low_humidity'] ?></td>
<td class="<?= $s['leak_alert'] > 0 ? 'err-color' : '' ?>"><?= $s['leak_alert'] ?>/<?= $s['leak_recovery'] ?></td>
<td><?= $s['recovery'] ?></td>
<td><?= $s['device_offline'] ?>/<?= $s['device_online'] ?></td>
<td><?= $s['sms_fail'] ?></td>
<td><?= $s['metrics'] ?></td>
<td><?= fmt_num($s['avg_temp'], '℃') ?> / <?= fmt_num($s['avg_humidity'], '%') ?></td>
<td><?= fmt_num($s['min_temp'], '℃') ?> / <?= fmt_num($s['max_temp'], '℃') ?></td>
<td><?= fmt_num($s['min_humidity'], '%') ?> / <?= fmt_num($s['max_humidity'], '%') ?></td>
<td><?= htmlspecialchars($s['last_measured_at'] ?: '-') ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($sensor_rows)): ?>
<tr><td colspan="14" class="no-data">해당 월 측정·경보 내역이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</main>
</body>
</html>