Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73835590a1 | |||
| c046507b00 | |||
| f6e422be3c | |||
| 8127c28687 | |||
| 4d4bd0c906 | |||
| b209d019a9 | |||
| 181d5f1450 | |||
| 1bbba00200 | |||
| 54a9f10630 | |||
| 4ec90d38f2 | |||
| 0e385fcb9c | |||
| 5e815dd945 | |||
| c9ea9d66a0 | |||
| d307630e99 | |||
| 1983d1c4ef | |||
| e01a08c664 | |||
| 3ca55ac09c | |||
| 23459570d8 | |||
| 3f73e680f0 | |||
| 353ed547f7 | |||
| c9d529f412 | |||
| 78c41a70ca | |||
| a772f4b872 | |||
| e6ba7ade71 | |||
| f9eb1153e6 | |||
| 0e9f4888c9 | |||
| c72f679cfb | |||
| 0bfca03942 | |||
| aea5d95f09 | |||
| eeaef928b5 | |||
| 4ce4814e50 | |||
| bc0fb961b5 | |||
| 9136156034 | |||
| 8cee50d350 |
@@ -10,9 +10,9 @@
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +========================================================================+
|
||||
# +=======================================================================+
|
||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||
# +========================================================================+
|
||||
# +=======================================================================+
|
||||
# | |
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
@@ -21,7 +21,7 @@
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
# +=======================================================================+
|
||||
|
||||
name: "Universal: Build & Release"
|
||||
|
||||
@@ -30,6 +30,15 @@ on:
|
||||
types: [opened, closed]
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '.mokogitea/workflows/**'
|
||||
- '*.md'
|
||||
- 'wiki/**'
|
||||
- '.editorconfig'
|
||||
- '.gitignore'
|
||||
- '.gitattributes'
|
||||
- '.gitmessage'
|
||||
- 'LICENSE'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
@@ -51,7 +60,7 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
|
||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
|
||||
promote-rc:
|
||||
name: Promote to RC
|
||||
runs-on: release
|
||||
@@ -149,7 +158,7 @@ jobs:
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
@@ -241,11 +250,47 @@ jobs:
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=stable" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [[ "$PLATFORM" == joomla* ]]; then
|
||||
echo "tag=stable" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "branch=main" >> "$GITHUB_OUTPUT"
|
||||
echo "Published version: ${VERSION}"
|
||||
|
||||
- name: "Create semver tag for non-Joomla repos"
|
||||
id: semver
|
||||
if: |
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
!startsWith(steps.platform.outputs.platform, 'joomla')
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
SEMVER_TAG="v${VERSION}"
|
||||
|
||||
echo "Creating semver tag: ${SEMVER_TAG}"
|
||||
|
||||
# Create the git tag via API
|
||||
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
|
||||
-X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/tags" \
|
||||
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "Created semver tag: ${SEMVER_TAG}"
|
||||
elif [ "$HTTP_CODE" = "409" ]; then
|
||||
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
|
||||
else
|
||||
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
|
||||
fi
|
||||
|
||||
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update release notes and promote changelog
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
@@ -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.04.00
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
@@ -88,8 +88,20 @@ jobs:
|
||||
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Check platform eligibility (Joomla only)
|
||||
id: eligibility
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
|
||||
fi
|
||||
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
# Auto-detect stability from branch name on push, or use input on dispatch
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
@@ -166,6 +178,7 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
id: release
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
@@ -176,6 +189,7 @@ jobs:
|
||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
@@ -212,6 +226,7 @@ jobs:
|
||||
|
||||
- name: Build package and upload
|
||||
id: package
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
@@ -225,6 +240,7 @@ jobs:
|
||||
# No need to build, commit, or sync updates.xml from workflows
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
@@ -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() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
namespace Moko\Plugin\System\MokoSuiteField\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Safety checklists — pre-job safety checks, compliance tracking, incident prevention.
|
||||
*/
|
||||
class SafetyChecklistHelper
|
||||
{
|
||||
/**
|
||||
* Create a safety checklist for a work order.
|
||||
*/
|
||||
public static function createChecklist(int $woId, string $trade): int
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
$items = self::getDefaultItems($trade);
|
||||
|
||||
$checklist = (object) [
|
||||
'wo_id' => $woId,
|
||||
'trade' => $trade,
|
||||
'status' => 'pending',
|
||||
'created' => $now,
|
||||
'created_by' => Factory::getApplication()->getIdentity()->id,
|
||||
];
|
||||
$db->insertObject('#__mokosuitefield_safety_checklists', $checklist, 'id');
|
||||
$checklistId = (int) $checklist->id;
|
||||
|
||||
foreach ($items as $i => $item) {
|
||||
$db->insertObject('#__mokosuitefield_safety_checklist_items', (object) [
|
||||
'checklist_id' => $checklistId,
|
||||
'item_text' => $item,
|
||||
'checked' => 0,
|
||||
'ordering' => $i + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return $checklistId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a checklist item.
|
||||
*/
|
||||
public static function checkItem(int $itemId, bool $passed, string $notes = ''): bool
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
// Verify item belongs to a pending checklist and is not already checked
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('sci.id, sci.checked, sc.status AS checklist_status')
|
||||
->from($db->quoteName('#__mokosuitefield_safety_checklist_items', 'sci'))
|
||||
->join('INNER', $db->quoteName('#__mokosuitefield_safety_checklists', 'sc') . ' ON sc.id = sci.checklist_id')
|
||||
->where('sci.id = ' . (int) $itemId));
|
||||
$existing = $db->loadObject();
|
||||
|
||||
if (!$existing || $existing->checklist_status !== 'pending' || (int) $existing->checked === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$update = (object) [
|
||||
'id' => $itemId,
|
||||
'checked' => 1,
|
||||
'passed' => $passed ? 1 : 0,
|
||||
'notes' => $notes,
|
||||
'checked_at' => Factory::getDate()->toSql(),
|
||||
'checked_by' => Factory::getApplication()->getIdentity()->id,
|
||||
];
|
||||
|
||||
$db->updateObject('#__mokosuitefield_safety_checklist_items', $update, 'id');
|
||||
|
||||
// Auto-complete checklist if all items are checked
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('sc.id, COUNT(sci2.id) AS total, SUM(CASE WHEN sci2.checked = 1 THEN 1 ELSE 0 END) AS done')
|
||||
->from($db->quoteName('#__mokosuitefield_safety_checklist_items', 'sci2'))
|
||||
->join('INNER', $db->quoteName('#__mokosuitefield_safety_checklists', 'sc') . ' ON sc.id = sci2.checklist_id')
|
||||
->where('sci2.id = ' . (int) $itemId)
|
||||
->group('sc.id'));
|
||||
$progress = $db->loadObject();
|
||||
|
||||
if ($progress && (int) $progress->done === (int) $progress->total) {
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->update('#__mokosuitefield_safety_checklists')
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote('completed'))
|
||||
->where('id = ' . (int) $progress->id));
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get checklist completion status for a work order.
|
||||
*/
|
||||
public static function getStatus(int $woId): ?object
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$db->setQuery($db->getQuery(true)
|
||||
->select('sc.id, sc.status')
|
||||
->select('COUNT(sci.id) AS total_items')
|
||||
->select('SUM(CASE WHEN sci.checked = 1 THEN 1 ELSE 0 END) AS checked_items')
|
||||
->select('SUM(CASE WHEN sci.checked = 1 AND sci.passed = 0 THEN 1 ELSE 0 END) AS failed_items')
|
||||
->from($db->quoteName('#__mokosuitefield_safety_checklists', 'sc'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitefield_safety_checklist_items', 'sci') . ' ON sci.checklist_id = sc.id')
|
||||
->where('sc.wo_id = ' . (int) $woId)
|
||||
->group('sc.id')
|
||||
->order('sc.created DESC'));
|
||||
|
||||
$status = $db->loadObject();
|
||||
if (!$status) return null;
|
||||
|
||||
$status->complete = (int) $status->checked_items === (int) $status->total_items;
|
||||
$status->all_passed = (int) $status->failed_items === 0;
|
||||
$status->safe_to_proceed = $status->complete && $status->all_passed;
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default safety items by trade.
|
||||
*/
|
||||
private static function getDefaultItems(string $trade): array
|
||||
{
|
||||
$common = [
|
||||
'PPE worn (gloves, safety glasses, boots)',
|
||||
'Work area inspected for hazards',
|
||||
'Tools and equipment in good condition',
|
||||
'Fire extinguisher accessible',
|
||||
'Emergency exits identified',
|
||||
];
|
||||
|
||||
$tradeSpecific = match ($trade) {
|
||||
'electrical' => ['Lockout/tagout verified', 'Voltage tester functional', 'Grounding confirmed', 'Arc flash boundaries marked'],
|
||||
'plumbing' => ['Water supply shut off', 'Gas lines identified and marked', 'Asbestos check for older buildings', 'Drain protection in place'],
|
||||
'hvac' => ['Refrigerant handling certification verified', 'Electrical isolation confirmed', 'Ductwork supports inspected', 'Ladder/scaffold secured'],
|
||||
'general' => ['Work permit obtained if required', 'Material Safety Data Sheets reviewed'],
|
||||
default => [],
|
||||
};
|
||||
|
||||
return array_merge($common, $tradeSpecific);
|
||||
}
|
||||
}
|
||||
@@ -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.04.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