feat: SchedulingHelper + block-stable CI #15
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user