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

This commit is contained in:
Jonathan Miller
2026-06-21 08:49:40 -05:00
parent 0bfca03942
commit f9eb1153e6
@@ -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() ?: [];
}
}