modify('first day of next month');
return [$month, $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s')];
}
// SMS 메시지 태그(prefix) → 집계 키 매핑. sms_send.php 가 발송 시 붙이는 태그와 일치.
function report_sms_tags(): array {
return [
'high_temp' => '[고온경보]',
'low_temp' => '[저온경보]',
'high_humidity' => '[고습경보]',
'low_humidity' => '[저습경보]',
'recovery' => '[정상복귀]',
'device_offline' => '[장비 오프라인]',
'device_online' => '[장비 복구]',
];
}
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
SUM(message LIKE '[고온경보]%') AS high_temp,
SUM(message LIKE '[저온경보]%') AS low_temp,
SUM(message LIKE '[고습경보]%') AS high_humidity,
SUM(message LIKE '[저습경보]%') AS low_humidity,
SUM(message LIKE '[정상복귀]%') AS recovery,
SUM(message LIKE '[장비 오프라인]%') AS device_offline,
SUM(message LIKE '[장비 복구]%') AS device_online,
SUM(status = 'fail') AS sms_fail
FROM 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),
'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 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 태그별 건수 + 실패 건수 ───────────────────────────────────────
$stmt = $db->prepare("
SELECT sensor_id, sensor_name,
SUM(message LIKE '[고온경보]%') AS high_temp,
SUM(message LIKE '[저온경보]%') AS low_temp,
SUM(message LIKE '[고습경보]%') AS high_humidity,
SUM(message LIKE '[저습경보]%') AS low_humidity,
SUM(message LIKE '[정상복귀]%') AS recovery,
SUM(message LIKE '[장비 오프라인]%') AS device_offline,
SUM(message LIKE '[장비 복구]%') AS device_online,
SUM(status = 'fail') AS sms_fail
FROM sms_log
WHERE sent_at >= :start_at AND sent_at < :end_at
GROUP BY sensor_id, sensor_name
");
$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;
}
// ── 센서별 측정 집계(온습도) ─────────────────────────────────────────────────
$stmt = $db->prepare("
SELECT sensor_id, sensor_name, 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 sensor_metric
WHERE measured_at >= :start_at AND measured_at < :end_at
GROUP BY sensor_id, sensor_name, device_location
");
$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_rows[] = [
'sensor_id' => $sid,
'sensor_name' => $m['sensor_name'] ?? $s['sensor_name'] ?? ('센서' . $sid),
'device_location' => $m['device_location'] ?? '',
'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' => (int)($s['high_temp'] ?? 0) + (int)($s['low_temp'] ?? 0)
+ (int)($s['high_humidity'] ?? 0) + (int)($s['low_humidity'] ?? 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'] ?? '',
];
}
usort($sensor_rows, function ($a, $b) {
return [$b['threshold_alerts'], $a['sensor_id']] <=> [$a['threshold_alerts'], $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, ['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',
'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['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) ?>
= htmlspecialchars($month) ?> 월간 운영 보고서
보고서 생성
= htmlspecialchars($report_generated_at) ?>
범위: = htmlspecialchars($report_scope) ?>
임계 경보(합계)= $summary['threshold_alerts'] ?>건
고온/저온 경보= $summary['high_temp'] ?>/= $summary['low_temp'] ?>건
고습/저습 경보= $summary['high_humidity'] ?>/= $summary['low_humidity'] ?>건
정상복귀= $summary['recovery'] ?>건
장비 오프라인/복구= $summary['device_offline'] ?>/= $summary['device_online'] ?>건
SMS 실패= $summary['sms_fail'] ?>건
측정 건수= $summary['metrics'] ?>건
평균 온도/습도= fmt_num($summary['avg_temp'], '℃') ?> / = fmt_num($summary['avg_humidity'], '%') ?>
온도 최저/최고= fmt_num($summary['min_temp'], '℃') ?> / = fmt_num($summary['max_temp'], '℃') ?>
습도 최저/최고= fmt_num($summary['min_humidity'], '%') ?> / = fmt_num($summary['max_humidity'], '%') ?>
센서별 요약
| 센서 | 위치 |
임계 경보 | 고온/저온 | 고습/저습 |
정상복귀 | 오프라인/복구 | SMS 실패 |
측정 | 평균 온도/습도 | 온도 최저/최고 | 습도 최저/최고 |
최근 측정 |
| = htmlspecialchars($s['sensor_name']) ?> |
= htmlspecialchars($s['device_location']) ?> |
= $s['threshold_alerts'] ?> |
= $s['high_temp'] ?>/= $s['low_temp'] ?> |
= $s['high_humidity'] ?>/= $s['low_humidity'] ?> |
= $s['recovery'] ?> |
= $s['device_offline'] ?>/= $s['device_online'] ?> |
= $s['sms_fail'] ?> |
= $s['metrics'] ?> |
= fmt_num($s['avg_temp'], '℃') ?> / = fmt_num($s['avg_humidity'], '%') ?> |
= fmt_num($s['min_temp'], '℃') ?> / = fmt_num($s['max_temp'], '℃') ?> |
= fmt_num($s['min_humidity'], '%') ?> / = fmt_num($s['max_humidity'], '%') ?> |
= htmlspecialchars($s['last_measured_at'] ?: '-') ?> |
| 해당 월 측정·경보 내역이 없습니다. |