Add approval workflow for movement, disposal, and maintenance

This commit is contained in:
Arya Dwi Putra
2025-12-02 13:21:34 +07:00
parent 3cb60bc94e
commit d3e5398c9e
12 changed files with 512 additions and 17 deletions

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers;
use App\Models\AssetApprovalRequest;
use App\Services\Workflow\ApprovalService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class ApprovalController extends Controller
{
public function __construct(private readonly ApprovalService $approvals)
{
}
public function approve(Request $request, AssetApprovalRequest $approval): RedirectResponse
{
$this->approvals->approve($approval, $request->input('notes'));
return back()->with('success', 'Permintaan disetujui.');
}
public function reject(Request $request, AssetApprovalRequest $approval): RedirectResponse
{
$this->approvals->reject($approval, $request->input('notes'));
return back()->with('success', 'Permintaan ditolak.');
}
}

View File

@@ -4,12 +4,17 @@ namespace App\Http\Controllers;
use App\Models\Asset;
use App\Models\AssetMaintenance;
use App\Services\Asset\AssetService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AssetMaintenanceController extends Controller
{
public function __construct(private readonly AssetService $assets)
{
}
public function index(): View
{
$maintenances = AssetMaintenance::with('asset')->latest()->paginate(config('system.ui.table_page_size', 20));
@@ -26,11 +31,11 @@ class AssetMaintenanceController extends Controller
'description' => ['required', 'string', 'max:255'],
'vendor' => ['nullable', 'string', 'max:255'],
'cost' => ['nullable', 'numeric', 'min:0'],
'status' => ['required', 'string'],
'notes' => ['nullable', 'string'],
]);
AssetMaintenance::create($data);
$asset = Asset::findOrFail($data['asset_id']);
$this->assets->maintenance($asset, $data);
return back()->with('success', 'Data perawatan berhasil dicatat.');
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class AssetApprovalRequest extends Model
{
use HasFactory, HasUuids;
protected $fillable = [
'approvable_id',
'approvable_type',
'type',
'status',
'current_step',
'required_steps',
'requested_by',
'approved_by',
'approved_at',
'rejected_by',
'rejected_at',
'decision_notes',
'metadata',
];
protected $casts = [
'metadata' => 'array',
'approved_at' => 'datetime',
'rejected_at' => 'datetime',
];
public function approvable()
{
return $this->morphTo();
}
public function requester()
{
return $this->belongsTo(User::class, 'requested_by');
}
public function approver()
{
return $this->belongsTo(User::class, 'approved_by');
}
public function rejector()
{
return $this->belongsTo(User::class, 'rejected_by');
}
public function isPending(): bool
{
return $this->status === 'pending';
}
}

View File

@@ -22,11 +22,20 @@ class AssetDisposal extends Model
'reversed_at',
'reversed_by',
'reversed_notes',
'status',
'requested_by',
'approved_by',
'approved_at',
'rejected_by',
'rejected_at',
'decision_notes',
];
protected $casts = [
'disposed_at' => 'datetime',
'reversed_at' => 'datetime',
'approved_at' => 'datetime',
'rejected_at' => 'datetime',
];
public function asset()
@@ -48,4 +57,9 @@ class AssetDisposal extends Model
{
return $this->belongsTo(Department::class, 'previous_department_id');
}
public function approval()
{
return $this->morphOne(AssetApprovalRequest::class, 'approvable');
}
}

View File

@@ -18,15 +18,28 @@ class AssetMaintenance extends Model
'cost',
'status',
'notes',
'requested_by',
'approved_by',
'approved_at',
'rejected_by',
'rejected_at',
'decision_notes',
];
protected $casts = [
'performed_at' => 'datetime',
'cost' => 'decimal:2',
'approved_at' => 'datetime',
'rejected_at' => 'datetime',
];
public function asset()
{
return $this->belongsTo(Asset::class);
}
public function approval()
{
return $this->morphOne(AssetApprovalRequest::class, 'approvable');
}
}

View File

