POSA_LEAKSMS/php/monthly_report_data.php
cw2007 06f96a8d09 feat(report): 월간 보고서 PDF 저장 전용 A4 문서 뷰
다크 대시보드를 그대로 인쇄하던 '단순 프린트지'를 화이트 A4 전문 보고서로 대체.
?format=print 로 브라우저 print-to-PDF 하면 표지·요약·차트·서명을 갖춘 정식 문서가
나온다. 설계 3안(경영진 브리프/보안증적/비주얼 애널리틱스) 병렬 → 종합 → 구현.
실제 브라우저 렌더로 6개 섹션 전부 검증.

구성:
- 표지 마스트헤드: 대형 대상월 + 관제 판정 씰(SVG 원형 도장 — 데이터 자동 판정
  安定/注意/警報)이 시그니처. 러닝 헤더/푸터(대외비·문서번호·시스템).
- 01 경영 요약: 헤드라인 KPI 4(SMS신뢰성 게이지·총경보·누수·측정) + 자동 총평 내러티브.
- 02 월간 지표: 경보·이벤트 / 온습도 측정 2열 정의형.
- 03 추이(인라인 SVG): 일별 평균 온·습도(안전/고온/저온 밴드·임계선), 일별 경보 스택
  막대(온습도=네이비 / 누수=적색 사선 해치 — 흑백 인쇄 구분).
- 04 관제 지점별 상세 카드, 05 누수 사고 로그(지속시간·복구), 06 기준·주석 및 서명란.

구현:
- 집계를 monthly_report_data.php(공유 include)로 추출 — HTML 조회·CSV·PDF 뷰가 단일
  소스 사용(회귀 0: HTML 표 3행·요약 11카드·CSV 3행 렌더 확인). 일별 추이·누수 사고·
  판정 파생은 인쇄 뷰 전용으로 추가.
- monthly_report_print.php(A4 뷰) + report_charts.php(인라인 SVG 차트) + assets/report-print.css
  (@page A4·여백·page-break·running header/footer·print-color-adjust). app.css 다크 토큰과
  격리(.report-paper 스코프, report-print.css 단독 링크).
- 폐쇄망 준수: 외부 CDN/JS/폰트/PDF 라이브러리 0, 시스템 폰트, 인라인 SVG.
- 보안: 신규 include 3파일 직접접근 가드 + .htaccess FilesMatch 차단(monthly_report.php
  본체는 진입 페이지라 제외). sensor_label 라벨·htmlspecialchars 이스케이프.
2026-07-11 16:56:02 +09:00

396 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// =============================================================================
// monthly_report_data.php - 월간 보고서 공유 데이터 계층 (단일 출처)
//
// monthly_report.php(HTML 조회 + CSV 다운로드)와 monthly_report_print.php(PDF
// 저장용 A4 문서 뷰)가 함께 require 한다. 집계 로직을 이 한 곳에만 두어 두 뷰가
// 동일한 값을 쓰게 하고(중복 = 회귀 위험), 인쇄 뷰 전용의 일별 추이·누수 사고·
// 판정(verdict) 파생을 여기서 함께 산출한다.
//
// 산출 변수(require 후 호출부에서 사용):
// $db, $month, $start_at, $end_at, $report_generated_at, $report_scope,
// $summary, $sensor_rows,
// $cal_days, $daily_temp, $daily_hum, $daily_metric_alerts, $daily_leak_alerts,
// $incidents, $has_incident_table, $unrecovered_leak,
// $verdict, $verdict_label, $verdict_reason
//
// 제공 헬퍼: month_range(), round_or_null(), fmt_num(), csv_cell(),
// report_table_exists()
// =============================================================================
// include 전용 — 웹 직접 접근 차단(.htaccess 와 이중 방어). 호출부(monthly_report.php)
// 가 세션 인증 후 require 한다. 익명 요청 컨텍스트에서 단독 실행되지 않게 막는다.
if (PHP_SAPI !== 'cli'
&& basename((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === basename(__FILE__)) {
http_response_code(404);
exit;
}
require_once __DIR__ . '/config.php';
// ─── 공용 헬퍼 (기존 monthly_report.php 에서 이관 — 로직 무변경) ────────────────
if (!function_exists('month_range')) {
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')];
}
}
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 실패·임계 경보·오프라인 없음';
}