Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 218
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
StoreHoursService
0.00% covered (danger)
0.00%
0 / 218
0.00% covered (danger)
0.00%
0 / 11
3660
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getConfig
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
20
 getEffectiveHours
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 updateHours
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
72
 validateHours
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
72
 normalizeTime
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 timeToMinutes
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 invalidateCache
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 normalizeByDayPayload
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
420
 refreshStoreHoursMeta
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 getUserDisplayName
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace BuyerKiosk\StoreConfig\Services;
4
5use BuyerKiosk\Core\Store;
6use BuyerKiosk\StoreConfig\DTOs\EffectiveHours;
7use BuyerKiosk\StoreConfig\Repositories\StoreHolidayRepository;
8use BuyerKiosk\StoreConfig\Repositories\StoreHoursRepository;
9use BuyerKiosk\StoreController;
10use DateTimeInterface;
11use DateTimeZone;
12use PDO;
13
14/**
15 * StoreHoursService
16 *
17 * Business logic layer for store hours configuration.
18 * Handles effective hours resolution, validation, and cache invalidation.
19 *
20 * Priority cascade for effective hours resolution:
21 * 1. Holiday override (highest priority)
22 * 2. Day-of-week override
23 * 3. Default store hours (lowest priority)
24 *
25 * @package BuyerKiosk\StoreConfig\Services
26 */
27class StoreHoursService
28{
29    private PDO $db;
30    private StoreHoursRepository $hoursRepo;
31    private StoreHolidayRepository $holidayRepo;
32    /**
33     * @var null|callable(string): void
34     */
35    private $cacheInvalidator;
36
37    /**
38     * Day names for API responses
39     */
40    private const DAY_NAMES = [
41        0 => 'Sunday',
42        1 => 'Monday',
43        2 => 'Tuesday',
44        3 => 'Wednesday',
45        4 => 'Thursday',
46        5 => 'Friday',
47        6 => 'Saturday',
48    ];
49
50    public function __construct(
51        PDO $db,
52        ?StoreHoursRepository $hoursRepo = null,
53        ?StoreHolidayRepository $holidayRepo = null,
54        ?callable $cacheInvalidator = null
55    ) {
56        $this->db = $db;
57        $this->hoursRepo = $hoursRepo ?? new StoreHoursRepository($db);
58        $this->holidayRepo = $holidayRepo ?? new StoreHolidayRepository($db);
59        $this->cacheInvalidator = $cacheInvalidator;
60    }
61
62    /**
63     * Get the complete store configuration
64     *
65     * Returns store info, default hours, per-day hours, and holidays
66     * in the format expected by the API response.
67     *
68     * @param Store $store The store entity
69     * @return array{storeInfo: array, hours: array, meta: array}
70     */
71    public function getConfig(Store $store): array
72    {
73        $typeNum = $store->getTypeNum();
74
75        // Get default hours from store
76        $defaultOpen = $store->getDefaultOpenTime();
77        $defaultClose = $store->getDefaultCloseTime();
78
79        // Get per-day overrides
80        $dayOverrides = $this->hoursRepo->findByTypeNum($typeNum);
81
82        // Build byDay array with all 7 days
83        $byDay = [];
84        for ($day = 0; $day <= 6; $day++) {
85            if (isset($dayOverrides[$day])) {
86                $override = $dayOverrides[$day];
87                $byDay[] = [
88                    'dayOfWeek' => $day,
89                    'dayName' => self::DAY_NAMES[$day],
90                    'openTime' => $override['openTime'],
91                    'closeTime' => $override['closeTime'],
92                    'isClosed' => $override['isClosed'],
93                    'usesDefault' => $override['usesDefault'],
94                ];
95            } else {
96                // No override - use defaults
97                $byDay[] = [
98                    'dayOfWeek' => $day,
99                    'dayName' => self::DAY_NAMES[$day],
100                    'openTime' => null,
101                    'closeTime' => null,
102                    'isClosed' => false,
103                    'usesDefault' => true,
104                ];
105            }
106        }
107
108        // Get holidays
109        $holidays = $this->holidayRepo->findByTypeNum($typeNum);
110
111        // Get last updated user name if available
112        $lastUpdatedBy = null;
113        if ($store->getHoursUpdatedByUserId()) {
114            $lastUpdatedBy = $this->getUserDisplayName($store->getHoursUpdatedByUserId());
115        }
116
117        return [
118            'storeInfo' => [
119                'typeNum' => $typeNum,
120                'name' => $store->getCompanyName() . ' #' . $store->getStoreNum(),
121                'address' => $store->getAddress(),
122                'city' => $store->getCity(),
123                'state' => $store->getState(),
124                'timezone' => $store->getTimeZone(),
125            ],
126            'hours' => [
127                'default' => [
128                    'openTime' => $defaultOpen,
129                    'closeTime' => $defaultClose,
130                ],
131                'byDay' => $byDay,
132                'holidays' => $holidays,
133            ],
134            'meta' => [
135                'lastUpdated' => $store->getHoursLastUpdated(),
136                'lastUpdatedBy' => $lastUpdatedBy,
137            ],
138        ];
139    }
140
141    /**
142     * Get effective hours for a specific date
143     *
144     * Applies the priority cascade: holiday > day override > default
145     *
146     * IMPORTANT: The date must be interpreted as a store-local calendar date.
147     * This method extracts the day-of-week from the date in the store's timezone.
148     *
149     * @param Store $store The store entity
150     * @param DateTimeInterface $date The date to check (interpreted as store-local)
151     * @return EffectiveHours
152     */
153    public function getEffectiveHours(Store $store, DateTimeInterface $date): EffectiveHours
154    {
155        $typeNum = $store->getTypeNum();
156
157        // Convert to store timezone for correct day-of-week calculation
158        $storeTimezone = new DateTimeZone($store->getTimeZone());
159
160        // Create a mutable DateTime from the interface for timezone conversion
161        $localDate = \DateTime::createFromInterface($date);
162        $localDate->setTimezone($storeTimezone);
163
164        // Priority 1: Check for holiday override on this specific date
165        $holiday = $this->holidayRepo->findByDate($typeNum, $localDate);
166        if ($holiday) {
167            return EffectiveHours::fromHoliday(
168                $holiday['openTime'],
169                $holiday['closeTime'],
170                $holiday['isClosed']
171            );
172        }
173
174        // Priority 2: Check for day-of-week override
175        $dayOfWeek = (int) $localDate->format('w'); // 0=Sun, 6=Sat
176        $dayHours = $this->hoursRepo->findByDay($typeNum, $dayOfWeek);
177
178        if ($dayHours && !$dayHours['usesDefault']) {
179            return EffectiveHours::fromDayOverride(
180                $dayHours['openTime'],
181                $dayHours['closeTime'],
182                $dayHours['isClosed']
183            );
184        }
185
186        // Priority 3: Use default store hours
187        return EffectiveHours::fromDefault(
188            $store->getDefaultOpenTime(),
189            $store->getDefaultCloseTime()
190        );
191    }
192
193    /**
194     * Update store hours configuration
195     *
196     * Updates default hours and optionally per-day overrides.
197     * Invalidates cache and sets hoursLastUpdated timestamp.
198     *
199     * @param Store $store The store entity
200     * @param array{default: array{openTime: string, closeTime: string}, byDay?: array} $data Hours configuration
201     * @param int $userId User making the change
202     * @return array Updated hours configuration
203     * @throws \InvalidArgumentException If validation fails
204     */
205    public function updateHours(Store $store, array $data, int $userId): array
206    {
207        $typeNum = $store->getTypeNum();
208
209        // Validate default hours
210        if (!isset($data['default']['openTime']) || !isset($data['default']['closeTime'])) {
211            throw new \InvalidArgumentException('Default open and close times are required');
212        }
213
214        $defaultOpen = $this->normalizeTime($data['default']['openTime']);
215        $defaultClose = $this->normalizeTime($data['default']['closeTime']);
216
217        $errors = $this->validateHours(
218            $defaultOpen,
219            $defaultClose,
220            false
221        );
222
223        if (!empty($errors)) {
224            throw new \InvalidArgumentException(implode('; ', $errors));
225        }
226
227        $dayHours = null;
228        if (array_key_exists('byDay', $data)) {
229            if (!is_array($data['byDay'])) {
230                throw new \InvalidArgumentException('byDay must be an array');
231            }
232
233            $dayHours = $this->normalizeByDayPayload($data['byDay'], $defaultOpen, $defaultClose);
234        }
235
236        // Begin transaction
237        $this->db->beginTransaction();
238
239        try {
240            // Update default hours in stores table
241            $stmt = $this->db->prepare("
242                UPDATE stores
243                SET defaultOpenTime = :openTime,
244                    defaultCloseTime = :closeTime,
245                    hoursLastUpdated = NOW(),
246                    hoursUpdatedByUserId = :userId
247                WHERE typeNum = :typeNum
248            ");
249            $stmt->bindValue(':openTime', $defaultOpen);
250            $stmt->bindValue(':closeTime', $defaultClose);
251            $stmt->bindValue(':userId', $userId, PDO::PARAM_INT);
252            $stmt->bindValue(':typeNum', $typeNum);
253            $stmt->execute();
254
255            // Update per-day hours if provided
256            if ($dayHours !== null) {
257                $this->hoursRepo->upsertByTypeNum($typeNum, $dayHours);
258            }
259
260            $this->db->commit();
261
262            // Invalidate cache
263            $this->invalidateCache($typeNum);
264
265            $this->refreshStoreHoursMeta($store);
266
267            // Return updated configuration
268            // Refresh store data to get new timestamps
269            $store->setDefaultOpenTime($defaultOpen);
270            $store->setDefaultCloseTime($defaultClose);
271
272            return $this->getConfig($store);
273        } catch (\Exception $e) {
274            $this->db->rollBack();
275            throw $e;
276        }
277    }
278
279    /**
280     * Validate store hours
281     *
282     * @param string|null $openTime Open time in HH:MM format
283     * @param string|null $closeTime Close time in HH:MM format
284     * @param bool $isClosed Whether the period is marked closed
285     * @return array<string> Array of validation error messages (empty if valid)
286     */
287    public function validateHours(?string $openTime, ?string $closeTime, bool $isClosed): array
288    {
289        $errors = [];
290
291        // If closed, times can be null
292        if ($isClosed) {
293            return $errors;
294        }
295
296        // Both times required if not closed
297        if (empty($openTime) || empty($closeTime)) {
298            $errors[] = 'Both open and close times are required when store is open';
299            return $errors;
300        }
301
302        // Validate format HH:MM (24-hour, zero-padded)
303        $timePattern = '/^([01]\d|2[0-3]):([0-5]\d)$/';
304
305        if (!preg_match($timePattern, $openTime)) {
306            $errors[] = 'Open time must be in HH:MM format (24-hour)';
307        }
308
309        if (!preg_match($timePattern, $closeTime)) {
310            $errors[] = 'Close time must be in HH:MM format (24-hour)';
311        }
312
313        // Validate close > open (same-day constraint)
314        if (empty($errors)) {
315            $openMinutes = $this->timeToMinutes($openTime);
316            $closeMinutes = $this->timeToMinutes($closeTime);
317
318            if ($closeMinutes <= $openMinutes) {
319                $errors[] = 'Close time must be after open time (overnight hours not supported)';
320            }
321        }
322
323        return $errors;
324    }
325
326    /**
327     * Normalize time to HH:MM format
328     *
329     * Ensures consistent zero-padded 24-hour format.
330     *
331     * @param string $time Time string
332     * @return string Normalized time in HH:MM format
333     */
334    public function normalizeTime(string $time): string
335    {
336        // Handle various input formats: "9:00", "09:00", "9:00 AM", etc.
337        $time = trim($time);
338
339        // Try to parse and reformat
340        $parsed = date_parse($time);
341
342        if ($parsed['hour'] !== false && $parsed['minute'] !== false) {
343            return sprintf('%02d:%02d', $parsed['hour'], $parsed['minute']);
344        }
345
346        // If parsing fails, try simple pattern match
347        if (preg_match('/^(\d{1,2}):(\d{2})$/', $time, $matches)) {
348            return sprintf('%02d:%02d', (int) $matches[1], (int) $matches[2]);
349        }
350
351        // Return as-is if we can't normalize
352        return $time;
353    }
354
355    /**
356     * Convert time string to minutes since midnight
357     *
358     * @param string $time Time in HH:MM format
359     * @return int Minutes since midnight
360     */
361    private function timeToMinutes(string $time): int
362    {
363        $parts = explode(':', $time);
364        return (int) $parts[0] * 60 + (int) $parts[1];
365    }
366
367    /**
368     * Invalidate the store cache
369     *
370     * Clears the Redis cache for the store object so the next
371     * request gets fresh data.
372     *
373     * @param string $typeNum Store identifier
374     * @return void
375     */
376    private function invalidateCache(string $typeNum): void
377    {
378        if ($this->cacheInvalidator !== null) {
379            ($this->cacheInvalidator)($typeNum);
380            return;
381        }
382
383        try {
384            // Use StoreController to clear the cache
385            $storeController = new StoreController($typeNum);
386            $storeController->clearCache();
387        } catch (\Exception $e) {
388            // Log but don't fail on cache invalidation errors
389            error_log("Failed to invalidate cache for store {$typeNum}" . $e->getMessage());
390        }
391    }
392
393    /**
394     * Normalize/validate a full byDay payload into a StoreHoursRepository upsert array.
395     *
396     * @param array $byDay
397     * @param string $defaultOpen Normalized HH:MM
398     * @param string $defaultClose Normalized HH:MM
399     * @return array<int, array{openTime: string|null, closeTime: string|null, isClosed: bool}>
400     */
401    private function normalizeByDayPayload(array $byDay, string $defaultOpen, string $defaultClose): array
402    {
403        $seenDays = [];
404
405        foreach ($byDay as $dayConfig) {
406            if (!is_array($dayConfig) || !array_key_exists('dayOfWeek', $dayConfig)) {
407                throw new \InvalidArgumentException('Each byDay entry must include dayOfWeek');
408            }
409
410            $dayOfWeek = (int) $dayConfig['dayOfWeek'];
411            if ($dayOfWeek < 0 || $dayOfWeek > 6) {
412                throw new \InvalidArgumentException('dayOfWeek must be between 0 and 6');
413            }
414
415            if (isset($seenDays[$dayOfWeek])) {
416                throw new \InvalidArgumentException('byDay contains duplicate dayOfWeek values');
417            }
418
419            $seenDays[$dayOfWeek] = true;
420        }
421
422        for ($day = 0; $day <= 6; $day++) {
423            if (!isset($seenDays[$day])) {
424                throw new \InvalidArgumentException('byDay must contain all 7 days (0-6)');
425            }
426        }
427
428        $dayHours = [];
429
430        foreach ($byDay as $dayConfig) {
431            $dayOfWeek = (int) $dayConfig['dayOfWeek'];
432            $dayName = self::DAY_NAMES[$dayOfWeek] ?? 'Unknown';
433
434            $isClosed = (bool) ($dayConfig['isClosed'] ?? false);
435
436            if ($isClosed) {
437                $dayHours[$dayOfWeek] = [
438                    'openTime' => null,
439                    'closeTime' => null,
440                    'isClosed' => true,
441                ];
442                continue;
443            }
444
445            $openProvided = array_key_exists('openTime', $dayConfig);
446            $closeProvided = array_key_exists('closeTime', $dayConfig);
447
448            if ($openProvided !== $closeProvided) {
449                throw new \InvalidArgumentException("{$dayName}: both openTime and closeTime must be provided, or neither");
450            }
451
452            if (!$openProvided) {
453                throw new \InvalidArgumentException("{$dayName}: openTime and closeTime are required when store is open");
454            }
455
456            $openTime = $dayConfig['openTime'];
457            $closeTime = $dayConfig['closeTime'];
458
459            if ($openTime === null && $closeTime === null) {
460                continue;
461            }
462
463            if (!is_string($openTime) || !is_string($closeTime)) {
464                throw new \InvalidArgumentException("{$dayName}: openTime and closeTime must be strings or null");
465            }
466
467            $openTime = $this->normalizeTime($openTime);
468            $closeTime = $this->normalizeTime($closeTime);
469
470            $dayErrors = $this->validateHours($openTime, $closeTime, false);
471            if (!empty($dayErrors)) {
472                throw new \InvalidArgumentException("{$dayName}" . implode('; ', $dayErrors));
473            }
474
475            $usesDefault = ($openTime === $defaultOpen) && ($closeTime === $defaultClose);
476            if ($usesDefault) {
477                continue;
478            }
479
480            $dayHours[$dayOfWeek] = [
481                'openTime' => $openTime,
482                'closeTime' => $closeTime,
483                'isClosed' => false,
484            ];
485        }
486
487        return $dayHours;
488    }
489
490    private function refreshStoreHoursMeta(Store $store): void
491    {
492        $stmt = $this->db->prepare("
493            SELECT hoursLastUpdated, hoursUpdatedByUserId
494            FROM stores
495            WHERE typeNum = :typeNum
496            LIMIT 1
497        ");
498        $stmt->bindValue(':typeNum', $store->getTypeNum());
499        $stmt->execute();
500
501        $row = $stmt->fetch(PDO::FETCH_ASSOC);
502        if (!$row) {
503            return;
504        }
505
506        $store->setHoursLastUpdated($row['hoursLastUpdated'] ?? null);
507        $store->setHoursUpdatedByUserId(
508            array_key_exists('hoursUpdatedByUserId', $row) && $row['hoursUpdatedByUserId'] !== null
509                ? (int) $row['hoursUpdatedByUserId']
510                : null
511        );
512    }
513
514    /**
515     * Get user display name by ID
516     *
517     * @param int $userId User ID
518     * @return string|null User display name or null if not found
519     */
520    private function getUserDisplayName(int $userId): ?string
521    {
522        try {
523            $stmt = $this->db->prepare("
524                SELECT CONCAT(firstName, ' ', lastName) as displayName
525                FROM kiosk_users.users
526                WHERE id = :userId
527                LIMIT 1
528            ");
529            $stmt->bindValue(':userId', $userId, PDO::PARAM_INT);
530            $stmt->execute();
531
532            $row = $stmt->fetch(PDO::FETCH_ASSOC);
533            return $row ? $row['displayName'] : null;
534        } catch (\Exception $e) {
535            return null;
536        }
537    }
538}