feat(products): show correct product prices

This commit is contained in:
2025-09-25 19:06:15 +03:00
parent acbfaebcf4
commit 35dd0de261
12 changed files with 270 additions and 143 deletions

View File

@@ -0,0 +1,14 @@
<?php
namespace Openguru\OpenCartFramework\Exceptions;
use Exception;
use Throwable;
class EntityNotFoundException extends Exception implements NonLoggableExceptionInterface
{
public function __construct($message = "Entity Not Found", $code = 404, Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}

View File

@@ -5,11 +5,14 @@ namespace Openguru\OpenCartFramework\OpenCart\Decorators;
use Cart\Cart;
use Cart\Currency;
use Config;
use Language;
use Loader;
use ModelCatalogProduct;
use ModelSettingSetting;
use ModelToolImage;
use Registry;
use Session;
use Url;
/**
* @property Loader $load
@@ -17,6 +20,9 @@ use Session;
* @property Session $session
* @property Currency $currency
* @property Config $config
* @property Url $url
* @property Language $language
* @property ModelToolImage $model_tool_image
* @property ModelCatalogProduct $model_catalog_product
* @property ModelSettingSetting $model_setting_setting
*/

View File

@@ -19,4 +19,13 @@ class OcModelCatalogProductAdapter
{
return $this->model->getProductOptions($productId);
}
/**
* @param int $productId
* @return array|false
*/
public function getProduct(int $productId)
{
return $this->model->getProduct($productId);
}
}

View File

