425 lines
15 KiB
PHP
425 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Repositories;
|
|
|
|
use App\Services\PriceTierService;
|
|
|
|
final class ProductRepository extends BaseRepository
|
|
{
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function popularPreview(int $limit = 10): array
|
|
{
|
|
$limit = max(1, min($limit, 30));
|
|
$sql = $this->previewSelectSql(
|
|
'ORDER BY p.sales_count DESC, RAND()',
|
|
$limit
|
|
);
|
|
|
|
$statement = $this->pdo()->query($sql);
|
|
|
|
return $this->preparePreviewProducts($statement->fetchAll());
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function catalogPreview(int $limit = 24): array
|
|
{
|
|
$limit = max(1, min($limit, 60));
|
|
$sql = $this->previewSelectSql(
|
|
'ORDER BY p.sort_order, p.name',
|
|
$limit
|
|
);
|
|
|
|
$statement = $this->pdo()->query($sql);
|
|
|
|
return $this->preparePreviewProducts($statement->fetchAll());
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function forCategory(int $categoryId, int $limit = 48): array
|
|
{
|
|
$limit = max(1, min($limit, 96));
|
|
$categoryIds = (new CategoryRepository())->descendantIds($categoryId);
|
|
$placeholders = implode(',', array_fill(0, count($categoryIds), '?'));
|
|
$sql = $this->previewSelectSql(
|
|
'AND (
|
|
p.category_id IN (' . $placeholders . ')
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM product_category_links pcl
|
|
WHERE pcl.product_id = p.id
|
|
AND pcl.category_id IN (' . $placeholders . ')
|
|
)
|
|
)
|
|
ORDER BY p.sort_order, p.name',
|
|
$limit
|
|
);
|
|
|
|
$statement = $this->pdo()->prepare($sql);
|
|
$statement->execute(array_merge($categoryIds, $categoryIds));
|
|
|
|
return $this->preparePreviewProducts($statement->fetchAll());
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public function findBySlug(string $slug): ?array
|
|
{
|
|
$statement = $this->pdo()->prepare(
|
|
'SELECT p.*, c.name AS category_name, c.slug AS category_slug
|
|
FROM products p
|
|
LEFT JOIN categories c ON c.id = p.category_id
|
|
WHERE p.slug = :slug
|
|
AND p.is_published = 1
|
|
AND p.is_available = 1
|
|
LIMIT 1'
|
|
);
|
|
$statement->execute(['slug' => $slug]);
|
|
$product = $statement->fetch();
|
|
|
|
return $product ?: null;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function variantsForProduct(int $productId): array
|
|
{
|
|
$statement = $this->pdo()->prepare(
|
|
'SELECT id, product_id, name, unit, package_quantity, step_quantity, price_per_unit, package_price, old_package_price, is_default
|
|
FROM product_variants
|
|
WHERE product_id = :product_id
|
|
AND is_published = 1
|
|
AND is_available = 1
|
|
ORDER BY is_default DESC, sort_order, id'
|
|
);
|
|
$statement->execute(['product_id' => $productId]);
|
|
|
|
return $statement->fetchAll();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function imagesForProduct(int $productId): array
|
|
{
|
|
$statement = $this->pdo()->prepare(
|
|
'SELECT id, path, alt, sort_order
|
|
FROM product_images
|
|
WHERE product_id = :product_id
|
|
ORDER BY sort_order, id'
|
|
);
|
|
$statement->execute(['product_id' => $productId]);
|
|
|
|
return $statement->fetchAll();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function adminLatest(int $limit = 30): array
|
|
{
|
|
$limit = max(1, min($limit, 100));
|
|
$statement = $this->pdo()->query(
|
|
'SELECT p.id, p.name, p.slug, p.is_published, c.name AS category_name, p.created_at
|
|
FROM products p
|
|
LEFT JOIN categories c ON c.id = p.category_id
|
|
ORDER BY p.created_at DESC, p.id DESC
|
|
LIMIT ' . $limit
|
|
);
|
|
|
|
return $statement->fetchAll();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public function createWithVariant(array $data): int
|
|
{
|
|
if (($data['name'] ?? '') === '' || ($data['slug'] ?? '') === '') {
|
|
throw new \InvalidArgumentException('Название и slug обязательны.');
|
|
}
|
|
|
|
$pdo = $this->pdo();
|
|
$pdo->beginTransaction();
|
|
|
|
try {
|
|
$statement = $pdo->prepare(
|
|
'INSERT INTO products
|
|
(category_id, name, slug, short_description, seo_title, seo_description, seo_keywords, base_unit, is_published, is_available, published_at)
|
|
VALUES
|
|
(:category_id, :name, :slug, :short_description, :seo_title, :seo_description, :seo_keywords, :base_unit, :is_published, 1, :published_at)'
|
|
);
|
|
$statement->execute([
|
|
'category_id' => $data['category_id'],
|
|
'name' => $data['name'],
|
|
'slug' => $data['slug'],
|
|
'short_description' => $data['short_description'] ?: null,
|
|
'seo_title' => $data['seo_title'] ?: null,
|
|
'seo_description' => $data['seo_description'] ?: null,
|
|
'seo_keywords' => $data['seo_keywords'] ?: null,
|
|
'base_unit' => $data['base_unit'] ?: 'kg',
|
|
'is_published' => $data['is_published'] ?? 0,
|
|
'published_at' => !empty($data['is_published']) ? date('Y-m-d H:i:s') : null,
|
|
]);
|
|
|
|
$productId = (int) $pdo->lastInsertId();
|
|
|
|
if (($data['variant_name'] ?? '') !== '' && (float) ($data['package_price'] ?? 0) > 0) {
|
|
$variantStatement = $pdo->prepare(
|
|
'INSERT INTO product_variants
|
|
(product_id, name, unit, package_quantity, step_quantity, price_per_unit, package_price, old_package_price, discount_type, discount_percent, is_default, is_published, is_available)
|
|
VALUES
|
|
(:product_id, :name, :unit, :package_quantity, :step_quantity, :price_per_unit, :package_price, :old_package_price, :discount_type, :discount_percent, 1, 1, 1)'
|
|
);
|
|
$variantStatement->execute([
|
|
'product_id' => $productId,
|
|
'name' => $data['variant_name'],
|
|
'unit' => $data['base_unit'] ?: 'kg',
|
|
'package_quantity' => $data['package_quantity'] ?: 1,
|
|
'step_quantity' => $data['step_quantity'] ?: ($data['package_quantity'] ?: 1),
|
|
'price_per_unit' => $data['price_per_unit'],
|
|
'package_price' => $data['package_price'],
|
|
'old_package_price' => $data['old_package_price'] ?? null,
|
|
'discount_type' => $data['discount_type'] ?? 'none',
|
|
'discount_percent' => $data['discount_percent'] ?? null,
|
|
]);
|
|
}
|
|
|
|
$pdo->commit();
|
|
|
|
return $productId;
|
|
} catch (\Throwable $exception) {
|
|
$pdo->rollBack();
|
|
throw $exception;
|
|
}
|
|
}
|
|
|
|
private function previewSelectSql(string $tailSql, int $limit): string
|
|
{
|
|
return 'SELECT
|
|
p.id,
|
|
p.name,
|
|
p.slug,
|
|
p.legacy_path,
|
|
p.h1,
|
|
p.seo_title,
|
|
p.seo_description,
|
|
p.seo_keywords,
|
|
c.name AS category_name,
|
|
c.slug AS category_slug,
|
|
p.main_image_path,
|
|
p.base_unit,
|
|
p.package_display_mode,
|
|
p.sales_count,
|
|
v.id AS variant_id,
|
|
v.name AS variant_name,
|
|
v.unit,
|
|
v.package_quantity,
|
|
v.price_per_unit,
|
|
v.package_price,
|
|
v.old_package_price,
|
|
v.discount_type,
|
|
v.discount_percent
|
|
FROM products p
|
|
LEFT JOIN categories c ON c.id = p.category_id
|
|
LEFT JOIN product_variants v
|
|
ON v.id = (
|
|
SELECT pv.id
|
|
FROM product_variants pv
|
|
WHERE pv.product_id = p.id
|
|
AND pv.is_published = 1
|
|
AND pv.is_available = 1
|
|
ORDER BY pv.is_default DESC, pv.sort_order, pv.id
|
|
LIMIT 1
|
|
)
|
|
WHERE p.is_published = 1
|
|
AND p.is_available = 1
|
|
' . $tailSql . '
|
|
LIMIT ' . $limit;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $products
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function preparePreviewProducts(array $products): array
|
|
{
|
|
return $this->attachPreviewVariants($this->attachDisplayCategories($this->attachPriceTiers($products)));
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $products
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function attachPreviewVariants(array $products): array
|
|
{
|
|
if ($products === []) {
|
|
return [];
|
|
}
|
|
|
|
$productIds = array_map(static fn (array $product): int => (int) $product['id'], $products);
|
|
$placeholders = implode(',', array_fill(0, count($productIds), '?'));
|
|
$statement = $this->pdo()->prepare(
|
|
'SELECT id, product_id, name, unit, package_quantity, step_quantity, price_per_unit, package_price, old_package_price, is_default, sort_order
|
|
FROM product_variants
|
|
WHERE product_id IN (' . $placeholders . ')
|
|
AND is_published = 1
|
|
AND is_available = 1
|
|
ORDER BY product_id, is_default DESC, sort_order, id'
|
|
);
|
|
$statement->execute($productIds);
|
|
|
|
$variantsByProduct = [];
|
|
foreach ($statement->fetchAll() as $variant) {
|
|
$productId = (int) $variant['product_id'];
|
|
if (count($variantsByProduct[$productId] ?? []) >= 6) {
|
|
continue;
|
|
}
|
|
$variantsByProduct[$productId][] = $variant;
|
|
}
|
|
|
|
foreach ($products as &$product) {
|
|
$product['preview_variants'] = $variantsByProduct[(int) $product['id']] ?? [];
|
|
}
|
|
|
|
unset($product);
|
|
|
|
return $products;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $products
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function attachDisplayCategories(array $products): array
|
|
{
|
|
if ($products === []) {
|
|
return [];
|
|
}
|
|
|
|
$productIds = array_map(static fn (array $product): int => (int) $product['id'], $products);
|
|
$placeholders = implode(',', array_fill(0, count($productIds), '?'));
|
|
$statement = $this->pdo()->prepare(
|
|
'SELECT pcl.product_id, c.id, c.parent_id, c.name, c.sort_order
|
|
FROM product_category_links pcl
|
|
INNER JOIN categories c ON c.id = pcl.category_id
|
|
WHERE pcl.product_id IN (' . $placeholders . ')
|
|
AND c.is_active = 1
|
|
ORDER BY pcl.product_id, c.sort_order, c.name'
|
|
);
|
|
$statement->execute($productIds);
|
|
|
|
$categoriesByProduct = [];
|
|
foreach ($statement->fetchAll() as $row) {
|
|
$categoriesByProduct[(int) $row['product_id']][(int) $row['id']] = [
|
|
'id' => (int) $row['id'],
|
|
'parent_id' => $row['parent_id'] === null ? null : (int) $row['parent_id'],
|
|
'name' => (string) $row['name'],
|
|
'sort_order' => (int) $row['sort_order'],
|
|
];
|
|
}
|
|
|
|
$categoryParents = $this->categoryParentMap();
|
|
|
|
foreach ($products as &$product) {
|
|
$linkedCategories = $categoriesByProduct[(int) $product['id']] ?? [];
|
|
|
|
if ($linkedCategories === [] && !empty($product['category_name'])) {
|
|
$product['category_display_name'] = $product['category_name'];
|
|
continue;
|
|
}
|
|
|
|
$leafCategories = [];
|
|
foreach ($linkedCategories as $categoryId => $category) {
|
|
if ($this->hasLinkedDescendant($categoryId, array_keys($linkedCategories), $categoryParents)) {
|
|
continue;
|
|
}
|
|
$leafCategories[] = $category;
|
|
}
|
|
|
|
usort($leafCategories, static function (array $left, array $right): int {
|
|
return [$left['sort_order'], $left['name']] <=> [$right['sort_order'], $right['name']];
|
|
});
|
|
|
|
$names = array_values(array_unique(array_map(static fn (array $category): string => $category['name'], $leafCategories)));
|
|
$product['category_display_name'] = $names === []
|
|
? ($product['category_name'] ?? 'Каталог')
|
|
: implode(' / ', $names);
|
|
}
|
|
|
|
unset($product);
|
|
|
|
return $products;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, int|null>
|
|
*/
|
|
private function categoryParentMap(): array
|
|
{
|
|
$statement = $this->pdo()->query('SELECT id, parent_id FROM categories WHERE is_active = 1');
|
|
$parents = [];
|
|
|
|
foreach ($statement->fetchAll() as $category) {
|
|
$parents[(int) $category['id']] = $category['parent_id'] === null ? null : (int) $category['parent_id'];
|
|
}
|
|
|
|
return $parents;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, int> $linkedCategoryIds
|
|
* @param array<int, int|null> $categoryParents
|
|
*/
|
|
private function hasLinkedDescendant(int $categoryId, array $linkedCategoryIds, array $categoryParents): bool
|
|
{
|
|
$linkedLookup = array_flip($linkedCategoryIds);
|
|
|
|
foreach ($linkedCategoryIds as $linkedCategoryId) {
|
|
if ($linkedCategoryId === $categoryId) {
|
|
continue;
|
|
}
|
|
|
|
$parentId = $categoryParents[$linkedCategoryId] ?? null;
|
|
while ($parentId !== null) {
|
|
if ($parentId === $categoryId && isset($linkedLookup[$linkedCategoryId])) {
|
|
return true;
|
|
}
|
|
|
|
$parentId = $categoryParents[$parentId] ?? null;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $products
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function attachPriceTiers(array $products): array
|
|
{
|
|
$priceTierService = new PriceTierService();
|
|
|
|
foreach ($products as &$product) {
|
|
$basePrice = $product['price_per_unit'] ?? $product['package_price'] ?? null;
|
|
$product['price_tiers'] = $basePrice === null
|
|
? []
|
|
: $priceTierService->forAmount((float) $basePrice);
|
|
}
|
|
|
|
unset($product);
|
|
|
|
return $products;
|
|
}
|
|
}
|