diff --git a/.gitea/workflows/block-stable.yaml b/.gitea/workflows/block-stable.yaml new file mode 100644 index 0000000..a32ae6f --- /dev/null +++ b/.gitea/workflows/block-stable.yaml @@ -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." diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/SchedulingHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/SchedulingHelper.php new file mode 100644 index 0000000..c62c6d4 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/SchedulingHelper.php @@ -0,0 +1,152 @@ +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; + } +}