見出し画像

消防団点検アプリを作ってみる(Windows11-xampp/apache版)

設備点検のおしごと

こんにちは、消防団在籍13年目のmiczです。
ところで消防団のお仕事の中には「設備点検」というのがあります。具体的には
1. 消火器点検:所定の場所に消火器があるか、期限切れになっていないか、ケースに異常はないか
2. 消火栓点検:バルブを回して水が出るか、消火栓自体に異常はないか
3. 防火水槽点検:適正な水があるか、防火水槽自体に異常はないか
がありまして、年に2回程度担当区域の街を回っては点検を行っています。

消火栓点検例

設備点検をスマートにしたい

で、この設備点検なのですが消防本部から紙の地図が渡されて、各種点検箇所を回り、その結果をこれまた配布された用紙に記入するという昔ながらのフローを取っていますが、「もう少しスマートにできませんかね」と思ったりするわけです。消防本部は紙地図を各消防団分団ごとに用意する必要はあるし(私の町では12分団ある)、各消防団分団から上がってきた点検結果はどうやって管理しているのでしょう。(まさか紙保存のままとか・・えっ

また消防団にとっても、紙地図は使いづらいし、点検用紙に記入してその結果を本部に渡すという行為が発生するので何かと面倒です。

と、いうことでアプリ作って設備点検をスマートにできないかというモチベーションで作ってみました。

アプリ・システム構成

まずざっくり構成を考えてみます。
・設備情報:エクセルにデータを入れてWebサーバ上に配置します。

設備情報(エクセルファイル)(source.xlsx)

・Webアプリ:ブラウザ上で設備情報を含んだ地図を表示させ、該当ピンを選択して点検情報を入力します。

消防団点検アプリ画面
点検結果入力画面

点検結果:Webサーバ上の所定の箇所に結果を保存します。

点検結果シート (inspection_results.xlsx)

さて、こんな感じになるようにするにはどうすればよいでしょうか。

Webサーバ上:top.html (トップページ)

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>消防団点検アプリ</title>
    <script src="mapProxy.php?callback=initMap"></script>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <style>
        /* マップの表示領域 */
        #map {
            height: 500px;
            width: 100%;
        }
       /* ダウンロードボタンのスタイル */
       #downloadButton {
            position: absolute;
            top: 10px;
            right: 10px;
            padding: 10px 20px;
            background-color: #007bff;
            color: white;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            font-size: 14px;
        }
        #downloadButton:hover {
            background-color: #0056b3;
        }
    </style>
    
    <script>
        let map;
        let markers = [];
        let userMarker; // 現在位置を表すマーカー
        let accuracyCircle; // 現在位置の誤差範囲を表す円


        // マップ初期化
        function initMap() {
            const center = { lat: 35.31092, lng: 139.27641 }; // 初期表示位置
            map = new google.maps.Map(document.getElementById("map"), {
                zoom: 16,
                center: center
            });

            // 初回の現在位置取得
            updateUserLocation();

            // 位置情報を1秒ごとに取得して更新
            setInterval(updateUserLocation, 1000);

            // サーバーからピン情報を取得して表示
            loadMarkers();
        }

        // サーバーからのピン情報を取得してマップに追加
        function loadMarkers() {
            $.ajax({
                url: "getMarkers.php",
                method: "GET",
                dataType: "json",
                success: function(data) {
                    const locations = data;
                    locations.forEach(location => {
                        const latLng = { lat: parseFloat(location.lat), lng: parseFloat(location.lng) };
                        const marker = new google.maps.Marker({
                            position: latLng,
                            map: map,
                            icon: getIconByType(location.type),
                            title: location.name
                        });

                        marker.addListener('click', function() {
                            showInspectionForm(marker, location);
                        });

                        markers.push(marker);
                    });
                },
                error: function() {
                    console.error("ピン情報の取得に失敗しました。");
                }
            });
        }

        // ピンの色を属性に応じて設定
        function getIconByType(type) {
            if (type === "消火栓") return 'http://maps.google.com/mapfiles/ms/icons/green-dot.png';
            if (type === "防火水槽") return 'http://maps.google.com/mapfiles/ms/icons/blue-dot.png';
            if (type === "消火器") return 'http://maps.google.com/mapfiles/ms/icons/red-dot.png';
        }

     
     // 位置情報を1秒ごとに取得して更新
     function updateUserLocation() {
        if (navigator.geolocation) {
            navigator.geolocation.getCurrentPosition(
                (position) => {
                    const pos = {
                        lat: position.coords.latitude,
                        lng: position.coords.longitude,
                    };
                    const accuracy = position.coords.accuracy; // 誤差範囲の半径

                    // 現在位置のマーカーが存在する場合は位置を更新し、存在しない場合は新規作成
                    if (userMarker) {
                        userMarker.setPosition(pos);
                    } else {
                        userMarker = new google.maps.Marker({
                            position: pos,
                            map: map,
                            title: "現在地",
                            icon: "https://maps.google.com/mapfiles/kml/pal4/icon7.png", // 現在位置のアイコン
                        });
                    }

                    // 誤差範囲を表す円を設定
                    if (accuracyCircle) {
                        accuracyCircle.setCenter(pos);
                        accuracyCircle.setRadius(accuracy);
                    } else {
                        accuracyCircle = new google.maps.Circle({
                            map: map,
                            center: pos,
                            radius: accuracy,
                            fillColor: '#ADD8E6',
                            fillOpacity: 0.4,
                            strokeColor: '#0000FF',
                            strokeOpacity: 0.8,
                            strokeWeight: 1,
                        });
                    }
                },
                (error) => {
                    console.error("位置情報の取得に失敗しました:", error);
                },
                {
                    enableHighAccuracy: true,
                    timeout: 5000,
                    maximumAge: 0,
                }
            );
        } else {
            console.error("このブラウザは位置情報に対応していません。");
        }
    }


        // 点検フォーム表示
        function showInspectionForm(marker, location) {
            const inspectionResult = prompt(`点検結果を入力してください。\n対象: ${location.name}, ${location.type}\n1: OK, 2: NG`, "1");
            if (inspectionResult === "1" || inspectionResult === "2") {
                const result = inspectionResult === "1" ? "OK" : "NG";
                const remarks = prompt("備考を入力してください。(任意)", "");
                
                // 点検結果を保存するためのオブジェクト
                const inspectionData = {
                    name: location.name,
                    type: location.type,
                    date: new Date().toLocaleDateString('ja-JP'),
                    time: new Date().toLocaleTimeString('ja-JP'),
                    result: result,
                    remarks: remarks
                };
                
                // アイコンの変更
                marker.setIcon({
                    path: google.maps.SymbolPath.CIRCLE,
                    scale: 8,
                    fillColor: 'black',
                    fillOpacity: 1,
                    strokeColor: 'black',
                    strokeWeight: 1
                });
                
                console.log("Inspection Data:", inspectionData); // デバッグ用
                saveInspectionResultsToExcel(inspectionData); // 結果をExcelに保存する関数を呼び出す
            }
        }

        // 点検結果保存関数
        function saveInspectionResultsToExcel(inspectionData) {
            $.ajax({
                url: "saveInspectionResults.php",
                method: "POST",
                data: inspectionData,
                success: function(response) {
                    alert('点検結果が保存されました!');
                },
                error: function() {
                    console.error("点検結果の保存に失敗しました。");
                }
            });
        }
    </script>
