Commit 101a0f35 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Wire all cross-module integrations: events, enforcement, dashboard, scheduled jobs

Money flows:
- Payslip paid → double-entry financial transaction (debit salary expense / credit cash)
- POS sale → inventory movements for tracked products
- Enrollment → auto-invoice via PricingService
- Invoice overdue detection enhanced with balance guard

Operational events (previously empty → now wired):
- AssignmentCreated → generate trainer attendance expectations
- AssignmentEnded → remove future trainer attendance
- FacilityStatusChanged → cancel reservations on maintenance
- PurchaseOrderReceived → create receiving inventory movements
- StockCountCompleted → process count adjustments
- KitAssembled → deduct component stock
- InventoryMovementCreated → check low stock threshold

Business rule enforcement:
- Medical certificate enforcement at attendance time (configurable)
- Group capacity check + auto-waitlist on enrollment
- Participant status blocking (frozen/suspended can't enroll)
- Attendance threshold from settings (not hardcoded)

Dashboard rebuilt with actionable data:
- Today's sessions, trainers present, payments, active participants
- Needs-attention cards (overdue invoices, pending approvals, expired docs)
- Today's schedule with take-attendance buttons
- Recent payments feed + quick action buttons

Scheduled jobs:
- attendance:auto-absent (hourly)
- documents:expire (daily 06:00)
- payments:detect-defaults (daily 08:00)
- attendance:send-alerts (daily 20:00)
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 0aa21bd4
<?php
namespace App\Console\Commands;
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Events\AttendanceMarked;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class AutoMarkAbsent extends Command
{
protected $signature = 'attendance:auto-absent';
protected $description = 'تحديد الغياب التلقائي';
public function handle(): int
{
$cutoff = now()->subHours(2);
// Only process today's and yesterday's sessions (don't touch ancient records)
$sessionDateRange = [now()->subDay()->toDateString(), now()->toDateString()];
// Find sessions that ended more than 2 hours ago (today or yesterday)
$expiredSessionIds = TrainingSession::whereIn('status', ['completed', 'in_progress'])
->whereBetween('session_date', $sessionDateRange)
->whereRaw("(session_date || ' ' || end_time)::timestamp < ?", [$cutoff])
->pluck('id');
if ($expiredSessionIds->isEmpty()) {
$this->info('لا توجد سجلات تحتاج تحديث.');
return self::SUCCESS;
}
// Get expected records that need to be marked absent
$records = AttendanceRecord::whereIn('training_session_id', $expiredSessionIds)
->where('status', AttendanceStatus::Expected)
->get();
if ($records->isEmpty()) {
$this->info('لا توجد سجلات تحتاج تحديث.');
return self::SUCCESS;
}
// Use a system user for event dispatch (first admin or fallback)
$systemUser = User::where('email', 'admin@oc-sport.com')->first()
?? User::first();
$count = 0;
foreach ($records as $record) {
$record->update([
'status' => AttendanceStatus::Absent->value,
'is_flagged' => true,
'marked_at' => now(),
'metadata' => json_encode([
'auto_marked' => true,
'marked_reason' => 'auto_absent_after_2h',
]),
]);
// Dispatch event so compensation penalties and notifications fire
if ($systemUser) {
AttendanceMarked::dispatch($record->fresh(), $systemUser);
}
$count++;
}
$this->info("تم تسجيل {$count} سجل كغائب تلقائياً.");
Log::info("AutoMarkAbsent: Marked {$count} records as absent");
return self::SUCCESS;
}
}
<?php
namespace App\Console\Commands;
use App\Domain\Financial\Events\PaymentPlanDefaulted;
use App\Domain\Financial\Models\Installment;
use App\Domain\Financial\Models\PaymentPlan;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class DetectPaymentPlanDefaults extends Command
{
protected $signature = 'payments:detect-defaults';
protected $description = 'كشف التخلف عن خطط الدفع';
private const GRACE_DAYS = 7;
private const DEFAULT_THRESHOLD = 2; // missed installments to consider plan defaulted
public function handle(): int
{
$graceCutoff = now()->subDays(self::GRACE_DAYS)->toDateString();
// Find pending installments that are overdue past grace period
$overdueInstallments = Installment::where('status', 'pending')
->where('due_date', '<', $graceCutoff)
->with('paymentPlan')
->get();
if ($overdueInstallments->isEmpty()) {
$this->info('لا توجد أقساط متأخرة تجاوزت فترة السماح.');
return self::SUCCESS;
}
$updatedCount = 0;
$defaultedPlans = [];
foreach ($overdueInstallments as $installment) {
// Mark installment as overdue
if ($installment->status === 'pending') {
$installment->update(['status' => 'overdue']);
$updatedCount++;
}
// Check if the payment plan as a whole has defaulted
$plan = $installment->paymentPlan;
if (!$plan || isset($defaultedPlans[$plan->id])) {
continue;
}
$missedCount = Installment::where('payment_plan_id', $plan->id)
->where('status', 'overdue')
->count();
if ($missedCount >= self::DEFAULT_THRESHOLD && $plan->status !== 'defaulted') {
$plan->update(['status' => 'defaulted']);
$defaultedPlans[$plan->id] = true;
PaymentPlanDefaulted::dispatch($plan);
Log::warning("PaymentPlan #{$plan->id} defaulted: {$missedCount} missed installments");
$this->warn("خطة الدفع #{$plan->id} متعثرة ({$missedCount} أقساط متأخرة)");
}
}
$this->info("تم تحديث {$updatedCount} قسط متأخر. خطط متعثرة: " . count($defaultedPlans));
Log::info("DetectPaymentPlanDefaults: Updated {$updatedCount} installments, " . count($defaultedPlans) . " plans defaulted");
return self::SUCCESS;
}
}
...@@ -8,13 +8,13 @@ ...@@ -8,13 +8,13 @@
class ExpireDocuments extends Command class ExpireDocuments extends Command
{ {
protected $signature = 'documents:expire'; protected $signature = 'documents:expire';
protected $description = 'Mark approved documents past their expiry date as expired'; protected $description = 'انتهاء صلاحية المستندات';
public function handle(DocumentService $service): int public function handle(DocumentService $service): int
{ {
$count = $service->checkAndExpireDocuments(); $count = $service->checkAndExpireDocuments();
$this->info("Expired {$count} document(s)."); $this->info("تم إنهاء صلاحية {$count} مستند(ات).");
return self::SUCCESS; return self::SUCCESS;
} }
......
...@@ -3,14 +3,17 @@ ...@@ -3,14 +3,17 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Domain\Attendance\Enums\AttendanceStatus; use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Events\AttendanceMarked;
use App\Domain\Attendance\Models\AttendanceRecord; use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Training\Models\TrainingSession; use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class MarkAutoAbsent extends Command class MarkAutoAbsent extends Command
{ {
protected $signature = 'attendance:mark-absent'; protected $signature = 'attendance:mark-absent';
protected $description = 'Mark expected attendance records as absent after session end + 2 hours'; protected $description = 'تحديد الغياب التلقائي بعد انتهاء الجلسة بساعتين';
public function handle(): int public function handle(): int
{ {
...@@ -29,20 +32,42 @@ public function handle(): int ...@@ -29,20 +32,42 @@ public function handle(): int
$allExpiredIds = $expiredSessionIds->merge($inProgressExpired)->unique(); $allExpiredIds = $expiredSessionIds->merge($inProgressExpired)->unique();
if ($allExpiredIds->isEmpty()) { if ($allExpiredIds->isEmpty()) {
$this->info(__('لا توجد جلسات منتهية.')); $this->info('لا توجد جلسات منتهية.');
return self::SUCCESS; return self::SUCCESS;
} }
$updated = AttendanceRecord::whereIn('training_session_id', $allExpiredIds) // Get records individually so we can dispatch events per record
$records = AttendanceRecord::whereIn('training_session_id', $allExpiredIds)
->where('status', AttendanceStatus::Expected) ->where('status', AttendanceStatus::Expected)
->update([ ->get();
if ($records->isEmpty()) {
$this->info('لا توجد سجلات تحتاج تحديث.');
return self::SUCCESS;
}
$systemUser = User::where('email', 'admin@oc-sport.com')->first()
?? User::first();
$count = 0;
foreach ($records as $record) {
$record->update([
'status' => AttendanceStatus::Absent->value, 'status' => AttendanceStatus::Absent->value,
'is_flagged' => true, 'is_flagged' => true,
'marked_at' => now(), 'marked_at' => now(),
'metadata' => json_encode(['auto_marked' => true, 'marked_reason' => 'auto_absent_after_2h']), 'metadata' => json_encode(['auto_marked' => true, 'marked_reason' => 'auto_absent_after_2h']),
]); ]);
$this->info(__('تم تسجيل :count سجل كغائب.', ['count' => $updated])); if ($systemUser) {
AttendanceMarked::dispatch($record->fresh(), $systemUser);
}
$count++;
}
$this->info("تم تسجيل {$count} سجل كغائب.");
Log::info("MarkAutoAbsent: Marked {$count} records as absent");
return self::SUCCESS; return self::SUCCESS;
} }
} }
...@@ -19,6 +19,7 @@ public function handle(): int ...@@ -19,6 +19,7 @@ public function handle(): int
Invoice::where('status', InvoiceStatus::Sent) Invoice::where('status', InvoiceStatus::Sent)
->whereNotNull('due_date') ->whereNotNull('due_date')
->where('due_date', '<', now()->toDateString()) ->where('due_date', '<', now()->toDateString())
->where('due_amount', '>', 0)
->chunkById(100, function ($invoices) use (&$count) { ->chunkById(100, function ($invoices) use (&$count) {
foreach ($invoices as $invoice) { foreach ($invoices as $invoice) {
$invoice->update(['status' => InvoiceStatus::Overdue]); $invoice->update(['status' => InvoiceStatus::Overdue]);
...@@ -27,9 +28,7 @@ public function handle(): int ...@@ -27,9 +28,7 @@ public function handle(): int
} }
}); });
if ($count > 0) { $this->info("Marked {$count} invoice(s) as overdue.");
$this->info("Marked {$count} invoices as overdue.");
}
return self::SUCCESS; return self::SUCCESS;
} }
......
<?php
namespace App\Console\Commands;
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Events\AttendanceThresholdBreached;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Services\SettingsService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class SendAttendanceAlerts extends Command
{
protected $signature = 'attendance:send-alerts';
protected $description = 'إرسال تنبيهات الحضور';
private const DEFAULT_MIN_ATTENDANCE_PERCENT = 75;
private const LOOKBACK_DAYS = 30;
public function handle(SettingsService $settings): int
{
$threshold = (float) ($settings->get('min_attendance_percent', self::DEFAULT_MIN_ATTENDANCE_PERCENT));
$participants = Participant::where('status', 'active')->get();
$alertCount = 0;
foreach ($participants as $participant) {
$rate = $this->calculateRecentRate($participant);
// Skip if insufficient data (null means not enough records)
if ($rate === null) {
continue;
}
if ($rate < $threshold) {
AttendanceThresholdBreached::dispatch($participant, $rate, $threshold);
$alertCount++;
$this->warn("نسبة حضور منخفضة: {$participant->person?->name_ar} ({$rate}%)");
}
}
$this->info("تم إرسال {$alertCount} تنبيه حضور.");
Log::info("SendAttendanceAlerts: Dispatched {$alertCount} threshold breach alerts");
return self::SUCCESS;
}
/**
* Calculate attendance rate for the last 30 days.
* Formula: rate = (present + late + partial) / (total - cancelled - exempt) x 100
* Returns null if insufficient data (less than 5 records).
*/
private function calculateRecentRate(Participant $participant): ?float
{
$since = now()->subDays(self::LOOKBACK_DAYS);
$query = AttendanceRecord::where('subject_type', Participant::class)
->where('subject_id', $participant->id)
->whereHas('session', fn ($q) => $q->where('session_date', '>=', $since));
$total = (clone $query)->count();
// Need at least 5 records to be meaningful
if ($total < 5) {
return null;
}
$excluded = (clone $query)->whereIn('status', [
AttendanceStatus::Cancelled,
AttendanceStatus::Exempt,
])->count();
$denominator = $total - $excluded;
if ($denominator <= 0) {
return null;
}
$positive = (clone $query)->whereIn('status', [
AttendanceStatus::Present,
AttendanceStatus::Late,
AttendanceStatus::Partial,
])->count();
return round(($positive / $denominator) * 100, 1);
}
}
...@@ -7,8 +7,10 @@ ...@@ -7,8 +7,10 @@
use App\Domain\Attendance\Events\AttendanceThresholdBreached; use App\Domain\Attendance\Events\AttendanceThresholdBreached;
use App\Domain\Attendance\Events\ParticipantAbsent; use App\Domain\Attendance\Events\ParticipantAbsent;
use App\Domain\Attendance\Models\AttendanceRecord; use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Document\Models\Document;
use App\Domain\Participant\Models\Participant; use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Services\SettingsService;
use App\Models\User; use App\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
...@@ -18,8 +20,14 @@ class AttendanceMarkingService ...@@ -18,8 +20,14 @@ class AttendanceMarkingService
private const DEFAULT_PARTICIPANT_GRACE_MINUTES = 15; private const DEFAULT_PARTICIPANT_GRACE_MINUTES = 15;
private const DEFAULT_TRAINER_GRACE_MINUTES = 10; private const DEFAULT_TRAINER_GRACE_MINUTES = 10;
public function __construct(
private SettingsService $settings,
) {}
public function markPresent(AttendanceRecord $record, User $marker, ?Carbon $checkInTime = null): AttendanceRecord public function markPresent(AttendanceRecord $record, User $marker, ?Carbon $checkInTime = null): AttendanceRecord
{ {
$this->enforceMedicalCertificate($record);
return DB::transaction(function () use ($record, $marker, $checkInTime) { return DB::transaction(function () use ($record, $marker, $checkInTime) {
$checkInTime ??= now(); $checkInTime ??= now();
$session = $record->session; $session = $record->session;
...@@ -54,6 +62,11 @@ public function markPresent(AttendanceRecord $record, User $marker, ?Carbon $che ...@@ -54,6 +62,11 @@ public function markPresent(AttendanceRecord $record, User $marker, ?Carbon $che
public function markStatus(AttendanceRecord $record, AttendanceStatus $status, User $marker, ?string $reason = null): AttendanceRecord public function markStatus(AttendanceRecord $record, AttendanceStatus $status, User $marker, ?string $reason = null): AttendanceRecord
{ {
// Enforce medical certificate for positive attendance statuses
if (in_array($status, [AttendanceStatus::Present, AttendanceStatus::Late, AttendanceStatus::Partial])) {
$this->enforceMedicalCertificate($record);
}
return DB::transaction(function () use ($record, $status, $marker, $reason) { return DB::transaction(function () use ($record, $status, $marker, $reason) {
$data = [ $data = [
'status' => $status, 'status' => $status,
...@@ -177,6 +190,31 @@ private function getGraceMinutes(AttendanceRecord $record): int ...@@ -177,6 +190,31 @@ private function getGraceMinutes(AttendanceRecord $record): int
return self::DEFAULT_PARTICIPANT_GRACE_MINUTES; return self::DEFAULT_PARTICIPANT_GRACE_MINUTES;
} }
private function enforceMedicalCertificate(AttendanceRecord $record): void
{
if ($record->subject_type !== Participant::class) {
return;
}
$blockWithoutMedical = (bool) $this->settings->get('block_attendance_without_medical', false);
if (!$blockWithoutMedical) {
return;
}
$hasValidCert = Document::where('documentable_type', Participant::class)
->where('documentable_id', $record->subject_id)
->where('document_type', 'medical_certificate')
->where('status', 'approved')
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>=', now());
})
->exists();
if (!$hasValidCert) {
throw new DomainException('لا يمكن تسجيل الحضور — الشهادة الطبية مطلوبة أو منتهية');
}
}
private function checkThresholds(AttendanceRecord $record): void private function checkThresholds(AttendanceRecord $record): void
{ {
if ($record->subject_type !== Participant::class) { if ($record->subject_type !== Participant::class) {
...@@ -189,7 +227,7 @@ private function checkThresholds(AttendanceRecord $record): void ...@@ -189,7 +227,7 @@ private function checkThresholds(AttendanceRecord $record): void
} }
$rate = $this->calculateRate($participant->id); $rate = $this->calculateRate($participant->id);
$threshold = 75.0; // Default, would be loaded from academy settings $threshold = (float) $this->settings->get('minAttendancePercent', 75);
if ($rate < $threshold) { if ($rate < $threshold) {
AttendanceThresholdBreached::dispatch($participant, $rate, $threshold); AttendanceThresholdBreached::dispatch($participant, $rate, $threshold);
......
<?php
namespace App\Domain\Facility\Listeners;
use App\Domain\Facility\Events\FacilityStatusChanged;
use App\Domain\Facility\Models\SpaceReservation;
use App\Domain\Notification\Services\NotificationService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class CancelReservationsOnMaintenance implements ShouldQueue
{
public function __construct(
private NotificationService $notificationService,
) {}
public function handle(FacilityStatusChanged $event): void
{
try {
// Only act when facility goes to maintenance or closed
if (!in_array($event->newStatus, ['maintenance', 'closed'])) {
return;
}
$facility = $event->facility;
// Cancel all FUTURE confirmed/tentative reservations
$reservations = SpaceReservation::where('facility_id', $facility->id)
->where('reservation_date', '>', today())
->whereIn('status', ['confirmed', 'tentative'])
->get();
foreach ($reservations as $reservation) {
$reservation->update([
'status' => 'cancelled',
'cancelled_by' => $event->actor->id,
'cancelled_at' => now(),
]);
// Notify the creator of the reservation
if ($reservation->created_by) {
$this->notificationService->sendSimple(
type: 'facility.reservation_cancelled',
recipientId: $reservation->created_by,
recipientType: 'user',
data: [
'facility_name' => $facility->name_ar ?? $facility->name,
'reservation_date' => $reservation->reservation_date->format('Y-m-d'),
'reason' => $event->newStatus === 'maintenance' ? 'صيانة المنشأة' : 'إغلاق المنشأة',
],
priority: 'high',
);
}
}
Log::info("CancelReservationsOnMaintenance: cancelled {$reservations->count()} reservations for facility {$facility->id}");
} catch (\Throwable $e) {
Log::error('CancelReservationsOnMaintenance failed: ' . $e->getMessage(), [
'facility_id' => $event->facility->id,
'new_status' => $event->newStatus,
]);
}
}
public function failed(FacilityStatusChanged $event, \Throwable $exception): void
{
Log::critical('CancelReservationsOnMaintenance PERMANENTLY FAILED', [
'facility_id' => $event->facility->id,
'new_status' => $event->newStatus,
'error' => $exception->getMessage(),
]);
}
}
...@@ -13,6 +13,9 @@ ...@@ -13,6 +13,9 @@
use App\Domain\HR\Models\Trainer; use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Models\TrainerAdvance; use App\Domain\HR\Models\TrainerAdvance;
use App\Domain\HR\Models\TrainerCompensation; use App\Domain\HR\Models\TrainerCompensation;
use App\Domain\Financial\Enums\TransactionType;
use App\Domain\Financial\Models\FinancialAccount;
use App\Domain\Financial\Models\Transaction;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Services\SettingsService; use App\Domain\Shared\Services\SettingsService;
use App\Models\User; use App\Models\User;
...@@ -335,9 +338,8 @@ public function markPaid( ...@@ -335,9 +338,8 @@ public function markPaid(
$this->applyAdvanceDeduction($payslip->trainer_id, $payslip->advances_deducted); $this->applyAdvanceDeduction($payslip->trainer_id, $payslip->advances_deducted);
} }
// TODO: Create double-entry financial transaction // Double-entry financial transaction
// Debit: Salary Expense account $this->createPayslipTransaction($payslip, $paymentMethod, $actor);
// Credit: Cash / Bank account (derived from $paymentMethod)
return $payslip->fresh(); return $payslip->fresh();
}); });
...@@ -450,6 +452,54 @@ private function generatePayslipNumber(int $academyId, Carbon $date): string ...@@ -450,6 +452,54 @@ private function generatePayslipNumber(int $academyId, Carbon $date): string
return "{$prefix}{$month}{$seq}"; return "{$prefix}{$month}{$seq}";
} }
/**
* Create the double-entry financial transaction for a paid payslip.
* Debit: Salary Expense (5000)
* Credit: Cash (1000) or Bank (1010) based on payment method
*/
private function createPayslipTransaction(Payslip $payslip, string $paymentMethod, User $actor): void
{
$academyId = $payslip->academy_id;
// Resolve salary expense account (code 5000)
$salaryExpenseAccount = FinancialAccount::where('academy_id', $academyId)
->where('code', '5000')
->first();
if (!$salaryExpenseAccount) {
throw new DomainException('حساب مصروف الرواتب غير موجود — يرجى إعداد شجرة الحسابات');
}
// Resolve cash/bank account based on payment method
$creditAccountCode = match ($paymentMethod) {
'cash' => '1000',
'bank_transfer', 'instapay', 'cheque' => '1010',
default => '1000',
};
$creditAccount = FinancialAccount::where('academy_id', $academyId)
->where('code', $creditAccountCode)
->first();
if (!$creditAccount) {
throw new DomainException('حساب النقدية/البنك غير موجود — يرجى إعداد شجرة الحسابات');
}
Transaction::create([
'academy_id' => $academyId,
'debit_account_id' => $salaryExpenseAccount->id,
'credit_account_id' => $creditAccount->id,
'reference_type' => Payslip::class,
'reference_id' => $payslip->id,
'amount' => $payslip->net_amount,
'currency' => 'EGP',
'type' => TransactionType::PaymentMade,
'description' => "راتب: {$payslip->payslip_number}",
'transaction_date' => now()->toDateString(),
'created_by' => $actor->id,
]);
}
/** /**
* Map a CompensationType value to the corresponding PayslipItemType. * Map a CompensationType value to the corresponding PayslipItemType.
*/ */
......
<?php
namespace App\Domain\Inventory\Events;
use App\Domain\Inventory\Models\InventoryLevel;
use App\Domain\Inventory\Models\InventoryMovement;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class InventoryMovementCreated implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public InventoryMovement $movement,
public InventoryLevel $level,
) {}
}
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
namespace App\Domain\Inventory\Listeners; namespace App\Domain\Inventory\Listeners;
use App\Domain\Inventory\Events\InventoryMovementCreated;
use App\Domain\Inventory\Events\ProductLowStock; use App\Domain\Inventory\Events\ProductLowStock;
use App\Domain\Inventory\Models\InventoryLevel; use App\Domain\Inventory\Models\InventoryLevel;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
...@@ -9,16 +10,19 @@ ...@@ -9,16 +10,19 @@
class CheckLowStockThreshold implements ShouldQueue class CheckLowStockThreshold implements ShouldQueue
{ {
public function handle($event): void public function handle(InventoryMovementCreated $event): void
{ {
try { try {
// This listener is triggered after any inventory movement $level = $event->level;
// and checks if the new level is below minimum
if (!isset($event->level) || !$event->level instanceof InventoryLevel) { // Only check after outbound movements
if ($event->movement->direction !== 'out') {
return; return;
} }
$level = $event->level; // Load the product relationship if not already loaded
$level->loadMissing(['product', 'warehouse']);
if ($level->min_stock_level === null || $level->min_stock_level <= 0) { if ($level->min_stock_level === null || $level->min_stock_level <= 0) {
return; return;
} }
...@@ -31,13 +35,16 @@ public function handle($event): void ...@@ -31,13 +35,16 @@ public function handle($event): void
); );
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('CheckLowStockThreshold failed: ' . $e->getMessage()); Log::error('CheckLowStockThreshold failed: ' . $e->getMessage(), [
'movement_id' => $event->movement->id,
]);
} }
} }
public function failed($event, \Throwable $exception): void public function failed(InventoryMovementCreated $event, \Throwable $exception): void
{ {
Log::critical('CheckLowStockThreshold PERMANENTLY FAILED', [ Log::critical('CheckLowStockThreshold PERMANENTLY FAILED', [
'movement_id' => $event->movement->id,
'error' => $exception->getMessage(), 'error' => $exception->getMessage(),
]); ]);
} }
......
<?php
namespace App\Domain\Inventory\Listeners;
use App\Domain\Inventory\Enums\MovementType;
use App\Domain\Inventory\Events\PurchaseOrderReceived;
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\InventoryService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class CreateReceivingMovements implements ShouldQueue
{
public function __construct(
private InventoryService $inventoryService,
) {}
public function handle(PurchaseOrderReceived $event): void
{
try {
$purchaseOrder = $event->purchaseOrder;
$warehouse = $purchaseOrder->warehouse;
if (!$warehouse) {
Log::warning('CreateReceivingMovements: PO has no warehouse', [
'purchase_order_id' => $purchaseOrder->id,
]);
return;
}
// Load items with products
$items = $purchaseOrder->items()->with('product')->get();
foreach ($items as $item) {
$product = $item->product;
if (!$product) {
Log::warning('CreateReceivingMovements: PO item has no product', [
'purchase_order_item_id' => $item->id,
]);
continue;
}
// Only create movements for tracked products
if (!$product->track_inventory) {
continue;
}
// Use quantity_received if set, otherwise quantity_ordered
$quantity = $item->quantity_received > 0
? $item->quantity_received
: $item->quantity_ordered;
if ($quantity <= 0) {
continue;
}
$this->inventoryService->createMovement(
product: $product,
warehouse: $warehouse,
type: MovementType::PurchaseReceived,
quantity: $quantity,
actor: $event->actor,
unitCost: $item->unit_cost,
reference: $purchaseOrder,
reason: "استلام أمر شراء #{$purchaseOrder->order_number}",
);
}
} catch (\Throwable $e) {
Log::error('CreateReceivingMovements failed: ' . $e->getMessage(), [
'purchase_order_id' => $event->purchaseOrder->id,
]);
}
}
public function failed(PurchaseOrderReceived $event, \Throwable $exception): void
{
Log::critical('CreateReceivingMovements PERMANENTLY FAILED', [
'purchase_order_id' => $event->purchaseOrder->id,
'error' => $exception->getMessage(),
]);
}
}
<?php
namespace App\Domain\Inventory\Listeners;
use App\Domain\Inventory\Enums\MovementType;
use App\Domain\Inventory\Events\KitAssembled;
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\InventoryService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class DeductKitComponents implements ShouldQueue
{
public function __construct(
private InventoryService $inventoryService,
) {}
public function handle(KitAssembled $event): void
{
try {
$kit = $event->kit;
$assemblyQuantity = $event->quantity;
// Load kit components with products
$components = $kit->components()->with('product')->get();
if ($components->isEmpty()) {
Log::warning('DeductKitComponents: Kit has no components', [
'kit_id' => $kit->id,
]);
return;
}
// Determine warehouse — use the first product's primary warehouse
// or the academy's default warehouse
$warehouse = $this->resolveWarehouse($kit);
if (!$warehouse) {
Log::error('DeductKitComponents: Cannot determine warehouse for kit', [
'kit_id' => $kit->id,
]);
return;
}
foreach ($components as $component) {
$product = $component->product;
if (!$product || !$product->track_inventory) {
continue;
}
// Total quantity to deduct = component quantity * assembly quantity
$totalQuantity = $component->quantity * $assemblyQuantity;
$this->inventoryService->createMovement(
product: $product,
warehouse: $warehouse,
type: MovementType::KitAssembly,
quantity: $totalQuantity,
actor: $event->actor,
reference: $kit,
reason: "تجميع طقم: {$kit->name_ar} × {$assemblyQuantity}",
);
}
} catch (\Throwable $e) {
Log::error('DeductKitComponents failed: ' . $e->getMessage(), [
'kit_id' => $event->kit->id,
'quantity' => $event->quantity,
]);
}
}
private function resolveWarehouse($kit): ?Warehouse
{
// Try to find the default/primary warehouse for this academy
return Warehouse::where('academy_id', $kit->academy_id)
->where('is_active', true)
->orderBy('id')
->first();
}
public function failed(KitAssembled $event, \Throwable $exception): void
{
Log::critical('DeductKitComponents PERMANENTLY FAILED', [
'kit_id' => $event->kit->id,
'quantity' => $event->quantity,
'error' => $exception->getMessage(),
]);
}
}
<?php
namespace App\Domain\Inventory\Listeners;
use App\Domain\Inventory\Enums\MovementType;
use App\Domain\Inventory\Events\StockCountCompleted;
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\InventoryService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class ProcessCountAdjustments implements ShouldQueue
{
public function __construct(
private InventoryService $inventoryService,
) {}
public function handle(StockCountCompleted $event): void
{
try {
$stockCount = $event->stockCount;
$warehouse = $stockCount->warehouse;
if (!$warehouse) {
Log::warning('ProcessCountAdjustments: StockCount has no warehouse', [
'stock_count_id' => $stockCount->id,
]);
return;
}
// Load items with products
$items = $stockCount->items()->with('product')->get();
foreach ($items as $item) {
$product = $item->product;
if (!$product) {
continue;
}
// Skip items where count matches system
$discrepancy = $item->counted_quantity - $item->system_quantity;
if ($discrepancy === 0) {
continue;
}
// Determine adjustment type based on discrepancy direction
if ($discrepancy > 0) {
// Counted MORE than system — adjustment up
$this->inventoryService->createMovement(
product: $product,
warehouse: $warehouse,
type: MovementType::CountAdjustmentUp,
quantity: $discrepancy,
actor: $event->actor,
reference: $stockCount,
reason: "تسوية جرد #{$stockCount->count_number} — زيادة {$discrepancy}",
);
} else {
// Counted LESS than system — adjustment down
$this->inventoryService->createMovement(
product: $product,
warehouse: $warehouse,
type: MovementType::CountAdjustmentDown,
quantity: abs($discrepancy),
actor: $event->actor,
reference: $stockCount,
reason: "تسوية جرد #{$stockCount->count_number} — نقص " . abs($discrepancy),
);
}
}
} catch (\Throwable $e) {
Log::error('ProcessCountAdjustments failed: ' . $e->getMessage(), [
'stock_count_id' => $event->stockCount->id,
]);
}
}
public function failed(StockCountCompleted $event, \Throwable $exception): void
{
Log::critical('ProcessCountAdjustments PERMANENTLY FAILED', [
'stock_count_id' => $event->stockCount->id,
'error' => $exception->getMessage(),
]);
}
}
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
namespace App\Domain\Inventory\Services; namespace App\Domain\Inventory\Services;
use App\Domain\Inventory\Enums\MovementType; use App\Domain\Inventory\Enums\MovementType;
use App\Domain\Inventory\Events\InventoryMovementCreated;
use App\Domain\Inventory\Events\ProductLowStock; use App\Domain\Inventory\Events\ProductLowStock;
use App\Domain\Inventory\Exceptions\InsufficientStockException; use App\Domain\Inventory\Exceptions\InsufficientStockException;
use App\Domain\Inventory\Models\InventoryLevel; use App\Domain\Inventory\Models\InventoryLevel;
...@@ -109,12 +110,8 @@ public function createMovement( ...@@ -109,12 +110,8 @@ public function createMovement(
'created_by' => $actor->id, 'created_by' => $actor->id,
]); ]);
// Check low stock threshold after outbound movement // Dispatch movement event for downstream listeners (e.g. low stock check)
if ($expectedDirection === 'out' && $level->min_stock_level > 0) { InventoryMovementCreated::dispatch($movement, $level);
if ($level->quantity_on_hand <= $level->min_stock_level) {
ProductLowStock::dispatch($product, $level, $warehouse);
}
}
return $movement; return $movement;
}); });
......
...@@ -7,6 +7,10 @@ ...@@ -7,6 +7,10 @@
use App\Domain\Financial\Services\InvoiceService; use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Financial\Services\PaymentService; use App\Domain\Financial\Services\PaymentService;
use App\Domain\Financial\Services\WalletService; use App\Domain\Financial\Services\WalletService;
use App\Domain\Inventory\Enums\MovementType;
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\InventoryService;
use App\Domain\Shared\Services\PlatformFeeService; use App\Domain\Shared\Services\PlatformFeeService;
use App\Domain\Participant\Models\Participant; use App\Domain\Participant\Models\Participant;
use App\Domain\POS\Enums\POSItemType; use App\Domain\POS\Enums\POSItemType;
...@@ -28,6 +32,7 @@ public function __construct( ...@@ -28,6 +32,7 @@ public function __construct(
private WalletService $walletService, private WalletService $walletService,
private CashSessionService $cashSessionService, private CashSessionService $cashSessionService,
private PlatformFeeService $platformFeeService, private PlatformFeeService $platformFeeService,
private InventoryService $inventoryService,
) {} ) {}
/** /**
...@@ -171,7 +176,7 @@ public function processTransaction( ...@@ -171,7 +176,7 @@ public function processTransaction(
$this->recordSinglePayment($invoice, $paymentMethod, $totalAmount, $cashier, $participant); $this->recordSinglePayment($invoice, $paymentMethod, $totalAmount, $cashier, $participant);
} }
// Handle enrollment for program items // Handle enrollment for program items + inventory deduction for tracked products
foreach ($cartItems as $item) { foreach ($cartItems as $item) {
$itemType = $item['item_type'] instanceof POSItemType $itemType = $item['item_type'] instanceof POSItemType
? $item['item_type'] ? $item['item_type']
...@@ -184,6 +189,17 @@ public function processTransaction( ...@@ -184,6 +189,17 @@ public function processTransaction(
isset($item['metadata']['group_id']) ? (int) $item['metadata']['group_id'] : null isset($item['metadata']['group_id']) ? (int) $item['metadata']['group_id'] : null
); );
} }
// Inventory movement for tracked products
if ($itemType === POSItemType::Product && isset($item['item_id'])) {
$this->deductInventoryIfTracked(
(int) $item['item_id'],
(int) ($item['quantity'] ?? 1),
$branchId,
$posTransaction,
$cashier
);
}
} }
// Update cash session totals // Update cash session totals
...@@ -293,6 +309,40 @@ private function createEnrollmentIfNeeded(Participant $participant, int $program ...@@ -293,6 +309,40 @@ private function createEnrollmentIfNeeded(Participant $participant, int $program
]); ]);
} }
/**
* Deduct inventory for a tracked product after POS sale.
* Finds the branch warehouse and creates a 'sale' movement.
*/
private function deductInventoryIfTracked(int $productId, int $quantity, int $branchId, POSTransaction $posTransaction, User $cashier): void
{
$product = Product::find($productId);
if (!$product || !$product->track_inventory) {
return;
}
// Resolve warehouse for this branch (first active warehouse for the branch)
$warehouse = Warehouse::where('branch_id', $branchId)
->where('is_active', true)
->first();
if (!$warehouse) {
// No warehouse configured for this branch — skip silently
// (admin should configure warehouses for inventory-tracked branches)
return;
}
$this->inventoryService->createMovement(
product: $product,
warehouse: $warehouse,
type: MovementType::Sale,
quantity: $quantity,
actor: $cashier,
unitCost: $product->cost_price,
reference: $posTransaction,
reason: "بيع نقطة بيع: {$posTransaction->receipt_number}",
);
}
/** /**
* Generate unique receipt number: RCP-{BRANCH}-{YYYYMMDD}-{SEQ} * Generate unique receipt number: RCP-{BRANCH}-{YYYYMMDD}-{SEQ}
*/ */
......
<?php
namespace App\Domain\Scheduling\Listeners;
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Scheduling\Events\AssignmentCreated;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class GenerateTrainerAttendance implements ShouldQueue
{
public function handle(AssignmentCreated $event): void
{
try {
$assignment = $event->assignment;
// Only process active assignments
if (!$assignment->isActive()) {
return;
}
// Determine the group to generate attendance for
$groupId = null;
if ($assignment->assignable_type === TrainingGroup::class) {
$groupId = $assignment->assignable_id;
} elseif ($assignment->assignable_type === TrainingSession::class) {
// Single session assignment — generate for just that session
$session = TrainingSession::find($assignment->assignable_id);
if ($session && $session->session_date >= now()->toDateString()) {
$this->createIfNotExists($session, $assignment->user_id, $session->academy_id);
}
return;
}
if (!$groupId) {
return;
}
// Generate 'expected' records for all FUTURE sessions of the group
$futureSessions = TrainingSession::where('training_group_id', $groupId)
->where('session_date', '>=', now()->toDateString())
->whereIn('status', ['scheduled', 'in_progress'])
->get();
foreach ($futureSessions as $session) {
$this->createIfNotExists($session, $assignment->user_id, $session->academy_id);
}
} catch (\Throwable $e) {
Log::error('GenerateTrainerAttendance failed: ' . $e->getMessage(), [
'assignment_id' => $event->assignment->id,
]);
}
}
private function createIfNotExists(TrainingSession $session, int $userId, int $academyId): void
{
$exists = AttendanceRecord::where('training_session_id', $session->id)
->where('subject_type', User::class)
->where('subject_id', $userId)
->exists();
if ($exists) {
return;
}
AttendanceRecord::create([
'academy_id' => $academyId,
'training_session_id' => $session->id,
'subject_type' => User::class,
'subject_id' => $userId,
'status' => AttendanceStatus::Expected->value,
'is_auto_generated' => true,
]);
}
public function failed(AssignmentCreated $event, \Throwable $exception): void
{
Log::critical('GenerateTrainerAttendance PERMANENTLY FAILED', [
'assignment_id' => $event->assignment->id,
'error' => $exception->getMessage(),
]);
}
}
<?php
namespace App\Domain\Scheduling\Listeners;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Scheduling\Events\AssignmentEnded;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class RemoveTrainerAttendance implements ShouldQueue
{
public function handle(AssignmentEnded $event): void
{
try {
$assignment = $event->assignment;
// Determine which sessions to clean up
if ($assignment->assignable_type === TrainingGroup::class) {
$futureSessionIds = TrainingSession::where('training_group_id', $assignment->assignable_id)
->where('session_date', '>', today())
->pluck('id');
} elseif ($assignment->assignable_type === TrainingSession::class) {
$session = TrainingSession::find($assignment->assignable_id);
if (!$session || $session->session_date <= today()) {
return;
}
$futureSessionIds = collect([$session->id]);
} else {
return;
}
if ($futureSessionIds->isEmpty()) {
return;
}
// Delete only 'expected' records for future sessions — don't touch past records
AttendanceRecord::whereIn('training_session_id', $futureSessionIds)
->where('subject_type', User::class)
->where('subject_id', $assignment->user_id)
->where('status', 'expected')
->delete();
} catch (\Throwable $e) {
Log::error('RemoveTrainerAttendance failed: ' . $e->getMessage(), [
'assignment_id' => $event->assignment->id,
]);
}
}
public function failed(AssignmentEnded $event, \Throwable $exception): void
{
Log::critical('RemoveTrainerAttendance PERMANENTLY FAILED', [
'assignment_id' => $event->assignment->id,
'error' => $exception->getMessage(),
]);
}
}
...@@ -2,7 +2,9 @@ ...@@ -2,7 +2,9 @@
namespace App\Domain\Training\Services; namespace App\Domain\Training\Services;
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Participant\Models\Participant; use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Events\EnrollmentCancelled; use App\Domain\Training\Events\EnrollmentCancelled;
use App\Domain\Training\Events\EnrollmentCompleted; use App\Domain\Training\Events\EnrollmentCompleted;
...@@ -19,13 +21,15 @@ class EnrollmentService ...@@ -19,13 +21,15 @@ class EnrollmentService
{ {
public function __construct( public function __construct(
private readonly TrainingGroupService $groupService, private readonly TrainingGroupService $groupService,
private readonly PricingService $pricingService,
private readonly InvoiceService $invoiceService,
) {} ) {}
public function enroll(Participant $participant, TrainingGroup $group, User $actor, ?array $options = []): Enrollment public function enroll(Participant $participant, TrainingGroup $group, User $actor, ?array $options = []): Enrollment
{ {
return DB::transaction(function () use ($participant, $group, $actor, $options) { return DB::transaction(function () use ($participant, $group, $actor, $options) {
// Guard: participant status must allow enrollment // Guard: participant status must allow enrollment
$blockedStatuses = ['suspended', 'blacklisted', 'transferred']; $blockedStatuses = ['frozen', 'suspended', 'blacklisted', 'transferred'];
if (in_array($participant->status->value, $blockedStatuses)) { if (in_array($participant->status->value, $blockedStatuses)) {
throw new DomainException('لا يمكن تسجيل مشترك ' . $this->getStatusLabel($participant->status->value)); throw new DomainException('لا يمكن تسجيل مشترك ' . $this->getStatusLabel($participant->status->value));
} }
...@@ -83,6 +87,11 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act ...@@ -83,6 +87,11 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act
// Update group count // Update group count
$this->groupService->incrementCount($group); $this->groupService->incrementCount($group);
// Auto-create invoice if program has a price (skip if invoice already provided via options)
if (empty($options['invoice_id'])) {
$this->createEnrollmentInvoice($enrollment, $participant, $group, $actor);
}
EnrollmentCreated::dispatch($enrollment, $actor); EnrollmentCreated::dispatch($enrollment, $actor);
return $enrollment; return $enrollment;
...@@ -326,9 +335,68 @@ public function processWaitlist(TrainingGroup $group): void ...@@ -326,9 +335,68 @@ public function processWaitlist(TrainingGroup $group): void
} }
} }
/**
* Auto-create an invoice for a new enrollment if the program has a base price.
* Uses PricingService to calculate the final price (applies rules/discounts).
*/
private function createEnrollmentInvoice(Enrollment $enrollment, Participant $participant, TrainingGroup $group, User $actor): void
{
$program = $group->program;
if (!$program) {
return;
}
// Use PricingService to calculate the price — it will throw if no base price exists
try {
$priceResult = $this->pricingService->calculate(
priceable: $program,
participant: $participant,
branchId: $group->branch_id,
);
} catch (DomainException) {
// No base price configured for this program — no invoice needed (free program)
return;
}
// If final amount is 0 (100% discount or free), skip invoice
if ($priceResult->finalAmount <= 0) {
$enrollment->update(['payment_status' => 'paid']);
return;
}
$invoice = $this->invoiceService->create([
'academy_id' => $enrollment->academy_id ?? $group->academy_id,
'branch_id' => $group->branch_id,
'billable_type' => $participant->getMorphClass(),
'billable_id' => $participant->id,
'number' => $this->invoiceService->generateNumber($group->academy_id),
'total_amount' => $priceResult->finalAmount,
'subtotal_amount' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount,
'tax_amount' => 0,
'due_date' => now()->addDays(7)->toDateString(),
'contact_name' => $participant->person?->name_ar ?? $participant->person?->name,
], [
[
'description' => "اشتراك: {$program->name_ar}",
'quantity' => 1,
'unit_price' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount,
'tax_amount' => 0,
],
], $actor);
// Link invoice to enrollment
$enrollment->update([
'invoice_id' => $invoice->id,
'payment_status' => 'pending',
]);
}
private function getStatusLabel(string $status): string private function getStatusLabel(string $status): string
{ {
return match ($status) { return match ($status) {
'frozen' => 'متجمد',
'suspended' => 'موقوف', 'suspended' => 'موقوف',
'blacklisted' => 'محظور', 'blacklisted' => 'محظور',
'transferred' => 'محول', 'transferred' => 'محول',
......
This diff is collapsed.
...@@ -95,19 +95,34 @@ class EventServiceProvider extends ServiceProvider ...@@ -95,19 +95,34 @@ class EventServiceProvider extends ServiceProvider
// Facility Events // Facility Events
\App\Domain\Facility\Events\SpaceReservationCreated::class => [], \App\Domain\Facility\Events\SpaceReservationCreated::class => [],
\App\Domain\Facility\Events\FacilityStatusChanged::class => [], \App\Domain\Facility\Events\FacilityStatusChanged::class => [
\App\Domain\Facility\Listeners\CancelReservationsOnMaintenance::class,
],
// Inventory Events // Inventory Events
\App\Domain\Inventory\Events\ProductLowStock::class => [ \App\Domain\Inventory\Events\ProductLowStock::class => [
\App\Domain\Inventory\Listeners\NotifyAdminLowStock::class, \App\Domain\Inventory\Listeners\NotifyAdminLowStock::class,
], ],
\App\Domain\Inventory\Events\PurchaseOrderReceived::class => [], \App\Domain\Inventory\Events\InventoryMovementCreated::class => [
\App\Domain\Inventory\Events\StockCountCompleted::class => [], \App\Domain\Inventory\Listeners\CheckLowStockThreshold::class,
\App\Domain\Inventory\Events\KitAssembled::class => [], ],
\App\Domain\Inventory\Events\PurchaseOrderReceived::class => [
\App\Domain\Inventory\Listeners\CreateReceivingMovements::class,
],
\App\Domain\Inventory\Events\StockCountCompleted::class => [
\App\Domain\Inventory\Listeners\ProcessCountAdjustments::class,
],
\App\Domain\Inventory\Events\KitAssembled::class => [
\App\Domain\Inventory\Listeners\DeductKitComponents::class,
],
// Scheduling Events // Scheduling Events
\App\Domain\Scheduling\Events\AssignmentCreated::class => [], \App\Domain\Scheduling\Events\AssignmentCreated::class => [
\App\Domain\Scheduling\Events\AssignmentEnded::class => [], \App\Domain\Scheduling\Listeners\GenerateTrainerAttendance::class,
],
\App\Domain\Scheduling\Events\AssignmentEnded::class => [
\App\Domain\Scheduling\Listeners\RemoveTrainerAttendance::class,
],
// Participant Events // Participant Events
\App\Domain\Participant\Events\ParticipantSuspended::class => [ \App\Domain\Participant\Events\ParticipantSuspended::class => [
......
...@@ -2,13 +2,16 @@ ...@@ -2,13 +2,16 @@
use Illuminate\Support\Facades\Schedule; use Illuminate\Support\Facades\Schedule;
Schedule::command('attendance:mark-absent')->hourly(); Schedule::command('attendance:auto-absent')->hourly();
Schedule::command('attendance:enforce-thresholds')->dailyAt('06:00'); Schedule::command('attendance:enforce-thresholds')->dailyAt('06:00');
Schedule::command('attendance:send-alerts')->dailyAt('20:00');
Schedule::command('invoices:mark-overdue')->dailyAt('01:00'); Schedule::command('invoices:mark-overdue')->dailyAt('01:00');
Schedule::command('sessions:generate-upcoming')->dailyAt('02:00'); Schedule::command('sessions:generate-upcoming')->dailyAt('02:00');
Schedule::command('audit:cleanup --days=365')->weekly(); Schedule::command('audit:cleanup --days=365')->weekly();
Schedule::command('documents:expire')->dailyAt('06:00');
Schedule::command('summary:daily')->dailyAt('07:00'); Schedule::command('summary:daily')->dailyAt('07:00');
Schedule::command('notifications:birthdays')->dailyAt('08:00'); Schedule::command('notifications:birthdays')->dailyAt('08:00');
Schedule::command('payments:detect-defaults')->dailyAt('08:00');
Schedule::command('inventory:notify-low-stock')->dailyAt('09:00'); Schedule::command('inventory:notify-low-stock')->dailyAt('09:00');
Schedule::command('reminders:expiring-enrollments --days=7')->dailyAt('10:00'); Schedule::command('reminders:expiring-enrollments --days=7')->dailyAt('10:00');
Schedule::command('reminders:expiring-enrollments --days=3')->dailyAt('10:30'); Schedule::command('reminders:expiring-enrollments --days=3')->dailyAt('10:30');
...@@ -18,7 +21,6 @@ ...@@ -18,7 +21,6 @@
Schedule::command('reminders:overdue-invoices')->weeklyOn(4, '09:00'); Schedule::command('reminders:overdue-invoices')->weeklyOn(4, '09:00');
Schedule::command('groups:reconcile-counts')->dailyAt('03:00'); Schedule::command('groups:reconcile-counts')->dailyAt('03:00');
Schedule::command('enrollments:deactivate-expired')->dailyAt('00:30'); Schedule::command('enrollments:deactivate-expired')->dailyAt('00:30');
Schedule::command('documents:expire')->dailyAt('00:15');
Schedule::command('financials:reconcile')->weeklyOn(0, '04:00'); Schedule::command('financials:reconcile')->weeklyOn(0, '04:00');
Schedule::command('reports:parent-weekly')->weeklyOn(6, '12:00'); Schedule::command('reports:parent-weekly')->weeklyOn(6, '12:00');
Schedule::command('groups:alert-capacity --threshold=90')->dailyAt('08:00'); Schedule::command('groups:alert-capacity --threshold=90')->dailyAt('08:00');
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment