POSA_LEAKSMS/php/tabs/history.php
cw2007 f7fbeacbdd feat: PM 급상승을 화재의심으로 전환 + SMS 단문화 + 센서고착 검출 비활성
서버실/항온항습: 여과·양압이라 평상 PM≈0 → PM 급상승을 화재 조기징후로 본다.
- 임계 하향: METRIC_PM25_HIGH 75→35, METRIC_PM10_HIGH 150→75 (연기 조기감지).
- SMS 태그 [미세먼지경보] → [화재의심]. 대시보드 라벨 통일(경보=화재의심, PM 측정=화재감시).
  metric_status(high_pm25/10) 코드·값·판정 로직 불변 — 표시단만 변경.
- SMS 단문화: 전 본문을 EUC-KR 90B 이내로 축약(관리주체 제거, 날짜 m-d H:i, 태그+위치 1줄).
  실측 45~70B ≤ 90B → 게이트웨이(Cafe24 smsType=S) 단문 절단 해소.
- 센서고착(live-but-stuck) 검출 기본 비활성(STUCK_DETECT_ENABLED=false):
  항온항습 문서고는 온습도 원래 평탄 → 평탄값을 고착으로 오탐하던 [센서고착] 문자 차단.
- 테스트: 태그·기본임계 단언 갱신. 회귀 스위트 PASS(9 run/0 fail), php -l 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Dj4n17ZvGAhga6D4pNRjn
2026-07-19 23:34:57 +09:00

273 lines
13 KiB
PHP