</head>
<body onload="initMap()">
    <h1>消防団点検アプリ</h1>
    <button id="downloadButton" onclick="window.location.href='/shobo/inspection_results.xlsx?v=' + new Date().getTime()">DL</button>
    <div id="map"></div>
</body>
</html>

Webサーバ上:mapProxy.php (マップapiキーの隠蔽)

<?php
// サーバーサイドに保持するAPIキー
$apiKey = '**mykey**';

// リクエストパラメータから取得
$callback = $_GET['callback'] ?? 'initMap';

// Google Maps API用のURL作成
$url = "https://maps.googleapis.com/maps/api/js?key=$apiKey&callback=$callback";

// APIレスポンスを取得して出力
$response = file_get_contents($url);
header('Content-Type: application/javascript');
echo $response;
?>

Webサーバ上:saveInspectionResults.php (点検結果書込)

<?php
require 'vendor/autoload.php'; // Composerのオートローダーを読み込む

use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\IOFactory;

// POSTデータを取得
$name = $_POST['name'];
$type = $_POST['type'];
$result = $_POST['result'];
$remarks = $_POST['remarks'];
$date = $_POST['date'] ?? date('Y-m-d');
$time = $_POST['time'] ?? date('H:i:s');

// 保存先のファイル名
$filePath = 'inspection_results.xlsx';

