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; } ?> 월간 운영 보고서 - <?= htmlspecialchars($month) ?>

월간 운영 보고서

대시보드
CSV 다운로드

보고서 생성

범위:

월간 요약

임계 경보(합계)건
고온/저온 경보/건
고습/저습 경보/건
누수 감지/복구/건
정상복귀건
장비 오프라인/복구/건
SMS 실패건
측정 건수건
평균 온도/습도 /
온도 최저/최고 /
습도 최저/최고 /

센서별 요약

센서위치 임계 경보고온/저온고습/저습누수 감지/복구 정상복귀오프라인/복구SMS 실패 측정평균 온도/습도온도 최저/최고습도 최저/최고 최근 측정
/ / / / / / /
해당 월 측정·경보 내역이 없습니다.