From 06f96a8d095c104b049ce49b59245e04bff43cac Mon Sep 17 00:00:00 2001 From: cw2007 Date: Sat, 11 Jul 2026 16:56:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(report):=20=EC=9B=94=EA=B0=84=20=EB=B3=B4?= =?UTF-8?q?=EA=B3=A0=EC=84=9C=20PDF=20=EC=A0=80=EC=9E=A5=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20A4=20=EB=AC=B8=EC=84=9C=20=EB=B7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다크 대시보드를 그대로 인쇄하던 '단순 프린트지'를 화이트 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 이스케이프. --- php/.htaccess | 6 +- php/assets/report-print.css | 285 +++++++++++++++++++++++++ php/monthly_report.php | 190 ++--------------- php/monthly_report_data.php | 396 +++++++++++++++++++++++++++++++++++ php/monthly_report_print.php | 332 +++++++++++++++++++++++++++++ php/report_charts.php | 339 ++++++++++++++++++++++++++++++ 6 files changed, 1371 insertions(+), 177 deletions(-) create mode 100644 php/assets/report-print.css create mode 100644 php/monthly_report_data.php create mode 100644 php/monthly_report_print.php create mode 100644 php/report_charts.php diff --git a/php/.htaccess b/php/.htaccess index 5ba4ba5..1a14fd9 100644 --- a/php/.htaccess +++ b/php/.htaccess @@ -21,8 +21,10 @@ # ─── 민감 PHP 파일 직접 접근 차단 ───────────────────────── -# (dashboard_data·forecast·live·trend·history·system 은 include 전용 — tabs/.htaccess 와 이중 차단) - +# (dashboard_data·forecast·live·trend·history·system·monthly_report_data· +# monthly_report_print·report_charts 는 include 전용 — tabs/.htaccess 와 이중 차단. +# monthly_report.php 본체는 진입 페이지이므로 차단 대상 아님) + Require all denied diff --git a/php/assets/report-print.css b/php/assets/report-print.css new file mode 100644 index 0000000..1d2acc7 --- /dev/null +++ b/php/assets/report-print.css @@ -0,0 +1,285 @@ +/* ============================================================================= + * report-print.css — 월간 운영 보고서(PDF 저장 전용) A4 화이트 문서 스타일 + * + * monthly_report_print.php 만 링크한다(app.css 미링크 — 다크 셸 토큰과 격리). + * 폐쇄망: 자기호스팅 로컬 파일. 외부 폰트/CDN 0 — 시스템 폰트만. + * 화면(@media screen)에서는 회색 캔버스 위에 뜬 흰 A4 종이로 미리보기(WYSIWYG), + * 인쇄(@media print)에서는 캔버스·툴바가 벗겨지고 종이가 곧 PDF 가 된다. + * + * 대상 브라우저: Chrome / Edge(Chromium) — 고정 러닝 헤더/푸터·색 보존 안정. + * FLAG: Chromium print-to-PDF 는 @page 마진박스 counter(page) 미지원 + 폐쇄망 + * JS 폴리필 금지 → 라이브 페이지번호 미탑재. 고정 푸터에 문서번호·생성일시·대외비를 + * 매 장 반복해 추적성을 확보하고, 화면 노트로 인쇄 대화상자의 머리글/바닥글 옵션을 안내. + * ============================================================================= */ + +.report-paper { + /* 라이트 고정 팔레트 — app.css @media print 토큰과 값 정합(흑백/컬러 안전) */ + --paper-ink: #1a1f2b; + --paper-sub: #5b6472; + --paper-navy: #14315e; + --paper-gold: #9a7b28; + --hair: #d9dee6; + --hair-strong: #c2cad6; + --ok: #0a6b39; + --warn: #8a5a00; + --alert: #b11f1f; + --band-safe: #e7f0ea; + --band-hot: #f6e3dc; + --band-cold: #dfeef2; + --paper-bg-soft: #f4f6f9; +} + +/* 색 보존 강제(네이비 밴드·골드 룰·판정 씰·차트 획 유실 방지) */ +.report-paper, +.report-paper * { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; +} + +/* ── @page: A4 세로 + 여백 ─────────────────────────────────────────────────── */ +@page { + size: A4 portrait; + margin: 18mm 16mm 20mm; +} +@page :first { + margin-top: 22mm; /* 표지 상단 여유 */ +} + +/* ── 종이 본체 ─────────────────────────────────────────────────────────────── */ +.report-paper { + font-family: "Malgun Gothic", "맑은 고딕", "Apple SD Gothic Neo", "Noto Sans KR", sans-serif; + color: var(--paper-ink); + background: #fff; + line-height: 1.6; + font-size: 10.5pt; + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; +} +.report-paper h1, .report-paper h2, .report-paper h3 { margin: 0; font-weight: 800; } +.report-paper p { margin: 0; } +.report-paper .num { font-variant-numeric: tabular-nums; } + +/* ── 러닝 헤더/푸터(position:fixed — Chromium 이 매 장 반복 렌더) ─────────────── */ +.run-header, .run-footer { + position: fixed; + left: 0; right: 0; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 8pt; + color: var(--paper-sub); + letter-spacing: .02em; +} +.run-header { top: 0; border-bottom: 0.6pt solid var(--hair); padding-bottom: 3pt; } +.run-footer { bottom: 0; border-top: 0.6pt solid var(--hair); padding-top: 3pt; } +.run-header .cls, .run-footer .cls { color: var(--alert); font-weight: 700; } +.run-header b { color: var(--paper-navy); font-weight: 800; } + +/* ── 표지 마스트헤드 ───────────────────────────────────────────────────────── */ +.cover { position: relative; padding-top: 4mm; } +.cover-eyebrow { + font-size: 9.5pt; font-weight: 700; letter-spacing: .35em; + color: var(--paper-navy); margin-bottom: 6mm; +} +.cover-eyebrow::before { + content: ""; display: inline-block; width: 26pt; height: 3pt; vertical-align: middle; + background: var(--paper-gold); margin-right: 8pt; border-radius: 2pt; +} +.cover-title { + font-size: 27pt; line-height: 1.15; color: var(--paper-navy); + letter-spacing: -.01em; margin-bottom: 3mm; max-width: 70%; +} +.cover-sub { font-size: 11pt; color: var(--paper-sub); margin-bottom: 10mm; } +.cover-month { + font-size: 58pt; font-weight: 800; color: var(--paper-navy); + line-height: 1; letter-spacing: -.02em; +} +.cover-period { font-size: 10.5pt; color: var(--paper-sub); margin-top: 2mm; } +.cover-seal { position: absolute; top: 0; right: 0; text-align: center; } +.cover-seal .seal-cap { font-size: 8pt; color: var(--paper-sub); margin-top: 2pt; } + +/* 관제 대상 3개소 스트립 */ +.cover-scope { + display: flex; gap: 6mm; margin-top: 10mm; padding-top: 6mm; + border-top: 1pt solid var(--hair-strong); +} +.scope-item { flex: 1; } +.scope-item .k { font-size: 8.5pt; color: var(--paper-sub); letter-spacing: .1em; } +.scope-item .v { font-size: 12pt; font-weight: 800; color: var(--paper-navy); } +.scope-item .t { font-size: 8.5pt; color: var(--paper-sub); } + +/* 문서 통제 블록 */ +.doc-control { + margin-top: 12mm; border: 1pt solid var(--hair-strong); border-radius: 2pt; + display: grid; grid-template-columns: repeat(3, 1fr); overflow: hidden; +} +.doc-control > div { padding: 3.2mm 4mm; border-right: 0.6pt solid var(--hair); border-bottom: 0.6pt solid var(--hair); } +.doc-control > div:nth-child(3n) { border-right: none; } +.doc-control .dk { font-size: 8pt; color: var(--paper-sub); letter-spacing: .08em; margin-bottom: 1pt; } +.doc-control .dv { font-size: 10pt; font-weight: 700; color: var(--paper-ink); } +.doc-control .dv.cls { color: var(--alert); } + +/* ── 섹션 제목 ─────────────────────────────────────────────────────────────── */ +.section { margin-top: 8mm; } +.section-title { + font-size: 13pt; color: var(--paper-navy); padding-bottom: 2mm; + border-bottom: 1.6pt solid var(--paper-navy); margin-bottom: 4mm; + display: flex; align-items: baseline; gap: 8pt; + break-after: avoid; page-break-after: avoid; +} +.section-title .en { font-size: 8.5pt; font-weight: 600; color: var(--paper-sub); letter-spacing: .12em; } +.section-title .no { + font-size: 9pt; font-weight: 800; color: #fff; background: var(--paper-navy); + border-radius: 2pt; padding: 1pt 6pt; letter-spacing: .05em; +} + +/* ── 경영 요약 KPI ─────────────────────────────────────────────────────────── */ +.kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4mm; } +.kpi-card { + border: 1pt solid var(--hair-strong); border-radius: 2pt; padding: 3.5mm 4mm; + break-inside: avoid; page-break-inside: avoid; position: relative; +} +.kpi-card::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3pt; background: var(--paper-navy); } +.kpi-card.is-alert::before { background: var(--alert); } +.kpi-card.is-warn::before { background: var(--warn); } +.kpi-card.is-ok::before { background: var(--ok); } +.kpi-card .kpi-k { font-size: 8.5pt; color: var(--paper-sub); letter-spacing: .04em; } +.kpi-card .kpi-v { font-size: 21pt; font-weight: 800; color: var(--paper-navy); line-height: 1.1; } +.kpi-card.is-alert .kpi-v { color: var(--alert); } +.kpi-card .kpi-s { font-size: 8.5pt; color: var(--paper-sub); } +.kpi-gauge-wrap { text-align: center; } + +.exec-summary { + margin-top: 4mm; border: 1pt solid var(--hair-strong); border-left: 3pt solid var(--paper-navy); + border-radius: 2pt; padding: 3.5mm 4.5mm; background: var(--paper-bg-soft); + break-inside: avoid; page-break-inside: avoid; +} +.exec-summary .verdict-line { font-size: 11pt; font-weight: 800; color: var(--paper-navy); } +.exec-summary .verdict-line.is-alert { color: var(--alert); } +.exec-summary .verdict-line.is-warn { color: var(--warn); } +.exec-summary .verdict-line.is-ok { color: var(--ok); } +.exec-summary p { font-size: 10pt; color: var(--paper-ink); margin-top: 1.5mm; } + +/* ── 정의형 지표 표(경보·이벤트 / 온습도) ──────────────────────────────────── */ +.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6mm; } +.metric-block { break-inside: avoid; page-break-inside: avoid; } +.metric-block h3 { font-size: 10pt; color: var(--paper-navy); margin-bottom: 2mm; } +table.def { width: 100%; border-collapse: collapse; font-size: 9.5pt; } +table.def th, table.def td { padding: 2mm 3mm; border-bottom: 0.6pt solid var(--hair); text-align: left; } +table.def th { color: var(--paper-sub); font-weight: 600; width: 55%; } +table.def td { text-align: right; font-weight: 700; color: var(--paper-ink); font-variant-numeric: tabular-nums; } +table.def td.is-alert { color: var(--alert); } + +/* ── 일반 데이터 표(지점별 등) ─────────────────────────────────────────────── */ +table.grid { width: 100%; border-collapse: collapse; font-size: 9pt; } +table.grid thead { display: table-header-group; } /* 페이지 넘김 시 머리행 반복 */ +table.grid th, table.grid td { padding: 2mm 2.5mm; border: 0.6pt solid var(--hair); text-align: center; } +table.grid th { background: var(--paper-navy); color: #fff; font-weight: 700; font-size: 8.5pt; } +table.grid td.l { text-align: left; } +table.grid tbody tr:nth-child(even) td { background: var(--paper-bg-soft); } +table.grid td.is-alert { color: var(--alert); font-weight: 800; } + +/* ── 지점별 카드 ───────────────────────────────────────────────────────────── */ +.loc-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4mm; } +.loc-card { + border: 1pt solid var(--hair-strong); border-radius: 2pt; padding: 3.5mm 4mm; + break-inside: avoid; page-break-inside: avoid; +} +.loc-card .lc-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2mm; padding-bottom: 2mm; border-bottom: 0.8pt solid var(--hair); } +.loc-card .lc-name { font-size: 11.5pt; font-weight: 800; color: var(--paper-navy); } +.loc-card .lc-loc { font-size: 8.5pt; color: var(--paper-sub); } +.pill { + font-size: 8pt; font-weight: 800; padding: 1pt 6pt; border-radius: 8pt; + border: 0.8pt solid currentColor; +} +.pill.is-ok { color: var(--ok); } +.pill.is-warn { color: var(--warn); } +.pill.is-alert { color: var(--alert); } +.loc-card dl { display: grid; grid-template-columns: auto 1fr; gap: 1mm 3mm; margin: 0; font-size: 9pt; } +.loc-card dt { color: var(--paper-sub); } +.loc-card dd { margin: 0; text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; } +.loc-card dd.is-alert { color: var(--alert); } + +/* ── 차트 패널 ─────────────────────────────────────────────────────────────── */ +.chart-panel { + border: 1pt solid var(--hair-strong); border-radius: 2pt; padding: 4mm; + margin-top: 3mm; break-inside: avoid; page-break-inside: avoid; +} +.chart-panel h3 { font-size: 10pt; color: var(--paper-navy); margin-bottom: 1mm; } +.chart-panel .chart-cap { font-size: 8.5pt; color: var(--paper-sub); margin-bottom: 2mm; } +.chart-empty { padding: 8mm; text-align: center; color: var(--paper-sub); font-size: 9.5pt; border: 0.8pt dashed var(--hair-strong); border-radius: 2pt; } +.chart-legend-row { display: flex; flex-wrap: wrap; gap: 4mm; margin-top: 2mm; font-size: 8.5pt; color: var(--paper-sub); } +.chart-legend-row span { display: inline-flex; align-items: center; gap: 4pt; } +.lg-sw { width: 11pt; height: 8pt; border-radius: 1pt; display: inline-block; } +.lg-sw.hatch { background: repeating-linear-gradient(45deg, var(--alert) 0 2pt, #fff 2pt 3.5pt); border: 0.5pt solid var(--alert); } +.lg-line { width: 16pt; height: 3pt; border-radius: 2pt; display: inline-block; } +.lg-line.dash { background: repeating-linear-gradient(90deg, currentColor 0 4pt, transparent 4pt 7pt) !important; } +.lg-band { width: 12pt; height: 9pt; border-radius: 1pt; display: inline-block; border: 0.5pt solid var(--hair-strong); } + +/* ── 누수 사고 로그 ────────────────────────────────────────────────────────── */ +.incident-none { + border: 1pt solid var(--ok); border-radius: 2pt; padding: 4mm 4.5mm; + color: var(--ok); font-weight: 700; font-size: 10pt; background: var(--band-safe); +} + +/* ── 기준·주석 + 서명란 ────────────────────────────────────────────────────── */ +.basis-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6mm; } +.basis-block { break-inside: avoid; page-break-inside: avoid; } +.basis-block h3 { font-size: 9.5pt; color: var(--paper-navy); margin-bottom: 2mm; } +.basis-block ul { margin: 0; padding-left: 4.5mm; font-size: 9pt; color: var(--paper-ink); } +.basis-block li { margin-bottom: 1mm; } +.basis-block li b { color: var(--paper-navy); } +.sign-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6mm; margin-top: 8mm; break-inside: avoid; page-break-inside: avoid; } +.sign-box { border: 1pt solid var(--hair-strong); border-radius: 2pt; } +.sign-box .sk { font-size: 8.5pt; color: var(--paper-sub); padding: 2mm 3mm; border-bottom: 0.6pt solid var(--hair); background: var(--paper-bg-soft); } +.sign-box .sv { height: 16mm; } +.sign-box .sn { font-size: 8pt; color: var(--paper-sub); padding: 1.5mm 3mm; border-top: 0.6pt solid var(--hair); text-align: right; } + +/* ── 페이지 브레이크 ───────────────────────────────────────────────────────── */ +.page-break { break-before: page; page-break-before: always; } + +/* ── 화면 미리보기(회색 캔버스 위 흰 종이) ─────────────────────────────────── */ +@media screen { + body { margin: 0; background: #e9eaee; } + .report-canvas { padding: 24px 12px 64px; } + .report-paper { + width: 210mm; max-width: 100%; min-height: 297mm; margin: 0 auto; + padding: 22mm 16mm 20mm; background: #fff; box-sizing: border-box; + box-shadow: 0 6px 28px rgba(20, 30, 60, .18); + } + /* 화면에서는 고정 헤더/푸터를 종이 안쪽에 흐름 배치(겹침 방지) */ + .run-header, .run-footer { position: static; margin: 0 auto; width: 210mm; max-width: 100%; } + .run-header { margin-bottom: 6mm; } + .run-footer { margin-top: 6mm; } + .print-note { + width: 210mm; max-width: 100%; margin: 0 auto 16px; padding: 10px 14px; + background: #fff8e6; border: 1px solid #e6c766; border-radius: 6px; + font-size: 12px; color: #6b5410; box-sizing: border-box; + font-family: "Malgun Gothic", "맑은 고딕", "Apple SD Gothic Neo", sans-serif; + } + .print-note b { color: #8a5a00; } + .print-note a { color: #14315e; } + .screen-toolbar { + width: 210mm; max-width: 100%; margin: 0 auto 12px; display: flex; gap: 8px; flex-wrap: wrap; + box-sizing: border-box; + } + .screen-toolbar a, .screen-toolbar button { + font: inherit; font-size: 13px; font-weight: 700; padding: 8px 14px; border-radius: 6px; + border: 1px solid #14315e; background: #14315e; color: #fff; cursor: pointer; text-decoration: none; + font-family: "Malgun Gothic", "맑은 고딕", "Apple SD Gothic Neo", sans-serif; + } + .screen-toolbar a.ghost { background: #fff; color: #14315e; } +} + +/* ── 인쇄(종이가 곧 PDF) ───────────────────────────────────────────────────── */ +@media print { + html, body { margin: 0; background: #fff; } + .report-canvas { padding: 0; background: #fff; } + .report-paper { + width: auto; max-width: none; min-height: 0; margin: 0; padding: 0; + background: #fff; box-shadow: none; + } + .print-note, .screen-toolbar, .screen-only { display: none !important; } + /* 고정 헤더/푸터와 본문 겹침 방지 여백 */ + .report-body { padding-top: 6mm; padding-bottom: 6mm; } +} diff --git a/php/monthly_report.php b/php/monthly_report.php index 2d398cd..b0b7960 100644 --- a/php/monthly_report.php +++ b/php/monthly_report.php @@ -26,180 +26,11 @@ if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) { } admin_enforce_session_timeout(); // 유휴 + 절대 수명 타임아웃(대시보드 외 페이지에서도 강제) -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')]; -} - -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 - " . sms_summary_select_sql() . " - 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), - '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 태그별 건수 + 실패 건수 ─────────────────────────────────────── -// 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']]; -}); +// ── 집계 데이터 계층(단일 출처) ────────────────────────────────────────────── +// 헬퍼(month_range/round_or_null/fmt_num/csv_cell)와 $summary/$sensor_rows 및 인쇄 +// 뷰 전용 파생(일별 추이·누수 사고·verdict)을 이 include 가 산출한다. HTML 조회 뷰, +// CSV 다운로드, PDF 인쇄 뷰가 동일 소스를 공유한다(집계 중복 0 — 회귀 방지). +require_once __DIR__ . '/monthly_report_data.php'; // ── CSV 다운로드 ───────────────────────────────────────────────────────────── if (($_GET['format'] ?? '') === 'csv') { @@ -252,6 +83,15 @@ if (($_GET['format'] ?? '') === 'csv') { fclose($out); exit; } + +// ── PDF 저장 전용 A4 문서 뷰 ───────────────────────────────────────────────── +// 다크 대시보드(app.css)를 그대로 인쇄하던 단순 프린트지를 대체한다. 화이트 A4 전문 +// 보고서(표지·경영요약·지표·추이·지점별·누수사고·서명)를 렌더한다. 집계는 위 include 가 +// 이미 산출했으므로 여기서 뷰만 그린다(CSV·HTML 조회 뷰는 불변 — 회귀 없음). +if (($_GET['format'] ?? '') === 'print') { + require __DIR__ . '/monthly_report_print.php'; + exit; +} ?> @@ -297,8 +137,8 @@ if (($_GET['format'] ?? '') === 'csv') {
+ PDF 보고서 CSV 다운로드 -
diff --git a/php/monthly_report_data.php b/php/monthly_report_data.php new file mode 100644 index 0000000..3900ecf --- /dev/null +++ b/php/monthly_report_data.php @@ -0,0 +1,396 @@ +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 실패·임계 경보·오프라인 없음'; +} diff --git a/php/monthly_report_print.php b/php/monthly_report_print.php new file mode 100644 index 0000000..c251c7c --- /dev/null +++ b/php/monthly_report_print.php @@ -0,0 +1,332 @@ + htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); + +$month_dot = str_replace('-', '.', $month); // 2026-06 → 2026.06 +$year_n = (int)substr($month, 0, 4); +$month_n = (int)substr($month, 5, 2); +$doc_no = 'ENV-' . $month; // 문서번호 + +try { + $dt_last = new DateTime($end_at); + $dt_last->modify('-1 day'); + $period_end = $dt_last->format('Y-m-d'); +} catch (Throwable $e) { + $period_end = substr($end_at, 0, 10); +} +$period_start = substr($start_at, 0, 10); + +// 관제 대상(설정 기준) +$metric_ids = defined('METRIC_SENSOR_IDS') ? METRIC_SENSOR_IDS : []; +$leak_ids = defined('LEAK_SENSOR_IDS') ? LEAK_SENSOR_IDS : []; +$scope_total = count(array_unique(array_merge($metric_ids, $leak_ids))); + +$total_alerts = $summary['threshold_alerts'] + $summary['leak_alert']; +$incident_count = $has_incident_table ? count($incidents) : $summary['leak_alert']; + +// verdict 색 클래스(요약 카드/문장) +$verdict_cls = 'is-' . ($verdict === 'alert' ? 'alert' : ($verdict === 'warn' ? 'warn' : 'ok')); +$verdict_color = rc_verdict_color($verdict); + +// 자동 총평 한 줄 +$auto_summary = sprintf( + '당월 온습도 측정 %s건 · 임계 경보 %d건 · 누수 감지 %d건이 집계되었으며, SMS 발송 신뢰성은 %d%%(%d건 중 실패 %d건)입니다.', + number_format($summary['metrics']), + $summary['threshold_alerts'], + $summary['leak_alert'], + $summary['sms_reliability_pct'], + $summary['total_sms'], + $summary['sms_fail'] +); + +// 센서(지점)별 판정 — 데이터 층 verdict 규칙과 동일(미복구 누수는 월 단위라 여기선 누수감지로 근사) +$sensor_verdict = function (array $s): array { + if ($s['leak_alert'] > 0 || $s['sms_fail'] > 0) { return ['alert', '경보']; } + if ($s['threshold_alerts'] > 0 || $s['device_offline'] > 0) { return ['warn', '주의']; } + return ['ok', '안정']; +}; + +// 임계 기준 문자열 +$rh_low_txt = (METRIC_RH_LOW >= 0) ? (rc_fmt((float)METRIC_RH_LOW) . '%') : '비활성'; +?> + + + + + + 정보시설 관제 월간 운영 보고서 - <?= $h($month) ?> + + + +
+ + +
+ ← 조회 화면 + + 대시보드 +
+ + + +
+ 정보시설 관제 월간 운영 보고서 · + 대외비 (CONFIDENTIAL) +
+ + + +
+
+ + +
+
+ +
종합 판정
+
+
정보시설 관제 · 온습도 / 누수
+

