**Prepared by:** Oditha

**Date:** 23 January 2026

**Version:** 1.0

**Reference:** [FRD - HoReCa Field Operations & Workflow Management](https://www.notion.so/FRD-HoReCa-Field-Operations-Workflow-Management-2f7cd2773bc548c9a2595d856f15ac15?pvs=21)

---

## Overview

This document specifies all automated tasks, scheduled jobs, and background processes for the Lion HoReCa Excellence system.

---

## Scheduler Overview

### Technology

-   **Laravel Task Scheduling** (using system cron)
-   **Queue Workers** (Redis-backed queues)
-   **Supervisor** (process monitor for queue workers)

### Cron Setup

Add this single entry to system crontab:

```bash
* * * * * cd /path-to-project && php artisan schedule:run >> /dev/null 2>&1
```

Laravel scheduler handles all task timing internally.

---

## Scheduled Tasks

### Schedule Configuration

**app/Console/Kernel.php:**

```php
protected function schedule(Schedule $schedule)
{
    // Set timezone
    $schedule->timezone('Asia/Colombo');

    // Daily Tasks
    $schedule->command('posm:generate-audits')
             ->dailyAt('02:00')
             ->withoutOverlapping()
             ->onSuccess(function () {
                 Log::info('POSM audit generation completed');
             })
             ->onFailure(function () {
                 Log::error('POSM audit generation failed');
                 // Send alert to admin
             });

    $schedule->command('posm:flag-overdue')
             ->dailyAt('03:00')
             ->withoutOverlapping();

    $schedule->command('posm:send-reminders')
             ->dailyAt('08:00')
             ->withoutOverlapping();

    // Hourly Tasks
    $schedule->command('sr:check-sla')
             ->hourly()
             ->withoutOverlapping();

    $schedule->command('visits:check-forgotten-checkouts')
             ->hourly()
             ->between('06:00', '22:00');

    // Every 15 minutes
    $schedule->command('cache:refresh-dashboard')
             ->everyFifteenMinutes();

    // Weekly Tasks
    $schedule->command('reports:generate-weekly')
             ->weekly()
             ->mondays()
             ->at('07:00');

    // Monthly Tasks
    $schedule->command('data:archive')
             ->monthly()
             ->at('01:00');

    // Database maintenance
    $schedule->command('db:backup')
             ->daily()
             ->at('01:00');

    $schedule->command('telescope:prune')
             ->daily();

    $schedule->command('queue:prune-failed')
             ->daily();
}
```

---

## Task 1: Generate POSM Audits

### Schedule

**Frequency:** Daily at 02:00 Asia/Colombo

### Command

**File:** `app/Console/Commands/GeneratePOSMAuditsCommand.php`

```php
namespace App\Console\Commands;

use App\Jobs\GeneratePOSMAuditsJob;
use Illuminate\Console\Command;

class GeneratePOSMAuditsCommand extends Command
{
    protected $signature = 'posm:generate-audits';
    protected $description = 'Generate POSM audit tasks for outlets due for audit';

    public function handle()
    {
        $this->info('Starting POSM audit generation...');

        // Dispatch job
        GeneratePOSMAuditsJob::dispatch();

        $this->info('Job dispatched successfully');
        return Command::SUCCESS;
    }
}
```

### Job Implementation

**File:** `app/Jobs/GeneratePOSMAuditsJob.php`

```php
namespace App\Jobs;

use App\Models\Outlet;
use App\Models\POSMAudit;
use App\Models\User;
use App\Notifications\AuditAssignedNotification;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class GeneratePOSMAuditsJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function handle()
    {
        Log::info('Generating POSM audits for outlets');

        // Find outlets with completed audits 20 days ago
        $twentyDaysAgo = Carbon::now()->subDays(20)->toDateString();

        $outlets = Outlet::select('outlets.*')
            ->join('posm_audits', 'outlets.id', '=', 'posm_audits.outlet_id')
            ->where('posm_audits.status', 'completed')
            ->whereDate('posm_audits.completed_date', $twentyDaysAgo)
            ->where('outlets.status', 'active')
            ->whereNotNull('outlets.assigned_tm_id')
            ->with(['assignedTM', 'assignedSM'])
            ->distinct()
            ->get();

        $auditsCreated = 0;

        foreach ($outlets as $outlet) {
            try {
                // Determine assigned TM (or escalate to SM if TM inactive)
                $assignedTM = $outlet->assignedTM;

                if (!$assignedTM || $assignedTM->status !== 'active') {
                    // Escalate to SM
                    $assignedTM = $outlet->assignedSM;

                    if (!$assignedTM || $assignedTM->status !== 'active') {
                        Log::warning("No active TM/SM for outlet {$outlet->id}, skipping audit generation");
                        continue;
                    }
                }

                // Create audit
                $audit = POSMAudit::create([
                    'outlet_id' => $outlet->id,
                    'assigned_tm_id' => $assignedTM->id,
                    'scheduled_date' => Carbon::now()->toDateString(),
                    'due_date' => Carbon::now()->addDays(5)->toDateString(),
                    'status' => 'scheduled',
                    'is_overdue' => false,
                    'created_by' => null, // System-generated
                ]);

                // Send notification to TM
                $assignedTM->notify(new AuditAssignedNotification($audit));

                $auditsCreated++;

            } catch (\Exception $e) {
                Log::error("Failed to create audit for outlet {$outlet->id}: {$e->getMessage()}");
            }
        }

        Log::info("POSM audit generation completed. Created {$auditsCreated} audits.");
    }
}
```

---

## Task 2: Flag Overdue Audits

### Schedule

**Frequency:** Daily at 03:00 Asia/Colombo

### Command

**File:** `app/Console/Commands/FlagOverdueAuditsCommand.php`

```php
namespace App\Console\Commands;

use App\Jobs\FlagOverdueAuditsJob;
use Illuminate\Console\Command;

class FlagOverdueAuditsCommand extends Command
{
    protected $signature = 'posm:flag-overdue';
    protected $description = 'Flag POSM audits that are overdue';

    public function handle()
    {
        $this->info('Flagging overdue audits...');

        FlagOverdueAuditsJob::dispatch();

        $this->info('Job dispatched successfully');
        return Command::SUCCESS;
    }
}
```

### Job Implementation

**File:** `app/Jobs/FlagOverdueAuditsJob.php`

```php
namespace App\Jobs;

use App\Models\POSMAudit;
use App\Models\User;
use App\Notifications\AuditOverdueNotification;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class FlagOverdueAuditsJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function handle()
    {
        Log::info('Flagging overdue audits');

        // Find audits that are overdue
        $overdueAudits = POSMAudit::where('due_date', '<', Carbon::now()->toDateString())
            ->whereNotIn('status', ['completed'])
            ->where('is_overdue', false)
            ->with(['outlet', 'assignedTM', 'assignedTM.reportsTo'])
            ->get();

        $flaggedCount = 0;

        foreach ($overdueAudits as $audit) {
            try {
                // Update is_overdue flag
                $audit->update(['is_overdue' => true]);

                // Calculate days overdue
                $daysOverdue = Carbon::now()->diffInDays(Carbon::parse($audit->due_date));

                // Send escalation notifications based on days overdue
                if ($daysOverdue >= 7) {
                    // 7+ days: Escalate to Regional Manager/Admin
                    $this->escalateToAdmin($audit, $daysOverdue);
                } elseif ($daysOverdue >= 3) {
                    // 3-6 days: Escalate to Regional Manager
                    $this->escalateToRegionalManager($audit, $daysOverdue);
                } else {
                    // 1-2 days: Notify SM
                    $this->escalateToSM($audit, $daysOverdue);
                }

                $flaggedCount++;

            } catch (\Exception $e) {
                Log::error("Failed to flag audit {$audit->id}: {$e->getMessage()}");
            }
        }

        Log::info("Flagged {$flaggedCount} overdue audits");
    }

    protected function escalateToSM(POSMAudit $audit, int $daysOverdue)
    {
        if ($audit->assignedTM && $audit->assignedTM->reportsTo) {
            $audit->assignedTM->reportsTo->notify(
                new AuditOverdueNotification($audit, $daysOverdue, 'sm')
            );
        }
    }

    protected function escalateToRegionalManager(POSMAudit $audit, int $daysOverdue)
    {
        // Get Regional Manager
        $sm = $audit->assignedTM?->reportsTo;
        $regionalManager = $sm?->reportsTo;

        if ($regionalManager) {
            $regionalManager->notify(
                new AuditOverdueNotification($audit, $daysOverdue, 'regional_manager')
            );
        }
    }

    protected function escalateToAdmin(POSMAudit $audit, int $daysOverdue)
    {
        // Get all admins
        $admins = User::where('role', 'admin')->get();

        foreach ($admins as $admin) {
            $admin->notify(
                new AuditOverdueNotification($audit, $daysOverdue, 'admin')
            );
        }
    }
}
```

---

## Task 3: Check Service Request SLA

### Schedule

**Frequency:** Every hour

### Command

**File:** `app/Console/Commands/CheckSLACommand.php`

```php
namespace App\Console\Commands;

use App\Jobs\CheckSLAJob;
use Illuminate\Console\Command;

class CheckSLACommand extends Command
{
    protected $signature = 'sr:check-sla';
    protected $description = 'Check service requests for SLA breaches and send warnings';

    public function handle()
    {
        CheckSLAJob::dispatch();
        return Command::SUCCESS;
    }
}
```

### Job Implementation

**File:** `app/Jobs/CheckSLAJob.php`

```php
namespace App\Jobs;

use App\Models\ServiceRequest;
use App\Notifications\SLAWarningNotification;
use App\Notifications\SLABreachedNotification;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class CheckSLAJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function handle()
    {
        Log::info('Checking service request SLAs');

        // Get open/in-progress service requests
        $serviceRequests = ServiceRequest::whereIn('status', ['open', 'in_progress'])
            ->with(['assignedTo', 'assignedTo.reportsTo', 'createdBy'])
            ->get();

        $warningsSent = 0;
        $breachNotifications = 0;

        foreach ($serviceRequests as $sr) {
            try {
                $now = Carbon::now();
                $slaDue = Carbon::parse($sr->sla_due_date);
                $hoursRemaining = $now->diffInHours($slaDue, false); // Negative if overdue

                if ($hoursRemaining < 0) {
                    // SLA breached
                    $this->handleSLABreach($sr, abs($hoursRemaining));
                    $breachNotifications++;
                } else {
                    // Calculate percentage elapsed
                    $totalHours = Carbon::parse($sr->created_at)->diffInHours($slaDue);
                    $elapsedPercentage = (($totalHours - $hoursRemaining) / $totalHours) * 100;

                    if ($elapsedPercentage >= 80 && $elapsedPercentage < 100) {
                        // 80% threshold - send warning
                        $this->sendSLAWarning($sr, $hoursRemaining);
                        $warningsSent++;
                    }
                }

            } catch (\Exception $e) {
                Log::error("Failed to check SLA for SR {$sr->id}: {$e->getMessage()}");
            }
        }

        Log::info("SLA check completed. Warnings: {$warningsSent}, Breaches: {$breachNotifications}");
    }

    protected function sendSLAWarning(ServiceRequest $sr, float $hoursRemaining)
    {
        // Check if warning already sent
        if ($sr->sla_warning_sent_at) {
            return; // Already sent
        }

        // Send notification to assigned user
        if ($sr->assignedTo) {
            $sr->assignedTo->notify(
                new SLAWarningNotification($sr, $hoursRemaining)
            );
        }

        // Mark warning as sent
        $sr->update(['sla_warning_sent_at' => Carbon::now()]);
    }

    protected function handleSLABreach(ServiceRequest $sr, float $hoursOverdue)
    {
        // Check if breach already notified
        if ($sr->sla_breached_at) {
            return; // Already handled
        }

        // Mark as breached
        $sr->update(['sla_breached_at' => Carbon::now()]);

        // Notify assigned user
        if ($sr->assignedTo) {
            $sr->assignedTo->notify(
                new SLABreachedNotification($sr, $hoursOverdue)
            );

            // Notify manager
            if ($sr->assignedTo->reportsTo) {
                $sr->assignedTo->reportsTo->notify(
                    new SLABreachedNotification($sr, $hoursOverdue)
                );
            }
        }

        // Log to Red Flags
        Log::channel('red_flags')->warning("SLA breached for SR {$sr->sr_number}", [
            'sr_id' => $sr->id,
            'hours_overdue' => $hoursOverdue,
            'assigned_to' => $sr->assignedTo?->name,
        ]);
    }
}
```

---

## Task 4: Check Forgotten Check-outs

### Schedule

**Frequency:** Every hour (between 06:00 and 22:00)

### Command

**File:** `app/Console/Commands/CheckForgottenCheckoutsCommand.php`

```php
namespace App\Console\Commands;

use App\Jobs\CheckForgottenCheckoutsJob;
use Illuminate\Console\Command;

class CheckForgottenCheckoutsCommand extends Command
{
    protected $signature = 'visits:check-forgotten-checkouts';
    protected $description = 'Check for visits without check-out and remind users';

    public function handle()
    {
        CheckForgottenCheckoutsJob::dispatch();
        return Command::SUCCESS;
    }
}
```

### Job Implementation

**File:** `app/Jobs/CheckForgottenCheckoutsJob.php`

```php
namespace App\Jobs;

use App\Models\OutletVisit;
use App\Notifications\ForgottenCheckoutNotification;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class CheckForgottenCheckoutsJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function handle()
    {
        Log::info('Checking for forgotten check-outs');

        // Find visits without check-out, checked in more than 12 hours ago
        $twelveHoursAgo = Carbon::now()->subHours(12);

        $forgottenVisits = OutletVisit::whereNull('check_out_time')
            ->where('check_in_time', '<', $twelveHoursAgo)
            ->where('checkout_reminder_sent', false)
            ->with(['user', 'outlet'])
            ->get();

        $remindersSent = 0;

        foreach ($forgottenVisits as $visit) {
            try {
                // Send notification to user
                $visit->user->notify(
                    new ForgottenCheckoutNotification($visit)
                );

                // Mark reminder as sent
                $visit->update(['checkout_reminder_sent' => true]);

                $remindersSent++;

            } catch (\Exception $e) {
                Log::error("Failed to send reminder for visit {$visit->id}: {$e->getMessage()}");
            }
        }

        Log::info("Sent {$remindersSent} check-out reminders");
    }
}
```

---

## Task 5: Refresh Dashboard Cache

### Schedule

**Frequency:** Every 15 minutes

### Command

**File:** `app/Console/Commands/RefreshDashboardCacheCommand.php`

```php
namespace App\Console\Commands;

use App\Services\DashboardService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

class RefreshDashboardCacheCommand extends Command
{
    protected $signature = 'cache:refresh-dashboard';
    protected $description = 'Refresh dashboard metrics cache';

    public function handle(DashboardService $dashboardService)
    {
        Log::info('Refreshing dashboard cache');

        try {
            // Pre-compute dashboard metrics for common views
            $dateRanges = ['today', 'last_7_days', 'last_30_days'];

            foreach ($dateRanges as $range) {
                // For all users (admin view)
                $metrics = $dashboardService->getMetrics($range, null);
                Cache::put("dashboard:metrics:{$range}:all", $metrics, 900); // 15 min TTL
            }

            Log::info('Dashboard cache refreshed successfully');
            return Command::SUCCESS;

        } catch (\Exception $e) {
            Log::error("Failed to refresh dashboard cache: {$e->getMessage()}");
            return Command::FAILURE;
        }
    }
}
```

---

## Task 6: Archive Old Data

### Schedule

**Frequency:** Monthly (1st of month at 01:00)

### Command

**File:** `app/Console/Commands/ArchiveDataCommand.php`

```php
namespace App\Console\Commands;

use App\Jobs\ArchiveDataJob;
use Illuminate\Console\Command;

class ArchiveDataCommand extends Command
{
    protected $signature = 'data:archive';
    protected $description = 'Archive old data to archive tables';

    public function handle()
    {
        $this->info('Starting data archival...');

        ArchiveDataJob::dispatch();

        $this->info('Archival job dispatched');
        return Command::SUCCESS;
    }
}
```

### Job Implementation

**File:** `app/Jobs/ArchiveDataJob.php`

```php
namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Carbon\Carbon;

class ArchiveDataJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $timeout = 3600; // 1 hour

    public function handle()
    {
        Log::info('Starting data archival');

        DB::transaction(function () {
            // Archive visits older than 3 years
            $this->archiveVisits();

            // Archive workflow executions older than 3 years
            $this->archiveWorkflowExecutions();

            // Archive photos older than 2 years
            $this->archivePhotos();
        });

        Log::info('Data archival completed');
    }

    protected function archiveVisits()
    {
        $threeYearsAgo = Carbon::now()->subYears(3);

        $count = DB::table('outlet_visits')
            ->where('check_in_time', '<', $threeYearsAgo)
            ->count();

        if ($count > 0) {
            // Copy to archive table
            DB::insert("
                INSERT INTO outlet_visits_archive
                SELECT * FROM outlet_visits
                WHERE check_in_time < ?
            ", [$threeYearsAgo]);

            // Delete from main table
            DB::table('outlet_visits')
                ->where('check_in_time', '<', $threeYearsAgo)
                ->delete();

            Log::info("Archived {$count} visit records");
        }
    }

    protected function archiveWorkflowExecutions()
    {
        // Similar implementation for workflow executions
    }

    protected function archivePhotos()
    {
        // Similar implementation for photos
        // Also move files to archive storage location
    }
}
```

---

## Queue Configuration

### Queue Setup

**config/queue.php:**

```php
'connections' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => 'default',
        'queue' => env('REDIS_QUEUE', 'default'),
        'retry_after' => 90,
        'block_for' => null,
    ],
],

'failed' => [
    'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
    'database' => env('DB_CONNECTION', 'mysql'),
    'table' => 'failed_jobs',
],
```

### Supervisor Configuration

**File:** `/etc/supervisor/conf.d/laravel-worker.conf`

```
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/horeca/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=8
redirect_stderr=true
stdout_logfile=/var/www/horeca/storage/logs/worker.log
stopwaitsecs=3600
```

### Start Queue Workers

```bash
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start laravel-worker:*
```

---

## Monitoring & Alerts

### Failed Job Handling

**Monitor failed jobs:**

```bash
php artisan queue:failed
```

**Retry failed jobs:**

```bash
php artisan queue:retry all
```

### Health Check Endpoint

**Route:**

```php
Route::get('/health-check', function () {
    return response()->json([
        'status' => 'healthy',
        'database' => DB::connection()->getPdo() ? 'connected' : 'disconnected',
        'redis' => Redis::connection()->ping() ? 'connected' : 'disconnected',
        'queue' => Queue::size() < 1000 ? 'healthy' : 'overloaded',
    ]);
});
```

### Logging

**config/logging.php:**

```php
'channels' => [
    'scheduler' => [
        'driver' => 'daily',
        'path' => storage_path('logs/scheduler.log'),
        'level' => 'debug',
        'days' => 14,
    ],

    'red_flags' => [
        'driver' => 'daily',
        'path' => storage_path('logs/red_flags.log'),
        'level' => 'warning',
        'days' => 90,
    ],
],
```

---

**Document Status:** Final

**Next Steps:** Share with DevOps team for deployment and monitoring setup
