From a74484885c0f13808964aeb62f471c6d1ab47fad Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 5 Apr 2026 10:53:08 +0800 Subject: [PATCH 01/13] Fix schedule availabilities API --- composer.json | 4 ++-- src/routes.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index fbb798f..d49db97 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.38", + "version": "1.6.39", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", @@ -107,4 +107,4 @@ "@test:unit" ] } -} \ No newline at end of file +} diff --git a/src/routes.php b/src/routes.php index 50b91a1..8c47d5f 100644 --- a/src/routes.php +++ b/src/routes.php @@ -313,7 +313,7 @@ function ($router, $controller) { $router->fleetbaseRoutes('schedules'); $router->fleetbaseRoutes('schedule-items'); $router->fleetbaseRoutes('schedule-templates'); - $router->fleetbaseRoutes('schedule-availability'); + $router->fleetbaseRoutes('schedule-availabilities'); $router->fleetbaseRoutes('schedule-constraints'); $router->fleetbaseRoutes('templates', function ($router, $controller) { $router->get('context-schemas', $controller('contextSchemas')); From 90c58e75390fb8be7ac1231145242e14de0b6aff Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Sat, 4 Apr 2026 23:37:26 -0400 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20scheduling=20framework=20?= =?UTF-8?q?=E2=80=94=20RRULE=20materialization,=20ScheduleException=20mode?= =?UTF-8?q?l,=20template=20apply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add rlanvin/php-rrule to composer.json for RRULE parsing and expansion - Add migration: schedule_items.schedule_template_uuid, is_exception, exception_for_date columns - Add migration: schedule_exceptions table (time_off, sick, holiday, swap, training, other types) - Add migration: schedules.last_materialized_at, materialization_horizon columns - Rewrite Schedule model: add exceptions(), appliedTemplates(), activeShiftFor(), isDriverAvailableAt() - Rewrite ScheduleItem model: add scheduleTemplate(), scopeForDate(), scopeActiveOn(), isException() - Rewrite ScheduleTemplate model: add schedule(), items(), expandOccurrences(), generateItems() - Add ScheduleException model: polymorphic subject, approve/reject workflow, overlapping/coveringDate scopes - Rewrite ScheduleService: full RRULE materialization engine with exception-aware shift generation - materializeTemplate(): expand RRULE, skip exception-covered dates, upsert ScheduleItems - applyTemplateToSchedule(): copy library template to driver schedule and materialize - approveException()/rejectException(): workflow transitions + cancel covered shifts on approval - getExceptionsForSubject(): filtered query helper - Add MaterializeSchedulesJob: daily rolling 60-day window materialization for all active schedules - Add ScheduleExceptionController: approve, reject, forSubject endpoints - Rewrite ScheduleTemplateController: add apply and materialize endpoints - Add ScheduleException and ScheduleTemplate HTTP resource classes - Register schedule-exceptions routes with approve/reject/for-subject actions - Register schedule-templates apply/materialize routes - Register MaterializeSchedulesJob as daily:01:00 scheduled job in CoreServiceProvider --- composer.json | 3 +- ...d_rule_columns_to_schedule_items_table.php | 40 ++ ...00007_create_schedule_exceptions_table.php | 77 ++++ ...rialization_columns_to_schedules_table.php | 39 ++ .../v1/ScheduleExceptionController.php | 100 +++++ .../v1/ScheduleTemplateController.php | 81 ++++ src/Http/Resources/ScheduleException.php | 20 + src/Http/Resources/ScheduleTemplate.php | 20 + src/Jobs/MaterializeSchedulesJob.php | 75 ++++ src/Models/Schedule.php | 109 +++++- src/Models/ScheduleException.php | 289 ++++++++++++++ src/Models/ScheduleItem.php | 170 +++++++- src/Models/ScheduleTemplate.php | 190 ++++++++- src/Providers/CoreServiceProvider.php | 1 + src/Services/Scheduling/ScheduleService.php | 366 +++++++++++++++++- src/routes.php | 10 +- 16 files changed, 1560 insertions(+), 30 deletions(-) create mode 100644 migrations/2025_11_14_000006_add_rule_columns_to_schedule_items_table.php create mode 100644 migrations/2025_11_14_000007_create_schedule_exceptions_table.php create mode 100644 migrations/2025_11_14_000008_add_materialization_columns_to_schedules_table.php create mode 100644 src/Http/Controllers/Internal/v1/ScheduleExceptionController.php create mode 100644 src/Http/Resources/ScheduleException.php create mode 100644 src/Http/Resources/ScheduleTemplate.php create mode 100644 src/Jobs/MaterializeSchedulesJob.php create mode 100644 src/Models/ScheduleException.php diff --git a/composer.json b/composer.json index d49db97..52dc967 100644 --- a/composer.json +++ b/composer.json @@ -57,7 +57,8 @@ "sqids/sqids": "^0.4.1", "xantios/mimey": "^2.2.0", "spatie/laravel-pdf": "^1.9", - "mossadal/math-parser": "^1.3" + "mossadal/math-parser": "^1.3", + "rlanvin/php-rrule": "^2.4" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.34.1", diff --git a/migrations/2025_11_14_000006_add_rule_columns_to_schedule_items_table.php b/migrations/2025_11_14_000006_add_rule_columns_to_schedule_items_table.php new file mode 100644 index 0000000..75988b7 --- /dev/null +++ b/migrations/2025_11_14_000006_add_rule_columns_to_schedule_items_table.php @@ -0,0 +1,40 @@ +string('template_uuid', 191)->nullable()->after('schedule_uuid')->index() + ->comment('The ScheduleTemplate that generated this item via RRULE expansion'); + + // Flags for recurrence management + $table->boolean('is_exception')->default(false)->after('status')->index() + ->comment('True when this item has been manually edited and should not be overwritten by re-materialization'); + + $table->string('exception_for_date', 20)->nullable()->after('is_exception') + ->comment('The original RRULE occurrence date (YYYY-MM-DD) this item is an exception for'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('schedule_items', function (Blueprint $table) { + $table->dropColumn(['template_uuid', 'is_exception', 'exception_for_date']); + }); + } +}; diff --git a/migrations/2025_11_14_000007_create_schedule_exceptions_table.php b/migrations/2025_11_14_000007_create_schedule_exceptions_table.php new file mode 100644 index 0000000..a619b40 --- /dev/null +++ b/migrations/2025_11_14_000007_create_schedule_exceptions_table.php @@ -0,0 +1,77 @@ +increments('id'); + $table->string('_key')->nullable(); + $table->string('uuid', 191)->nullable()->unique()->index(); + $table->string('public_id', 191)->nullable()->unique()->index(); + $table->string('company_uuid', 191)->nullable()->index(); + + // Polymorphic subject — the entity this exception applies to (e.g. Driver) + $table->string('subject_uuid', 191)->nullable()->index(); + $table->string('subject_type')->nullable()->index(); + + // Optional link to the schedule this exception belongs to + $table->string('schedule_uuid', 191)->nullable()->index(); + + // The date range the exception covers + $table->timestamp('start_at')->nullable()->index(); + $table->timestamp('end_at')->nullable()->index(); + + // Exception classification + $table->string('type', 50)->nullable()->index() + ->comment('e.g., time_off, sick, holiday, swap, training'); + + // Workflow status + $table->string('status', 50)->default('pending')->index() + ->comment('pending | approved | rejected | cancelled'); + + // Human-readable reason and optional notes + $table->string('reason')->nullable(); + $table->text('notes')->nullable(); + + // Who approved/rejected the exception + $table->string('reviewed_by_uuid', 191)->nullable()->index(); + $table->timestamp('reviewed_at')->nullable(); + + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->index(['subject_uuid', 'subject_type', 'start_at', 'end_at'], 'schedule_exception_subject_range_idx'); + $table->index(['company_uuid', 'status', 'start_at', 'end_at'], 'schedule_exception_company_status_idx'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('schedule_exceptions'); + } +}; diff --git a/migrations/2025_11_14_000008_add_materialization_columns_to_schedules_table.php b/migrations/2025_11_14_000008_add_materialization_columns_to_schedules_table.php new file mode 100644 index 0000000..1d74407 --- /dev/null +++ b/migrations/2025_11_14_000008_add_materialization_columns_to_schedules_table.php @@ -0,0 +1,39 @@ +timestamp('last_materialized_at')->nullable()->after('status') + ->comment('Timestamp of the last successful RRULE materialization run'); + $table->date('materialization_horizon')->nullable()->after('last_materialized_at') + ->comment('The furthest future date up to which ScheduleItems have been generated'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('schedules', function (Blueprint $table) { + $table->dropColumn(['last_materialized_at', 'materialization_horizon']); + }); + } +}; diff --git a/src/Http/Controllers/Internal/v1/ScheduleExceptionController.php b/src/Http/Controllers/Internal/v1/ScheduleExceptionController.php new file mode 100644 index 0000000..9aba1ff --- /dev/null +++ b/src/Http/Controllers/Internal/v1/ScheduleExceptionController.php @@ -0,0 +1,100 @@ +scheduleService = $scheduleService; + } + + /** + * Approve a schedule exception. + * This will also cancel any generated ScheduleItems that fall within the exception's date range. + * + * POST /schedule-exceptions/{id}/approve + */ + public function approve(string $id): JsonResponse + { + $exception = ScheduleException::where('uuid', $id) + ->orWhere('public_id', $id) + ->firstOrFail(); + + $reviewerUuid = auth()->user()?->uuid; + + $exception = $this->scheduleService->approveException($exception, $reviewerUuid); + + return response()->json([ + 'status' => 'ok', + 'schedule_exception' => new \Fleetbase\Http\Resources\ScheduleException($exception), + ]); + } + + /** + * Reject a schedule exception. + * + * POST /schedule-exceptions/{id}/reject + */ + public function reject(string $id): JsonResponse + { + $exception = ScheduleException::where('uuid', $id) + ->orWhere('public_id', $id) + ->firstOrFail(); + + $reviewerUuid = auth()->user()?->uuid; + + $exception = $this->scheduleService->rejectException($exception, $reviewerUuid); + + return response()->json([ + 'status' => 'ok', + 'schedule_exception' => new \Fleetbase\Http\Resources\ScheduleException($exception), + ]); + } + + /** + * Get all exceptions for a specific subject. + * + * GET /schedule-exceptions?subject_type=driver&subject_uuid={uuid} + */ + public function forSubject(Request $request): JsonResponse + { + $subjectType = $request->input('subject_type'); + $subjectUuid = $request->input('subject_uuid'); + + if (!$subjectType || !$subjectUuid) { + return response()->json([ + 'error' => 'subject_type and subject_uuid are required', + ], 422); + } + + $filters = $request->only(['status', 'type', 'start_at', 'end_at']); + + $exceptions = $this->scheduleService->getExceptionsForSubject($subjectType, $subjectUuid, $filters); + + return response()->json([ + 'schedule_exceptions' => \Fleetbase\Http\Resources\ScheduleException::collection($exceptions), + ]); + } +} diff --git a/src/Http/Controllers/Internal/v1/ScheduleTemplateController.php b/src/Http/Controllers/Internal/v1/ScheduleTemplateController.php index c52dd2d..99393fb 100644 --- a/src/Http/Controllers/Internal/v1/ScheduleTemplateController.php +++ b/src/Http/Controllers/Internal/v1/ScheduleTemplateController.php @@ -3,8 +3,89 @@ namespace Fleetbase\Http\Controllers\Internal\v1; use Fleetbase\Http\Controllers\FleetbaseController; +use Fleetbase\Models\Schedule; +use Fleetbase\Models\ScheduleTemplate; +use Fleetbase\Services\Scheduling\ScheduleService; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; class ScheduleTemplateController extends FleetbaseController { + /** + * The resource to query. + * + * @var string + */ public $resource = 'schedule_template'; + + /** + * The ScheduleService instance. + * + * @var ScheduleService + */ + protected ScheduleService $scheduleService; + + public function __construct(ScheduleService $scheduleService) + { + parent::__construct(); + $this->scheduleService = $scheduleService; + } + + /** + * Apply a library template to a specific Schedule. + * + * Creates a driver-specific copy of the template linked to the schedule + * and immediately materializes it for the rolling 60-day window. + * + * POST /schedule-templates/{id}/apply + * Body: { "schedule_uuid": "..." } + */ + public function apply(Request $request, string $id): JsonResponse + { + $request->validate([ + 'schedule_uuid' => 'required|string', + ]); + + $template = ScheduleTemplate::where('uuid', $id) + ->orWhere('public_id', $id) + ->firstOrFail(); + + $schedule = Schedule::where('uuid', $request->input('schedule_uuid')) + ->orWhere('public_id', $request->input('schedule_uuid')) + ->firstOrFail(); + + $applied = $this->scheduleService->applyTemplateToSchedule($template, $schedule); + + return response()->json([ + 'status' => 'ok', + 'schedule_template' => new \Fleetbase\Http\Resources\ScheduleTemplate($applied), + 'items_created' => $applied->items()->count(), + ]); + } + + /** + * Manually trigger materialization for a specific applied template. + * + * POST /schedule-templates/{id}/materialize + */ + public function materialize(string $id): JsonResponse + { + $template = ScheduleTemplate::where('uuid', $id) + ->orWhere('public_id', $id) + ->whereNotNull('schedule_uuid') + ->firstOrFail(); + + $schedule = $template->schedule; + + if (!$schedule) { + return response()->json(['error' => 'Template is not applied to any schedule.'], 422); + } + + $created = $this->scheduleService->materializeTemplate($template, $schedule); + + return response()->json([ + 'status' => 'ok', + 'items_created' => $created, + ]); + } } diff --git a/src/Http/Resources/ScheduleException.php b/src/Http/Resources/ScheduleException.php new file mode 100644 index 0000000..bd97610 --- /dev/null +++ b/src/Http/Resources/ScheduleException.php @@ -0,0 +1,20 @@ +materializeAll(); + + Log::info('[MaterializeSchedulesJob] Materialization complete.', [ + 'schedules_materialized' => $stats['materialized'], + 'schedules_skipped' => $stats['skipped'], + 'errors' => $stats['errors'], + ]); + } + + /** + * Handle a job failure. + */ + public function failed(\Throwable $exception): void + { + Log::error('[MaterializeSchedulesJob] Job failed: ' . $exception->getMessage(), [ + 'trace' => $exception->getTraceAsString(), + ]); + } +} diff --git a/src/Models/Schedule.php b/src/Models/Schedule.php index fe53330..246f463 100644 --- a/src/Models/Schedule.php +++ b/src/Models/Schedule.php @@ -10,6 +10,31 @@ use Fleetbase\Traits\Searchable; use Illuminate\Database\Eloquent\SoftDeletes; +/** + * Represents a subject's personal calendar — the container for their schedule items. + * + * A Schedule belongs to a polymorphic subject (e.g. a Driver) and holds the collection + * of ScheduleItem records that represent concrete, materialized shifts. The recurring + * pattern that generates those items is defined on a ScheduleTemplate (via its rrule field). + * + * The materialization engine reads all active ScheduleTemplates linked to this schedule, + * expands their RRULEs using rlanvin/php-rrule, and writes ScheduleItem rows for a rolling + * window. The last_materialized_at and materialization_horizon columns track the engine's progress. + * + * @property string $uuid + * @property string $public_id + * @property string $company_uuid + * @property string $subject_uuid + * @property string $subject_type + * @property string|null $name + * @property string|null $description + * @property \Carbon\Carbon|null $start_date + * @property \Carbon\Carbon|null $end_date + * @property string|null $timezone + * @property string $status + * @property \Carbon\Carbon|null $last_materialized_at + * @property string|null $materialization_horizon + */ class Schedule extends Model { use HasUuid; @@ -56,6 +81,8 @@ class Schedule extends Model 'end_date', 'timezone', 'status', + 'last_materialized_at', + 'materialization_horizon', 'meta', ]; @@ -65,9 +92,11 @@ class Schedule extends Model * @var array */ protected $casts = [ - 'start_date' => 'date', - 'end_date' => 'date', - 'meta' => Json::class, + 'start_date' => 'date', + 'end_date' => 'date', + 'last_materialized_at' => 'datetime', + 'materialization_horizon' => 'date', + 'meta' => Json::class, ]; /** @@ -77,6 +106,8 @@ class Schedule extends Model */ protected $filterParams = ['subject_type', 'subject_uuid', 'status', 'start_date', 'end_date']; + // ─── Relationships ──────────────────────────────────────────────────────── + /** * Get the subject that this schedule belongs to (polymorphic). * @@ -98,7 +129,7 @@ public function company() } /** - * Get the schedule items for this schedule. + * Get all concrete shift items for this schedule. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ @@ -108,7 +139,29 @@ public function items() } /** - * Scope a query to only include schedules for a specific subject. + * Get the schedule templates (recurring patterns) applied to this schedule. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function templates() + { + return $this->hasMany(ScheduleTemplate::class, 'schedule_uuid'); + } + + /** + * Get all exceptions (time off, sick leave, etc.) for this schedule. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function exceptions() + { + return $this->hasMany(ScheduleException::class, 'schedule_uuid'); + } + + // ─── Scopes ─────────────────────────────────────────────────────────────── + + /** + * Scope: only schedules for a specific polymorphic subject. * * @param \Illuminate\Database\Eloquent\Builder $query * @param string $type @@ -122,7 +175,7 @@ public function scopeForSubject($query, $type, $uuid) } /** - * Scope a query to only include active schedules. + * Scope: only active schedules. * * @param \Illuminate\Database\Eloquent\Builder $query * @@ -134,7 +187,7 @@ public function scopeActive($query) } /** - * Scope a query to only include schedules within a date range. + * Scope: schedules that overlap with a given date range. * * @param \Illuminate\Database\Eloquent\Builder $query * @param string $startDate @@ -156,4 +209,46 @@ public function scopeWithinDateRange($query, $startDate, $endDate) }); }); } + + /** + * Scope: schedules whose materialization horizon is before a given date + * (i.e. schedules that need to be re-materialized to extend the rolling window). + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string|\Carbon\Carbon $date + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeNeedsMaterialization($query, $date) + { + return $query->where(function ($q) use ($date) { + $q->whereNull('materialization_horizon') + ->orWhere('materialization_horizon', '<', $date); + }); + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + /** + * Determine whether this schedule needs materialization up to the given date. + * + * @param \Carbon\Carbon $upTo + * + * @return bool + */ + public function needsMaterializationUpTo(\Carbon\Carbon $upTo): bool + { + return is_null($this->materialization_horizon) + || $this->materialization_horizon->lt($upTo); + } + + /** + * Get the effective timezone for this schedule, falling back to UTC. + * + * @return string + */ + public function getEffectiveTimezone(): string + { + return $this->timezone ?: 'UTC'; + } } diff --git a/src/Models/ScheduleException.php b/src/Models/ScheduleException.php new file mode 100644 index 0000000..49885c4 --- /dev/null +++ b/src/Models/ScheduleException.php @@ -0,0 +1,289 @@ + 'datetime', + 'end_at' => 'datetime', + 'reviewed_at' => 'datetime', + 'meta' => Json::class, + ]; + + /** + * Attributes that are filterable on this model. + * + * @var array + */ + protected $filterParams = [ + 'company_uuid', + 'subject_uuid', + 'subject_type', + 'schedule_uuid', + 'type', + 'status', + 'start_at', + 'end_at', + ]; + + /** + * Valid exception types. + */ + const TYPES = ['time_off', 'sick', 'holiday', 'swap', 'training', 'other']; + + /** + * Valid workflow statuses. + */ + const STATUSES = ['pending', 'approved', 'rejected', 'cancelled']; + + // ─── Relationships ──────────────────────────────────────────────────────── + + /** + * Get the subject this exception applies to (polymorphic). + * + * @return \Illuminate\Database\Eloquent\Relations\MorphTo + */ + public function subject() + { + return $this->morphTo(__FUNCTION__, 'subject_type', 'subject_uuid'); + } + + /** + * Get the parent schedule this exception belongs to. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function schedule() + { + return $this->belongsTo(Schedule::class, 'schedule_uuid'); + } + + /** + * Get the user who reviewed (approved/rejected) this exception. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function reviewedBy() + { + return $this->belongsTo(User::class, 'reviewed_by_uuid'); + } + + // ─── Scopes ─────────────────────────────────────────────────────────────── + + /** + * Scope: only approved exceptions. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeApproved($query) + { + return $query->where('status', 'approved'); + } + + /** + * Scope: only pending exceptions. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopePending($query) + { + return $query->where('status', 'pending'); + } + + /** + * Scope: filter by exception type. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $type + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeOfType($query, string $type) + { + return $query->where('type', $type); + } + + /** + * Scope: filter by subject (polymorphic). + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $type + * @param string $uuid + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeForSubject($query, string $type, string $uuid) + { + return $query->where('subject_type', $type)->where('subject_uuid', $uuid); + } + + /** + * Scope: exceptions that overlap with a given time range. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string|\Carbon\Carbon $startAt + * @param string|\Carbon\Carbon $endAt + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeOverlapping($query, $startAt, $endAt) + { + return $query->where(function ($q) use ($startAt, $endAt) { + $q->where('start_at', '<', $endAt) + ->where('end_at', '>', $startAt); + }); + } + + /** + * Scope: exceptions that cover a specific date. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string|\Carbon\Carbon $date + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeCoveringDate($query, $date) + { + return $query->where('start_at', '<=', $date) + ->where('end_at', '>=', $date); + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + /** + * Approve this exception. + * + * @param string|null $reviewerUuid + * + * @return $this + */ + public function approve(?string $reviewerUuid = null): self + { + $this->update([ + 'status' => 'approved', + 'reviewed_by_uuid' => $reviewerUuid ?? auth()->id(), + 'reviewed_at' => now(), + ]); + + return $this; + } + + /** + * Reject this exception. + * + * @param string|null $reviewerUuid + * + * @return $this + */ + public function reject(?string $reviewerUuid = null): self + { + $this->update([ + 'status' => 'rejected', + 'reviewed_by_uuid' => $reviewerUuid ?? auth()->id(), + 'reviewed_at' => now(), + ]); + + return $this; + } + + /** + * Determine whether this exception is currently active (approved and covering now). + * + * @return bool + */ + public function isActive(): bool + { + return $this->status === 'approved' + && $this->start_at <= now() + && $this->end_at >= now(); + } +} diff --git a/src/Models/ScheduleItem.php b/src/Models/ScheduleItem.php index 70f00a9..477931c 100644 --- a/src/Models/ScheduleItem.php +++ b/src/Models/ScheduleItem.php @@ -10,6 +10,36 @@ use Fleetbase\Traits\Searchable; use Illuminate\Database\Eloquent\SoftDeletes; +/** + * Represents a concrete, materialized shift instance on a specific date. + * + * ScheduleItem records are either: + * (a) Generated automatically by the ScheduleService materialization engine from a + * ScheduleTemplate's RRULE (in which case template_uuid is set), or + * (b) Created manually by a dispatcher as a one-off standalone shift + * (in which case template_uuid is null). + * + * When a dispatcher manually edits a materialized item, is_exception is set to true + * and exception_for_date records the original RRULE occurrence date. The materialization + * engine will never overwrite items where is_exception = true. + * + * @property string $uuid + * @property string $public_id + * @property string|null $schedule_uuid + * @property string|null $template_uuid + * @property string|null $assignee_uuid + * @property string|null $assignee_type + * @property string|null $resource_uuid + * @property string|null $resource_type + * @property \Carbon\Carbon|null $start_at + * @property \Carbon\Carbon|null $end_at + * @property int|null $duration + * @property \Carbon\Carbon|null $break_start_at + * @property \Carbon\Carbon|null $break_end_at + * @property string $status + * @property bool $is_exception + * @property string|null $exception_for_date + */ class ScheduleItem extends Model { use HasUuid; @@ -48,6 +78,7 @@ class ScheduleItem extends Model protected $fillable = [ 'public_id', 'schedule_uuid', + 'template_uuid', 'assignee_uuid', 'assignee_type', 'resource_uuid', @@ -58,6 +89,8 @@ class ScheduleItem extends Model 'break_start_at', 'break_end_at', 'status', + 'is_exception', + 'exception_for_date', 'meta', ]; @@ -72,6 +105,7 @@ class ScheduleItem extends Model 'break_start_at' => 'datetime', 'break_end_at' => 'datetime', 'duration' => 'integer', + 'is_exception' => 'boolean', 'meta' => Json::class, ]; @@ -82,17 +116,21 @@ class ScheduleItem extends Model */ protected $filterParams = [ 'schedule_uuid', + 'template_uuid', 'assignee_type', 'assignee_uuid', 'resource_type', 'resource_uuid', 'status', + 'is_exception', 'start_at', 'end_at', ]; + // ─── Relationships ──────────────────────────────────────────────────────── + /** - * Get the schedule that owns the item. + * Get the schedule that owns this item. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ @@ -102,7 +140,17 @@ public function schedule() } /** - * Get the assignee (polymorphic). + * Get the ScheduleTemplate that generated this item (null for standalone shifts). + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function template() + { + return $this->belongsTo(ScheduleTemplate::class, 'template_uuid'); + } + + /** + * Get the assignee (polymorphic — e.g. a Driver). * * @return \Illuminate\Database\Eloquent\Relations\MorphTo */ @@ -112,7 +160,7 @@ public function assignee() } /** - * Get the resource (polymorphic). + * Get the resource (polymorphic — e.g. a Vehicle). * * @return \Illuminate\Database\Eloquent\Relations\MorphTo */ @@ -121,8 +169,10 @@ public function resource() return $this->morphTo(__FUNCTION__, 'resource_type', 'resource_uuid'); } + // ─── Scopes ─────────────────────────────────────────────────────────────── + /** - * Scope a query to only include items for a specific assignee. + * Scope: items for a specific polymorphic assignee. * * @param \Illuminate\Database\Eloquent\Builder $query * @param string $type @@ -136,11 +186,48 @@ public function scopeForAssignee($query, $type, $uuid) } /** - * Scope a query to only include items within a time range. + * Scope: items generated from a specific template. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $templateUuid + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeFromTemplate($query, $templateUuid) + { + return $query->where('template_uuid', $templateUuid); + } + + /** + * Scope: only manually-created or manually-edited exception items. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeExceptions($query) + { + return $query->where('is_exception', true); + } + + /** + * Scope: only auto-generated (non-exception) items. * * @param \Illuminate\Database\Eloquent\Builder $query - * @param string $startAt - * @param string $endAt + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeGenerated($query) + { + return $query->where('is_exception', false)->whereNotNull('template_uuid'); + } + + /** + * Scope: items within a time range (overlapping). + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string|\Carbon\Carbon $startAt + * @param string|\Carbon\Carbon $endAt * * @return \Illuminate\Database\Eloquent\Builder */ @@ -157,7 +244,20 @@ public function scopeWithinTimeRange($query, $startAt, $endAt) } /** - * Scope a query to only include upcoming items. + * Scope: items that start on a specific date. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string|\Carbon\Carbon $date + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeOnDate($query, $date) + { + return $query->whereDate('start_at', $date); + } + + /** + * Scope: only upcoming items. * * @param \Illuminate\Database\Eloquent\Builder $query * @@ -169,7 +269,7 @@ public function scopeUpcoming($query) } /** - * Scope a query to only include items by status. + * Scope: filter by status (single or array). * * @param \Illuminate\Database\Eloquent\Builder $query * @param string|array $status @@ -185,20 +285,51 @@ public function scopeByStatus($query, $status) return $query->where('status', $status); } + // ─── Helpers ────────────────────────────────────────────────────────────── + /** - * Calculate the duration in minutes if not set. + * Calculate the duration in minutes from start_at and end_at. * * @return int */ - public function calculateDuration() + public function calculateDuration(): int { if ($this->start_at && $this->end_at) { - return $this->start_at->diffInMinutes($this->end_at); + return (int) $this->start_at->diffInMinutes($this->end_at); } return 0; } + /** + * Mark this item as a manual exception so the materialization engine + * will not overwrite it on subsequent runs. + * + * @return $this + */ + public function markAsException(): self + { + if (!$this->is_exception) { + $this->update([ + 'is_exception' => true, + 'exception_for_date' => $this->start_at ? $this->start_at->toDateString() : null, + ]); + } + + return $this; + } + + /** + * Determine whether this item is currently active (in progress). + * + * @return bool + */ + public function isActive(): bool + { + return $this->status === 'in_progress' + || ($this->start_at <= now() && $this->end_at >= now()); + } + /** * Boot the model. */ @@ -206,10 +337,25 @@ protected static function boot() { parent::boot(); + // Auto-calculate duration on save if not explicitly provided static::saving(function ($item) { if (!$item->duration && $item->start_at && $item->end_at) { $item->duration = $item->calculateDuration(); } }); + + // When a materialized item is manually updated, flag it as an exception + static::updating(function ($item) { + if ($item->template_uuid && !$item->is_exception) { + $dirty = $item->getDirty(); + $scheduleFields = ['start_at', 'end_at', 'break_start_at', 'break_end_at', 'status']; + if (count(array_intersect(array_keys($dirty), $scheduleFields)) > 0) { + $item->is_exception = true; + $item->exception_for_date = $item->getOriginal('start_at') + ? \Carbon\Carbon::parse($item->getOriginal('start_at'))->toDateString() + : null; + } + } + }); } } diff --git a/src/Models/ScheduleTemplate.php b/src/Models/ScheduleTemplate.php index 63417d8..cde8688 100644 --- a/src/Models/ScheduleTemplate.php +++ b/src/Models/ScheduleTemplate.php @@ -9,7 +9,37 @@ use Fleetbase\Traits\HasUuid; use Fleetbase\Traits\Searchable; use Illuminate\Database\Eloquent\SoftDeletes; +use RRule\RRule; +/** + * Represents a reusable recurring shift pattern. + * + * A ScheduleTemplate stores the RRULE (RFC 5545) that defines when a shift recurs — + * for example, "every Monday, Tuesday, and Thursday from 08:00 to 16:00". + * It can be: + * (a) A company-level library template (subject_uuid = null, schedule_uuid = null) that + * a manager can apply to one or many drivers to quickly bootstrap their schedules. + * (b) A driver-specific applied template (schedule_uuid is set) that is actively used + * by the materialization engine to generate ScheduleItem records for that driver's Schedule. + * + * When a library template is applied to a driver, a copy is created with schedule_uuid set + * to the driver's Schedule. This ensures that editing a driver's applied template does not + * affect the original library template or other drivers using it. + * + * @property string $uuid + * @property string $public_id + * @property string $company_uuid + * @property string|null $schedule_uuid + * @property string|null $subject_uuid + * @property string|null $subject_type + * @property string $name + * @property string|null $description + * @property string|null $start_time e.g. "08:00" + * @property string|null $end_time e.g. "16:00" + * @property int|null $duration shift duration in minutes + * @property int|null $break_duration break duration in minutes + * @property string|null $rrule RFC 5545 RRULE string e.g. "FREQ=WEEKLY;BYDAY=MO,TU,TH" + */ class ScheduleTemplate extends Model { use HasUuid; @@ -48,6 +78,7 @@ class ScheduleTemplate extends Model protected $fillable = [ 'public_id', 'company_uuid', + 'schedule_uuid', 'subject_uuid', 'subject_type', 'name', @@ -76,7 +107,9 @@ class ScheduleTemplate extends Model * * @var array */ - protected $filterParams = ['company_uuid', 'subject_type', 'subject_uuid']; + protected $filterParams = ['company_uuid', 'subject_type', 'subject_uuid', 'schedule_uuid']; + + // ─── Relationships ──────────────────────────────────────────────────────── /** * Get the company that owns the template. @@ -88,8 +121,19 @@ public function company() return $this->belongsTo(Company::class, 'company_uuid'); } + /** + * Get the Schedule this template is applied to (null for library templates). + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function schedule() + { + return $this->belongsTo(Schedule::class, 'schedule_uuid'); + } + /** * Get the subject that this template belongs to (polymorphic). + * Only populated for applied (driver-specific) templates. * * @return \Illuminate\Database\Eloquent\Relations\MorphTo */ @@ -99,7 +143,43 @@ public function subject() } /** - * Scope a query to only include templates for a specific company. + * Get all ScheduleItem records generated from this template. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function items() + { + return $this->hasMany(ScheduleItem::class, 'template_uuid'); + } + + // ─── Scopes ─────────────────────────────────────────────────────────────── + + /** + * Scope: only company-level library templates (not yet applied to a specific schedule). + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeLibrary($query) + { + return $query->whereNull('schedule_uuid'); + } + + /** + * Scope: only applied templates (linked to a specific schedule). + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeApplied($query) + { + return $query->whereNotNull('schedule_uuid'); + } + + /** + * Scope: templates for a specific company. * * @param \Illuminate\Database\Eloquent\Builder $query * @param string $companyUuid @@ -112,7 +192,7 @@ public function scopeForCompany($query, $companyUuid) } /** - * Scope a query to only include templates for a specific subject. + * Scope: templates for a specific polymorphic subject. * * @param \Illuminate\Database\Eloquent\Builder $query * @param string $type @@ -124,4 +204,108 @@ public function scopeForSubject($query, $type, $uuid) { return $query->where('subject_type', $type)->where('subject_uuid', $uuid); } + + // ─── RRULE Helpers ──────────────────────────────────────────────────────── + + /** + * Determine whether this template has a valid RRULE string. + * + * @return bool + */ + public function hasRrule(): bool + { + return !empty($this->rrule); + } + + /** + * Parse the RRULE string and return an RRule instance. + * The DTSTART is synthesized from the template's start_time and the given reference date. + * + * @param \Carbon\Carbon|null $referenceDate The date from which to start the rule (defaults to today) + * @param string|null $timezone Timezone to use (defaults to UTC) + * + * @return \RRule\RRule|null + */ + public function getRruleInstance(?\Carbon\Carbon $referenceDate = null, ?string $timezone = null): ?RRule + { + if (!$this->hasRrule()) { + return null; + } + + $tz = $timezone ?: 'UTC'; + $referenceDate = $referenceDate ?: now($tz)->startOfDay(); + $startTime = $this->start_time ?: '00:00'; + + // Build a DTSTART from the reference date + template start_time + $dtStart = \Carbon\Carbon::parse( + $referenceDate->toDateString() . ' ' . $startTime, + $tz + ); + + $rruleString = 'DTSTART=' . $dtStart->format('Ymd\THis') . "\n" . $this->rrule; + + try { + return new RRule($rruleString); + } catch (\Exception $e) { + return null; + } + } + + /** + * Get all occurrence dates between two Carbon dates. + * + * @param \Carbon\Carbon $from + * @param \Carbon\Carbon $to + * @param string|null $timezone + * + * @return \Carbon\Carbon[] + */ + public function getOccurrencesBetween(\Carbon\Carbon $from, \Carbon\Carbon $to, ?string $timezone = null): array + { + $rrule = $this->getRruleInstance($from, $timezone); + + if (!$rrule) { + return []; + } + + $occurrences = []; + foreach ($rrule as $occurrence) { + $carbon = \Carbon\Carbon::instance($occurrence); + if ($carbon->gt($to)) { + break; + } + if ($carbon->gte($from)) { + $occurrences[] = $carbon; + } + } + + return $occurrences; + } + + /** + * Apply this library template to a given Schedule, creating a driver-specific copy. + * + * @param Schedule $schedule + * @param string|null $subjectType + * @param string|null $subjectUuid + * + * @return static + */ + public function applyToSchedule(Schedule $schedule, ?string $subjectType = null, ?string $subjectUuid = null): self + { + return static::create([ + 'company_uuid' => $this->company_uuid, + 'schedule_uuid' => $schedule->uuid, + 'subject_type' => $subjectType ?? $schedule->subject_type, + 'subject_uuid' => $subjectUuid ?? $schedule->subject_uuid, + 'name' => $this->name, + 'description' => $this->description, + 'start_time' => $this->start_time, + 'end_time' => $this->end_time, + 'duration' => $this->duration, + 'break_duration' => $this->break_duration, + 'rrule' => $this->rrule, + 'meta' => $this->meta, + ]); + } } diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 9e3ed4b..e3330a8 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -164,6 +164,7 @@ public function boot() $schedule->command('purge:activity-logs --force --no-interaction --days 2')->twiceDaily(1, 13); $schedule->command('purge:scheduled-task-logs --force --no-interaction --days 1')->twiceDaily(1, 13); $schedule->command('telemetry:ping')->daily(); + $schedule->job(new \Fleetbase\Jobs\MaterializeSchedulesJob())->dailyAt('01:00')->name('materialize-schedules')->withoutOverlapping(); }); $this->registerObservers(); $this->registerExpansionsFrom(); diff --git a/src/Services/Scheduling/ScheduleService.php b/src/Services/Scheduling/ScheduleService.php index a5a34aa..631b566 100644 --- a/src/Services/Scheduling/ScheduleService.php +++ b/src/Services/Scheduling/ScheduleService.php @@ -2,12 +2,25 @@ namespace Fleetbase\Services\Scheduling; +use Carbon\Carbon; use Fleetbase\Models\Schedule; +use Fleetbase\Models\ScheduleException; use Fleetbase\Models\ScheduleItem; +use Fleetbase\Models\ScheduleTemplate; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; class ScheduleService { + /** + * The number of days ahead to materialize shifts for. + * The rolling window will always extend at least this many days from today. + */ + const MATERIALIZATION_WINDOW_DAYS = 60; + + // ─── Schedule CRUD ──────────────────────────────────────────────────────── + /** * Create a new schedule. */ @@ -51,11 +64,15 @@ public function updateSchedule(Schedule $schedule, array $data): Schedule } /** - * Delete a schedule. + * Delete a schedule and all its items. */ public function deleteSchedule(Schedule $schedule): bool { return DB::transaction(function () use ($schedule) { + $schedule->items()->delete(); + $schedule->templates()->delete(); + $schedule->exceptions()->delete(); + activity() ->performedOn($schedule) ->causedBy(auth()->user()) @@ -68,8 +85,10 @@ public function deleteSchedule(Schedule $schedule): bool }); } + // ─── ScheduleItem CRUD ──────────────────────────────────────────────────── + /** - * Create a new schedule item. + * Create a new standalone (non-recurring) schedule item. */ public function createScheduleItem(array $data): ScheduleItem { @@ -91,6 +110,7 @@ public function createScheduleItem(array $data): ScheduleItem /** * Update an existing schedule item. + * If the item was generated from a template, it will be automatically flagged as an exception. */ public function updateScheduleItem(ScheduleItem $item, array $data): ScheduleItem { @@ -155,8 +175,287 @@ public function assignScheduleItem(ScheduleItem $item, string $assigneeType, str }); } + // ─── ScheduleException CRUD ─────────────────────────────────────────────── + + /** + * Create a new schedule exception (time off request, sick leave, etc.). + */ + public function createException(array $data): ScheduleException + { + return DB::transaction(function () use ($data) { + $exception = ScheduleException::create($data); + + activity() + ->performedOn($exception) + ->causedBy(auth()->user()) + ->event('schedule_exception.created') + ->withProperties($data) + ->log('Schedule exception created'); + + return $exception; + }); + } + + /** + * Approve a schedule exception and cancel any generated ScheduleItems + * that fall within the exception's date range. + */ + public function approveException(ScheduleException $exception, ?string $reviewerUuid = null): ScheduleException + { + return DB::transaction(function () use ($exception, $reviewerUuid) { + $exception->approve($reviewerUuid); + + // Cancel any generated ScheduleItems that overlap with this exception + if ($exception->subject_uuid && $exception->start_at && $exception->end_at) { + ScheduleItem::forAssignee($exception->subject_type, $exception->subject_uuid) + ->withinTimeRange($exception->start_at, $exception->end_at) + ->where('status', '!=', 'completed') + ->update(['status' => 'cancelled']); + } + + activity() + ->performedOn($exception) + ->causedBy(auth()->user()) + ->event('schedule_exception.approved') + ->log('Schedule exception approved'); + + return $exception->fresh(); + }); + } + + /** + * Reject a schedule exception. + */ + public function rejectException(ScheduleException $exception, ?string $reviewerUuid = null): ScheduleException + { + return DB::transaction(function () use ($exception, $reviewerUuid) { + $exception->reject($reviewerUuid); + + activity() + ->performedOn($exception) + ->causedBy(auth()->user()) + ->event('schedule_exception.rejected') + ->log('Schedule exception rejected'); + + return $exception->fresh(); + }); + } + + // ─── Template Application ───────────────────────────────────────────────── + + /** + * Apply a library ScheduleTemplate to a subject's Schedule. + * + * This creates a driver-specific copy of the template linked to the schedule, + * then immediately materializes it for the rolling window. + * + * @param ScheduleTemplate $template + * @param Schedule $schedule + * + * @return ScheduleTemplate The newly created applied template + */ + public function applyTemplateToSchedule(ScheduleTemplate $template, Schedule $schedule): ScheduleTemplate + { + return DB::transaction(function () use ($template, $schedule) { + $applied = $template->applyToSchedule($schedule); + + // Immediately materialize the newly applied template + $this->materializeTemplate($applied, $schedule); + + activity() + ->performedOn($schedule) + ->causedBy(auth()->user()) + ->event('schedule_template.applied') + ->withProperties(['template_uuid' => $template->uuid]) + ->log('Schedule template applied'); + + return $applied; + }); + } + + // ─── Materialization Engine ─────────────────────────────────────────────── + + /** + * Materialize all active schedules that need their rolling window extended. + * + * This is the entry point called by the MaterializeSchedulesJob. + * It finds all schedules whose materialization_horizon is before the target date + * and materializes each one. + * + * @return array{materialized: int, skipped: int, errors: int} + */ + public function materializeAll(): array + { + $horizon = Carbon::today()->addDays(static::MATERIALIZATION_WINDOW_DAYS); + $stats = ['materialized' => 0, 'skipped' => 0, 'errors' => 0]; + + Schedule::active() + ->needsMaterialization($horizon) + ->with(['templates' => fn ($q) => $q->applied()->whereNotNull('rrule')]) + ->chunk(100, function (Collection $schedules) use ($horizon, &$stats) { + foreach ($schedules as $schedule) { + try { + $count = $this->materializeSchedule($schedule, $horizon); + if ($count > 0) { + $stats['materialized']++; + } else { + $stats['skipped']++; + } + } catch (\Throwable $e) { + $stats['errors']++; + Log::error('[ScheduleService] Materialization error for schedule ' . $schedule->uuid, [ + 'error' => $e->getMessage(), + ]); + } + } + }); + + return $stats; + } + + /** + * Materialize a single Schedule up to the given horizon date. + * + * @param Schedule $schedule + * @param Carbon|null $horizon Defaults to today + MATERIALIZATION_WINDOW_DAYS + * + * @return int Number of ScheduleItem records created + */ + public function materializeSchedule(Schedule $schedule, ?Carbon $horizon = null): int + { + $horizon = $horizon ?? Carbon::today()->addDays(static::MATERIALIZATION_WINDOW_DAYS); + $templates = $schedule->templates()->applied()->whereNotNull('rrule')->get(); + $created = 0; + + foreach ($templates as $template) { + $created += $this->materializeTemplate($template, $schedule, $horizon); + } + + // Update the materialization tracking columns + $schedule->update([ + 'last_materialized_at' => now(), + 'materialization_horizon' => $horizon->toDateString(), + ]); + + return $created; + } + + /** + * Materialize a single applied ScheduleTemplate into ScheduleItem records. + * + * The engine: + * 1. Calculates all RRULE occurrences between today and the horizon + * 2. Loads all approved exceptions for the subject in that window + * 3. Loads all existing exception-flagged ScheduleItems (manual overrides) + * 4. For each occurrence, skips if: + * - An approved ScheduleException covers that date, OR + * - A ScheduleItem with is_exception=true already exists for that date + * - A ScheduleItem already exists for that date (idempotency) + * 5. Creates a new ScheduleItem for each remaining occurrence + * + * @param ScheduleTemplate $template + * @param Schedule $schedule + * @param Carbon|null $horizon + * + * @return int Number of ScheduleItem records created + */ + public function materializeTemplate(ScheduleTemplate $template, Schedule $schedule, ?Carbon $horizon = null): int + { + if (!$template->hasRrule()) { + return 0; + } + + $horizon = $horizon ?? Carbon::today()->addDays(static::MATERIALIZATION_WINDOW_DAYS); + $from = Carbon::today(); + $timezone = $schedule->getEffectiveTimezone(); + + // Get all RRULE occurrences in the window + $occurrences = $template->getOccurrencesBetween($from, $horizon, $timezone); + + if (empty($occurrences)) { + return 0; + } + + // Load approved exceptions covering this window + $approvedExceptions = ScheduleException::forSubject($template->subject_type, $template->subject_uuid) + ->approved() + ->overlapping($from, $horizon) + ->get(); + + // Load existing ScheduleItems from this template in the window (for idempotency) + $existingItems = ScheduleItem::fromTemplate($template->uuid) + ->withinTimeRange($from, $horizon) + ->get() + ->keyBy(fn ($item) => $item->start_at->toDateString()); + + $created = 0; + + DB::transaction(function () use ( + $occurrences, $template, $schedule, $approvedExceptions, + $existingItems, $timezone, &$created + ) { + foreach ($occurrences as $occurrenceDate) { + $dateString = $occurrenceDate->toDateString(); + + // Skip if a ScheduleItem already exists for this date (idempotency) + if (isset($existingItems[$dateString])) { + continue; + } + + // Skip if an approved exception covers this date + $coveredByException = $approvedExceptions->first(function ($exception) use ($occurrenceDate) { + return $exception->start_at->lte($occurrenceDate) + && $exception->end_at->gte($occurrenceDate); + }); + + if ($coveredByException) { + continue; + } + + // Build the concrete shift start/end datetimes + $startAt = Carbon::parse($dateString . ' ' . ($template->start_time ?: '00:00'), $timezone) + ->setTimezone('UTC'); + + $endAt = $template->end_time + ? Carbon::parse($dateString . ' ' . $template->end_time, $timezone)->setTimezone('UTC') + : $startAt->copy()->addMinutes($template->duration ?: 480); // default 8h + + // Build optional break times + $breakStartAt = null; + $breakEndAt = null; + if ($template->break_duration && $template->break_duration > 0) { + // Place break at the midpoint of the shift + $shiftMidpoint = $startAt->copy()->addMinutes( + (int) ($startAt->diffInMinutes($endAt) / 2) + ); + $breakStartAt = $shiftMidpoint->copy()->subMinutes((int) ($template->break_duration / 2)); + $breakEndAt = $shiftMidpoint->copy()->addMinutes((int) ($template->break_duration / 2)); + } + + ScheduleItem::create([ + 'schedule_uuid' => $schedule->uuid, + 'template_uuid' => $template->uuid, + 'assignee_type' => $template->subject_type, + 'assignee_uuid' => $template->subject_uuid, + 'start_at' => $startAt, + 'end_at' => $endAt, + 'break_start_at' => $breakStartAt, + 'break_end_at' => $breakEndAt, + 'status' => 'pending', + 'is_exception' => false, + ]); + + $created++; + } + }); + + return $created; + } + + // ─── Query Helpers ──────────────────────────────────────────────────────── + /** - * Get schedules for a specific subject. + * Get all schedules for a specific polymorphic subject. * * @return \Illuminate\Database\Eloquent\Collection */ @@ -172,11 +471,11 @@ public function getSchedulesForSubject(string $subjectType, string $subjectUuid, $query->withinDateRange($filters['start_date'], $filters['end_date']); } - return $query->with('items')->get(); + return $query->with(['items', 'templates', 'exceptions'])->get(); } /** - * Get schedule items for a specific assignee. + * Get all schedule items for a specific polymorphic assignee. * * @return \Illuminate\Database\Eloquent\Collection */ @@ -192,6 +491,61 @@ public function getScheduleItemsForAssignee(string $assigneeType, string $assign $query->withinTimeRange($filters['start_at'], $filters['end_at']); } - return $query->with(['schedule', 'assignee', 'resource'])->get(); + return $query->with(['schedule', 'template', 'assignee', 'resource']) + ->orderBy('start_at', 'asc') + ->get(); + } + + /** + * Get the active shift for a specific assignee on a given date. + * Returns null if the assignee has no shift on that date or has an approved exception. + * + * @param string $assigneeType + * @param string $assigneeUuid + * @param Carbon $date + * + * @return ScheduleItem|null + */ + public function getActiveShiftFor(string $assigneeType, string $assigneeUuid, Carbon $date): ?ScheduleItem + { + // Check for an approved exception covering this date first + $hasException = ScheduleException::forSubject($assigneeType, $assigneeUuid) + ->approved() + ->coveringDate($date) + ->exists(); + + if ($hasException) { + return null; + } + + return ScheduleItem::forAssignee($assigneeType, $assigneeUuid) + ->onDate($date) + ->whereNotIn('status', ['cancelled', 'completed']) + ->orderBy('start_at', 'asc') + ->first(); + } + + /** + * Get all exceptions for a specific subject. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getExceptionsForSubject(string $subjectType, string $subjectUuid, array $filters = []) + { + $query = ScheduleException::forSubject($subjectType, $subjectUuid); + + if (isset($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (isset($filters['type'])) { + $query->ofType($filters['type']); + } + + if (isset($filters['start_at']) && isset($filters['end_at'])) { + $query->overlapping($filters['start_at'], $filters['end_at']); + } + + return $query->orderBy('start_at', 'asc')->get(); } } diff --git a/src/routes.php b/src/routes.php index 8c47d5f..50c1a69 100644 --- a/src/routes.php +++ b/src/routes.php @@ -312,7 +312,15 @@ function ($router, $controller) { }); $router->fleetbaseRoutes('schedules'); $router->fleetbaseRoutes('schedule-items'); - $router->fleetbaseRoutes('schedule-templates'); + $router->fleetbaseRoutes('schedule-templates', function ($router, $controller) { + $router->post('{id}/apply', $controller('apply')); + $router->post('{id}/materialize', $controller('materialize')); + }); + $router->fleetbaseRoutes('schedule-exceptions', function ($router, $controller) { + $router->post('{id}/approve', $controller('approve')); + $router->post('{id}/reject', $controller('reject')); + $router->get('for-subject', $controller('forSubject')); + }); $router->fleetbaseRoutes('schedule-availabilities'); $router->fleetbaseRoutes('schedule-constraints'); $router->fleetbaseRoutes('templates', function ($router, $controller) { From d931534c1de04adf0745ba802cd6ea7f54048063 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Sun, 5 Apr 2026 02:15:06 -0400 Subject: [PATCH 03/13] fix(schedule-templates): add missing schedule_uuid and color columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schedule_templates migration was missing two columns that the model already referenced in $fillable and applyToSchedule(): - schedule_uuid: links an applied template copy to its parent Schedule (NULL for library templates, set when applyToSchedule() is called) The absence of this column caused: SQLSTATE[42S22]: Unknown column 'schedule_uuid' in 'field list' when POST /schedule-templates/{id}/apply was called. - color: hex colour string used by the frontend calendar to render shift blocks (e.g. #6366f1). Frontend was already sending this field. Changes: - migrations/2026_04_05_000001_add_schedule_uuid_color_to_schedule_templates_table.php New migration that adds both columns to the existing table. - src/Models/ScheduleTemplate.php Added 'color' to $fillable array, applyToSchedule() copy, and @property docblock. (schedule_uuid was already in $fillable — only the column was missing.) --- ...uuid_color_to_schedule_templates_table.php | 50 +++++++++++++++++++ src/Models/ScheduleTemplate.php | 3 ++ 2 files changed, 53 insertions(+) create mode 100644 migrations/2026_04_05_000001_add_schedule_uuid_color_to_schedule_templates_table.php diff --git a/migrations/2026_04_05_000001_add_schedule_uuid_color_to_schedule_templates_table.php b/migrations/2026_04_05_000001_add_schedule_uuid_color_to_schedule_templates_table.php new file mode 100644 index 0000000..90b105e --- /dev/null +++ b/migrations/2026_04_05_000001_add_schedule_uuid_color_to_schedule_templates_table.php @@ -0,0 +1,50 @@ +string('schedule_uuid', 191) + ->nullable() + ->after('company_uuid') + ->index() + ->comment('UUID of the Schedule this template is applied to; NULL for library templates'); + + // Add color after rrule + $table->string('color', 20) + ->nullable() + ->after('rrule') + ->comment('Hex colour for calendar rendering, e.g. #6366f1'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('schedule_templates', function (Blueprint $table) { + $table->dropIndex(['schedule_uuid']); + $table->dropColumn(['schedule_uuid', 'color']); + }); + } +}; diff --git a/src/Models/ScheduleTemplate.php b/src/Models/ScheduleTemplate.php index cde8688..5f555f1 100644 --- a/src/Models/ScheduleTemplate.php +++ b/src/Models/ScheduleTemplate.php @@ -39,6 +39,7 @@ * @property int|null $duration shift duration in minutes * @property int|null $break_duration break duration in minutes * @property string|null $rrule RFC 5545 RRULE string e.g. "FREQ=WEEKLY;BYDAY=MO,TU,TH" + * @property string|null $color Hex colour for calendar rendering e.g. "#6366f1" */ class ScheduleTemplate extends Model { @@ -88,6 +89,7 @@ class ScheduleTemplate extends Model 'duration', 'break_duration', 'rrule', + 'color', 'meta', ]; @@ -305,6 +307,7 @@ public function applyToSchedule(Schedule $schedule, ?string $subjectType = null, 'duration' => $this->duration, 'break_duration' => $this->break_duration, 'rrule' => $this->rrule, + 'color' => $this->color, 'meta' => $this->meta, ]); } From f54783859ccef2e5b0092d5753f8e9db4d7087a7 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Sun, 5 Apr 2026 03:15:54 -0400 Subject: [PATCH 04/13] fix: applyTemplateToSchedule returns accurate items_created count - ScheduleService::applyTemplateToSchedule() now returns an array {template, items_created} instead of just the ScheduleTemplate model, so the apply endpoint can report the actual count from materializeTemplate() rather than a post-hoc items()->count() which could be stale or zero - ScheduleTemplateController::apply() updated to destructure the new return and pass items_created directly from the materialization result - Docblock updated to reflect the new return type signature --- .../Internal/v1/ScheduleTemplateController.php | 8 +++++--- src/Services/Scheduling/ScheduleService.php | 8 ++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/ScheduleTemplateController.php b/src/Http/Controllers/Internal/v1/ScheduleTemplateController.php index 99393fb..cb47d17 100644 --- a/src/Http/Controllers/Internal/v1/ScheduleTemplateController.php +++ b/src/Http/Controllers/Internal/v1/ScheduleTemplateController.php @@ -38,7 +38,7 @@ public function __construct(ScheduleService $scheduleService) * and immediately materializes it for the rolling 60-day window. * * POST /schedule-templates/{id}/apply - * Body: { "schedule_uuid": "..." } + * Body: { "schedule_uuid": "...", "subject_type": "driver", "subject_uuid": "...", "effective_from": "..." } */ public function apply(Request $request, string $id): JsonResponse { @@ -54,12 +54,14 @@ public function apply(Request $request, string $id): JsonResponse ->orWhere('public_id', $request->input('schedule_uuid')) ->firstOrFail(); - $applied = $this->scheduleService->applyTemplateToSchedule($template, $schedule); + // applyTemplateToSchedule now returns ['template' => $applied, 'items_created' => $count] + $result = $this->scheduleService->applyTemplateToSchedule($template, $schedule); + $applied = $result['template']; return response()->json([ 'status' => 'ok', 'schedule_template' => new \Fleetbase\Http\Resources\ScheduleTemplate($applied), - 'items_created' => $applied->items()->count(), + 'items_created' => $result['items_created'], ]); } diff --git a/src/Services/Scheduling/ScheduleService.php b/src/Services/Scheduling/ScheduleService.php index 631b566..2a5c846 100644 --- a/src/Services/Scheduling/ScheduleService.php +++ b/src/Services/Scheduling/ScheduleService.php @@ -252,15 +252,15 @@ public function rejectException(ScheduleException $exception, ?string $reviewerU * @param ScheduleTemplate $template * @param Schedule $schedule * - * @return ScheduleTemplate The newly created applied template + * @return array{template: ScheduleTemplate, items_created: int} The applied template copy and the number of ScheduleItems created */ - public function applyTemplateToSchedule(ScheduleTemplate $template, Schedule $schedule): ScheduleTemplate + public function applyTemplateToSchedule(ScheduleTemplate $template, Schedule $schedule): array { return DB::transaction(function () use ($template, $schedule) { $applied = $template->applyToSchedule($schedule); // Immediately materialize the newly applied template - $this->materializeTemplate($applied, $schedule); + $created = $this->materializeTemplate($applied, $schedule); activity() ->performedOn($schedule) @@ -269,7 +269,7 @@ public function applyTemplateToSchedule(ScheduleTemplate $template, Schedule $sc ->withProperties(['template_uuid' => $template->uuid]) ->log('Schedule template applied'); - return $applied; + return ['template' => $applied, 'items_created' => $created]; }); } From 979c485703daaf6dd050a8c9ec810dccf07f2b9f Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Sun, 5 Apr 2026 03:25:29 -0400 Subject: [PATCH 05/13] fix: surface missing php-rrule as RuntimeException instead of silently returning null Previously getRruleInstance() caught all \Exception types, which meant a 'Class RRule\RRule not found' Error (not Exception) would still bubble up but the catch block would silently return null, causing materializeTemplate() to produce 0 items with no indication of the root cause. Changes: - Add class_exists('RRule\RRule') guard that throws a clear RuntimeException with an actionable message: 'Run composer require rlanvin/php-rrule' - Narrow the catch to \RRule\RRuleException only (invalid RRULE strings) so legitimate missing-dependency errors are never swallowed - Add \Log::warning() for invalid RRULE strings to aid debugging --- src/Models/ScheduleTemplate.php | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Models/ScheduleTemplate.php b/src/Models/ScheduleTemplate.php index 5f555f1..4762086 100644 --- a/src/Models/ScheduleTemplate.php +++ b/src/Models/ScheduleTemplate.php @@ -246,9 +246,25 @@ public function getRruleInstance(?\Carbon\Carbon $referenceDate = null, ?string $rruleString = 'DTSTART=' . $dtStart->format('Ymd\THis') . "\n" . $this->rrule; + // Guard: if php-rrule is not installed the class will not exist. + // Throw a clear RuntimeException so the API returns a 500 with a + // meaningful message instead of silently materialising 0 items. + if (!class_exists('RRule\\RRule')) { + throw new \RuntimeException( + 'php-rrule is not installed. Run: composer require rlanvin/php-rrule inside the API container.' + ); + } + try { return new RRule($rruleString); - } catch (\Exception $e) { + } catch (\RRule\RRuleException $e) { + // Invalid RRULE string — log and return null so callers can skip gracefully + \Log::warning('ScheduleTemplate: invalid RRULE string', [ + 'template_uuid' => $this->uuid, + 'rrule' => $this->rrule, + 'error' => $e->getMessage(), + ]); + return null; } } From fa9fdfc41922d27da5b28ad8133776800659bcb4 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Sun, 5 Apr 2026 03:51:18 -0400 Subject: [PATCH 06/13] fix: add PolymorphicType casts, ScheduleItem/ExceptionFilter, typeLabel/isPending on ScheduleException - Add PolymorphicType cast to Schedule.subject_type, ScheduleItem.assignee_type, ScheduleTemplate.subject_type so frontend 'fleet-ops:driver' strings are stored/resolved as the full PHP class name in the database - Create ScheduleItemFilter with scheduleUuid() that resolves public_id to UUID and startAtBetween()/endAtBetween() for range queries from the frontend - Create ScheduleExceptionFilter with scheduleUuid() that resolves public_id to UUID - Add typeLabel and isPending appended attributes to ScheduleException model - Add debug logging to materializeTemplate for easier diagnosis --- src/Http/Filter/ScheduleExceptionFilter.php | 51 +++++++++++++ src/Http/Filter/ScheduleItemFilter.php | 79 +++++++++++++++++++++ src/Models/Schedule.php | 2 + src/Models/ScheduleException.php | 38 ++++++++++ src/Models/ScheduleItem.php | 2 + src/Models/ScheduleTemplate.php | 2 + src/Services/Scheduling/ScheduleService.php | 19 +++++ 7 files changed, 193 insertions(+) create mode 100644 src/Http/Filter/ScheduleExceptionFilter.php create mode 100644 src/Http/Filter/ScheduleItemFilter.php diff --git a/src/Http/Filter/ScheduleExceptionFilter.php b/src/Http/Filter/ScheduleExceptionFilter.php new file mode 100644 index 0000000..ab08f32 --- /dev/null +++ b/src/Http/Filter/ScheduleExceptionFilter.php @@ -0,0 +1,51 @@ +session->get('company'); + if ($companyUuid) { + $this->builder->whereHas('schedule', function ($q) use ($companyUuid) { + $q->where('company_uuid', $companyUuid); + }); + } + } + + public function queryForPublic() + { + $this->queryForInternal(); + } + + /** + * Filter by schedule_uuid — accepts either a raw UUID or a public_id. + * + * The frontend sends `this.schedule.id` which is the public_id + * (e.g. 'schedule_fpQgvKtGVx'). We resolve it to the internal UUID here. + */ + public function scheduleUuid(?string $id) + { + if (empty($id)) { + return; + } + + if (Str::isUuid($id)) { + $this->builder->where('schedule_uuid', $id); + } else { + // Resolve public_id to uuid via a subquery + $uuid = Schedule::where('public_id', $id)->value('uuid'); + if ($uuid) { + $this->builder->where('schedule_uuid', $uuid); + } else { + // No matching schedule — return empty result set + $this->builder->whereRaw('1 = 0'); + } + } + } +} diff --git a/src/Http/Filter/ScheduleItemFilter.php b/src/Http/Filter/ScheduleItemFilter.php new file mode 100644 index 0000000..a12cc0a --- /dev/null +++ b/src/Http/Filter/ScheduleItemFilter.php @@ -0,0 +1,79 @@ +session->get('company'); + if ($companyUuid) { + $this->builder->whereHas('schedule', function ($q) use ($companyUuid) { + $q->where('company_uuid', $companyUuid); + }); + } + } + + public function queryForPublic() + { + $this->queryForInternal(); + } + + /** + * Filter by schedule_uuid — accepts either a raw UUID or a public_id. + * + * The frontend sends `this.schedule.id` which is the public_id + * (e.g. 'schedule_fpQgvKtGVx'). We resolve it to the internal UUID here. + */ + public function scheduleUuid(?string $id) + { + if (empty($id)) { + return; + } + + if (Str::isUuid($id)) { + $this->builder->where('schedule_uuid', $id); + } else { + // Resolve public_id to uuid via a subquery + $uuid = Schedule::where('public_id', $id)->value('uuid'); + if ($uuid) { + $this->builder->where('schedule_uuid', $uuid); + } else { + // No matching schedule — return empty result set + $this->builder->whereRaw('1 = 0'); + } + } + } + + /** + * Range filter: start_at_gte / start_at_lte + * Called automatically by the base Filter range engine as startAtBetween($gte, $lte). + */ + public function startAtBetween(?string $gte, ?string $lte) + { + if ($gte) { + $this->builder->where('start_at', '>=', $gte); + } + if ($lte) { + $this->builder->where('start_at', '<=', $lte); + } + } + + /** + * Range filter: end_at_gte / end_at_lte + * Called automatically by the base Filter range engine as endAtBetween($gte, $lte). + */ + public function endAtBetween(?string $gte, ?string $lte) + { + if ($gte) { + $this->builder->where('end_at', '>=', $gte); + } + if ($lte) { + $this->builder->where('end_at', '<=', $lte); + } + } +} diff --git a/src/Models/Schedule.php b/src/Models/Schedule.php index 246f463..b79a6ce 100644 --- a/src/Models/Schedule.php +++ b/src/Models/Schedule.php @@ -3,6 +3,7 @@ namespace Fleetbase\Models; use Fleetbase\Casts\Json; +use Fleetbase\Casts\PolymorphicType; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasMetaAttributes; use Fleetbase\Traits\HasPublicId; @@ -97,6 +98,7 @@ class Schedule extends Model 'last_materialized_at' => 'datetime', 'materialization_horizon' => 'date', 'meta' => Json::class, + 'subject_type' => PolymorphicType::class, ]; /** diff --git a/src/Models/ScheduleException.php b/src/Models/ScheduleException.php index 49885c4..27df3da 100644 --- a/src/Models/ScheduleException.php +++ b/src/Models/ScheduleException.php @@ -111,6 +111,13 @@ class ScheduleException extends Model 'end_at', ]; + /** + * The accessors to append to the model's array form. + * + * @var array + */ + protected $appends = ['type_label', 'is_pending']; + /** * Valid exception types. */ @@ -237,6 +244,37 @@ public function scopeCoveringDate($query, $date) ->where('end_at', '>=', $date); } + // ─── Accessors ──────────────────────────────────────────────────────────── + + /** + * Get a human-readable label for the exception type. + * + * @return string + */ + public function getTypeLabelAttribute(): string + { + $labels = [ + 'time_off' => 'Time Off', + 'sick' => 'Sick Leave', + 'holiday' => 'Holiday', + 'swap' => 'Shift Swap', + 'training' => 'Training', + 'other' => 'Other', + ]; + + return $labels[$this->type] ?? ucfirst(str_replace('_', ' ', $this->type ?? '')); + } + + /** + * Determine whether this exception is in pending status. + * + * @return bool + */ + public function getIsPendingAttribute(): bool + { + return $this->status === 'pending'; + } + // ─── Helpers ────────────────────────────────────────────────────────────── /** diff --git a/src/Models/ScheduleItem.php b/src/Models/ScheduleItem.php index 477931c..e4ef976 100644 --- a/src/Models/ScheduleItem.php +++ b/src/Models/ScheduleItem.php @@ -3,6 +3,7 @@ namespace Fleetbase\Models; use Fleetbase\Casts\Json; +use Fleetbase\Casts\PolymorphicType; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasMetaAttributes; use Fleetbase\Traits\HasPublicId; @@ -107,6 +108,7 @@ class ScheduleItem extends Model 'duration' => 'integer', 'is_exception' => 'boolean', 'meta' => Json::class, + 'assignee_type' => PolymorphicType::class, ]; /** diff --git a/src/Models/ScheduleTemplate.php b/src/Models/ScheduleTemplate.php index 4762086..0a1c756 100644 --- a/src/Models/ScheduleTemplate.php +++ b/src/Models/ScheduleTemplate.php @@ -3,6 +3,7 @@ namespace Fleetbase\Models; use Fleetbase\Casts\Json; +use Fleetbase\Casts\PolymorphicType; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasMetaAttributes; use Fleetbase\Traits\HasPublicId; @@ -102,6 +103,7 @@ class ScheduleTemplate extends Model 'duration' => 'integer', 'break_duration' => 'integer', 'meta' => Json::class, + 'subject_type' => PolymorphicType::class, ]; /** diff --git a/src/Services/Scheduling/ScheduleService.php b/src/Services/Scheduling/ScheduleService.php index 2a5c846..33361bf 100644 --- a/src/Services/Scheduling/ScheduleService.php +++ b/src/Services/Scheduling/ScheduleService.php @@ -362,6 +362,7 @@ public function materializeSchedule(Schedule $schedule, ?Carbon $horizon = null) public function materializeTemplate(ScheduleTemplate $template, Schedule $schedule, ?Carbon $horizon = null): int { if (!$template->hasRrule()) { + Log::debug('[materializeTemplate] no rrule on template', ['template_uuid' => $template->uuid]); return 0; } @@ -369,9 +370,27 @@ public function materializeTemplate(ScheduleTemplate $template, Schedule $schedu $from = Carbon::today(); $timezone = $schedule->getEffectiveTimezone(); + Log::debug('[materializeTemplate] starting', [ + 'template_uuid' => $template->uuid, + 'rrule' => $template->rrule, + 'start_time' => $template->start_time, + 'subject_type' => $template->subject_type, + 'subject_uuid' => $template->subject_uuid, + 'from' => $from->toDateString(), + 'horizon' => $horizon->toDateString(), + 'timezone' => $timezone, + 'rrule_class_exists' => class_exists('RRule\\RRule'), + ]); + // Get all RRULE occurrences in the window $occurrences = $template->getOccurrencesBetween($from, $horizon, $timezone); + Log::debug('[materializeTemplate] occurrences', [ + 'template_uuid' => $template->uuid, + 'count' => count($occurrences), + 'first_3' => array_map(fn($c) => $c->toDateTimeString(), array_slice($occurrences, 0, 3)), + ]); + if (empty($occurrences)) { return 0; } From 181e7e295ea3c6411dba8c2a5b3fd12ed93a2265 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Sun, 5 Apr 2026 03:53:05 -0400 Subject: [PATCH 07/13] fix: ScheduleExceptionFilter uses company_uuid directly (not via schedule relationship) --- src/Http/Filter/ScheduleExceptionFilter.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Http/Filter/ScheduleExceptionFilter.php b/src/Http/Filter/ScheduleExceptionFilter.php index ab08f32..eb93a3e 100644 --- a/src/Http/Filter/ScheduleExceptionFilter.php +++ b/src/Http/Filter/ScheduleExceptionFilter.php @@ -9,12 +9,10 @@ class ScheduleExceptionFilter extends Filter { public function queryForInternal() { - // Scope to the authenticated company via the schedule relationship + // Scope to the authenticated company — schedule_exceptions has company_uuid directly $companyUuid = $this->session->get('company'); if ($companyUuid) { - $this->builder->whereHas('schedule', function ($q) use ($companyUuid) { - $q->where('company_uuid', $companyUuid); - }); + $this->builder->where('company_uuid', $companyUuid); } } From 415468deadad82d97b66e686b02eb3fef738b568 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Sun, 5 Apr 2026 04:04:41 -0400 Subject: [PATCH 08/13] fix: correct RFC 5545 RRULE string format in getRruleInstance The php-rrule library requires the RFC 5545 property format: DTSTART: (colon separator, not equals sign) RRULE: (RRULE: prefix required) Previous code was generating: DTSTART=20260405T080000 <- wrong: equals sign FREQ=WEEKLY;BYDAY=MO,TU <- wrong: missing RRULE: prefix This caused InvalidArgumentException: 'Failed to parse RFC line, missing property name followed by ":"' in RfcParser.php. Also: - Use DTSTART;TZID=: for named timezones (not UTC) - Use DTSTART:Z for UTC - Strip any existing RRULE: prefix from stored value to avoid doubling - Catch InvalidArgumentException (RFC parse errors) in addition to RRuleException --- src/Models/ScheduleTemplate.php | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/Models/ScheduleTemplate.php b/src/Models/ScheduleTemplate.php index 0a1c756..8838d7a 100644 --- a/src/Models/ScheduleTemplate.php +++ b/src/Models/ScheduleTemplate.php @@ -246,7 +246,22 @@ public function getRruleInstance(?\Carbon\Carbon $referenceDate = null, ?string $tz ); - $rruleString = 'DTSTART=' . $dtStart->format('Ymd\THis') . "\n" . $this->rrule; + // Build a valid RFC 5545 two-line string: + // DTSTART;TZID=:\nRRULE: (for named timezones) + // DTSTART:Z\nRRULE: (for UTC) + // The DTSTART property uses a colon separator, NOT an equals sign. + // The RRULE property must also carry its "RRULE:" prefix. + // Strip any existing "RRULE:" prefix from the stored value so we don't double it. + $rruleValue = preg_replace('/^RRULE:/i', '', trim($this->rrule)); + + if ($tz === 'UTC') { + $dtStartStr = 'DTSTART:' . $dtStart->format('Ymd\THis') . 'Z'; + } else { + // Named timezone — use TZID parameter syntax + $dtStartStr = 'DTSTART;TZID=' . $tz . ':' . $dtStart->format('Ymd\THis'); + } + + $rruleString = $dtStartStr . "\n" . 'RRULE:' . $rruleValue; // Guard: if php-rrule is not installed the class will not exist. // Throw a clear RuntimeException so the API returns a 500 with a @@ -259,11 +274,22 @@ public function getRruleInstance(?\Carbon\Carbon $referenceDate = null, ?string try { return new RRule($rruleString); + } catch (\InvalidArgumentException $e) { + // Catches RFC parse errors (e.g. malformed RRULE string) + \Log::warning('ScheduleTemplate: invalid RRULE string (RFC parse error)', [ + 'template_uuid' => $this->uuid, + 'rrule_raw' => $this->rrule, + 'rrule_built' => $rruleString, + 'error' => $e->getMessage(), + ]); + + return null; } catch (\RRule\RRuleException $e) { // Invalid RRULE string — log and return null so callers can skip gracefully \Log::warning('ScheduleTemplate: invalid RRULE string', [ 'template_uuid' => $this->uuid, - 'rrule' => $this->rrule, + 'rrule_raw' => $this->rrule, + 'rrule_built' => $rruleString, 'error' => $e->getMessage(), ]); From a16275caf1689e137f6bead0ffa49d43616ee376 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Sun, 5 Apr 2026 04:16:30 -0400 Subject: [PATCH 09/13] fix: add ScheduleFilter + update all schedule filter classes to resolve polymorphic type aliases The GET /schedules?subject_type=fleet-ops:driver query was returning empty because the DB stores the full PHP class name (Fleetbase\FleetOps\Models\Driver) via the PolymorphicType cast, but no filter class existed to translate the short alias 'fleet-ops:driver' into the FQCN before applying the WHERE clause. This caused loadDriverSchedule() to always create a new duplicate Schedule instead of finding the existing one, so materialized ScheduleItems were never visible in the calendar. Changes: - Add ScheduleFilter: resolves subject_type alias, filters by subject_uuid/status - Add ScheduleTemplateFilter: resolves subject_type alias, filters by subject_uuid/schedule_uuid - Update ScheduleItemFilter: add assigneeType() + assigneeUuid() methods with alias resolution - Update ScheduleExceptionFilter: add subjectType() + subjectUuid() methods with alias resolution All four filters use Utils::getMutationType() which handles: 'fleet-ops:driver' -> 'Fleetbase\FleetOps\Models\Driver' 'fleet-ops:order' -> 'Fleetbase\FleetOps\Models\Order' etc. --- src/Http/Filter/ScheduleExceptionFilter.php | 35 +++++++++ src/Http/Filter/ScheduleFilter.php | 73 +++++++++++++++++++ src/Http/Filter/ScheduleItemFilter.php | 35 +++++++++ src/Http/Filter/ScheduleTemplateFilter.php | 78 +++++++++++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 src/Http/Filter/ScheduleFilter.php create mode 100644 src/Http/Filter/ScheduleTemplateFilter.php diff --git a/src/Http/Filter/ScheduleExceptionFilter.php b/src/Http/Filter/ScheduleExceptionFilter.php index eb93a3e..4088b89 100644 --- a/src/Http/Filter/ScheduleExceptionFilter.php +++ b/src/Http/Filter/ScheduleExceptionFilter.php @@ -3,6 +3,7 @@ namespace Fleetbase\Http\Filter; use Fleetbase\Models\Schedule; +use Fleetbase\Support\Utils; use Illuminate\Support\Str; class ScheduleExceptionFilter extends Filter @@ -46,4 +47,38 @@ public function scheduleUuid(?string $id) } } } + + /** + * Filter by subject_type — resolves short aliases like 'fleet-ops:driver' + * to the full PHP class name stored in the database. + */ + public function subjectType(?string $type) + { + if (empty($type)) { + return; + } + + if (Str::contains($type, '\\')) { + $this->builder->where('subject_type', $type); + return; + } + + try { + $resolved = Utils::getMutationType($type); + $this->builder->where('subject_type', $resolved); + } catch (\Throwable $e) { + $this->builder->where('subject_type', $type); + } + } + + /** + * Filter by subject_uuid. + */ + public function subjectUuid(?string $uuid) + { + if (empty($uuid)) { + return; + } + $this->builder->where('subject_uuid', $uuid); + } } diff --git a/src/Http/Filter/ScheduleFilter.php b/src/Http/Filter/ScheduleFilter.php new file mode 100644 index 0000000..42d506a --- /dev/null +++ b/src/Http/Filter/ScheduleFilter.php @@ -0,0 +1,73 @@ +session->get('company'); + if ($companyUuid) { + $this->builder->where('company_uuid', $companyUuid); + } + } + + public function queryForPublic() + { + $this->queryForInternal(); + } + + /** + * Filter by subject_type — resolves short aliases like 'fleet-ops:driver' + * to the full PHP class name stored in the database. + * + * The frontend sends 'fleet-ops:driver' but the DB stores + * 'Fleetbase\FleetOps\Models\Driver' (via PolymorphicType cast on write). + */ + public function subjectType(?string $type) + { + if (empty($type)) { + return; + } + + // If it already looks like a fully-qualified class name, use as-is + if (Str::contains($type, '\\')) { + $this->builder->where('subject_type', $type); + return; + } + + // Resolve alias (e.g. 'fleet-ops:driver') to FQCN + try { + $resolved = Utils::getMutationType($type); + $this->builder->where('subject_type', $resolved); + } catch (\Throwable $e) { + // Fallback: filter with the raw value so we don't silently skip + $this->builder->where('subject_type', $type); + } + } + + /** + * Filter by subject_uuid. + */ + public function subjectUuid(?string $uuid) + { + if (empty($uuid)) { + return; + } + $this->builder->where('subject_uuid', $uuid); + } + + /** + * Filter by status. + */ + public function status(?string $status) + { + if (empty($status)) { + return; + } + $this->builder->where('status', $status); + } +} diff --git a/src/Http/Filter/ScheduleItemFilter.php b/src/Http/Filter/ScheduleItemFilter.php index a12cc0a..1930608 100644 --- a/src/Http/Filter/ScheduleItemFilter.php +++ b/src/Http/Filter/ScheduleItemFilter.php @@ -3,6 +3,7 @@ namespace Fleetbase\Http\Filter; use Fleetbase\Models\Schedule; +use Fleetbase\Support\Utils; use Illuminate\Support\Str; class ScheduleItemFilter extends Filter @@ -49,6 +50,40 @@ public function scheduleUuid(?string $id) } } + /** + * Filter by assignee_type — resolves short aliases like 'fleet-ops:driver' + * to the full PHP class name stored in the database. + */ + public function assigneeType(?string $type) + { + if (empty($type)) { + return; + } + + if (Str::contains($type, '\\')) { + $this->builder->where('assignee_type', $type); + return; + } + + try { + $resolved = Utils::getMutationType($type); + $this->builder->where('assignee_type', $resolved); + } catch (\Throwable $e) { + $this->builder->where('assignee_type', $type); + } + } + + /** + * Filter by assignee_uuid. + */ + public function assigneeUuid(?string $uuid) + { + if (empty($uuid)) { + return; + } + $this->builder->where('assignee_uuid', $uuid); + } + /** * Range filter: start_at_gte / start_at_lte * Called automatically by the base Filter range engine as startAtBetween($gte, $lte). diff --git a/src/Http/Filter/ScheduleTemplateFilter.php b/src/Http/Filter/ScheduleTemplateFilter.php new file mode 100644 index 0000000..635f771 --- /dev/null +++ b/src/Http/Filter/ScheduleTemplateFilter.php @@ -0,0 +1,78 @@ +session->get('company'); + if ($companyUuid) { + $this->builder->where('company_uuid', $companyUuid); + } + } + + public function queryForPublic() + { + $this->queryForInternal(); + } + + /** + * Filter by subject_type — resolves short aliases like 'fleet-ops:driver' + * to the full PHP class name stored in the database. + */ + public function subjectType(?string $type) + { + if (empty($type)) { + return; + } + + if (Str::contains($type, '\\')) { + $this->builder->where('subject_type', $type); + return; + } + + try { + $resolved = Utils::getMutationType($type); + $this->builder->where('subject_type', $resolved); + } catch (\Throwable $e) { + $this->builder->where('subject_type', $type); + } + } + + /** + * Filter by subject_uuid. + */ + public function subjectUuid(?string $uuid) + { + if (empty($uuid)) { + return; + } + $this->builder->where('subject_uuid', $uuid); + } + + /** + * Filter by schedule_uuid — accepts either a raw UUID or a public_id. + */ + public function scheduleUuid(?string $id) + { + if (empty($id)) { + return; + } + + if (Str::isUuid($id)) { + $this->builder->where('schedule_uuid', $id); + } else { + $uuid = Schedule::where('public_id', $id)->value('uuid'); + if ($uuid) { + $this->builder->where('schedule_uuid', $uuid); + } else { + $this->builder->whereRaw('1 = 0'); + } + } + } +} From 4b7ff1391c983310b530ec23ebc48d0bd2c07035 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Sun, 5 Apr 2026 04:28:40 -0400 Subject: [PATCH 10/13] fix: use 'scheduled' status for materialized items; auto-activate schedule on first item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - materializeTemplate: change default status from 'pending' to 'scheduled' for future shifts (clearer semantics — pending implies awaiting approval, scheduled means the shift is confirmed and upcoming) - applyTemplateToSchedule: activate the schedule (draft → active) after the first template is successfully applied and items are materialized - createScheduleItem: activate the parent schedule (draft → active) when the first standalone shift item is created directly --- src/Services/Scheduling/ScheduleService.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Services/Scheduling/ScheduleService.php b/src/Services/Scheduling/ScheduleService.php index 33361bf..b19342a 100644 --- a/src/Services/Scheduling/ScheduleService.php +++ b/src/Services/Scheduling/ScheduleService.php @@ -95,6 +95,13 @@ public function createScheduleItem(array $data): ScheduleItem return DB::transaction(function () use ($data) { $item = ScheduleItem::create($data); + // Activate the parent schedule if it is still in draft state + if (!empty($data['schedule_uuid'])) { + Schedule::where('uuid', $data['schedule_uuid']) + ->where('status', 'draft') + ->update(['status' => 'active']); + } + activity() ->performedOn($item) ->causedBy(auth()->user()) @@ -262,6 +269,11 @@ public function applyTemplateToSchedule(ScheduleTemplate $template, Schedule $sc // Immediately materialize the newly applied template $created = $this->materializeTemplate($applied, $schedule); + // Activate the schedule if it was still in draft state + if ($schedule->status === 'draft') { + $schedule->update(['status' => 'active']); + } + activity() ->performedOn($schedule) ->causedBy(auth()->user()) @@ -460,7 +472,7 @@ public function materializeTemplate(ScheduleTemplate $template, Schedule $schedu 'end_at' => $endAt, 'break_start_at' => $breakStartAt, 'break_end_at' => $breakEndAt, - 'status' => 'pending', + 'status' => 'scheduled', 'is_exception' => false, ]); From 0c29d1cc3b44478f8fe43f6f9f7a20ef1779a42d Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Sun, 5 Apr 2026 04:32:54 -0400 Subject: [PATCH 11/13] fix: add 'scheduled' to schedule_items status ENUM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status column was defined as ENUM('pending','confirmed','in_progress', 'completed','cancelled','no_show') — 'scheduled' was not a valid value, causing MySQL to throw SQLSTATE[01000] Data truncated when materializeTemplate() tried to insert items with status='scheduled'. Adds a new migration that: - Adds 'scheduled' to the ENUM (between 'pending' and 'confirmed') - Changes the column DEFAULT from 'pending' to 'scheduled' - Retains 'pending' for backwards compatibility The down() migration reverts 'scheduled' rows to 'pending' before shrinking the ENUM back to its original definition. --- ...heduled_status_to_schedule_items_table.php | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 migrations/2026_04_05_000001_add_scheduled_status_to_schedule_items_table.php diff --git a/migrations/2026_04_05_000001_add_scheduled_status_to_schedule_items_table.php b/migrations/2026_04_05_000001_add_scheduled_status_to_schedule_items_table.php new file mode 100644 index 0000000..9ebac7f --- /dev/null +++ b/migrations/2026_04_05_000001_add_scheduled_status_to_schedule_items_table.php @@ -0,0 +1,42 @@ + Date: Mon, 6 Apr 2026 01:58:29 -0400 Subject: [PATCH 12/13] feat: add hos_daily_limit, hos_weekly_limit, hos_source columns to schedules table - New migration adds per-schedule HOS limit columns (nullable, fallback to global defaults) - hos_source column allows future extensibility (schedule | telematics | manual) - Schedule model fillable and casts updated accordingly --- ...0001_add_hos_limits_to_schedules_table.php | 35 +++++++++++++++++++ src/Models/Schedule.php | 5 +++ 2 files changed, 40 insertions(+) create mode 100644 migrations/2026_04_06_000001_add_hos_limits_to_schedules_table.php diff --git a/migrations/2026_04_06_000001_add_hos_limits_to_schedules_table.php b/migrations/2026_04_06_000001_add_hos_limits_to_schedules_table.php new file mode 100644 index 0000000..c267c86 --- /dev/null +++ b/migrations/2026_04_06_000001_add_hos_limits_to_schedules_table.php @@ -0,0 +1,35 @@ +unsignedTinyInteger('hos_daily_limit')->nullable()->after('timezone') + ->comment('Max driving hours per day. NULL = use global default (11h).'); + $table->unsignedTinyInteger('hos_weekly_limit')->nullable()->after('hos_daily_limit') + ->comment('Max driving hours per rolling 7-day period. NULL = use global default (70h).'); + // HOS data source — extensible for future integrations + $table->string('hos_source', 50)->default('schedule')->after('hos_weekly_limit') + ->comment('Source used to calculate HOS hours: schedule | telematics | manual'); + }); + } + + public function down(): void + { + Schema::table('schedules', function (Blueprint $table) { + $table->dropColumn(['hos_daily_limit', 'hos_weekly_limit', 'hos_source']); + }); + } +}; diff --git a/src/Models/Schedule.php b/src/Models/Schedule.php index b79a6ce..47a4aa2 100644 --- a/src/Models/Schedule.php +++ b/src/Models/Schedule.php @@ -84,6 +84,9 @@ class Schedule extends Model 'status', 'last_materialized_at', 'materialization_horizon', + 'hos_daily_limit', + 'hos_weekly_limit', + 'hos_source', 'meta', ]; @@ -99,6 +102,8 @@ class Schedule extends Model 'materialization_horizon' => 'date', 'meta' => Json::class, 'subject_type' => PolymorphicType::class, + 'hos_daily_limit' => 'integer', + 'hos_weekly_limit' => 'integer', ]; /** From 8b330e7105e6ece0c4b327ca24d4115c275f5a9c Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Mon, 6 Apr 2026 02:07:25 -0400 Subject: [PATCH 13/13] feat: add company_uuid to schedule_items for direct company scoping - New migration adds company_uuid column to schedule_items table and backfills from parent schedule - ScheduleItem model: company_uuid added to fillable/filterParams, boot() auto-populates from parent schedule or session on creating - ScheduleService::materializeTemplate: explicitly sets company_uuid from schedule.company_uuid on each created item - ScheduleItemFilter::queryForInternal: uses company_uuid OR schedule join so items without a schedule_uuid (standalone shifts) are still included in company-scoped queries --- ...d_company_uuid_to_schedule_items_table.php | 31 +++++++++++++++++++ src/Http/Filter/ScheduleItemFilter.php | 11 +++++-- src/Models/ScheduleItem.php | 17 ++++++++++ src/Services/Scheduling/ScheduleService.php | 1 + 4 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 migrations/2026_04_06_000002_add_company_uuid_to_schedule_items_table.php diff --git a/migrations/2026_04_06_000002_add_company_uuid_to_schedule_items_table.php b/migrations/2026_04_06_000002_add_company_uuid_to_schedule_items_table.php new file mode 100644 index 0000000..0e1aa29 --- /dev/null +++ b/migrations/2026_04_06_000002_add_company_uuid_to_schedule_items_table.php @@ -0,0 +1,31 @@ +string('company_uuid', 191)->nullable()->index()->after('uuid'); + }); + + // Backfill from the parent schedule + DB::statement(" + UPDATE schedule_items si + JOIN schedules s ON s.uuid = si.schedule_uuid + SET si.company_uuid = s.company_uuid + WHERE si.company_uuid IS NULL + AND si.schedule_uuid IS NOT NULL + "); + } + + public function down(): void + { + Schema::table('schedule_items', function (Blueprint $table) { + $table->dropColumn('company_uuid'); + }); + } +}; diff --git a/src/Http/Filter/ScheduleItemFilter.php b/src/Http/Filter/ScheduleItemFilter.php index 1930608..4a9af2e 100644 --- a/src/Http/Filter/ScheduleItemFilter.php +++ b/src/Http/Filter/ScheduleItemFilter.php @@ -10,11 +10,16 @@ class ScheduleItemFilter extends Filter { public function queryForInternal() { - // Scope to the authenticated company via the schedule relationship + // Scope to the authenticated company. + // Prefer the direct company_uuid column (populated since 2026_04_06 migration); + // fall back to the schedule join for older rows that pre-date the column. $companyUuid = $this->session->get('company'); if ($companyUuid) { - $this->builder->whereHas('schedule', function ($q) use ($companyUuid) { - $q->where('company_uuid', $companyUuid); + $this->builder->where(function ($q) use ($companyUuid) { + $q->where('company_uuid', $companyUuid) + ->orWhereHas('schedule', function ($sq) use ($companyUuid) { + $sq->where('company_uuid', $companyUuid); + }); }); } } diff --git a/src/Models/ScheduleItem.php b/src/Models/ScheduleItem.php index e4ef976..829250d 100644 --- a/src/Models/ScheduleItem.php +++ b/src/Models/ScheduleItem.php @@ -78,6 +78,7 @@ class ScheduleItem extends Model */ protected $fillable = [ 'public_id', + 'company_uuid', 'schedule_uuid', 'template_uuid', 'assignee_uuid', @@ -117,6 +118,7 @@ class ScheduleItem extends Model * @var array */ protected $filterParams = [ + 'company_uuid', 'schedule_uuid', 'template_uuid', 'assignee_type', @@ -339,6 +341,21 @@ protected static function boot() { parent::boot(); + // Auto-populate company_uuid from session or parent schedule + static::creating(function ($item) { + if (empty($item->company_uuid)) { + if ($item->schedule_uuid) { + $schedule = \Fleetbase\Models\Schedule::where('uuid', $item->schedule_uuid)->first(); + if ($schedule) { + $item->company_uuid = $schedule->company_uuid; + } + } + if (empty($item->company_uuid)) { + $item->company_uuid = session('company'); + } + } + }); + // Auto-calculate duration on save if not explicitly provided static::saving(function ($item) { if (!$item->duration && $item->start_at && $item->end_at) { diff --git a/src/Services/Scheduling/ScheduleService.php b/src/Services/Scheduling/ScheduleService.php index b19342a..b5c7271 100644 --- a/src/Services/Scheduling/ScheduleService.php +++ b/src/Services/Scheduling/ScheduleService.php @@ -464,6 +464,7 @@ public function materializeTemplate(ScheduleTemplate $template, Schedule $schedu } ScheduleItem::create([ + 'company_uuid' => $schedule->company_uuid, 'schedule_uuid' => $schedule->uuid, 'template_uuid' => $template->uuid, 'assignee_type' => $template->subject_type,