feat: Implement GPS reports functionality including vehicle trips, trip details, and abnormalities.

This commit is contained in:
Pringgosutono
2025-12-16 07:59:50 +07:00
parent 20b081a5c0
commit c3abd60868
3 changed files with 502 additions and 17 deletions

View File

@ -6,19 +6,15 @@ use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Validator; use Validator;
use Auth; use Auth;
use App\Responses; use App\Responses;
use App\Helper; use App\Helper;
use Maatwebsite\Excel\Facades\Excel;
use Maatwebsite\Excel\Concerns\FromArray;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithCustomStartCell;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Carbon\Carbon; use Carbon\Carbon;
use App\Models\UserLogs; use App\Models\UserLogs;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Log;
class ReportsController extends Controller class ReportsController extends Controller
{ {
@ -160,9 +156,9 @@ class ReportsController extends Controller
// // RETURN 1 - LIST // // RETURN 1 - LIST
// if($req->type != 'report'){ // if($req->type != 'report'){
$apiResp = Responses::success("success list vehicles report"); $apiResp = Responses::success("success list vehicles report");
$apiResp["data"] = $list; $apiResp["data"] = $list;
return new Response($apiResp, $apiResp["meta"]["code"]); return new Response($apiResp, $apiResp["meta"]["code"]);
// } // }
// // RETURN 2 - REPORT // // RETURN 2 - REPORT
@ -232,8 +228,8 @@ class ReportsController extends Controller
// return Excel::download($export, 'trip_report.xlsx'); // return Excel::download($export, 'trip_report.xlsx');
// } // }
} catch (\Exception $e) { } catch (\Exception $e) {
$apiResp = Responses::error($e->getMessage()); $apiResp = Responses::error($e->getMessage());
return new Response($apiResp, $apiResp["meta"]["code"]); return new Response($apiResp, $apiResp["meta"]["code"]);
// return Responses::json(Responses::SERVER_ERROR, 'An error occurred while generating the report.', (object)[]); // return Responses::json(Responses::SERVER_ERROR, 'An error occurred while generating the report.', (object)[]);
} }
} }
@ -356,16 +352,128 @@ class ReportsController extends Controller
// // RETURN 1 - LIST // // RETURN 1 - LIST
// if($req->type != 'report'){ // if($req->type != 'report'){
$apiResp = Responses::success("success list abnormalities report"); $apiResp = Responses::success("success list abnormalities report");
$apiResp["data"] = $list; $apiResp["data"] = $list;
return new Response($apiResp, $apiResp["meta"]["code"]); return new Response($apiResp, $apiResp["meta"]["code"]);
// } // }
} catch (\Exception $e) { } catch (\Exception $e) {
$apiResp = Responses::error($e->getMessage()); $apiResp = Responses::error($e->getMessage());
return new Response($apiResp, $apiResp["meta"]["code"]); return new Response($apiResp, $apiResp["meta"]["code"]);
// return Responses::json(Responses::SERVER_ERROR, 'An error occurred while generating the report.', (object)[]); // return Responses::json(Responses::SERVER_ERROR, 'An error occurred while generating the report.', (object)[]);
} }
} }
public function api_view_trip_detail(Request $req, $token)
{
// token = base64_encode(tgl0 + '|' + tgl1 + '|' + nopol1 + '|' + now_unix())
// $token = "1759686805|1759693045|B.10-517|1765845676";
$token = base64_decode($token);
$token = explode('|', $token);
$tgl0 = (int) $token[0] ?? null;
$tgl1 = (int) $token[1] ?? null;
$nopol1 = $token[2] ?? null;
$now = (int) $token[3] ?? null;
$isMoreThanOneHour = time() - $now > 60 * 60;
if ($tgl0 == null || $tgl1 == null || $nopol1 == null || $now == null || $isMoreThanOneHour) {
$apiResp = Responses::bad_request("Invalid token");
return new Response($apiResp, $apiResp["meta"]["code"]);
}
// get vid by nopol1
$vid = DB::select("SELECT id FROM t_vehicles WHERE nopol1 = ?", [$nopol1]);
if (count($vid) == 0) {
$apiResp = Responses::bad_request("Vehicle not found");
return new Response($apiResp, $apiResp["meta"]["code"]);
}
$vid = $vid[0]->id;
$d = [$vid, $tgl0, $tgl1];
$list = DB::select("SELECT
t.crt_d, t.latitude, t.longitude, t.speed,
tgta.fulladdress,
t.pre_milleage, t.vhc_milleage, fuel_count
FROM
t_gps_tracks t
left join t_gps_tracks_address tgta on tgta.master_id = t.id
WHERE
t.vhc_id = ?
and t.latitude IS NOT NULL
AND t.longitude IS NOT NULL
AND t.action = 'location'
AND t.crt_d BETWEEN ? AND ?
ORDER BY t.crt_d asc
", $d);
if (count($list) == 0) {
$apiResp = Responses::not_found("Track not found");
return new Response($apiResp, $apiResp["meta"]["code"]);
}
$start = [
'time' => $list[0]->crt_d,
'fulladdress' => urldecode($list[0]->fulladdress),
'mileage' => $list[0]->vhc_milleage,
];
$finish = [
'time' => $list[count($list) - 1]->crt_d,
'fulladdress' => urldecode($list[count($list) - 1]->fulladdress),
'mileage' => $list[count($list) - 1]->vhc_milleage,
];
$t0 = Carbon::createFromTimestamp($list[0]->crt_d);
$t1 = Carbon::createFromTimestamp($list[count($list) - 1]->crt_d);
$diff = $t1->diff($t0);
$hours = $diff->h + ($diff->days * 24); // include days converted to hours
$minutes = $diff->i;
$duration = "{$hours} hour" . ($hours > 1 ? 's' : '') . " {$minutes} minute" . ($minutes > 1 ? 's' : '');
$distance = $list[count($list) - 1]->vhc_milleage - $list[0]->vhc_milleage;
$fuel_consumed = $list[count($list) - 1]->fuel_count - $list[0]->fuel_count;
$data = [
'nopol1' => $nopol1,
'vid' => $vid,
'tgl0' => $tgl0,
'tgl1' => $tgl1,
'list' => $list,
'start' => $start,
'finish' => $finish,
'duration' => $duration,
'distance' => $distance,
'fuel_consumed' => $fuel_consumed,
];
// dd($list);
return view('menu_v1.reports.view_trip_detail', $data);
}
public function decryptText(string $ciphertext = "FqujI/06YPCpCSP0Xlt6bA==")
{
$secret = "Mg3Xt1169cRNJWX6HG12DVkgmAXINVuq";
$iv_str = "Mg3Xt1169cRNJWX6";
// Derive key and IV exactly as the web tool does
$md5 = md5($secret, true); // 16-byte raw binary MD5
$key = $md5 . $md5; // 32 bytes → AES-256 key
$iv = substr($md5, 0, 16); // IV = first 16 bytes of MD5
$data = base64_decode($ciphertext, true);
if ($data === false) {
return 'Invalid Base64';
}
$plaintext = openssl_decrypt(
$data,
'aes-256-cbc',
$key,
0,
$iv
);
if ($plaintext === false) {
return 'Decryption failed: ' . openssl_error_string();
}
return $plaintext; // → "1764833739"
}
} }