정보시설 관제
월간 운영 보고서

+

서버실 · 문서고 온습도 및 누수 모니터링 / SMS 경보 운영 결과

+ +
+
대상 기간 ~ (년 월)
+ +
+
+
관제 지점
+
개소
+
온습도 · 누수
+
+
+
정보기반
+
서버실(안)
+
온습도 계측
+
+
+
조직혁신 · 고객센터
+
문서고 · 서버실(밖)
+
누수 감시
+
+
+ +
+
문서번호
+
생성 일시
+
문서 분류
대외비
+
보고 범위
+
데이터 출처
leak_sms_log · leak_sensor_metric
+
시스템
관제
+
+
+ + +
+
01 경영 요약 EXECUTIVE SUMMARY
+ +
+
+
+ +
+
+
+
총 경보
+
+
임계 · 누수
+
+
+
누수 감지
+
+
+
+
+
측정 건수
+
+
평균 /
+
+
+ +
+
종합 판정:
+

+
+
+ + +
+
02 월간 지표 상세 MONTHLY METRICS
+
+
+

경보 · 이벤트

+ + + + + + + + + + +
고온 경보건
저온 경보건
고습 경보건
저습 경보건
임계 경보 합계건
누수 감지 / 복구 / 건
정상 복귀건
장비 오프라인 / 복구 / 건
SMS 실패 / 총 발송 / 건
+
+
+

