다크 대시보드를 그대로 인쇄하던 '단순 프린트지'를 화이트 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 이스케이프.
396 lines
18 KiB
PHP
396 lines
18 KiB
PHP
<?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 실패·임계 경보·오프라인 없음';
|
||
}
|