Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 144
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Event
0.00% covered (danger)
0.00%
0 / 144
0.00% covered (danger)
0.00%
0 / 12
2756
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 calculatePhase
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
110
 isInPhase
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDateRange
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 canTransitionTo
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getValidTypes
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getValidStatuses
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getValidPhases
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 fromRow
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
72
 toArray
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
2
 validate
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
380
 parseDateTime
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace BuyerKiosk\EventManagement\Models;
4
5use DateTime;
6use DateInterval;
7
8/**
9 * Event - Store Event Management model
10 *
11 * Represents a store event (season, holiday, sale, or custom) that can have
12 * associated integrations with other systems like backstock, SMS, signage,
13 * Comeback Cash, tasks, and notes.
14 *
15 * Events have distinct phases:
16 * - Upcoming: Before the build-up period starts
17 * - Build-up: Preparation period before event starts
18 * - Active: Event is live
19 * - Wind-down: Post-event period for cleanup/transition
20 * - Completed: Event has fully concluded
21 *
22 * @package BuyerKiosk\EventManagement\Models
23 */
24class Event
25{
26    // Event Types
27    public const TYPE_SEASON = 'season';
28    public const TYPE_HOLIDAY = 'holiday';
29    public const TYPE_SALE = 'sale';
30    public const TYPE_CUSTOM = 'custom';
31
32    // Status constants
33    public const STATUS_DRAFT = 'draft';
34    public const STATUS_SCHEDULED = 'scheduled';
35    public const STATUS_ACTIVE = 'active';
36    public const STATUS_COMPLETED = 'completed';
37    public const STATUS_CANCELLED = 'cancelled';
38    public const STATUS_ARCHIVED = 'archived';
39
40    // Phase constants
41    public const PHASE_UPCOMING = 'upcoming';
42    public const PHASE_BUILD_UP = 'build_up';
43    public const PHASE_ACTIVE = 'active';
44    public const PHASE_WIND_DOWN = 'wind_down';
45    public const PHASE_COMPLETED = 'completed';
46
47    /**
48     * Valid status transitions
49     *
50     * Defines which status changes are allowed from each state.
51     * Archived events can only be unarchived via special method.
52     */
53    private const STATUS_TRANSITIONS = [
54        self::STATUS_DRAFT => [self::STATUS_SCHEDULED, self::STATUS_CANCELLED],
55        self::STATUS_SCHEDULED => [self::STATUS_ACTIVE, self::STATUS_CANCELLED],
56        self::STATUS_ACTIVE => [self::STATUS_COMPLETED, self::STATUS_CANCELLED],
57        self::STATUS_COMPLETED => [self::STATUS_ARCHIVED],
58        self::STATUS_CANCELLED => [self::STATUS_ARCHIVED],
59        self::STATUS_ARCHIVED => [], // Can only unarchive via special method
60    ];
61
62    /**
63     * @var int|null Event ID
64     */
65    public ?int $id = null;
66
67    /**
68     * @var int|null Template ID this event was created from
69     */
70    public ?int $templateId = null;
71
72    /**
73     * @var int|null Source event ID if this is a recurring instance
74     */
75    public ?int $sourceEventId = null;
76
77    /**
78     * @var string Event name for display
79     */
80    public string $name = '';
81
82    /**
83     * @var string|null Event description
84     */
85    public ?string $description = null;
86
87    /**
88     * @var string Event type: 'season', 'holiday', 'sale', 'custom'
89     */
90    public string $eventType = self::TYPE_CUSTOM;
91
92    /**
93     * @var int Year this event occurs in
94     */
95    public int $year;
96
97    /**
98     * @var DateTime|null When the event starts
99     */
100    public ?DateTime $startDate = null;
101
102    /**
103     * @var DateTime|null When the event ends
104     */
105    public ?DateTime $endDate = null;
106
107    /**
108     * @var int Number of days before startDate for build-up phase
109     */
110    public int $buildUpDays = 14;
111
112    /**
113     * @var int Number of days after endDate for wind-down phase
114     */
115    public int $windDownDays = 7;
116
117    /**
118     * @var string Event status: 'draft', 'scheduled', 'active', 'completed', 'cancelled', 'archived'
119     */
120    public string $status = self::STATUS_DRAFT;
121
122    /**
123     * @var string|null Previous status before current status (for audit trail)
124     */
125    public ?string $previousStatus = null;
126
127    /**
128     * @var string Current phase: 'upcoming', 'build_up', 'active', 'wind_down', 'completed'
129     */
130    public string $phase = self::PHASE_UPCOMING;
131
132    /**
133     * @var string|null Display color (hex code)
134     */
135    public ?string $color = null;
136
137    /**
138     * @var string|null Icon identifier for display
139     */
140    public ?string $icon = null;
141
142    /**
143     * @var bool Whether this event recurs annually
144     */
145    public bool $isRecurring = false;
146
147    /**
148     * @var DateTime|null When the event was archived
149     */
150    public ?DateTime $archivedAt = null;
151
152    /**
153     * @var string|null Created timestamp
154     */
155    public ?string $createdAt = null;
156
157    /**
158     * @var string|null Updated timestamp
159     */
160    public ?string $updatedAt = null;
161
162    /**
163     * @var int|null User ID who created the event
164     */
165    public ?int $createdBy = null;
166
167    /**
168     * Constructor - initializes year to current year
169     */
170    public function __construct()
171    {
172        $this->year = (int) date('Y');
173    }
174
175    /**
176     * Calculate the current phase based on today's date
177     *
178     * Phase determination logic:
179     * - If no dates set, returns current phase property
180     * - UPCOMING: Before (startDate - buildUpDays)
181     * - BUILD_UP: Between (startDate - buildUpDays) and startDate
182     * - ACTIVE: Between startDate and endDate
183     * - WIND_DOWN: Between endDate and (endDate + windDownDays)
184     * - COMPLETED: After (endDate + windDownDays)
185     *
186     * @param DateTime|null $now Optional: override current date for testing
187     * @return string Phase constant
188     */
189    public function calculatePhase(?DateTime $now = null): string
190    {
191        // If no dates set, return the stored phase
192        if ($this->startDate === null || $this->endDate === null) {
193            return $this->phase;
194        }
195
196        $now = $now ?? new DateTime();
197
198        // Calculate build-up start date
199        $buildUpStart = (clone $this->startDate)->sub(new DateInterval("P{$this->buildUpDays}D"));
200
201        // Calculate wind-down end date
202        $windDownEnd = (clone $this->endDate)->add(new DateInterval("P{$this->windDownDays}D"));
203
204        // Determine phase based on current date
205        if ($now < $buildUpStart) {
206            return self::PHASE_UPCOMING;
207        }
208
209        if ($now >= $buildUpStart && $now < $this->startDate) {
210            return self::PHASE_BUILD_UP;
211        }
212
213        if ($now >= $this->startDate && $now <= $this->endDate) {
214            return self::PHASE_ACTIVE;
215        }
216
217        if ($now > $this->endDate && $now <= $windDownEnd) {
218            return self::PHASE_WIND_DOWN;
219        }
220
221        return self::PHASE_COMPLETED;
222    }
223
224    /**
225     * Check if event is currently in a specific phase
226     *
227     * @param string $phase Phase constant to check
228     * @param DateTime|null $now Optional: override current date for testing
229     * @return bool True if event is in the specified phase
230     */
231    public function isInPhase(string $phase, ?DateTime $now = null): bool
232    {
233        return $this->calculatePhase($now) === $phase;
234    }
235
236    /**
237     * Get the full date range including build-up and wind-down periods
238     *
239     * Returns all four significant dates for the event lifecycle.
240     * Returns null values for dates that cannot be calculated.
241     *
242     * @return array{buildUpStart: DateTime|null, startDate: DateTime|null, endDate: DateTime|null, windDownEnd: DateTime|null}
243     */
244    public function getDateRange(): array
245    {
246        $buildUpStart = null;
247        $windDownEnd = null;
248
249        if ($this->startDate !== null) {
250            $buildUpStart = (clone $this->startDate)->sub(new DateInterval("P{$this->buildUpDays}D"));
251        }
252
253        if ($this->endDate !== null) {
254            $windDownEnd = (clone $this->endDate)->add(new DateInterval("P{$this->windDownDays}D"));
255        }
256
257        return [
258            'buildUpStart' => $buildUpStart,
259            'startDate' => $this->startDate,
260            'endDate' => $this->endDate,
261            'windDownEnd' => $windDownEnd,
262        ];
263    }
264
265    /**
266     * Check if a status transition is valid
267     *
268     * Uses the STATUS_TRANSITIONS constant to determine valid state changes.
269     *
270     * @param string $newStatus The target status to transition to
271     * @return bool True if the transition is allowed
272     */
273    public function canTransitionTo(string $newStatus): bool
274    {
275        $allowedTransitions = self::STATUS_TRANSITIONS[$this->status] ?? [];
276        return in_array($newStatus, $allowedTransitions, true);
277    }
278
279    /**
280     * Get all valid event types
281     *
282     * @return array List of valid event type constants
283     */
284    public static function getValidTypes(): array
285    {
286        return [
287            self::TYPE_SEASON,
288            self::TYPE_HOLIDAY,
289            self::TYPE_SALE,
290            self::TYPE_CUSTOM,
291        ];
292    }
293
294    /**
295     * Get all valid statuses
296     *
297     * @return array List of valid status constants
298     */
299    public static function getValidStatuses(): array
300    {
301        return [
302            self::STATUS_DRAFT,
303            self::STATUS_SCHEDULED,
304            self::STATUS_ACTIVE,
305            self::STATUS_COMPLETED,
306            self::STATUS_CANCELLED,
307            self::STATUS_ARCHIVED,
308        ];
309    }
310
311    /**
312     * Get all valid phases
313     *
314     * @return array List of valid phase constants
315     */
316    public static function getValidPhases(): array
317    {
318        return [
319            self::PHASE_UPCOMING,
320            self::PHASE_BUILD_UP,
321            self::PHASE_ACTIVE,
322            self::PHASE_WIND_DOWN,
323            self::PHASE_COMPLETED,
324        ];
325    }
326
327    /**
328     * Create an Event object from a database row
329     *
330     * Maps camelCase database columns to properties.
331     *
332     * @param array $row Database row from events table
333     * @return self Hydrated Event instance
334     */
335    public static function fromRow(array $row): self
336    {
337        $event = new self();
338
339        $event->id = isset($row['id']) ? (int) $row['id'] : null;
340        $event->templateId = isset($row['templateId']) ? (int) $row['templateId'] : null;
341        $event->sourceEventId = isset($row['sourceEventId']) ? (int) $row['sourceEventId'] : null;
342        $event->name = $row['name'] ?? '';
343        $event->description = $row['description'] ?? null;
344        $event->eventType = $row['eventType'] ?? self::TYPE_CUSTOM;
345        $event->year = isset($row['year']) ? (int) $row['year'] : (int) date('Y');
346
347        // Parse datetime fields
348        $event->startDate = self::parseDateTime($row['startDate'] ?? null);
349        $event->endDate = self::parseDateTime($row['endDate'] ?? null);
350
351        // Build-up and wind-down periods
352        $event->buildUpDays = isset($row['buildUpDays']) ? (int) $row['buildUpDays'] : 14;
353        $event->windDownDays = isset($row['windDownDays']) ? (int) $row['windDownDays'] : 7;
354
355        // Status and phase
356        $event->status = $row['status'] ?? self::STATUS_DRAFT;
357        $event->previousStatus = $row['previousStatus'] ?? null;
358        $event->phase = $row['phase'] ?? self::PHASE_UPCOMING;
359
360        // Display properties
361        $event->color = $row['color'] ?? null;
362        $event->icon = $row['icon'] ?? null;
363
364        // Recurring flag
365        $event->isRecurring = (bool) ($row['isRecurring'] ?? false);
366
367        // Archived timestamp
368        $event->archivedAt = self::parseDateTime($row['archivedAt'] ?? null);
369
370        // Audit fields
371        $event->createdAt = $row['created_at'] ?? null;
372        $event->updatedAt = $row['updated_at'] ?? null;
373        $event->createdBy = isset($row['createdBy']) ? (int) $row['createdBy'] : null;
374
375        return $event;
376    }
377
378    /**
379     * Convert event to array for serialization
380     *
381     * Dates are formatted as Y-m-d (DATE type in database).
382     * Timestamps are formatted as Y-m-d H:i:s.
383     *
384     * @return array Associative array representation
385     */
386    public function toArray(): array
387    {
388        $dateRange = $this->getDateRange();
389
390        return [
391            'id' => $this->id,
392            'templateId' => $this->templateId,
393            'sourceEventId' => $this->sourceEventId,
394            'name' => $this->name,
395            'description' => $this->description,
396            'eventType' => $this->eventType,
397            'year' => $this->year,
398            'startDate' => $this->startDate?->format('Y-m-d'),
399            'endDate' => $this->endDate?->format('Y-m-d'),
400            'buildUpDays' => $this->buildUpDays,
401            'windDownDays' => $this->windDownDays,
402            'status' => $this->status,
403            'previousStatus' => $this->previousStatus,
404            'phase' => $this->phase,
405            'calculatedPhase' => $this->calculatePhase(),
406            'color' => $this->color,
407            'icon' => $this->icon,
408            'isRecurring' => $this->isRecurring,
409            'archivedAt' => $this->archivedAt?->format('Y-m-d H:i:s'),
410            'createdAt' => $this->createdAt,
411            'updatedAt' => $this->updatedAt,
412            'createdBy' => $this->createdBy,
413            // Calculated date range
414            'dateRange' => [
415                'buildUpStart' => $dateRange['buildUpStart']?->format('Y-m-d'),
416                'startDate' => $dateRange['startDate']?->format('Y-m-d'),
417                'endDate' => $dateRange['endDate']?->format('Y-m-d'),
418                'windDownEnd' => $dateRange['windDownEnd']?->format('Y-m-d'),
419            ],
420        ];
421    }
422
423    /**
424     * Validate the event configuration
425     *
426     * Checks all business rules and constraints for event validity.
427     *
428     * @return array Array of validation errors (empty if valid)
429     */
430    public function validate(): array
431    {
432        $errors = [];
433
434        // Name is required
435        if (empty(trim($this->name))) {
436            $errors[] = 'Event name is required';
437        }
438
439        // Name must not exceed 100 characters
440        if (strlen($this->name) > 100) {
441            $errors[] = 'Event name cannot exceed 100 characters';
442        }
443
444        // Valid event type
445        if (!in_array($this->eventType, self::getValidTypes(), true)) {
446            $errors[] = 'Invalid event type: ' . $this->eventType;
447        }
448
449        // Valid status
450        if (!in_array($this->status, self::getValidStatuses(), true)) {
451            $errors[] = 'Invalid event status: ' . $this->status;
452        }
453
454        // Valid phase
455        if (!in_array($this->phase, self::getValidPhases(), true)) {
456            $errors[] = 'Invalid event phase: ' . $this->phase;
457        }
458
459        // Year must be reasonable (current year +/- 5 years)
460        $currentYear = (int) date('Y');
461        if ($this->year < $currentYear - 5 || $this->year > $currentYear + 5) {
462            $errors[] = 'Year must be within 5 years of current year';
463        }
464
465        // Start date is required
466        if ($this->startDate === null) {
467            $errors[] = 'Start date is required';
468        }
469
470        // End date is required
471        if ($this->endDate === null) {
472            $errors[] = 'End date is required';
473        }
474
475        // End date must be after start date
476        if ($this->startDate !== null && $this->endDate !== null) {
477            if ($this->endDate < $this->startDate) {
478                $errors[] = 'End date must be after start date';
479            }
480        }
481
482        // Year must match start date year
483        if ($this->startDate !== null) {
484            $startYear = (int) $this->startDate->format('Y');
485            if ($this->year !== $startYear) {
486                $errors[] = 'Year must match the start date year';
487            }
488        }
489
490        // Build-up days must be non-negative
491        if ($this->buildUpDays < 0) {
492            $errors[] = 'Build-up days cannot be negative';
493        }
494
495        // Wind-down days must be non-negative
496        if ($this->windDownDays < 0) {
497            $errors[] = 'Wind-down days cannot be negative';
498        }
499
500        // Color must be valid hex if provided
501        if ($this->color !== null && !preg_match('/^#[0-9A-Fa-f]{6}$/', $this->color)) {
502            $errors[] = 'Color must be a valid hex color code (e.g., #FF5733)';
503        }
504
505        return $errors;
506    }
507
508    /**
509     * Parse a datetime string into a DateTime object
510     *
511     * @param string|null $value Datetime string or null
512     * @return DateTime|null Parsed DateTime or null
513     */
514    private static function parseDateTime(?string $value): ?DateTime
515    {
516        if ($value === null || $value === '') {
517            return null;
518        }
519
520        // Handle MySQL zero dates
521        if (strpos($value, '0000-00-00') === 0) {
522            return null;
523        }
524
525        try {
526            return new DateTime($value);
527        } catch (\Exception $e) {
528            return null;
529        }
530    }
531}