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 이스케이프.
This commit is contained in:
parent
553e59deb8
commit
06f96a8d09
6 changed files with 1371 additions and 177 deletions
|
|
@ -21,8 +21,10 @@
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
# ─── 민감 PHP 파일 직접 접근 차단 ─────────────────────────
|
# ─── 민감 PHP 파일 직접 접근 차단 ─────────────────────────
|
||||||
# (dashboard_data·forecast·live·trend·history·system 은 include 전용 — tabs/.htaccess 와 이중 차단)
|
# (dashboard_data·forecast·live·trend·history·system·monthly_report_data·
|
||||||
<FilesMatch "^(config|config\.local|config\.local\.example|sms_send|incidents|ops_checks|setup_hash|test_mobile|dashboard_data|forecast|live|trend|history|system)\.php$">
|
# monthly_report_print·report_charts 는 include 전용 — tabs/.htaccess 와 이중 차단.
|
||||||
|
# monthly_report.php 본체는 진입 페이지이므로 차단 대상 아님)
|
||||||
|
<FilesMatch "^(config|config\.local|config\.local\.example|sms_send|incidents|ops_checks|setup_hash|test_mobile|dashboard_data|forecast|live|trend|history|system|monthly_report_data|monthly_report_print|report_charts)\.php$">
|
||||||
<IfModule mod_authz_core.c>
|
<IfModule mod_authz_core.c>
|
||||||
Require all denied
|
Require all denied
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
|
||||||
285
php/assets/report-print.css
Normal file
285
php/assets/report-print.css
Normal file
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -26,180 +26,11 @@ if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
|
||||||
}
|
}
|
||||||
admin_enforce_session_timeout(); // 유휴 + 절대 수명 타임아웃(대시보드 외 페이지에서도 강제)
|
admin_enforce_session_timeout(); // 유휴 + 절대 수명 타임아웃(대시보드 외 페이지에서도 강제)
|
||||||
|
|
||||||
function month_range(string $month): array {
|
// ── 집계 데이터 계층(단일 출처) ──────────────────────────────────────────────
|
||||||
if (!preg_match('/^\d{4}-\d{2}$/', $month)) {
|
// 헬퍼(month_range/round_or_null/fmt_num/csv_cell)와 $summary/$sensor_rows 및 인쇄
|
||||||
$month = date('Y-m');
|
// 뷰 전용 파생(일별 추이·누수 사고·verdict)을 이 include 가 산출한다. HTML 조회 뷰,
|
||||||
}
|
// CSV 다운로드, PDF 인쇄 뷰가 동일 소스를 공유한다(집계 중복 0 — 회귀 방지).
|
||||||
$start = DateTime::createFromFormat('Y-m-d H:i:s', $month . '-01 00:00:00');
|
require_once __DIR__ . '/monthly_report_data.php';
|
||||||
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']];
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── CSV 다운로드 ─────────────────────────────────────────────────────────────
|
// ── CSV 다운로드 ─────────────────────────────────────────────────────────────
|
||||||
if (($_GET['format'] ?? '') === 'csv') {
|
if (($_GET['format'] ?? '') === 'csv') {
|
||||||
|
|
@ -252,6 +83,15 @@ if (($_GET['format'] ?? '') === 'csv') {
|
||||||
fclose($out);
|
fclose($out);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PDF 저장 전용 A4 문서 뷰 ─────────────────────────────────────────────────
|
||||||
|
// 다크 대시보드(app.css)를 그대로 인쇄하던 단순 프린트지를 대체한다. 화이트 A4 전문
|
||||||
|
// 보고서(표지·경영요약·지표·추이·지점별·누수사고·서명)를 렌더한다. 집계는 위 include 가
|
||||||
|
// 이미 산출했으므로 여기서 뷰만 그린다(CSV·HTML 조회 뷰는 불변 — 회귀 없음).
|
||||||
|
if (($_GET['format'] ?? '') === 'print') {
|
||||||
|
require __DIR__ . '/monthly_report_print.php';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
|
|
@ -297,8 +137,8 @@ if (($_GET['format'] ?? '') === 'csv') {
|
||||||
<form class="toolbar" method="GET">
|
<form class="toolbar" method="GET">
|
||||||
<label>월 <input type="month" name="month" value="<?= htmlspecialchars($month) ?>"></label>
|
<label>월 <input type="month" name="month" value="<?= htmlspecialchars($month) ?>"></label>
|
||||||
<button type="submit">조회</button>
|
<button type="submit">조회</button>
|
||||||
|
<a class="tool-btn" href="?month=<?= htmlspecialchars($month) ?>&format=print" target="_blank" rel="noopener">PDF 보고서</a>
|
||||||
<a class="tool-btn" href="?month=<?= htmlspecialchars($month) ?>&format=csv">CSV 다운로드</a>
|
<a class="tool-btn" href="?month=<?= htmlspecialchars($month) ?>&format=csv">CSV 다운로드</a>
|
||||||
<button type="button" onclick="window.print()">인쇄/PDF</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
|
|
|
||||||
396
php/monthly_report_data.php
Normal file
396
php/monthly_report_data.php
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
<?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 실패·임계 경보·오프라인 없음';
|
||||||
|
}
|
||||||
332
php/monthly_report_print.php
Normal file
332
php/monthly_report_print.php
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
<?php
|
||||||
|
// =============================================================================
|
||||||
|
// monthly_report_print.php - 월간 운영 보고서 · PDF 저장 전용 A4 문서 뷰
|
||||||
|
//
|
||||||
|
// monthly_report.php 의 ?format=print 분기에서 require 된다(단독 진입점 아님).
|
||||||
|
// 집계 변수는 monthly_report_data.php 가 이미 산출해 이 스코프에 있다:
|
||||||
|
// $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
|
||||||
|
//
|
||||||
|
// app.css(다크 셸)는 링크하지 않는다 — assets/report-print.css 만 링크해 다크 토큰과
|
||||||
|
// 격리한다. 차트는 report_charts.php 의 인라인 SVG 서버렌더(폐쇄망: 외부 참조 0).
|
||||||
|
// 모든 동적 텍스트는 htmlspecialchars, 숫자는 fmt_num() 으로 출력한다.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단(monthly_report.php 경유 전용)
|
||||||
|
|
||||||
|
require_once __DIR__ . '/report_charts.php';
|
||||||
|
|
||||||
|
// ── 표시용 파생값 ─────────────────────────────────────────────────────────────
|
||||||
|
$h = fn($s) => 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) . '%') : '비활성';
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>정보시설 관제 월간 운영 보고서 - <?= $h($month) ?></title>
|
||||||
|
<link rel="stylesheet" href="assets/report-print.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="report-canvas">
|
||||||
|
|
||||||
|
<!-- 화면 전용 안내 + 툴바(인쇄 시 자동 숨김) -->
|
||||||
|
<div class="screen-toolbar screen-only">
|
||||||
|
<a class="ghost" href="monthly_report.php?month=<?= $h($month) ?>">← 조회 화면</a>
|
||||||
|
<button type="button" onclick="window.print()">PDF로 저장 / 인쇄</button>
|
||||||
|
<a class="ghost" href="dashboard.php">대시보드</a>
|
||||||
|
</div>
|
||||||
|
<div class="print-note screen-only">
|
||||||
|
이 화면은 <b>PDF 저장 전용 A4 보고서</b> 미리보기입니다. <b>Ctrl+P</b>(⌘P) → 대상 <b>“PDF로 저장”</b>,
|
||||||
|
용지 <b>A4</b>, 여백 <b>기본</b>, <b>“배경 그래픽”</b> 옵션을 켜 주세요. 페이지 번호가 필요하면 인쇄
|
||||||
|
대화상자의 <b>머리글/바닥글</b> 옵션을 사용하세요(폐쇄망 정책상 문서 내 자동 페이지번호는 미탑재).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 러닝 헤더(매 장 반복) -->
|
||||||
|
<div class="run-header">
|
||||||
|
<span><b>정보시설 관제 월간 운영 보고서</b> · <?= $h($month_dot) ?></span>
|
||||||
|
<span class="cls">대외비 (CONFIDENTIAL)</span>
|
||||||
|
</div>
|
||||||
|
<!-- 러닝 푸터(매 장 반복 — 문서번호·생성일시로 추적성 확보) -->
|
||||||
|
<div class="run-footer">
|
||||||
|
<span>문서번호 <?= $h($doc_no) ?> · 생성 <?= $h($report_generated_at) ?></span>
|
||||||
|
<span>서버실·문서고 온습도/누수 관제 시스템 <?= $h(APP_VERSION) ?></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="report-paper">
|
||||||
|
<div class="report-body">
|
||||||
|
|
||||||
|
<!-- ═══════════ 1. 표지 마스트헤드 ═══════════ -->
|
||||||
|
<section class="cover">
|
||||||
|
<div class="cover-seal">
|
||||||
|
<?= render_verdict_seal($verdict, $verdict_label) ?>
|
||||||
|
<div class="seal-cap">종합 판정</div>
|
||||||
|
</div>
|
||||||
|
<div class="cover-eyebrow">정보시설 관제 · 온습도 / 누수</div>
|
||||||
|
<h1 class="cover-title">정보시설 관제<br>월간 운영 보고서</h1>
|
||||||
|
<p class="cover-sub">서버실 · 문서고 온습도 및 누수 모니터링 / SMS 경보 운영 결과</p>
|
||||||
|
|
||||||
|
<div class="cover-month"><?= $h($month_dot) ?></div>
|
||||||
|
<div class="cover-period">대상 기간 <?= $h($period_start) ?> ~ <?= $h($period_end) ?> (<?= $year_n ?>년 <?= $month_n ?>월)</div>
|
||||||
|
|
||||||
|
<div class="cover-scope">
|
||||||
|
<div class="scope-item">
|
||||||
|
<div class="k">관제 지점</div>
|
||||||
|
<div class="v"><?= (int)$scope_total ?>개소</div>
|
||||||
|
<div class="t">온습도 <?= count($metric_ids) ?> · 누수 <?= count($leak_ids) ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="scope-item">
|
||||||
|
<div class="k">정보기반</div>
|
||||||
|
<div class="v">서버실(안)</div>
|
||||||
|
<div class="t">온습도 계측</div>
|
||||||
|
</div>
|
||||||
|
<div class="scope-item">
|
||||||
|
<div class="k">조직혁신 · 고객센터</div>
|
||||||
|
<div class="v">문서고 · 서버실(밖)</div>
|
||||||
|
<div class="t">누수 감시</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="doc-control">
|
||||||
|
<div><div class="dk">문서번호</div><div class="dv"><?= $h($doc_no) ?></div></div>
|
||||||
|
<div><div class="dk">생성 일시</div><div class="dv"><?= $h($report_generated_at) ?></div></div>
|
||||||
|
<div><div class="dk">문서 분류</div><div class="dv cls">대외비</div></div>
|
||||||
|
<div><div class="dk">보고 범위</div><div class="dv"><?= $h($report_scope) ?></div></div>
|
||||||
|
<div><div class="dk">데이터 출처</div><div class="dv">leak_sms_log · leak_sensor_metric</div></div>
|
||||||
|
<div><div class="dk">시스템</div><div class="dv">관제 <?= $h(APP_VERSION) ?></div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 2. 경영 요약 ═══════════ -->
|
||||||
|
<section class="section page-break">
|
||||||
|
<div class="section-title"><span class="no">01</span> 경영 요약 <span class="en">EXECUTIVE SUMMARY</span></div>
|
||||||
|
|
||||||
|
<div class="kpi-row">
|
||||||
|
<div class="kpi-card is-ok">
|
||||||
|
<div class="kpi-gauge-wrap">
|
||||||
|
<?= render_uptime_gauge($summary['sms_reliability_pct'], $verdict_color, $summary['sms_reliability_pct'] . '%', 'SMS 신뢰성') ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card <?= $total_alerts > 0 ? 'is-warn' : 'is-ok' ?>">
|
||||||
|
<div class="kpi-k">총 경보</div>
|
||||||
|
<div class="kpi-v"><?= (int)$total_alerts ?></div>
|
||||||
|
<div class="kpi-s">임계 <?= (int)$summary['threshold_alerts'] ?> · 누수 <?= (int)$summary['leak_alert'] ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card <?= $summary['leak_alert'] > 0 ? 'is-alert' : 'is-ok' ?>">
|
||||||
|
<div class="kpi-k">누수 감지</div>
|
||||||
|
<div class="kpi-v"><?= (int)$summary['leak_alert'] ?></div>
|
||||||
|
<div class="kpi-s"><?= $has_incident_table ? ('사고 ' . (int)$incident_count . '건 · 미복구 ' . (int)$unrecovered_leak) : ('복구 ' . (int)$summary['leak_recovery'] . '건') ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card is-ok">
|
||||||
|
<div class="kpi-k">측정 건수</div>
|
||||||
|
<div class="kpi-v"><?= number_format($summary['metrics']) ?></div>
|
||||||
|
<div class="kpi-s">평균 <?= fmt_num($summary['avg_temp'], '℃') ?> / <?= fmt_num($summary['avg_humidity'], '%') ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="exec-summary">
|
||||||
|
<div class="verdict-line <?= $verdict_cls ?>">종합 판정: <?= $h($verdict_label) ?><?= $verdict_reason !== '' ? ' — ' . $h($verdict_reason) : '' ?></div>
|
||||||
|
<p><?= $h($auto_summary) ?></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 3. 월간 지표 상세 ═══════════ -->
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-title"><span class="no">02</span> 월간 지표 상세 <span class="en">MONTHLY METRICS</span></div>
|
||||||
|
<div class="metric-grid">
|
||||||
|
<div class="metric-block">
|
||||||
|
<h3>경보 · 이벤트</h3>
|
||||||
|
<table class="def">
|
||||||
|
<tr><th>고온 경보</th><td><?= (int)$summary['high_temp'] ?>건</td></tr>
|
||||||
|
<tr><th>저온 경보</th><td><?= (int)$summary['low_temp'] ?>건</td></tr>
|
||||||
|
<tr><th>고습 경보</th><td><?= (int)$summary['high_humidity'] ?>건</td></tr>
|
||||||
|
<tr><th>저습 경보</th><td><?= (int)$summary['low_humidity'] ?>건</td></tr>
|
||||||
|
<tr><th>임계 경보 합계</th><td><?= (int)$summary['threshold_alerts'] ?>건</td></tr>
|
||||||
|
<tr><th>누수 감지 / 복구</th><td class="<?= $summary['leak_alert'] > 0 ? 'is-alert' : '' ?>"><?= (int)$summary['leak_alert'] ?> / <?= (int)$summary['leak_recovery'] ?>건</td></tr>
|
||||||
|
<tr><th>정상 복귀</th><td><?= (int)$summary['recovery'] ?>건</td></tr>
|
||||||
|
<tr><th>장비 오프라인 / 복구</th><td><?= (int)$summary['device_offline'] ?> / <?= (int)$summary['device_online'] ?>건</td></tr>
|
||||||
|
<tr><th>SMS 실패 / 총 발송</th><td class="<?= $summary['sms_fail'] > 0 ? 'is-alert' : '' ?>"><?= (int)$summary['sms_fail'] ?> / <?= (int)$summary['total_sms'] ?>건</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="metric-block">
|
||||||
|
<h3>온습도 측정</h3>
|
||||||
|
<table class="def">
|
||||||
|
<tr><th>측정 건수</th><td><?= number_format($summary['metrics']) ?>건</td></tr>
|
||||||
|
<tr><th>평균 온도</th><td><?= fmt_num($summary['avg_temp'], '℃') ?></td></tr>
|
||||||
|
<tr><th>온도 최저 / 최고</th><td><?= fmt_num($summary['min_temp'], '℃') ?> / <?= fmt_num($summary['max_temp'], '℃') ?></td></tr>
|
||||||
|
<tr><th>평균 습도</th><td><?= fmt_num($summary['avg_humidity'], '%') ?></td></tr>
|
||||||
|
<tr><th>습도 최저 / 최고</th><td><?= fmt_num($summary['min_humidity'], '%') ?> / <?= fmt_num($summary['max_humidity'], '%') ?></td></tr>
|
||||||
|
<tr><th>SMS 신뢰성</th><td><?= (int)$summary['sms_reliability_pct'] ?>%</td></tr>
|
||||||
|
<tr><th>고온 / 저온 임계</th><td><?= $h(rc_fmt((float)METRIC_TEMP_HIGH_C)) ?>℃ / <?= $h(rc_fmt((float)METRIC_TEMP_LOW_C)) ?>℃</td></tr>
|
||||||
|
<tr><th>고습 / 저습 임계</th><td><?= $h(rc_fmt((float)METRIC_RH_HIGH)) ?>% / <?= $h($rh_low_txt) ?></td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 4. 월중 추이(차트) ═══════════ -->
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-title"><span class="no">03</span> 월중 추이 <span class="en">DAILY TRENDS</span></div>
|
||||||
|
|
||||||
|
<div class="chart-panel">
|
||||||
|
<h3>일별 평균 온·습도</h3>
|
||||||
|
<p class="chart-cap">임계 밴드(고온 적 · 안전 녹 · 저온 청)를 배경에 인쇄. 온도 실선(좌축) · 습도 파선(우축). 결측일은 선이 끊깁니다.</p>
|
||||||
|
<?= render_daily_temp_hum_line($cal_days, $daily_temp, $daily_hum) ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-panel">
|
||||||
|
<h3>일별 경보 발생</h3>
|
||||||
|
<p class="chart-cap">온습도 임계 경보(네이비)와 누수 감지(적 · 사선)를 당월 1일~말일 축에 스택으로 표시합니다.</p>
|
||||||
|
<?= render_daily_alert_bars($cal_days, $daily_metric_alerts, $daily_leak_alerts) ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 5. 관제 지점별 상세 ═══════════ -->
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-title"><span class="no">04</span> 관제 지점별 상세 <span class="en">BY LOCATION</span></div>
|
||||||
|
<?php if (empty($sensor_rows)): ?>
|
||||||
|
<div class="chart-empty">해당 월 측정·경보 내역이 없습니다.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="loc-cards">
|
||||||
|
<?php foreach ($sensor_rows as $s): [$sv_cls, $sv_lab] = $sensor_verdict($s); ?>
|
||||||
|
<div class="loc-card">
|
||||||
|
<div class="lc-head">
|
||||||
|
<div>
|
||||||
|
<div class="lc-name"><?= $h($s['sensor_name']) ?></div>
|
||||||
|
<div class="lc-loc"><?= $h($s['device_location']) ?></div>
|
||||||
|
</div>
|
||||||
|
<span class="pill is-<?= $sv_cls ?>"><?= $h($sv_lab) ?></span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<dt>임계 경보</dt><dd><?= (int)$s['threshold_alerts'] ?>건</dd>
|
||||||
|
<dt>고온/저온</dt><dd><?= (int)$s['high_temp'] ?>/<?= (int)$s['low_temp'] ?></dd>
|
||||||
|
<dt>고습/저습</dt><dd><?= (int)$s['high_humidity'] ?>/<?= (int)$s['low_humidity'] ?></dd>
|
||||||
|
<dt>누수 감지/복구</dt><dd class="<?= $s['leak_alert'] > 0 ? 'is-alert' : '' ?>"><?= (int)$s['leak_alert'] ?>/<?= (int)$s['leak_recovery'] ?></dd>
|
||||||
|
<dt>오프라인/복구</dt><dd><?= (int)$s['device_offline'] ?>/<?= (int)$s['device_online'] ?></dd>
|
||||||
|
<dt>SMS 실패</dt><dd class="<?= $s['sms_fail'] > 0 ? 'is-alert' : '' ?>"><?= (int)$s['sms_fail'] ?></dd>
|
||||||
|
<dt>측정</dt><dd><?= number_format($s['metrics']) ?>건</dd>
|
||||||
|
<dt>평균 온/습</dt><dd><?= fmt_num($s['avg_temp'], '℃') ?> / <?= fmt_num($s['avg_humidity'], '%') ?></dd>
|
||||||
|
<dt>온도 최저/최고</dt><dd><?= fmt_num($s['min_temp'], '℃') ?> / <?= fmt_num($s['max_temp'], '℃') ?></dd>
|
||||||
|
<dt>습도 최저/최고</dt><dd><?= fmt_num($s['min_humidity'], '%') ?> / <?= fmt_num($s['max_humidity'], '%') ?></dd>
|
||||||
|
<dt>최근 측정</dt><dd><?= $h($s['last_measured_at'] ?: '-') ?></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 6. 누수 사고 로그 ═══════════ -->
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-title"><span class="no">05</span> 누수 사고 로그 <span class="en">LEAK INCIDENTS</span></div>
|
||||||
|
<?php if (!$has_incident_table): ?>
|
||||||
|
<div class="chart-empty">누수 사고 이력(leak_incident) 테이블이 도입되지 않은 배포입니다. SMS 태그 집계로 대체합니다 — 당월 누수 감지 <?= (int)$summary['leak_alert'] ?>건 / 복구 <?= (int)$summary['leak_recovery'] ?>건.</div>
|
||||||
|
<?php elseif (empty($incidents)): ?>
|
||||||
|
<div class="incident-none">✓ 당월 누수 사고 없음 (0건) — 감시 구간 내 누수 미발생.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<table class="grid">
|
||||||
|
<thead><tr><th>발생 일시</th><th>지점</th><th>위치</th><th>지속</th><th>상태</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($incidents as $inc): ?>
|
||||||
|
<tr>
|
||||||
|
<td class="l"><?= $h($inc['started_at']) ?></td>
|
||||||
|
<td class="l"><?= $h($inc['sensor_name']) ?></td>
|
||||||
|
<td class="l"><?= $h($inc['device_location']) ?></td>
|
||||||
|
<td><?php
|
||||||
|
if ($inc['ongoing']) { echo '진행중'; }
|
||||||
|
elseif ($inc['duration_min'] !== null) {
|
||||||
|
$mm = (int)$inc['duration_min'];
|
||||||
|
echo $mm >= 60 ? intdiv($mm, 60) . '시간 ' . ($mm % 60) . '분' : $mm . '분';
|
||||||
|
} else { echo '-'; }
|
||||||
|
?></td>
|
||||||
|
<td class="<?= $inc['ongoing'] ? 'is-alert' : '' ?>"><?= $inc['ongoing'] ? '미복구' : '복구' ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 7. 기준 · 주석 및 서명 ═══════════ -->
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-title"><span class="no">06</span> 기준 · 주석 및 서명 <span class="en">BASIS & SIGN-OFF</span></div>
|
||||||
|
<div class="basis-grid">
|
||||||
|
<div class="basis-block">
|
||||||
|
<h3>판정 · 임계 기준</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>임계</b> 고온 <?= $h(rc_fmt((float)METRIC_TEMP_HIGH_C)) ?>℃ · 저온 <?= $h(rc_fmt((float)METRIC_TEMP_LOW_C)) ?>℃ · 고습 <?= $h(rc_fmt((float)METRIC_RH_HIGH)) ?>% · 저습 <?= $h($rh_low_txt) ?></li>
|
||||||
|
<li><b>판정 규칙</b> 미복구 누수 또는 SMS 실패 > 0 → 경보 · 임계 경보 또는 오프라인 발생 → 주의 · 그 외 → 안정</li>
|
||||||
|
<li><b>오프라인 판정</b> <?= (int)(HEARTBEAT_TIMEOUT_SEC / 60) ?>분 무수신 · <b>경보 쿨다운</b> 온습도 <?= (int)(METRIC_ALERT_COOLDOWN_SEC / 60) ?>분 / 누수 <?= (int)(LEAK_ALERT_COOLDOWN_SEC / 60) ?>분</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="basis-block">
|
||||||
|
<h3>데이터 · 주석</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>출처</b> leak_sms_log(태그 접두 집계) · leak_sensor_metric(측정) · leak_sensor_log/leak_incident(누수)</li>
|
||||||
|
<li><b>집계 범위</b> <?= $h($period_start) ?> 00:00 ~ <?= $h($period_end) ?> 24:00 (센서는 sensor_id 기준, 표시명은 관리주체 라벨)</li>
|
||||||
|
<li><b>개인정보</b> 수신번호는 표시 단계에서 마스킹(mask_phone) · 본 문서 분류: <span style="color:#b11f1f;font-weight:700">대외비</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sign-row">
|
||||||
|
<div class="sign-box"><div class="sk">작성</div><div class="sv"></div><div class="sn">직/성명 · 일자</div></div>
|
||||||
|
<div class="sign-box"><div class="sk">검토</div><div class="sv"></div><div class="sn">직/성명 · 일자</div></div>
|
||||||
|
<div class="sign-box"><div class="sk">승인</div><div class="sv"></div><div class="sn">직/성명 · 일자</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div><!-- /report-body -->
|
||||||
|
</div><!-- /report-paper -->
|
||||||
|
</div><!-- /report-canvas -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
339
php/report_charts.php
Normal file
339
php/report_charts.php
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
<?php
|
||||||
|
// =============================================================================
|
||||||
|
// report_charts.php - 월간 보고서 인라인 SVG 차트 렌더 함수 (폐쇄망: 외부 0)
|
||||||
|
//
|
||||||
|
// monthly_report_print.php 가 include 해 호출한다. 전부 서버렌더 인라인 SVG —
|
||||||
|
// 차트 라이브러리·외부 폰트·CDN 없음. 색은 인쇄 안전 팔레트(app.css @media print
|
||||||
|
// 토큰과 값 정합)를 hex 로 직접 박아 print-color-adjust:exact 로 유실을 막는다.
|
||||||
|
// trend.php 의 좌표/스케일(x_at·y_temp·y_hum·build_segments·threshold) 로직을 이식하고
|
||||||
|
// 임계 밴드 배경을 더했다.
|
||||||
|
//
|
||||||
|
// 흑백 프린터 대비: 색 외 채널을 병행한다 — 온도 실선 vs 습도 파선, 누수 막대 사선
|
||||||
|
// 해치, 판정 씰 텍스트 라벨. (색·도형·텍스트 3중 인코딩)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단(include 전용)
|
||||||
|
|
||||||
|
// ─── 인쇄 안전 팔레트(단일 출처) ──────────────────────────────────────────────
|
||||||
|
const RC_NAVY = '#14315e'; // 기준/온습도 경보 막대
|
||||||
|
const RC_INK = '#1a1f2b';
|
||||||
|
const RC_SUB = '#5b6472';
|
||||||
|
const RC_HAIR = '#c9d0da'; // 그리드/괘선
|
||||||
|
const RC_OK = '#0a6b39'; // 안정(녹)
|
||||||
|
const RC_WARN = '#8a5a00'; // 주의(황갈)
|
||||||
|
const RC_ALERT = '#b11f1f'; // 경보/누수(적)
|
||||||
|
const RC_TEMP = '#b34a1f'; // 온도 실선(cat-hot)
|
||||||
|
const RC_HUM = '#0f5e74'; // 습도 파선(cat-cold)
|
||||||
|
const RC_BAND_SAFE = '#e7f0ea'; // 안전 밴드(녹)
|
||||||
|
const RC_BAND_HOT = '#f6e3dc'; // 고온 밴드(적)
|
||||||
|
const RC_BAND_COLD = '#dfeef2'; // 저온 밴드(청)
|
||||||
|
|
||||||
|
// verdict → 색 매핑(씰·게이지 공유)
|
||||||
|
function rc_verdict_color(string $verdict): string {
|
||||||
|
return match ($verdict) {
|
||||||
|
'alert' => 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(); ?>
|
||||||
|
<svg viewBox="0 0 120 120" width="112" height="112" role="img"
|
||||||
|
aria-label="관제 판정: <?= $lab ?>" class="verdict-seal"
|
||||||
|
style="print-color-adjust:exact;-webkit-print-color-adjust:exact">
|
||||||
|
<g transform="rotate(-8 60 60)" fill="none" stroke="<?= $c ?>">
|
||||||
|
<circle cx="60" cy="60" r="56" stroke-width="2.4"></circle>
|
||||||
|
<circle cx="60" cy="60" r="48" stroke-width="1"></circle>
|
||||||
|
</g>
|
||||||
|
<g transform="rotate(-8 60 60)" fill="<?= $c ?>" stroke="none" text-anchor="middle">
|
||||||
|
<text x="60" y="34" font-size="10.5" font-weight="700" letter-spacing="1.5">관제 판정</text>
|
||||||
|
<text x="60" y="74" font-size="30" font-weight="800" letter-spacing="2"><?= $lab ?></text>
|
||||||
|
<text x="60" y="94" font-size="7.5" font-weight="700" letter-spacing="3">MONTHLY REVIEW</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<?php
|
||||||
|
return (string)ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 2) 원형 게이지 — 값 %(0~100). 경영 요약 KPI(예: SMS 신뢰성). trend.php 게이지 이식.
|
||||||
|
// =============================================================================
|
||||||
|
function render_uptime_gauge(int $pct, string $color, string $center_big, string $center_small): string {
|
||||||
|
$pct = max(0, min(100, $pct));
|
||||||
|
ob_start(); ?>
|
||||||
|
<svg viewBox="0 0 120 120" width="100%" height="120" role="img"
|
||||||
|
aria-label="<?= htmlspecialchars($center_small) ?> <?= $pct ?>%" class="kpi-gauge"
|
||||||
|
style="print-color-adjust:exact;-webkit-print-color-adjust:exact">
|
||||||
|
<g transform="rotate(-90 60 60)" fill="none" stroke-linecap="round">
|
||||||
|
<circle cx="60" cy="60" r="50" pathLength="100" stroke="<?= RC_HAIR ?>" stroke-width="10"></circle>
|
||||||
|
<circle cx="60" cy="60" r="50" pathLength="100" stroke="<?= htmlspecialchars($color) ?>"
|
||||||
|
stroke-width="10" stroke-dasharray="<?= $pct ?> 100"></circle>
|
||||||
|
</g>
|
||||||
|
<text x="60" y="58" text-anchor="middle" font-size="26" font-weight="800"
|
||||||
|
fill="<?= RC_INK ?>"><?= htmlspecialchars($center_big) ?></text>
|
||||||
|
<text x="60" y="76" text-anchor="middle" font-size="9" font-weight="600"
|
||||||
|
fill="<?= RC_SUB ?>"><?= htmlspecialchars($center_small) ?></text>
|
||||||
|
</svg>
|
||||||
|
<?php
|
||||||
|
return (string)ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 3) 일별 평균 온·습도 strip-chart — 임계 밴드 배경 + 온도 실선 + 습도 파선(이중축).
|
||||||
|
// $cal_days: ['Y-m-d',...] $daily_temp/$daily_hum: 동일 순서, 결측 null.
|
||||||
|
// =============================================================================
|
||||||
|
function render_daily_temp_hum_line(array $cal_days, array $daily_temp, array $daily_hum): string {
|
||||||
|
$n = count($cal_days);
|
||||||
|
$temps = array_values($daily_temp); // cal_days 순서 유지(값 배열)
|
||||||
|
$hums = array_values($daily_hum);
|
||||||
|
|
||||||
|
$has_temp = count(array_filter($temps, fn($v) => $v !== null)) > 0;
|
||||||
|
$has_hum = count(array_filter($hums, fn($v) => $v !== null)) > 0;
|
||||||
|
if ($n === 0 || (!$has_temp && !$has_hum)) {
|
||||||
|
return '<div class="chart-empty">당월 측정 데이터가 없습니다.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$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(); ?>
|
||||||
|
<svg viewBox="0 0 <?= $W ?> <?= $H ?>" width="100%" height="<?= $H ?>"
|
||||||
|
preserveAspectRatio="xMidYMid meet" role="img"
|
||||||
|
aria-label="당월 일별 평균 온도·습도 추이(임계 밴드 배경)" class="strip-chart"
|
||||||
|
style="print-color-adjust:exact;-webkit-print-color-adjust:exact">
|
||||||
|
<!-- 임계 밴드: 고온(적)·안전(녹)·저온(청) -->
|
||||||
|
<rect x="<?= $pad_l ?>" y="<?= sprintf('%.1f', $band_top) ?>" width="<?= $plot_w ?>"
|
||||||
|
height="<?= sprintf('%.1f', max(0, $y_high - $band_top)) ?>" fill="<?= RC_BAND_HOT ?>"></rect>
|
||||||
|
<rect x="<?= $pad_l ?>" y="<?= sprintf('%.1f', $y_high) ?>" width="<?= $plot_w ?>"
|
||||||
|
height="<?= sprintf('%.1f', max(0, $y_low - $y_high)) ?>" fill="<?= RC_BAND_SAFE ?>"></rect>
|
||||||
|
<rect x="<?= $pad_l ?>" y="<?= sprintf('%.1f', $y_low) ?>" width="<?= $plot_w ?>"
|
||||||
|
height="<?= sprintf('%.1f', max(0, $band_bottom - $y_low)) ?>" fill="<?= RC_BAND_COLD ?>"></rect>
|
||||||
|
<rect x="<?= $pad_l ?>" y="<?= $pad_t ?>" width="<?= $plot_w ?>" height="<?= $plot_h ?>"
|
||||||
|
fill="none" stroke="<?= RC_HAIR ?>" stroke-width="1"></rect>
|
||||||
|
|
||||||
|
<!-- 가로 그리드 + 좌(온도)/우(습도) 눈금 -->
|
||||||
|
<?php for ($g = 0; $g <= 4; $g++):
|
||||||
|
$gy = $pad_t + $plot_h * $g / 4;
|
||||||
|
$tv = $t_max - ($t_max - $t_min) * $g / 4;
|
||||||
|
$hv = $h_max - ($h_max - $h_min) * $g / 4; ?>
|
||||||
|
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $gy) ?>" x2="<?= $pad_l + $plot_w ?>"
|
||||||
|
y2="<?= sprintf('%.1f', $gy) ?>" stroke="<?= RC_HAIR ?>" stroke-width="0.6" stroke-dasharray="3,3"></line>
|
||||||
|
<text x="<?= $pad_l - 7 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>" text-anchor="end"
|
||||||
|
font-size="10" fill="<?= RC_TEMP ?>"><?= htmlspecialchars(rc_fmt($tv)) ?></text>
|
||||||
|
<text x="<?= $pad_l + $plot_w + 7 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>" text-anchor="start"
|
||||||
|
font-size="10" fill="<?= RC_HUM ?>"><?= htmlspecialchars(rc_fmt($hv)) ?></text>
|
||||||
|
<?php endfor; ?>
|
||||||
|
|
||||||
|
<!-- 임계선(온도 고온/저온) 라벨 -->
|
||||||
|
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $y_high) ?>" x2="<?= $pad_l + $plot_w ?>"
|
||||||
|
y2="<?= sprintf('%.1f', $y_high) ?>" stroke="<?= RC_ALERT ?>" stroke-width="1" stroke-dasharray="2,3" opacity="0.7"></line>
|
||||||
|
<text x="<?= $pad_l + 4 ?>" y="<?= sprintf('%.1f', $y_high - 3) ?>" font-size="9"
|
||||||
|
fill="<?= RC_ALERT ?>">고온 <?= htmlspecialchars(rc_fmt($t_high)) ?>℃</text>
|
||||||
|
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $y_low) ?>" x2="<?= $pad_l + $plot_w ?>"
|
||||||
|
y2="<?= sprintf('%.1f', $y_low) ?>" stroke="<?= RC_HUM ?>" stroke-width="1" stroke-dasharray="2,3" opacity="0.7"></line>
|
||||||
|
<text x="<?= $pad_l + 4 ?>" y="<?= sprintf('%.1f', $y_low + 11) ?>" font-size="9"
|
||||||
|
fill="<?= RC_HUM ?>">저온 <?= htmlspecialchars(rc_fmt($t_low)) ?>℃</text>
|
||||||
|
|
||||||
|
<!-- X축 날짜(일자) 라벨: 1/5/10.../말일 -->
|
||||||
|
<?php
|
||||||
|
for ($i = 0; $i < $n; $i++):
|
||||||
|
$day = (int)substr($cal_days[$i], 8, 2);
|
||||||
|
$is_tick = ($day === 1 || $day % 5 === 0 || $i === $n - 1);
|
||||||
|
if (!$is_tick) { continue; }
|
||||||
|
$lx = $x_at($i);
|
||||||
|
$anchor = $i === 0 ? 'start' : ($i === $n - 1 ? 'end' : 'middle'); ?>
|
||||||
|
<line x1="<?= sprintf('%.1f', $lx) ?>" y1="<?= $pad_t + $plot_h ?>" x2="<?= sprintf('%.1f', $lx) ?>"
|
||||||
|
y2="<?= $pad_t + $plot_h + 4 ?>" stroke="<?= RC_HAIR ?>" stroke-width="0.8"></line>
|
||||||
|
<text x="<?= sprintf('%.1f', $lx) ?>" y="<?= $H - 22 ?>" text-anchor="<?= $anchor ?>"
|
||||||
|
font-size="9.5" fill="<?= RC_SUB ?>"><?= $day ?></text>
|
||||||
|
<?php endfor; ?>
|
||||||
|
<text x="<?= $pad_l + $plot_w / 2 ?>" y="<?= $H - 6 ?>" text-anchor="middle"
|
||||||
|
font-size="9" fill="<?= RC_SUB ?>">일(당월)</text>
|
||||||
|
|
||||||
|
<!-- 습도 파선 -->
|
||||||
|
<?php foreach ($hum_segments as $seg): ?>
|
||||||
|
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>" fill="none" stroke="<?= RC_HUM ?>"
|
||||||
|
stroke-width="1.8" stroke-dasharray="6,3" stroke-linejoin="round" stroke-linecap="round"></polyline>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<!-- 온도 실선 -->
|
||||||
|
<?php foreach ($temp_segments as $seg): ?>
|
||||||
|
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>" fill="none" stroke="<?= RC_TEMP ?>"
|
||||||
|
stroke-width="2" stroke-linejoin="round" stroke-linecap="round"></polyline>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</svg>
|
||||||
|
<div class="chart-legend-row">
|
||||||
|
<span><span class="lg-line" style="background:<?= RC_TEMP ?>"></span> 평균 온도(℃, 좌축·실선)</span>
|
||||||
|
<span><span class="lg-line dash" style="background:<?= RC_HUM ?>"></span> 평균 습도(%, 우축·파선)</span>
|
||||||
|
<span><span class="lg-band" style="background:<?= RC_BAND_SAFE ?>"></span> 안전대</span>
|
||||||
|
<span><span class="lg-band" style="background:<?= RC_BAND_HOT ?>"></span> 고온대</span>
|
||||||
|
<span><span class="lg-band" style="background:<?= RC_BAND_COLD ?>"></span> 저온대</span>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return (string)ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 4) 일별 경보 발생 스택 막대 — 온습도 임계(네이비) + 누수 감지(적·사선 해치).
|
||||||
|
// =============================================================================
|
||||||
|
function render_daily_alert_bars(array $cal_days, array $daily_metric, array $daily_leak): string {
|
||||||
|
$n = count($cal_days);
|
||||||
|
$metric = array_values($daily_metric);
|
||||||
|
$leak = array_values($daily_leak);
|
||||||
|
if ($n === 0) {
|
||||||
|
return '<div class="chart-empty">당월 일자 축이 없습니다.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$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(); ?>
|
||||||
|
<svg viewBox="0 0 <?= $W ?> <?= $H ?>" width="100%" height="<?= $H ?>"
|
||||||
|
preserveAspectRatio="xMidYMid meet" role="img"
|
||||||
|
aria-label="당월 일별 경보 발생(온습도 임계 + 누수 감지) 스택 막대"
|
||||||
|
style="print-color-adjust:exact;-webkit-print-color-adjust:exact">
|
||||||
|
<defs>
|
||||||
|
<pattern id="leakHatch" patternUnits="userSpaceOnUse" width="5" height="5" patternTransform="rotate(45)">
|
||||||
|
<rect width="5" height="5" fill="<?= RC_ALERT ?>"></rect>
|
||||||
|
<line x1="0" y1="0" x2="0" y2="5" stroke="#fff" stroke-width="1.4"></line>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect x="<?= $pad_l ?>" y="<?= $pad_t ?>" width="<?= $plot_w ?>" height="<?= $plot_h ?>"
|
||||||
|
fill="none" stroke="<?= RC_HAIR ?>" stroke-width="1"></rect>
|
||||||
|
|
||||||
|
<!-- Y 그리드 + 눈금 -->
|
||||||
|
<?php for ($g = 0; $g <= 4; $g++):
|
||||||
|
$gv = $y_top - $step * $g;
|
||||||
|
$gy = $pad_t + $plot_h * $g / 4; ?>
|
||||||
|
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $gy) ?>" x2="<?= $pad_l + $plot_w ?>"
|
||||||
|
y2="<?= sprintf('%.1f', $gy) ?>" stroke="<?= RC_HAIR ?>" stroke-width="0.6" stroke-dasharray="3,3"></line>
|
||||||
|
<text x="<?= $pad_l - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>" text-anchor="end"
|
||||||
|
font-size="10" fill="<?= RC_SUB ?>"><?= $gv ?></text>
|
||||||
|
<?php endfor; ?>
|
||||||
|
|
||||||
|
<!-- 스택 막대 -->
|
||||||
|
<?php for ($i = 0; $i < $n; $i++):
|
||||||
|
$mv = (int)($metric[$i] ?? 0);
|
||||||
|
$lv = (int)($leak[$i] ?? 0);
|
||||||
|
$cx = $pad_l + $slot * ($i + 0.5);
|
||||||
|
$bx = $cx - $bar_w / 2;
|
||||||
|
$base = $pad_t + $plot_h;
|
||||||
|
// 온습도(네이비) 아래, 누수(해치) 위
|
||||||
|
$m_h = $mv > 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;
|
||||||
|
?>
|
||||||
|
<?php if ($mv > 0): ?>
|
||||||
|
<rect x="<?= sprintf('%.1f', $bx) ?>" y="<?= sprintf('%.1f', $m_y) ?>" width="<?= sprintf('%.1f', $bar_w) ?>"
|
||||||
|
height="<?= sprintf('%.1f', $m_h) ?>" fill="<?= RC_NAVY ?>"></rect>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($lv > 0): ?>
|
||||||
|
<rect x="<?= sprintf('%.1f', $bx) ?>" y="<?= sprintf('%.1f', $l_y) ?>" width="<?= sprintf('%.1f', $bar_w) ?>"
|
||||||
|
height="<?= sprintf('%.1f', $l_h) ?>" fill="url(#leakHatch)" stroke="<?= RC_ALERT ?>" stroke-width="0.5"></rect>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php
|
||||||
|
$day = (int)substr($cal_days[$i], 8, 2);
|
||||||
|
if ($day === 1 || $day % 5 === 0 || $i === $n - 1): ?>
|
||||||
|
<text x="<?= sprintf('%.1f', $cx) ?>" y="<?= $H - 22 ?>" text-anchor="middle"
|
||||||
|
font-size="9.5" fill="<?= RC_SUB ?>"><?= $day ?></text>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endfor; ?>
|
||||||
|
<text x="<?= $pad_l + $plot_w / 2 ?>" y="<?= $H - 6 ?>" text-anchor="middle"
|
||||||
|
font-size="9" fill="<?= RC_SUB ?>">일(당월) · 발생 건수</text>
|
||||||
|
</svg>
|
||||||
|
<div class="chart-legend-row">
|
||||||
|
<span><span class="lg-sw" style="background:<?= RC_NAVY ?>"></span> 온습도 임계 경보</span>
|
||||||
|
<span><span class="lg-sw hatch"></span> 누수 감지</span>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return (string)ob_get_clean();
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue