diff --git a/app/Domains/Accounting/ApprovalSetting/Events/ApprovalCompletedEvent.php b/app/Domains/Accounting/ApprovalSetting/Events/ApprovalCompletedEvent.php index 03cade025..47c703588 100644 --- a/app/Domains/Accounting/ApprovalSetting/Events/ApprovalCompletedEvent.php +++ b/app/Domains/Accounting/ApprovalSetting/Events/ApprovalCompletedEvent.php @@ -2,7 +2,7 @@ namespace App\Domains\Accounting\ApprovalSetting\Events; -use App\Domains\Accounting\ApprovalSetting\Models\Approval; +use App\Domains\Accounting\ApprovalSetting\Models\TraditionalApprove; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; @@ -15,13 +15,13 @@ class ApprovalCompletedEvent * Create a new event instance */ public function __construct( - public Approval $approval + public TraditionalApprove $approval ) {} /** * Get the approval instance */ - public function getApproval(): Approval + public function getApproval(): TraditionalApprove { return $this->approval; } diff --git a/app/Domains/Accounting/ApprovalSetting/Events/ApprovalRejectedEvent.php b/app/Domains/Accounting/ApprovalSetting/Events/ApprovalRejectedEvent.php new file mode 100644 index 000000000..1f026ae23 --- /dev/null +++ b/app/Domains/Accounting/ApprovalSetting/Events/ApprovalRejectedEvent.php @@ -0,0 +1,31 @@ +approval; + } +} diff --git a/app/Domains/Accounting/ApprovalSetting/Events/ApprovalSubmittedEvent.php b/app/Domains/Accounting/ApprovalSetting/Events/ApprovalSubmittedEvent.php index 18156af60..32c29b38b 100644 --- a/app/Domains/Accounting/ApprovalSetting/Events/ApprovalSubmittedEvent.php +++ b/app/Domains/Accounting/ApprovalSetting/Events/ApprovalSubmittedEvent.php @@ -2,7 +2,7 @@ namespace App\Domains\Accounting\ApprovalSetting\Events; -use App\Domains\Accounting\ApprovalSetting\Models\Approval; +use App\Domains\Accounting\ApprovalSetting\Models\TraditionalApprove; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; @@ -15,13 +15,13 @@ class ApprovalSubmittedEvent * Create a new event instance */ public function __construct( - public Approval $approval + public TraditionalApprove $approval ) {} /** * Get the approval instance */ - public function getApproval(): Approval + public function getApproval(): TraditionalApprove { return $this->approval; } diff --git a/app/Domains/Accounting/ApprovalSetting/Listeners/NotifyCreatorOfApprovalCompletion.php b/app/Domains/Accounting/ApprovalSetting/Listeners/NotifyCreatorOfApprovalCompletion.php new file mode 100644 index 000000000..531bb573f --- /dev/null +++ b/app/Domains/Accounting/ApprovalSetting/Listeners/NotifyCreatorOfApprovalCompletion.php @@ -0,0 +1,33 @@ +approval; + $creator = $approval->creator; + + if (!$creator) { + return; + } + + // Skip if the creator is the same user who performed the final action + if ($creator->id === Auth::id()) { + return; + } + + $creator->notify(new ApprovalOutcomeNotification( + $approval, + 'approved' + )); + } +} diff --git a/app/Domains/Accounting/ApprovalSetting/Listeners/NotifyCreatorOfApprovalRejection.php b/app/Domains/Accounting/ApprovalSetting/Listeners/NotifyCreatorOfApprovalRejection.php new file mode 100644 index 000000000..61711ad0c --- /dev/null +++ b/app/Domains/Accounting/ApprovalSetting/Listeners/NotifyCreatorOfApprovalRejection.php @@ -0,0 +1,35 @@ +approval; + $creator = $approval->creator; + + if (!$creator) { + return; + } + + // Skip if the creator is the same user who performed the rejection + if ($creator->id === Auth::id()) { + return; + } + + $creator->notify(new ApprovalOutcomeNotification( + $approval, + 'rejected', + $event->reason, + $event->rejectedStep?->level + )); + } +} diff --git a/app/Domains/Accounting/ApprovalSetting/Models/ApprovalAuditLog.php b/app/Domains/Accounting/ApprovalSetting/Models/ApprovalAuditLog.php new file mode 100644 index 000000000..26d6bc670 --- /dev/null +++ b/app/Domains/Accounting/ApprovalSetting/Models/ApprovalAuditLog.php @@ -0,0 +1,61 @@ + 'array', + ]; + + /** + * The entity being approved + */ + public function itemable(): MorphTo + { + return $this->morphTo(); + } + + /** + * The approval instance + */ + public function approval(): BelongsTo + { + return $this->belongsTo(TraditionalApprove::class, 'approval_id'); + } + + /** + * The approval step (nullable) + */ + public function step(): BelongsTo + { + return $this->belongsTo(ApprovalRecord::class, 'approval_record_id'); + } + + /** + * The user who performed the action + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Domains/Accounting/ApprovalSetting/Models/TraditionalApprove.php b/app/Domains/Accounting/ApprovalSetting/Models/TraditionalApprove.php index cccb7c75a..e2925b0ff 100644 --- a/app/Domains/Accounting/ApprovalSetting/Models/TraditionalApprove.php +++ b/app/Domains/Accounting/ApprovalSetting/Models/TraditionalApprove.php @@ -70,7 +70,7 @@ public function approvalRecords() public function creator() { - return $this->belongsTo(User::class); + return $this->belongsTo(User::class, 'created_by'); } public function comments() diff --git a/app/Domains/Accounting/ApprovalSetting/Notifications/ApprovalOutcomeNotification.php b/app/Domains/Accounting/ApprovalSetting/Notifications/ApprovalOutcomeNotification.php new file mode 100644 index 000000000..21ecf3c07 --- /dev/null +++ b/app/Domains/Accounting/ApprovalSetting/Notifications/ApprovalOutcomeNotification.php @@ -0,0 +1,68 @@ + + */ + public function via(object $notifiable): array + { + return ['database']; + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + $entityName = class_basename($this->approval->itemable_type); + $outcome = $this->status === 'approved' ? __('Approved') : __('Rejected'); + + $message = __(':entity request #:id has been :outcome.', [ + 'entity' => $entityName, + 'id' => $this->approval->itemable_id, + 'outcome' => strtolower($outcome), + ]); + + if ($this->status === 'rejected' && $this->rejectionReason) { + $message .= ' ' . __('Reason: :reason (Level :level)', [ + 'reason' => $this->rejectionReason, + 'level' => $this->rejectionLevel, + ]); + } + + return [ + 'approval_id' => $this->approval->id, + 'itemable_type' => $this->approval->itemable_type, + 'itemable_id' => $this->approval->itemable_id, + 'status' => $this->status, + 'message' => $message, + 'rejection_reason' => $this->rejectionReason, + 'rejection_level' => $this->rejectionLevel, + 'decision_at' => now()->toDateTimeString(), + ]; + } +} diff --git a/app/Domains/Accounting/ApprovalSetting/Services/ApprovalActionHandler.php b/app/Domains/Accounting/ApprovalSetting/Services/ApprovalActionHandler.php index 6af5956a4..ba00190b7 100644 --- a/app/Domains/Accounting/ApprovalSetting/Services/ApprovalActionHandler.php +++ b/app/Domains/Accounting/ApprovalSetting/Services/ApprovalActionHandler.php @@ -8,7 +8,16 @@ use App\Domains\Accounting\ApprovalSetting\Models\TraditionalApprove; use App\Domains\Accounting\ApprovalSetting\Models\ApprovalAction; use App\Domains\Accounting\ApprovalSetting\Models\ApprovalRecord; +use App\Domains\Accounting\ApprovalSetting\Events\ApprovalCompletedEvent; +use App\Domains\Accounting\ApprovalSetting\Events\ApprovalRejectedEvent; use App\Domains\Accounting\ApprovalSetting\Services\ApprovalRecordService; +use App\Domains\Accounting\Entities\Models\AccountingTransaction; +use App\Domains\Accounting\Entities\Models\InventoryTransaction; +use App\Domains\Accounting\InvoiceManagement\Models\InvoiceManagement; +use App\Domains\Accounting\ChartOfAccount\Enums\AccountingTransactionStatusEnum; +use App\Domains\Accounting\InvoiceManagement\Enums\InvoiceStatusEnum; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; /** * Handles approval action execution and status updates @@ -17,7 +26,8 @@ class ApprovalActionHandler { public function __construct( - private ApprovalRecordService $recordService + private ApprovalRecordService $recordService, + private ApprovalAuditService $auditService ) {} /** @@ -146,6 +156,13 @@ public function recordApprovalAction( 'approval_path' => $setting->approval_path, ]); + // Log to audit trail + if ($action === 'approve') { + $this->auditService->logStepApproved($approval, $currentRecord, $approver, $data['notes'] ?? null); + } elseif ($action === 'reject') { + $this->auditService->logStepRejected($approval, $currentRecord, $approver, $data['reason'] ?? 'Rejected'); + } + return [ 'action' => $approvalAction, 'recordApprover' => $recordApprover, @@ -180,6 +197,12 @@ public function handleApproval(TraditionalApprove $approval): void 'itemable_type' => $approval->itemable_type, 'itemable_id' => $approval->itemable_id, ]); + + // Log completion to audit trail + $this->auditService->logApprovalCompleted($approval); + + // Dispatch completion event + ApprovalCompletedEvent::dispatch($approval); } /** @@ -192,20 +215,49 @@ public function handleRejection(TraditionalApprove $approval, ?string $reason = 'rejected_at' => now(), ]); + // Mark remaining steps as skipped + ApprovalRecord::where('approval_id', $approval->id) + ->where('level', '>', $approval->current_level) + ->update(['status' => 'skipped']); + + if ($reason) { + $rejectedRecord = $approval->records() + ->where('level', $approval->current_level) + ->first(); + + if ($rejectedRecord) { + $rejectedRecord->update([ + 'rejection_reason' => $reason, + 'status' => 'rejected' + ]); + } + } + + // Finalize entity state and cleanup transactions + $this->finalizeEntityRejection($approval); + // Sync rejection reason to the itemable if it has the column $model = $approval->itemable; - if ($model && \Illuminate\Support\Facades\Schema::hasColumn($model->getTable(), 'rejection_reason')) { + if ($model && Schema::hasColumn($model->getTable(), 'rejection_reason')) { $model->update(['rejection_reason' => $reason]); } - // Update the related model if it has approval status - $this->updateRelatedModelStatus($approval, StatusEnum::REJECTED->value); - Log::info("Approval rejected", [ 'approval_id' => $approval->id, 'itemable_type' => $approval->itemable_type, 'itemable_id' => $approval->itemable_id, ]); + + // Log rejection to audit trail + $this->auditService->logApprovalRejected($approval, $reason); + + // Find the record that caused the rejection to include in event + $rejectedRecord = ApprovalRecord::where('approval_id', $approval->id) + ->where('status', 'rejected') + ->first(); + + // Dispatch rejection event + ApprovalRejectedEvent::dispatch($approval, $rejectedRecord, $reason); } /** @@ -410,4 +462,79 @@ private function evaluateAutoApproveCondition( default => false, }; } + + /** + * Finalize the approvable entity state and cleanup pending transactions + */ + private function finalizeEntityRejection(TraditionalApprove $approval): void + { + $model = $approval->itemable; + if (!$model) { + return; + } + + // 1. Mark the parent entity as rejected + $this->updateEntityStatus($model); + + // 2. Delete related pending AccountingTransaction records + if (method_exists($model, 'accountingTransactions')) { + $model->accountingTransactions() + ->withoutGlobalScopes() + ->where('status', AccountingTransactionStatusEnum::Pending->value) + ->forceDelete(); + } else { + AccountingTransaction::withoutGlobalScopes() + ->where('itemable_type', get_class($model)) + ->where('itemable_id', $model->id) + ->where('status', AccountingTransactionStatusEnum::Pending->value) + ->forceDelete(); + } + + // 3. Delete related pending InventoryTransaction records + InventoryTransaction::where('sourceable_type', get_class($model)) + ->where('sourceable_id', $model->id) + ->where('status', 'pending') + ->forceDelete(); + + // 4. Update InvoiceManagement records + if (method_exists($model, 'invoiceManagement')) { + $model->invoiceManagement()->update([ + 'approval_status' => InvoiceStatusEnum::Rejected->value + ]); + } else { + InvoiceManagement::where('itemable_type', get_class($model)) + ->where('itemable_id', $model->id) + ->update([ + 'approval_status' => InvoiceStatusEnum::Rejected->value + ]); + } + } + + /** + * Update the status of the approvable entity + */ + private function updateEntityStatus($model): void + { + // Try common status update methods + if (method_exists($model, 'reject')) { + $model->reject(); + return; + } + + $updates = []; + + // Check if model has a status column + if (Schema::hasColumn($model->getTable(), 'status')) { + $updates['status'] = 'rejected'; + } + + // Check for approval_status column + if (Schema::hasColumn($model->getTable(), 'approval_status')) { + $updates['approval_status'] = 'rejected'; + } + + if (!empty($updates)) { + $model->update($updates); + } + } } diff --git a/app/Domains/Accounting/ApprovalSetting/Services/ApprovalAuditService.php b/app/Domains/Accounting/ApprovalSetting/Services/ApprovalAuditService.php new file mode 100644 index 000000000..c051e5521 --- /dev/null +++ b/app/Domains/Accounting/ApprovalSetting/Services/ApprovalAuditService.php @@ -0,0 +1,135 @@ +createLog($approval, 'approval_started', [ + 'user_id' => Auth::id() ?? $approval->created_by, + 'metadata' => [ + 'department' => $approval->department, + 'approval_setting_id' => $approval->approval_setting_id, + ] + ]); + } + + /** + * Log a step approval + */ + public function logStepApproved( + TraditionalApprove $approval, + ApprovalRecord $record, + User $user, + ?string $comment = null, + ?array $metadata = null + ): void { + $this->createLog($approval, 'step_approved', [ + 'approval_record_id' => $record->id, + 'user_id' => $user->id, + 'comment' => $comment, + 'metadata' => array_merge(['level' => $record->level], $metadata ?? []) + ]); + } + + /** + * Log a step rejection + */ + public function logStepRejected( + TraditionalApprove $approval, + ApprovalRecord $record, + User $user, + string $reason, + ?array $metadata = null + ): void { + $this->createLog($approval, 'step_rejected', [ + 'approval_record_id' => $record->id, + 'user_id' => $user->id, + 'comment' => $reason, + 'metadata' => array_merge(['level' => $record->level], $metadata ?? []) + ]); + } + + /** + * Log a comment added to an approval + */ + public function logCommentAdded( + TraditionalApprove $approval, + TraditionalApproveComment $comment + ): void { + $this->createLog($approval, 'comment_added', [ + 'user_id' => $comment->user_id, + 'comment' => $comment->comment, + 'metadata' => [ + 'comment_id' => $comment->id, + ] + ]); + } + + /** + * Log the final completion (approval) of the process + */ + public function logApprovalCompleted(TraditionalApprove $approval): void + { + $this->createLog($approval, 'approval_completed', [ + 'metadata' => [ + 'approved_at' => $approval->approved_at, + ] + ]); + } + + /** + * Log the final rejection of the process + */ + public function logApprovalRejected(TraditionalApprove $approval, ?string $reason = null): void + { + $this->createLog($approval, 'approval_rejected', [ + 'comment' => $reason, + 'metadata' => [ + 'rejected_at' => $approval->rejected_at, + ] + ]); + } + + /** + * Get the full unified timeline for an approval instance + */ + public function getTimeline(int $approvalId): Collection + { + return ApprovalAuditLog::query() + ->where('approval_id', $approvalId) + ->with(['user', 'step']) + ->orderBy('created_at', 'asc') + ->get(); + } + + /** + * Internal helper to create log entries + */ + private function createLog(TraditionalApprove $approval, string $eventType, array $data): void + { + ApprovalAuditLog::create([ + 'company_id' => $approval->company_id, + 'itemable_type' => $approval->itemable_type, + 'itemable_id' => $approval->itemable_id, + 'approval_id' => $approval->id, + 'approval_record_id' => $data['approval_record_id'] ?? null, + 'event_type' => $eventType, + 'user_id' => $data['user_id'] ?? Auth::id(), + 'comment' => $data['comment'] ?? null, + 'metadata' => $data['metadata'] ?? null, + ]); + } +} diff --git a/app/Domains/Accounting/ApprovalSetting/Services/TraditionalApprovalServices.php b/app/Domains/Accounting/ApprovalSetting/Services/TraditionalApprovalServices.php index 59749c00f..a164e7262 100644 --- a/app/Domains/Accounting/ApprovalSetting/Services/TraditionalApprovalServices.php +++ b/app/Domains/Accounting/ApprovalSetting/Services/TraditionalApprovalServices.php @@ -33,7 +33,8 @@ public function __construct( private ApprovalActionHandler $actionHandler, private ApprovalQueryService $queryService, private ApprovalRecordService $recordService, - private TemporaryFileServices $TemporaryFileServices + private TemporaryFileServices $TemporaryFileServices, + private ApprovalAuditService $auditService ) {} /** @@ -76,6 +77,9 @@ public function createApproval(object $model, string $typeSetting): ?Traditional // Create approval records for each level $this->recordService->createApprovalRecords($approval, $settings); + // Log approval start + $this->auditService->logApprovalStarted($approval); + return $approval->load('approvalRecords.approvers'); }); } diff --git a/app/Domains/Accounting/ApprovalSetting/Services/TraditionalApproveCommentService.php b/app/Domains/Accounting/ApprovalSetting/Services/TraditionalApproveCommentService.php index 0dd4f2c15..8a54d5452 100644 --- a/app/Domains/Accounting/ApprovalSetting/Services/TraditionalApproveCommentService.php +++ b/app/Domains/Accounting/ApprovalSetting/Services/TraditionalApproveCommentService.php @@ -12,7 +12,8 @@ class TraditionalApproveCommentService { public function __construct( - private readonly TemporaryFileServices $temporaryFileServices + private readonly TemporaryFileServices $temporaryFileServices, + private readonly ApprovalAuditService $auditService ) {} public function index(): LengthAwarePaginator @@ -41,6 +42,9 @@ public function store(TraditionalApproveCommentDTO $dto): void ]); $this->attachFiles($dto, $comment); + + // Log comment to audit trail + $this->auditService->logCommentAdded($comment->traditionalApprove, $comment); }); } diff --git a/app/Domains/Accounting/InvoiceManagement/Enums/InvoiceStatusEnum.php b/app/Domains/Accounting/InvoiceManagement/Enums/InvoiceStatusEnum.php index 9604b38f5..4fcfc9fd4 100644 --- a/app/Domains/Accounting/InvoiceManagement/Enums/InvoiceStatusEnum.php +++ b/app/Domains/Accounting/InvoiceManagement/Enums/InvoiceStatusEnum.php @@ -6,6 +6,7 @@ enum InvoiceStatusEnum: string { case Pending = 'pending'; // في انتظار الموافقة على العمليات المالية case Approved = 'approved'; // تم الموافقة على العمليات المالية + case Rejected = 'rejected'; // تم رفض الموافقة على العمليات المالية case Paid = 'paid'; // مدفوعة بالكامل case PartiallyPaid = 'partially_paid'; // مدفوعة جزئياً case Unpaid = 'unpaid'; // غير مدفوعة diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 658ed8a40..af0d72a11 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -18,6 +18,10 @@ use App\Broadcasting\WhatsAppChannel; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Event; +use App\Domains\Accounting\ApprovalSetting\Events\ApprovalCompletedEvent; +use App\Domains\Accounting\ApprovalSetting\Events\ApprovalRejectedEvent; +use App\Domains\Accounting\ApprovalSetting\Listeners\NotifyCreatorOfApprovalCompletion; +use App\Domains\Accounting\ApprovalSetting\Listeners\NotifyCreatorOfApprovalRejection; use App\Domains\Core\User\Models\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\ServiceProvider; @@ -251,6 +255,16 @@ public function boot(): void \App\Domains\Procurement\Order\Listeners\CreateNextVehicle::class, ); + Event::listen( + ApprovalCompletedEvent::class, + NotifyCreatorOfApprovalCompletion::class + ); + + Event::listen( + ApprovalRejectedEvent::class, + NotifyCreatorOfApprovalRejection::class + ); + User::observe(UserObserver::class); Leave::observe(LeaveObserver::class); Attendance::observe(AttendanceObserver::class); diff --git a/database/migrations/2026_05_10_134000_create_approval_audit_logs_table.php b/database/migrations/2026_05_10_134000_create_approval_audit_logs_table.php new file mode 100644 index 000000000..177d679d0 --- /dev/null +++ b/database/migrations/2026_05_10_134000_create_approval_audit_logs_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('company_id')->constrained()->cascadeOnDelete(); + $table->string('itemable_type'); + $table->unsignedBigInteger('itemable_id'); + $table->foreignId('approval_id')->constrained('traditional_approves')->cascadeOnDelete(); + $table->foreignId('approval_record_id')->nullable()->constrained('approval_records')->nullOnDelete(); + $table->string('event_type'); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->text('comment')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['itemable_type', 'itemable_id'], 'approval_audit_logs_itemable_index'); + $table->index(['approval_id', 'event_type'], 'approval_audit_logs_approval_event_index'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('approval_audit_logs'); + } +};