Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e815dd945 | |||
| c9ea9d66a0 | |||
| d307630e99 | |||
| 1983d1c4ef | |||
| e01a08c664 | |||
| 3ca55ac09c | |||
| 23459570d8 | |||
| 3f73e680f0 | |||
| 353ed547f7 | |||
| c9d529f412 | |||
| 78c41a70ca | |||
| a772f4b872 | |||
| e6ba7ade71 | |||
| f9eb1153e6 | |||
| 0e9f4888c9 | |||
| c72f679cfb |
@@ -0,0 +1,76 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
name: "Publish to Composer"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '[0-9]*.[0-9]*.[0-9]*'
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish Package
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip publish]')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-dev --no-interaction --prefer-dist --quiet
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;")
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Package version: ${VERSION}"
|
||||
|
||||
# Gitea Composer Registry — auto-publishes from tags
|
||||
# The tag push itself registers the package at:
|
||||
# https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer
|
||||
- name: Verify Gitea registry
|
||||
run: |
|
||||
echo "Gitea Composer registry auto-publishes from tags."
|
||||
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
|
||||
echo "Install: composer require mokoconsulting/mokocli"
|
||||
|
||||
# Packagist — notify of new version
|
||||
- name: Notify Packagist
|
||||
if: secrets.PACKAGIST_TOKEN != ''
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "Notifying Packagist of version ${VERSION}..."
|
||||
curl -sf -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
|
||||
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
|
||||
&& echo "Packagist notified" \
|
||||
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "## Composer Package Published" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Registry | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Gitea | \`composer require mokoconsulting/mokocli:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Packagist | \`composer require mokoconsulting/mokocli\` |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.05.00
|
||||
# VERSION: 01.07.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
@@ -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() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
namespace Moko\Plugin\System\MokoSuiteField\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Technician skill matrix — certifications, skill-based dispatch matching, expiry tracking.
|
||||
*/
|
||||
class TechnicianSkillHelper
|
||||
{
|
||||
/**
|
||||
* Get skill matrix for all active technicians.
|
||||
*/
|
||||
public static function getSkillMatrix(): array
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('t.id AS tech_id, cd.name AS tech_name')
|
||||
->select('GROUP_CONCAT(ts.skill_name ORDER BY ts.skill_name SEPARATOR ", ") AS skills')
|
||||
->select('COUNT(ts.id) AS skill_count')
|
||||
->select('SUM(CASE WHEN ts.certification_expires IS NOT NULL AND ts.certification_expires < NOW() THEN 1 ELSE 0 END) AS expired_certs')
|
||||
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_tech_skills', 'ts') . ' ON ts.tech_id = t.id')
|
||||
->where($db->quoteName('t.status') . ' = ' . $db->quote('active'))
|
||||
->group('t.id')
|
||||
->order('cd.name ASC'));
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find best technician match for a work order based on required skills.
|
||||
*/
|
||||
public static function findBestMatch(array $requiredSkills, string $date = ''): array
|
||||
{
|
||||
if (empty($requiredSkills)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
$skillPlaceholders = implode(',', array_map(fn($s) => $db->quote($s), $requiredSkills));
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('t.id AS tech_id, cd.name AS tech_name, t.hourly_rate')
|
||||
->select('COUNT(DISTINCT ts.skill_name) AS matching_skills')
|
||||
->select((string) count($requiredSkills) . ' AS required_skills')
|
||||
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||
->join('INNER', $db->quoteName('#__mokosuitefield_tech_skills', 'ts')
|
||||
. ' ON ts.tech_id = t.id AND ts.skill_name IN (' . $skillPlaceholders . ')'
|
||||
. ' AND (ts.certification_expires IS NULL OR ts.certification_expires > ' . $db->quote($date) . ')')
|
||||
->where($db->quoteName('t.status') . ' = ' . $db->quote('active'))
|
||||
->group('t.id')
|
||||
->order('matching_skills DESC, t.hourly_rate ASC'));
|
||||
|
||||
$matches = $db->loadObjectList() ?: [];
|
||||
|
||||
foreach ($matches as &$m) {
|
||||
$m->match_pct = round((int) $m->matching_skills / (int) $m->required_skills * 100, 1);
|
||||
}
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expiring certifications within N days.
|
||||
*/
|
||||
public static function getExpiringCertifications(int $days = 30): array
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$cutoff = date('Y-m-d', strtotime("+{$days} days"));
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('ts.id, ts.skill_name, ts.certification_number, ts.certification_expires')
|
||||
->select('cd.name AS tech_name, cd.email_to')
|
||||
->from($db->quoteName('#__mokosuitefield_tech_skills', 'ts'))
|
||||
->join('INNER', $db->quoteName('#__mokosuitefield_technicians', 't') . ' ON t.id = ts.tech_id')
|
||||
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
|
||||
->where($db->quoteName('t.status') . ' = ' . $db->quote('active'))
|
||||
->where('ts.certification_expires IS NOT NULL')
|
||||
->where('ts.certification_expires BETWEEN NOW() AND ' . $db->quote($cutoff))
|
||||
->order('ts.certification_expires ASC'));
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoSuite Field</name>
|
||||
<packagename>mokosuitefield</packagename>
|
||||
<version>01.05.00</version>
|
||||
<version>01.07.00</version>
|
||||
<creationDate>2026-06-12</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
Reference in New Issue
Block a user