feat: SchedulingHelper + block-stable CI #15

Merged
jmiller merged 2 commits from dev into main 2026-06-18 16:24:09 +00:00
2 changed files with 185 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Block Stable Releases (Untested)
# This workflow auto-deletes "stable" releases until we're ready.
# DELETE THIS FILE when all testing is complete and stable releases should be allowed.
on:
release:
types: [published]
jobs:
block-stable:
runs-on: ubuntu-latest
if: contains(github.event.release.tag_name, 'stable')
steps:
- name: Delete stable release (not yet tested)
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
echo "Blocking stable release: ${{ github.event.release.tag_name }}"
echo "Reason: Code has not been fully tested yet."
echo "Remove .gitea/workflows/block-stable.yaml when ready for stable releases."
# Delete the release via API
curl -s -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ github.event.release.id }}"
# Also delete the tag to keep things clean
curl -s -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/tags/${{ github.event.release.tag_name }}" || true
echo "Stable release blocked and deleted."
@@ -0,0 +1,152 @@
<?php
namespace Moko\Plugin\System\MokoSuiteField\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Scheduling helper — tech availability, appointment slots, recurring service schedules.
*/
class SchedulingHelper
{
/**
* Get available appointment slots for a date and trade.
*/
public static function getAvailableSlots(string $date, string $trade = 'general', int $durationMin = 60): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$params = Factory::getApplication()->getParams('com_mokosuitefield');
$startHour = (int) $params->get('schedule_start_hour', 8);
$endHour = (int) $params->get('schedule_end_hour', 17);
$slotInterval = (int) $params->get('slot_interval_minutes', 30);
// Get available techs for this trade
$db->setQuery($db->getQuery(true)
->select('t.id, cd.name AS tech_name, t.max_daily_jobs')
->select('(SELECT COUNT(*) FROM #__mokosuitefield_work_orders wo WHERE wo.technician_id = t.id AND wo.scheduled_date = ' . $db->quote($date) . ' AND wo.status NOT IN (' . $db->quote('cancelled') . ',' . $db->quote('completed') . ')) AS booked_count')
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->where($db->quoteName('t.status') . ' = ' . $db->quote('available'))
->where('(' . $db->quoteName('t.trade') . ' = ' . $db->quote($trade) . ' OR ' . $db->quoteName('t.trade') . ' = ' . $db->quote('multi_trade') . ')')
->having('booked_count < t.max_daily_jobs'));
$techs = $db->loadObjectList() ?: [];
if (empty($techs)) return [];
// Get existing WO times for these techs
$techIds = array_column($techs, 'id');
$db->setQuery($db->getQuery(true)
->select('technician_id, scheduled_time, estimated_duration')
->from('#__mokosuitefield_work_orders')
->where('scheduled_date = ' . $db->quote($date))
->where('technician_id IN (' . implode(',', array_map('intval', $techIds)) . ')')
->where($db->quoteName('status') . ' NOT IN (' . $db->quote('cancelled') . ',' . $db->quote('completed') . ')'));
$existingWOs = $db->loadObjectList() ?: [];
// Build blocked time ranges per tech
$blocked = [];
foreach ($existingWOs as $wo) {
$start = strtotime($wo->scheduled_time);
$end = $start + ((int) ($wo->estimated_duration ?? 60)) * 60;
$blocked[$wo->technician_id][] = [$start, $end];
}
// Generate slots
$slots = [];
$current = strtotime($date . ' ' . str_pad($startHour, 2, '0', STR_PAD_LEFT) . ':00:00');
$dayEnd = strtotime($date . ' ' . str_pad($endHour, 2, '0', STR_PAD_LEFT) . ':00:00') - ($durationMin * 60);
while ($current <= $dayEnd) {
$slotEnd = $current + ($durationMin * 60);
// Find at least one available tech for this slot
$availableTechs = [];
foreach ($techs as $tech) {
$conflict = false;
foreach ($blocked[$tech->id] ?? [] as [$bStart, $bEnd]) {
if ($current < $bEnd && $slotEnd > $bStart) {
$conflict = true;
break;
}
}
if (!$conflict) {
$availableTechs[] = $tech->tech_name;
}
}
if (!empty($availableTechs)) {
$slots[] = (object) [
'time' => date('H:i', $current),
'display' => date('g:i A', $current),
'available_techs' => count($availableTechs),
];
}
$current += $slotInterval * 60;
}
return $slots;
}
/**
* Schedule a work order into a specific slot.
*/
public static function scheduleWorkOrder(int $woId, string $date, string $time, ?int $techId = null): bool
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
// Auto-assign tech if not specified
if (!$techId) {
$wo = $db->setQuery($db->getQuery(true)->select('trade')->from('#__mokosuitefield_work_orders')->where('id = ' . (int) $woId))->loadObject();
$bestTech = DispatchHelper::findBestTech($wo->trade ?? 'general', '');
$techId = $bestTech ? (int) $bestTech->id : null;
}
$update = (object) [
'id' => $woId,
'scheduled_date' => $date,
'scheduled_time' => $time,
'technician_id' => $techId,
'status' => 'scheduled',
];
return $db->updateObject('#__mokosuitefield_work_orders', $update, 'id');
}
/**
* Get today's schedule for all techs.
*/
public static function getTodaySchedule(): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('t.id AS tech_id, cd.name AS tech_name, t.trade')
->from($db->quoteName('#__mokosuitefield_technicians', 't'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = t.contact_id')
->where($db->quoteName('t.status') . ' != ' . $db->quote('inactive'))
->order('cd.name ASC'));
$techs = $db->loadObjectList() ?: [];
foreach ($techs as &$tech) {
$db->setQuery($db->getQuery(true)
->select('wo.id, wo.wo_number, wo.scheduled_time, wo.estimated_duration, wo.status, wo.priority')
->select('loc.address, loc.city')
->select('cd2.name AS customer_name')
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
->join('LEFT', $db->quoteName('#__mokosuitefield_locations', 'loc') . ' ON loc.id = wo.location_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd2') . ' ON cd2.id = wo.contact_id')
->where('wo.technician_id = ' . (int) $tech->tech_id)
->where('wo.scheduled_date = CURDATE()')
->where($db->quoteName('wo.status') . ' != ' . $db->quote('cancelled'))
->order('wo.scheduled_time ASC'));
$tech->jobs = $db->loadObjectList() ?: [];
$tech->job_count = count($tech->jobs);
}
return $techs;
}
}