шаг 4 завершен

This commit is contained in:
Alisa
2026-06-04 01:30:45 +03:00
parent 62ce8c4d01
commit 238831ad3a
26 changed files with 1703 additions and 70 deletions
@@ -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,
]);
}
}
+60
View File
@@ -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),
]);
}
}
+69
View File
@@ -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);
}
}
+74
View File
@@ -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();
}
}
+106 -3
View File
@@ -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;
}