
1. 引言:PrestaShop 产品组合价格显示机制与优化需求
prestashop 默认情况下,当产品存在多种组合(如不同颜色、尺寸等)时,产品页面通常会显示默认组合的价格,或者在某些主题中,价格会根据用户选择的组合动态更新。然而,平台本身并未提供直接显示“该产品所有组合中的最低价格”的功能,也无法自动将最低价格的组合设置为默认选中状态。对于拥有大量组合商品的商家而言,这可能导致用户无法直观地看到产品的最佳性价比,从而影响转化率。
本教程旨在解决这一痛点,通过修改核心控制器逻辑,实现以下目标:
- 识别产品所有组合中的最低价格。
- 将拥有最低价格的组合设置为产品页面的默认选中项。
- 确保前端价格显示与最低价格组合保持一致。
2. 核心思路:通过控制器覆盖实现逻辑修改
PrestaShop 遵循 MVC 架构,产品页面的数据准备主要由 ProductController 负责。具体到产品组合信息的处理,assignAttributesGroups 方法是关键所在。我们将通过创建控制器覆盖(Override)来修改此方法,以避免直接修改核心文件,从而确保系统升级时的兼容性。
为什么使用覆盖? 直接修改 PrestaShop 核心文件(例如 controllers/front/ProductController.php)会导致在系统更新时,您的修改被覆盖,从而丢失。使用覆盖机制 (/override 目录) 是 PrestaShop 推荐的定制方式,它允许您在不触碰核心文件的情况下扩展或修改现有功能。
3. 实现步骤:修改 ProductController
3.1 创建或修改 ProductController 覆盖文件
首先,您需要在您的 PrestaShop 安装目录中创建或修改覆盖文件。 路径:your_prestashop_root/override/controllers/front/ProductController.php
如果文件不存在,请创建它。文件内容应包含以下基本结构:
3.2 修改 assignAttributesGroups 方法
现在,我们将 assignAttributesGroups 方法复制到 ProductController.php 覆盖文件中,并进行必要的修改。我们的目标是在遍历所有属性组时,找到最低价格的组合,并将其 id_attribute 存储起来,以便后续设置为默认选中。
找到原始 assignAttributesGroups 方法中的以下代码段:
protected function assignAttributesGroups($product_for_template = null) { $colors = []; $groups = []; $this->combinations = []; /** @todo (RM) should only get groups and not all declination ? */ $attributes_groups = $this->product->getAttributesGroups($this->context->language->id); if (is_array($attributes_groups) && $attributes_groups) { // ... 现有逻辑 ... } // ... 方法的其余部分 ... }在 $this->combinations = []; 之后,但在 /** @todo ... */ 之前,添加用于查找最低价格组合的逻辑。同时,修改设置选中状态的代码,使其指向最低价格组合。
combinations = []; // 【新增代码段1:查找最低价格组合】 $lowestPrice = ["lowest_price" => null, "lowest_price_id" => null]; $attributes_groups = $this->product->getAttributesGroups($this->context->language->id); if (is_array($attributes_groups) && $attributes_groups) { foreach ($attributes_groups as $k => $row) { // 比较当前组合价格与已知的最低价格 if ($lowestPrice["lowest_price"] === null || (float)$row['price'] < $lowestPrice["lowest_price"]) { $lowestPrice["lowest_price"] = (float)$row['price']; $lowestPrice["lowest_price_id"] = $row['id_attribute']; } } } // 【新增代码段1 结束】 /** @todo (RM) should only get groups and not all declination ? */ // 重新获取属性组,因为上面的循环可能已经遍历过一次 $attributes_groups = $this->product->getAttributesGroups($this->context->language->id); if (is_array($attributes_groups) && $attributes_groups) { $combination_images = $this->product->getCombinationImages($this->context->language->id); $combination_prices_set = []; foreach ($attributes_groups as $k => $row) { // Color management if (isset($row['is_color_group']) && $row['is_color_group'] && (isset($row['attribute_color']) && $row['attribute_color']) || (file_exists(_PS_COL_IMG_DIR_ . $row['id_attribute'] . '.jpg'))) { $colors[$row['id_attribute']]['value'] = $row['attribute_color']; $colors[$row['id_attribute']]['name'] = $row['attribute_name']; if (!isset($colors[$row['id_attribute']]['attributes_quantity'])) { $colors[$row['id_attribute']]['attributes_quantity'] = 0; } $colors[$row['id_attribute']]['attributes_quantity'] += (int) $row['quantity']; } if (!isset($groups[$row['id_attribute_group']])) { $groups[$row['id_attribute_group']] = [ 'group_name' => $row['group_name'], 'name' => $row['public_group_name'], 'group_type' => $row['group_type'], 'default' => -1, ]; } $groups[$row['id_attribute_group']]['attributes'][$row['id_attribute']] = [ 'name' => $row['attribute_name'], 'html_color_code' => $row['attribute_color'], 'texture' => (@filemtime(_PS_COL_IMG_DIR_ . $row['id_attribute'] . '.jpg')) ? _THEME_COL_DIR_ . $row['id_attribute'] . '.jpg' : '', // 【修改代码段2:设置选中状态】 // 原始代码:#'selected' => (isset($product_for_template['attributes'][$row['id_attribute_group']]['id_attribute']) && $product_for_template['attributes'][$row['id_attribute_group']]['id_attribute'] == $row['id_attribute']) ? true : false, 'selected'=> ($lowestPrice["lowest_price_id"] == $row['id_attribute']) ? true : false, // 【修改代码段2 结束】 ]; //$product.attributes.$id_attribute_group.id_attribute eq $id_attribute if ($row['default_on'] && $groups[$row['id_attribute_group']]['default'] == -1) { $groups[$row['id_attribute_group']]['default'] = (int) $row['id_attribute']; } if (!isset($groups[$row['id_attribute_group']]['attributes_quantity'][$row['id_attribute']])) { $groups[$row['id_attribute_group']]['attributes_quantity'][$row['id_attribute']] = 0; } $groups[$row['id_attribute_group']]['attributes_quantity'][$row['id_attribute']] += (int) $row['quantity']; $this->combinations[$row['id_product_attribute']]['attributes_values'][$row['id_attribute_group']] = $row['attribute_name']; $this->combinations[$row['id_product_attribute']]['attributes'][] = (int) $row['id_attribute']; $this->combinations[$row['id_product_attribute']]['price'] = (float) $row['price']; // Call getPriceStatic in order to set $combination_specific_price if (!isset($combination_prices_set[(int) $row['id_product_attribute']])) { $combination_specific_price = null; Product::getPriceStatic((int) $this->product->id, false, $row['id_product_attribute'], 6, null, false, true, 1, false, null, null, null, $combination_specific_price); $combination_prices_set[(int) $row['id_product_attribute']] = true; $this->combinations[$row['id_product_attribute']]['specific_price'] = $combination_specific_price; } $this->combinations[$row['id_product_attribute']]['ecotax'] = (float) $row['ecotax']; $this->combinations[$row['id_product_attribute']]['weight'] = (float) $row['weight']; $this->combinations[$row['id_product_attribute']]['quantity'] = (int) $row['quantity']; $this->combinations[$row['id_product_attribute']]['reference'] = $row['reference']; $this->combinations[$row['id_product_attribute']]['unit_impact'] = $row['unit_price_impact']; $this->combinations[$row['id_product_attribute']]['minimal_quantity'] = $row['minimal_quantity']; if ($row['available_date'] != '0000-00-00' && Validate::isDate($row['available_date'])) { $this->combinations[$row['id_product_attribute']]['available_date'] = $row['available_date']; $this->combinations[$row['id_product_attribute']]['date_formatted'] = Tools::displayDate($row['available_date']); } else { $this->combinations[$row['id_product_attribute']]['available_date'] = $this->combinations[$row['id_product_attribute']]['date_formatted'] = ''; } if (!isset($combination_images[$row['id_product_attribute']][0]['id_image'])) { $this->combinations[$row['id_product_attribute']]['id_image'] = -1; } else { $this->combinations[$row['id_product_attribute']]['id_image'] = $id_image = (int) $combination_images[$row['id_product_attribute']][0]['id_image']; if ($row['default_on']) { foreach ($this->context->smarty->tpl_vars['product']->value['images'] as $image) { if ($image['cover'] == 1) { $current_cover = $image; } } if (!isset($current_cover)) { $current_cover = array_values($this->context->smarty->tpl_vars['product']->value['images'])[0]; } if (is_array($combination_images[$row['id_product_attribute']])) { foreach ($combination_images[$row['id_product_attribute']] as $tmp) { if ($tmp['id_image'] == $current_cover['id_image']) { $this->combinations[$row['id_product_attribute']]['id_image'] = $id_image = (int) $tmp['id_image']; break; } } } if ($id_image > 0) { if (isset($this->context->smarty->tpl_vars['images']->value)) { $product_images = $this->context->smarty->tpl_vars['images']->value; } if (isset($product_images) && is_array($product_images) && isset($product_images[$id_image])) { $product_images[$id_image]['cover'] = 1; $this->context->smarty->assign('mainImage', $product_images[$id_image]); if (count($product_images)) { $this->context->smarty->assign('images', $product_images); } } $cover = $current_cover; if (isset($cover) && is_array($cover) && isset($product_images) && is_array($product_images)) { $product_images[$cover['id_image']]['cover'] = 0; if (isset($product_images[$id_image])) { $cover = $product_images[$id_image]; } $cover['id_image'] = (Configuration::get('PS_LEGACY_IMAGES') ? ($this->product->id . '-' . $id_image) : (int) $id_image); $cover['id_image_only'] = (int) $id_image; $this->context->smarty->assign('cover', $cover); } } } } } // 【新增代码段3:覆盖属性组的默认选中】 // 在原始代码的 'foreach ($attributes_groups as $k => $row)' 循环结束后, // 且在 'wash attributes list depending on available attributes' 逻辑之前添加。 // 这一步确保即使有多个属性组,也能将第一个属性组的默认选中项设置为最低价格组合的ID。 // 注意:此处的 $row['id_attribute_group'] 在循环结束后可能不是我们想要的, // 更好的做法是遍历 $groups 数组,找到第一个属性组并设置其默认值。 // 简单起见,如果最低价格ID属于某个属性组,我们将其设置为该属性组的默认。 // 更严谨的实现需要根据 $lowestPrice['lowest_price_id'] 找到它所属的 $id_attribute_group。 // 考虑到PrestaShop通常会将所有属性归类到不同的属性组, // 并且我们只想设置一个默认选中项,这里假设最低价格的属性ID能被正确匹配。 if ($lowestPrice["lowest_price_id"] !== null) { foreach ($groups as $id_group => &$group_data) { if (isset($group_data['attributes'][$lowestPrice["lowest_price_id"]])) { $group_data['default'] = (int) $lowestPrice['lowest_price_id']; break; // 找到并设置后即可退出 } } } // 【新增代码段3 结束】 // wash attributes list depending on available attributes depending on selected preceding attributes $current_selected_attributes = []; $count = 0; foreach ($groups as &$group) { ++$count; if ($count > 1) { //find attributes of current group, having a possible combination with current selected $id_product_attributes = [0]; $query = 'SELECT pac.`id_product_attribute` FROM `' . _DB_PREFIX_ . 'product_attribute_combination` pac INNER JOIN `' . _DB_PREFIX_ . 'product_attribute` pa ON pa.id_product_attribute = pac.id_product_attribute WHERE id_product = ' . $this->product->id . ' AND id_attribute IN (' . implode(',', array_map('intval', $current_selected_attributes)) . ') GROUP BY id_product_attribute HAVING COUNT(id_product) = ' . count($current_selected_attributes); if ($results = Db::getInstance()->executeS($query)) { foreach ($results as $row) { $id_product_attributes[] = $row['id_product_attribute']; } } $id_attributes = Db::getInstance()->executeS('SELECT `id_attribute` FROM `' . _DB_PREFIX_ . 'product_attribute_combination` pac2 WHERE `id_product_attribute` IN (' . implode(',', array_map('intval', $id_product_attributes)) . ') AND id_attribute NOT IN (' . implode(',', array_map('intval', $current_selected_attributes)) . ')'); foreach ($id_attributes as $k => $row) { $id_attributes[$k] = (int) $row['id_attribute']; } foreach ($group['attributes'] as $key => $attribute) { if (!in_array((int) $key, $id_attributes)) { unset( $group['attributes'][$key], $group['attributes_quantity'][$key] ); } } } //find selected attribute or first of group $index = 0; $current_selected_attribute = 0; foreach ($group['attributes'] as $key => $attribute) { if ($index === 0) { $current_selected_attribute = $key; } if ($attribute['selected']) { $current_selected_attribute = $key; break; } } if ($current_selected_attribute > 0) { $current_selected_attributes[] = $current_selected_attribute; } } // wash attributes list (if some attributes are unavailables and if allowed to wash it) if (!Product::isAvailableWhenOutOfStock($this->product->out_of_stock) && Configuration::get('PS_DISP_UNAVAILABLE_ATTR') == 0) { foreach ($groups as &$group) { foreach ($group['attributes_quantity'] as $key => &$quantity) { if ($quantity <= 0) { unset($group['attributes'][$key]); } } } foreach ($colors as $key => $color) { if ($color['attributes_quantity'] <= 0) { unset($colors[$key]); } } } foreach ($this->combinations as $id_product_attribute => $comb) { $attribute_list = ''; foreach ($comb['attributes'] as $id_attribute) { $attribute_list .= '\'' . (int) $id_attribute . '\','; } $attribute_list = rtrim($attribute_list, ','); $this->combinations[$id_product_attribute]['list'] = $attribute_list; } $this->context->smarty->assign([ 'groups' => $groups, 'colors' => (count($colors)) ? $colors : false, 'combinations' => $this->combinations, 'combinationImages' => $combination_images, ]); } else { $this->context->smarty->assign([ 'groups' => [], 'colors' => false, 'combinations' => [], 'combination