@@ -7,8 +7,10 @@ namespace App\Handlers;
use App\Services\ProductsService;
use Exception;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Exceptions\EntityNotFoundException;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Http\Response;
use Openguru\OpenCartFramework\Logger\LoggerInterface;
use RuntimeException;
@@ -25,7 +27,7 @@ class ProductsHandler
$this->logger = $logger;
}
public function handle(Request $request): JsonResponse
public function index(Request $request): JsonResponse
{
$page = (int) $request->get('page', 1);
$perPage = min((int) $request->get('perPage', 6), 15);
@@ -60,14 +62,11 @@ class ProductsHandler
$imageFullHeight = 1000;
try {
$product = $this->productsService->getProduct(
$productId,
$languageId,
$imageWidth,
$imageHeight,
$imageFullWidth,
$imageFullHeight
);
$product = $this->productsService->getProductById($productId);
} catch (EntityNotFoundException $exception) {
return new JsonResponse([
'message' => 'Product with id ' . $productId . ' not found',
], Response::HTTP_NOT_FOUND);
} catch (Exception $exception) {
$this->logger->logException($exception);
throw new RuntimeException('Error get product with id ' . $productId, 500, $exception);

View File

@@ -2,16 +2,17 @@
namespace App\Services;
use App\Adapters\OcModelCatalogProductAdapter;
use Cart\Currency;
use Cart\Tax;
use Exception;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Exceptions\EntityNotFoundException;
use Openguru\OpenCartFramework\ImageTool\ImageToolInterface;
use Openguru\OpenCartFramework\Logger\LoggerInterface;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Support\PaginationHelper;
@@ -21,7 +22,6 @@ class ProductsService
private Currency $currency;
private Tax $tax;
private Settings $settings;
private OcModelCatalogProductAdapter $ocModelCatalogProduct;
private ImageToolInterface $ocImageTool;
private OcRegistryDecorator $oc;
private LoggerInterface $logger;
@@ -31,7 +31,6 @@ class ProductsService
Currency $currency,
Tax $tax,
Settings $settings,
OcModelCatalogProductAdapter $ocModelCatalogProduct,
ImageToolInterface $ocImageTool,
OcRegistryDecorator $registry,
LoggerInterface $logger
@@ -40,7 +39,6 @@ class ProductsService
$this->currency = $currency;
$this->tax = $tax;
$this->settings = $settings;
$this->ocModelCatalogProduct = $ocModelCatalogProduct;
$this->ocImageTool = $ocImageTool;
$this->oc = $registry;
$this->logger = $logger;
@@ -71,6 +69,16 @@ class ProductsService
->value('name');
}
$customerGroupId = (int)$this->oc->config->get('config_customer_group_id');
$specialPriceSql = "(SELECT price
FROM oc_product_special ps
WHERE ps.product_id = products.product_id
AND ps.customer_group_id = $customerGroupId
AND ((ps.date_start = '0000-00-00' OR ps.date_start < NOW()) AND
(ps.date_end = '0000-00-00' OR ps.date_end > NOW()))
ORDER BY ps.priority ASC, ps.price ASC
LIMIT 1) AS special";
$productsQuery = $this->queryBuilder->newQuery()
->select([
'products.product_id' => 'product_id',
@@ -79,6 +87,7 @@ class ProductsService
'products.price' => 'price',
'products.image' => 'product_image',
'products.tax_class_id' => 'tax_class_id',
new RawExpression($specialPriceSql),
])
->from(db_table('product'), 'products')
->join(
@@ -166,6 +175,18 @@ class ProductsService
$this->settings->get('oc_default_currency'),
);
$special = false;
if ($product['special'] && (float) $product['special'] >= 0) {
$special = $this->currency->format(
$this->tax->calculate(
$product['special'],
$product['tax_class_id'],
$this->settings->get('oc_config_tax'),
),
$this->settings->get('oc_default_currency'),
);
}
if (! empty($productsImagesMap[$product['product_id']])) {
$allImages = array_merge($allImages, $productsImagesMap[$product['product_id']]);
}
@@ -175,6 +196,7 @@ class ProductsService
'product_quantity' => (int) $product['product_quantity'],
'name' => html_entity_decode($product['product_name'], ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'price' => $price,
'special' => $special,
'images' => $allImages,
];
}, $products),
@@ -187,74 +209,71 @@ class ProductsService
}
/**
* @throws EntityNotFoundException
* @throws Exception
*/
public function getProduct(
int $productId,
int $languageId,
int $imageWidth,
int $imageHeight,
int $imageFullWidth,
int $imageFullHeight
): array {
$product = $this->queryBuilder->newQuery()
->select([
'products.product_id' => 'product_id',
'product_description.name' => 'product_name',
'product_description.description' => 'product_description',
'products.price' => 'price',
'products.minimum' => 'minimum',
'products.quantity' => 'quantity',
'products.image' => 'product_image',
'products.tax_class_id' => 'tax_class_id',
'manufacturer.name' => 'product_manufacturer',
])
->from(db_table('product'), 'products')
->join(
db_table('product_description') . ' AS product_description',
function (JoinClause $join) use ($languageId) {
$join->on('products.product_id', '=', 'product_description.product_id')
->where('product_description.language_id', '=', $languageId);
}
)
->leftJoin(
db_table('manufacturer') . ' AS manufacturer',
function (JoinClause $join) {
$join->on('products.manufacturer_id', '=', 'manufacturer.manufacturer_id');
}
)
->where('products.product_id', '=', $productId)
->limit(1)
->firstOrNull();
public function getProductById(int $productId): array
{
$this->oc->load->language('product/product');
$this->oc->load->model('catalog/category');
$this->oc->load->model('catalog/manufacturer');
$this->oc->load->model('catalog/product');
$this->oc->load->model('catalog/review');
$this->oc->load->model('tool/image');
if (! $product) {
return [];
$imageThumbWidth = 500;
$imageThumbHeight = 500;
$imageFullWidth = 1000;
$imageFullHeight = 1000;
$configTax = $this->oc->config->get('config_tax');
$product_info = $this->oc->model_catalog_product->getProduct($productId);
if (! $product_info) {
throw new EntityNotFoundException('Product with id ' . $productId . ' not found');
}
$productsImages = $this->queryBuilder->newQuery()
->select([
'products_images.product_id' => 'product_id',
'products_images.image' => 'image',
])
->from(db_table('product_image'), 'products_images')
->orderBy('products_images.sort_order')
->where('products_images.product_id', '=', $productId)
->get();
$data = [];
$data['text_minimum'] = sprintf($this->oc->language->get('text_minimum'), $product_info['minimum']);
$imagePaths = [];
if ($product['product_image']) {
$imagePaths[] = $product['product_image'];
$data['tab_review'] = sprintf($this->oc->language->get('tab_review'), $product_info['reviews']);
$data['product_id'] = $productId;
$data['name'] = $product_info['name'];
$data['manufacturer'] = $product_info['manufacturer'];
$data['model'] = $product_info['model'];
$data['reward'] = $product_info['reward'];
$data['points'] = (int) $product_info['points'];
$data['description'] = html_entity_decode($product_info['description'], ENT_QUOTES, 'UTF-8');
if ($product_info['quantity'] <= 0) {
$data['stock'] = $product_info['stock_status'];
} elseif ($this->oc->config->get('config_stock_display')) {
$data['stock'] = $product_info['quantity'];
} else {
$data['stock'] = $this->oc->language->get('text_instock');
}
foreach ($productsImages as $item) {
$imagePaths[] = $item['image'];
$allImages = [];
if ($product_info['image']) {
$allImages[] = $product_info['image'];
}
$results = $this->oc->model_catalog_product->getProductImages($productId);
foreach ($results as $result) {
$allImages[] = $result['image'];
}
$images = [];
foreach ($imagePaths as $imagePath) {
foreach ($allImages as $imagePath) {
try {
[$width, $height] = $this->ocImageTool->getRealSize($imagePath);
$images[] = [
'thumbnailURL' => $this->ocImageTool->resize($imagePath, $imageWidth, $imageHeight, 'placeholder.png'),
'thumbnailURL' => $this->ocImageTool->resize(
$imagePath,
$imageThumbWidth,
$imageThumbHeight,
'placeholder.png'
),
'largeURL' => $this->ocImageTool->resize(
$imagePath,
$imageFullWidth,
@@ -263,96 +282,136 @@ class ProductsService
),
'width' => $width,
'height' => $height,
'alt' => html_entity_decode($product['product_name'], ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'alt' => html_entity_decode($product_info['name'], ENT_QUOTES | ENT_HTML5, 'UTF-8'),
];
} catch (Exception $e) {
$this->logger->logException($e);
}
}
$price = $this->currency->format(
$data['images'] = $images;
$data['price'] = $this->currency->format(
$this->tax->calculate(
$product['price'],
$product['tax_class_id'],
$this->settings->get('oc_config_tax'),
$product_info['price'],
$product_info['tax_class_id'],
$configTax,
),
$this->settings->get('oc_default_currency'),
$this->oc->session->data['currency']
);
return [
'id' => $product['product_id'],
'name' => html_entity_decode($product['product_name'], ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'description' => html_entity_decode($product['product_description'], ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'manufacturer' => html_entity_decode($product['product_manufacturer'], ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'price' => $price,
'minimum' => $product['minimum'],
'quantity' => $product['quantity'],
'images' => $images,
'options' => $this->loadProductOptions($product),
'attributes' => $this->loadProductAttributes($product['product_id']),
];
}
if (! is_null($product_info['special']) && (float) $product_info['special'] >= 0) {
$data['special'] = $this->currency->format(
$this->tax->calculate(
$product_info['special'],
$product_info['tax_class_id'],
$configTax,
),
$this->oc->session->data['currency']
);
$tax_price = (float) $product_info['special'];
} else {
$data['special'] = false;
$tax_price = (float) $product_info['price'];
}
private function loadProductOptions($product): array
{
$result = [];
$productId = $product['product_id'];
$taxClassId = $product['tax_class_id'];
if ($configTax) {
$data['tax'] = $this->currency->format($tax_price, $this->oc->session->data['currency']);
} else {
$data['tax'] = false;
}
$options = $this->ocModelCatalogProduct->getProductOptions($productId);
$ocConfigTax = $this->settings->get('oc_config_tax');
$ocDefaultCurrency = $this->settings->get('oc_default_currency');
$discounts = $this->oc->model_catalog_product->getProductDiscounts($productId);
foreach ($options as $option) {
$data['discounts'] = [];
foreach ($discounts as $discount) {
$data['discounts'][] = array(
'quantity' => $discount['quantity'],
'price' => $this->currency->format(
$this->tax->calculate(
$discount['price'],
$product_info['tax_class_id'],
$configTax,
),
$this->oc->session->data['currency']
)
);
}
$data['options'] = [];
foreach ($this->oc->model_catalog_product->getProductOptions($productId) as $option) {
$product_option_value_data = [];
foreach ($option['product_option_value'] as $option_value) {
if (! $option_value['subtract'] || ($option_value['quantity'] > 0)) {
if ((float) $option_value['price']) {
$priceWithTax = $this->tax->calculate(
$price = $this->currency->format(
$this->tax->calculate(
$option_value['price'],
$taxClassId,
$ocConfigTax ? 'Р' : false,
);
$product_info['tax_class_id'],
$configTax ? 'P' : false
),
$this->oc->session->data['currency']
);
$price = $this->currency->format($priceWithTax, $ocDefaultCurrency);
} else {
$price = false;
}
$product_option_value_data[] = [
'product_option_value_id' => (int) $option_value['product_option_value_id'],
'option_value_id' => (int) $option_value['option_value_id'],
$product_option_value_data[] = array(
'product_option_value_id' => $option_value['product_option_value_id'],
'option_value_id' => $option_value['option_value_id'],
'name' => $option_value['name'],
'image' => $this->ocImageTool->resize($option_value['image'], 50, 50),
'image' => $this->oc->model_tool_image->resize($option_value['image'], 50, 50),
'price' => $price,
'price_prefix' => $option_value['price_prefix'],
'selected' => false,
];
);
}
}
$result[] = [
'product_option_id' => (int) $option['product_option_id'],
'values' => $product_option_value_data,
'option_id' => (int) $option['option_id'],
$data['options'][] = array(
'product_option_id' => $option['product_option_id'],
'product_option_value' => $product_option_value_data,
'option_id' => $option['option_id'],
'name' => $option['name'],
'type' => $option['type'],
'value' => $option['value'],
'required' => filter_var($option['required'], FILTER_VALIDATE_BOOLEAN),
];
);
}
return $result;
}
if ($product_info['minimum']) {
$data['minimum'] = (int) $product_info['minimum'];
} else {
$data['minimum'] = 1;
}
/**
* @throws Exception
*/
private function loadProductAttributes(int $productId): array
{
$this->oc->load->model('catalog/product');
$data['review_status'] = $this->oc->config->get('config_review_status');
return $this->oc->model_catalog_product->getProductAttributes($productId);
$data['review_guest'] = true;
$data['customer_name'] = 'John Doe';
$data['reviews'] = sprintf($this->oc->language->get('text_reviews'), (int) $product_info['reviews']);
$data['rating'] = (int) $product_info['rating'];
$data['attribute_groups'] = $this->oc->model_catalog_product->getProductAttributes($productId);
$data['tags'] = array();
if ($product_info['tag']) {
$tags = explode(',', $product_info['tag']);
foreach ($tags as $tag) {
$data['tags'][] = array(
'tag' => trim($tag),
'href' => $this->oc->url->link('product/search', 'tag=' . trim($tag))
);
}
}
$data['recurrings'] = $this->oc->model_catalog_product->getProfiles($productId);
$this->oc->model_catalog_product->updateViewed($productId);
return $data;
}
}

View File

@@ -8,7 +8,7 @@ use App\Handlers\SettingsHandler;
use App\Handlers\TelegramHandler;
return [
'products' => [ProductsHandler::class, 'handle'],
'products' => [ProductsHandler::class, 'index'],
'product_show' => [ProductsHandler::class, 'show'],
'storeOrder' => [OrderHandler::class, 'store'],

View File

@@ -0,0 +1,19 @@
<template>
<div class="flex flex-col items-center justify-center text-center py-16">
<span class="text-5xl mb-4">😔</span>
<h2 class="text-xl font-semibold mb-2">Товар не найден</h2>
<p class="text-sm mb-4">К сожалению, запрошенный товар недоступен или был удалён.</p>
<button class="btn btn-primary" @click="goBack">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
Назад
</button>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router';
const router = useRouter();
const goBack = () => router.back();
</script>

View File

@@ -3,7 +3,7 @@
<OptionTemplate :name="model.name" :required="model.required">
<div class="flex flex-wrap gap-2">
<label
v-for="value in model.values"
v-for="value in model.product_option_value"
class="group relative flex items-center justify-center btn btn-soft btn-secondary btn-sm"
:class="value.selected ? 'btn-active' : ''"
>
@@ -31,13 +31,13 @@ const model = defineModel();
const emit = defineEmits(['update:modelValue']);
function select(toggledValue) {
model.value.values.forEach(value => {
model.value.product_option_value.forEach(value => {
if (value === toggledValue) {
value.selected = !value.selected;
}
});
model.value.value = model.value.values.filter(item => item.selected === true);
model.value.value = model.value.product_option_value.filter(item => item.selected === true);
emit('update:modelValue', model.value);
}

View File

@@ -2,7 +2,7 @@
<OptionTemplate :name="model.name" :required="model.required">
<div class="flex flex-wrap gap-2">
<label
v-for="value in model.values"
v-for="value in model.product_option_value"
class="group relative flex items-center justify-center btn btn-soft btn-secondary btn-sm"
:class="value.selected ? 'btn-active' : ''"
>
@@ -31,7 +31,7 @@ const model = defineModel();
const emit = defineEmits(['update:modelValue']);
function select(selectedValue) {
model.value.values.forEach(value => {
model.value.product_option_value.forEach(value => {
value.selected = (value === selectedValue);
});

View File

@@ -7,7 +7,7 @@
>
<option value="" disabled selected>Выберите значение</option>
<option
v-for="value in model.values"
v-for="value in model.product_option_value"
:key="value.product_option_value_id"
:value="value.product_option_value_id"
:selected="value.selected"
@@ -27,11 +27,11 @@ const emit = defineEmits(['update:modelValue']);
function onChange(event) {
const selectedId = Number(event.target.value);
model.value.values.forEach(value => {
model.value.product_option_value.forEach(value => {
value.selected = (value.product_option_value_id === selectedId);
});
model.value.value = model.value.values.find(value => value.product_option_value_id === selectedId);
model.value.value = model.value.product_option_value.find(value => value.product_option_value_id === selectedId);
emit('update:modelValue', model.value);
}

View File

@@ -15,7 +15,13 @@
>
<ProductImageSwiper :images="product.images"/>
<h3 class="product-title mt-4 text-sm">{{ product.name }}</h3>
<p class="mt-1 text-lg font-medium">{{ product.price }}</p>
<div v-if="product.special" class="mt-1">
<p class="text-xs line-through mr-2">{{ product.price }}</p>
<p class="text-lg font-medium">{{ product.special }}</p>
</div>
<p v-else class="mt-1 text-lg font-medium">{{ product.price }}</p>
</RouterLink>
<div ref="bottom" style="height: 1px;"></div>
</div>

View File

@@ -22,21 +22,33 @@
/>
<!-- Product info -->
<div
class="mx-auto max-w-2xl px-4 pt-3 pb-24 sm:px-6 rounded-t-lg">
<div class="mx-auto max-w-2xl px-4 pt-3 pb-32 sm:px-6 rounded-t-lg">
<div class="lg:col-span-2 lg:border-r lg:pr-8">
<h1 class="text-2xl font-bold tracking-tight sm:text-3xl">{{ product.name }}</h1>
<h1 class="font-bold tracking-tight text-3xl">{{ product.name }}</h1>
</div>
<div>
<h3 class="text-sm font-medium">{{ product.manufacturer }}</h3>
</div>
<div class="mt-4 lg:row-span-3 lg:mt-0">
<p class="text-3xl tracking-tight">{{ product.price }}</p>
<div v-if="product.special" class="flex items-center">
<p class="text-2xl tracking-tight mr-3">{{ product.special }}</p>
<p class="text-base-400 line-through">{{ product.price }}</p>
</div>
<p v-else class="text-3xl tracking-tight">{{ product.price }}</p>
<p v-if="product.tax" class="text-sm">Без НДС: {{ product.tax }}</p>
<p v-if="product.points && product.points > 0" class="text-sm">Бонусные баллы: {{ product.points }}</p>
<p v-for="discount in product.discounts" class="text-sm">
{{ discount.quantity }} или больше {{ discount.price }}
</p>
<p v-if="false" class="text-xs">Кол-во на складе: {{ product.quantity }} шт.</p>
<p v-if="product.minimum && product.minimum > 1">Минимальное кола для заказа: {{ product.minimum }}</p>
<p v-if="product.minimum && product.minimum > 1">Минимальное коло для заказа: {{ product.minimum }}</p>
</div>
<div class="badge badge-primary">{{ product.stock }}</div>
<div v-if="product.options && product.options.length" class="mt-4">
<ProductOptions v-model="product.options"/>
</div>
@@ -51,14 +63,14 @@
</div>
</div>
<div v-if="product.attributes && product.attributes.length > 0" class="mt-3">
<div v-if="product.attribute_groups && product.attribute_groups.length > 0" class="mt-3">
<h3 class="font-bold mb-2">Характеристики</h3>
<div class="space-y-6">
<div class="overflow-x-auto">
<table class="table table-xs">
<tbody>
<template v-for="attrGroup in product.attributes" :key="attrGroup.attribute_group_id">
<template v-for="attrGroup in product.attribute_groups" :key="attrGroup.attribute_group_id">
<tr class="bg-base-200 font-semibold">
<td colspan="2">{{ attrGroup.name }}</td>
</tr>
@@ -76,7 +88,7 @@
</div>
</div>
<div v-if="product.id" class="fixed px-4 pb-10 pt-4 bottom-0 left-0 w-full bg-base-200 z-50 flex flex-col gap-2 border-t-1 border-t-base-300">
<div v-if="product.product_id" class="fixed px-4 pb-10 pt-4 bottom-0 left-0 w-full bg-base-200 z-50 flex flex-col gap-2 border-t-1 border-t-base-300">
<div class="text-error">
{{ error }}
</div>
@@ -107,6 +119,8 @@
</div>
</div>
<ProductNotFound v-else/>
<FullScreenImageViewer
v-if="isFullScreen"
:images="product.images"
@@ -127,6 +141,7 @@ import {SUPPORTED_OPTION_TYPES} from "@/constants/options.js";
import {apiFetch} from "@/utils/ftch.js";
import FullScreenImageViewer from "@/components/FullScreenImageViewer.vue";
import LoadingFullScreen from "@/components/LoadingFullScreen.vue";
import ProductNotFound from "@/components/ProductNotFound.vue";
const route = useRoute();
const productId = computed(() => route.params.id);