шаг 4 завершен
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use App\Repositories\CategoryRepository;
|
||||
|
||||
final class CategoryAdminController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$repository = new CategoryRepository();
|
||||
$message = null;
|
||||
$error = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$repository->create([
|
||||
'parent_id' => $_POST['parent_id'] !== '' ? (int) $_POST['parent_id'] : null,
|
||||
'name' => trim((string) $_POST['name']),
|
||||
'slug' => trim((string) $_POST['slug']),
|
||||
'h1' => trim((string) ($_POST['h1'] ?? '')),
|
||||
'seo_title' => trim((string) ($_POST['seo_title'] ?? '')),
|
||||
'seo_description' => trim((string) ($_POST['seo_description'] ?? '')),
|
||||
'seo_keywords' => trim((string) ($_POST['seo_keywords'] ?? '')),
|
||||
'sort_order' => (int) ($_POST['sort_order'] ?? 100),
|
||||
'is_active' => isset($_POST['is_active']) ? 1 : 0,
|
||||
]);
|
||||
$message = 'Категория добавлена.';
|
||||
} catch (\Throwable $exception) {
|
||||
$error = app_env('APP_DEBUG', 'false') === 'true' ? $exception->getMessage() : 'Не удалось добавить категорию.';
|
||||
}
|
||||
}
|
||||
|
||||
view('admin/categories', [
|
||||
'title' => 'Категории - админка Рыбсток',
|
||||
'breadcrumbs' => [
|
||||
['title' => 'Главная', 'url' => '/'],
|
||||
['title' => 'Админка', 'url' => '/admin/'],
|
||||
['title' => 'Категории'],
|
||||
],
|
||||
'categories' => $repository->tree(),
|
||||
'flatCategories' => $repository->flatActive(),
|
||||
'message' => $message,
|
||||
'error' => $error,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use App\Repositories\CategoryRepository;
|
||||
use App\Repositories\ProductRepository;
|
||||
|
||||
final class ProductAdminController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$categoryRepository = new CategoryRepository();
|
||||
$productRepository = new ProductRepository();
|
||||
$message = null;
|
||||
$error = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$oldPackagePrice = $_POST['old_package_price'] !== '' ? (float) $_POST['old_package_price'] : null;
|
||||
$discountPercent = $_POST['discount_percent'] !== '' ? (float) $_POST['discount_percent'] : null;
|
||||
$packagePrice = (float) ($_POST['package_price'] ?? 0);
|
||||
$discountType = 'none';
|
||||
|
||||
if ($oldPackagePrice !== null && $discountPercent !== null && $discountPercent > 0) {
|
||||
$packagePrice = round($oldPackagePrice * (1 - ($discountPercent / 100)), 2);
|
||||
$discountType = 'percent';
|
||||
} elseif ($oldPackagePrice !== null) {
|
||||
$discountType = 'manual_old_price';
|
||||
}
|
||||
|
||||
$productRepository->createWithVariant([
|
||||
'category_id' => $_POST['category_id'] !== '' ? (int) $_POST['category_id'] : null,
|
||||
'name' => trim((string) $_POST['name']),
|
||||
'slug' => trim((string) $_POST['slug']),
|
||||
'short_description' => trim((string) ($_POST['short_description'] ?? '')),
|
||||
'seo_title' => trim((string) ($_POST['seo_title'] ?? '')),
|
||||
'seo_description' => trim((string) ($_POST['seo_description'] ?? '')),
|
||||
'seo_keywords' => trim((string) ($_POST['seo_keywords'] ?? '')),
|
||||
'base_unit' => (string) ($_POST['base_unit'] ?? 'kg'),
|
||||
'is_published' => isset($_POST['is_published']) ? 1 : 0,
|
||||
'variant_name' => trim((string) ($_POST['variant_name'] ?? '')),
|
||||
'package_quantity' => (float) ($_POST['package_quantity'] ?? 1),
|
||||
'step_quantity' => (float) ($_POST['step_quantity'] ?? 1),
|
||||
'price_per_unit' => $_POST['price_per_unit'] !== '' ? (float) $_POST['price_per_unit'] : null,
|
||||
'package_price' => $packagePrice,
|
||||
'old_package_price' => $oldPackagePrice,
|
||||
'discount_type' => $discountType,
|
||||
'discount_percent' => $discountPercent,
|
||||
]);
|
||||
$message = 'Товар добавлен.';
|
||||
} catch (\Throwable $exception) {
|
||||
$error = app_env('APP_DEBUG', 'false') === 'true' ? $exception->getMessage() : 'Не удалось добавить товар.';
|
||||
}
|
||||
}
|
||||
|
||||
view('admin/products', [
|
||||
'title' => 'Товары - админка Рыбсток',
|
||||
'breadcrumbs' => [
|
||||
['title' => 'Главная', 'url' => '/'],
|
||||
['title' => 'Админка', 'url' => '/admin/'],
|
||||
['title' => 'Товары'],
|
||||
],
|
||||
'categories' => $categoryRepository->flatActive(),
|
||||
'products' => $productRepository->adminLatest(30),
|
||||
'message' => $message,
|
||||
'error' => $error,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Repositories\CategoryRepository;
|
||||
use App\Repositories\ProductRepository;
|
||||
|
||||
final class CatalogController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$categoryRepository = new CategoryRepository();
|
||||
$productRepository = new ProductRepository();
|
||||
|
||||
view('catalog/index', [
|
||||
'title' => 'Каталог продукции - Рыбсток',
|
||||
'metaDescription' => 'Полный каталог Рыбсток: рыба, морепродукты, икра, мясо, сыры, полуфабрикаты и продукты с доставкой.',
|
||||
'metaKeywords' => 'каталог Рыбсток, рыба, морепродукты, мясо, сыры, полуфабрикаты',
|
||||
'breadcrumbs' => [
|
||||
['title' => 'Главная', 'url' => '/'],
|
||||
['title' => 'Каталог'],
|
||||
],
|
||||
'categories' => $categoryRepository->tree(),
|
||||
'currentCategory' => null,
|
||||
'products' => $productRepository->catalogPreview(48),
|
||||
]);
|
||||
}
|
||||
|
||||
public function category(string $slug): void
|
||||
{
|
||||
$categoryRepository = new CategoryRepository();
|
||||
$productRepository = new ProductRepository();
|
||||
$category = $categoryRepository->findBySlug($slug);
|
||||
|
||||
if ($category === null) {
|
||||
http_response_code(404);
|
||||
view('pages/404', [
|
||||
'title' => 'Категория не найдена - Рыбсток',
|
||||
'breadcrumbs' => [
|
||||
['title' => 'Главная', 'url' => '/'],
|
||||
['title' => 'Каталог', 'url' => '/catalog'],
|
||||
['title' => 'Категория не найдена'],
|
||||
],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
view('catalog/index', [
|
||||
'title' => ($category['seo_title'] ?: $category['name']) . ' - Рыбсток',
|
||||
'metaDescription' => $category['seo_description'] ?: '',
|
||||
'metaKeywords' => $category['seo_keywords'] ?: '',
|
||||
'breadcrumbs' => $categoryRepository->breadcrumbs((int) $category['id']),
|
||||
'categories' => $categoryRepository->tree(),
|
||||
'currentCategory' => $category,
|
||||
'products' => $productRepository->forCategory((int) $category['id'], 48),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class Router
|
||||
{
|
||||
/**
|
||||
* @var array<int, array{method: string, pattern: string, handler: callable}>
|
||||
*/
|
||||
private array $routes = [];
|
||||
|
||||
public function get(string $pattern, callable $handler): void
|
||||
{
|
||||
$this->routes[] = ['method' => 'GET', 'pattern' => $pattern, 'handler' => $handler];
|
||||
}
|
||||
|
||||
public function dispatch(string $method, string $uri): void
|
||||
{
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
|
||||
$path = rtrim($path, '/') ?: '/';
|
||||
|
||||
foreach ($this->routes as $route) {
|
||||
if ($route['method'] !== $method) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$params = $this->match($route['pattern'], $path);
|
||||
|
||||
if ($params === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
($route['handler'])(...$params);
|
||||
return;
|
||||
}
|
||||
|
||||
http_response_code(404);
|
||||
view('pages/404', [
|
||||
'title' => 'Страница не найдена - Рыбсток',
|
||||
'breadcrumbs' => [
|
||||
['title' => 'Главная', 'url' => '/'],
|
||||
['title' => 'Страница не найдена'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>|null
|
||||
*/
|
||||
private function match(string $pattern, string $path): ?array
|
||||
{
|
||||
$regex = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '([^/]+)', $pattern);
|
||||
$regex = '#^' . rtrim((string) $regex, '/') . '$#u';
|
||||
|
||||
if ($pattern === '/') {
|
||||
$regex = '#^/$#u';
|
||||
}
|
||||
|
||||
if (!preg_match($regex, $path, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
array_shift($matches);
|
||||
|
||||
return array_map('urldecode', $matches);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,21 @@ final class CategoryRepository extends BaseRepository
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function flatActive(): array
|
||||
{
|
||||
$statement = $this->pdo()->query(
|
||||
'SELECT id, parent_id, name, slug, sort_order
|
||||
FROM categories
|
||||
WHERE is_active = 1
|
||||
ORDER BY parent_id IS NOT NULL, sort_order, name'
|
||||
);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
@@ -107,4 +122,63 @@ final class CategoryRepository extends BaseRepository
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function descendantIds(int $categoryId): array
|
||||
{
|
||||
$items = $this->flatActive();
|
||||
$childrenByParent = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$parentId = $item['parent_id'] === null ? 0 : (int) $item['parent_id'];
|
||||
$childrenByParent[$parentId][] = (int) $item['id'];
|
||||
}
|
||||
|
||||
$ids = [$categoryId];
|
||||
$queue = [$categoryId];
|
||||
|
||||
while ($queue !== []) {
|
||||
$currentId = array_shift($queue);
|
||||
|
||||
foreach ($childrenByParent[$currentId] ?? [] as $childId) {
|
||||
$ids[] = $childId;
|
||||
$queue[] = $childId;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function create(array $data): int
|
||||
{
|
||||
if (($data['name'] ?? '') === '' || ($data['slug'] ?? '') === '') {
|
||||
throw new \InvalidArgumentException('Название и slug обязательны.');
|
||||
}
|
||||
|
||||
$statement = $this->pdo()->prepare(
|
||||
'INSERT INTO categories
|
||||
(parent_id, name, slug, h1, seo_title, seo_description, seo_keywords, sort_order, is_active)
|
||||
VALUES
|
||||
(:parent_id, :name, :slug, :h1, :seo_title, :seo_description, :seo_keywords, :sort_order, :is_active)'
|
||||
);
|
||||
|
||||
$statement->execute([
|
||||
'parent_id' => $data['parent_id'],
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'],
|
||||
'h1' => $data['h1'] ?: null,
|
||||
'seo_title' => $data['seo_title'] ?: null,
|
||||
'seo_description' => $data['seo_description'] ?: null,
|
||||
'seo_keywords' => $data['seo_keywords'] ?: null,
|
||||
'sort_order' => $data['sort_order'] ?? 100,
|
||||
'is_active' => $data['is_active'] ?? 1,
|
||||
]);
|
||||
|
||||
return (int) $this->pdo()->lastInsertId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,107 @@ final class ProductRepository extends BaseRepository
|
||||
return $this->attachPriceTiers($statement->fetchAll());
|
||||
}
|
||||
|
||||
private function previewSelectSql(string $orderBy, int $limit): string
|
||||
/**
|
||||
* @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 . ') ORDER BY p.sort_order, p.name',
|
||||
$limit
|
||||
);
|
||||
|
||||
$statement = $this->pdo()->prepare($sql);
|
||||
$statement->execute($categoryIds);
|
||||
|
||||
return $this->attachPriceTiers($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,
|
||||
@@ -62,7 +162,10 @@ final class ProductRepository extends BaseRepository
|
||||
v.unit,
|
||||
v.package_quantity,
|
||||
v.price_per_unit,
|
||||
v.package_price
|
||||
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
|
||||
@@ -77,7 +180,7 @@ final class ProductRepository extends BaseRepository
|
||||
)
|
||||
WHERE p.is_published = 1
|
||||
AND p.is_available = 1
|
||||
' . $orderBy . '
|
||||
' . $tailSql . '
|
||||
LIMIT ' . $limit;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user