db = getWooCommerceDatabase(); $this->tablePrefix = getWooCommerceDbPrefix(); $this->logFile = __DIR__ . '/../../logs/bogo_debug.log'; // Ensure logs directory exists $logDir = dirname($this->logFile); if (!is_dir($logDir)) { mkdir($logDir, 0755, true); } $this->log("DiscountRulesService initialized", [ 'tablePrefix' => $this->tablePrefix, 'db_connected' => $this->db->ping() ]); } /** * Log debug message * * @param string $message * @param mixed $data */ private function log(string $message, $data = null): void { if (!$this->debug) return; $timestamp = date('Y-m-d H:i:s'); $logMessage = "[{$timestamp}] {$message}"; if ($data !== null) { $logMessage .= "\n" . print_r($data, true); } $logMessage .= "\n" . str_repeat('-', 80) . "\n"; file_put_contents($this->logFile, $logMessage, FILE_APPEND); } /** * Get all active BOGO rules mapped by product ID * * @return array Map of product_id => array of BOGO rules */ public function getBogoRulesMap(): array { $rules = $this->fetchActiveBogoRules(); return $this->mapRulesToProducts($rules); } /** * Fetch active BOGO rules from database * * @return array */ private function fetchActiveBogoRules(): array { $table = $this->tablePrefix . 'wdr_rules'; $currentTime = time(); $this->log("fetchActiveBogoRules() called", [ 'table' => $table, 'currentTime' => $currentTime, 'tablePrefix' => $this->tablePrefix ]); // First check if table exists $tableCheck = $this->db->query("SHOW TABLES LIKE '{$table}'"); if ($tableCheck->num_rows === 0) { $this->log("ERROR: Table {$table} does not exist!"); return []; } $this->log("Table {$table} exists"); $query = "SELECT id, title, filters, buy_x_get_x_adjustments, buy_x_get_y_adjustments, advanced_discount_message FROM {$table} WHERE enabled = 1 AND deleted = 0 AND (date_from <= ? OR date_from IS NULL) AND (date_to >= ? OR date_to IS NULL) AND (usage_limits > used_limits OR used_limits IS NULL OR usage_limits = 0) AND ( (buy_x_get_x_adjustments IS NOT NULL AND buy_x_get_x_adjustments != '{}' AND buy_x_get_x_adjustments != '[]') OR (buy_x_get_y_adjustments IS NOT NULL AND buy_x_get_y_adjustments != '{}' AND buy_x_get_y_adjustments != '[]') ) ORDER BY priority ASC"; $this->log("Executing query", $query); $stmt = $this->db->prepare($query); if (!$stmt) { $this->log("ERROR: Failed to prepare statement", $this->db->error); return []; } $stmt->bind_param('ii', $currentTime, $currentTime); if (!$stmt->execute()) { $this->log("ERROR: Failed to execute statement", $stmt->error); $stmt->close(); return []; } $result = $stmt->get_result(); $rules = []; while ($row = $result->fetch_assoc()) { $rules[] = $row; } $this->log("Found " . count($rules) . " BOGO rules", $rules); $stmt->close(); return $rules; } /** * Map rules to products based on filters * * @param array $rules * @return array */ private function mapRulesToProducts(array $rules): array { $productMap = []; foreach ($rules as $rule) { $productIds = $this->extractProductIdsFromFilters($rule['filters']); $bogoData = $this->parseBogoAdjustments($rule); if (empty($bogoData)) { continue; } foreach ($productIds as $productId) { if (!isset($productMap[$productId])) { $productMap[$productId] = []; } $productMap[$productId][] = $bogoData; } } return $productMap; } /** * Extract product IDs from rule filters * * @param string|null $filtersJson * @return array */ private function extractProductIdsFromFilters(?string $filtersJson): array { if (empty($filtersJson)) { return []; } $filters = json_decode($filtersJson, true); if (!is_array($filters)) { return []; } $productIds = []; foreach ($filters as $filter) { $type = $filter['type'] ?? ''; $value = $filter['value'] ?? []; // Direct product filter if ($type === 'products' && !empty($value)) { $productIds = array_merge($productIds, array_map('intval', $value)); } // Category filter - we'd need to expand this to actual products // For now, we'll handle this separately if needed if ($type === 'product_category' && !empty($value)) { $categoryProducts = $this->getProductsByCategories($value); $productIds = array_merge($productIds, $categoryProducts); } // All products filter if ($type === 'all_products') { // Return empty to indicate "all products" - handled differently return ['all']; } } return array_unique($productIds); } /** * Get product IDs by category IDs * * @param array $categoryIds * @return array */ private function getProductsByCategories(array $categoryIds): array { if (empty($categoryIds)) { return []; } $table = $this->tablePrefix . 'term_relationships'; $placeholders = implode(',', array_fill(0, count($categoryIds), '?')); $query = "SELECT DISTINCT object_id FROM {$table} WHERE term_taxonomy_id IN ({$placeholders})"; $stmt = $this->db->prepare($query); $types = str_repeat('i', count($categoryIds)); $stmt->bind_param($types, ...$categoryIds); $stmt->execute(); $result = $stmt->get_result(); $productIds = []; while ($row = $result->fetch_assoc()) { $productIds[] = (int) $row['object_id']; } $stmt->close(); return $productIds; } /** * Parse BOGO adjustments from rule * * @param array $rule * @return array|null */ private function parseBogoAdjustments(array $rule): ?array { // Try Buy X Get X first $bxgx = $this->parseBuyXGetX($rule); if ($bxgx) { return $bxgx; } // Try Buy X Get Y $bxgy = $this->parseBuyXGetY($rule); if ($bxgy) { return $bxgy; } return null; } /** * Extract badge_text from advanced_discount_message * * @param array $rule * @return string|null */ private function extractBadgeText(array $rule): ?string { $advancedMessage = $rule['advanced_discount_message'] ?? null; if (empty($advancedMessage)) { return null; } $data = json_decode($advancedMessage, true); if (!is_array($data)) { return null; } // Look for badge_text in discount_badge if (isset($data['discount_badge']['badge_text']) && !empty($data['discount_badge']['badge_text'])) { return $data['discount_badge']['badge_text']; } // Fallback to badge_text at root level if (isset($data['badge_text']) && !empty($data['badge_text'])) { return $data['badge_text']; } return null; } /** * Parse Buy X Get X adjustments (e.g., 1+1 free) * * @param array $rule * @return array|null */ private function parseBuyXGetX(array $rule): ?array { $json = $rule['buy_x_get_x_adjustments'] ?? null; if (empty($json) || $json === '{}' || $json === '[]') { return null; } $data = json_decode($json, true); if (!isset($data['ranges']) || empty($data['ranges'])) { return null; } // Get the first range (most common case) $range = reset($data['ranges']); if (!$range) { return null; } $buyQty = (int) ($range['from'] ?? 0); $getQty = (int) ($range['free_qty'] ?? 0); $freeType = $range['free_type'] ?? 'free_product'; $freeValue = (float) ($range['free_value'] ?? 100); $recursive = !empty($range['recursive']); if ($buyQty <= 0 || $getQty <= 0) { return null; } // Generate human-readable label (fallback) $label = $this->generateBogoLabel($buyQty, $getQty, $freeType, $freeValue); // Extract badge_text from advanced_discount_message $badgeText = $this->extractBadgeText($rule); return [ 'rule_id' => (int) $rule['id'], 'rule_title' => $rule['title'], 'type' => 'buy_x_get_x', 'label' => $label, 'badge_text' => $badgeText, 'buy_qty' => $buyQty, 'get_qty' => $getQty, 'discount_type' => $freeType, 'discount_value' => $freeValue, 'recursive' => $recursive, 'get_product_ids' => null // Same product ]; } /** * Parse Buy X Get Y adjustments (e.g., buy A get B free) * * @param array $rule * @return array|null */ private function parseBuyXGetY(array $rule): ?array { $json = $rule['buy_x_get_y_adjustments'] ?? null; if (empty($json) || $json === '{}' || $json === '[]') { return null; } $data = json_decode($json, true); // Check if it's a valid BXGY rule $type = $data['type'] ?? ''; if ($type !== 'bxgy_product' && $type !== 'bxgy_category') { return null; } if (!isset($data['ranges']) || empty($data['ranges'])) { return null; } // Get the first range $range = reset($data['ranges']); if (!$range) { return null; } $buyQty = (int) ($range['from'] ?? 0); $getQty = (int) ($range['free_qty'] ?? 0); $freeType = $range['free_type'] ?? 'free_product'; $freeValue = (float) ($range['free_value'] ?? 100); $getProductIds = $range['products'] ?? []; $recursive = !empty($range['recursive']); if ($buyQty <= 0 || $getQty <= 0) { return null; } // Generate human-readable label (fallback) $label = $this->generateBogoLabel($buyQty, $getQty, $freeType, $freeValue, true); // Extract badge_text from advanced_discount_message $badgeText = $this->extractBadgeText($rule); return [ 'rule_id' => (int) $rule['id'], 'rule_title' => $rule['title'], 'type' => 'buy_x_get_y', 'label' => $label, 'badge_text' => $badgeText, 'buy_qty' => $buyQty, 'get_qty' => $getQty, 'discount_type' => $freeType, 'discount_value' => $freeValue, 'recursive' => $recursive, 'get_product_ids' => array_map('intval', $getProductIds) ]; } /** * Generate human-readable BOGO label * * @param int $buyQty * @param int $getQty * @param string $discountType * @param float $discountValue * @param bool $isDifferentProduct * @return string */ private function generateBogoLabel( int $buyQty, int $getQty, string $discountType, float $discountValue, bool $isDifferentProduct = false ): string { // Common patterns if ($discountType === 'free_product' || $discountValue >= 100) { if ($buyQty === 1 && $getQty === 1) { return $isDifferentProduct ? 'Koop 1, krijg 1 ander gratis' : '1+1 Gratis'; } if ($buyQty === 2 && $getQty === 1) { return $isDifferentProduct ? 'Koop 2, krijg 1 ander gratis' : '2+1 Gratis'; } if ($buyQty === 3 && $getQty === 1) { return $isDifferentProduct ? 'Koop 3, krijg 1 ander gratis' : '3+1 Gratis'; } return "Koop {$buyQty}, krijg {$getQty} gratis"; } if ($discountType === 'percentage') { $pct = (int) $discountValue; return "Koop {$buyQty}, krijg {$getQty} met {$pct}% korting"; } if ($discountType === 'flat') { return "Koop {$buyQty}, krijg {$getQty} met €" . number_format($discountValue, 2, ',', '.') . " korting"; } return "Koop {$buyQty}, krijg {$getQty}"; } /** * Build a complete product map including "all products" rules * * @param array $productIds List of all product IDs to check * @return array */ public function buildProductBogoMap(array $productIds): array { $this->log("buildProductBogoMap() called", [ 'productIds_count' => count($productIds), 'productIds_sample' => array_slice($productIds, 0, 5) ]); try { $rules = $this->fetchActiveBogoRules(); $this->log("Fetched rules", ['count' => count($rules)]); $productMap = []; $allProductsRules = []; foreach ($rules as $rule) { $this->log("Processing rule", [ 'id' => $rule['id'], 'title' => $rule['title'], 'filters' => $rule['filters'] ]); $filterProductIds = $this->extractProductIdsFromFilters($rule['filters']); $this->log("Extracted filter product IDs", $filterProductIds); $bogoData = $this->parseBogoAdjustments($rule); $this->log("Parsed BOGO data", $bogoData); if (empty($bogoData)) { $this->log("Skipping rule - no BOGO data"); continue; } // Check if this is an "all products" rule if ($filterProductIds === ['all']) { $this->log("Rule applies to all products"); $allProductsRules[] = $bogoData; continue; } foreach ($filterProductIds as $productId) { if (!isset($productMap[$productId])) { $productMap[$productId] = []; } $productMap[$productId][] = $bogoData; } } // Apply "all products" rules to all products if (!empty($allProductsRules)) { $this->log("Applying 'all products' rules", ['count' => count($allProductsRules)]); foreach ($productIds as $productId) { if (!isset($productMap[$productId])) { $productMap[$productId] = []; } $productMap[$productId] = array_merge($productMap[$productId], $allProductsRules); } } $this->log("Final product map", [ 'products_with_bogo' => count($productMap), 'sample' => array_slice($productMap, 0, 3, true) ]); return $productMap; } catch (Exception $e) { $this->log("ERROR in buildProductBogoMap", [ 'message' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); return []; } } }