@@ -21,10 +21,19 @@ class AssetMovement extends Model
'notes',
'moved_by',
'performed_at',
'status',
'requested_by',
'approved_by',
'approved_at',
'rejected_by',
'rejected_at',
'decision_notes',
];
protected $casts = [
'performed_at' => 'datetime',
'approved_at' => 'datetime',
'rejected_at' => 'datetime',
];
public function asset()
@@ -61,4 +70,9 @@ class AssetMovement extends Model
{
return $this->belongsTo(AssetUser::class, 'to_asset_user_id');
}
public function approval()
{
return $this->morphOne(AssetApprovalRequest::class, 'approvable');
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Notifications;
use App\Models\AssetApprovalRequest;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ApprovalDecisionNotification extends Notification
{
use Queueable;
public function __construct(
private readonly AssetApprovalRequest $approval,
private readonly bool $approved,
private readonly ?string $notes = null
) {
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$status = $this->approved ? 'disetujui' : 'ditolak';
$approvable = $this->approval->approvable;
$assetCode = method_exists($approvable, 'asset') ? optional($approvable->asset)->code : null;
return (new MailMessage)
->subject("Permintaan {$this->approval->type} {$status}")
->line("Permintaan {$this->approval->type} Anda telah {$status}.")
->lineIf($assetCode, "Asset: {$assetCode}")
->lineIf($this->notes, "Catatan: {$this->notes}")
->action('Buka Dashboard', url('/dashboard/index'));
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Notifications;
use App\Models\AssetApprovalRequest;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ApprovalRequestedNotification extends Notification
{
use Queueable;
public function __construct(private readonly AssetApprovalRequest $approval)
{
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$approvable = $this->approval->approvable;
$title = ucfirst($this->approval->type) . ' membutuhkan persetujuan';
$assetCode = method_exists($approvable, 'asset') ? optional($approvable->asset)->code : null;
return (new MailMessage)
->subject($title)
->line($title)
->lineIf($assetCode, "Asset: {$assetCode}")
->line('Diminta oleh: '.optional($this->approval->requester)->name)
->line('Klik tombol di bawah untuk membuka dashboard dan meninjau.')
->action('Buka Dashboard', url('/dashboard/index'));
}
}

View File

@@ -10,6 +10,8 @@ use App\Models\AssetMovement;
use App\Models\AssetDisposal;
use App\Models\AssetAudit;
use App\Models\AssetChangelog;
use App\Models\AssetMaintenance;
use App\Services\Logging\ActivityLogger;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@@ -17,7 +19,10 @@ use SimpleSoftwareIO\QrCode\Facades\QrCode;
class AssetService
{
public function __construct(private readonly SystemSettingService $settings)
public function __construct(
private readonly SystemSettingService $settings,
private readonly ActivityLogger $logger
)
{
}
@@ -123,7 +128,9 @@ class AssetService
*/
public function move(Asset $asset, array $data): AssetMovement
{
return DB::transaction(function () use ($asset, $data) {
$requireApproval = config('system.workflow.require_approval.movement', false);
return DB::transaction(function () use ($asset, $data, $requireApproval) {
$movement = AssetMovement::create([
'asset_id' => $asset->id,
'from_location_id' => $asset->asset_location_id,
@@ -135,13 +142,23 @@ class AssetService
'notes' => $data['notes'] ?? null,
'moved_by' => auth()->id(),
'performed_at' => $data['performed_at'] ?? now(),
'status' => $requireApproval ? 'pending' : 'approved',
'requested_by' => auth()->id(),
]);
$asset->update([
'asset_location_id' => $data['to_location_id'] ?? $asset->asset_location_id,
'department_id' => $data['to_department_id'] ?? $asset->department_id,
'asset_user_id' => $data['to_asset_user_id'] ?? $asset->asset_user_id,
]);
if (!$requireApproval) {
$asset->update([
'asset_location_id' => $data['to_location_id'] ?? $asset->asset_location_id,
'department_id' => $data['to_department_id'] ?? $asset->department_id,
'asset_user_id' => $data['to_asset_user_id'] ?? $asset->asset_user_id,
]);
$movement->update([
'approved_by' => auth()->id(),
'approved_at' => now(),
]);
} else {
app(\App\Services\Workflow\ApprovalService::class)->create('movement', $movement);
}
$this->logHistory($asset, 'movement', 'Perpindahan aset', $movement->toArray());
@@ -154,23 +171,34 @@ class AssetService
*/
public function dispose(Asset $asset, array $data): AssetDisposal
{
return DB::transaction(function () use ($asset, $data) {
$requireApproval = config('system.workflow.require_approval.disposal', false);
return DB::transaction(function () use ($asset, $data, $requireApproval) {
$disposal = AssetDisposal::create([
'asset_id' => $asset->id,
'reason' => $data['reason'] ?? null,
'notes' => $data['notes'] ?? null,
'disposed_by' => auth()->id(),
'disposed_at' => $data['disposed_at'] ?? now(),
'disposed_by' => $requireApproval ? null : auth()->id(),
'disposed_at' => $requireApproval ? null : ($data['disposed_at'] ?? now()),
'previous_status_id' => $asset->asset_status_id,
'previous_location_id' => $asset->asset_location_id,
'previous_department_id' => $asset->department_id,
'status' => $requireApproval ? 'pending' : 'approved',
'requested_by' => auth()->id(),
]);
// Ubah status aset ke DISPOSED bila tersedia
$disposedStatus = AssetStatus::where('code', 'DISPOSED')->first();
$asset->update([
'asset_status_id' => $disposedStatus?->id ?? $asset->asset_status_id,
]);
if (!$requireApproval) {
$disposedStatus = AssetStatus::where('code', 'DISPOSED')->first();
$asset->update([
'asset_status_id' => $disposedStatus?->id ?? $asset->asset_status_id,
]);
$disposal->update([
'approved_by' => auth()->id(),
'approved_at' => now(),
]);
} else {
app(\App\Services\Workflow\ApprovalService::class)->create('disposal', $disposal);
}
$this->logHistory($asset, 'disposal', 'Aset didispose', $disposal->toArray());
@@ -227,4 +255,35 @@ class AssetService
return $audit;
});
}
/**
* Catat maintenance aset; jika butuh persetujuan, status pending.
*/
public function maintenance(Asset $asset, array $data): AssetMaintenance
{
$requireApproval = config('system.workflow.require_approval.maintenance', false);
return DB::transaction(function () use ($asset, $data, $requireApproval) {
$maintenance = AssetMaintenance::create([
'asset_id' => $asset->id,
'performed_at' => $data['performed_at'] ?? now(),
'description' => $data['description'] ?? null,
'vendor' => $data['vendor'] ?? null,
'cost' => $data['cost'] ?? null,
'status' => $requireApproval ? 'pending' : 'approved',
'notes' => $data['notes'] ?? null,
'requested_by' => auth()->id(),
'approved_by' => $requireApproval ? null : auth()->id(),
'approved_at' => $requireApproval ? null : now(),
]);
if ($requireApproval) {
app(\App\Services\Workflow\ApprovalService::class)->create('maintenance', $maintenance);
}
$this->logHistory($asset, 'maintenance', 'Perawatan aset', $maintenance->toArray());
return $maintenance;
});
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace App\Services\Workflow;
use App\Models\Asset;
use App\Models\AssetApprovalRequest;
use App\Models\AssetDisposal;
use App\Models\AssetMaintenance;
use App\Models\AssetMovement;
use App\Services\Logging\ActivityLogger;
use App\Models\Role;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\DB;
use App\Notifications\ApprovalRequestedNotification;
use App\Notifications\ApprovalDecisionNotification;
class ApprovalService
{
public function __construct(private readonly ActivityLogger $logger)
{
}
public function create(string $type, $model): AssetApprovalRequest
{
$approval = AssetApprovalRequest::create([
'approvable_id' => $model->getKey(),
'approvable_type' => get_class($model),
'type' => $type,
'status' => 'pending',
'requested_by' => auth()->id(),
'current_step' => 1,
'required_steps' => config("system.workflow.required_steps.{$type}", 1),
]);
$this->notifyApprovers($approval);
return $approval;
}
public function approve(AssetApprovalRequest $approval, ?string $notes = null): void
{
DB::transaction(function () use ($approval, $notes) {
if ($approval->status !== 'pending') {
return;
}
$approval->update([
'status' => 'approved',
'approved_by' => auth()->id(),
'approved_at' => now(),
'decision_notes' => $notes,
'current_step' => $approval->required_steps,
]);
$this->applyApprovedAction($approval);
$this->logger->audit([
'action' => 'approval_approved',
'model' => class_basename($approval->approvable_type),
'model_id' => $approval->approvable_id,
'changes' => ['approval_id' => $approval->id, 'notes' => $notes],
]);
$this->notifyDecision($approval, true, $notes);
});
}
public function reject(AssetApprovalRequest $approval, ?string $notes = null): void
{
if ($approval->status !== 'pending') {
return;
}
$approval->update([
'status' => 'rejected',
'rejected_by' => auth()->id(),
'rejected_at' => now(),
'decision_notes' => $notes,
]);
$this->logger->audit([
'action' => 'approval_rejected',
'model' => class_basename($approval->approvable_type),
'model_id' => $approval->approvable_id,
'changes' => ['approval_id' => $approval->id, 'notes' => $notes],
]);
$this->notifyDecision($approval, false, $notes);
}
private function applyApprovedAction(AssetApprovalRequest $approval): void
{
$approvable = $approval->approvable;
if ($approvable instanceof AssetMovement) {
$asset = $approvable->asset;
$asset->update([
'asset_location_id' => $approvable->to_location_id ?? $asset->asset_location_id,
'department_id' => $approvable->to_department_id ?? $asset->department_id,
'asset_user_id' => $approvable->to_asset_user_id ?? $asset->asset_user_id,
]);
$approvable->update([
'status' => 'approved',
'approved_by' => auth()->id(),
'approved_at' => now(),
]);
}
if ($approvable instanceof AssetDisposal) {
$disposedStatus = optional($approvable->asset)->status?->code === 'DISPOSED'
? $approvable->asset->asset_status_id
: \App\Models\AssetStatus::where('code', 'DISPOSED')->value('id');
$approvable->asset->update([
'asset_status_id' => $disposedStatus ?? $approvable->asset->asset_status_id,
]);
$approvable->update([
'status' => 'approved',
'approved_by' => auth()->id(),
'approved_at' => now(),
]);
}
if ($approvable instanceof AssetMaintenance) {
$approvable->update([
'status' => 'approved',
'approved_by' => auth()->id(),
'approved_at' => now(),
]);
}
}
private function notifyApprovers(AssetApprovalRequest $approval): void
{
$approvers = Role::where('name', 'super-admin')->first()?->users ?? collect();
if ($approvers->isEmpty()) {
return;
}
Notification::send($approvers, new ApprovalRequestedNotification($approval));
$this->logger->audit([
'action' => 'notify_approval_request',
'changes' => ['approval_id' => $approval->id],
]);
}
private function notifyDecision(AssetApprovalRequest $approval, bool $approved, ?string $notes = null): void
{
if ($approval->requested_by) {
$requester = \App\Models\User::find($approval->requested_by);
if ($requester) {
$requester->notify(new ApprovalDecisionNotification($approval, $approved, $notes));
}
}
}
}

View File

@@ -0,0 +1,72 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('asset_approval_requests', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuidMorphs('approvable');
$table->string('type'); // movement, disposal, maintenance
$table->string('status')->default('pending'); // pending, approved, rejected
$table->unsignedTinyInteger('current_step')->default(1);
$table->unsignedTinyInteger('required_steps')->default(1);
$table->uuid('requested_by')->nullable();
$table->uuid('approved_by')->nullable();
$table->timestamp('approved_at')->nullable();
$table->uuid('rejected_by')->nullable();
$table->timestamp('rejected_at')->nullable();
$table->text('decision_notes')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
});
Schema::table('asset_movements', function (Blueprint $table) {
$table->string('status')->default('approved')->after('performed_at');
$table->uuid('requested_by')->nullable()->after('status');
$table->uuid('approved_by')->nullable()->after('requested_by');
$table->timestamp('approved_at')->nullable()->after('approved_by');
$table->uuid('rejected_by')->nullable()->after('approved_at');
$table->timestamp('rejected_at')->nullable()->after('rejected_by');
$table->text('decision_notes')->nullable()->after('rejected_at');
});
Schema::table('asset_disposals', function (Blueprint $table) {
$table->string('status')->default('approved')->after('reversed_notes');
$table->uuid('requested_by')->nullable()->after('status');
$table->uuid('approved_by')->nullable()->after('requested_by');
$table->timestamp('approved_at')->nullable()->after('approved_by');
$table->uuid('rejected_by')->nullable()->after('approved_at');
$table->timestamp('rejected_at')->nullable()->after('rejected_by');
$table->text('decision_notes')->nullable()->after('rejected_at');
});
Schema::table('asset_maintenances', function (Blueprint $table) {
$table->string('status')->default('approved')->after('notes');
$table->uuid('requested_by')->nullable()->after('status');
$table->uuid('approved_by')->nullable()->after('requested_by');
$table->timestamp('approved_at')->nullable()->after('approved_by');
$table->uuid('rejected_by')->nullable()->after('approved_at');
$table->timestamp('rejected_at')->nullable()->after('rejected_by');
$table->text('decision_notes')->nullable()->after('rejected_at');
});
}
public function down(): void
{
Schema::table('asset_maintenances', function (Blueprint $table) {
$table->dropColumn(['status','requested_by','approved_by','approved_at','rejected_by','rejected_at','decision_notes']);
});
Schema::table('asset_disposals', function (Blueprint $table) {
$table->dropColumn(['status','requested_by','approved_by','approved_at','rejected_by','rejected_at','decision_notes']);
});
Schema::table('asset_movements', function (Blueprint $table) {
$table->dropColumn(['status','requested_by','approved_by','approved_at','rejected_by','rejected_at','decision_notes']);
});
Schema::dropIfExists('asset_approval_requests');
}
};

View File

@@ -101,6 +101,9 @@ Route::middleware(['auth','maintenance.readonly','session.timeout','audit.reques
Route::resource('asset-maintenances', AssetMaintenanceController::class)->only(['index','store','update','destroy'])
->middleware('permission:maintenance.manage');
Route::post('approvals/{approval}/approve', [\App\Http\Controllers\ApprovalController::class, 'approve'])->name('approvals.approve')->middleware('permission:assets.manage');
Route::post('approvals/{approval}/reject', [\App\Http\Controllers\ApprovalController::class, 'reject'])->name('approvals.reject')->middleware('permission:assets.manage');
});
Route::get('login', [AuthController::class, 'showLogin'])->name('login');