diff --git a/composer.json b/composer.json index fbb798f6..52dc9673 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", @@ -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", @@ -107,4 +108,4 @@ "@test:unit" ] } -} \ No newline at end of file +} 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 00000000..75988b7e --- /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 00000000..a619b40c --- /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 00000000..1d74407b --- /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/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 00000000..90b105ed --- /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/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 00000000..9ebac7f6 --- /dev/null +++ b/migrations/2026_04_05_000001_add_scheduled_status_to_schedule_items_table.php @@ -0,0 +1,42 @@ +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/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 00000000..0e1aa29e --- /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/Controllers/Internal/v1/ScheduleExceptionController.php b/src/Http/Controllers/Internal/v1/ScheduleExceptionController.php new file mode 100644 index 00000000..9aba1ff8 --- /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 c52dd2d3..cb47d17d 100644 --- a/src/Http/Controllers/Internal/v1/ScheduleTemplateController.php +++ b/src/Http/Controllers/Internal/v1/ScheduleTemplateController.php @@ -3,8 +3,91 @@ 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": "...", "subject_type": "driver", "subject_uuid": "...", "effective_from": "..." } + */ + 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(); + + // 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' => $result['items_created'], + ]); + } + + /** + * 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/Filter/ScheduleExceptionFilter.php b/src/Http/Filter/ScheduleExceptionFilter.php new file mode 100644 index 00000000..4088b893 --- /dev/null +++ b/src/Http/Filter/ScheduleExceptionFilter.php @@ -0,0 +1,84 @@ +session->get('company'); + if ($companyUuid) { + $this->builder->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'); + } + } + } + + /** + * 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 00000000..42d506a4 --- /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 new file mode 100644 index 00000000..4a9af2eb --- /dev/null +++ b/src/Http/Filter/ScheduleItemFilter.php @@ -0,0 +1,119 @@ +session->get('company'); + if ($companyUuid) { + $this->builder->where(function ($q) use ($companyUuid) { + $q->where('company_uuid', $companyUuid) + ->orWhereHas('schedule', function ($sq) use ($companyUuid) { + $sq->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'); + } + } + } + + /** + * 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). + */ + 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/Http/Filter/ScheduleTemplateFilter.php b/src/Http/Filter/ScheduleTemplateFilter.php new file mode 100644 index 00000000..635f7717 --- /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'); + } + } + } +} diff --git a/src/Http/Resources/ScheduleException.php b/src/Http/Resources/ScheduleException.php new file mode 100644 index 00000000..bd97610a --- /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 fe53330a..47a4aa2b 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; @@ -10,6 +11,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 +82,11 @@ class Schedule extends Model 'end_date', 'timezone', 'status', + 'last_materialized_at', + 'materialization_horizon', + 'hos_daily_limit', + 'hos_weekly_limit', + 'hos_source', 'meta', ]; @@ -65,9 +96,14 @@ 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, + 'subject_type' => PolymorphicType::class, + 'hos_daily_limit' => 'integer', + 'hos_weekly_limit' => 'integer', ]; /** @@ -77,6 +113,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 +136,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 +146,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 +182,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 +194,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 +216,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 00000000..27df3da3 --- /dev/null +++ b/src/Models/ScheduleException.php @@ -0,0 +1,327 @@ + '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', + ]; + + /** + * The accessors to append to the model's array form. + * + * @var array + */ + protected $appends = ['type_label', 'is_pending']; + + /** + * 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); + } + + // ─── 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 ────────────────────────────────────────────────────────────── + + /** + * 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 70f00a9b..829250da 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; @@ -10,6 +11,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; @@ -47,7 +78,9 @@ class ScheduleItem extends Model */ protected $fillable = [ 'public_id', + 'company_uuid', 'schedule_uuid', + 'template_uuid', 'assignee_uuid', 'assignee_type', 'resource_uuid', @@ -58,6 +91,8 @@ class ScheduleItem extends Model 'break_start_at', 'break_end_at', 'status', + 'is_exception', + 'exception_for_date', 'meta', ]; @@ -72,7 +107,9 @@ class ScheduleItem extends Model 'break_start_at' => 'datetime', 'break_end_at' => 'datetime', 'duration' => 'integer', + 'is_exception' => 'boolean', 'meta' => Json::class, + 'assignee_type' => PolymorphicType::class, ]; /** @@ -81,18 +118,23 @@ class ScheduleItem extends Model * @var array */ protected $filterParams = [ + 'company_uuid', '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 +144,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 +164,7 @@ public function assignee() } /** - * Get the resource (polymorphic). + * Get the resource (polymorphic — e.g. a Vehicle). * * @return \Illuminate\Database\Eloquent\Relations\MorphTo */ @@ -121,8 +173,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 +190,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 + * + * @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 $startAt - * @param string $endAt + * @param string|\Carbon\Carbon $startAt + * @param string|\Carbon\Carbon $endAt * * @return \Illuminate\Database\Eloquent\Builder */ @@ -157,7 +248,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 +273,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 +289,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 +341,40 @@ 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) { $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 63417d8c..8838d7a6 100644 --- a/src/Models/ScheduleTemplate.php +++ b/src/Models/ScheduleTemplate.php @@ -3,13 +3,45 @@ namespace Fleetbase\Models; use Fleetbase\Casts\Json; +use Fleetbase\Casts\PolymorphicType; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasMetaAttributes; use Fleetbase\Traits\HasPublicId; 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" + * @property string|null $color Hex colour for calendar rendering e.g. "#6366f1" + */ class ScheduleTemplate extends Model { use HasUuid; @@ -48,6 +80,7 @@ class ScheduleTemplate extends Model protected $fillable = [ 'public_id', 'company_uuid', + 'schedule_uuid', 'subject_uuid', 'subject_type', 'name', @@ -57,6 +90,7 @@ class ScheduleTemplate extends Model 'duration', 'break_duration', 'rrule', + 'color', 'meta', ]; @@ -69,6 +103,7 @@ class ScheduleTemplate extends Model 'duration' => 'integer', 'break_duration' => 'integer', 'meta' => Json::class, + 'subject_type' => PolymorphicType::class, ]; /** @@ -76,7 +111,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 +125,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 +147,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 +196,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 +208,151 @@ 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 + ); + + // 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 + // 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 (\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_raw' => $this->rrule, + 'rrule_built' => $rruleString, + 'error' => $e->getMessage(), + ]); + + 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, + 'color' => $this->color, + 'meta' => $this->meta, + ]); + } } diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 9e3ed4b6..e3330a81 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 a5a34aa8..b5c7271a 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,14 +85,23 @@ 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 { 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()) @@ -91,6 +117,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 +182,312 @@ 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 array{template: ScheduleTemplate, items_created: int} The applied template copy and the number of ScheduleItems created + */ + 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 + $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()) + ->event('schedule_template.applied') + ->withProperties(['template_uuid' => $template->uuid]) + ->log('Schedule template applied'); + + return ['template' => $applied, 'items_created' => $created]; + }); + } + + // ─── 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()) { + Log::debug('[materializeTemplate] no rrule on template', ['template_uuid' => $template->uuid]); + return 0; + } + + $horizon = $horizon ?? Carbon::today()->addDays(static::MATERIALIZATION_WINDOW_DAYS); + $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; + } + + // 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([ + 'company_uuid' => $schedule->company_uuid, + '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' => 'scheduled', + '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 +503,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 +523,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 50b91a1a..50c1a695 100644 --- a/src/routes.php +++ b/src/routes.php @@ -312,8 +312,16 @@ function ($router, $controller) { }); $router->fleetbaseRoutes('schedules'); $router->fleetbaseRoutes('schedule-items'); - $router->fleetbaseRoutes('schedule-templates'); - $router->fleetbaseRoutes('schedule-availability'); + $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) { $router->get('context-schemas', $controller('contextSchemas'));