온습도 측정

+ + + + + + + + + +
측정 건수건
평균 온도
온도 최저 / 최고 /
평균 습도
습도 최저 / 최고 /
SMS 신뢰성%
고온 / 저온 임계℃ / ℃
고습 / 저습 임계% /
+
+
+
+ + +
+
03 월중 추이 DAILY TRENDS
+ +
+

일별 평균 온·습도

+

임계 밴드(고온 적 · 안전 녹 · 저온 청)를 배경에 인쇄. 온도 실선(좌축) · 습도 파선(우축). 결측일은 선이 끊깁니다.

+ +
+ +
+

일별 경보 발생

+

온습도 임계 경보(네이비)와 누수 감지(적 · 사선)를 당월 1일~말일 축에 스택으로 표시합니다.

+ +
+
+ + +
+
04 관제 지점별 상세 BY LOCATION
+ +
해당 월 측정·경보 내역이 없습니다.
+ +
+ +
+
+
+
+
+
+ +
+
+
임계 경보
건
+
고온/저온
/
+
고습/저습
/
+
누수 감지/복구
/
+
오프라인/복구
/
+
SMS 실패
+
측정
건
+
평균 온/습
/
+
온도 최저/최고
/
+
습도 최저/최고
/
+
최근 측정
+
+
+ +
+ +
+ + +
+
05 누수 사고 로그 LEAK INCIDENTS
+ +
누수 사고 이력(leak_incident) 테이블이 도입되지 않은 배포입니다. SMS 태그 집계로 대체합니다 — 당월 누수 감지 건 / 복구 건.
+ +
✓ 당월 누수 사고 없음 (0건) — 감시 구간 내 누수 미발생.
+ + + + + + + + + + + + + + +
발생 일시지점위치지속상태
= 60 ? intdiv($mm, 60) . '시간 ' . ($mm % 60) . '분' : $mm . '분'; + } else { echo '-'; } + ?>
+ +
+ + +
+
06 기준 · 주석 및 서명 BASIS & SIGN-OFF
+
+
+

