шаг 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
+9
View File
@@ -42,4 +42,13 @@
Страница покажет реальный путь исполняемого файла, базу данных, количество таблиц и текущие верхние категории. Страница покажет реальный путь исполняемого файла, базу данных, количество таблиц и текущие верхние категории.
## Основные адреса
- `/` - главная.
- `/catalog` - полный каталог.
- `/catalog/ryba` - пример страницы категории.
- `/admin/` - стартовая админка.
- `/admin/categories.php` - категории.
- `/admin/products.php` - товары.
Если сервер смотрит не в `public/`, нужно настроить виртуальный хост, чтобы публичной была именно эта папка. Если сервер смотрит не в `public/`, нужно настроить виртуальный хост, чтобы публичной была именно эта папка.
+11 -11
View File
@@ -5,16 +5,16 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/bootstrap.php'; require_once dirname(__DIR__) . '/app/bootstrap.php';
$sections = [ $sections = [
'Товары', ['title' => 'Товары', 'url' => '/admin/products.php'],
'Категории', ['title' => 'Категории', 'url' => '/admin/categories.php'],
'Фасовки', ['title' => 'Фасовки', 'url' => '#'],
'Заказы', ['title' => 'Заказы', 'url' => '#'],
'Промокоды', ['title' => 'Промокоды', 'url' => '#'],
'Акции', ['title' => 'Акции', 'url' => '#'],
'Популярные товары', ['title' => 'Популярные товары', 'url' => '#'],
'Отзывы', ['title' => 'Отзывы', 'url' => '#'],
'Настройки', ['title' => 'Настройки', 'url' => '#'],
'Прайс-лист', ['title' => 'Прайс-лист', 'url' => '#'],
]; ];
?> ?>
<!doctype html> <!doctype html>
@@ -34,7 +34,7 @@ $sections = [
<div class="admin-grid"> <div class="admin-grid">
<?php foreach ($sections as $section): ?> <?php foreach ($sections as $section): ?>
<div class="admin-tile"><?= e($section) ?></div> <a class="admin-tile" href="<?= e($section['url']) ?>"><?= e($section['title']) ?></a>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</section> </section>
@@ -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 $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>> * @return array<int, array<string, mixed>>
*/ */
@@ -107,4 +122,63 @@ final class CategoryRepository extends BaseRepository
return $items; 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()); 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 return 'SELECT
p.id, p.id,
@@ -62,7 +162,10 @@ final class ProductRepository extends BaseRepository
v.unit, v.unit,
v.package_quantity, v.package_quantity,
v.price_per_unit, v.price_per_unit,
v.package_price v.package_price,
v.old_package_price,
v.discount_type,
v.discount_percent
FROM products p FROM products p
LEFT JOIN categories c ON c.id = p.category_id LEFT JOIN categories c ON c.id = p.category_id
LEFT JOIN product_variants v LEFT JOIN product_variants v
@@ -77,7 +180,7 @@ final class ProductRepository extends BaseRepository
) )
WHERE p.is_published = 1 WHERE p.is_published = 1
AND p.is_available = 1 AND p.is_available = 1
' . $orderBy . ' ' . $tailSql . '
LIMIT ' . $limit; LIMIT ' . $limit;
} }
+228
View File
@@ -7,11 +7,23 @@ SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS order_status_history; DROP TABLE IF EXISTS order_status_history;
DROP TABLE IF EXISTS order_items; DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS orders; DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS price_requests;
DROP TABLE IF EXISTS delivery_calculations; DROP TABLE IF EXISTS delivery_calculations;
DROP TABLE IF EXISTS promocode_targets; DROP TABLE IF EXISTS promocode_targets;
DROP TABLE IF EXISTS promocodes; DROP TABLE IF EXISTS promocodes;
DROP TABLE IF EXISTS product_search_terms; DROP TABLE IF EXISTS product_search_terms;
DROP TABLE IF EXISTS product_events;
DROP TABLE IF EXISTS product_relations;
DROP TABLE IF EXISTS recommendation_rules;
DROP TABLE IF EXISTS product_badge_links;
DROP TABLE IF EXISTS product_badges;
DROP TABLE IF EXISTS product_segment_links;
DROP TABLE IF EXISTS product_segments;
DROP TABLE IF EXISTS product_images; DROP TABLE IF EXISTS product_images;
DROP TABLE IF EXISTS variant_attribute_values;
DROP TABLE IF EXISTS product_attribute_groups;
DROP TABLE IF EXISTS attribute_values;
DROP TABLE IF EXISTS attribute_groups;
DROP TABLE IF EXISTS product_variants; DROP TABLE IF EXISTS product_variants;
DROP TABLE IF EXISTS popular_products; DROP TABLE IF EXISTS popular_products;
DROP TABLE IF EXISTS promo_block_items; DROP TABLE IF EXISTS promo_block_items;
@@ -111,12 +123,17 @@ CREATE TABLE product_variants (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
product_id BIGINT UNSIGNED NOT NULL, product_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(190) NOT NULL, name VARCHAR(190) NOT NULL,
code VARCHAR(100) NULL,
article VARCHAR(100) NULL,
unit ENUM('kg', 'g', 'l', 'ml', 'pcs', 'pack') NOT NULL DEFAULT 'kg', unit ENUM('kg', 'g', 'l', 'ml', 'pcs', 'pack') NOT NULL DEFAULT 'kg',
package_quantity DECIMAL(10,3) NOT NULL DEFAULT 1.000, package_quantity DECIMAL(10,3) NOT NULL DEFAULT 1.000,
step_quantity DECIMAL(10,3) NOT NULL DEFAULT 1.000, step_quantity DECIMAL(10,3) NOT NULL DEFAULT 1.000,
price_per_unit DECIMAL(12,2) NULL, price_per_unit DECIMAL(12,2) NULL,
package_price DECIMAL(12,2) NOT NULL, package_price DECIMAL(12,2) NOT NULL,
old_package_price DECIMAL(12,2) NULL, old_package_price DECIMAL(12,2) NULL,
discount_type ENUM('none', 'manual_old_price', 'percent') NOT NULL DEFAULT 'none',
discount_percent DECIMAL(5,2) NULL,
volume_or_pieces DECIMAL(12,4) NULL,
min_order_quantity DECIMAL(10,3) NOT NULL DEFAULT 1.000, min_order_quantity DECIMAL(10,3) NOT NULL DEFAULT 1.000,
stock_quantity DECIMAL(12,3) NULL, stock_quantity DECIMAL(12,3) NULL,
is_default TINYINT(1) NOT NULL DEFAULT 0, is_default TINYINT(1) NOT NULL DEFAULT 0,
@@ -127,12 +144,79 @@ CREATE TABLE product_variants (
updated_at DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
KEY idx_variants_product (product_id), KEY idx_variants_product (product_id),
KEY idx_variants_code (code),
KEY idx_variants_article (article),
KEY idx_variants_public (product_id, is_published, is_available, sort_order), KEY idx_variants_public (product_id, is_published, is_available, sort_order),
CONSTRAINT fk_variants_product CONSTRAINT fk_variants_product
FOREIGN KEY (product_id) REFERENCES products(id) FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE attribute_groups (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(190) NOT NULL,
slug VARCHAR(190) NOT NULL,
display_type ENUM('select', 'radio', 'swatch') NOT NULL DEFAULT 'select',
is_filterable TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 100,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_attribute_groups_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE attribute_values (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
group_id BIGINT UNSIGNED NOT NULL,
value VARCHAR(190) NOT NULL,
slug VARCHAR(190) NOT NULL,
sort_order INT NOT NULL DEFAULT 100,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_attribute_value (group_id, slug),
KEY idx_attribute_values_group (group_id, sort_order),
CONSTRAINT fk_attribute_values_group
FOREIGN KEY (group_id) REFERENCES attribute_groups(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE product_attribute_groups (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
product_id BIGINT UNSIGNED NOT NULL,
group_id BIGINT UNSIGNED NOT NULL,
sort_order INT NOT NULL DEFAULT 100,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_product_attribute_group (product_id, group_id),
CONSTRAINT fk_product_attribute_groups_product
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE,
CONSTRAINT fk_product_attribute_groups_group
FOREIGN KEY (group_id) REFERENCES attribute_groups(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE variant_attribute_values (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
variant_id BIGINT UNSIGNED NOT NULL,
group_id BIGINT UNSIGNED NOT NULL,
value_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_variant_attribute (variant_id, group_id),
KEY idx_variant_attribute_value (value_id),
CONSTRAINT fk_variant_attribute_variant
FOREIGN KEY (variant_id) REFERENCES product_variants(id)
ON DELETE CASCADE,
CONSTRAINT fk_variant_attribute_group
FOREIGN KEY (group_id) REFERENCES attribute_groups(id)
ON DELETE CASCADE,
CONSTRAINT fk_variant_attribute_value
FOREIGN KEY (value_id) REFERENCES attribute_values(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE product_images ( CREATE TABLE product_images (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
product_id BIGINT UNSIGNED NOT NULL, product_id BIGINT UNSIGNED NOT NULL,
@@ -161,6 +245,111 @@ CREATE TABLE product_search_terms (
ON DELETE CASCADE ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE product_segments (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(190) NOT NULL,
slug VARCHAR(190) NOT NULL,
description TEXT NULL,
sort_order INT NOT NULL DEFAULT 100,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_product_segments_slug (slug),
KEY idx_product_segments_active (is_active, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE product_segment_links (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
product_id BIGINT UNSIGNED NOT NULL,
segment_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_product_segment_link (product_id, segment_id),
KEY idx_segment_links_segment (segment_id),
CONSTRAINT fk_segment_links_product
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE,
CONSTRAINT fk_segment_links_segment
FOREIGN KEY (segment_id) REFERENCES product_segments(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE product_badges (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(190) NOT NULL,
slug VARCHAR(190) NOT NULL,
color VARCHAR(40) NULL,
sort_order INT NOT NULL DEFAULT 100,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_product_badges_slug (slug),
KEY idx_product_badges_active (is_active, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE product_badge_links (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
product_id BIGINT UNSIGNED NOT NULL,
badge_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_product_badge_link (product_id, badge_id),
KEY idx_badge_links_badge (badge_id),
CONSTRAINT fk_badge_links_product
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE,
CONSTRAINT fk_badge_links_badge
FOREIGN KEY (badge_id) REFERENCES product_badges(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE recommendation_rules (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(190) NOT NULL,
source_type ENUM('home', 'category', 'product', 'cart', 'segment') NOT NULL DEFAULT 'product',
target_type ENUM('similar', 'bought_together', 'upsell', 'scenario', 'manual') NOT NULL DEFAULT 'similar',
segment_id BIGINT UNSIGNED NULL,
category_id BIGINT UNSIGNED NULL,
max_items TINYINT UNSIGNED NOT NULL DEFAULT 12,
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 100,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_recommendation_rules_active (is_active, source_type, target_type, sort_order),
KEY idx_recommendation_rules_segment (segment_id),
KEY idx_recommendation_rules_category (category_id),
CONSTRAINT fk_recommendation_rules_segment
FOREIGN KEY (segment_id) REFERENCES product_segments(id)
ON DELETE SET NULL,
CONSTRAINT fk_recommendation_rules_category
FOREIGN KEY (category_id) REFERENCES categories(id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE product_relations (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
source_product_id BIGINT UNSIGNED NOT NULL,
target_product_id BIGINT UNSIGNED NOT NULL,
relation_type ENUM('bought_together', 'similar', 'upsell', 'scenario', 'manual') NOT NULL DEFAULT 'manual',
weight SMALLINT UNSIGNED NOT NULL DEFAULT 100,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_product_relation (source_product_id, target_product_id, relation_type),
KEY idx_product_relations_source (source_product_id, relation_type, is_active, weight),
KEY idx_product_relations_target (target_product_id),
CONSTRAINT fk_product_relations_source
FOREIGN KEY (source_product_id) REFERENCES products(id)
ON DELETE CASCADE,
CONSTRAINT fk_product_relations_target
FOREIGN KEY (target_product_id) REFERENCES products(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE promocodes ( CREATE TABLE promocodes (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(80) NOT NULL, code VARCHAR(80) NOT NULL,
@@ -218,6 +407,26 @@ CREATE TABLE delivery_calculations (
KEY idx_delivery_type_created (delivery_type, created_at) KEY idx_delivery_type_created (delivery_type, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE price_requests (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
customer_name VARCHAR(120) NULL,
phone VARCHAR(40) NULL,
email VARCHAR(190) NULL,
company_name VARCHAR(190) NULL,
request_text MEDIUMTEXT NULL,
file_path VARCHAR(255) NULL,
status ENUM('new', 'in_progress', 'answered', 'archived') NOT NULL DEFAULT 'new',
admin_comment TEXT NULL,
client_ip VARCHAR(45) NULL,
user_agent VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_price_requests_status (status, created_at),
KEY idx_price_requests_email (email),
KEY idx_price_requests_phone (phone)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE orders ( CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_number VARCHAR(40) NOT NULL, order_number VARCHAR(40) NOT NULL,
@@ -254,6 +463,25 @@ CREATE TABLE orders (
ON DELETE SET NULL ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE product_events (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
event_type ENUM('view', 'add_to_cart', 'order', 'compare', 'recommendation_click') NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
session_id VARCHAR(120) NULL,
order_id BIGINT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_product_events_product (product_id, event_type, created_at),
KEY idx_product_events_session (session_id, created_at),
KEY idx_product_events_order (order_id),
CONSTRAINT fk_product_events_product
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE,
CONSTRAINT fk_product_events_order
FOREIGN KEY (order_id) REFERENCES orders(id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE order_items ( CREATE TABLE order_items (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL, order_id BIGINT UNSIGNED NOT NULL,
+135
View File
@@ -261,6 +261,140 @@ INSERT INTO categories (parent_id, name, slug, sort_order, is_active) VALUES
((SELECT id FROM categories c WHERE c.slug = 'molochnaya-produkciya'), 'Сливки', 'slivki', 20, 1), ((SELECT id FROM categories c WHERE c.slug = 'molochnaya-produkciya'), 'Сливки', 'slivki', 20, 1),
((SELECT id FROM categories c WHERE c.slug = 'molochnaya-produkciya'), 'Сливочное масло', 'slivochnoe-maslo', 30, 1); ((SELECT id FROM categories c WHERE c.slug = 'molochnaya-produkciya'), 'Сливочное масло', 'slivochnoe-maslo', 30, 1);
INSERT INTO attribute_groups (name, slug, display_type, is_filterable, sort_order) VALUES
('Упаковка', 'upakovka', 'select', 1, 10),
('Размер рыбы', 'razmer-ryby', 'select', 1, 20),
('Размер креветки', 'razmer-krevetki', 'select', 1, 30),
('Тип обработки', 'tip-obrabotki', 'select', 1, 40),
('Тип заморозки', 'tip-zamorozki', 'select', 1, 50),
('Происхождение', 'proishozhdenie', 'select', 1, 60),
('Размер / калибр', 'razmer-kalibr', 'select', 1, 70),
('Вид приготовления', 'vid-prigotovleniya', 'select', 1, 80),
('Тип фасовки', 'tip-fasovki', 'select', 1, 90);
INSERT INTO attribute_values (group_id, value, slug, sort_order) VALUES
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '55 кг', '55kg', 10),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '42 кг', '42kg', 20),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '35 кг', '35kg', 30),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '31.5 кг', '31-5kg', 40),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '30 кг', '30kg', 50),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '25 кг', '25kg', 60),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '22 кг', '22kg', 70),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '21 кг', '21kg', 80),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '20 кг', '20kg', 90),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '15 кг', '15kg', 100),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '14 кг', '14kg', 110),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '12 кг', '12kg', 120),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '10 кг', '10kg', 130),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '7 кг', '7kg', 140),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '6 кг', '6kg', 150),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '5 кг', '5kg', 160),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '4 кг', '4kg', 170),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '3 кг', '3kg', 180),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '2 кг', '2kg', 190),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '1 кг', '1kg', 200),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '500 г', '500g', 210),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '400 г', '400g', 220),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '300 г', '300g', 230),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '250 г', '250g', 240),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '200 г', '200g', 250),
((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '100 г', '100g', 260),
((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-ryby'), '100 г', '100g', 10),
((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-ryby'), '200 г', '200g', 20),
((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-ryby'), '300 г', '300g', 30),
((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-ryby'), '400 г', '400g', 40),
((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-ryby'), '500 г', '500g', 50),
((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-ryby'), '1 кг+', '1kg-plus', 60),
((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-krevetki'), '16/20', '16-20', 10),
((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-krevetki'), '21/25', '21-25', 20),
((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-krevetki'), '26/30', '26-30', 30),
((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-krevetki'), '31/40', '31-40', 40),
((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-krevetki'), '40/60', '40-60', 50),
((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-krevetki'), '50/70', '50-70', 60),
((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-obrabotki'), 'Неразделанная', 'nerazdelannaya', 10),
((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-obrabotki'), 'Потрошеная', 'potroshenaya', 20),
((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-obrabotki'), 'Без головы', 'bez-golovy', 30),
((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-obrabotki'), 'С головой', 's-golovoj', 40),
((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-obrabotki'), 'Разделанная с головой', 'razdelannaya-s-golovoj', 50),
((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-obrabotki'), 'Потрошеная без головы', 'potroshenaya-bez-golovy', 60),
((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-obrabotki'), 'Без кожи', 'bez-kozhi', 70),
((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-obrabotki'), 'Филе', 'file', 80),
((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-obrabotki'), 'Стейк', 'stejk', 90),
((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-zamorozki'), 'Штучная заморозка', 'shtuchnaya-zamorozka', 10),
((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-zamorozki'), 'Монолитная заморозка', 'monolitnaya-zamorozka', 20),
((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-zamorozki'), 'Свежемороженые', 'svezhemorozhenye', 30),
((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-zamorozki'), 'Охлажденная', 'ohlazhdennaya', 40),
((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Вьетнам', 'vetnam', 10),
((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Камчатка', 'kamchatka', 20),
((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Япономорская', 'yaponomorskaya', 30),
((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Сахалин', 'sahalin', 40),
((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Каспийская', 'kaspijskaya', 50),
((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Цимлянская', 'cimlyanskaya', 60),
((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Чили', 'chili', 70),
((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Армения', 'armeniya', 80),
((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Средиземноморская', 'sredizemnomorskaya', 90),
((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Севанская', 'sevanskaya', 100),
((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-kalibr'), '1 кг и более', '1kg-i-bolee', 10),
((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-kalibr'), '4 кг и более', '4kg-i-bolee', 20),
((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-kalibr'), '7-8 кг', '7-8kg', 30),
((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-kalibr'), 'L2', 'l2', 40),
((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-kalibr'), 'U4', 'u4', 50),
((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-kalibr'), '16-20', '16-20', 60),
((SELECT id FROM attribute_groups g WHERE g.slug = 'vid-prigotovleniya'), 'Слабосоленая', 'slabosolenaya', 10),
((SELECT id FROM attribute_groups g WHERE g.slug = 'vid-prigotovleniya'), 'Вяленая', 'vyalenaya', 20),
((SELECT id FROM attribute_groups g WHERE g.slug = 'vid-prigotovleniya'), 'Горячего копчения', 'goryachego-kopcheniya', 30),
((SELECT id FROM attribute_groups g WHERE g.slug = 'vid-prigotovleniya'), 'Холодного копчения', 'holodnogo-kopcheniya', 40),
((SELECT id FROM attribute_groups g WHERE g.slug = 'vid-prigotovleniya'), 'В кляре', 'v-klyare', 50),
((SELECT id FROM attribute_groups g WHERE g.slug = 'vid-prigotovleniya'), 'В масле', 'v-masle', 60),
((SELECT id FROM attribute_groups g WHERE g.slug = 'vid-prigotovleniya'), 'С морской капустой', 's-morskoj-kapustoj', 70),
((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-fasovki'), 'Крупная фасовка', 'krupnaya-fasovka', 10),
((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-fasovki'), 'Малая фасовка', 'malaya-fasovka', 20),
((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-fasovki'), 'Порционная фасовка', 'porcionnaya-fasovka', 30),
((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-fasovki'), 'Весовой товар', 'vesovoj-tovar', 40);
INSERT INTO product_segments (name, slug, description, sort_order, is_active) VALUES
('Для дома', 'dlya-doma', 'Небольшие и понятные позиции для обычной домашней покупки.', 10, 1),
('Для семьи', 'dlya-semi', 'Фасовки и продукты, которые удобно покупать на семью.', 20, 1),
('Для совместной покупки', 'dlya-sovmestnoj-pokupki', 'Крупные упаковки, которые удобно делить с родственниками или соседями.', 30, 1),
('Большая фасовка', 'bolshaya-fasovka', 'Короба и упаковки, где покупателю важна выгода за объем.', 40, 1),
('HoReCa', 'horeca', 'Позиции для кафе, ресторанов, магазинов и производств.', 50, 1),
('Премиум', 'premium', 'Икра, деликатесы, премиальная рыба, морепродукты и сыры.', 60, 1),
('К пиву', 'k-pivu', 'Вяленая, копченая рыба, снеки и закусочные позиции.', 70, 1),
('Быстро приготовить', 'bystro-prigotovit', 'Полуфабрикаты и продукты для быстрого ужина.', 80, 1),
('Для суши', 'dlya-sushi-segment', 'Рыба, морепродукты, рис, нори, соусы и сопутствующие товары для суши.', 90, 1),
('Диетическое', 'dieticheskoe', 'Филе, белая рыба, индейка, овощи и легкие продукты.', 100, 1),
('Консервы и банки', 'konservy-i-banki', 'Штучные товары в банках и фиксированной фасовке.', 110, 1),
('Овощи и заморозка', 'ovoschi-i-zamorozka', 'Овощные смеси, ягоды, грибы и замороженные гарниры.', 120, 1),
('Мясо для дома', 'myaso-dlya-doma', 'Мясо и птица в понятных домашних сценариях.', 130, 1);
INSERT INTO product_badges (name, slug, color, sort_order, is_active) VALUES
('Малая фасовка', 'malaya-fasovka', '#3f7f5f', 10, 1),
('Крупная фасовка', 'krupnaya-fasovka', '#8a5a2b', 20, 1),
('Штучная заморозка', 'shtuchnaya-zamorozka', '#2f6f9f', 30, 1),
('Монолитная заморозка', 'monolitnaya-zamorozka', '#5f6f7f', 40, 1),
('Без кожи', 'bez-kozhi', '#5b7c8a', 50, 1),
('Без головы', 'bez-golovy', '#6a7a55', 60, 1),
('С головой', 's-golovoj', '#7a6655', 70, 1),
('Премиум', 'premium', '#9a6a2f', 80, 1),
('В рассоле или масле', 'v-rassole-ili-masle', '#6b6b90', 90, 1),
('Для суши', 'dlya-sushi', '#3b7f7a', 100, 1),
('К пиву', 'k-pivu', '#8c6a2f', 110, 1),
('Для семьи', 'dlya-semi', '#4f7a4f', 120, 1),
('Для ресторана', 'dlya-restorana', '#575f7a', 130, 1),
('Можно разделить с соседями', 'mozhno-razdelit-s-sosedyami', '#7a5f8a', 140, 1),
('Без предоплаты', 'bez-predoplaty', '#3f805f', 150, 1);
INSERT INTO recommendation_rules
(title, source_type, target_type, segment_id, category_id, max_items, is_active, sort_order)
VALUES
('Похожие товары в той же категории', 'product', 'similar', NULL, NULL, 12, 1, 10),
('С этим обычно покупают', 'product', 'bought_together', NULL, NULL, 12, 1, 20),
('Выгоднее крупной фасовкой', 'product', 'upsell', (SELECT id FROM product_segments s WHERE s.slug = 'bolshaya-fasovka'), NULL, 8, 1, 30),
('Подходит для домашней покупки', 'product', 'scenario', (SELECT id FROM product_segments s WHERE s.slug = 'dlya-doma'), NULL, 8, 1, 40),
('Собрать набор для суши', 'cart', 'scenario', (SELECT id FROM product_segments s WHERE s.slug = 'dlya-sushi-segment'), NULL, 12, 1, 50),
('Закуски к копченой и вяленой рыбе', 'product', 'scenario', (SELECT id FROM product_segments s WHERE s.slug = 'k-pivu'), NULL, 12, 1, 60),
('Быстрый ужин и полуфабрикаты', 'home', 'scenario', (SELECT id FROM product_segments s WHERE s.slug = 'bystro-prigotovit'), NULL, 12, 1, 70);
INSERT INTO promocodes INSERT INTO promocodes
(code, title, discount_type, discount_value, min_order_amount, applies_to, is_active, is_featured, show_in_price_tiers, price_tier_label) (code, title, discount_type, discount_value, min_order_amount, applies_to, is_active, is_featured, show_in_price_tiers, price_tier_label)
VALUES VALUES
@@ -271,6 +405,7 @@ VALUES
INSERT INTO site_settings (setting_key, setting_value, value_type, group_name) VALUES INSERT INTO site_settings (setting_key, setting_value, value_type, group_name) VALUES
('site_phone', '+7-977-736-33-63', 'string', 'contacts'), ('site_phone', '+7-977-736-33-63', 'string', 'contacts'),
('admin_email', '', 'string', 'mail'), ('admin_email', '', 'string', 'mail'),
('price_request_email', '', 'string', 'mail'),
('inside_mkad_free_from', '5000', 'number', 'delivery'), ('inside_mkad_free_from', '5000', 'number', 'delivery'),
('inside_mkad_paid_price', '599', 'number', 'delivery'), ('inside_mkad_paid_price', '599', 'number', 'delivery'),
('outside_mkad_base_price', '199', 'number', 'delivery'), ('outside_mkad_base_price', '199', 'number', 'delivery'),
@@ -0,0 +1,97 @@
# Анализ каталога и логика рекомендаций
## Главный вывод
Старый каталог нельзя переносить как одну длинную ленту товаров. В списке одновременно есть розничные позиции 100-500 г, домашние фасовки 1-5 кг, семейные закупки 5-10 кг, крупные короба 20-40 кг, премиальные деликатесы, бакалея, мясо, молочная продукция и HoReCa-позиции.
Если покупатель смотрит форель 1 кг, ему нельзя случайно показывать 20 кг говяжьего оковалка. Рекомендации должны учитывать сценарий покупки, размер фасовки, категорию и ценовой уровень. Иначе каталог будет казаться хаотичным и будет снижать доверие.
## Сценарные сегменты
Каталог нужно вести не только по категориям старого сайта, но и по понятным покупательским сценариям:
- Для дома: 100-500 г, 1 кг, банки, небольшие упаковки, продукты на пробу.
- Для семьи: 2-5 кг и понятные продукты для регулярной покупки.
- Для совместной покупки: 5-15 кг, где удобно разделить с соседями или родственниками.
- Большая фасовка: 10-40 кг, короба, монолит, ящики, выгодная цена за объем.
- HoReCa: рестораны, кафе, магазины, позиции с большими объемами и стабильным спросом.
- Премиум: икра, семга, палтус, клыкач, краб, гребешок, дорогие сыры.
- К пиву: вяленая, копченая рыба, балыки, юкола, снеки.
- Быстро приготовить: котлеты, наггетсы, стрипсы, жареная рыба, овощные смеси.
- Для суши: нори, рис, угорь, креветки, чука, имбирь, соусы, красная рыба.
- Диетическое: филе без кожи, белая рыба, индейка, овощи, ягоды.
## Какие признаки нужно хранить
Для правильной витрины и рекомендаций у товара должны быть не только категория и цена, но и дополнительные признаки:
- размер фасовки: малая, средняя, крупная;
- тип покупки: дом, семья, совместная покупка, HoReCa;
- тип заморозки: штучная, монолитная, охлажденная;
- обработка: филе, стейк, без кожи, без головы, с головой, неразделанная;
- назначение: к пиву, для суши, быстро приготовить, премиум, диетическое;
- ценовой сегмент: бюджетный, средний, премиум;
- совместимость: что логично докупить к этому товару.
## Рекомендации
Блоки рекомендаций должны быть управляемыми:
- Похожие товары: та же категория, близкая фасовка, близкий ценовой уровень.
- С этим покупают: товары, которые реально дополняют покупку.
- Выгоднее коробом: только если человек уже смотрит похожий продукт и большая фасовка уместна.
- Малая фасовка: если товар тяжелый или дорогой, предлагать более доступный вариант.
- Собрать набор: например для суши, ужина, закусок к пиву, семейной закупки.
- Популярное в категории: не просто случайные товары, а релевантные текущей категории.
## Чего нельзя делать
- Не смешивать малую домашнюю покупку с тяжелым HoReCa-оптом без явного сценария.
- Не показывать мясо к рыбе только потому, что оно популярное.
- Не поднимать наверх дорогие деликатесы, если покупатель ищет бюджетную белую рыбу.
- Не показывать 20-30 кг монолит человеку, который выбирает 300 г, 500 г или 1 кг.
- Не делать одну общую популярную ленту без категорий, фасовок и сценариев.
## Что заложено в базу
В схему добавлены таблицы:
- `product_segments` - сценарные группы товаров;
- `product_segment_links` - связь товара с сегментами;
- `product_badges` - короткие бейджи товара;
- `product_badge_links` - связь товара с бейджами;
- `recommendation_rules` - управляемые правила рекомендаций;
- `product_relations` - ручные и автоматические связи между товарами;
- `product_events` - события для будущего обучения: просмотр, корзина, заказ, сравнение, клик по рекомендации.
Это позволит сначала настроить рекомендации вручную, а потом постепенно делать их умнее по реальному поведению покупателей.
## Как использовать при переносе товаров
При импорте со старого сайта каждый товар нужно не только положить в старую категорию, но и автоматически проставить предварительные сегменты и бейджи по названию, фасовке и цене.
Примеры:
- 100-500 г, банка, 1 кг: `Для дома`, `Малая фасовка`.
- 3-10 кг: `Для семьи` или `Для совместной покупки`.
- 10-40 кг: `Большая фасовка`, иногда `HoReCa`.
- Вяленая/копченая/балык/юкола: `К пиву`.
- Нори, рис, угорь, чука, имбирь, креветки: `Для суши`.
- Краб, гребешок, клыкач, икра, дорогая семга: `Премиум`.
- Котлеты, наггетсы, стрипсы, блинчики, овощные смеси: `Быстро приготовить`.
После автоматической разметки администратор должен иметь возможность быстро поправить сегменты и бейджи вручную.
## Идеальный следующий уровень
Для удобства покупателей стоит добавить:
- переключатель "Для дома / Для семьи / Оптом";
- фильтр по фасовке: до 1 кг, 1-5 кг, 5-10 кг, 10 кг и больше;
- блок "Можно взять на пробу";
- блок "Выгодно разделить";
- блок "Собрать набор";
- блок "Не знаете что выбрать?" с мягкими готовыми сценариями;
- умную сортировку, где сначала идут релевантные товары, а не случайная популярность.
Так сайт будет выглядеть не как складская выгрузка, а как понятный помощник в покупке.
@@ -0,0 +1,173 @@
# Анализ первых товаров и атрибутов
## Что видно по присланному списку
Товары нельзя хранить только как "название + цена". Для удобного сайта и админки нужны:
- товар;
- одна или несколько фасовок;
- цена за кг, если товар весовой;
- цена за упаковку;
- тип заморозки;
- тип обработки/разделки;
- происхождение;
- размер или калибр;
- вид приготовления;
- тип фасовки.
## Новые группы атрибутов
В `database/seed.sql` добавлены группы:
- Упаковка;
- Размер рыбы;
- Размер креветки;
- Тип обработки;
- Тип заморозки;
- Происхождение;
- Размер / калибр;
- Вид приготовления;
- Тип фасовки.
## Примеры признаков из товаров
### Тип заморозки
- штучная заморозка;
- монолитная заморозка;
- свежемороженые;
- охлажденная.
### Тип обработки
- неразделанная;
- разделанная с головой;
- потрошеная без головы;
- без головы;
- с головой;
- без кожи;
- филе;
- стейк.
### Вид приготовления
- слабосоленая;
- вяленая;
- горячего копчения;
- холодного копчения;
- в кляре;
- в масле;
- с морской капустой.
### Происхождение
- Вьетнам;
- Камчатка;
- Япономорская;
- Сахалин;
- Каспийская;
- Цимлянская;
- Чили;
- Армения;
- Средиземноморская;
- Севанская.
### Размер / калибр
- 1 кг и более;
- 4 кг и более;
- 7-8 кг;
- L2;
- U4;
- 16-20.
## Правила отображения цены
### Весовой товар с крупной фасовкой
Пример: горбуша 22 кг, цена 559 руб./кг.
Показывать:
- цена за кг: 559 руб.;
- за упаковку 22 кг: 12298 руб.;
- выбор фасовки: 22 кг или 11 кг.
### Единственная упаковка 1 кг
Пример: креветки 1 кг.
Показывать аккуратно:
- цена за упаковку 1 кг;
- можно не дублировать одинаковую цену два раза, если это визуально перегружает карточку.
### Маленькая фиксированная упаковка
Пример: кукумария 200 г.
Показывать:
- за упаковку 200 г: 300 руб.;
- не пересчитывать за кг, если это не нужно покупателю.
### Порционная фасовка
Пример: стейки тунца 0.5 кг.
Показывать:
- за упаковку 0.5 кг;
- кратность покупки 0.5 кг.
## Что стоит сделать для удобства покупателей
- Бейдж "крупная фасовка" для 10 кг, 20 кг, 22 кг и похожих упаковок.
- Бейдж "малая фасовка" для 1 кг, 0.5 кг, 200 г.
- Бейдж "штучная заморозка" для удобного разделения продукта.
- Бейдж "монолитная заморозка" там, где важно предупредить покупателя.
- Фильтр "разделка": неразделанная, разделанная, филе, стейк, без головы.
- Фильтр "тип заморозки".
- Фильтр "страна/регион происхождения".
- Фильтр "размер/калибр".
## Как это ляжет в админку
У товара должны быть общие поля:
- название;
- категория;
- описание;
- SEO;
- фото;
- опубликован/не опубликован.
У товара должны быть атрибуты:
- какие группы применяются к этому товару: например Упаковка и Размер рыбы.
У каждой фасовки/варианта должны быть:
- упаковка;
- размер/калибр, если есть;
- цена;
- старая цена;
- код товара;
- артикул;
- вес/объем/штук;
- доступность;
- публикация;
- значения атрибутов.
## Следующее действие
Нужно получить полный список атрибутов из старой админки, особенно:
- все значения упаковки;
- все размеры рыбы;
- все размеры креветок;
- все варианты разделки;
- все варианты заморозки;
- все регионы/страны происхождения.
После этого можно сделать удобную форму товара в админке не одной строкой, а таблицей фасовок как на старом сайте.
@@ -0,0 +1,102 @@
# Шаг 4. Каталог, роутинг и первые формы админки
## Что сделано
Добавлен базовый роутинг:
- `/` - главная;
- `/catalog` - полный каталог;
- `/catalog/{slug}` - страница категории;
- 404-страница.
Добавлены файлы:
- `app/Core/Router.php`;
- `app/Controllers/CatalogController.php`;
- `views/catalog/index.php`;
- `views/partials/product-card.php`;
- `views/pages/404.php`;
- `public/.htaccess`.
## Навигация
Каталог получил боковую навигацию по категориям:
- главное дерево категорий;
- вложенность до третьего уровня;
- подсветка текущей категории;
- липкая боковая панель на desktop;
- адаптация под мобильный вид.
Хлебные крошки и кнопка "Вернуться назад" уже работают через общий layout.
## Товары в каталоге
Каталог готов выводить опубликованные товары и фасовки.
Для страницы категории товары выбираются не только из самой категории, но и из всех вложенных подкатегорий.
Пока товаров нет, выводится понятное сообщение о будущем переносе каталога.
## Админка
Добавлены первые формы:
- `/admin/categories.php` - добавление категории/подкатегории;
- `/admin/products.php` - добавление товара с одной стартовой фасовкой.
Это первичная техническая админка без авторизации. Полноценный защищенный вход администратора нужно сделать отдельным следующим шагом.
## Атрибуты и фасовки
После сравнения со старой админкой в схему добавлена расширенная модель атрибутов:
- `attribute_groups` - группы атрибутов, например "Упаковка", "Размер рыбы", "Размер креветки", "Тип заморозки", "Происхождение";
- `attribute_values` - значения атрибутов, например "20 кг", "10 кг", "31/40", "штучная заморозка", "монолитная заморозка";
- `product_attribute_groups` - какие группы используются у конкретного товара;
- `variant_attribute_values` - какие значения выбраны у конкретной фасовки/варианта.
В `product_variants` добавлены поля:
- `code`;
- `article`;
- `volume_or_pieces`;
- `old_package_price`.
Это нужно, чтобы повторить удобную логику старой админки: один товар, несколько строк фасовок, разные цены, вес/объем/штук, код товара, артикул и дополнительные атрибуты.
Дополнительный анализ первых товаров и атрибутов вынесен в `docs/АНАЛИЗ_ПЕРВЫХ_ТОВАРОВ_И_АТРИБУТОВ.md`.
## Дополнение: сценарный каталог и рекомендации
После анализа большого списка товаров со старого сайта стало понятно, что каталог нельзя строить только как длинную ленту из 42 страниц. В ассортименте смешаны маленькие позиции для дома, семейные фасовки, крупные короба, HoReCa, премиальные деликатесы, бакалея, мясо, молочная продукция и полуфабрикаты.
В базу добавлена архитектура для умных, но управляемых рекомендаций:
- `product_segments` и `product_segment_links` - сценарные сегменты: для дома, для семьи, большая фасовка, HoReCa, премиум, к пиву, быстро приготовить, для суши и другие;
- `product_badges` и `product_badge_links` - короткие бейджи для карточек и фильтров;
- `recommendation_rules` - правила, которые можно будет контролировать из админки;
- `product_relations` - ручные и автоматические связи между товарами;
- `product_events` - события для будущего обучения: просмотры, корзина, заказ, сравнение, клики по рекомендациям.
Подробный вывод вынесен в `docs/АНАЛИЗ_КАТАЛОГА_И_РЕКОМЕНДАЦИЙ.md`.
## Дополнение: навигация, скидки и проходные цены
После проверки страниц добавлены еще три важных требования:
- дерево категорий должно раскрываться на каждом уровне, чтобы страницы вроде "Рыба" не превращались в длинное полотно;
- у фасовки должна быть скидочная модель: текущая цена, старая перечеркнутая цена, ручная установка скидки и процентная скидка с автоматическим пересчетом;
- на главной странице нужен блок "Запрос проходных цен", где покупатель сможет отправить список позиций текстом или прикрепленным файлом, а заявка уйдет администратору на почту.
В схему добавлены поля `discount_type`, `discount_percent`, используется `old_package_price`, а также таблица `price_requests` для будущих заявок проходных цен.
## Следующий шаг
Шаг 5:
1. Сделать авторизацию администратора.
2. Защитить `/admin/`.
3. Улучшить формы редактирования категорий и товаров.
4. Добавить редактирование и удаление.
5. Добавить страницу товара и корзинную заготовку.
+8
View File
@@ -0,0 +1,8 @@
Options -Indexes
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
</IfModule>
+9
View File
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
use App\Controllers\Admin\CategoryAdminController;
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
(new CategoryAdminController())->index();
+9
View File
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
use App\Controllers\Admin\ProductAdminController;
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
(new ProductAdminController())->index();
+148 -5
View File
@@ -61,6 +61,10 @@ body {
padding: 24px; padding: 24px;
} }
.primary-link {
font-weight: 700;
}
.start-screen { .start-screen {
width: min(760px, 100%); width: min(760px, 100%);
padding: 32px; padding: 32px;
@@ -191,18 +195,17 @@ a:hover {
.category-list { .category-list {
display: grid; display: grid;
gap: 10px; gap: 8px;
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
} }
.category-list .category-list { .category-list .category-list {
display: flex; display: grid;
flex-wrap: wrap; gap: 6px;
gap: 8px;
margin: 8px 0 0; margin: 8px 0 0;
padding: 0; padding: 0 0 0 14px;
list-style: none; list-style: none;
} }
@@ -211,6 +214,15 @@ a:hover {
font-weight: 700; font-weight: 700;
} }
.category-list li.is-current > a,
.category-list a.is-current-link {
display: inline-block;
border-radius: 6px;
padding: 3px 6px;
color: #ffffff;
background: #1d5d3b;
}
.category-list.depth-1 li { .category-list.depth-1 li {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
@@ -225,6 +237,21 @@ a:hover {
font-weight: 400; font-weight: 400;
} }
.category-details summary {
display: flex;
gap: 8px;
align-items: center;
cursor: pointer;
}
.category-details summary a {
min-width: 0;
}
.category-details summary::marker {
color: #7a856f;
}
.settings-list { .settings-list {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -284,6 +311,15 @@ a:hover {
color: #2d372f; color: #2d372f;
} }
.old-price {
display: inline-block;
margin-right: 8px;
font-size: 13px;
font-weight: 400;
color: #8b9485;
text-decoration: line-through;
}
.price-tiers { .price-tiers {
display: grid; display: grid;
gap: 6px; gap: 6px;
@@ -317,6 +353,103 @@ a:hover {
text-align: center; text-align: center;
} }
.catalog-layout,
.admin-layout {
width: min(1180px, 100%);
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.catalog-sidebar,
.catalog-content {
min-width: 0;
}
.sticky-panel {
position: sticky;
top: 18px;
max-height: calc(100vh - 36px);
overflow: auto;
}
.catalog-heading {
padding: 24px;
margin-bottom: 18px;
border: 1px solid #dfe5da;
border-radius: 8px;
background: #ffffff;
}
.catalog-heading h1 {
margin-bottom: 10px;
}
.catalog-product-grid {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.admin-form {
display: grid;
gap: 14px;
margin-top: 20px;
}
.admin-form label {
display: grid;
gap: 6px;
font-size: 14px;
font-weight: 700;
color: #394437;
}
.admin-form input,
.admin-form select,
.admin-form textarea {
width: 100%;
border: 1px solid #cfd8c9;
border-radius: 8px;
padding: 11px 12px;
font: inherit;
font-size: 15px;
background: #ffffff;
}
.admin-form textarea {
resize: vertical;
}
.checkbox-label {
display: flex !important;
grid-template-columns: none;
gap: 8px !important;
align-items: center;
}
.checkbox-label input {
width: auto;
}
.admin-list {
display: grid;
gap: 10px;
}
.admin-list div {
display: grid;
gap: 4px;
padding: 12px;
border: 1px solid #dfe5da;
border-radius: 8px;
background: #ffffff;
}
.admin-list span {
font-size: 13px;
color: #687466;
}
@media (max-width: 760px) { @media (max-width: 760px) {
.site-utility { .site-utility {
width: calc(100% - 32px); width: calc(100% - 32px);
@@ -336,4 +469,14 @@ a:hover {
.section-heading { .section-heading {
display: grid; display: grid;
} }
.catalog-layout,
.admin-layout {
grid-template-columns: 1fr;
}
.sticky-panel {
position: static;
max-height: none;
}
} }
+9 -1
View File
@@ -2,8 +2,16 @@
declare(strict_types=1); declare(strict_types=1);
use App\Controllers\CatalogController;
use App\Controllers\HomeController; use App\Controllers\HomeController;
use App\Core\Router;
require_once dirname(__DIR__) . '/app/bootstrap.php'; require_once dirname(__DIR__) . '/app/bootstrap.php';
(new HomeController())->index(); $router = new Router();
$router->get('/', static fn () => (new HomeController())->index());
$router->get('/catalog', static fn () => (new CatalogController())->index());
$router->get('/catalog/{slug}', static fn (string $slug) => (new CatalogController())->category($slug));
$router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/** @var array<int, array<string, mixed>> $categories */
/** @var array<int, array<string, mixed>> $flatCategories */
/** @var string|null $message */
/** @var string|null $error */
?>
<section class="admin-layout">
<div class="panel">
<p class="eyebrow">Админка</p>
<h1>Категории</h1>
<p class="muted">Первичная форма для добавления категории или подкатегории.</p>
<?php if ($message !== null): ?>
<div class="status-box success"><?= e($message) ?></div>
<?php endif; ?>
<?php if ($error !== null): ?>
<div class="status-box danger"><?= e($error) ?></div>
<?php endif; ?>
<form class="admin-form" method="post">
<label>
Родительская категория
<select name="parent_id">
<option value="">Без родителя</option>
<?php foreach ($flatCategories as $category): ?>
<option value="<?= e((string) $category['id']) ?>"><?= e($category['name']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
Название
<input name="name" required>
</label>
<label>
Slug
<input name="slug" required>
</label>
<label>
H1
<input name="h1">
</label>
<label>
Title
<input name="seo_title">
</label>
<label>
Description
<textarea name="seo_description" rows="3"></textarea>
</label>
<label>
Keywords
<textarea name="seo_keywords" rows="2"></textarea>
</label>
<label>
Сортировка
<input name="sort_order" type="number" value="100">
</label>
<label class="checkbox-label">
<input name="is_active" type="checkbox" checked>
Активна
</label>
<button class="primary-button" type="submit">Добавить категорию</button>
</form>
</div>
<div class="panel">
<h2>Текущее дерево</h2>
<?php
$items = $categories;
$depth = 0;
$currentSlug = '';
require base_path('views/partials/category-tree.php');
?>
</div>
</section>
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
/** @var array<int, array<string, mixed>> $categories */
/** @var array<int, array<string, mixed>> $products */
/** @var string|null $message */
/** @var string|null $error */
?>
<section class="admin-layout">
<div class="panel">
<p class="eyebrow">Админка</p>
<h1>Товары</h1>
<p class="muted">Первичная форма для добавления товара с одной стартовой фасовкой.</p>
<?php if ($message !== null): ?>
<div class="status-box success"><?= e($message) ?></div>
<?php endif; ?>
<?php if ($error !== null): ?>
<div class="status-box danger"><?= e($error) ?></div>
<?php endif; ?>
<form class="admin-form" method="post">
<label>
Категория
<select name="category_id">
<option value="">Без категории</option>
<?php foreach ($categories as $category): ?>
<option value="<?= e((string) $category['id']) ?>"><?= e($category['name']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
Название
<input name="name" required>
</label>
<label>
Slug
<input name="slug" required>
</label>
<label>
Краткое описание
<textarea name="short_description" rows="3"></textarea>
</label>
<label>
Title
<input name="seo_title">
</label>
<label>
Description
<textarea name="seo_description" rows="3"></textarea>
</label>
<label>
Keywords
<textarea name="seo_keywords" rows="2"></textarea>
</label>
<label>
Базовая единица
<select name="base_unit">
<option value="kg">кг</option>
<option value="g">г</option>
<option value="l">л</option>
<option value="ml">мл</option>
<option value="pcs">шт</option>
<option value="pack">упаковка</option>
</select>
</label>
<label>
Название фасовки
<input name="variant_name" placeholder="Например: короб 10 кг">
</label>
<label>
Количество в фасовке
<input name="package_quantity" type="number" step="0.001" value="1">
</label>
<label>
Шаг количества
<input name="step_quantity" type="number" step="0.001" value="1">
</label>
<label>
Цена за единицу
<input name="price_per_unit" type="number" step="0.01">
</label>
<label>
Цена за фасовку
<input name="package_price" type="number" step="0.01" value="0">
</label>
<label>
Старая цена для перечеркивания
<input name="old_package_price" type="number" step="0.01" placeholder="Например: 4550">
</label>
<label>
Скидка в процентах
<input name="discount_percent" type="number" min="0" max="99" step="0.01" placeholder="Например: 10">
</label>
<label class="checkbox-label">
<input name="is_published" type="checkbox">
Опубликовать
</label>
<button class="primary-button" type="submit">Добавить товар</button>
</form>
</div>
<div class="panel">
<h2>Последние товары</h2>
<?php if ($products === []): ?>
<p class="muted">Товаров пока нет.</p>
<?php else: ?>
<div class="admin-list">
<?php foreach ($products as $product): ?>
<div>
<strong><?= e($product['name']) ?></strong>
<span><?= e($product['category_name'] ?? 'Без категории') ?></span>
<span><?= !empty($product['is_published']) ? 'Опубликован' : 'Черновик' ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</section>
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/** @var array<int, array<string, mixed>> $categories */
/** @var array<string, mixed>|null $currentCategory */
/** @var array<int, array<string, mixed>> $products */
$heading = $currentCategory['h1'] ?? $currentCategory['name'] ?? 'Каталог продукции';
?>
<section class="catalog-layout">
<aside class="catalog-sidebar">
<div class="panel sticky-panel">
<h2>Категории</h2>
<?php
$items = $categories;
$depth = 0;
$currentSlug = $currentCategory['slug'] ?? '';
require base_path('views/partials/category-tree.php');
?>
</div>
</aside>
<section class="catalog-content">
<div class="catalog-heading">
<p class="eyebrow"><?= $currentCategory === null ? 'Полный каталог' : 'Категория' ?></p>
<h1><?= e($heading) ?></h1>
<?php if (!empty($currentCategory['description'])): ?>
<p><?= e($currentCategory['description']) ?></p>
<?php else: ?>
<p class="muted">Выберите категорию слева или смотрите товары сразу в каталоге.</p>
<?php endif; ?>
</div>
<?php if ($products === []): ?>
<div class="panel">
<p class="muted">В этой категории товары появятся после переноса каталога со старого сайта.</p>
</div>
<?php else: ?>
<div class="product-grid catalog-product-grid">
<?php foreach ($products as $product): ?>
<?php require base_path('views/partials/product-card.php'); ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
</section>
+6
View File
@@ -0,0 +1,6 @@
<section class="start-screen">
<p class="eyebrow">404</p>
<h1>Страница не найдена</h1>
<p>Возможно, ссылка изменилась. Вернитесь в каталог или на главную страницу.</p>
<p><a class="primary-link" href="/catalog">Перейти в каталог</a></p>
</section>
+3 -38
View File
@@ -34,6 +34,7 @@ declare(strict_types=1);
<?php <?php
$items = $categories; $items = $categories;
$depth = 0; $depth = 0;
$currentSlug = '';
require base_path('views/partials/category-tree.php'); require base_path('views/partials/category-tree.php');
?> ?>
<?php endif; ?> <?php endif; ?>
@@ -76,27 +77,7 @@ declare(strict_types=1);
<?php else: ?> <?php else: ?>
<div class="product-grid"> <div class="product-grid">
<?php foreach ($catalogProducts as $product): ?> <?php foreach ($catalogProducts as $product): ?>
<article class="product-card"> <?php require base_path('views/partials/product-card.php'); ?>
<p class="product-category"><?= e($product['category_name'] ?? 'Каталог') ?></p>
<h3><?= e($product['name']) ?></h3>
<p><?= e($product['variant_name'] ?? 'Выберите доступную фасовку') ?></p>
<?php if ($product['price_per_unit'] !== null): ?>
<span class="price-line">за <?= e($product['unit'] ?? 'ед.') ?>: <?= e((string) $product['price_per_unit']) ?> руб.</span>
<?php endif; ?>
<?php if ($product['package_price'] !== null): ?>
<strong><?= e((string) $product['package_price']) ?> руб. за фасовку</strong>
<?php endif; ?>
<?php if (!empty($product['price_tiers'])): ?>
<ul class="price-tiers">
<?php foreach ($product['price_tiers'] as $tier): ?>
<li>
<span><?= e($tier['label']) ?></span>
<strong><?= e((string) $tier['price']) ?> руб.</strong>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</article>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
@@ -109,23 +90,7 @@ declare(strict_types=1);
<?php else: ?> <?php else: ?>
<div class="product-grid"> <div class="product-grid">
<?php foreach ($popularProducts as $product): ?> <?php foreach ($popularProducts as $product): ?>
<article class="product-card"> <?php require base_path('views/partials/product-card.php'); ?>
<h3><?= e($product['name']) ?></h3>
<p><?= e($product['variant_name'] ?? 'Фасовка не выбрана') ?></p>
<?php if ($product['package_price'] !== null): ?>
<strong><?= e((string) $product['package_price']) ?> руб.</strong>
<?php endif; ?>
<?php if (!empty($product['price_tiers'])): ?>
<ul class="price-tiers">
<?php foreach ($product['price_tiers'] as $tier): ?>
<li>
<span><?= e($tier['label']) ?></span>
<strong><?= e((string) $tier['price']) ?> руб.</strong>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</article>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
+37 -3
View File
@@ -4,12 +4,43 @@ declare(strict_types=1);
/** @var array<int, array<string, mixed>> $items */ /** @var array<int, array<string, mixed>> $items */
/** @var int $depth */ /** @var int $depth */
/** @var string $currentSlug */
if (!function_exists('category_tree_has_current')) {
/**
* @param array<string, mixed> $category
*/
function category_tree_has_current(array $category, string $currentSlug): bool
{
if ($currentSlug === '') {
return false;
}
if (($category['slug'] ?? '') === $currentSlug) {
return true;
}
foreach (($category['children'] ?? []) as $child) {
if (category_tree_has_current($child, $currentSlug)) {
return true;
}
}
return false;
}
}
?> ?>
<ul class="category-list depth-<?= e((string) $depth) ?>"> <ul class="category-list depth-<?= e((string) $depth) ?>">
<?php foreach ($items as $category): ?> <?php foreach ($items as $category): ?>
<li> <?php $isCurrent = ($currentSlug ?? '') === $category['slug']; ?>
<a href="/catalog/<?= e($category['slug']) ?>"><?= e($category['name']) ?></a> <?php $hasChildren = !empty($category['children']); ?>
<?php if (!empty($category['children'])): ?> <?php $shouldOpen = category_tree_has_current($category, $currentSlug ?? ''); ?>
<li class="<?= $isCurrent ? 'is-current' : '' ?>">
<?php if ($hasChildren): ?>
<details class="category-details" <?= $shouldOpen ? 'open' : '' ?>>
<summary>
<a class="<?= $isCurrent ? 'is-current-link' : '' ?>" href="/catalog/<?= e($category['slug']) ?>"><?= e($category['name']) ?></a>
</summary>
<?php <?php
$previousItems = $items; $previousItems = $items;
$items = $category['children']; $items = $category['children'];
@@ -18,6 +49,9 @@ declare(strict_types=1);
$depth--; $depth--;
$items = $previousItems; $items = $previousItems;
?> ?>
</details>
<?php else: ?>
<a href="/catalog/<?= e($category['slug']) ?>"><?= e($category['name']) ?></a>
<?php endif; ?> <?php endif; ?>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/** @var array<string, mixed> $product */
?>
<article class="product-card">
<p class="product-category"><?= e($product['category_name'] ?? 'Каталог') ?></p>
<h3><a href="/product/<?= e($product['slug']) ?>"><?= e($product['name']) ?></a></h3>
<p><?= e($product['variant_name'] ?? 'Выберите доступную фасовку') ?></p>
<?php if ($product['price_per_unit'] !== null): ?>
<span class="price-line">за <?= e($product['unit'] ?? 'ед.') ?>: <?= e((string) $product['price_per_unit']) ?> руб.</span>
<?php endif; ?>
<?php if ($product['package_price'] !== null): ?>
<strong>
<?php if (!empty($product['old_package_price'])): ?>
<span class="old-price"><?= e((string) $product['old_package_price']) ?> руб.</span>
<?php endif; ?>
<?= e((string) $product['package_price']) ?> руб. за фасовку
</strong>
<?php endif; ?>
<?php if (!empty($product['price_tiers'])): ?>
<ul class="price-tiers">
<?php foreach ($product['price_tiers'] as $tier): ?>
<li>
<span><?= e($tier['label']) ?></span>
<strong><?= e((string) $tier['price']) ?> руб.</strong>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</article>
+2 -1
View File
@@ -641,4 +641,5 @@
- Шаг 2 выполнен: создана SQL-схема базы `database/schema.sql`, стартовые данные `database/seed.sql`, страница установки `public/install.php` и документ `docs/ШАГ_2_БАЗА_ДАННЫХ.md`. - Шаг 2 выполнен: создана SQL-схема базы `database/schema.sql`, стартовые данные `database/seed.sql`, страница установки `public/install.php` и документ `docs/ШАГ_2_БАЗА_ДАННЫХ.md`.
- Шаг 3 выполнен: созданы первые PHP-классы, репозитории, контроллер главной страницы, вывод категорий/настроек/популярных товаров, каталог на главной, категории старого сайта в `database/seed.sql`, SEO-поля, хлебные крошки, кнопка назад, градации выгодных цен по промокодам и документ `docs/ШАГ_3_PHP_КЛАССЫ.md`. - Шаг 3 выполнен: созданы первые PHP-классы, репозитории, контроллер главной страницы, вывод категорий/настроек/популярных товаров, каталог на главной, категории старого сайта в `database/seed.sql`, SEO-поля, хлебные крошки, кнопка назад, градации выгодных цен по промокодам и документ `docs/ШАГ_3_PHP_КЛАССЫ.md`.
- Перенос товаров выделен в отдельную задачу: `docs/ПЛАН_ПЕРЕНОСА_ТОВАРОВ.md`. - Перенос товаров выделен в отдельную задачу: `docs/ПЛАН_ПЕРЕНОСА_ТОВАРОВ.md`.
- Следующий шаг: сделать базовый роутинг, страницу полного каталога и первые формы админки для категорий/товаров. - Шаг 4 выполнен: добавлен роутинг, страницы полного каталога и категорий, боковая навигация, вывод товаров по вложенным категориям, первые формы админки для категорий/товаров, расширенная модель атрибутов/фасовок как в старой админке, анализ первых товаров, сценарные сегменты каталога, бейджи, правила рекомендаций, события для будущего обучения и документы `docs/ШАГ_4_КАТАЛОГ_И_АДМИНКА.md`, `docs/АНАЛИЗ_ПЕРВЫХ_ТОВАРОВ_И_АТРИБУТОВ.md`, `docs/АНАЛИЗ_КАТАЛОГА_И_РЕКОМЕНДАЦИЙ.md`.
- Следующий шаг: сделать авторизацию администратора, защитить админку, добавить редактирование категорий/товаров и заготовку страницы товара.