POSA_LEAKSMS/php/monthly_report.php
cw2007 553e59deb8 fix(report): 월간 센서별 요약을 현재 기준(라벨·누수·sensor_id 그룹)으로 정합
센서별 요약이 예전 보고명/이상값을 보이던 문제 수정. MockPDO 하네스로 CSV
출력 검증(각 센서 1행, 누수 컬럼, 범위 반영).

- 센서 표시명/위치를 sensor_label()(SENSOR_LABELS 관리주체 라벨)로 통일 —
  대시보드·SMS 와 동일 기준. 기존엔 Pi 보고 원본 sensor_name/device_location 을
  그대로 노출(예전 값).
- 센서별 집계를 GROUP BY sensor_id 로만 묶음(기존: sensor_id+sensor_name/location).
  과거 보고명이 달라져도 한 센서가 여러 행으로 쪼개지지 않음(이상 행 제거).
- 누수 감지/복구를 월간 요약·센서별 표·CSV 에 반영(누수 모듈 통합 기준).
  쿼리는 이미 leak_alert/leak_recovery 를 가져왔으나 표시에서 누락돼 있었음.
- 정렬을 온습도 경보 + 누수 감지 합계 기준으로. 범위 문구에 '누수 감지' 추가.
- 미사용·구식 report_sms_tags() 제거(누수 태그 누락된 dead 매핑).
2026-07-11 12:39:39 +09:00

