Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 276
0.00% covered (danger)
0.00%
0 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
BackstockAdapter
0.00% covered (danger)
0.00%
0 / 276
0.00% covered (danger)
0.00%
0 / 16
4556
0.00% covered (danger)
0.00%
0 / 1
 getType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 create
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 syncDates
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
12
 activate
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 deactivate
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 delete
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 getStatus
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
20
 validateConfig
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
240
 getDefaultConfig
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 createBsEvent
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
6
 syncCategories
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 getBsEvent
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 determineStatus
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 calculateBsEventPhase
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
110
 getCategoryCount
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getBinCounts
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2
3namespace BuyerKiosk\EventManagement\Adapters;
4
5use BuyerKiosk\EventManagement\Models\Event;
6use BuyerKiosk\EventManagement\Models\EventIntegration;
7use PDO;
8use PDOException;
9
10/**
11 * BackstockAdapter - Manages backstock event integrations
12 *
13 * Creates and manages bsEvents records that are linked to unified events.
14 * When a unified event is created, this adapter creates a corresponding
15 * bsEvents record with matching dates and links categories.
16 *
17 * Data flow:
18 * - event.id -> bsEvents.eventId (marks as event-managed)
19 * - bsEvents.id -> event_integrations.foreignId
20 * - event.startDate -> bsEvents.startDate
21 * - event.endDate -> bsEvents.endDate
22 * - event.buildUpDays -> bsEvents.buildUpDays
23 * - config.categories -> bsEvent_Categories
24 *
25 * @package BuyerKiosk\EventManagement\Adapters
26 */
27class BackstockAdapter extends AbstractAdapter
28{
29    /**
30     * Table name for backstock events
31     */
32    private const TABLE_EVENTS = 'bsEvents';
33
34    /**
35     * Table name for backstock event categories
36     */
37    private const TABLE_CATEGORIES = 'bsEvent_Categories';
38
39    /**
40     * Get the integration type this adapter handles
41     *
42     * @return string
43     */
44    public function getType(): string
45    {
46        return EventIntegration::TYPE_BACKSTOCK;
47    }
48
49    /**
50     * Create a bsEvents record linked to the unified event
51     *
52     * Creates a new backstock event with:
53     * 1. Dates synced from the unified event
54     * 2. eventId set to link back to the unified event
55     * 3. Categories linked from config
56     *
57     * @param Event $event The unified event
58     * @param array $config Integration configuration
59     * @return int The bsEvents.id of the created record
60     * @throws IntegrationException On failure
61     */
62    public function create(Event $event, array $config): int
63    {
64        // Validate configuration first
65        $errors = $this->validateConfig($config);
66        if (!empty($errors)) {
67            throw IntegrationException::invalidConfig($this->getType(), $errors);
68        }
69
70        $this->beginTransaction();
71
72        try {
73            // Create the bsEvents record
74            $bsEventId = $this->createBsEvent($event, $config);
75
76            // Set the eventId on the bsEvents record for traceability
77            $this->setEventIdOnRecord(self::TABLE_EVENTS, $bsEventId, $event->id);
78
79            // Link categories if provided
80            $categoryIds = $this->getConfigValue($config, 'categories', []);
81            if (!empty($categoryIds)) {
82                $this->syncCategories($bsEventId, $categoryIds, $config);
83            }
84
85            $this->commit();
86
87            return $bsEventId;
88        } catch (PDOException $e) {
89            $this->rollback();
90            $this->logError('Failed to create bsEvents record', [
91                'eventId' => $event->id,
92                'error' => $e->getMessage(),
93            ]);
94            throw IntegrationException::createFailed(
95                $this->getType(),
96                'Database error: ' . $e->getMessage(),
97                ['eventId' => $event->id],
98                $e
99            );
100        } catch (\Exception $e) {
101            $this->rollback();
102            throw $e;
103        }
104    }
105
106    /**
107     * Sync dates when the unified event dates change
108     *
109     * Updates the bsEvents record to match the new unified event dates.
110     *
111     * @param Event $event The updated event
112     * @param EventIntegration $integration The integration record
113     * @throws IntegrationException On failure
114     */
115    public function syncDates(Event $event, EventIntegration $integration): void
116    {
117        $bsEventId = $integration->foreignId;
118
119        // Verify the record exists
120        if (!$this->recordExists(self::TABLE_EVENTS, $bsEventId)) {
121            throw IntegrationException::targetNotFound($this->getType(), $bsEventId);
122        }
123
124        try {
125            $sql = "UPDATE `" . self::TABLE_EVENTS . "` SET
126                    `startDate` = :startDate,
127                    `endDate` = :endDate,
128                    `buildUpDays` = :buildUpDays,
129                    `windDownDays` = :windDownDays,
130                    `year` = :year,
131                    `updated_at` = NOW()
132                    WHERE `id` = :id";
133
134            $stmt = $this->db->prepare($sql);
135            $stmt->execute([
136                ':startDate' => $this->formatDateOnly($event->startDate),
137                ':endDate' => $this->formatDateOnly($event->endDate),
138                ':buildUpDays' => $event->buildUpDays,
139                ':windDownDays' => $event->windDownDays,
140                ':year' => $event->year,
141                ':id' => $bsEventId,
142            ]);
143
144        } catch (PDOException $e) {
145            $this->logError('Failed to sync dates for bsEvents record', [
146                'bsEventId' => $bsEventId,
147                'error' => $e->getMessage(),
148            ]);
149            throw IntegrationException::syncDatesFailed(
150                $this->getType(),
151                $bsEventId,
152                'Database error: ' . $e->getMessage(),
153                $e
154            );
155        }
156    }
157
158    /**
159     * Activate the backstock event
160     *
161     * Sets isActive = 1 on the bsEvents record.
162     *
163     * @param Event $event The event being activated
164     * @param EventIntegration $integration The integration to activate
165     * @throws IntegrationException On failure
166     */
167    public function activate(Event $event, EventIntegration $integration): void
168    {
169        $bsEventId = $integration->foreignId;
170
171        if (!$this->recordExists(self::TABLE_EVENTS, $bsEventId)) {
172            throw IntegrationException::targetNotFound($this->getType(), $bsEventId);
173        }
174
175        try {
176            $sql = "UPDATE `" . self::TABLE_EVENTS . "` SET
177                    `isActive` = 1,
178                    `updated_at` = NOW()
179                    WHERE `id` = :id";
180
181            $stmt = $this->db->prepare($sql);
182            $stmt->execute([':id' => $bsEventId]);
183
184        } catch (PDOException $e) {
185            $this->logError('Failed to activate bsEvents record', [
186                'bsEventId' => $bsEventId,
187                'error' => $e->getMessage(),
188            ]);
189            throw IntegrationException::activateFailed(
190                $this->getType(),
191                $bsEventId,
192                'Database error: ' . $e->getMessage(),
193                $e
194            );
195        }
196    }
197
198    /**
199     * Deactivate the backstock event
200     *
201     * Sets isActive = 0 on the bsEvents record.
202     *
203     * @param Event $event The event being deactivated
204     * @param EventIntegration $integration The integration to deactivate
205     * @throws IntegrationException On failure
206     */
207    public function deactivate(Event $event, EventIntegration $integration): void
208    {
209        $bsEventId = $integration->foreignId;
210
211        if (!$this->recordExists(self::TABLE_EVENTS, $bsEventId)) {
212            throw IntegrationException::targetNotFound($this->getType(), $bsEventId);
213        }
214
215        try {
216            $sql = "UPDATE `" . self::TABLE_EVENTS . "` SET
217                    `isActive` = 0,
218                    `updated_at` = NOW()
219                    WHERE `id` = :id";
220
221            $stmt = $this->db->prepare($sql);
222            $stmt->execute([':id' => $bsEventId]);
223
224        } catch (PDOException $e) {
225            $this->logError('Failed to deactivate bsEvents record', [
226                'bsEventId' => $bsEventId,
227                'error' => $e->getMessage(),
228            ]);
229            throw IntegrationException::deactivateFailed(
230                $this->getType(),
231                $bsEventId,
232                'Database error: ' . $e->getMessage(),
233                $e
234            );
235        }
236    }
237
238    /**
239     * Delete the bsEvents record (cascade from event deletion)
240     *
241     * Deletes the bsEvents record and its category links.
242     * The bsEvent_Categories will be deleted via ON DELETE CASCADE
243     * if FK exists, otherwise we delete them explicitly.
244     *
245     * @param EventIntegration $integration The integration to delete
246     * @throws IntegrationException On failure
247     */
248    public function delete(EventIntegration $integration): void
249    {
250        $bsEventId = $integration->foreignId;
251
252        // If record doesn't exist, nothing to delete
253        if (!$this->recordExists(self::TABLE_EVENTS, $bsEventId)) {
254            return;
255        }
256
257        $this->beginTransaction();
258
259        try {
260            // Delete category links first (in case no FK cascade)
261            $sql = "DELETE FROM `" . self::TABLE_CATEGORIES . "` WHERE `eventId` = :eventId";
262            $stmt = $this->db->prepare($sql);
263            $stmt->execute([':eventId' => $bsEventId]);
264
265            // Delete the bsEvents record
266            $sql = "DELETE FROM `" . self::TABLE_EVENTS . "` WHERE `id` = :id";
267            $stmt = $this->db->prepare($sql);
268            $stmt->execute([':id' => $bsEventId]);
269
270            $this->commit();
271
272        } catch (PDOException $e) {
273            $this->rollback();
274            $this->logError('Failed to delete bsEvents record', [
275                'bsEventId' => $bsEventId,
276                'error' => $e->getMessage(),
277            ]);
278            throw IntegrationException::deleteFailed(
279                $this->getType(),
280                $bsEventId,
281                'Database error: ' . $e->getMessage(),
282                $e
283            );
284        }
285    }
286
287    /**
288     * Get status information from the backstock system
289     *
290     * Returns:
291     * - status: derived from bsEvents.isActive and current phase
292     * - details: category counts, bin counts, completion rates
293     *
294     * @param EventIntegration $integration The integration to check
295     * @return array Status details
296     * @throws IntegrationException On failure
297     */
298    public function getStatus(EventIntegration $integration): array
299    {
300        $bsEventId = $integration->foreignId;
301
302        try {
303            // Get the bsEvents record
304            $bsEvent = $this->getBsEvent($bsEventId);
305
306            if ($bsEvent === null) {
307                return $this->buildStatusResponse('failed', [
308                    'error' => 'Backstock event not found',
309                    'bsEventId' => $bsEventId,
310                ]);
311            }
312
313            // Determine status based on bsEvents state
314            $status = $this->determineStatus($bsEvent);
315
316            // Get category and bin counts
317            $categoryCount = $this->getCategoryCount($bsEventId);
318            $binCounts = $this->getBinCounts($bsEventId);
319
320            // Calculate completion rate
321            $completionRate = $binCounts['total'] > 0
322                ? round(($binCounts['finished'] / $binCounts['total']) * 100)
323                : 0;
324
325            $details = [
326                'bsEventId' => $bsEventId,
327                'name' => $bsEvent['name'],
328                'isActive' => (bool) $bsEvent['isActive'],
329                'phase' => $this->calculateBsEventPhase($bsEvent),
330                'categoryCount' => $categoryCount,
331                'binCounts' => $binCounts,
332                'completionRate' => $completionRate,
333                'startDate' => $bsEvent['startDate'],
334                'endDate' => $bsEvent['endDate'],
335            ];
336
337            $lastUpdated = $this->parseDate($bsEvent['updated_at']);
338
339            return $this->buildStatusResponse($status, $details, $lastUpdated);
340
341        } catch (PDOException $e) {
342            $this->logError('Failed to get status for bsEvents record', [
343                'bsEventId' => $bsEventId,
344                'error' => $e->getMessage(),
345            ]);
346            throw IntegrationException::getStatusFailed(
347                $this->getType(),
348                $bsEventId,
349                'Database error: ' . $e->getMessage(),
350                $e
351            );
352        }
353    }
354
355    /**
356     * Validate the integration configuration
357     *
358     * Required:
359     * - name: string, non-empty
360     *
361     * Optional:
362     * - categories: array of category IDs
363     * - markupAdjustment: int between -100 and 100
364     * - priorityBoost: bool
365     *
366     * @param array $config Configuration to validate
367     * @return array Array of validation errors (empty if valid)
368     */
369    public function validateConfig(array $config): array
370    {
371        $errors = [];
372
373        // Name is required
374        if (!isset($config['name']) || trim($config['name']) === '') {
375            $errors[] = 'Backstock event name is required';
376        } elseif (strlen($config['name']) > 100) {
377            $errors[] = 'Backstock event name cannot exceed 100 characters';
378        }
379
380        // Categories must be an array if provided
381        if (isset($config['categories'])) {
382            if (!is_array($config['categories'])) {
383                $errors[] = 'Categories must be an array';
384            } else {
385                foreach ($config['categories'] as $catId) {
386                    if (!is_int($catId) && !ctype_digit((string) $catId)) {
387                        $errors[] = 'Category IDs must be integers';
388                        break;
389                    }
390                }
391            }
392        }
393
394        // Markup adjustment must be between -100 and 100
395        if (isset($config['markupAdjustment'])) {
396            $adjustment = $config['markupAdjustment'];
397            if (!is_numeric($adjustment) || $adjustment < -100 || $adjustment > 100) {
398                $errors[] = 'Markup adjustment must be between -100 and 100';
399            }
400        }
401
402        // Priority boost must be boolean
403        if (isset($config['priorityBoost']) && !is_bool($config['priorityBoost'])) {
404            $errors[] = 'Priority boost must be a boolean';
405        }
406
407        return $errors;
408    }
409
410    /**
411     * Get the default configuration for backstock integrations
412     *
413     * @return array Default configuration values
414     */
415    public function getDefaultConfig(): array
416    {
417        return [
418            'name' => '',
419            'categories' => [],
420            'markupAdjustment' => 0,
421            'priorityBoost' => false,
422        ];
423    }
424
425    // =========================================================================
426    // Private helper methods
427    // =========================================================================
428
429    /**
430     * Create a bsEvents record
431     *
432     * @param Event $event The unified event
433     * @param array $config Integration configuration
434     * @return int The new bsEvents.id
435     */
436    private function createBsEvent(Event $event, array $config): int
437    {
438        $sql = "INSERT INTO `" . self::TABLE_EVENTS . "` (
439                    `templateId`, `name`, `eventType`, `year`,
440                    `startDate`, `endDate`, `buildUpDays`, `windDownDays`,
441                    `color`, `icon`, `notes`, `isActive`, `isRecurring`,
442                    `created_at`, `updated_at`
443                ) VALUES (
444                    :templateId, :name, :eventType, :year,
445                    :startDate, :endDate, :buildUpDays, :windDownDays,
446                    :color, :icon, :notes, :isActive, :isRecurring,
447                    NOW(), NOW()
448                )";
449
450        $stmt = $this->db->prepare($sql);
451        $stmt->execute([
452            ':templateId' => $event->templateId,
453            ':name' => $this->getConfigValue($config, 'name', $event->name),
454            ':eventType' => $event->eventType,
455            ':year' => $event->year,
456            ':startDate' => $this->formatDateOnly($event->startDate),
457            ':endDate' => $this->formatDateOnly($event->endDate),
458            ':buildUpDays' => $event->buildUpDays,
459            ':windDownDays' => $event->windDownDays,
460            ':color' => $event->color,
461            ':icon' => $event->icon,
462            ':notes' => $event->description,
463            ':isActive' => 1,
464            ':isRecurring' => $event->isRecurring ? 1 : 0,
465        ]);
466
467        return (int) $this->db->lastInsertId();
468    }
469
470    /**
471     * Sync categories for a bsEvents record
472     *
473     * @param int $bsEventId The bsEvents ID
474     * @param array $categoryIds Array of category IDs
475     * @param array $config Integration configuration for priorities
476     */
477    private function syncCategories(int $bsEventId, array $categoryIds, array $config): void
478    {
479        // Delete existing category links
480        $sql = "DELETE FROM `" . self::TABLE_CATEGORIES . "` WHERE `eventId` = :eventId";
481        $stmt = $this->db->prepare($sql);
482        $stmt->execute([':eventId' => $bsEventId]);
483
484        // Insert new category links
485        if (empty($categoryIds)) {
486            return;
487        }
488
489        $priorities = $this->getConfigValue($config, 'categoryPriorities', []);
490        $priorityBoost = $this->getConfigValue($config, 'priorityBoost', false);
491        $basePriority = $priorityBoost ? 10 : 5;
492
493        $sql = "INSERT INTO `" . self::TABLE_CATEGORIES . "` (`eventId`, `categoryId`, `priority`)
494                VALUES (:eventId, :categoryId, :priority)";
495        $stmt = $this->db->prepare($sql);
496
497        foreach ($categoryIds as $categoryId) {
498            $priority = $priorities[$categoryId] ?? $basePriority;
499            $stmt->execute([
500                ':eventId' => $bsEventId,
501                ':categoryId' => (int) $categoryId,
502                ':priority' => $priority,
503            ]);
504        }
505    }
506
507    /**
508     * Get a bsEvents record by ID
509     *
510     * @param int $id The bsEvents ID
511     * @return array|null The record or null if not found
512     */
513    private function getBsEvent(int $id): ?array
514    {
515        $sql = "SELECT * FROM `" . self::TABLE_EVENTS . "` WHERE `id` = :id";
516        $stmt = $this->db->prepare($sql);
517        $stmt->execute([':id' => $id]);
518        $row = $stmt->fetch(PDO::FETCH_ASSOC);
519        return $row ?: null;
520    }
521
522    /**
523     * Determine status from bsEvents record state
524     *
525     * @param array $bsEvent The bsEvents record
526     * @return string Status constant
527     */
528    private function determineStatus(array $bsEvent): string
529    {
530        $phase = $this->calculateBsEventPhase($bsEvent);
531
532        if (!$bsEvent['isActive']) {
533            return 'pending';
534        }
535
536        if ($phase === 'completed') {
537            return 'completed';
538        }
539
540        if (in_array($phase, ['build_up', 'active', 'wind_down'])) {
541            return 'active';
542        }
543
544        return 'pending';
545    }
546
547    /**
548     * Calculate the current phase for a bsEvents record
549     *
550     * @param array $bsEvent The bsEvents record
551     * @return string Phase: 'upcoming', 'build_up', 'active', 'wind_down', 'completed'
552     */
553    private function calculateBsEventPhase(array $bsEvent): string
554    {
555        $startDate = $bsEvent['startDate'] ?? null;
556        $endDate = $bsEvent['endDate'] ?? null;
557
558        if (!$startDate || !$endDate) {
559            return 'upcoming';
560        }
561
562        $today = new \DateTime();
563        $start = new \DateTime($startDate);
564        $end = new \DateTime($endDate);
565        $buildUpDays = (int) ($bsEvent['buildUpDays'] ?? 14);
566        $windDownDays = (int) ($bsEvent['windDownDays'] ?? 7);
567
568        $buildUpStart = (clone $start)->modify("-{$buildUpDays} days");
569        $windDownEnd = (clone $end)->modify("+{$windDownDays} days");
570
571        if ($today < $buildUpStart) {
572            return 'upcoming';
573        } elseif ($today >= $buildUpStart && $today < $start) {
574            return 'build_up';
575        } elseif ($today >= $start && $today <= $end) {
576            return 'active';
577        } elseif ($today > $end && $today <= $windDownEnd) {
578            return 'wind_down';
579        } else {
580            return 'completed';
581        }
582    }
583
584    /**
585     * Get the count of categories linked to a bsEvents record
586     *
587     * @param int $bsEventId The bsEvents ID
588     * @return int Category count
589     */
590    private function getCategoryCount(int $bsEventId): int
591    {
592        $sql = "SELECT COUNT(*) FROM `" . self::TABLE_CATEGORIES . "` WHERE `eventId` = :eventId";
593        $stmt = $this->db->prepare($sql);
594        $stmt->execute([':eventId' => $bsEventId]);
595        return (int) $stmt->fetchColumn();
596    }
597
598    /**
599     * Get bin counts for a bsEvents record
600     *
601     * Returns counts by status:
602     * - total: All bins in linked categories
603     * - stored: Bins in off-site locations
604     * - pulled: Bins on-site with last action != 0
605     * - finished: Bins with last action = 0 (emptied)
606     *
607     * @param int $bsEventId The bsEvents ID
608     * @return array Bin counts
609     */
610    private function getBinCounts(int $bsEventId): array
611    {
612        $counts = [
613            'total' => 0,
614            'stored' => 0,
615            'pulled' => 0,
616            'finished' => 0,
617        ];
618
619        // Get category IDs for this event
620        $sql = "SELECT `categoryId` FROM `" . self::TABLE_CATEGORIES . "` WHERE `eventId` = :eventId";
621        $stmt = $this->db->prepare($sql);
622        $stmt->execute([':eventId' => $bsEventId]);
623        $categoryIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
624
625        if (empty($categoryIds)) {
626            return $counts;
627        }
628
629        $placeholders = implode(',', array_fill(0, count($categoryIds), '?'));
630
631        // Get bins matching event categories with their location and last action
632        $sql = "SELECT DISTINCT b.id, l.onsite,
633                    (SELECT action FROM bsActions WHERE binID = b.id ORDER BY timePerformed DESC, id DESC LIMIT 1) as lastAction
634                FROM bsBins b
635                LEFT JOIN bsLocations l ON b.location = l.id
636                LEFT JOIN bsBin_Cat bc ON b.id = bc.binID
637                WHERE b.deleted = 0
638                AND (b.mainCategory IN ($placeholders) OR bc.catID IN ($placeholders))";
639
640        $stmt = $this->db->prepare($sql);
641        $params = array_merge($categoryIds, $categoryIds);
642        $stmt->execute($params);
643        $bins = $stmt->fetchAll(PDO::FETCH_ASSOC);
644
645        $counts['total'] = count($bins);
646
647        foreach ($bins as $bin) {
648            $lastAction = $bin['lastAction'];
649            $onsite = (int) ($bin['onsite'] ?? 0);
650
651            if ($lastAction === '0' || $lastAction === 0) {
652                $counts['finished']++;
653            } elseif ($onsite === 1) {
654                $counts['pulled']++;
655            } else {
656                $counts['stored']++;
657            }
658        }
659
660        return $counts;
661    }
662}