diff --git a/SCHEDULING_MODULE.md b/SCHEDULING_MODULE.md new file mode 100644 index 00000000..c8faf534 --- /dev/null +++ b/SCHEDULING_MODULE.md @@ -0,0 +1,127 @@ +# Core Scheduling Module + +This module provides a polymorphic, reusable scheduling system for the Fleetbase platform. + +## Features + +- **Polymorphic Architecture**: Schedule any entity type (drivers, vehicles, stores, warehouses, etc.) +- **Flexible Schedule Items**: Assign items to any assignee with any resource +- **Availability Management**: Track availability windows for any entity +- **Constraint System**: Pluggable constraint validation framework +- **Event-Driven**: Comprehensive event system for extensibility +- **Activity Logging**: All scheduling activities logged via Spatie Activity Log + +## Database Tables + +1. **schedules** - Master schedule records +2. **schedule_items** - Individual scheduled items/slots +3. **schedule_templates** - Reusable schedule patterns +4. **schedule_availability** - Availability tracking +5. **schedule_constraints** - Configurable scheduling rules + +## Models + +- `Schedule` - Main schedule model with polymorphic subject +- `ScheduleItem` - Schedule item with polymorphic assignee and resource +- `ScheduleTemplate` - Reusable template patterns +- `ScheduleAvailability` - Availability windows +- `ScheduleConstraint` - Constraint definitions + +## Services + +- `ScheduleService` - Core scheduling operations +- `AvailabilityService` - Availability management +- `ConstraintService` - Pluggable constraint validation + +## Events + +- `ScheduleCreated` +- `ScheduleUpdated` +- `ScheduleDeleted` +- `ScheduleItemCreated` +- `ScheduleItemUpdated` +- `ScheduleItemDeleted` +- `ScheduleItemAssigned` +- `ScheduleConstraintViolated` + +## API Endpoints + +All endpoints are available under `/int/v1/` prefix: + +- `/schedules` - Schedule CRUD operations +- `/schedule-items` - Schedule item CRUD operations +- `/schedule-templates` - Template CRUD operations +- `/schedule-availability` - Availability CRUD operations +- `/schedule-constraints` - Constraint CRUD operations + +## Extension Integration + +Extensions can integrate with the scheduling module by: + +1. **Registering Constraints**: Use `ConstraintService::register()` to add domain-specific constraints +2. **Listening to Events**: Subscribe to scheduling events to trigger extension-specific workflows +3. **Using the Meta Field**: Store extension-specific data in the `meta` JSON field + +### Example: FleetOps HOS Constraint + +```php +// In FleetOps ServiceProvider +public function boot() +{ + $constraintService = app(\Fleetbase\Services\Scheduling\ConstraintService::class); + $constraintService->register('driver', \Fleetbase\FleetOps\Constraints\HOSConstraint::class); +} +``` + +## Usage Examples + +### Creating a Schedule + +```php +$schedule = Schedule::create([ + 'company_uuid' => $company->uuid, + 'subject_type' => 'fleet', + 'subject_uuid' => $fleet->uuid, + 'name' => 'Weekly Driver Schedule', + 'start_date' => '2025-11-15', + 'end_date' => '2025-11-22', + 'timezone' => 'America/New_York', + 'status' => 'active', +]); +``` + +### Creating a Schedule Item + +```php +$item = ScheduleItem::create([ + 'schedule_uuid' => $schedule->uuid, + 'assignee_type' => 'driver', + 'assignee_uuid' => $driver->uuid, + 'resource_type' => 'vehicle', + 'resource_uuid' => $vehicle->uuid, + 'start_at' => '2025-11-15 08:00:00', + 'end_at' => '2025-11-15 17:00:00', + 'status' => 'confirmed', +]); +``` + +### Setting Availability + +```php +$availability = ScheduleAvailability::create([ + 'subject_type' => 'driver', + 'subject_uuid' => $driver->uuid, + 'start_at' => '2025-11-20 00:00:00', + 'end_at' => '2025-11-22 23:59:59', + 'is_available' => false, + 'reason' => 'vacation', +]); +``` + +## Future Enhancements + +- Optimization algorithms for automatic schedule generation +- RRULE processing for recurring patterns +- Conflict detection and resolution +- Capacity planning and load balancing +- Multi-timezone support improvements diff --git a/migrations/2025_11_14_000001_create_schedules_table.php b/migrations/2025_11_14_000001_create_schedules_table.php new file mode 100644 index 00000000..e2b4c31b --- /dev/null +++ b/migrations/2025_11_14_000001_create_schedules_table.php @@ -0,0 +1,49 @@ +increments('id'); + $table->string('_key')->nullable(); + $table->string('uuid', 191)->nullable()->index(); + $table->string('public_id', 191)->nullable()->unique()->index(); + $table->string('company_uuid', 191)->nullable()->index(); + $table->string('subject_uuid', 191)->nullable()->index(); + $table->string('subject_type')->nullable()->index(); + $table->string('name')->nullable(); + $table->text('description')->nullable(); + $table->date('start_date')->nullable(); + $table->date('end_date')->nullable(); + $table->string('timezone', 50)->default('UTC'); + $table->enum('status', ['draft', 'published', 'active', 'paused', 'archived'])->default('draft')->index(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->unique(['uuid']); + $table->index(['subject_uuid', 'subject_type', 'status']); + $table->index(['company_uuid', 'status', 'start_date', 'end_date']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('schedules'); + } +}; diff --git a/migrations/2025_11_14_000002_create_schedule_items_table.php b/migrations/2025_11_14_000002_create_schedule_items_table.php new file mode 100644 index 00000000..cb023845 --- /dev/null +++ b/migrations/2025_11_14_000002_create_schedule_items_table.php @@ -0,0 +1,53 @@ +increments('id'); + $table->string('_key')->nullable(); + $table->string('uuid', 191)->nullable()->index(); + $table->string('public_id', 191)->nullable()->unique()->index(); + $table->string('schedule_uuid', 191)->nullable()->index(); + $table->string('assignee_uuid', 191)->nullable()->index(); + $table->string('assignee_type')->nullable()->index(); + $table->string('resource_uuid', 191)->nullable()->index(); + $table->string('resource_type')->nullable()->index(); + $table->timestamp('start_at')->nullable()->index(); + $table->timestamp('end_at')->nullable()->index(); + $table->integer('duration')->nullable()->comment('Duration in minutes'); + $table->timestamp('break_start_at')->nullable(); + $table->timestamp('break_end_at')->nullable(); + $table->enum('status', ['pending', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show'])->default('pending')->index(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->unique(['uuid']); + $table->index(['schedule_uuid', 'start_at', 'end_at']); + $table->index(['assignee_uuid', 'assignee_type', 'status']); + $table->index(['resource_uuid', 'resource_type', 'start_at', 'end_at']); + $table->index(['status', 'start_at']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('schedule_items'); + } +}; diff --git a/migrations/2025_11_14_000003_create_schedule_templates_table.php b/migrations/2025_11_14_000003_create_schedule_templates_table.php new file mode 100644 index 00000000..3a4b04af --- /dev/null +++ b/migrations/2025_11_14_000003_create_schedule_templates_table.php @@ -0,0 +1,50 @@ +increments('id'); + $table->string('_key')->nullable(); + $table->string('uuid', 191)->nullable()->index(); + $table->string('public_id', 191)->nullable()->unique()->index(); + $table->string('company_uuid', 191)->nullable()->index(); + $table->string('subject_uuid', 191)->nullable()->index(); + $table->string('subject_type')->nullable()->index(); + $table->string('name')->nullable(); + $table->text('description')->nullable(); + $table->time('start_time')->nullable(); + $table->time('end_time')->nullable(); + $table->integer('duration')->nullable()->comment('Duration in minutes'); + $table->integer('break_duration')->nullable()->comment('Break duration in minutes'); + $table->text('rrule')->nullable()->comment('RFC 5545 recurrence rule'); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->unique(['uuid']); + $table->index(['subject_uuid', 'subject_type']); + $table->index(['company_uuid']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('schedule_templates'); + } +}; diff --git a/migrations/2025_11_14_000004_create_schedule_availability_table.php b/migrations/2025_11_14_000004_create_schedule_availability_table.php new file mode 100644 index 00000000..e25268cb --- /dev/null +++ b/migrations/2025_11_14_000004_create_schedule_availability_table.php @@ -0,0 +1,48 @@ +increments('id'); + $table->string('_key')->nullable(); + $table->string('uuid', 191)->nullable()->index(); + $table->string('subject_uuid', 191)->nullable()->index(); + $table->string('subject_type')->nullable()->index(); + $table->timestamp('start_at')->nullable()->index(); + $table->timestamp('end_at')->nullable()->index(); + $table->boolean('is_available')->default(true)->index(); + $table->tinyInteger('preference_level')->nullable()->comment('1-5 preference strength'); + $table->text('rrule')->nullable()->comment('RFC 5545 recurrence rule'); + $table->string('reason')->nullable(); + $table->text('notes')->nullable(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->unique(['uuid']); + $table->index(['subject_uuid', 'subject_type', 'start_at', 'end_at', 'is_available'], 'schedule_availability_composite_idx'); + $table->index(['subject_type', 'is_available', 'start_at', 'end_at']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('schedule_availability'); + } +}; diff --git a/migrations/2025_11_14_000005_create_schedule_constraints_table.php b/migrations/2025_11_14_000005_create_schedule_constraints_table.php new file mode 100644 index 00000000..03da581d --- /dev/null +++ b/migrations/2025_11_14_000005_create_schedule_constraints_table.php @@ -0,0 +1,52 @@ +increments('id'); + $table->string('_key')->nullable(); + $table->string('uuid', 191)->nullable()->index(); + $table->string('company_uuid', 191)->nullable()->index(); + $table->string('subject_uuid', 191)->nullable()->index(); + $table->string('subject_type')->nullable()->index(); + $table->string('name')->nullable(); + $table->text('description')->nullable(); + $table->string('type', 50)->nullable()->index()->comment('e.g., hos, labor, business, capacity'); + $table->string('category', 50)->nullable()->index()->comment('e.g., compliance, optimization'); + $table->string('constraint_key', 100)->nullable()->index(); + $table->text('constraint_value')->nullable(); + $table->string('jurisdiction', 50)->nullable()->comment('e.g., US-Federal, US-CA, EU'); + $table->integer('priority')->default(0)->comment('Higher = more important'); + $table->boolean('is_active')->default(true)->index(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->unique(['uuid']); + $table->index(['type', 'category', 'is_active']); + $table->index(['company_uuid', 'is_active']); + $table->index(['subject_uuid', 'subject_type', 'is_active']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('schedule_constraints'); + } +}; diff --git a/src/Events/ScheduleConstraintViolated.php b/src/Events/ScheduleConstraintViolated.php new file mode 100644 index 00000000..9a168b2c --- /dev/null +++ b/src/Events/ScheduleConstraintViolated.php @@ -0,0 +1,17 @@ +scheduleItem = $scheduleItem; + $this->violations = $violations; + } +} diff --git a/src/Events/ScheduleCreated.php b/src/Events/ScheduleCreated.php new file mode 100644 index 00000000..703ecbb9 --- /dev/null +++ b/src/Events/ScheduleCreated.php @@ -0,0 +1,34 @@ +schedule = $schedule; + } +} diff --git a/src/Events/ScheduleDeleted.php b/src/Events/ScheduleDeleted.php new file mode 100644 index 00000000..068cb7ad --- /dev/null +++ b/src/Events/ScheduleDeleted.php @@ -0,0 +1,15 @@ +schedule = $schedule; + } +} diff --git a/src/Events/ScheduleItemAssigned.php b/src/Events/ScheduleItemAssigned.php new file mode 100644 index 00000000..d870b847 --- /dev/null +++ b/src/Events/ScheduleItemAssigned.php @@ -0,0 +1,15 @@ +scheduleItem = $scheduleItem; + } +} diff --git a/src/Events/ScheduleItemCreated.php b/src/Events/ScheduleItemCreated.php new file mode 100644 index 00000000..2a1d8960 --- /dev/null +++ b/src/Events/ScheduleItemCreated.php @@ -0,0 +1,15 @@ +scheduleItem = $scheduleItem; + } +} diff --git a/src/Events/ScheduleItemDeleted.php b/src/Events/ScheduleItemDeleted.php new file mode 100644 index 00000000..a651e2a1 --- /dev/null +++ b/src/Events/ScheduleItemDeleted.php @@ -0,0 +1,15 @@ +scheduleItem = $scheduleItem; + } +} diff --git a/src/Events/ScheduleItemUpdated.php b/src/Events/ScheduleItemUpdated.php new file mode 100644 index 00000000..205314fa --- /dev/null +++ b/src/Events/ScheduleItemUpdated.php @@ -0,0 +1,15 @@ +scheduleItem = $scheduleItem; + } +} diff --git a/src/Events/ScheduleUpdated.php b/src/Events/ScheduleUpdated.php new file mode 100644 index 00000000..9c7e33bf --- /dev/null +++ b/src/Events/ScheduleUpdated.php @@ -0,0 +1,20 @@ +schedule = $schedule; + } +} diff --git a/src/Http/Controllers/Internal/v1/ScheduleAvailabilityController.php b/src/Http/Controllers/Internal/v1/ScheduleAvailabilityController.php new file mode 100644 index 00000000..3eb9cceb --- /dev/null +++ b/src/Http/Controllers/Internal/v1/ScheduleAvailabilityController.php @@ -0,0 +1,15 @@ + 'date', + 'end_date' => 'date', + 'meta' => Json::class, + ]; + + /** + * Attributes that is filterable on this model. + * + * @var array + */ + protected $filterParams = ['subject_type', 'subject_uuid', 'status', 'start_date', 'end_date']; + + /** + * Get the subject that this schedule belongs to (polymorphic). + * + * @return \Illuminate\Database\Eloquent\Relations\MorphTo + */ + public function subject() + { + return $this->morphTo(__FUNCTION__, 'subject_type', 'subject_uuid'); + } + + /** + * Get the company that owns the schedule. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function company() + { + return $this->belongsTo(Company::class, 'company_uuid'); + } + + /** + * Get the schedule items for this schedule. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function items() + { + return $this->hasMany(ScheduleItem::class, 'schedule_uuid'); + } + + /** + * Scope a query to only include schedules for a specific subject. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $type + * @param string $uuid + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeForSubject($query, $type, $uuid) + { + return $query->where('subject_type', $type)->where('subject_uuid', $uuid); + } + + /** + * Scope a query to only include active schedules. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeActive($query) + { + return $query->where('status', 'active'); + } + + /** + * Scope a query to only include schedules within a date range. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $startDate + * @param string $endDate + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeWithinDateRange($query, $startDate, $endDate) + { + return $query->where(function ($q) use ($startDate, $endDate) { + $q->whereBetween('start_date', [$startDate, $endDate]) + ->orWhereBetween('end_date', [$startDate, $endDate]) + ->orWhere(function ($q) use ($startDate, $endDate) { + $q->where('start_date', '<=', $startDate) + ->where(function ($q) use ($endDate) { + $q->where('end_date', '>=', $endDate) + ->orWhereNull('end_date'); + }); + }); + }); + } +} diff --git a/src/Models/ScheduleAvailability.php b/src/Models/ScheduleAvailability.php new file mode 100644 index 00000000..bb6022b3 --- /dev/null +++ b/src/Models/ScheduleAvailability.php @@ -0,0 +1,146 @@ + 'datetime', + 'end_at' => 'datetime', + 'is_available' => 'boolean', + 'preference_level' => 'integer', + 'meta' => Json::class, + ]; + + /** + * Attributes that is filterable on this model. + * + * @var array + */ + protected $filterParams = [ + 'subject_type', + 'subject_uuid', + 'is_available', + 'start_at', + 'end_at', + ]; + + /** + * Get the subject that this availability belongs to (polymorphic). + * + * @return \Illuminate\Database\Eloquent\Relations\MorphTo + */ + public function subject() + { + return $this->morphTo(__FUNCTION__, 'subject_type', 'subject_uuid'); + } + + /** + * Scope a query to only include availability for a specific subject. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $type + * @param string $uuid + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeForSubject($query, $type, $uuid) + { + return $query->where('subject_type', $type)->where('subject_uuid', $uuid); + } + + /** + * Scope a query to only include available periods. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeAvailable($query) + { + return $query->where('is_available', true); + } + + /** + * Scope a query to only include unavailable periods. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeUnavailable($query) + { + return $query->where('is_available', false); + } + + /** + * Scope a query to only include availability within a time range. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $startAt + * @param string $endAt + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeWithinTimeRange($query, $startAt, $endAt) + { + return $query->where(function ($q) use ($startAt, $endAt) { + $q->whereBetween('start_at', [$startAt, $endAt]) + ->orWhereBetween('end_at', [$startAt, $endAt]) + ->orWhere(function ($q) use ($startAt, $endAt) { + $q->where('start_at', '<=', $startAt) + ->where('end_at', '>=', $endAt); + }); + }); + } +} diff --git a/src/Models/ScheduleConstraint.php b/src/Models/ScheduleConstraint.php new file mode 100644 index 00000000..b1666383 --- /dev/null +++ b/src/Models/ScheduleConstraint.php @@ -0,0 +1,164 @@ + 'integer', + 'is_active' => 'boolean', + 'meta' => Json::class, + ]; + + /** + * Attributes that is filterable on this model. + * + * @var array + */ + protected $filterParams = [ + 'company_uuid', + 'subject_type', + 'subject_uuid', + 'type', + 'category', + 'is_active', + 'jurisdiction', + ]; + + /** + * Get the company that owns the constraint. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function company() + { + return $this->belongsTo(Company::class, 'company_uuid'); + } + + /** + * Get the subject that this constraint belongs to (polymorphic). + * + * @return \Illuminate\Database\Eloquent\Relations\MorphTo + */ + public function subject() + { + return $this->morphTo(__FUNCTION__, 'subject_type', 'subject_uuid'); + } + + /** + * Scope a query to only include active constraints. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope a query to only include constraints by type. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $type + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeByType($query, $type) + { + return $query->where('type', $type); + } + + /** + * Scope a query to only include constraints by category. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $category + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeByCategory($query, $category) + { + return $query->where('category', $category); + } + + /** + * Scope a query to only include constraints for a specific subject. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $type + * @param string $uuid + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeForSubject($query, $type, $uuid) + { + return $query->where('subject_type', $type)->where('subject_uuid', $uuid); + } + + /** + * Scope a query to order by priority (highest first). + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeOrderByPriority($query) + { + return $query->orderBy('priority', 'desc'); + } +} diff --git a/src/Models/ScheduleItem.php b/src/Models/ScheduleItem.php new file mode 100644 index 00000000..70f00a9b --- /dev/null +++ b/src/Models/ScheduleItem.php @@ -0,0 +1,215 @@ + 'datetime', + 'end_at' => 'datetime', + 'break_start_at' => 'datetime', + 'break_end_at' => 'datetime', + 'duration' => 'integer', + 'meta' => Json::class, + ]; + + /** + * Attributes that is filterable on this model. + * + * @var array + */ + protected $filterParams = [ + 'schedule_uuid', + 'assignee_type', + 'assignee_uuid', + 'resource_type', + 'resource_uuid', + 'status', + 'start_at', + 'end_at', + ]; + + /** + * Get the schedule that owns the item. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function schedule() + { + return $this->belongsTo(Schedule::class, 'schedule_uuid'); + } + + /** + * Get the assignee (polymorphic). + * + * @return \Illuminate\Database\Eloquent\Relations\MorphTo + */ + public function assignee() + { + return $this->morphTo(__FUNCTION__, 'assignee_type', 'assignee_uuid'); + } + + /** + * Get the resource (polymorphic). + * + * @return \Illuminate\Database\Eloquent\Relations\MorphTo + */ + public function resource() + { + return $this->morphTo(__FUNCTION__, 'resource_type', 'resource_uuid'); + } + + /** + * Scope a query to only include items for a specific assignee. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $type + * @param string $uuid + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeForAssignee($query, $type, $uuid) + { + return $query->where('assignee_type', $type)->where('assignee_uuid', $uuid); + } + + /** + * Scope a query to only include items within a time range. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $startAt + * @param string $endAt + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeWithinTimeRange($query, $startAt, $endAt) + { + return $query->where(function ($q) use ($startAt, $endAt) { + $q->whereBetween('start_at', [$startAt, $endAt]) + ->orWhereBetween('end_at', [$startAt, $endAt]) + ->orWhere(function ($q) use ($startAt, $endAt) { + $q->where('start_at', '<=', $startAt) + ->where('end_at', '>=', $endAt); + }); + }); + } + + /** + * Scope a query to only include upcoming items. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeUpcoming($query) + { + return $query->where('start_at', '>', now())->orderBy('start_at', 'asc'); + } + + /** + * Scope a query to only include items by status. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string|array $status + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeByStatus($query, $status) + { + if (is_array($status)) { + return $query->whereIn('status', $status); + } + + return $query->where('status', $status); + } + + /** + * Calculate the duration in minutes if not set. + * + * @return int + */ + public function calculateDuration() + { + if ($this->start_at && $this->end_at) { + return $this->start_at->diffInMinutes($this->end_at); + } + + return 0; + } + + /** + * Boot the model. + */ + protected static function boot() + { + parent::boot(); + + static::saving(function ($item) { + if (!$item->duration && $item->start_at && $item->end_at) { + $item->duration = $item->calculateDuration(); + } + }); + } +} diff --git a/src/Models/ScheduleTemplate.php b/src/Models/ScheduleTemplate.php new file mode 100644 index 00000000..63417d8c --- /dev/null +++ b/src/Models/ScheduleTemplate.php @@ -0,0 +1,127 @@ + 'integer', + 'break_duration' => 'integer', + 'meta' => Json::class, + ]; + + /** + * Attributes that is filterable on this model. + * + * @var array + */ + protected $filterParams = ['company_uuid', 'subject_type', 'subject_uuid']; + + /** + * Get the company that owns the template. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function company() + { + return $this->belongsTo(Company::class, 'company_uuid'); + } + + /** + * Get the subject that this template belongs to (polymorphic). + * + * @return \Illuminate\Database\Eloquent\Relations\MorphTo + */ + public function subject() + { + return $this->morphTo(__FUNCTION__, 'subject_type', 'subject_uuid'); + } + + /** + * Scope a query to only include templates for a specific company. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $companyUuid + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeForCompany($query, $companyUuid) + { + return $query->where('company_uuid', $companyUuid); + } + + /** + * Scope a query to only include templates for a specific subject. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $type + * @param string $uuid + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeForSubject($query, $type, $uuid) + { + return $query->where('subject_type', $type)->where('subject_uuid', $uuid); + } +} diff --git a/src/Services/Scheduling/AvailabilityService.php b/src/Services/Scheduling/AvailabilityService.php new file mode 100644 index 00000000..0a207523 --- /dev/null +++ b/src/Services/Scheduling/AvailabilityService.php @@ -0,0 +1,118 @@ +performedOn($availability) + ->causedBy(auth()->user()) + ->event('availability.set') + ->withProperties($data) + ->log('Availability set'); + + return $availability; + }); + } + + /** + * Check if a subject is available during a time range. + * + * @param string $subjectType + * @param string $subjectUuid + * @param string $startAt + * @param string $endAt + * + * @return bool + */ + public function checkAvailability(string $subjectType, string $subjectUuid, string $startAt, string $endAt): bool + { + // Check for any unavailability periods that overlap with the requested time range + $unavailability = ScheduleAvailability::forSubject($subjectType, $subjectUuid) + ->unavailable() + ->withinTimeRange($startAt, $endAt) + ->exists(); + + return !$unavailability; + } + + /** + * Get availability for a subject within a time range. + * + * @param string $subjectType + * @param string $subjectUuid + * @param string $startAt + * @param string $endAt + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getAvailability(string $subjectType, string $subjectUuid, string $startAt, string $endAt) + { + return ScheduleAvailability::forSubject($subjectType, $subjectUuid) + ->withinTimeRange($startAt, $endAt) + ->orderBy('start_at') + ->get(); + } + + /** + * Get available resources of a specific type within a time range. + * + * @param string $subjectType + * @param string $startAt + * @param string $endAt + * @param array $filters + * + * @return array + */ + public function getAvailableResources(string $subjectType, string $startAt, string $endAt, array $filters = []): array + { + // Get all subjects of the specified type that are unavailable during the time range + $unavailableSubjects = ScheduleAvailability::where('subject_type', $subjectType) + ->unavailable() + ->withinTimeRange($startAt, $endAt) + ->pluck('subject_uuid') + ->unique() + ->toArray(); + + // This would need to be extended based on the actual subject model + // For now, return the list of unavailable subject UUIDs + return [ + 'unavailable_subjects' => $unavailableSubjects, + ]; + } + + /** + * Delete availability. + * + * @param ScheduleAvailability $availability + * + * @return bool + */ + public function deleteAvailability(ScheduleAvailability $availability): bool + { + return DB::transaction(function () use ($availability) { + activity() + ->performedOn($availability) + ->causedBy(auth()->user()) + ->event('availability.deleted') + ->log('Availability deleted'); + + return $availability->delete(); + }); + } +} diff --git a/src/Services/Scheduling/ConstraintService.php b/src/Services/Scheduling/ConstraintService.php new file mode 100644 index 00000000..b76ebbcd --- /dev/null +++ b/src/Services/Scheduling/ConstraintService.php @@ -0,0 +1,131 @@ +constraintHandlers[$entityType])) { + $this->constraintHandlers[$entityType] = []; + } + + $this->constraintHandlers[$entityType][] = $handlerClass; + } + + /** + * Validate a schedule item against all applicable constraints. + * + * @param ScheduleItem $item + * + * @return array + */ + public function validate(ScheduleItem $item): array + { + $violations = []; + + // Get the assignee type to determine which constraint handlers to use + $assigneeType = $item->assignee_type; + + if (!$assigneeType || !isset($this->constraintHandlers[$assigneeType])) { + return $violations; + } + + // Run all registered constraint handlers for this entity type + foreach ($this->constraintHandlers[$assigneeType] as $handlerClass) { + $handler = app($handlerClass); + + if (method_exists($handler, 'validate')) { + $result = $handler->validate($item); + + if ($result && !$result->passed()) { + $violations = array_merge($violations, $result->getViolations()); + } + } + } + + // Log violations if any + if (!empty($violations)) { + activity() + ->performedOn($item) + ->causedBy(auth()->user()) + ->event('schedule.constraint_violated') + ->withProperties(['violations' => $violations]) + ->log('Schedule constraint violated'); + + event(new \Fleetbase\Events\ScheduleConstraintViolated($item, $violations)); + } + + return $violations; + } + + /** + * Get active constraints for a specific subject. + * + * @param string $subjectType + * @param string $subjectUuid + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getConstraintsForSubject(string $subjectType, string $subjectUuid) + { + return ScheduleConstraint::forSubject($subjectType, $subjectUuid) + ->active() + ->orderByPriority() + ->get(); + } + + /** + * Get active constraints by type. + * + * @param string $type + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getConstraintsByType(string $type) + { + return ScheduleConstraint::byType($type) + ->active() + ->orderByPriority() + ->get(); + } + + /** + * Check if a specific constraint is satisfied. + * + * @param ScheduleItem $item + * @param string $constraintKey + * + * @return bool + */ + public function checkConstraint(ScheduleItem $item, string $constraintKey): bool + { + $violations = $this->validate($item); + + foreach ($violations as $violation) { + if (isset($violation['constraint_key']) && $violation['constraint_key'] === $constraintKey) { + return false; + } + } + + return true; + } +} diff --git a/src/Services/Scheduling/ScheduleService.php b/src/Services/Scheduling/ScheduleService.php new file mode 100644 index 00000000..fe8d15cf --- /dev/null +++ b/src/Services/Scheduling/ScheduleService.php @@ -0,0 +1,237 @@ +performedOn($schedule) + ->causedBy(auth()->user()) + ->event('schedule.created') + ->withProperties($data) + ->log('Schedule created'); + + event(new \Fleetbase\Events\ScheduleCreated($schedule)); + + return $schedule; + }); + } + + /** + * Update an existing schedule. + * + * @param Schedule $schedule + * @param array $data + * + * @return Schedule + */ + public function updateSchedule(Schedule $schedule, array $data): Schedule + { + return DB::transaction(function () use ($schedule, $data) { + $schedule->update($data); + + activity() + ->performedOn($schedule) + ->causedBy(auth()->user()) + ->event('schedule.updated') + ->withProperties($data) + ->log('Schedule updated'); + + event(new \Fleetbase\Events\ScheduleUpdated($schedule)); + + return $schedule->fresh(); + }); + } + + /** + * Delete a schedule. + * + * @param Schedule $schedule + * + * @return bool + */ + public function deleteSchedule(Schedule $schedule): bool + { + return DB::transaction(function () use ($schedule) { + activity() + ->performedOn($schedule) + ->causedBy(auth()->user()) + ->event('schedule.deleted') + ->log('Schedule deleted'); + + event(new \Fleetbase\Events\ScheduleDeleted($schedule)); + + return $schedule->delete(); + }); + } + + /** + * Create a new schedule item. + * + * @param array $data + * + * @return ScheduleItem + */ + public function createScheduleItem(array $data): ScheduleItem + { + return DB::transaction(function () use ($data) { + $item = ScheduleItem::create($data); + + activity() + ->performedOn($item) + ->causedBy(auth()->user()) + ->event('schedule_item.created') + ->withProperties($data) + ->log('Schedule item created'); + + event(new \Fleetbase\Events\ScheduleItemCreated($item)); + + return $item; + }); + } + + /** + * Update an existing schedule item. + * + * @param ScheduleItem $item + * @param array $data + * + * @return ScheduleItem + */ + public function updateScheduleItem(ScheduleItem $item, array $data): ScheduleItem + { + return DB::transaction(function () use ($item, $data) { + $item->update($data); + + activity() + ->performedOn($item) + ->causedBy(auth()->user()) + ->event('schedule_item.updated') + ->withProperties($data) + ->log('Schedule item updated'); + + event(new \Fleetbase\Events\ScheduleItemUpdated($item)); + + return $item->fresh(); + }); + } + + /** + * Delete a schedule item. + * + * @param ScheduleItem $item + * + * @return bool + */ + public function deleteScheduleItem(ScheduleItem $item): bool + { + return DB::transaction(function () use ($item) { + activity() + ->performedOn($item) + ->causedBy(auth()->user()) + ->event('schedule_item.deleted') + ->log('Schedule item deleted'); + + event(new \Fleetbase\Events\ScheduleItemDeleted($item)); + + return $item->delete(); + }); + } + + /** + * Assign a schedule item to an assignee. + * + * @param ScheduleItem $item + * @param string $assigneeType + * @param string $assigneeUuid + * + * @return ScheduleItem + */ + public function assignScheduleItem(ScheduleItem $item, string $assigneeType, string $assigneeUuid): ScheduleItem + { + return DB::transaction(function () use ($item, $assigneeType, $assigneeUuid) { + $item->update([ + 'assignee_type' => $assigneeType, + 'assignee_uuid' => $assigneeUuid, + ]); + + activity() + ->performedOn($item) + ->causedBy(auth()->user()) + ->event('schedule_item.assigned') + ->withProperties([ + 'assignee_type' => $assigneeType, + 'assignee_uuid' => $assigneeUuid, + ]) + ->log('Schedule item assigned'); + + event(new \Fleetbase\Events\ScheduleItemAssigned($item)); + + return $item->fresh(); + }); + } + + /** + * Get schedules for a specific subject. + * + * @param string $subjectType + * @param string $subjectUuid + * @param array $filters + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getSchedulesForSubject(string $subjectType, string $subjectUuid, array $filters = []) + { + $query = Schedule::forSubject($subjectType, $subjectUuid); + + if (isset($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (isset($filters['start_date']) && isset($filters['end_date'])) { + $query->withinDateRange($filters['start_date'], $filters['end_date']); + } + + return $query->with('items')->get(); + } + + /** + * Get schedule items for a specific assignee. + * + * @param string $assigneeType + * @param string $assigneeUuid + * @param array $filters + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getScheduleItemsForAssignee(string $assigneeType, string $assigneeUuid, array $filters = []) + { + $query = ScheduleItem::forAssignee($assigneeType, $assigneeUuid); + + if (isset($filters['status'])) { + $query->byStatus($filters['status']); + } + + if (isset($filters['start_at']) && isset($filters['end_at'])) { + $query->withinTimeRange($filters['start_at'], $filters['end_at']); + } + + return $query->with(['schedule', 'assignee', 'resource'])->get(); + } +} diff --git a/src/Support/Scheduling/ConstraintResult.php b/src/Support/Scheduling/ConstraintResult.php new file mode 100644 index 00000000..bd81b0c9 --- /dev/null +++ b/src/Support/Scheduling/ConstraintResult.php @@ -0,0 +1,40 @@ +passed = $passed; + $this->violations = $violations; + } + + public static function pass(): self + { + return new self(true, []); + } + + public static function fail(array $violations): self + { + return new self(false, $violations); + } + + public function passed(): bool + { + return $this->passed; + } + + public function failed(): bool + { + return !$this->passed; + } + + public function getViolations(): array + { + return $this->violations; + } +} diff --git a/src/routes.php b/src/routes.php index 709247fb..372f2445 100644 --- a/src/routes.php +++ b/src/routes.php @@ -310,3 +310,10 @@ function ($router, $controller) { ); } ); + + // Scheduling Module Routes + $router->fleetbaseRoutes('schedules'); + $router->fleetbaseRoutes('schedule-items'); + $router->fleetbaseRoutes('schedule-templates'); + $router->fleetbaseRoutes('schedule-availability'); + $router->fleetbaseRoutes('schedule-constraints');