판정 · 임계 기준

+
    +
  • 임계 고온 ℃ · 저온 ℃ · 고습 % · 저습
  • +
  • 판정 규칙 미복구 누수 또는 SMS 실패 > 0 → 경보 · 임계 경보 또는 오프라인 발생 → 주의 · 그 외 → 안정
  • +
  • 오프라인 판정 분 무수신 · 경보 쿨다운 온습도 분 / 누수 분
  • +
+
+
+

데이터 · 주석

+
    +
  • 출처 leak_sms_log(태그 접두 집계) · leak_sensor_metric(측정) · leak_sensor_log/leak_incident(누수)
  • +
  • 집계 범위 00:00 ~ 24:00 (센서는 sensor_id 기준, 표시명은 관리주체 라벨)
  • +
  • 개인정보 수신번호는 표시 단계에서 마스킹(mask_phone) · 본 문서 분류: 대외비
  • +
+
+
+ +
+
작성
직/성명 · 일자
+
검토
직/성명 · 일자
+
승인
직/성명 · 일자
+
+
+ +
+
+
+ + diff --git a/php/report_charts.php b/php/report_charts.php new file mode 100644 index 0000000..4b60b67 --- /dev/null +++ b/php/report_charts.php @@ -0,0 +1,339 @@ + RC_ALERT, + 'warn' => RC_WARN, + default => RC_NAVY, // ok(안정)은 신뢰감 있는 네이비 + }; +} + +// 짧은 수치 포맷(소수 1자리, 불필요한 0 제거) +function rc_fmt(float $v): string { + return rtrim(rtrim(number_format($v, 1, '.', ''), '0'), '.'); +} + +// ============================================================================= +// 1) 판정 씰 — 결재 도장형 스탬프. verdict 색·라벨 자동. 표지/경영요약 공유. +// ============================================================================= +function render_verdict_seal(string $verdict, string $label): string { + $c = rc_verdict_color($verdict); + $lab = htmlspecialchars($label); + ob_start(); ?> + + + + + + + 관제 판정 + + MONTHLY REVIEW + + + + + + + + + + + + $v !== null)) > 0; + $has_hum = count(array_filter($hums, fn($v) => $v !== null)) > 0; + if ($n === 0 || (!$has_temp && !$has_hum)) { + return '
당월 측정 데이터가 없습니다.
'; + } + + $W = 920; $H = 300; + $pad_l = 46; $pad_r = 48; $pad_t = 22; $pad_b = 40; + $plot_w = $W - $pad_l - $pad_r; + $plot_h = $H - $pad_t - $pad_b; + + // 임계 상수(밴드/스케일에 포함해 항상 보이게) + $t_high = (float)METRIC_TEMP_HIGH_C; + $t_low = (float)METRIC_TEMP_LOW_C; + + // 온도 축: 데이터 + 임계 포함해 범위 산정 + $t_vals = array_filter($temps, fn($v) => $v !== null); + $t_pool = array_merge($t_vals, [$t_high, $t_low]); + $t_min = min($t_pool); $t_max = max($t_pool); + if ($t_min === $t_max) { $t_min -= 1; $t_max += 1; } + $t_span = ($t_max - $t_min) * 0.12; + $t_min -= $t_span; $t_max += $t_span; + + // 습도 축: 데이터 기준 + $h_vals = array_filter($hums, fn($v) => $v !== null); + if ($h_vals) { + $h_min = min($h_vals); $h_max = max($h_vals); + if ($h_min === $h_max) { $h_min -= 1; $h_max += 1; } + $h_span = ($h_max - $h_min) * 0.12; + $h_min -= $h_span; $h_max += $h_span; + } else { + $h_min = 0; $h_max = 100; + } + + $x_at = function (int $i) use ($pad_l, $plot_w, $n): float { + if ($n <= 1) { return $pad_l + $plot_w / 2; } + return $pad_l + ($plot_w * $i / ($n - 1)); + }; + $y_temp = function (?float $v) use ($pad_t, $plot_h, $t_min, $t_max): ?float { + if ($v === null) { return null; } + $r = ($t_max - $t_min) ?: 1; + return $pad_t + $plot_h * (1 - (($v - $t_min) / $r)); + }; + $y_hum = function (?float $v) use ($pad_t, $plot_h, $h_min, $h_max): ?float { + if ($v === null) { return null; } + $r = ($h_max - $h_min) ?: 1; + return $pad_t + $plot_h * (1 - (($v - $h_min) / $r)); + }; + $clampy = fn(float $y) => max($pad_t, min($pad_t + $plot_h, $y)); + + // 결측(null)은 구간을 끊어 polyline 을 만든다 + $build_segments = function (array $vals, callable $y_fn) use ($x_at): array { + $segments = []; $current = []; + foreach ($vals as $i => $v) { + if ($v === null) { + if (count($current) >= 2) { $segments[] = $current; } + $current = []; + continue; + } + $y = $y_fn((float)$v); + $current[] = sprintf('%.1f,%.1f', $x_at($i), $y); + } + if (count($current) >= 2) { $segments[] = $current; } + return $segments; + }; + $temp_segments = $build_segments($temps, $y_temp); + $hum_segments = $build_segments($hums, $y_hum); + + // 임계 밴드 경계(온도축) + $y_high = $clampy($y_temp($t_high)); + $y_low = $clampy($y_temp($t_low)); + $band_top = $pad_t; $band_bottom = $pad_t + $plot_h; + + ob_start(); ?> + + + + + + + + + + + + + + + + + 고온 ℃ + + 저온 ℃ + + + + + + + 일(당월) + + + + + + + + + + +
+ 평균 온도(℃, 좌축·실선) + 평균 습도(%, 우축·파선) + 안전대 + 고온대 + 저온대 +
+당월 일자 축이 없습니다.'; + } + + $totals = []; + for ($i = 0; $i < $n; $i++) { + $totals[$i] = (int)($metric[$i] ?? 0) + (int)($leak[$i] ?? 0); + } + $peak = max(1, max($totals)); + // Y 눈금 상한을 보기 좋은 정수로 올림(최대 4단계) + $step = (int)max(1, ceil($peak / 4)); + $y_top = $step * 4; + + $W = 920; $H = 240; + $pad_l = 40; $pad_r = 16; $pad_t = 20; $pad_b = 40; + $plot_w = $W - $pad_l - $pad_r; + $plot_h = $H - $pad_t - $pad_b; + + $slot = $plot_w / $n; + $bar_w = min(22, $slot * 0.6); + + ob_start(); ?> + + + + + + + + + + + + + + + + + 0 ? ($plot_h * $mv / $y_top) : 0; + $l_h = $lv > 0 ? ($plot_h * $lv / $y_top) : 0; + $m_y = $base - $m_h; + $l_y = $m_y - $l_h; + ?> + 0): ?> + + + 0): ?> + + + + + + + 일(당월) · 발생 건수 + +
+ 온습도 임계 경보 + 누수 감지 +
+