Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d4bd0c906 | |||
| b209d019a9 | |||
| 181d5f1450 | |||
| 1bbba00200 | |||
| 54a9f10630 | |||
| 4ec90d38f2 | |||
| 0e385fcb9c | |||
| 5e815dd945 | |||
| c9ea9d66a0 | |||
| d307630e99 | |||
| 1983d1c4ef | |||
| e01a08c664 | |||
| 3ca55ac09c | |||
| 23459570d8 |
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.06.00
|
||||
# VERSION: 01.08.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
namespace Moko\Plugin\System\MokoSuiteField\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Customer satisfaction — post-service surveys, NPS scoring, technician ratings.
|
||||
*/
|
||||
class CustomerSatisfactionHelper
|
||||
{
|
||||
/**
|
||||
* Record a post-service survey response.
|
||||
*/
|
||||
public static function recordSurvey(int $workOrderId, int $contactId, int $rating, ?string $comment = null): object
|
||||
{
|
||||
if ($rating < 1 || $rating > 5) {
|
||||
throw new \InvalidArgumentException('Rating must be 1-5.');
|
||||
}
|
||||
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
// Prevent duplicate surveys per work order
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('id')
|
||||
->from('#__mokosuitefield_surveys')
|
||||
->where('work_order_id = ' . (int) $workOrderId)
|
||||
->where('contact_id = ' . (int) $contactId));
|
||||
|
||||
if ($db->loadResult()) {
|
||||
return (object) ['success' => false, 'error' => 'Survey already submitted for this work order'];
|
||||
}
|
||||
|
||||
$filter = \Joomla\Filter\InputFilter::getInstance();
|
||||
|
||||
$survey = (object) [
|
||||
'work_order_id' => $workOrderId,
|
||||
'contact_id' => $contactId,
|
||||
'rating' => $rating,
|
||||
'comment' => $comment !== null ? $filter->clean($comment, 'STRING') : null,
|
||||
'nps_score' => $rating >= 4 ? 'promoter' : ($rating >= 3 ? 'passive' : 'detractor'),
|
||||
'created_at' => Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokosuitefield_surveys', $survey, 'id');
|
||||
|
||||
return (object) ['success' => true, 'survey_id' => (int) $survey->id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get NPS (Net Promoter Score) for a period.
|
||||
*/
|
||||
public static function getNps(string $from = '', string $to = ''): object
|
||||
{
|
||||
$from = $from ?: date('Y-01-01');
|
||||
$to = $to ?: date('Y-m-d');
|
||||
|
||||
if (!\DateTime::createFromFormat('Y-m-d', $from) || !\DateTime::createFromFormat('Y-m-d', $to)) {
|
||||
throw new \InvalidArgumentException('Date parameters must be Y-m-d format.');
|
||||
}
|
||||
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('COUNT(*) AS total_responses')
|
||||
->select('SUM(CASE WHEN rating >= 4 THEN 1 ELSE 0 END) AS promoters')
|
||||
->select('SUM(CASE WHEN rating = 3 THEN 1 ELSE 0 END) AS passives')
|
||||
->select('SUM(CASE WHEN rating <= 2 THEN 1 ELSE 0 END) AS detractors')
|
||||
->select('AVG(rating) AS avg_rating')
|
||||
->from('#__mokosuitefield_surveys')
|
||||
->where('DATE(created_at) BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to)));
|
||||
|
||||
$stats = $db->loadObject();
|
||||
$total = (int) ($stats->total_responses ?? 0);
|
||||
$promoterPct = $total > 0 ? (int) $stats->promoters / $total * 100 : 0;
|
||||
$detractorPct = $total > 0 ? (int) $stats->detractors / $total * 100 : 0;
|
||||
|
||||
return (object) [
|
||||
'nps' => round($promoterPct - $detractorPct),
|
||||
'total_responses' => $total,
|
||||
'promoters' => (int) ($stats->promoters ?? 0),
|
||||
'passives' => (int) ($stats->passives ?? 0),
|
||||
'detractors' => (int) ($stats->detractors ?? 0),
|
||||
'avg_rating' => round((float) ($stats->avg_rating ?? 0), 1),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get technician satisfaction rankings.
|
||||
*/
|
||||
public static function getTechnicianRankings(int $limit = 20): array
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('t.id AS tech_id, cd.name AS tech_name')
|
||||
->select('COUNT(s.id) AS survey_count')
|
||||
->select('AVG(s.rating) AS avg_rating')
|
||||
->select('SUM(CASE WHEN s.rating >= 4 THEN 1 ELSE 0 END) AS five_star_count')
|
||||
->from($db->quoteName('#__mokosuitefield_surveys', 's'))
|
||||
->join('INNER', $db->quoteName('#__mokosuitefield_work_orders', 'wo') . ' ON wo.id = s.work_order_id')
|
||||
->join('INNER', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = wo.tech_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||
->group('t.id, cd.name')
|
||||
->having('COUNT(s.id) >= 3')
|
||||
->order('avg_rating DESC'), 0, min(max(1, $limit), 100));
|
||||
|
||||
$results = $db->loadObjectList() ?: [];
|
||||
|
||||
foreach ($results as &$r) {
|
||||
$r->avg_rating = round((float) $r->avg_rating, 1);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
namespace Moko\Plugin\System\MokoSuiteField\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* GPS tracking — vehicle location history, geofence alerts, drive time analysis.
|
||||
*/
|
||||
class GpsTrackingHelper
|
||||
{
|
||||
/**
|
||||
* Record a GPS ping for a vehicle.
|
||||
*/
|
||||
public static function recordPing(int $vehicleId, float $latitude, float $longitude, float $speed = 0): bool
|
||||
{
|
||||
if ($latitude < -90 || $latitude > 90 || $longitude < -180 || $longitude > 180) {
|
||||
throw new \InvalidArgumentException('Invalid GPS coordinates.');
|
||||
}
|
||||
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$ping = (object) [
|
||||
'vehicle_id' => $vehicleId,
|
||||
'latitude' => round($latitude, 6),
|
||||
'longitude' => round($longitude, 6),
|
||||
'speed_mph' => max(0, round($speed, 1)),
|
||||
'recorded_at'=> Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokosuitefield_gps_pings', $ping);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest position for all active vehicles.
|
||||
*/
|
||||
public static function getFleetPositions(): array
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('v.id AS vehicle_id, v.name AS vehicle_name, v.license_plate')
|
||||
->select('gp.latitude, gp.longitude, gp.speed_mph, gp.recorded_at')
|
||||
->select('cd.name AS assigned_tech')
|
||||
->from($db->quoteName('#__mokosuitefield_vehicles', 'v'))
|
||||
->join('LEFT', '(SELECT g1.* FROM #__mokosuitefield_gps_pings g1'
|
||||
. ' INNER JOIN (SELECT vehicle_id, MAX(recorded_at) AS max_at'
|
||||
. ' FROM #__mokosuitefield_gps_pings GROUP BY vehicle_id) g2'
|
||||
. ' ON g1.vehicle_id = g2.vehicle_id AND g1.recorded_at = g2.max_at) AS gp'
|
||||
. ' ON gp.vehicle_id = v.id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.vehicle_id = v.id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||
->where($db->quoteName('v.status') . ' = ' . $db->quote('active'))
|
||||
->order('v.name ASC'));
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get drive history for a vehicle on a specific date.
|
||||
*/
|
||||
public static function getDriveHistory(int $vehicleId, string $date = ''): array
|
||||
{
|
||||
$date = $date ?: date('Y-m-d');
|
||||
|
||||
if (!\DateTime::createFromFormat('Y-m-d', $date)) {
|
||||
throw new \InvalidArgumentException('Date must be Y-m-d format.');
|
||||
}
|
||||
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('gp.latitude, gp.longitude, gp.speed_mph, gp.recorded_at')
|
||||
->from($db->quoteName('#__mokosuitefield_gps_pings', 'gp'))
|
||||
->where('gp.vehicle_id = ' . (int) $vehicleId)
|
||||
->where('DATE(gp.recorded_at) = ' . $db->quote($date))
|
||||
->order('gp.recorded_at ASC'));
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vehicles currently exceeding speed threshold.
|
||||
*/
|
||||
public static function getSpeeding(float $thresholdMph = 70): array
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('v.id AS vehicle_id, v.name AS vehicle_name, v.license_plate')
|
||||
->select('gp.latitude, gp.longitude, gp.speed_mph, gp.recorded_at')
|
||||
->select('cd.name AS assigned_tech')
|
||||
->from($db->quoteName('#__mokosuitefield_vehicles', 'v'))
|
||||
->join('INNER', '(SELECT g1.* FROM #__mokosuitefield_gps_pings g1'
|
||||
. ' INNER JOIN (SELECT vehicle_id, MAX(recorded_at) AS max_at'
|
||||
. ' FROM #__mokosuitefield_gps_pings GROUP BY vehicle_id) g2'
|
||||
. ' ON g1.vehicle_id = g2.vehicle_id AND g1.recorded_at = g2.max_at) AS gp'
|
||||
. ' ON gp.vehicle_id = v.id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.vehicle_id = v.id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||
->where($db->quoteName('v.status') . ' = ' . $db->quote('active'))
|
||||
->where('gp.speed_mph > ' . (float) $thresholdMph)
|
||||
->where('gp.recorded_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE)')
|
||||
->order('gp.speed_mph DESC'));
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoSuite Field</name>
|
||||
<packagename>mokosuitefield</packagename>
|
||||
<version>01.06.00</version>
|
||||
<version>01.08.00</version>
|
||||
<creationDate>2026-06-12</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
Reference in New Issue
Block a user