// 既存のファイルが存在するか確認
if (file_exists($filePath)) {
    // 既存のファイルを読み込む
    $spreadsheet = IOFactory::load($filePath);
} else {
    // 新しいスプレッドシートを作成
    $spreadsheet = new Spreadsheet();
    $sheet = $spreadsheet->getActiveSheet();

    // ヘッダーを設定
    $sheet->setCellValue('A1', '名前');
    $sheet->setCellValue('B1', '種類');
    $sheet->setCellValue('C1', '結果');
    $sheet->setCellValue('D1', '備考');
    $sheet->setCellValue('E1', '日付');
    $sheet->setCellValue('F1', '時間');
}

// 同じ名前がすでに存在するかチェック
$sheet = $spreadsheet->getActiveSheet();
$lastRow = $sheet->getHighestRow();
$rowToUpdate = null;

for ($row = 2; $row <= $lastRow; $row++) { // 1行目はヘッダーなので2行目から開始
    if ($sheet->getCell('A' . $row)->getValue() == $name) {
        $rowToUpdate = $row;
        break; // 同じ名前が見つかったらループを抜ける
    }
}

// 名前が存在した場合、行を上書き
if ($rowToUpdate) {
    $sheet->setCellValue('B' . $rowToUpdate, $type);
    $sheet->setCellValue('C' . $rowToUpdate, $result);
    $sheet->setCellValue('D' . $rowToUpdate, $remarks);
    $sheet->setCellValue('E' . $rowToUpdate, $date);
    $sheet->setCellValue('F' . $rowToUpdate, $time);
} else {
    // 新しい行を追加
    $sheet->setCellValue('A' . ($lastRow + 1), $name);
    $sheet->setCellValue('B' . ($lastRow + 1), $type);
    $sheet->setCellValue('C' . ($lastRow + 1), $result);
    $sheet->setCellValue('D' . ($lastRow + 1), $remarks);
    $sheet->setCellValue('E' . ($lastRow + 1), $date);
    $sheet->setCellValue('F' . ($lastRow + 1), $time);
}

// ファイルに保存
$writer = new Xlsx($spreadsheet);
$writer->save($filePath);

// レスポンスを返す
echo json_encode(['status' => 'success', 'message' => 'データが保存されました。']);

Webサーバ上:getMarkers.php (消防設備情報読み込み)

<?php
require 'vendor/autoload.php'; // PHPExcel読み込み用
use PhpOffice\PhpSpreadsheet\IOFactory;

// エクセルファイルの読み込み
$filePath = 'source.xlsx';
$spreadsheet = IOFactory::load($filePath);
$sheet = $spreadsheet->getSheetByName('マスタ');

// データの取得
$data = [];
foreach ($sheet->getRowIterator(2) as $row) {
    $name = $sheet->getCell('A'.$row->getRowIndex())->getValue();
    $position = $sheet->getCell('B'.$row->getRowIndex())->getValue();
    $type = $sheet->getCell('C'.$row->getRowIndex())->getValue();
    
    list($lat, $lng) = explode(',', $position);
    
    $data[] = [
        'name' => $name,
        'lat' => $lat,
        'lng' => $lng,
        'type' => $type
    ];
}

// JSON形式で返す
header('Content-Type: application/json');
echo json_encode($data);


おわりに

本スクリプトはすべてCopilot、Chat-GPTで書かせました。自位置を表示する機能が含まれているので、httpsで動作させる必要があります。これについては 「自己証明書(SSL)を作ってhttpsアクセスし「セキュリティ保護なし」にならないようにする [xampp/apache]」を参照してください。

今回は、ローカルPCをWebサーバにして実験的に実施してみましたので、次回はGoogle Cloud Platform上にのせて動作するようチャレンジしたいと思います。(24/10/26に完了)
Google Cloud Platform上にのせて動作したので次の火災予防運動の点検時期にでも消防本部と消防団に対して実証実験を行ってみようと思います。

ありがとうございました。


いいなと思ったら応援しよう!