다크 대시보드를 그대로 인쇄하던 '단순 프린트지'를 화이트 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 이스케이프.
202 lines
12 KiB
PHP
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>
|