telvero-sales-backoffice/api/services/DiscountRulesService.php

543 lines
17 KiB
PHP

<?php
/**
* DiscountRulesService - Fetches BOGO rules from Woo Discount Rules plugin
*
* Queries the wp_wdr_rules table to get active Buy X Get X and Buy X Get Y rules
* and maps them to products for the telesales panel.
*/
class DiscountRulesService
{
private mysqli $db;
private string $tablePrefix;
private bool $debug = true;
private string $logFile;
public function __construct()
{
// Use WooCommerce database for accessing plugin tables
$this->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<int, 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<int, 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<int>
*/
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<int>
*/
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<int, 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 [];
}
}
}