362 lines
19 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(); // 유휴 + 절대 수명 타임아웃(대시보드 외 페이지에서도 강제)
function month_range(string $month): array {
if (!preg_match('/^\d{4}-\d{2}$/', $month)) {
$month = date('Y-m');
}
$start = DateTime::createFromFormat('Y-m-d H:i:s', $month . '-01 00:00:00');
if (!$start) {
$start = new DateTime('first day of this month 00:00:00');
}
$end = clone $start;
$end->modify('first day of next month');
return [$month, $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s')];
}
function round_or_null($value, int $precision = 1): ?float {
return $value !== null ? round((float)$value, $precision) : null;
}
function fmt_num(?float $value, string $unit = ''): string {
if ($value === null) {
return '-';
}
return htmlspecialchars(rtrim(rtrim(number_format($value, 1), '0'), '.')) . $unit;
}
// CSV 수식 주입(formula injection) 방지: 자유 텍스트 셀이 =,+,-,@,탭,CR 로 시작하면
// 선행 작은따옴표로 무력화한다(Excel/Sheets 가 수식으로 실행하지 않도록).
// 숫자 셀(음수 온도 등)은 호출하지 않는다 — 값이 깨지지 않도록.
function csv_cell($value): string {
$s = (string)$value;
if ($s !== '' && in_array($s[0], ['=', '+', '-', '@', "\t", "\r"], true)) {
return "'" . $s;
}
return $s;
}
$db = get_db();
[$month, $start_at, $end_at] = month_range($_GET['month'] ?? date('Y-m'));
$report_generated_at = date('Y-m-d H:i:s');
$report_scope = '온습도 임계 경보, 누수 감지, 장비 오프라인, SMS';
// ── 월간 요약: SMS 태그별 건수 + 실패 건수 ───────────────────────────────────
$stmt = $db->prepare("
SELECT
" . sms_summary_select_sql() . "
FROM leak_sms_log
WHERE sent_at >= :start_at AND sent_at < :end_at
");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
$row = $stmt->fetch() ?: [];
$summary = [
'high_temp' => (int)($row['high_temp'] ?? 0),
'low_temp' => (int)($row['low_temp'] ?? 0),
'high_humidity' => (int)($row['high_humidity'] ?? 0),
'low_humidity' => (int)($row['low_humidity'] ?? 0),
'recovery' => (int)($row['recovery'] ?? 0),
'device_offline' => (int)($row['device_offline'] ?? 0),
'device_online' => (int)($row['device_online'] ?? 0),
'leak_alert' => (int)($row['leak_alert'] ?? 0),
'leak_recovery' => (int)($row['leak_recovery'] ?? 0),
'sms_fail' => (int)($row['sms_fail'] ?? 0),
'metrics' => 0,
'avg_temp' => null,
'min_temp' => null,
'max_temp' => null,
'avg_humidity' => null,
'min_humidity' => null,
'max_humidity' => null,
];
$summary['threshold_alerts'] = $summary['high_temp'] + $summary['low_temp']
+ $summary['high_humidity'] + $summary['low_humidity'];
// ── 월간 요약: 측정 건수 + 평균/최저/최고 온도·습도 ───────────────────────────
$stmt = $db->prepare("
SELECT
COUNT(*) AS metrics,
AVG(temperature_c) AS avg_temp,
MIN(temperature_c) AS min_temp,
MAX(temperature_c) AS max_temp,
AVG(humidity_percent) AS avg_humidity,
MIN(humidity_percent) AS min_humidity,
MAX(humidity_percent) AS max_humidity
FROM leak_sensor_metric
WHERE measured_at >= :start_at AND measured_at < :end_at
");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
$row = $stmt->fetch() ?: [];
$summary['metrics'] = (int)($row['metrics'] ?? 0);
$summary['avg_temp'] = round_or_null($row['avg_temp'] ?? null);
$summary['min_temp'] = round_or_null($row['min_temp'] ?? null);
$summary['max_temp'] = round_or_null($row['max_temp'] ?? null);
$summary['avg_humidity'] = round_or_null($row['avg_humidity'] ?? null);
$summary['min_humidity'] = round_or_null($row['min_humidity'] ?? null);
$summary['max_humidity'] = round_or_null($row['max_humidity'] ?? null);
// ── 센서별 SMS 태그별 건수 + 실패 건수 ───────────────────────────────────────
// sensor_id 로만 묶는다(보고명 sensor_name 은 시기별로 다를 수 있어 GROUP 키에서 제외 —
// 한 센서가 여러 행으로 쪼개지는 것 방지). 표시명은 아래에서 sensor_label() 로 결정.
$stmt = $db->prepare("
SELECT sensor_id, MAX(sensor_name) AS sensor_name,
" . sms_summary_select_sql() . "
FROM leak_sms_log
WHERE sent_at >= :start_at AND sent_at < :end_at
GROUP BY sensor_id
");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
$sms_by_sensor = [];
foreach ($stmt->fetchAll() as $r) {
$sms_by_sensor[(int)$r['sensor_id']] = $r;
}
// ── 센서별 측정 집계(온습도) ─────────────────────────────────────────────────
// 마찬가지로 sensor_id 로만 묶고, 보고명/위치는 최신 대표값(MAX)만 폴백용으로 가져온다.
$stmt = $db->prepare("
SELECT sensor_id,
MAX(sensor_name) AS sensor_name,
MAX(device_location) AS device_location,
COUNT(*) AS metrics,
AVG(temperature_c) AS avg_temp,
MIN(temperature_c) AS min_temp,
MAX(temperature_c) AS max_temp,
AVG(humidity_percent) AS avg_humidity,
MIN(humidity_percent) AS min_humidity,
MAX(humidity_percent) AS max_humidity,
MAX(measured_at) AS last_measured_at
FROM leak_sensor_metric
WHERE measured_at >= :start_at AND measured_at < :end_at
GROUP BY sensor_id
");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
$metric_by_sensor = [];
foreach ($stmt->fetchAll() as $r) {
$metric_by_sensor[(int)$r['sensor_id']] = $r;
}
// ── 센서별 행 병합(측정 + SMS) ───────────────────────────────────────────────
$sensor_rows = [];
foreach (array_unique(array_merge(array_keys($metric_by_sensor), array_keys($sms_by_sensor))) as $sid) {
$m = $metric_by_sensor[$sid] ?? [];
$s = $sms_by_sensor[$sid] ?? [];
// 표시명/위치: SENSOR_LABELS(현재 관리주체) 우선, 없으면 보고 대표값 폴백 — 대시보드·SMS 와 동일
[$loc, $name] = sensor_label((int)$sid, $m['device_location'] ?? null, $m['sensor_name'] ?? $s['sensor_name'] ?? null);
$threshold_alerts = (int)($s['high_temp'] ?? 0) + (int)($s['low_temp'] ?? 0)
+ (int)($s['high_humidity'] ?? 0) + (int)($s['low_humidity'] ?? 0);
$leak_alert = (int)($s['leak_alert'] ?? 0);
$sensor_rows[] = [
'sensor_id' => $sid,
'sensor_name' => $name,
'device_location' => $loc,
'high_temp' => (int)($s['high_temp'] ?? 0),
'low_temp' => (int)($s['low_temp'] ?? 0),
'high_humidity' => (int)($s['high_humidity'] ?? 0),
'low_humidity' => (int)($s['low_humidity'] ?? 0),
'threshold_alerts' => $threshold_alerts,
'leak_alert' => $leak_alert,
'leak_recovery' => (int)($s['leak_recovery'] ?? 0),
'recovery' => (int)($s['recovery'] ?? 0),
'device_offline' => (int)($s['device_offline'] ?? 0),
'device_online' => (int)($s['device_online'] ?? 0),
'sms_fail' => (int)($s['sms_fail'] ?? 0),
'metrics' => (int)($m['metrics'] ?? 0),
'avg_temp' => round_or_null($m['avg_temp'] ?? null),
'min_temp' => round_or_null($m['min_temp'] ?? null),
'max_temp' => round_or_null($m['max_temp'] ?? null),
'avg_humidity' => round_or_null($m['avg_humidity'] ?? null),
'min_humidity' => round_or_null($m['min_humidity'] ?? null),
'max_humidity' => round_or_null($m['max_humidity'] ?? null),
'last_measured_at' => $m['last_measured_at'] ?? '',
'alert_sort' => $threshold_alerts + $leak_alert, // 정렬용(온습도 경보 + 누수 감지)
];
}
usort($sensor_rows, function ($a, $b) {
return [$b['alert_sort'], $a['sensor_id']] <=> [$a['alert_sort'], $b['sensor_id']];
});
// ── 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;
}
?>
<!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=csv">CSV 다운로드</a>
<button type="button" onclick="window.print()">인쇄/PDF</button>
</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>