From 9ae00ebacde02695df70f19abfe237b0db893546 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 26 Feb 2026 12:40:30 +0700 Subject: [PATCH] feat: Implement a feed scheduling and delivery system with database migrations, models, Filament resources, and an automated processing command. --- app/Console/Commands/ProcessFeedSchedule.php | 50 +++++++ .../FeedDeliveries/FeedDeliveryResource.php | 105 ++++++++++++++ .../Pages/ManageFeedDeliveries.php | 13 ++ .../FeedSchedules/FeedScheduleResource.php | 136 ++++++++++++++++++ .../Pages/ManageFeedSchedules.php | 31 ++++ app/Models/Feed.php | 10 ++ app/Models/FeedDelivery.php | 31 ++++ app/Models/FeedSchedule.php | 33 +++++ database/factories/FeedDeliveryFactory.php | 23 +++ database/factories/FeedScheduleFactory.php | 22 +++ ..._26_000001_create_feed_schedules_table.php | 32 +++++ ...26_000002_create_feed_deliveries_table.php | 31 ++++ database/seeders/ShieldSeeder.php | 40 ++++++ routes/console.php | 7 +- 14 files changed, 559 insertions(+), 5 deletions(-) create mode 100644 app/Console/Commands/ProcessFeedSchedule.php create mode 100644 app/Filament/Resources/Manage/FeedDeliveries/FeedDeliveryResource.php create mode 100644 app/Filament/Resources/Manage/FeedDeliveries/Pages/ManageFeedDeliveries.php create mode 100644 app/Filament/Resources/Manage/FeedSchedules/FeedScheduleResource.php create mode 100644 app/Filament/Resources/Manage/FeedSchedules/Pages/ManageFeedSchedules.php create mode 100644 app/Models/FeedDelivery.php create mode 100644 app/Models/FeedSchedule.php create mode 100644 database/factories/FeedDeliveryFactory.php create mode 100644 database/factories/FeedScheduleFactory.php create mode 100644 database/migrations/2026_02_26_000001_create_feed_schedules_table.php create mode 100644 database/migrations/2026_02_26_000002_create_feed_deliveries_table.php diff --git a/app/Console/Commands/ProcessFeedSchedule.php b/app/Console/Commands/ProcessFeedSchedule.php new file mode 100644 index 0000000..adff12b --- /dev/null +++ b/app/Console/Commands/ProcessFeedSchedule.php @@ -0,0 +1,50 @@ +feed; + if ($feed) { + $decrement = min($schedule->quantity, $feed->stock); + $feed->decrement('stock', $decrement); + } + + FeedDelivery::create([ + 'feed_schedule_id' => $schedule->id, + 'feed_id' => $schedule->feed_id, + 'quantity' => $schedule->quantity, + 'delivered_at' => $now, + ]); + + $schedule->save(); + } + + return 0; + } +} diff --git a/app/Filament/Resources/Manage/FeedDeliveries/FeedDeliveryResource.php b/app/Filament/Resources/Manage/FeedDeliveries/FeedDeliveryResource.php new file mode 100644 index 0000000..3ffc775 --- /dev/null +++ b/app/Filament/Resources/Manage/FeedDeliveries/FeedDeliveryResource.php @@ -0,0 +1,105 @@ +columns([ + TextColumn::make('feed.name') + ->label('Pakan') + ->sortable(), + + TextColumn::make('quantity') + ->label('Qty') + ->numeric(), + + TextColumn::make('delivered_at') + ->label('Tanggal Disalurkan') + ->dateTime('d M Y H:i') + ->sortable(), + + TextColumn::make('schedule.id') + ->label('Jadwal ID') + ->sortable(), + + ...TimestampColumns::make(), + ]) + ->filters([ + TrashedFilter::make() + ->native(false) + ->visible(fn (): bool => auth()->user()->hasRole(RoleEnum::DEVELOPER)), + ]) + ->recordActions([ + EditAction::make() + ->modalWidth(Width::Large), + + DeleteAction::make(), + + ForceDeleteAction::make(), + + RestoreAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + ...DefaultBulkActions::make('Jadwal Pakan'), + ]), + ]) + ->emptyStateIcon(Heroicon::Clock) + ->emptyStateDescription('Setelah Anda membuat data pertama, maka akan muncul disini.') + ->defaultSort('created_at', 'desc') + ->deferFilters(false); + } + + public static function getPages(): array + { + return [ + 'index' => ManageFeedDeliveries::route('/'), + ]; + } + + public static function getRecordRouteBindingEloquentQuery(): Builder + { + return parent::getRecordRouteBindingEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } +} diff --git a/app/Filament/Resources/Manage/FeedDeliveries/Pages/ManageFeedDeliveries.php b/app/Filament/Resources/Manage/FeedDeliveries/Pages/ManageFeedDeliveries.php new file mode 100644 index 0000000..8a50cf3 --- /dev/null +++ b/app/Filament/Resources/Manage/FeedDeliveries/Pages/ManageFeedDeliveries.php @@ -0,0 +1,13 @@ +components([ + Select::make('feed_id') + ->label('Pakan') + ->relationship('feed', 'name') + ->required() + ->searchable() + ->preload(), + + TimePickerField::make('scheduled_at') + ->label('Jadwal') + ->placeholder('Pilih Jadwal') + ->okLabel('Konfirmasi') + ->cancelLabel('Batal') + ->required(), + + TextInput::make('quantity') + ->label('Kuantitas') + ->placeholder('0') + ->autocomplete(false) + ->required() + ->currencyMask(thousandSeparator: '.', decimalSeparator: ',', precision: 0) + ->dehydrateStateUsing(fn ($state) => (int) str()->replace(',', '', (string) ($state ?? 0))) + ->helperText('Satuan kuantitas pakan sesuai dengan satuan yang ada di menu pakan.'), + ]) + ->columns(1); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('feed.name') + ->label('Pakan') + ->sortable(), + + TextColumn::make('quantity') + ->label('Kuantitas') + ->numeric(decimalPlaces: 0) + ->suffix(fn (FeedSchedule $record): string => ' '.$record->feed?->unit?->alias), + + TextColumn::make('scheduled_at') + ->label('Jadwal') + ->dateTime('H:i') + ->sortable(), + + ...TimestampColumns::make(), + ]) + ->filters([ + TrashedFilter::make() + ->native(false) + ->visible(fn (): bool => auth()->user()->hasRole(RoleEnum::DEVELOPER)), + ]) + ->recordActions([ + EditAction::make() + ->modalWidth(Width::Large), + + DeleteAction::make(), + + ForceDeleteAction::make(), + + RestoreAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + ...DefaultBulkActions::make('Jadwal Pakan'), + ]), + ]) + ->emptyStateIcon(Heroicon::Clock) + ->emptyStateDescription('Setelah Anda membuat data pertama, maka akan muncul disini.') + ->defaultSort('created_at', 'desc') + ->deferFilters(false); + } + + public static function getPages(): array + { + return [ + 'index' => ManageFeedSchedules::route('/'), + ]; + } + + public static function getRecordRouteBindingEloquentQuery(): Builder + { + return parent::getRecordRouteBindingEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } +} diff --git a/app/Filament/Resources/Manage/FeedSchedules/Pages/ManageFeedSchedules.php b/app/Filament/Resources/Manage/FeedSchedules/Pages/ManageFeedSchedules.php new file mode 100644 index 0000000..7117976 --- /dev/null +++ b/app/Filament/Resources/Manage/FeedSchedules/Pages/ManageFeedSchedules.php @@ -0,0 +1,31 @@ +label('Tambah') + ->modalHeading('Tambah Jadwal Pakan') + ->modalSubmitActionLabel('Simpan') + ->modalCancelActionLabel('Batal') + ->extraModalFooterActions(fn (CreateAction $action): array => [ + $action->makeModalSubmitAction('createAnother', arguments: ['another' => true]) + ->label('Simpan dan Tambah Lagi'), + ]) + ->modalWidth(Width::Large), + ]; + } +} diff --git a/app/Models/Feed.php b/app/Models/Feed.php index 1ef815f..1432a27 100644 --- a/app/Models/Feed.php +++ b/app/Models/Feed.php @@ -49,4 +49,14 @@ public function purchases(): HasMany { return $this->hasMany(FeedPurchase::class); } + + public function schedules(): HasMany + { + return $this->hasMany(FeedSchedule::class); + } + + public function deliveries(): HasMany + { + return $this->hasMany(FeedDelivery::class); + } } diff --git a/app/Models/FeedDelivery.php b/app/Models/FeedDelivery.php new file mode 100644 index 0000000..4cc0df0 --- /dev/null +++ b/app/Models/FeedDelivery.php @@ -0,0 +1,31 @@ + 'datetime', + ]; + } + + public function schedule(): BelongsTo + { + return $this->belongsTo(FeedSchedule::class); + } + + public function feed(): BelongsTo + { + return $this->belongsTo(Feed::class); + } +} diff --git a/app/Models/FeedSchedule.php b/app/Models/FeedSchedule.php new file mode 100644 index 0000000..bb194ba --- /dev/null +++ b/app/Models/FeedSchedule.php @@ -0,0 +1,33 @@ + 'datetime:H:i', + ]; + } + + public function feed(): BelongsTo + { + return $this->belongsTo(Feed::class); + } + + public function deliveries(): HasMany + { + return $this->hasMany(FeedDelivery::class); + } +} diff --git a/database/factories/FeedDeliveryFactory.php b/database/factories/FeedDeliveryFactory.php new file mode 100644 index 0000000..a8d705b --- /dev/null +++ b/database/factories/FeedDeliveryFactory.php @@ -0,0 +1,23 @@ + FeedSchedule::factory(), + 'feed_id' => Feed::factory(), + 'quantity' => $this->faker->numberBetween(1, 50), + 'delivered_at' => $this->faker->dateTimeBetween('-1 week', 'now'), + ]; + } +} diff --git a/database/factories/FeedScheduleFactory.php b/database/factories/FeedScheduleFactory.php new file mode 100644 index 0000000..4e80bc9 --- /dev/null +++ b/database/factories/FeedScheduleFactory.php @@ -0,0 +1,22 @@ + Feed::factory(), + 'quantity' => $this->faker->numberBetween(1, 50), + 'scheduled_at' => $this->faker->dateTimeBetween('-1 week', '+1 week'), + 'executed_at' => null, + ]; + } +} diff --git a/database/migrations/2026_02_26_000001_create_feed_schedules_table.php b/database/migrations/2026_02_26_000001_create_feed_schedules_table.php new file mode 100644 index 0000000..78513bf --- /dev/null +++ b/database/migrations/2026_02_26_000001_create_feed_schedules_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('feed_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('quantity'); + $table->time('scheduled_at'); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('feed_schedules'); + } +}; diff --git a/database/migrations/2026_02_26_000002_create_feed_deliveries_table.php b/database/migrations/2026_02_26_000002_create_feed_deliveries_table.php new file mode 100644 index 0000000..fffed78 --- /dev/null +++ b/database/migrations/2026_02_26_000002_create_feed_deliveries_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('feed_schedule_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('feed_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('quantity'); + $table->timestamp('delivered_at')->useCurrent(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('feed_deliveries'); + } +}; diff --git a/database/seeders/ShieldSeeder.php b/database/seeders/ShieldSeeder.php index 3a1f285..28f9017 100644 --- a/database/seeders/ShieldSeeder.php +++ b/database/seeders/ShieldSeeder.php @@ -100,6 +100,30 @@ public function run(): void "Replicate:FeedPurchase", "Reorder:FeedPurchase", + "ViewAny:FeedSchedule", + "View:FeedSchedule", + "Create:FeedSchedule", + "Update:FeedSchedule", + "Delete:FeedSchedule", + "Restore:FeedSchedule", + "ForceDelete:FeedSchedule", + "ForceDeleteAny:FeedSchedule", + "RestoreAny:FeedSchedule", + "Replicate:FeedSchedule", + "Reorder:FeedSchedule", + + "ViewAny:FeedDelivery", + "View:FeedDelivery", + "Create:FeedDelivery", + "Update:FeedDelivery", + "Delete:FeedDelivery", + "Restore:FeedDelivery", + "ForceDelete:FeedDelivery", + "ForceDeleteAny:FeedDelivery", + "RestoreAny:FeedDelivery", + "Replicate:FeedDelivery", + "Reorder:FeedDelivery", + "ViewAny:Order", "View:Order", "Create:Order", @@ -207,6 +231,22 @@ public function run(): void "Replicate:FeedPurchase", "Reorder:FeedPurchase", + "ViewAny:FeedSchedule", + "View:FeedSchedule", + "Create:FeedSchedule", + "Update:FeedSchedule", + "Delete:FeedSchedule", + "Replicate:FeedSchedule", + "Reorder:FeedSchedule", + + "ViewAny:FeedDelivery", + "View:FeedDelivery", + "Create:FeedDelivery", + "Update:FeedDelivery", + "Delete:FeedDelivery", + "Replicate:FeedDelivery", + "Reorder:FeedDelivery", + "ViewAny:Order", "View:Order", "Create:Order", diff --git a/routes/console.php b/routes/console.php index 3c9adf1..7741f6d 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,5 @@ comment(Inspiring::quote()); -})->purpose('Display an inspiring quote'); +Schedule::command('feed:schedule-process')->everyMinute();