View File

@ -0,0 +1,374 @@
<!DOCTYPE html>
<html lang="\en">
<head>
<title>Movana Fleet Management | @yield('title', 'App')</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<link rel="icon" type="image/x-icon" href="{{ asset('images/favicon.png') }}">
<link href="{{ asset('fonts/stylesheet.css') }}" rel="stylesheet">
<link href="{{ asset('assets/vendor/bootstrap-5.0.2-dist/css/bootstrap.css') }}" rel="stylesheet">
<link href="{{ asset('assets/vendor/leaflet-1.7.1/leaflet.css') }}" rel="stylesheet">
<link href="{{ asset('assets/vendor/ionicons-v2.0-1/css/ionicons.css') }}" rel="stylesheet">
<link href="{{ asset('assets/vendor/select2-4.1.0-rc.0/dist/css/select2.css') }}" rel="stylesheet">
<link href="{{ asset('assets/vendor/DataTables/datatables.css') }}" rel="stylesheet">
<link href="{{ asset('assets/css/bootstrap-datepicker.min.css') }}" rel="stylesheet">
<link href="{{ asset('assets/css/meus.css') }}" rel="stylesheet">
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/jquery-datetimepicker/2.5.20/jquery.datetimepicker.min.css"
integrity="sha512-f0tzWhCwVFS3WeYaofoLWkTP62ObhewQ1EZn65oSYDZUg1+CyywGKkWzm8BxaJj5HGKI72PnMH9jYyIFz+GH7g=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<style>
.landscape-photo {
max-height: max(21vh, 210px);
}
.thumb-img-table {
width: max(4vw, 75px);
height: max(4vh, 55px);
object-fit: cover;
}
.thumb-img-small {
width: max(2vw, 45px);
height: max(2vh, 35px);
object-fit: cover;
}
.thumb-img-landscape {
width: max(6vw, 275px);
height: max(5vh, 125px);
object-fit: cover;
}
.thumb-img-landscape-med {
width: max(4vw, 125px);
height: max(2vh, 65px);
object-fit: contain;
}
</style>
@yield('customcss')
</head>
{{--
<body onload="startTime()"> --}}
<body>
<div class="container-fluid">
<div class="content">
<div class="card">
<div class="card-header">
<h4>{{$nopol1}}</h4>
</div>
<div class="card-body">
<div class="row head-text" id="viewPdf">
<div class="col-6">
<p class="text-bold mb-0">Start</p>
<p class="mb-0 time">{{ $start['time'] }}</p>
<p class="mb-0">Vehicle Mileage: {{number_format($start['mileage'], 2)}} km</p>
<p>{{$start['fulladdress']}}</p>
</div>
<div class="col-6">
<p class="text-bold mb-0">Finish</p>
<p class="mb-0 time">{{ $finish['time'] }}</p>
<p class="mb-0">Vehicle Mileage: {{number_format($finish['mileage'], 2)}} km</p>
<p>{{$finish['fulladdress']}}</p>
</div>
<div class="col-4">
<p class="text-bold mb-0">Distance</p>
<p class="mb-0">{{number_format($distance, 2)}} km</p>
</div>
<div class="col-4">
<p class="text-bold mb-0">Duration</p>
<p class="mb-0">{{$duration}}</p>
</div>
<div class="col-4">
<p class="text-bold mb-0">Fuel consumption</p>
<p class="mb-0">{{$fuel_consumed / 10}} L</p>
</div>
<div class="col-12 mt-2">
<div id="leafMap" style="height: 400px;"></div>
</div>
<div class="col-12">
<!-- <li class="list-group-item p-1 px-2">
<p class="text-bold mb-0">Time: 25 Aug 2025 07:31:08</p>
<p class="text-muted mb-0 dtl-text">-8.55387 - 125.542409</p>
<p class="text-muted mb-0 dtl-text">Avenida Luro Mata, Praia dos Coqueiros, Bebunuk, Dom Aleixo, Dili, Timor-Leste;2066973</p>
<p class="mb-0 dtl-text">Current speed: 7km/h</p>
</li> -->
@foreach ($list as $item)
<!-- <li class="list-group-item p-1 px-2">
<p class="text-bold mb-0">Time: {{date('d-m-Y H:i:s', $item->crt_d)}}</p>
<p class="text-muted mb-0 dtl-text">Vehicle Mileage: {{number_format($item->vhc_milleage, 2)}} km</p>
<p class="text-muted mb-0 dtl-text">{{number_format($item->latitude, 6)}} - {{number_format($item->longitude, 6)}}</p>
<p class="text-muted mb-0 dtl-text">{{urldecode($item->fulladdress)}}</p>
<p class="text-muted mb-0 dtl-text">Current speed: {{number_format($item->speed, 2)}} km/h</p>
</li> -->
<li class="list-group-item p-1 px-2">
<div class="row">
<div class="col-4">
<p class="text-bold mb-0 dtl-text">Time: <span
class="time">{{ $item->crt_d }}</span>
</p>
<p class="text-muted mb-0 dtl-text">Vehicle Mileage:
{{number_format($item->vhc_milleage, 2)}} km
</p>
<p class="text-muted mb-0 dtl-text">Current speed: {{$item->speed}} km/h</p>
</div>
<div class="col-8">
<p class="text-muted mb-0 dtl-text">{{number_format($item->latitude, 6)}},
{{number_format($item->longitude, 6)}}
</p>
<p class="text-muted mb-0 dtl-text">{{urldecode($item->fulladdress)}}</p>
</div>
</div>
</li>
@endforeach
</div>
</div>
</div>
</div>
</div>
</div>
<script src="{{ asset('assets/vendor/jquery-3.6.0/jquery-3.6.0.js') }}"></script>
<script src="{{ asset('assets/vendor/bootstrap-5.0.2-dist/js/bootstrap.bundle.min.js') }}"></script>
<script src="{{ asset('assets/js/moment.min.js') }}"></script>
<script src="{{ asset('assets/js/helper.js') }}"></script>
<script src="{{ asset('assets/js/load-image.all.min.js') }}"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script src="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="{{ asset('assets/vendor/printThis.js') }}"></script>
<script>
$(document).ready(function () {
$('.time').each(function () {
const unix = parseInt($(this).text().trim()) + 25200;
$(this).text(moment.unix(unix).format('DD MMM YYYY HH:mm:ss'));
});
let coords
setTimeout(async () => {
map.invalidateSize(); // force Leaflet to recalc
map.fitBounds(polyline.getBounds());
// map.fitBounds(await coords.map(c => L.latLng(c[0], c[1])));
}, 200);
const linesData = (@json($list));
// 1) Initialize map
const map = L.map("leafMap").setView([-8.90507, 125.9945732], 10)
// 2) Add tile layer
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 20,
crossOrigin: true
}).addTo(map);
// // // 3) Coordinates (Lat, Lng) for polyline
const points = linesData
.filter(p => p.latitude && p.longitude)
.map((point) => [point.latitude, point.longitude])
// 4) Add polyline
const polyline = L.polyline(points, {
color: 'red',
weight: 3,
opacity: 0.7,
smoothFactor: 1
})
// .addTo(map);
function chunkArray(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
function fetchOsrm(points) {
const coords = points;
const hints = ";".repeat(points.length - 1);
const body = {
coordinates: coords,
overview: "false",
alternatives: "false",
steps: "true",
hints: hints
};
let config = {
method: 'post',
maxBodyLength: Infinity,
url: 'https://brilianapps.britimorleste.tl/osrm-backend/post-route/v1/driving/',
headers: {
'Content-Type': 'application/json'
},
data: body
};
return axios.request(config)
.then((response) => {
return response.data;
})
.catch((error) => {
console.error("Error:", error.message);
return null;
});
}
function decodeOSRMGeometry(encoded) {
const coordinates = [];
let index = 0,
lat = 0,
lng = 0;
while (index < encoded.length) {
let result = 1,
shift = 0,
b;
do {
b = encoded.charCodeAt(index++) - 63 - 1;
result += b << shift;
shift += 5;
} while (b >= 0x1f);
lat += (result & 1 ? ~(result >> 1) : result >> 1);
result = 1;
shift = 0;
do {
b = encoded.charCodeAt(index++) - 63 - 1;
result += b << shift;
shift += 5;
} while (b >= 0x1f);
lng += (result & 1 ? ~(result >> 1) : result >> 1);
coordinates.push([lat / 1e5, lng / 1e5]);
}
return coordinates;
}
async function getCoordinates(points) {
const chunkSize = 500;
const chunks = chunkArray(points, chunkSize); // Split the points array into chunks of 500
let allCoords = [];
for (const chunk of chunks) {
const osrm = await fetchOsrm(chunk); // Fetch OSRM data for each chunk
if (!osrm) {
console.log("OSRM failed for chunk");
return;
}
const coords = osrm.routes[0].legs.flatMap(leg =>
leg.steps.flatMap(step =>
decodeOSRMGeometry(step.geometry)
)
);
allCoords = allCoords.concat(coords); // Combine the result
}
// Now add the polyline to the map
L.polyline(allCoords, {
color: "#2980B9",
weight: 3,
opacity: 0.8
}).addTo(map);
// map.fitBounds(allCoords.map(c => L.latLng(c[0], c[1])));
}
// Usage: Pass the array of coordinates to the function
getCoordinates(points);
// start and finish point
const startIcon = L.icon({
iconUrl: "{{ asset('images/start.png') }}",
iconSize: [30, 30],
iconAnchor: [15, 28], // lb, rt, bottom, rb. Positive
})
L.marker(points[0], { icon: startIcon }).addTo(map)
const finishIcon = L.icon({
iconUrl: "{{ asset('images/finish.png') }}",
iconSize: [30, 30],
iconAnchor: [15, 28], // lb, rt, bottom, rb. Positive
})
L.marker(points[points.length - 1], { icon: finishIcon }).addTo(map)
// // 5) Auto-fit map to polyline bounds
// map.fitBounds(polyline.getBounds())
// download pdf
window._downloadReportBound ||= (
$(document).on('click', '#btnDownloadReport', function () {
$('#viewPdf').printThis({
debug: false, // show the iframe for debugging
importCSS: true, // copy linked styles
importStyle: true, // copy inline styles
});
// const viewPdf = document.getElementById("viewPdf");
// // find overlay svg (the one holding polylines)
// const overlaySvg = document.querySelector('.leaflet-overlay-pane svg');
// let originalTransform = '';
// if (overlaySvg) {
// originalTransform = overlaySvg.style.transform;
// overlaySvg.style.transform = 'none';
// }
// html2canvas(viewPdf, {
// scale: 2,
// useCORS: true,
// logging: true
// }).then(canvas => {
// const imgData = canvas.toDataURL('image/png');
// const { jsPDF } = window.jspdf;
// const pdf = new jsPDF('p', 'mm', 'a4');
// const pageWidth = pdf.internal.pageSize.getWidth();
// const pageHeight = pdf.internal.pageSize.getHeight();
// const imgWidth = pageWidth - 20; // margin
// const imgHeight = canvas.height * imgWidth / canvas.width;
// let position = 10;
// // 👉 Handle multipage content
// let heightLeft = imgHeight;
// while (heightLeft > 0) {
// pdf.addImage(imgData, 'PNG', 10, position, imgWidth, imgHeight);
// heightLeft -= pageHeight;
// if (heightLeft > 0) {
// pdf.addPage();
// position = 0;
// }
// }
// pdf.save(`{{$nopol1}} Trip Report {{$start['time']}}.pdf`);
// });
}),
true
);
});
</script>
</body>
</html>

View File

@ -36,4 +36,7 @@ Route::post("/v1/inject/add_conf_rate_v1", "InjectController@add_conf_rate_v1");
Route::post("/v1/storage/save_photos", "StorageController@save_photos")->name("api_storage_save_photos"); Route::post("/v1/storage/save_photos", "StorageController@save_photos")->name("api_storage_save_photos");
Route::post("auth/login", [AuthController::class, "login"]); Route::post("auth/login", [AuthController::class, "login"]);
Route::get("/view/vehicle-trip-detail/{token}", "ReportsController@api_view_trip_detail")->name("api_view_trip_detail");
// }); // });