modify('first day of next month'); return [$month, $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s')]; } } if (!function_exists('round_or_null')) { function round_or_null($value, int $precision = 1): ?float { return $value !== null ? round((float)$value, $precision) : null; } } if (!function_exists('fmt_num')) { 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 가 수식으로 실행하지 않도록). // 숫자 셀(음수 온도 등)은 호출하지 않는다 — 값이 깨지지 않도록. if (!function_exists('csv_cell')) { function csv_cell($value): string { $s = (string)$value; if ($s !== '' && in_array($s[0], ['=', '+', '-', '@', "\t", "\r"], true)) { return "'" . $s; } return $s; } } // 테이블 존재 확인(정보스키마) — dashboard_data.php 의 table_exists() 와 동일 취지지만 // 그 파일은 include 시 부작용(전역 쿼리) 위험이 있어 결합을 피한다. 부재 시 graceful // degrade(누수 사고 섹션·판정 폴백). 요청 내 static 캐시로 재조회 방지. if (!function_exists('report_table_exists')) { function report_table_exists(PDO $db, string $table): bool { static $cache = []; if (array_key_exists($table, $cache)) { return $cache[$table]; } try { $stmt = $db->prepare(" SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table "); $stmt->execute([':table' => $table]); return $cache[$table] = (int)$stmt->fetchColumn() > 0; } catch (Throwable $e) { return $cache[$table] = false; } } } $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() . ", COUNT(*) AS total_sms 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), 'total_sms' => (int)($row['total_sms'] ?? 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 신뢰성(성공률, %) — 총 발송 대비 성공 비율. 발송이 없으면 100%로 본다. $summary['sms_reliability_pct'] = $summary['total_sms'] > 0 ? (int)round(($summary['total_sms'] - $summary['sms_fail']) / $summary['total_sms'] * 100) : 100; // ── 센서별 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']]; }); // ─── 인쇄 뷰 전용 파생: 당월 일자 축(cal_days) ──────────────────────────────── // 일별 차트가 28~31 가변 말일을 정확히 다루도록 1일~말일 전체 축을 만든다. 결측일은 // null 로 남겨(추이 라인 끊김) 왜곡을 막는다. $cal_days = []; try { $cursor = new DateTime($start_at); $stop = new DateTime($end_at); while ($cursor < $stop) { $cal_days[] = $cursor->format('Y-m-d'); $cursor->modify('+1 day'); } } catch (Throwable $e) { $cal_days = []; } // ─── 쿼리A: 일별 평균 온·습도 (strip-chart 용) ──────────────────────────────── $daily_temp = array_fill_keys($cal_days, null); $daily_hum = array_fill_keys($cal_days, null); try { $stmt = $db->prepare(" SELECT DATE(measured_at) AS d, AVG(temperature_c) AS avg_t, AVG(humidity_percent) AS avg_h, COUNT(*) AS c FROM leak_sensor_metric WHERE measured_at >= :start_at AND measured_at < :end_at GROUP BY DATE(measured_at) ORDER BY d "); $stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]); foreach ($stmt->fetchAll() as $r) { $d = (string)$r['d']; if (!array_key_exists($d, $daily_temp)) { continue; } $daily_temp[$d] = $r['avg_t'] !== null ? round((float)$r['avg_t'], 1) : null; $daily_hum[$d] = $r['avg_h'] !== null ? round((float)$r['avg_h'], 1) : null; } } catch (Throwable $e) { // 스키마 편차(컬럼 부재 등) → 빈 추이로 graceful degrade $daily_temp = array_fill_keys($cal_days, null); $daily_hum = array_fill_keys($cal_days, null); } // ─── 쿼리B: 일별 경보 발생(온습도 임계 + 누수 감지) — 스택 막대용 ───────────── $daily_metric_alerts = array_fill_keys($cal_days, 0); $daily_leak_alerts = array_fill_keys($cal_days, 0); try { $stmt = $db->prepare(" SELECT DATE(measured_at) AS d, COUNT(*) AS c FROM leak_sensor_metric WHERE measured_at >= :start_at AND measured_at < :end_at AND metric_status <> 'normal' GROUP BY DATE(measured_at) "); $stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]); foreach ($stmt->fetchAll() as $r) { $d = (string)$r['d']; if (array_key_exists($d, $daily_metric_alerts)) { $daily_metric_alerts[$d] = (int)$r['c']; } } } catch (Throwable $e) { $daily_metric_alerts = array_fill_keys($cal_days, 0); } try { $stmt = $db->prepare(" SELECT DATE(sensor_time) AS d, COUNT(*) AS c FROM leak_sensor_log WHERE sensor_time >= :start_at AND sensor_time < :end_at AND event_type = 'leak_alert' GROUP BY DATE(sensor_time) "); $stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]); foreach ($stmt->fetchAll() as $r) { $d = (string)$r['d']; if (array_key_exists($d, $daily_leak_alerts)) { $daily_leak_alerts[$d] = (int)$r['c']; } } } catch (Throwable $e) { $daily_leak_alerts = array_fill_keys($cal_days, 0); } // ─── 쿼리C: 월중 누수 사고 로그(leak_incident, 존재 시) ─────────────────────── $has_incident_table = report_table_exists($db, 'leak_incident'); $incidents = []; if ($has_incident_table) { try { $stmt = $db->prepare(" SELECT id, sensor_id, sensor_name, device_location, started_at, recovered_at FROM leak_incident WHERE started_at >= :start_at AND started_at < :end_at ORDER BY started_at ASC "); $stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]); foreach ($stmt->fetchAll() as $r) { [$loc, $name] = sensor_label((int)$r['sensor_id'], $r['device_location'] ?? null, $r['sensor_name'] ?? null); $started = $r['started_at'] ?? null; $recovered = $r['recovered_at'] ?? null; // 지속시간(분) — 미복구(null)는 진행중 $duration_min = null; if ($started !== null && $recovered !== null) { $ds = strtotime($started); $de = strtotime($recovered); if ($ds !== false && $de !== false && $de >= $ds) { $duration_min = (int)round(($de - $ds) / 60); } } $incidents[] = [ 'id' => (int)$r['id'], 'sensor_id' => (int)$r['sensor_id'], 'sensor_name' => $name, 'device_location' => $loc, 'started_at' => $started, 'recovered_at' => $recovered, 'duration_min' => $duration_min, 'ongoing' => $recovered === null, ]; } } catch (Throwable $e) { $incidents = []; } } // ─── 판정(verdict) 파생 — 씰·경영 요약이 공유하는 결정론적 규칙 ─────────────── // 미복구 누수: leak_incident 가 있으면 당월 사고 중 recovered_at IS NULL 개수, // 없으면 [누수감지] SMS − [누수복구] SMS 차이로 폴백(일관성). if ($has_incident_table) { $unrecovered_leak = 0; foreach ($incidents as $inc) { if ($inc['ongoing']) { $unrecovered_leak++; } } } else { $unrecovered_leak = max(0, $summary['leak_alert'] - $summary['leak_recovery']); } if ($unrecovered_leak > 0 || $summary['sms_fail'] > 0) { $verdict = 'alert'; $verdict_label = '경보'; $bits = []; if ($unrecovered_leak > 0) { $bits[] = "미복구 누수 {$unrecovered_leak}건"; } if ($summary['sms_fail'] > 0) { $bits[] = "SMS 실패 {$summary['sms_fail']}건"; } $verdict_reason = implode(' · ', $bits); } elseif ($summary['threshold_alerts'] > 0 || $summary['device_offline'] > 0) { $verdict = 'warn'; $verdict_label = '주의'; $bits = []; if ($summary['threshold_alerts'] > 0) { $bits[] = "임계 경보 {$summary['threshold_alerts']}건"; } if ($summary['device_offline'] > 0) { $bits[] = "오프라인 {$summary['device_offline']}건"; } $verdict_reason = implode(' · ', $bits); } else { $verdict = 'ok'; $verdict_label = '안정'; $verdict_reason = '미복구 누수·SMS 실패·임계 경보·오프라인 없음'; }