Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 641
0.00% covered (danger)
0.00%
0 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
EventService
0.00% covered (danger)
0.00%
0 / 641
0.00% covered (danger)
0.00%
0 / 31
20880
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getAllEvents
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 getEventById
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 getActiveEvents
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 getUpcomingEvents
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 createEvent
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
72
 updateEvent
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
342
 deleteEvent
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 createFromTemplate
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 getBinsToPull
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
56
 getBinsToStore
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
42
 getBinsForEvent
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getBinCountsByCategoryIds
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
12
 getBinCountsByStatus
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
56
 getBinCountsByPhase
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
12
 updateEventProgress
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 getEventReadiness
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
182
 getSeasonalReadiness
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 generateAlerts
0.00% covered (danger)
0.00%
0 / 80
0.00% covered (danger)
0.00%
0 / 1
552
 getUnacknowledgedAlerts
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 acknowledgeAlert
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getDashboardSummary
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
6
 getEventTimeline
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
12
 getTemplates
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getTemplateById
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getCategoryIdsForEvent
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getBinsByCategories
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
56
 formatBinForPull
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
2
 formatBinForStore
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
2
 calculatePhaseForEvent
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 createAlert
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace BuyerKiosk\Backstock;
4
5/**
6 * EventService - Business logic for seasonal events management
7 *
8 * Orchestrates the seasonal events system, including event lifecycle management,
9 * bin operations, progress tracking, and alert generation.
10 */
11class EventService extends Backstock
12{
13    /** @var BackstockFactory */
14    private $factory;
15
16    public function __construct(\Store $store)
17    {
18        parent::__construct($store);
19        $this->factory = new BackstockFactory($store);
20    }
21
22    // ==================== EVENT MANAGEMENT ====================
23
24    /**
25     * Get all events with their progress and category counts
26     * @return array
27     */
28    public function getAllEvents()
29    {
30        $event = new Event($this->store);
31        $eventCategory = new EventCategory($this->store);
32        $eventProgress = new EventProgress($this->store);
33
34        $events = $event->getAll();
35
36        foreach ($events as &$eventData) {
37            // Add categories
38            $eventData['categories'] = $eventCategory->getByEventId($eventData['id']);
39            $eventData['categoryCount'] = count($eventData['categories']);
40
41            // Add progress
42            $progress = $eventProgress->getByEventId($eventData['id']);
43            $eventData['progress'] = $progress ? $progress->toArray() : null;
44
45            // Add current phase
46            $eventObj = new Event($this->store);
47            $eventObj->getById($eventData['id']);
48            $eventData['currentPhase'] = $eventObj->getCurrentPhase();
49            $eventData['buildUpStartDate'] = $eventObj->getBuildUpStartDate();
50            $eventData['windDownEndDate'] = $eventObj->getWindDownEndDate();
51        }
52
53        return $events;
54    }
55
56    /**
57     * Get single event with full details
58     * @param int $id Event ID
59     * @return array|null
60     */
61    public function getEventById($id)
62    {
63        $event = new Event($this->store);
64        $event->getById($id);
65
66        if (!$event->getId()) {
67            return null;
68        }
69
70        $eventCategory = new EventCategory($this->store);
71        $eventProgress = new EventProgress($this->store);
72        $eventAlert = new EventAlert($this->store);
73
74        $data = $event->toArray();
75        $data['categories'] = $eventCategory->getByEventId($id);
76        $data['categoryCount'] = count($data['categories']);
77
78        $progress = $eventProgress->getByEventId($id);
79        $data['progress'] = $progress ? $progress->toArray() : null;
80
81        $data['alerts'] = $eventAlert->getByEventId($id);
82        $data['unacknowledgedAlerts'] = $eventAlert->getUnacknowledgedByEventId($id);
83
84        // Get bin counts by phase
85        $data['binCounts'] = $this->getBinCountsByPhase($id);
86
87        return $data;
88    }
89
90    /**
91     * Get currently active events (within start/end date range)
92     * @return array
93     */
94    public function getActiveEvents()
95    {
96        $event = new Event($this->store);
97        $events = $event->getActive();
98
99        $this->log->logInfo("getActiveEvents: Found " . count($events) . " events with isActive=1");
100
101        // Filter to only events currently running (not just is_active flag)
102        $activeEvents = [];
103        foreach ($events as $eventData) {
104            $eventObj = new Event($this->store);
105            $eventObj->getById($eventData['id']);
106
107            $phase = $eventObj->getCurrentPhase();
108            $this->log->logInfo("Event '{$eventData['name']}' (id={$eventData['id']}): startDate={$eventData['startDate']}, endDate={$eventData['endDate']}, phase={$phase}");
109
110            if ($phase === 'active') {
111                $activeEvents[] = $this->getEventById($eventData['id']);
112            }
113        }
114
115        $this->log->logInfo("getActiveEvents: Returning " . count($activeEvents) . " active events");
116        return $activeEvents;
117    }
118
119    /**
120     * Get events starting in the next N days
121     * @param int $days Number of days to look ahead
122     * @return array
123     */
124    public function getUpcomingEvents($days = 30)
125    {
126        $event = new Event($this->store);
127        $events = $event->getUpcoming($days);
128
129        foreach ($events as &$eventData) {
130            $eventCategory = new EventCategory($this->store);
131            $eventData['categories'] = $eventCategory->getByEventId($eventData['id']);
132            $eventData['categoryCount'] = count($eventData['categories']);
133        }
134
135        return $events;
136    }
137
138    /**
139     * Create new event from array data
140     * @param array $data Event data
141     * @return Event|null
142     */
143    public function createEvent($data)
144    {
145        try {
146            $this->log->logInfo("EventService::createEvent called with data: " . json_encode($data));
147
148            $event = new Event($this->store);
149
150            // Set event properties
151            if (isset($data['templateId'])) $event->setTemplateId($data['templateId']);
152            $event->setName($data['name']);
153            $event->setEventType($data['eventType']);
154            $event->setYear($data['year']);
155            $event->setStartDate($data['startDate']);
156            $event->setEndDate($data['endDate']);
157            $event->setBuildUpDays($data['buildUpDays'] ?? 14);
158            $event->setWindDownDays($data['windDownDays'] ?? 7);
159
160            if (isset($data['color'])) $event->setColor($data['color']);
161            if (isset($data['icon'])) $event->setIcon($data['icon']);
162            if (isset($data['notes'])) $event->setNotes($data['notes']);
163
164            $event->setIsActive($data['isActive'] ?? true);
165            $event->setIsRecurring($data['isRecurring'] ?? true);
166
167            $this->log->logInfo("Creating event: " . $event->getName());
168            $event->create();
169            $this->log->logInfo("Event created with ID: " . $event->getId());
170
171            // Link categories if provided
172            if (isset($data['categoryIds']) && is_array($data['categoryIds'])) {
173                $eventCategory = new EventCategory($this->store);
174                $priorities = $data['categoryPriorities'] ?? [];
175                $eventCategory->syncCategories($event->getId(), $data['categoryIds'], $priorities);
176            }
177
178            // Create initial progress record
179            $progress = new EventProgress($this->store);
180            $progress->getOrCreate($event->getId());
181
182            $this->log->logInfo("Created event: " . $event->getName() . " (ID: " . $event->getId() . ")");
183
184            return $event;
185        } catch (\Exception $e) {
186            $this->log->logError("EventService::createEvent failed: " . $e->getMessage());
187            return null;
188        }
189    }
190
191    /**
192     * Update existing event
193     * @param int $id Event ID
194     * @param array $data Updated event data
195     * @return Event|null
196     */
197    public function updateEvent($id, $data)
198    {
199        try {
200            $event = new Event($this->store);
201            $event->getById($id);
202
203            if (!$event->getId()) {
204                return null;
205            }
206
207            // Update event properties
208            if (isset($data['templateId'])) $event->setTemplateId($data['templateId']);
209            if (isset($data['name'])) $event->setName($data['name']);
210            if (isset($data['eventType'])) $event->setEventType($data['eventType']);
211            if (isset($data['year'])) $event->setYear($data['year']);
212            if (isset($data['startDate'])) $event->setStartDate($data['startDate']);
213            if (isset($data['endDate'])) $event->setEndDate($data['endDate']);
214            if (isset($data['buildUpDays'])) $event->setBuildUpDays($data['buildUpDays']);
215            if (isset($data['windDownDays'])) $event->setWindDownDays($data['windDownDays']);
216            if (isset($data['color'])) $event->setColor($data['color']);
217            if (isset($data['icon'])) $event->setIcon($data['icon']);
218            if (isset($data['notes'])) $event->setNotes($data['notes']);
219            if (isset($data['isActive'])) $event->setIsActive($data['isActive']);
220            if (isset($data['isRecurring'])) $event->setIsRecurring($data['isRecurring']);
221
222            $event->update();
223
224            // Update categories if provided
225            if (isset($data['categoryIds']) && is_array($data['categoryIds'])) {
226                $eventCategory = new EventCategory($this->store);
227                $priorities = $data['categoryPriorities'] ?? [];
228                $eventCategory->syncCategories($id, $data['categoryIds'], $priorities);
229            }
230
231            // Update progress phase if needed
232            $this->updateEventProgress($id);
233
234            $this->log->logInfo("Updated event: " . $event->getName() . " (ID: " . $id . ")");
235
236            return $event;
237        } catch (\Exception $e) {
238            $this->log->logError("EventService::updateEvent failed: " . $e->getMessage());
239            return null;
240        }
241    }
242
243    /**
244     * Delete event and related data
245     * @param int $id Event ID
246     * @return bool
247     */
248    public function deleteEvent($id)
249    {
250        try {
251            // Delete in transaction
252            $this->storeDB->beginTransaction();
253
254            // Delete event categories
255            $stmt = $this->storeDB->prepare("DELETE FROM bsEvent_Categories WHERE eventId = :eventId");
256            $stmt->bindValue(":eventId", $id, \PDO::PARAM_INT);
257            $stmt->execute();
258
259            // Delete event progress
260            $stmt = $this->storeDB->prepare("DELETE FROM bsEvent_Progress WHERE eventId = :eventId");
261            $stmt->bindValue(":eventId", $id, \PDO::PARAM_INT);
262            $stmt->execute();
263
264            // Delete event alerts
265            $stmt = $this->storeDB->prepare("DELETE FROM bsEvent_Alerts WHERE eventId = :eventId");
266            $stmt->bindValue(":eventId", $id, \PDO::PARAM_INT);
267            $stmt->execute();
268
269            // Delete event
270            $event = new Event($this->store);
271            $event->getById($id);
272            $event->delete();
273
274            $this->storeDB->commit();
275
276            $this->log->logInfo("Deleted event ID: " . $id);
277
278            return true;
279        } catch (\Exception $e) {
280            $this->storeDB->rollBack();
281            $this->log->logError("EventService::deleteEvent failed: " . $e->getMessage());
282            return false;
283        }
284    }
285
286    /**
287     * Create event from global template
288     * @param int $templateId Template ID from kiosk_buykiosk
289     * @param int $year Year for the event
290     * @param string $startDate Start date (Y-m-d)
291     * @param string $endDate End date (Y-m-d)
292     * @return Event|null
293     */
294    public function createFromTemplate($templateId, $year, $startDate, $endDate)
295    {
296        try {
297            $template = $this->getTemplateById($templateId);
298
299            if (!$template) {
300                $this->log->logError("Template ID $templateId not found");
301                return null;
302            }
303
304            $data = [
305                'templateId' => $templateId,
306                'name' => $template['name'] . " " . $year,
307                'eventType' => $template['eventType'],
308                'year' => $year,
309                'startDate' => $startDate,
310                'endDate' => $endDate,
311                'buildUpDays' => $template['defaultBuildUpDays'],
312                'windDownDays' => $template['defaultWindDownDays'],
313                'color' => $template['color'],
314                'icon' => $template['icon'],
315                'notes' => $template['description'],
316                'isActive' => true,
317                'isRecurring' => true
318            ];
319
320            // Create event without preset categories - user can add categories later
321            return $this->createEvent($data);
322        } catch (\Exception $e) {
323            $this->log->logError("EventService::createFromTemplate failed: " . $e->getMessage());
324            return null;
325        }
326    }
327
328    // ==================== BIN OPERATIONS ====================
329
330    /**
331     * Get bins that should be pulled from storage for active or build-up phase events
332     * Only returns bins that are offsite (stored) and not finished
333     * @param int $daysAhead Optional number of days ahead to look for events (currently unused, reserved for future)
334     * @return array
335     */
336    public function getBinsToPull(int $daysAhead = 14)
337    {
338        try {
339            $binsToPull = [];
340
341            // Get events in active phase or build-up phase (prep period)
342            // Active: startDate <= today AND endDate >= today
343            // Build-up: build-up start (startDate - buildUpDays) <= today AND startDate > today
344            $stmt = $this->storeDB->prepare("
345                SELECT id, name, startDate, endDate, buildUpDays, color, icon
346                FROM bsEvents
347                WHERE isActive = 1
348                AND (
349                    -- Active phase: between start and end date
350                    (startDate <= CURDATE() AND endDate >= CURDATE())
351                    OR
352                    -- Build-up phase: between build-up start and event start
353                    (DATE_SUB(startDate, INTERVAL buildUpDays DAY) <= CURDATE() AND startDate > CURDATE())
354                )
355                ORDER BY startDate ASC
356            ");
357            $stmt->execute();
358            $events = $stmt->fetchAll(\PDO::FETCH_ASSOC);
359
360            foreach ($events as $eventData) {
361                $categoryIds = $this->getCategoryIdsForEvent($eventData['id']);
362
363                if (empty($categoryIds)) {
364                    continue;
365                }
366
367                // Get bins in off-site locations that are NOT finished (excludeFinished = true)
368                $bins = $this->getBinsByCategories($categoryIds, false, true); // false = off-site, true = exclude finished
369
370                foreach ($bins as $bin) {
371                    $binsToPull[] = $this->formatBinForPull($bin, $eventData);
372                }
373            }
374
375            // Sort by event start date, then category priority, then bin age
376            usort($binsToPull, function($a, $b) {
377                if ($a['eventStartDate'] != $b['eventStartDate']) {
378                    return strcmp($a['eventStartDate'], $b['eventStartDate']);
379                }
380                if ($a['priority'] != $b['priority']) {
381                    return $b['priority'] - $a['priority'];
382                }
383                return $b['age'] - $a['age'];
384            });
385
386            return $binsToPull;
387        } catch (\Exception $e) {
388            $this->log->logError("EventService::getBinsToPull failed: " . $e->getMessage());
389            return [];
390        }
391    }
392
393    /**
394     * Get bins that should be stored offsite - bins for events 60+ days out that are currently onsite
395     * These are seasonal bins that don't need to be on the floor yet
396     * @param int $daysOut Minimum days until event start (default 60)
397     * @return array
398     */
399    public function getBinsToStore($daysOut = 60)
400    {
401        try {
402            $binsToStore = [];
403
404            // Get events that are 60+ days out (not yet in build-up phase)
405            // These bins can be stored offsite until closer to the event
406            $stmt = $this->storeDB->prepare("
407                SELECT id, name, startDate, endDate, buildUpDays, color, icon
408                FROM bsEvents
409                WHERE isActive = 1
410                AND DATE_SUB(startDate, INTERVAL buildUpDays DAY) > DATE_ADD(CURDATE(), INTERVAL :days_out DAY)
411                ORDER BY startDate ASC
412            ");
413            $stmt->bindValue(":days_out", $daysOut, \PDO::PARAM_INT);
414            $stmt->execute();
415            $events = $stmt->fetchAll(\PDO::FETCH_ASSOC);
416
417            foreach ($events as $eventData) {
418                $categoryIds = $this->getCategoryIdsForEvent($eventData['id']);
419
420                if (empty($categoryIds)) {
421                    continue;
422                }
423
424                // Get bins that are ON-SITE with these categories - these could be stored offsite
425                $bins = $this->getBinsByCategories($categoryIds, true); // true = on-site only
426
427                foreach ($bins as $bin) {
428                    $binsToStore[] = $this->formatBinForStore($bin, $eventData);
429                }
430            }
431
432            // Sort by event start date (furthest out first), then bin age
433            usort($binsToStore, function($a, $b) {
434                // Sort by start date ascending (soonest first, but all are 60+ days out)
435                if ($a['eventStartDate'] != $b['eventStartDate']) {
436                    return strcmp($a['eventStartDate'], $b['eventStartDate']);
437                }
438                return $b['age'] - $a['age'];
439            });
440
441            return $binsToStore;
442        } catch (\Exception $e) {
443            $this->log->logError("EventService::getBinsToStore failed: " . $e->getMessage());
444            return [];
445        }
446    }
447
448    /**
449     * Get all bins linked to an event's categories
450     * @param int $eventId Event ID
451     * @return array
452     */
453    public function getBinsForEvent($eventId)
454    {
455        $categoryIds = $this->getCategoryIdsForEvent($eventId);
456
457        $this->log->logInfo("getBinsForEvent($eventId): categoryIds = " . json_encode($categoryIds));
458
459        if (empty($categoryIds)) {
460            $this->log->logInfo("getBinsForEvent($eventId): No categories linked to event");
461            return [];
462        }
463
464        $bins = $this->getBinsByCategories($categoryIds);
465        $this->log->logInfo("getBinsForEvent($eventId): Found " . count($bins) . " bins");
466
467        return $bins;
468    }
469
470    /**
471     * Get bin counts by category IDs (for previewing when creating events)
472     * @param array $categoryIds Array of category IDs
473     * @return array
474     */
475    public function getBinCountsByCategoryIds($categoryIds)
476    {
477        try {
478            if (empty($categoryIds)) {
479                return [
480                    'total' => 0,
481                    'onsite' => 0,
482                    'offsite' => 0
483                ];
484            }
485
486            $placeholders = implode(',', array_fill(0, count($categoryIds), '?'));
487
488            $stmt = $this->storeDB->prepare("
489                SELECT
490                    COUNT(DISTINCT b.id) as total,
491                    SUM(CASE WHEN l.onsite = 1 THEN 1 ELSE 0 END) as onsite,
492                    SUM(CASE WHEN l.onsite = 0 OR l.onsite IS NULL THEN 1 ELSE 0 END) as offsite
493                FROM bsBins b
494                LEFT JOIN bsLocations l ON b.location = l.id
495                LEFT JOIN bsBin_Cat bc ON b.id = bc.binID
496                WHERE b.deleted = 0
497                AND (b.mainCategory IN ($placeholders) OR bc.catID IN ($placeholders))
498            ");
499
500            // Bind category IDs twice (for mainCategory and subCategories)
501            $params = array_merge($categoryIds, $categoryIds);
502            $stmt->execute($params);
503
504            $result = $stmt->fetch(\PDO::FETCH_ASSOC);
505
506            return [
507                'total' => (int)$result['total'],
508                'onsite' => (int)$result['onsite'],
509                'offsite' => (int)$result['offsite']
510            ];
511        } catch (\Exception $e) {
512            $this->log->logError("EventService::getBinCountsByCategoryIds failed: " . $e->getMessage());
513            return [
514                'total' => 0,
515                'onsite' => 0,
516                'offsite' => 0
517            ];
518        }
519    }
520
521    /**
522     * Get bin counts by status (stored/pulled/finished) for an event
523     * - Stored: bin location is offsite (onsite=0)
524     * - Pulled: bin location is onsite (onsite=1) AND last action != 0
525     * - Finished: last action = 0 (Removed Everything)
526     *
527     * @param int $eventId Event ID
528     * @return array
529     */
530    public function getBinCountsByStatus($eventId)
531    {
532        try {
533            $categoryIds = $this->getCategoryIdsForEvent($eventId);
534
535            if (empty($categoryIds)) {
536                return [
537                    'total' => 0,
538                    'stored' => 0,
539                    'pulled' => 0,
540                    'finished' => 0
541                ];
542            }
543
544            $placeholders = implode(',', array_fill(0, count($categoryIds), '?'));
545
546            // Get all bins matching event categories with their location and last action
547            $stmt = $this->storeDB->prepare("
548                SELECT DISTINCT b.id, b.location, l.onsite,
549                    (SELECT action FROM bsActions WHERE binID = b.id ORDER BY timePerformed DESC, id DESC LIMIT 1) as lastAction
550                FROM bsBins b
551                LEFT JOIN bsLocations l ON b.location = l.id
552                LEFT JOIN bsBin_Cat bc ON b.id = bc.binID
553                WHERE b.deleted = 0
554                AND (b.mainCategory IN ($placeholders) OR bc.catID IN ($placeholders))
555            ");
556
557            $params = array_merge($categoryIds, $categoryIds);
558            $stmt->execute($params);
559            $bins = $stmt->fetchAll(\PDO::FETCH_ASSOC);
560
561            $counts = [
562                'total' => count($bins),
563                'stored' => 0,
564                'pulled' => 0,
565                'finished' => 0
566            ];
567
568            foreach ($bins as $bin) {
569                $lastAction = $bin['lastAction'];
570                $onsite = (int)$bin['onsite'];
571
572                if ($lastAction === '0' || $lastAction === 0) {
573                    // Last action was "Removed Everything" = Finished
574                    $counts['finished']++;
575                } elseif ($onsite === 1) {
576                    // On-site but not emptied = Pulled
577                    $counts['pulled']++;
578                } else {
579                    // Off-site = Stored
580                    $counts['stored']++;
581                }
582            }
583
584            return $counts;
585        } catch (\Exception $e) {
586            $this->log->logError("EventService::getBinCountsByStatus failed: " . $e->getMessage());
587            return [
588                'total' => 0,
589                'stored' => 0,
590                'pulled' => 0,
591                'finished' => 0
592            ];
593        }
594    }
595
596    /**
597     * Get bin counts by location type (onsite/offsite) - legacy method
598     * @param int $eventId Event ID
599     * @return array
600     */
601    public function getBinCountsByPhase($eventId)
602    {
603        try {
604            $categoryIds = $this->getCategoryIdsForEvent($eventId);
605
606            if (empty($categoryIds)) {
607                return [
608                    'total' => 0,
609                    'onsite' => 0,
610                    'offsite' => 0
611                ];
612            }
613
614            $placeholders = implode(',', array_fill(0, count($categoryIds), '?'));
615
616            $stmt = $this->storeDB->prepare("
617                SELECT
618                    COUNT(DISTINCT b.id) as total,
619                    SUM(CASE WHEN l.onsite = 1 THEN 1 ELSE 0 END) as onsite,
620                    SUM(CASE WHEN l.onsite = 0 THEN 1 ELSE 0 END) as offsite
621                FROM bsBins b
622                LEFT JOIN bsLocations l ON b.location = l.id
623                LEFT JOIN bsBin_Cat bc ON b.id = bc.binID
624                WHERE b.deleted = 0
625                AND (b.mainCategory IN ($placeholders) OR bc.catID IN ($placeholders))
626            ");
627
628            // Bind category IDs twice (for mainCategory and subCategories)
629            $params = array_merge($categoryIds, $categoryIds);
630            $stmt->execute($params);
631
632            $result = $stmt->fetch(\PDO::FETCH_ASSOC);
633
634            return [
635                'total' => (int)$result['total'],
636                'onsite' => (int)$result['onsite'],
637                'offsite' => (int)$result['offsite']
638            ];
639        } catch (\Exception $e) {
640            $this->log->logError("EventService::getBinCountsByPhase failed: " . $e->getMessage());
641            return [
642                'total' => 0,
643                'onsite' => 0,
644                'offsite' => 0
645            ];
646        }
647    }
648
649    // ==================== PROGRESS TRACKING ====================
650
651    /**
652     * Update event progress based on actual bin locations
653     * @param int $eventId Event ID
654     * @return EventProgress|null
655     */
656    public function updateEventProgress($eventId)
657    {
658        try {
659            $event = new Event($this->store);
660            $event->getById($eventId);
661
662            if (!$event->getId()) {
663                return null;
664            }
665
666            $progress = new EventProgress($this->store);
667            $progress->getOrCreate($eventId);
668
669            // Update phase
670            $phase = $event->getCurrentPhase();
671            $progress->setPhase($phase);
672
673            // Get bin counts
674            $counts = $this->getBinCountsByPhase($eventId);
675            $progress->setBinsOnFloor($counts['onsite']);
676            $progress->setBinsStored($counts['offsite']);
677            $progress->setBinsPulled($counts['total'] - $counts['offsite']);
678
679            $progress->update();
680
681            return $progress;
682        } catch (\Exception $e) {
683            $this->log->logError("EventService::updateEventProgress failed: " . $e->getMessage());
684            return null;
685        }
686    }
687
688    /**
689     * Calculate readiness percentage for an event
690     * @param int $eventId Event ID
691     * @return array
692     */
693    public function getEventReadiness($eventId)
694    {
695        try {
696            $event = new Event($this->store);
697            $event->getById($eventId);
698
699            $phase = $event->getCurrentPhase();
700            $counts = $this->getBinCountsByStatus($eventId);
701
702            $readiness = 0;
703            $status = 'unknown';
704            $showProgress = true; // Whether to show progress bar or just bin count
705
706            if ($counts['total'] == 0) {
707                $readiness = 0;
708                $status = 'no_bins';
709                $showProgress = false;
710            } elseif ($phase === 'upcoming') {
711                // Event hasn't started build-up yet - just show bin count, no progress
712                $readiness = 0;
713                $status = 'pending';
714                $showProgress = false;
715            } elseif ($phase === 'build_up' || $phase === 'active') {
716                // During build-up/active, readiness = % of bins finished (emptied onto floor)
717                $readiness = $counts['total'] > 0 ? round(($counts['finished'] / $counts['total']) * 100) : 0;
718                $status = $readiness >= 80 ? 'ready' : ($readiness >= 50 ? 'partial' : 'not_ready');
719            } elseif ($phase === 'wind_down') {
720                // During wind-down, readiness = % of bins stored back
721                $readiness = $counts['total'] > 0 ? round(($counts['stored'] / $counts['total']) * 100) : 0;
722                $status = $readiness >= 80 ? 'ready' : ($readiness >= 50 ? 'partial' : 'not_ready');
723            } else {
724                $readiness = 100;
725                $status = 'complete';
726            }
727
728            return [
729                'eventId' => $eventId,
730                'phase' => $phase,
731                'readiness' => $readiness,
732                'status' => $status,
733                'showProgress' => $showProgress,
734                'binCounts' => $counts
735            ];
736        } catch (\Exception $e) {
737            $this->log->logError("EventService::getEventReadiness failed: " . $e->getMessage());
738            return [
739                'eventId' => $eventId,
740                'phase' => 'unknown',
741                'readiness' => 0,
742                'status' => 'error',
743                'showProgress' => false,
744                'binCounts' => ['total' => 0, 'stored' => 0, 'pulled' => 0, 'finished' => 0]
745            ];
746        }
747    }
748
749    /**
750     * Get readiness summary for all active and upcoming events
751     * @return array
752     */
753    public function getSeasonalReadiness()
754    {
755        try {
756            // Get all active events that are currently in build_up, active, or upcoming phases
757            $stmt = $this->storeDB->prepare("
758                SELECT id, name, startDate, endDate
759                FROM bsEvents
760                WHERE isActive = 1
761                AND endDate >= CURDATE()
762                ORDER BY startDate ASC
763            ");
764            $stmt->execute();
765            $events = $stmt->fetchAll(\PDO::FETCH_ASSOC);
766
767            $readiness = [];
768            foreach ($events as $eventData) {
769                $eventObj = new Event($this->store);
770                $eventObj->getById($eventData['id']);
771                $phase = $eventObj->getCurrentPhase();
772
773                // Show readiness for all events until they're complete
774                if (in_array($phase, ['upcoming', 'build_up', 'active', 'wind_down'])) {
775                    $eventReadiness = $this->getEventReadiness($eventData['id']);
776                    $eventReadiness['name'] = $eventData['name'];
777                    $eventReadiness['readinessPercent'] = $eventReadiness['readiness'];
778                    $readiness[] = $eventReadiness;
779                }
780            }
781
782            return $readiness;
783        } catch (\Exception $e) {
784            $this->log->logError("EventService::getSeasonalReadiness failed: " . $e->getMessage());
785            return [];
786        }
787    }
788
789    // ==================== ALERTS ====================
790
791    /**
792     * Generate alerts based on current state
793     * @return array Generated alerts
794     */
795    public function generateAlerts()
796    {
797        $generatedAlerts = [];
798
799        try {
800            // Get all active events
801            $stmt = $this->storeDB->prepare("
802                SELECT id, name, startDate, endDate, buildUpDays, windDownDays
803                FROM bsEvents
804                WHERE isActive = 1
805            ");
806            $stmt->execute();
807            $events = $stmt->fetchAll(\PDO::FETCH_ASSOC);
808
809            foreach ($events as $eventData) {
810                $eventObj = new Event($this->store);
811                $eventObj->getById($eventData['id']);
812                $phase = $eventObj->getCurrentPhase();
813
814                $today = new \DateTime();
815                $startDate = new \DateTime($eventData['startDate']);
816                $endDate = new \DateTime($eventData['endDate']);
817
818                $buildUpStart = clone $startDate;
819                $buildUpStart->modify("-{$eventData['buildUpDays']} days");
820
821                $windDownEnd = clone $endDate;
822                $windDownEnd->modify("+{$eventData['windDownDays']} days");
823
824                // Check for build-up starting alerts
825                $daysUntilBuildUp = $today->diff($buildUpStart)->days;
826                if ($buildUpStart > $today && $daysUntilBuildUp <= 3) {
827                    $alert = $this->createAlert(
828                        $eventData['id'],
829                        'build_up_starting',
830                        "Build-up period starts in {$daysUntilBuildUp} days for {$eventData['name']}"
831                    );
832                    if ($alert) $generatedAlerts[] = $alert;
833                }
834
835                // Check for build-up behind schedule
836                if ($phase === 'build_up') {
837                    $readiness = $this->getEventReadiness($eventData['id']);
838                    if ($readiness['readiness'] < 50) {
839                        $binCount = $readiness['binCounts']['total'] - $readiness['binCounts']['onsite'];
840                        $alert = $this->createAlert(
841                            $eventData['id'],
842                            'build_up_behind',
843                            "Behind on pulling bins for {$eventData['name']}",
844                            $binCount
845                        );
846                        if ($alert) $generatedAlerts[] = $alert;
847                    }
848                }
849
850                // Check for event starting alerts
851                $daysUntilStart = $today->diff($startDate)->days;
852                if ($startDate > $today && $daysUntilStart <= 3) {
853                    $alert = $this->createAlert(
854                        $eventData['id'],
855                        'event_starting',
856                        "{$eventData['name']} starts in {$daysUntilStart} days"
857                    );
858                    if ($alert) $generatedAlerts[] = $alert;
859                }
860
861                // Check for event started
862                if ($phase === 'active' && $today->diff($startDate)->days == 0) {
863                    $alert = $this->createAlert(
864                        $eventData['id'],
865                        'event_started',
866                        "{$eventData['name']} has started today"
867                    );
868                    if ($alert) $generatedAlerts[] = $alert;
869                }
870
871                // Check for wind-down starting alerts
872                $daysUntilWindDown = $today->diff($endDate)->days;
873                if ($endDate > $today && $daysUntilWindDown <= 3) {
874                    $alert = $this->createAlert(
875                        $eventData['id'],
876                        'wind_down_starting',
877                        "Wind-down period starts in {$daysUntilWindDown} days for {$eventData['name']}"
878                    );
879                    if ($alert) $generatedAlerts[] = $alert;
880                }
881
882                // Check for wind-down behind schedule
883                if ($phase === 'wind_down') {
884                    $readiness = $this->getEventReadiness($eventData['id']);
885                    if ($readiness['readiness'] < 50) {
886                        $binCount = $readiness['binCounts']['onsite'];
887                        $alert = $this->createAlert(
888                            $eventData['id'],
889                            'wind_down_behind',
890                            "Behind on storing bins for {$eventData['name']}",
891                            $binCount
892                        );
893                        if ($alert) $generatedAlerts[] = $alert;
894                    }
895                }
896
897                // Check for event ended
898                if ($phase === 'completed') {
899                    $alert = $this->createAlert(
900                        $eventData['id'],
901                        'event_ended',
902                        "{$eventData['name']} has completed"
903                    );
904                    if ($alert) $generatedAlerts[] = $alert;
905                }
906            }
907
908            return $generatedAlerts;
909        } catch (\Exception $e) {
910            $this->log->logError("EventService::generateAlerts failed: " . $e->getMessage());
911            return [];
912        }
913    }
914
915    /**
916     * Get all unacknowledged alerts
917     * @return array
918     */
919    public function getUnacknowledgedAlerts()
920    {
921        $eventAlert = new EventAlert($this->store);
922        return $eventAlert->getUnacknowledged();
923    }
924
925    /**
926     * Mark alert as acknowledged
927     * @param int $alertId Alert ID
928     * @param int $employeeId Employee ID
929     * @return bool
930     */
931    public function acknowledgeAlert($alertId, $employeeId)
932    {
933        try {
934            $alert = new EventAlert($this->store);
935            $alert->getById($alertId);
936
937            if (!$alert->getId()) {
938                return false;
939            }
940
941            $alert->acknowledge($employeeId);
942
943            return true;
944        } catch (\Exception $e) {
945            $this->log->logError("EventService::acknowledgeAlert failed: " . $e->getMessage());
946            return false;
947        }
948    }
949
950    // ==================== DASHBOARD DATA ====================
951
952    /**
953     * Get summary for dashboard widget
954     * @return array
955     */
956    public function getDashboardSummary()
957    {
958        try {
959            $activeEvents = $this->getActiveEvents();
960            $upcomingEvents = $this->getUpcomingEvents(30);
961            $binsToPull = $this->getBinsToPull(); // Gets bins for active/build-up events that are offsite
962            $binsToStore = $this->getBinsToStore(); // Gets onsite bins for events 60+ days out
963            $alerts = $this->getUnacknowledgedAlerts();
964
965            return [
966                'activeEventsCount' => count($activeEvents),
967                'activeEvents' => array_slice($activeEvents, 0, 3),
968                'upcomingEventsCount' => count($upcomingEvents),
969                'upcomingEvents' => array_slice($upcomingEvents, 0, 5),
970                'binsToPullCount' => count($binsToPull),
971                'binsToPull' => array_slice($binsToPull, 0, 10),
972                'binsToStoreCount' => count($binsToStore),
973                'binsToStore' => array_slice($binsToStore, 0, 10),
974                'unacknowledgedAlertsCount' => count($alerts),
975                'alerts' => $alerts
976            ];
977        } catch (\Exception $e) {
978            $this->log->logError("EventService::getDashboardSummary failed: " . $e->getMessage());
979            return [
980                'activeEventsCount' => 0,
981                'activeEvents' => [],
982                'upcomingEventsCount' => 0,
983                'upcomingEvents' => [],
984                'binsToPullCount' => 0,
985                'binsToPull' => [],
986                'binsToStoreCount' => 0,
987                'binsToStore' => [],
988                'unacknowledgedAlertsCount' => 0,
989                'alerts' => []
990            ];
991        }
992    }
993
994    /**
995     * Get timeline data for calendar view
996     * @param int $months Number of months to include
997     * @return array
998     */
999    public function getEventTimeline($months = 6)
1000    {
1001        try {
1002            $stmt = $this->storeDB->prepare("
1003                SELECT id, name, eventType, startDate, endDate,
1004                       buildUpDays, windDownDays, color, icon
1005                FROM bsEvents
1006                WHERE isActive = 1
1007                AND startDate >= DATE_SUB(CURDATE(), INTERVAL 1 MONTH)
1008                AND startDate <= DATE_ADD(CURDATE(), INTERVAL :months MONTH)
1009                ORDER BY startDate ASC
1010            ");
1011            $stmt->bindValue(":months", $months, \PDO::PARAM_INT);
1012            $stmt->execute();
1013            $events = $stmt->fetchAll(\PDO::FETCH_ASSOC);
1014
1015            $timeline = [];
1016            foreach ($events as $eventData) {
1017                $eventObj = new Event($this->store);
1018                $eventObj->getById($eventData['id']);
1019
1020                $timeline[] = [
1021                    'id' => $eventData['id'],
1022                    'name' => $eventData['name'],
1023                    'eventType' => $eventData['eventType'],
1024                    'startDate' => $eventData['startDate'],
1025                    'endDate' => $eventData['endDate'],
1026                    'buildUpStartDate' => $eventObj->getBuildUpStartDate(),
1027                    'windDownEndDate' => $eventObj->getWindDownEndDate(),
1028                    'color' => $eventData['color'],
1029                    'icon' => $eventData['icon'],
1030                    'currentPhase' => $eventObj->getCurrentPhase()
1031                ];
1032            }
1033
1034            return $timeline;
1035        } catch (\Exception $e) {
1036            $this->log->logError("EventService::getEventTimeline failed: " . $e->getMessage());
1037            return [];
1038        }
1039    }
1040
1041    // ==================== TEMPLATE METHODS ====================
1042
1043    /**
1044     * Get all event templates from global database
1045     * @return array
1046     */
1047    public function getTemplates()
1048    {
1049        try {
1050            $globalDB = dbConnectByName('kiosk_buykiosk');
1051
1052            $stmt = $globalDB->prepare("
1053                SELECT id, name, eventType, description,
1054                       defaultBuildUpDays, defaultWindDownDays,
1055                       suggestedMonth, suggestedDay,
1056                       color, icon
1057                FROM bsEventTemplates
1058                ORDER BY eventType, name ASC
1059            ");
1060            $stmt->execute();
1061
1062            return $stmt->fetchAll(\PDO::FETCH_ASSOC);
1063        } catch (\Exception $e) {
1064            $this->log->logError("EventService::getTemplates failed: " . $e->getMessage());
1065            return [];
1066        }
1067    }
1068
1069    /**
1070     * Get single template by ID from global database
1071     * @param int $id Template ID
1072     * @return array|null
1073     */
1074    public function getTemplateById($id)
1075    {
1076        try {
1077            $globalDB = dbConnectByName('kiosk_buykiosk');
1078
1079            $stmt = $globalDB->prepare("
1080                SELECT id, name, eventType, description,
1081                       defaultBuildUpDays, defaultWindDownDays,
1082                       suggestedMonth, suggestedDay,
1083                       color, icon
1084                FROM bsEventTemplates
1085                WHERE id = :id
1086            ");
1087            $stmt->bindValue(":id", $id, \PDO::PARAM_INT);
1088            $stmt->execute();
1089
1090            $template = $stmt->fetch(\PDO::FETCH_ASSOC);
1091
1092            // Note: Template categories are not supported yet - categories are linked at the event level
1093            return $template;
1094        } catch (\Exception $e) {
1095            $this->log->logError("EventService::getTemplateById failed: " . $e->getMessage());
1096            return null;
1097        }
1098    }
1099
1100    // ==================== HELPER METHODS ====================
1101
1102    /**
1103     * Get category IDs linked to an event
1104     * @param int $eventId Event ID
1105     * @return array Array of category IDs
1106     */
1107    private function getCategoryIdsForEvent($eventId)
1108    {
1109        try {
1110            $stmt = $this->storeDB->prepare("
1111                SELECT categoryId
1112                FROM bsEvent_Categories
1113                WHERE eventId = :eventId
1114            ");
1115            $stmt->bindValue(":eventId", $eventId, \PDO::PARAM_INT);
1116            $stmt->execute();
1117
1118            $categoryIds = [];
1119            while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
1120                $categoryIds[] = $row['categoryId'];
1121            }
1122
1123            return $categoryIds;
1124        } catch (\Exception $e) {
1125            $this->log->logError("EventService::getCategoryIdsForEvent failed: " . $e->getMessage());
1126            return [];
1127        }
1128    }
1129
1130    /**
1131     * Get bins by category list and location type
1132     * @param array $categoryIds Array of category IDs
1133     * @param bool|null $onsite True for on-site, false for off-site, null for all
1134     * @param bool $excludeFinished If true, exclude bins whose last action was "Removed Everything" (action=0)
1135     * @return array
1136     */
1137    private function getBinsByCategories($categoryIds, $onsite = null, $excludeFinished = false)
1138    {
1139        try {
1140            if (empty($categoryIds)) {
1141                return [];
1142            }
1143
1144            $placeholders = implode(',', array_fill(0, count($categoryIds), '?'));
1145
1146            $onsiteCondition = '';
1147            if ($onsite !== null) {
1148                $onsiteCondition = $onsite ? 'AND l.onsite = 1' : 'AND l.onsite = 0';
1149            }
1150
1151            // If excluding finished bins, add a subquery to check last action
1152            $excludeFinishedCondition = '';
1153            if ($excludeFinished) {
1154                // Exclude bins where the last action was 0 (Removed Everything = Finished)
1155                // Include bins with no actions OR bins whose last action is NOT 0
1156                $excludeFinishedCondition = "
1157                    AND (
1158                        NOT EXISTS (SELECT 1 FROM bsActions WHERE binID = b.id)
1159                        OR (
1160                            SELECT action FROM bsActions
1161                            WHERE binID = b.id
1162                            ORDER BY timePerformed DESC, id DESC
1163                            LIMIT 1
1164                        ) != 0
1165                    )
1166                ";
1167            }
1168
1169            $stmt = $this->storeDB->prepare("
1170                SELECT DISTINCT b.*, DATE(b.ageDate) as dateNoTime
1171                FROM bsBins b
1172                LEFT JOIN bsLocations l ON b.location = l.id
1173                LEFT JOIN bsBin_Cat bc ON b.id = bc.binID
1174                WHERE b.deleted = 0
1175                AND (b.mainCategory IN ($placeholders) OR bc.catID IN ($placeholders))
1176                $onsiteCondition
1177                $excludeFinishedCondition
1178                ORDER BY b.ageDate ASC
1179            ");
1180
1181            // Bind category IDs twice (for mainCategory and subCategories)
1182            $params = array_merge($categoryIds, $categoryIds);
1183            $stmt->execute($params);
1184
1185            // Prepare lookup arrays for makeBinReadable
1186            $this->factory->prepareLookupArrays();
1187
1188            $bins = [];
1189            while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
1190                $bin = new Bin($this->store);
1191                $bin->createFromRow($row);
1192                $bins[] = $this->factory->makeBinReadable($bin);
1193            }
1194
1195            return $bins;
1196        } catch (\Exception $e) {
1197            $this->log->logError("EventService::getBinsByCategories failed: " . $e->getMessage());
1198            return [];
1199        }
1200    }
1201
1202    /**
1203     * Format bin data with event context
1204     * @param Bin $bin Bin object
1205     * @param array $event Event data
1206     * @return array
1207     */
1208    private function formatBinForPull($bin, $event)
1209    {
1210        return [
1211            'binId' => $bin->id,
1212            'binName' => $bin->name,
1213            'binUuid' => $bin->uuid,
1214            'mainCategory' => $bin->mainCategory,
1215            'categories' => $bin->categories,
1216            'location' => $bin->location,
1217            'locationId' => $bin->locationID,
1218            'onSite' => $bin->onSite,
1219            'age' => $bin->age,
1220            'ageDate' => $bin->ageDate,
1221            'dateCreated' => $bin->dateCreatedReadable,
1222            'eventId' => $event['id'],
1223            'eventName' => $event['name'],
1224            'eventStartDate' => $event['startDate'],
1225            'eventEndDate' => $event['endDate'] ?? null,
1226            'eventColor' => $event['color'] ?? null,
1227            'eventIcon' => $event['icon'] ?? null,
1228            'priority' => 5 // Default priority, could be enhanced later
1229        ];
1230    }
1231
1232    /**
1233     * Format bin data for store suggestion (bins that could be moved offsite)
1234     * @param Bin $bin Bin object
1235     * @param array $event Event data
1236     * @return array
1237     */
1238    private function formatBinForStore($bin, $event)
1239    {
1240        // Calculate days until the event's build-up phase starts
1241        $buildUpStart = new \DateTime($event['startDate']);
1242        $buildUpStart->modify('-' . ($event['buildUpDays'] ?? 0) . ' days');
1243        $today = new \DateTime();
1244        $daysUntilNeeded = $today->diff($buildUpStart)->days;
1245
1246        return [
1247            'binId' => $bin->id,
1248            'binName' => $bin->name,
1249            'binUuid' => $bin->uuid,
1250            'mainCategory' => $bin->mainCategory,
1251            'categories' => $bin->categories,
1252            'location' => $bin->location,
1253            'locationId' => $bin->locationID,
1254            'onSite' => $bin->onSite,
1255            'age' => $bin->age,
1256            'ageDate' => $bin->ageDate,
1257            'dateCreated' => $bin->dateCreatedReadable,
1258            'eventId' => $event['id'],
1259            'eventName' => $event['name'],
1260            'eventStartDate' => $event['startDate'],
1261            'eventEndDate' => $event['endDate'] ?? null,
1262            'eventColor' => $event['color'] ?? null,
1263            'eventIcon' => $event['icon'] ?? null,
1264            'daysUntilNeeded' => $daysUntilNeeded,
1265            'buildUpStartDate' => $buildUpStart->format('Y-m-d')
1266        ];
1267    }
1268
1269    /**
1270     * Calculate current phase for event
1271     * @param array $event Event data array
1272     * @return string
1273     */
1274    private function calculatePhaseForEvent($event)
1275    {
1276        $eventObj = new Event($this->store);
1277        $eventObj->getById($event['id']);
1278        return $eventObj->getCurrentPhase();
1279    }
1280
1281    /**
1282     * Create an alert if it doesn't already exist
1283     * @param int $eventId Event ID
1284     * @param string $alertType Alert type
1285     * @param string $message Alert message
1286     * @param int|null $binCount Bin count for the alert
1287     * @return EventAlert|null
1288     */
1289    private function createAlert($eventId, $alertType, $message, $binCount = null)
1290    {
1291        try {
1292            // Check if alert already exists (unacknowledged)
1293            $stmt = $this->storeDB->prepare("
1294                SELECT id
1295                FROM bsEvent_Alerts
1296                WHERE eventId = :eventId
1297                AND alertType = :alertType
1298                AND acknowledged = 0
1299                AND DATE(created_at) = CURDATE()
1300            ");
1301            $stmt->bindValue(":eventId", $eventId, \PDO::PARAM_INT);
1302            $stmt->bindValue(":alertType", $alertType);
1303            $stmt->execute();
1304
1305            if ($stmt->fetch()) {
1306                // Alert already exists for today
1307                return null;
1308            }
1309
1310            $alert = new EventAlert($this->store);
1311            $alert->setEventId($eventId);
1312            $alert->setAlertType($alertType);
1313            $alert->setMessage($message);
1314            if ($binCount !== null) {
1315                $alert->setBinCount($binCount);
1316            }
1317            $alert->setAcknowledged(false);
1318            $alert->create();
1319
1320            return $alert;
1321        } catch (\Exception $e) {
1322            $this->log->logError("EventService::createAlert failed: " . $e->getMessage());
1323            return null;
1324        }
1325    }
1326}