23 Commits

Author SHA1 Message Date
gitea-actions[bot] 4d4bd0c906 chore(release): build 01.08.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 4s
2026-06-21 16:05:06 +00:00
jmiller b209d019a9 Merge pull request 'feat: CustomerSatisfactionHelper — NPS, technician ratings' (#24) from dev into main 2026-06-21 16:04:31 +00:00
jmiller 181d5f1450 chore: sync issue-branch.yml from Template-Generic [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 4s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 19s
2026-06-21 11:04:21 -05:00
gitea-actions[bot] 1bbba00200 chore(version): pre-release bump to 01.07.02-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 3s
2026-06-21 15:57:53 +00:00
gitea-actions[bot] 54a9f10630 chore(version): auto-bump patch 01.07.01-dev [skip ci] 2026-06-21 15:57:45 +00:00
Jonathan Miller 4ec90d38f2 feat: CustomerSatisfactionHelper — post-service NPS, technician ratings, survey management
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 10s
2026-06-21 10:57:33 -05:00
jmiller 0e385fcb9c chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-21 15:01:36 +00:00
gitea-actions[bot] 5e815dd945 chore(release): build 01.07.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 24s
2026-06-21 15:00:40 +00:00
jmiller c9ea9d66a0 Merge pull request 'feat: GpsTrackingHelper + TechnicianSkillHelper fixes' (#23) from dev into main 2026-06-21 15:00:11 +00:00
jmiller d307630e99 chore: sync issue-branch.yml from Template-Generic [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 15s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 1m36s
2026-06-21 09:59:59 -05:00
gitea-actions[bot] 1983d1c4ef chore(version): pre-release bump to 01.06.02-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 4s
2026-06-21 14:22:27 +00:00
gitea-actions[bot] e01a08c664 chore(version): auto-bump patch 01.06.01-dev [skip ci] 2026-06-21 14:22:18 +00:00
Jonathan Miller 3ca55ac09c feat: GpsTrackingHelper — fleet positions, drive history, speed alerts
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 10s
2026-06-21 09:22:02 -05:00
jmiller 23459570d8 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-21 14:06:48 +00:00
gitea-actions[bot] 3f73e680f0 chore(release): build 01.06.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 26s
2026-06-21 14:05:54 +00:00
jmiller 353ed547f7 Merge pull request 'feat: TechnicianSkillHelper — skill matrix, dispatch matching' (#22) from dev into main
Merge PR #22: feat: TechnicianSkillHelper — skill matrix, dispatch matching
2026-06-21 14:05:18 +00:00
jmiller c9d529f412 chore: sync issue-branch.yml from Template-Generic [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 14s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 1m32s
2026-06-21 09:04:48 -05:00
jmiller 78c41a70ca chore: sync composer-publish.yml from Template-Generic [skip ci] 2026-06-21 09:04:16 -05:00
gitea-actions[bot] a772f4b872 chore(version): pre-release bump to 01.05.02-dev [skip ci] 2026-06-21 13:50:00 +00:00
gitea-actions[bot] e6ba7ade71 chore(version): auto-bump patch 01.05.01-dev [skip ci] 2026-06-21 13:49:51 +00:00
Jonathan Miller f9eb1153e6 feat: TechnicianSkillHelper — skill matrix, best-match dispatch, cert expiry tracking
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 11s
2026-06-21 08:49:40 -05:00
jmiller 0e9f4888c9 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-21 06:36:06 +00:00
jmiller c72f679cfb chore: sync composer-publish.yml from Template-Generic [skip ci] 2026-06-21 06:36:05 +00:00
6 changed files with 405 additions and 2 deletions
+76
View File
@@ -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
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.05.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() ?: [];
}
}
@@ -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() ?: [];
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuite Field</name>
<packagename>mokosuitefield</packagename>
<version>01.05.00</version>
<version>01.08.00</version>
<creationDate>2026-06-12</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>