diff --git a/.gitignore b/.gitignore index 0605e39..0f440f0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ # Local History for Visual Studio Code .history/ - +woo-discount-rules/* # Built Visual Studio Code Extensions *.vsix .env diff --git a/api/actions/products.php b/api/actions/products.php index e4aebfe..54f0541 100644 --- a/api/actions/products.php +++ b/api/actions/products.php @@ -3,9 +3,11 @@ * Products Action - Get products with enriched data * * Optimized to fetch only required fields for telesales app + * Includes BOGO rules from Woo Discount Rules plugin */ require_once __DIR__ . '/../services/UpsellService.php'; +require_once __DIR__ . '/../services/DiscountRulesService.php'; /** * Handle get_products action @@ -14,8 +16,27 @@ require_once __DIR__ . '/../services/UpsellService.php'; */ function handleGetProducts(): void { + $logFile = __DIR__ . '/../../logs/products_debug.log'; + $logDir = dirname($logFile); + if (!is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + $log = function($msg, $data = null) use ($logFile) { + $timestamp = date('Y-m-d H:i:s'); + $logMessage = "[{$timestamp}] {$msg}"; + if ($data !== null) { + $logMessage .= "\n" . print_r($data, true); + } + $logMessage .= "\n" . str_repeat('-', 80) . "\n"; + file_put_contents($logFile, $logMessage, FILE_APPEND); + }; + try { + $log("handleGetProducts() started"); + $woocommerce = getWooCommerce(); + $log("WooCommerce client initialized"); // Only fetch fields needed for telesales app $productFields = 'id,name,price,type,upsell_ids,cross_sell_ids'; @@ -26,12 +47,29 @@ function handleGetProducts(): void 'per_page' => 100, '_fields' => $productFields ]); + $log("Fetched products from WooCommerce", ['count' => count($products)]); + // Build service maps $cuw_map = UpsellService::buildProductMap(); + $log("Built CUW map"); + + // Get BOGO rules from Woo Discount Rules + $log("Creating DiscountRulesService..."); + $discountService = new DiscountRulesService(); + $log("DiscountRulesService created"); + + $productIds = array_map(fn($p) => (int) $p->id, $products); + $log("Product IDs extracted", ['count' => count($productIds)]); + + $log("Calling buildProductBogoMap..."); + $bogo_map = $discountService->buildProductBogoMap($productIds); + $log("BOGO map built", ['products_with_bogo' => count($bogo_map)]); $enriched = []; foreach ($products as $product) { + $productId = (int) $product->id; + // Get variations for variable products (only needed fields) $variation_details = []; if ($product->type === 'variable') { @@ -52,17 +90,20 @@ function handleGetProducts(): void ? array_map('intval', (array) $product->cross_sell_ids) : []; - $cuw_ids = $cuw_map[(int)$product->id] ?? []; + $cuw_ids = $cuw_map[$productId] ?? []; $recommended_ids = array_values(array_unique(array_filter(array_merge( $upsell_ids, $cross_sell_ids, $cuw_ids )))); + + // Get BOGO rules for this product + $bogo_rules = $bogo_map[$productId] ?? []; // Build minimal product object with only needed fields $enriched[] = [ - 'id' => (int) $product->id, + 'id' => $productId, 'name' => $product->name, 'price' => $product->price, 'type' => $product->type, @@ -70,12 +111,28 @@ function handleGetProducts(): void 'cross_sell_ids' => $cross_sell_ids, 'variation_details' => $variation_details, 'cuw_ids' => $cuw_ids, - 'recommended_ids' => $recommended_ids + 'recommended_ids' => $recommended_ids, + 'bogo_rules' => $bogo_rules ]; } + $log("Returning " . count($enriched) . " enriched products"); echo json_encode($enriched); } catch (Exception $e) { - echo json_encode([]); + $log("ERROR in handleGetProducts", [ + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString() + ]); + echo json_encode(['error' => $e->getMessage()]); + } catch (Error $e) { + $log("FATAL ERROR in handleGetProducts", [ + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString() + ]); + echo json_encode(['error' => $e->getMessage()]); } } diff --git a/api/config.php b/api/config.php index f274ebe..8d8ae2b 100644 --- a/api/config.php +++ b/api/config.php @@ -6,7 +6,7 @@ use Automattic\WooCommerce\Client; /** - * Get database connection + * Get database connection (Sales Panel database) * @return mysqli */ function getDatabase(): mysqli @@ -33,6 +33,44 @@ function getDatabase(): mysqli return $db; } +/** + * Get WooCommerce database connection (WordPress/WooCommerce database) + * Used for accessing plugin tables like wp_wdr_rules + * @return mysqli + */ +function getWooCommerceDatabase(): mysqli +{ + static $wcDb = null; + + if ($wcDb === null) { + $wcDb = new mysqli( + $_ENV['WC_DB_HOST'] ?? $_ENV['DB_HOST'], + $_ENV['WC_DB_USER'] ?? $_ENV['DB_USER'], + $_ENV['WC_DB_PASS'] ?? $_ENV['DB_PASS'], + $_ENV['WC_DB_NAME'] ?? $_ENV['DB_NAME'] + ); + + if ($wcDb->connect_error) { + header('Content-Type: application/json'); + http_response_code(500); + die(json_encode(['error' => 'WooCommerce database connectie mislukt: ' . $wcDb->connect_error])); + } + + $wcDb->set_charset('utf8mb4'); + } + + return $wcDb; +} + +/** + * Get WooCommerce database table prefix + * @return string + */ +function getWooCommerceDbPrefix(): string +{ + return $_ENV['WC_DB_PREFIX'] ?? 'wp_'; +} + /** * Get WooCommerce client * @return Client diff --git a/api/services/DiscountRulesService.php b/api/services/DiscountRulesService.php new file mode 100644 index 0000000..200d9bf --- /dev/null +++ b/api/services/DiscountRulesService.php @@ -0,0 +1,542 @@ +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 []; + } + } +} diff --git a/index.html b/index.html index 75d3689..c950552 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,7 @@ + @@ -136,7 +137,13 @@
- +
+ + + +
@@ -146,6 +153,32 @@
+ +
+
+
+
+ + + +
+
+

+

+
+
+ + +
+
+