<?php
// tabs/history.php — [이력 조회] 탭. dashboard.php 셸에서 include 된다.
// 장애 원인 추적용: 누수 사고·측정·이벤트·SMS 이력을 한 탭에서 대조한다.
// 사용 변수: load_tab_history() 결과(extract) — $leak_incidents, $leak_photos_by_log,
// $recent_metrics, $logs, $sms_logs, $sms_stats.
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
?>
<div class="zone" id="tab-history">
<div class="zone-label"><b>운영 이력</b> <span class="en">History</span></div>
<!-- 누수 사고 이력 + 사진 -->
<h2 class="table-title">누수 사고 이력 <span class="h2-sub">최근 20건</span></h2>
<details class="mobile-detail" id="d-incidents" data-mobile-collapsed open>
<summary>누수 사고 이력 <span>최근 20건</span></summary>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-hide-xs">#</th><th>센서</th><th class="col-hide-xs">위치</th>
<th>감지 시각</th><th>복구 시각</th><th>사진</th>
</tr>
</thead>
<tbody>
<?php foreach ($leak_incidents as $inc):
$is_open = ($inc['recovered_at'] === null);
$lid = $inc['log_id_alert'] !== null ? (int)$inc['log_id_alert'] : 0;
$photos = $leak_photos_by_log[$lid] ?? [];
?>
<tr class="<?= $is_open ? 'alert-row' : '' ?>">
<td class="col-hide-xs"><?= (int)$inc['id'] ?></td>
<td><?= htmlspecialchars($inc['sensor_name']) ?></td>
<td class="col-hide-xs"><?= htmlspecialchars($inc['device_location']) ?></td>
<td><?= htmlspecialchars($inc['started_at']) ?></td>
<td>
<?php if ($is_open): ?>
<span class="pill leak">미복구</span>
<?php else: ?>
<?= htmlspecialchars($inc['recovered_at']) ?>
<?php endif; ?>
</td>
<td>
<?php if (!empty($photos)): ?>
<div class="leak-thumbs">
<?php foreach ($photos as $ph): ?>
<a href="photo.php?id=<?= (int)$ph['id'] ?>" target="_blank" rel="noopener">
<img src="photo.php?id=<?= (int)$ph['id'] ?>"
alt="누수 사진 <?= htmlspecialchars($ph['captured_at']) ?>"
loading="lazy">
</a>
<?php endforeach; ?>
</div>
<?php else: ?>
<span class="caption">-</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($leak_incidents)): ?>
<tr><td colspan="6" class="empty-cell">아직 누수 사고 이력이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</details>
<!-- 최근 온습도 측정 이력 -->
<h2 id="metrics-section" class="table-title">최근 온습도 측정 <span class="h2-sub">30건 기준</span></h2>
<details class="mobile-detail" id="d-metrics" data-mobile-collapsed open>
<summary>최근 온습도 측정 <span>30건 기준</span></summary>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>측정시각</th><th>센서</th><th class="col-hide-xs">위치</th>
<th>온도</th><th>습도</th><th>상태</th>
</tr>
</thead>
<tbody>
<?php foreach ($recent_metrics as $row):
$status_code = $row['metric_status'] ?? 'normal';
$is_alert = $status_code !== 'normal';
?>
<tr class="<?= $is_alert ? 'alert-row' : '' ?>">
<td><?= htmlspecialchars($row['measured_at']) ?></td>
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
<td class="col-hide-xs"><?= htmlspecialchars($row['device_location']) ?></td>
<td><?= htmlspecialchars(metric_display($row['temperature_c'], '°C')) ?></td>
<td><?= htmlspecialchars(metric_display($row['humidity_percent'], '%')) ?></td>
<td><span class="pill <?= htmlspecialchars($status_code) ?>"><?= htmlspecialchars(metric_status_label($status_code)) ?></span></td>
</tr>
<?php endforeach; ?>
<?php if (empty($recent_metrics)): ?>
<tr><td colspan="6" class="empty-cell">아직 온습도 측정 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</details>
<!-- 최근 미세먼지 측정 이력 (PMS7003 — 온습도와 별도 계통) -->
<h2 id="pm-section" class="table-title">최근 화재감시(PM) 측정 <span class="h2-sub">30건 기준</span></h2>
<details class="mobile-detail" id="d-pm" data-mobile-collapsed open>
<summary>최근 화재감시(PM) 측정 <span>30건 기준</span></summary>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>측정시각</th><th>센서</th><th class="col-hide-xs">위치</th>
<th class="col-hide-xs">PM1.0</th><th>PM2.5</th><th>PM10</th><th>상태</th>
</tr>
</thead>
<tbody>
<?php foreach (($recent_pm ?? []) as $row):
$status_code = $row['metric_status'] ?? 'normal';
$is_alert = $status_code !== 'normal';
?>
<tr class="<?= $is_alert ? 'alert-row' : '' ?>">
<td><?= htmlspecialchars($row['measured_at']) ?></td>
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
<td class="col-hide-xs"><?= htmlspecialchars($row['device_location']) ?></td>
<td class="col-hide-xs"><?= $row['pm1_0'] !== null ? (int)$row['pm1_0'] . 'µg/m³' : '-' ?></td>
<td><?= $row['pm2_5'] !== null ? (int)$row['pm2_5'] . 'µg/m³' : '-' ?></td>
<td><?= $row['pm10'] !== null ? (int)$row['pm10'] . 'µg/m³' : '-' ?></td>
<td><span class="pill <?= htmlspecialchars($status_code) ?>"><?= htmlspecialchars(metric_status_label($status_code)) ?></span></td>
</tr>
<?php endforeach; ?>
<?php if (empty($recent_pm)): ?>
<tr><td colspan="7" class="empty-cell">아직 화재감시(PM) 측정 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</details>
<!-- 센서 이벤트 로그 (startup/periodic/leak_alert/leak_recovery) -->
<h2 id="logs-section" class="table-title">센서 이벤트 로그 <span class="h2-sub">30건 기준</span></h2>
<details class="mobile-detail" id="d-logs" data-mobile-collapsed open>
<summary>센서 이벤트 로그 <span>30건 기준</span></summary>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-hide-xs">#</th><th>시각</th><th>센서</th>
<th class="col-hide-xs">위치</th><th>이벤트</th>
</tr>
</thead>
<tbody>
<?php foreach ($logs as $row):
$event_labels = [
'startup' => '장비시작',
'periodic' => '정기보고',
'leak_alert' => '누수감지',
'leak_recovery' => '누수복구',
];
$evt = $row['event_type'];
?>
<tr class="<?= $evt === 'leak_alert' ? 'alert-row' : '' ?>">
<td class="col-hide-xs"><?= (int)$row['id'] ?></td>
<td><?= htmlspecialchars($row['sensor_time']) ?></td>
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
<td class="col-hide-xs"><?= htmlspecialchars($row['device_location']) ?></td>
<td>
<?php if ($evt === 'leak_alert' || $evt === 'leak_recovery'): ?>
<span class="pill <?= htmlspecialchars($evt) ?>"><?= htmlspecialchars($event_labels[$evt]) ?></span>
<?php else: ?>
<?= htmlspecialchars($event_labels[$evt] ?? $evt) ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($logs)): ?>
<tr><td colspan="5" class="empty-cell">아직 센서 이벤트가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</details>
<!-- SMS 발송 로그 -->
<h2 id="sms-section" class="table-title">SMS 발송 이력 <span class="h2-sub">최근 20건</span></h2>
<details class="mobile-detail" id="d-sms" data-mobile-collapsed open>
<summary>SMS 발송 이력 <span>최근 20건</span></summary>
<div class="table-wrap">
<table>
<thead>
<tr><th class="col-hide-xs">#</th><th>발송시각</th><th>센서</th><th>수신자</th><th>상태</th><th class="col-hide-xs">결과</th></tr>
</thead>
<tbody>
<?php foreach ($sms_logs as $row): ?>
<tr>
<td class="col-hide-xs"><?= (int)$row['id'] ?></td>
<td><?= htmlspecialchars($row['sent_at']) ?></td>
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
<td><?= htmlspecialchars(mask_phone($row['receivers'] ?? '')) ?></td>
<td>
<?php if ($row['status'] === 'success'): ?>
<span class="ok-color">성공</span>
<?php else: ?>
<span class="err-color">실패</span>
<?php endif; ?>
</td>
<td class="col-hide-xs caption"><?= htmlspecialchars($row['result_message']) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($sms_logs)): ?>
<tr><td colspan="6" class="empty-cell">아직 SMS 발송 이력이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</details>
<!-- 30일 평균 온습도 (센서별 — 전 센서 혼합 평균은 위치별 환경 차이를 뭉개므로 분리) -->
<?php if (!empty($metric_avgs)): ?>
<section class="panel">
<h2>30일 평균 온습도 <span class="h2-sub">· 센서별</span></h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>센서</th><th class="col-hide-xs">위치</th><th>평균 온도</th><th>평균 습도</th><th class="col-hide-xs">측정 수</th></tr>
</thead>
<tbody>
<?php foreach ($metric_avgs as $ma): ?>
<tr>
<td><?= htmlspecialchars($ma['name']) ?></td>
<td class="col-hide-xs"><?= htmlspecialchars($ma['location']) ?></td>
<td><?= $ma['avg_temp'] !== null ? htmlspecialchars(number_format((float)$ma['avg_temp'], 1)) . '°C' : '-' ?></td>
<td><?= $ma['avg_hum'] !== null ? htmlspecialchars(number_format((float)$ma['avg_hum'], 1)) . '%' : '-' ?></td>
<td class="col-hide-xs"><?= (int)$ma['n'] ?>건</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<?php endif; ?>
<!-- 30일 SMS 발송 통계 (발송 건수 기준 — 측정 기준 경보 통계는 추이·통계 탭) -->
<section class="panel" id="summary-section">
<h2>30일 SMS 발송 통계</h2>
<div class="report-grid">
<div class="report-stat">
<span>총 발송</span>
<strong><?= (int)$sms_stats['total'] ?>건</strong>
</div>
<div class="report-stat">
<span>온습도 경보 SMS</span>
<strong class="<?= $sms_stats['alert_total'] > 0 ? 'warn-color' : '' ?>"><?= (int)$sms_stats['alert_total'] ?>건</strong>
</div>
<div class="report-stat">
<span>화재의심 SMS</span>
<strong class="<?= ($sms_stats['high_pm'] ?? 0) > 0 ? 'warn-color' : '' ?>"><?= (int)($sms_stats['high_pm'] ?? 0) ?>건</strong>
</div>
<div class="report-stat">
<span>누수감지 SMS</span>
<strong class="<?= $sms_stats['leak_alert'] > 0 ? 'err-color' : '' ?>"><?= (int)$sms_stats['leak_alert'] ?>건</strong>
</div>
<div class="report-stat">
<span>복구 SMS</span>
<strong><?= (int)$sms_stats['recovery'] ?>건</strong>
</div>
<div class="report-stat">
<span>오프라인 SMS</span>
<strong><?= (int)$sms_stats['offline'] ?>건</strong>
</div>
<div class="report-stat">
<span>발송 실패</span>
<strong class="<?= $sms_stats['fail'] > 0 ? 'err-color' : '' ?>"><?= (int)$sms_stats['fail'] ?>건</strong>
</div>
</div>
<p class="panel-note">종류별 상세 집계와 월별 분석은 <a href="monthly_report.php">월간 보고</a>에서 확인하세요. 측정 기준 경보 통계(도넛·추이)는 <a href="<?= htmlspecialchars(dash_url(['tab' => 'trend'])) ?>">추이·통계 탭</a>에 있습니다.</p>
</section>
</div>