<?php
namespace App\Controller;
use App\Controller\Catalog\CatalogRepository;
use App\Controller\Catalog\CatalogSteamEnricher;
use App\Controller\Catalog\GalaxyLinkCatalogProvider;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class AdminController extends BaseController
{
/**
* @Route("/admin", name="admin_dashboard", methods={"GET"})
*/
public function dashboard(Request $request, Connection $db, CatalogRepository $catalog): Response
{
$catalog->ensureSchema();
$filters = [
'q' => trim((string) $request->query->get('q', '')),
'source' => trim((string) $request->query->get('source', '')),
'category' => trim((string) $request->query->get('category', '')),
'active' => trim((string) $request->query->get('active', '')),
'stock' => trim((string) $request->query->get('stock', '')),
'discount' => trim((string) $request->query->get('discount', '')),
'page' => max(1, (int) $request->query->get('page', 1)),
];
$products = $this->fetchProducts($db, $filters, 50);
$steamEnrichmentReport = null;
if ($request->hasSession()) {
$steamEnrichmentReport = $request->getSession()->get('admin_steam_enrichment_report');
$request->getSession()->remove('admin_steam_enrichment_report');
}
return $this->render('admin/dashboard.html.twig', $this->adminView($request, $db, [
'stats' => $this->dashboardStats($db),
'sources' => $this->fetchSources($db),
'categories' => $this->fetchCategories($db),
'products' => $products['items'],
'pagination' => $products['pagination'],
'filters' => $filters,
'steam_enrichment_report' => $steamEnrichmentReport,
'steam_background_status' => $this->steamBackgroundStatus(),
'current_section' => 'dashboard',
]));
}
/**
* @Route("/admin/references", name="admin_references", methods={"GET"})
*/
public function references(Request $request, Connection $db, CatalogRepository $catalog): Response
{
$catalog->ensureSchema();
return $this->render('admin/references.html.twig', $this->adminView($request, $db, [
'sources' => $this->fetchSources($db),
'categories' => $this->fetchCategories($db),
'empty_source' => $this->emptySource(),
'empty_category' => $this->emptyCategory(),
'current_section' => 'references',
]));
}
/**
* @Route("/admin/users", name="admin_users", methods={"GET"})
*/
public function users(Request $request, Connection $db): Response
{
$this->ensureUsersSchema($db);
$filters = [
'q' => trim((string) $request->query->get('q', '')),
'page' => max(1, (int) $request->query->get('page', 1)),
];
$users = $this->fetchUsers($db, $filters, 40);
return $this->render('admin/users.html.twig', $this->adminView($request, $db, [
'users' => $users['items'],
'pagination' => $users['pagination'],
'filters' => $filters,
'current_section' => 'users',
]));
}
/**
* @Route("/admin/products/new", name="admin_product_new", methods={"GET", "POST"})
*/
public function newProduct(Request $request, Connection $db, CatalogRepository $catalog): Response
{
$catalog->ensureSchema();
if ($request->isMethod('POST')) {
try {
$id = $this->saveProduct($request, $db, null);
$this->addFlash('success', 'Товар создан.');
return $this->redirectToRoute('admin_product_edit', ['id' => $id]);
} catch (\Throwable $error) {
$this->addFlash('danger', $error->getMessage());
}
}
return $this->render('admin/product_form.html.twig', $this->adminView($request, $db, [
'product' => $this->emptyProduct(),
'variants' => [],
'categories' => $this->fetchCategories($db),
'sources' => $this->fetchSources($db),
'mode' => 'new',
'current_section' => 'products',
]));
}
/**
* @Route("/admin/products/{id}/edit", name="admin_product_edit", methods={"GET", "POST"}, requirements={"id"="\d+"})
*/
public function editProduct(int $id, Request $request, Connection $db, CatalogRepository $catalog): Response
{
$catalog->ensureSchema();
$product = $this->fetchProduct($db, $id);
if (!$product) {
throw $this->createNotFoundException('Product not found.');
}
if ($request->isMethod('POST')) {
try {
$this->saveProduct($request, $db, $id);
$this->addFlash('success', 'Товар сохранен.');
return $this->redirectToRoute('admin_product_edit', ['id' => $id]);
} catch (\Throwable $error) {
$this->addFlash('danger', $error->getMessage());
$product = array_merge($product, $request->request->all());
}
}
return $this->render('admin/product_form.html.twig', $this->adminView($request, $db, [
'product' => $this->decorateProduct($product),
'variants' => $this->fetchProductVariants($db, $product),
'categories' => $this->fetchCategories($db),
'sources' => $this->fetchSources($db),
'mode' => 'edit',
'current_section' => 'products',
]));
}
/**
* @Route("/admin/users/{id}/edit", name="admin_user_edit", methods={"GET", "POST"}, requirements={"id"="\d+"})
*/
public function editUser(int $id, Request $request, Connection $db): Response
{
$this->ensureUsersSchema($db);
$user = $this->fetchUser($db, $id);
if (!$user) {
throw $this->createNotFoundException('User not found.');
}
if ($request->isMethod('POST')) {
try {
$this->saveUser($request, $db, $id);
$this->addFlash('success', 'Пользователь сохранен.');
return $this->redirectToRoute('admin_user_edit', ['id' => $id]);
} catch (\Throwable $error) {
$this->addFlash('danger', $error->getMessage());
$user = array_merge($user, $request->request->all());
}
}
return $this->render('admin/user_form.html.twig', $this->adminView($request, $db, [
'user' => $this->decorateUser($user),
'current_section' => 'users',
]));
}
/**
* @Route("/admin/products/bulk", name="admin_products_bulk", methods={"POST"})
*/
public function bulkProducts(Request $request, Connection $db, CatalogRepository $catalog): RedirectResponse
{
$catalog->ensureSchema();
$ids = array_values(array_filter(array_map('intval', (array) $request->request->get('ids', []))));
$action = (string) $request->request->get('action', '');
if (count($ids) === 0) {
$this->addFlash('warning', 'Выберите товары для массового действия.');
return $this->redirectToRoute('admin_dashboard', $request->query->all());
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$now = $this->now();
if ($action === 'activate') {
$db->executeStatement("UPDATE catalog_products SET is_active = 1, updated_at = ? WHERE id IN ({$placeholders})", array_merge([$now], $ids));
$this->addFlash('success', 'Выбранные товары включены.');
} elseif ($action === 'deactivate') {
$db->executeStatement("UPDATE catalog_products SET is_active = 0, updated_at = ? WHERE id IN ({$placeholders})", array_merge([$now], $ids));
$this->addFlash('success', 'Выбранные товары скрыты.');
} elseif ($action === 'stock_on') {
$db->executeStatement("UPDATE catalog_products SET in_stock = 1, updated_at = ? WHERE id IN ({$placeholders})", array_merge([$now], $ids));
$this->addFlash('success', 'Наличие включено.');
} elseif ($action === 'stock_off') {
$db->executeStatement("UPDATE catalog_products SET in_stock = 0, updated_at = ? WHERE id IN ({$placeholders})", array_merge([$now], $ids));
$this->addFlash('success', 'Наличие выключено.');
} elseif ($action === 'clear_discount') {
$db->executeStatement("UPDATE catalog_products SET discount_percent = NULL, updated_at = ? WHERE id IN ({$placeholders})", array_merge([$now], $ids));
$this->addFlash('success', 'Скидки очищены.');
} elseif ($action === 'set_discount') {
$discount = $this->normalizeDiscount($request->request->get('discount_percent'));
$db->executeStatement("UPDATE catalog_products SET discount_percent = ?, updated_at = ? WHERE id IN ({$placeholders})", array_merge([$discount, $now], $ids));
$this->addFlash('success', $discount === null ? 'Скидки очищены.' : 'Скидка применена.');
} else {
$this->addFlash('warning', 'Неизвестное массовое действие.');
}
return $this->redirectToRoute('admin_dashboard', $request->query->all());
}
/**
* @Route("/admin/products/{id}/toggle", name="admin_product_toggle", methods={"POST"}, requirements={"id"="\d+"})
*/
public function toggleProduct(int $id, Request $request, Connection $db, CatalogRepository $catalog): RedirectResponse
{
$catalog->ensureSchema();
$field = (string) $request->request->get('field', '');
if (!in_array($field, ['is_active', 'in_stock'], true)) {
$this->addFlash('warning', 'Нельзя изменить это поле.');
return $this->redirectToRoute('admin_dashboard');
}
$value = (int) $db->fetchOne("SELECT {$field} FROM catalog_products WHERE id = ? LIMIT 1", [$id]);
$db->update('catalog_products', [
$field => $value ? 0 : 1,
'updated_at' => $this->now(),
], ['id' => $id]);
$this->addFlash('success', $field === 'is_active' ? 'Видимость обновлена.' : 'Наличие обновлено.');
return $this->redirect($request->headers->get('referer') ?: $this->generateUrl('admin_dashboard'));
}
/**
* @Route("/admin/sync/galaxylink", name="admin_sync_galaxylink", methods={"POST"})
*/
public function syncGalaxyLink(GalaxyLinkCatalogProvider $provider): RedirectResponse
{
@set_time_limit(1200);
@ini_set('memory_limit', '512M');
try {
$giftResult = $provider->sync([
'types' => [GalaxyLinkCatalogProvider::KEY_GIFT_TYPE],
'categories' => ['STEAM_GIFTS'],
'rows' => 500,
'region' => 'RU',
'limit_products' => 0,
'lang' => 'russian',
'cc' => 'us',
'image_limit' => 0,
'enrich_steam' => false,
'dry_run' => false,
]);
$keyCategories = array_values(array_filter(
GalaxyLinkCatalogProvider::STEAM_CATALOG_CATEGORIES,
function (string $category): bool {
return $category !== 'STEAM_GIFTS';
}
));
$keyResult = $provider->sync([
'types' => [GalaxyLinkCatalogProvider::KEY_GIFT_TYPE],
'categories' => $keyCategories,
'rows' => 500,
'region' => 'RU',
'limit_products' => 0,
'lang' => 'russian',
'cc' => 'us',
'image_limit' => 0,
'enrich_steam' => false,
'dry_run' => false,
]);
$warnings = array_merge($giftResult->warnings, $keyResult->warnings);
$errors = array_merge($giftResult->errors, $keyResult->errors);
$message = sprintf(
'GalaxyLink синхронизирован: Steam Gifts %d, Steam Keys %d, категорий %d, предупреждений %d.',
$giftResult->products,
$keyResult->products,
$giftResult->categories + $keyResult->categories,
count($warnings)
);
$this->addFlash(count($errors) > 0 ? 'warning' : 'success', $message);
} catch (\Throwable $error) {
$this->addFlash('danger', 'Ошибка синхронизации GalaxyLink: ' . $error->getMessage());
}
return $this->redirectToRoute('admin_dashboard');
}
/**
* @Route("/admin/sync/steam-content", name="admin_sync_steam_content", methods={"POST"})
*/
public function syncSteamContent(Request $request, CatalogRepository $catalog, CatalogSteamEnricher $enricher): RedirectResponse
{
@set_time_limit(1200);
@ini_set('memory_limit', '512M');
$catalog->ensureSchema();
try {
$report = $enricher->enrichMissing([
'lang' => 'russian',
'cc' => 'us',
'image_limit' => 0,
'limit' => 300,
'batch_size' => 80,
]);
if ($request->hasSession()) {
$request->getSession()->set('admin_steam_enrichment_report', $report);
}
$message = sprintf(
'Steam-дозаполнение завершено: обновлено товаров %d, игровых групп %d, не получилось %d.',
(int) $report['updated_products'],
(int) $report['updated_games'],
(int) $report['failed_count']
);
$this->addFlash((int) $report['failed_count'] > 0 ? 'warning' : 'success', $message);
} catch (\Throwable $error) {
$this->addFlash('danger', 'Ошибка Steam-дозаполнения: ' . $error->getMessage());
}
return $this->redirectToRoute('admin_dashboard');
}
/**
* @Route("/admin/sync/steam-content/background/start", name="admin_sync_steam_background_start", methods={"POST"})
*/
public function startSteamBackgroundSync(CatalogRepository $catalog): RedirectResponse
{
$catalog->ensureSchema();
$status = $this->steamBackgroundStatus();
if (!empty($status['running'])) {
$this->addFlash('warning', 'Фоновое Steam-дозаполнение уже идет.');
return $this->redirectToRoute('admin_dashboard');
}
try {
$paths = $this->steamBackgroundPaths();
if (!is_dir($paths['dir'])) {
@mkdir($paths['dir'], 0775, true);
}
if (is_file($paths['stop'])) {
@unlink($paths['stop']);
}
$projectDir = (string) $this->getParameter('kernel.project_dir');
$arguments = [
'/usr/bin/env',
'APP_ENV=prod',
'APP_DEBUG=0',
PHP_BINARY,
'-d',
'memory_limit=512M',
$projectDir . '/bin/console',
'--env=prod',
'--no-debug',
'app:catalog:enrich-steam',
'--continuous',
'--limit=300',
'--batch-size=80',
'--lang=russian',
'--cc=us',
'--image-limit=0',
'--status-file=' . $paths['status'],
'--stop-file=' . $paths['stop'],
'--rate-limit-sleep=90',
];
$command = implode(' ', array_map('escapeshellarg', $arguments)) . ' >> ' . escapeshellarg($paths['log']) . ' 2>&1 & echo $!';
$output = [];
$exitCode = 0;
exec($command, $output, $exitCode);
$pid = isset($output[0]) ? (int) trim((string) $output[0]) : 0;
if ($exitCode !== 0 || $pid <= 0) {
throw new \RuntimeException('Не удалось запустить фоновый процесс Steam-дозаполнения.');
}
$now = $this->nowIso();
$this->writeSteamBackgroundStatus([
'state' => 'running',
'running' => true,
'requested_stop' => false,
'pid' => $pid,
'started_at' => $now,
'updated_at' => $now,
'finished_at' => null,
'pass' => 0,
'message' => 'Фоновое Steam-дозаполнение запускается.',
'totals' => $this->emptySteamBackgroundReport(),
'last_report' => $this->emptySteamBackgroundReport(),
]);
$this->addFlash('success', 'Фоновое Steam-дозаполнение запущено. Страницу можно закрыть.');
} catch (\Throwable $error) {
$this->addFlash('danger', 'Не удалось запустить фоновое Steam-дозаполнение: ' . $error->getMessage());
}
return $this->redirectToRoute('admin_dashboard');
}
/**
* @Route("/admin/sync/steam-content/background/stop", name="admin_sync_steam_background_stop", methods={"POST"})
*/
public function stopSteamBackgroundSync(): RedirectResponse
{
$status = $this->steamBackgroundStatus();
if (empty($status['running'])) {
$this->addFlash('warning', 'Фоновое Steam-дозаполнение сейчас не запущено.');
return $this->redirectToRoute('admin_dashboard');
}
$paths = $this->steamBackgroundPaths();
if (!is_dir($paths['dir'])) {
@mkdir($paths['dir'], 0775, true);
}
file_put_contents($paths['stop'], $this->nowIso(), LOCK_EX);
$status['state'] = 'stopping';
$status['requested_stop'] = true;
$status['message'] = 'Остановка запрошена. Процесс завершит текущую операцию и остановится.';
$status['updated_at'] = $this->nowIso();
$this->writeSteamBackgroundStatus($status);
$this->addFlash('success', 'Остановка фонового Steam-дозаполнения запрошена.');
return $this->redirectToRoute('admin_dashboard');
}
/**
* @Route("/admin/sync/steam-content/background/status", name="admin_sync_steam_background_status", methods={"GET"})
*/
public function steamBackgroundSyncStatus(): JsonResponse
{
return new JsonResponse($this->steamBackgroundStatus());
}
/**
* @Route("/admin/references/sources/save", name="admin_source_save", methods={"POST"})
*/
public function saveSource(Request $request, Connection $db, CatalogRepository $catalog): RedirectResponse
{
$catalog->ensureSchema();
try {
$this->persistSource($request, $db);
$this->addFlash('success', 'Источник сохранен.');
} catch (\Throwable $error) {
$this->addFlash('danger', $error->getMessage());
}
return $this->redirectToRoute('admin_references');
}
/**
* @Route("/admin/references/sources/{id}/toggle", name="admin_source_toggle", methods={"POST"}, requirements={"id"="\d+"})
*/
public function toggleSource(int $id, Connection $db, CatalogRepository $catalog): RedirectResponse
{
$catalog->ensureSchema();
$value = $db->fetchOne('SELECT enabled FROM catalog_sources WHERE id = ? LIMIT 1', [$id]);
if ($value === false) {
$this->addFlash('warning', 'Источник не найден.');
return $this->redirectToRoute('admin_references');
}
$db->update('catalog_sources', [
'enabled' => (int) $value ? 0 : 1,
'updated_at' => $this->now(),
], ['id' => $id]);
$this->addFlash('success', 'Статус источника обновлен.');
return $this->redirectToRoute('admin_references');
}
/**
* @Route("/admin/references/categories/save", name="admin_category_save", methods={"POST"})
*/
public function saveCategory(Request $request, Connection $db, CatalogRepository $catalog): RedirectResponse
{
$catalog->ensureSchema();
try {
$this->persistCategory($request, $db);
$this->addFlash('success', 'Категория сохранена.');
} catch (\Throwable $error) {
$this->addFlash('danger', $error->getMessage());
}
return $this->redirectToRoute('admin_references');
}
/**
* @Route("/admin/references/categories/{id}/toggle", name="admin_category_toggle", methods={"POST"}, requirements={"id"="\d+"})
*/
public function toggleCategory(int $id, Connection $db, CatalogRepository $catalog): RedirectResponse
{
$catalog->ensureSchema();
$value = $db->fetchOne('SELECT is_active FROM catalog_categories WHERE id = ? LIMIT 1', [$id]);
if ($value === false) {
$this->addFlash('warning', 'Категория не найдена.');
return $this->redirectToRoute('admin_references');
}
$db->update('catalog_categories', [
'is_active' => (int) $value ? 0 : 1,
'updated_at' => $this->now(),
], ['id' => $id]);
$this->addFlash('success', 'Статус категории обновлен.');
return $this->redirectToRoute('admin_references');
}
private function adminView(Request $request, Connection $db, array $params): array
{
try {
$user = $this->getAuthorizedUser($request, $db);
} catch (\Throwable $error) {
$user = null;
}
$params['admin_user'] = [
'id' => $user ? (int) $user['id'] : 0,
'login' => $user ? (string) $user['login'] : 'Admin',
'img' => $user && !empty($user['img']) ? (string) $user['img'] : '/assets/profile.svg',
];
return $params;
}
private function steamBackgroundPaths(): array
{
$projectDir = rtrim((string) $this->getParameter('kernel.project_dir'), DIRECTORY_SEPARATOR);
$dir = $projectDir . '/var/admin-steam-enrichment';
return [
'dir' => $dir,
'status' => $dir . '/status.json',
'stop' => $dir . '/stop.flag',
'log' => $dir . '/steam-enrichment.log',
];
}
private function steamBackgroundStatus(): array
{
$paths = $this->steamBackgroundPaths();
$status = $this->readSteamBackgroundStatus($paths['status']);
$state = (string) ($status['state'] ?? 'idle');
$pid = (int) ($status['pid'] ?? 0);
$running = $pid > 0 && $this->processIsRunning($pid);
$requestedStop = is_file($paths['stop']);
if (in_array($state, ['running', 'stopping'], true) && !$running) {
$state = $requestedStop ? 'stopped' : 'error';
$status['state'] = $state;
$status['running'] = false;
$status['finished_at'] = $status['finished_at'] ?? $this->nowIso();
$status['updated_at'] = $this->nowIso();
if (empty($status['message']) || $state === 'error') {
$status['message'] = $state === 'error'
? 'Фоновый процесс завершился без финального статуса. Проверьте лог.'
: 'Фоновое Steam-дозаполнение остановлено.';
}
$this->writeSteamBackgroundStatus($status);
}
$status = array_merge([
'state' => 'idle',
'running' => false,
'requested_stop' => false,
'pid' => 0,
'started_at' => null,
'updated_at' => null,
'finished_at' => null,
'pass' => 0,
'message' => 'Фоновое Steam-дозаполнение ещё не запускалось.',
'totals' => $this->emptySteamBackgroundReport(),
'last_report' => $this->emptySteamBackgroundReport(),
], $status);
$status['state'] = $state;
$status['running'] = $running && in_array($state, ['running', 'stopping'], true);
$status['requested_stop'] = $requestedStop;
$status['state_label'] = $this->steamBackgroundStateLabel($state);
$status['state_class'] = $this->steamBackgroundStateClass($state);
$status['failed'] = array_slice((array) ($status['totals']['failed'] ?? []), 0, 20);
$status['failed_truncated'] = max(
(int) ($status['totals']['failed_truncated'] ?? 0),
max(0, (int) ($status['totals']['failed_count'] ?? 0) - count((array) ($status['totals']['failed'] ?? [])))
);
return $status;
}
private function readSteamBackgroundStatus(string $path): array
{
if (!is_file($path)) {
return [];
}
$contents = file_get_contents($path);
if (!is_string($contents) || trim($contents) === '') {
return [];
}
$decoded = json_decode($contents, true);
return is_array($decoded) ? $decoded : [];
}
private function writeSteamBackgroundStatus(array $status): void
{
$paths = $this->steamBackgroundPaths();
if (!is_dir($paths['dir'])) {
@mkdir($paths['dir'], 0775, true);
}
$json = json_encode($status, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
if ($json === false) {
return;
}
file_put_contents($paths['status'], $json, LOCK_EX);
}
private function emptySteamBackgroundReport(): array
{
return [
'scanned_products' => 0,
'scanned_games' => 0,
'updated_products' => 0,
'updated_games' => 0,
'failed_count' => 0,
'failed' => [],
'failed_ids' => [],
'failed_truncated' => 0,
'stopped' => false,
];
}
private function processIsRunning(int $pid): bool
{
if ($pid <= 0) {
return false;
}
if (function_exists('posix_kill')) {
return @posix_kill($pid, 0);
}
$exitCode = 1;
exec('kill -0 ' . (int) $pid . ' 2>/dev/null', $output, $exitCode);
return $exitCode === 0;
}
private function steamBackgroundStateLabel(string $state): string
{
switch ($state) {
case 'running':
return 'Идёт';
case 'stopping':
return 'Останавливается';
case 'completed':
return 'Завершено';
case 'stopped':
return 'Остановлено';
case 'error':
return 'Ошибка';
default:
return 'Ожидает';
}
}
private function steamBackgroundStateClass(string $state): string
{
switch ($state) {
case 'running':
return 'primary';
case 'stopping':
return 'warning';
case 'completed':
return 'success';
case 'stopped':
return 'secondary';
case 'error':
return 'danger';
default:
return 'secondary';
}
}
private function isSteamCatalogCategory(string $category): bool
{
$category = strtoupper(trim($category));
return $category === 'STEAM_GIFTS' || $category === 'OTHERS' || strpos($category, 'STEAM_KEYS') === 0;
}
private function dashboardStats(Connection $db): array
{
$rows = $db->fetchAllAssociative('SELECT id, name, provider, provider_category_code, appid, is_active, in_stock, is_manual, discount_percent, price_amount, price_currency FROM catalog_products');
$groups = [];
$delivery = [
'gifts' => 0,
'keys' => 0,
'other' => 0,
];
$active = 0;
$stock = 0;
$manual = 0;
$discounted = 0;
$sumUsd = 0.0;
foreach ($rows as $row) {
$category = strtoupper((string) ($row['provider_category_code'] ?? ''));
$groups[$this->productStatsGroupKey($row)] = true;
if (!empty($row['is_active'])) {
$active++;
}
if (!empty($row['in_stock'])) {
$stock++;
}
if (!empty($row['is_manual'])) {
$manual++;
}
if ((float) ($row['discount_percent'] ?? 0) > 0) {
$discounted++;
}
if ((string) ($row['price_currency'] ?? '') === 'USD') {
$sumUsd += (float) ($row['price_amount'] ?? 0);
}
if (strpos($category, 'GIFT') !== false) {
$delivery['gifts']++;
} elseif (strpos($category, 'KEY') !== false || $category === 'OTHERS') {
$delivery['keys']++;
} else {
$delivery['other']++;
}
}
return [
'raw_products' => count($rows),
'catalog_products' => count($groups),
'active_products' => $active,
'in_stock' => $stock,
'manual_products' => $manual,
'discounted_products' => $discounted,
'sources' => (int) $db->fetchOne('SELECT COUNT(*) FROM catalog_sources'),
'categories' => (int) $db->fetchOne('SELECT COUNT(*) FROM catalog_categories'),
'users' => $this->safeCount($db, 'users'),
'usd_inventory' => $this->formatMoney($sumUsd, 'USD'),
'delivery' => $delivery,
];
}
private function fetchProducts(Connection $db, array $filters, int $perPage): array
{
$where = ['1 = 1'];
$params = [];
if ($filters['q'] !== '') {
$where[] = '(LOWER(p.name) LIKE ? OR LOWER(p.external_code) LIKE ? OR p.appid LIKE ?)';
$needle = '%' . strtolower($filters['q']) . '%';
$params[] = $needle;
$params[] = $needle;
$params[] = '%' . $filters['q'] . '%';
}
if ($filters['source'] !== '') {
$where[] = 'p.source_id = ?';
$params[] = (int) $filters['source'];
}
if ($filters['category'] !== '') {
$where[] = 'p.category_id = ?';
$params[] = (int) $filters['category'];
}
if ($filters['active'] !== '') {
$where[] = 'p.is_active = ?';
$params[] = (int) $filters['active'];
}
if ($filters['stock'] !== '') {
$where[] = 'p.in_stock = ?';
$params[] = (int) $filters['stock'];
}
if ($filters['discount'] === 'yes') {
$where[] = 'p.discount_percent IS NOT NULL AND p.discount_percent > 0';
} elseif ($filters['discount'] === 'no') {
$where[] = '(p.discount_percent IS NULL OR p.discount_percent <= 0)';
}
$whereSql = implode(' AND ', $where);
$total = (int) $db->fetchOne(
"SELECT COUNT(*) FROM catalog_products p
LEFT JOIN catalog_categories c ON c.id = p.category_id
LEFT JOIN catalog_sources s ON s.id = p.source_id
WHERE {$whereSql}",
$params
);
$page = max(1, (int) $filters['page']);
$totalPages = max(1, (int) ceil($total / $perPage));
$page = min($page, $totalPages);
$offset = ($page - 1) * $perPage;
$rows = $db->fetchAllAssociative(
"SELECT p.*, c.name AS category_name, c.type_code AS category_type_code, c.external_code AS category_external_code, s.code AS source_code, s.name AS source_name
FROM catalog_products p
LEFT JOIN catalog_categories c ON c.id = p.category_id
LEFT JOIN catalog_sources s ON s.id = p.source_id
WHERE {$whereSql}
ORDER BY p.updated_at DESC, p.id DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return [
'items' => array_map([$this, 'decorateProduct'], $rows),
'pagination' => [
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'total_pages' => $totalPages,
'from' => $total === 0 ? 0 : $offset + 1,
'to' => min($offset + $perPage, $total),
],
];
}
private function fetchProduct(Connection $db, int $id): ?array
{
$row = $db->fetchAssociative(
'SELECT p.*, c.name AS category_name, c.type_code AS category_type_code, c.external_code AS category_external_code, s.code AS source_code, s.name AS source_name
FROM catalog_products p
LEFT JOIN catalog_categories c ON c.id = p.category_id
LEFT JOIN catalog_sources s ON s.id = p.source_id
WHERE p.id = ? LIMIT 1',
[$id]
);
return $row ?: null;
}
private function fetchProductVariants(Connection $db, array $product): array
{
$appid = trim((string) ($product['appid'] ?? ''));
if ($appid !== '') {
$rows = $db->fetchAllAssociative(
'SELECT p.*, c.name AS category_name, s.code AS source_code
FROM catalog_products p
LEFT JOIN catalog_categories c ON c.id = p.category_id
LEFT JOIN catalog_sources s ON s.id = p.source_id
WHERE p.appid = ? AND p.id <> ?
ORDER BY p.in_stock DESC, p.price_amount ASC, p.id ASC',
[$appid, (int) $product['id']]
);
return array_map([$this, 'decorateProduct'], $rows);
}
$category = (string) ($product['provider_category_code'] ?? $product['category_external_code'] ?? '');
$titleKey = $this->steamTitleGroupKey((string) ($product['name'] ?? ''));
if (!$this->isSteamCatalogCategory($category) || $titleKey === '') {
return [];
}
$rows = $db->fetchAllAssociative(
'SELECT p.*, c.name AS category_name, s.code AS source_code
FROM catalog_products p
LEFT JOIN catalog_categories c ON c.id = p.category_id
LEFT JOIN catalog_sources s ON s.id = p.source_id
WHERE LOWER(TRIM(p.name)) = ? AND p.id <> ?
AND (p.provider_category_code = ? OR p.provider_category_code = ? OR p.provider_category_code LIKE ?)
ORDER BY p.in_stock DESC, p.price_amount ASC, p.id ASC',
[$titleKey, (int) $product['id'], 'STEAM_GIFTS', 'OTHERS', 'STEAM_KEYS%']
);
return array_map([$this, 'decorateProduct'], $rows);
}
private function fetchSources(Connection $db): array
{
return $db->fetchAllAssociative(
'SELECT s.*, COALESCE(pc.products_count, 0) AS products_count
FROM catalog_sources s
LEFT JOIN (
SELECT source_id, COUNT(*) AS products_count
FROM catalog_products
GROUP BY source_id
) pc ON pc.source_id = s.id
ORDER BY s.name ASC'
);
}
private function fetchCategories(Connection $db): array
{
return $db->fetchAllAssociative(
'SELECT c.*, s.name AS source_name, COALESCE(pc.products_count, 0) AS products_count
FROM catalog_categories c
LEFT JOIN catalog_sources s ON s.id = c.source_id
LEFT JOIN (
SELECT category_id, COUNT(*) AS products_count
FROM catalog_products
GROUP BY category_id
) pc ON pc.category_id = c.id
ORDER BY c.name ASC'
);
}
private function fetchUsers(Connection $db, array $filters, int $perPage): array
{
$where = ['1 = 1'];
$params = [];
if ($filters['q'] !== '') {
$where[] = '(LOWER(login) LIKE ? OR LOWER(email) LIKE ? OR LOWER(hash) LIKE ? OR ip LIKE ?)';
$needle = '%' . strtolower($filters['q']) . '%';
$params[] = $needle;
$params[] = $needle;
$params[] = $needle;
$params[] = '%' . $filters['q'] . '%';
}
$whereSql = implode(' AND ', $where);
$total = (int) $db->fetchOne("SELECT COUNT(*) FROM users WHERE {$whereSql}", $params);
$page = max(1, (int) $filters['page']);
$totalPages = max(1, (int) ceil($total / $perPage));
$page = min($page, $totalPages);
$offset = ($page - 1) * $perPage;
$rows = $db->fetchAllAssociative(
"SELECT id, login, email, balance, hash, img, auth_token_hash, ip, ref_id, refs, created_at
FROM users
WHERE {$whereSql}
ORDER BY id DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return [
'items' => array_map([$this, 'decorateUser'], $rows),
'pagination' => [
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'total_pages' => $totalPages,
'from' => $total === 0 ? 0 : $offset + 1,
'to' => min($offset + $perPage, $total),
],
];
}
private function fetchUser(Connection $db, int $id): ?array
{
$row = $db->fetchAssociative(
'SELECT id, login, email, balance, hash, img, auth_token_hash, ip, ref_id, refs, created_at
FROM users
WHERE id = ? LIMIT 1',
[$id]
);
return $row ?: null;
}
private function saveProduct(Request $request, Connection $db, ?int $id): int
{
$data = $request->request;
$now = $this->now();
$price = $this->nullableNumber($data->get('price_amount'));
$discount = $this->normalizeDiscount($data->get('discount_percent'));
$galleryJson = trim((string) $data->get('gallery_json', ''));
$formsJson = trim((string) $data->get('forms_json', ''));
$metadataJson = trim((string) $data->get('metadata_json', ''));
$this->assertJson($galleryJson, 'Галерея');
$this->assertJson($formsJson, 'Формы');
$this->assertJson($metadataJson, 'Метаданные');
$sourceId = $this->nullableInt($data->get('source_id'));
if ($sourceId === null) {
$sourceId = $this->ensureManualSource($db);
}
$categoryId = $this->nullableInt($data->get('category_id'));
if ($categoryId === null) {
$categoryId = $this->ensureManualCategory($db, $sourceId);
}
$payload = [
'source_id' => $sourceId,
'category_id' => $categoryId,
'provider' => trim((string) $data->get('provider', 'manual')) ?: 'manual',
'provider_type' => trim((string) $data->get('provider_type', 'manual')) ?: null,
'provider_category_code' => trim((string) $data->get('provider_category_code', 'MANUAL')) ?: null,
'name' => trim((string) $data->get('name', 'Товар PlayPapa')),
'short_description_html' => trim((string) $data->get('short_description_html', '')) ?: null,
'description_html' => trim((string) $data->get('description_html', '')) ?: null,
'image_url' => trim((string) $data->get('image_url', '')) ?: null,
'gallery_json' => $galleryJson !== '' ? $galleryJson : '[]',
'price_amount' => $price,
'price_currency' => strtoupper(trim((string) $data->get('price_currency', 'USD'))) ?: 'USD',
'region' => strtoupper(trim((string) $data->get('region', ''))) ?: null,
'appid' => trim((string) $data->get('appid', '')) ?: null,
'in_stock' => $data->has('in_stock') ? 1 : 0,
'stock_quantity' => $this->nullableInt($data->get('stock_quantity')),
'promo' => $discount !== null ? 1 : 0,
'discount_percent' => $discount,
'forms_json' => $formsJson !== '' ? $formsJson : '[]',
'metadata_json' => $metadataJson !== '' ? $metadataJson : '[]',
'is_active' => $data->has('is_active') ? 1 : 0,
'updated_at' => $now,
];
if ($id !== null) {
$db->update('catalog_products', $payload, ['id' => $id]);
return $id;
}
$externalCode = trim((string) $data->get('external_code', ''));
if ($externalCode === '') {
$externalCode = 'manual_' . strtolower(bin2hex(random_bytes(5)));
}
$payload['external_code'] = $externalCode;
$payload['slug'] = $this->uniqueSlug($db, (string) $payload['name'] . '-' . $externalCode);
$payload['raw_json'] = '[]';
$payload['is_manual'] = 1;
$payload['is_active'] = $payload['is_active'];
$payload['last_synced_at'] = null;
$payload['created_at'] = $now;
$db->insert('catalog_products', $payload);
return (int) $db->lastInsertId();
}
private function saveUser(Request $request, Connection $db, int $id): void
{
$data = $request->request;
$login = trim((string) $data->get('login', ''));
$email = trim((string) $data->get('email', ''));
$password = trim((string) $data->get('password', ''));
if (mb_strlen($login) < 3 || mb_strlen($login) > 50) {
throw new \InvalidArgumentException('Логин должен быть от 3 до 50 символов.');
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Некорректный e-mail.');
}
if ($password !== '' && mb_strlen($password) < 6) {
throw new \InvalidArgumentException('Новый пароль должен быть не короче 6 символов.');
}
$duplicate = $db->fetchOne(
'SELECT id FROM users WHERE (login = ? OR email = ?) AND id <> ? LIMIT 1',
[$login, $email, $id]
);
if ($duplicate) {
throw new \InvalidArgumentException('Такой логин или e-mail уже занят.');
}
$payload = [
'login' => $login,
'email' => $email,
'balance' => $this->nullableNumber($data->get('balance')) ?? 0,
'img' => trim((string) $data->get('img', '')) ?: '/assets/profile.svg',
'ip' => trim((string) $data->get('ip', '')),
'ref_id' => $this->nullableInt($data->get('ref_id')) ?? 0,
'refs' => $this->nullableInt($data->get('refs')) ?? 0,
];
if ($password !== '') {
$payload['password_hash'] = password_hash($password, PASSWORD_DEFAULT);
}
if ($data->has('clear_session')) {
$payload['auth_token_hash'] = null;
}
$db->update('users', $payload, ['id' => $id]);
}
private function persistSource(Request $request, Connection $db): void
{
$data = $request->request;
$id = $this->nullableInt($data->get('id'));
$code = strtolower(trim((string) $data->get('code', '')));
$code = trim((string) preg_replace('/[^a-z0-9_-]+/', '-', $code), '-');
$name = trim((string) $data->get('name', ''));
$provider = trim((string) $data->get('provider', ''));
$settingsJson = trim((string) $data->get('settings_json', ''));
if ($code === '') {
$code = $this->slugify($name ?: $provider ?: 'source');
}
if ($name === '' || $provider === '') {
throw new \InvalidArgumentException('У источника должны быть название и provider.');
}
$this->assertJson($settingsJson, 'Настройки источника');
$duplicate = $db->fetchAssociative('SELECT id FROM catalog_sources WHERE code = ? LIMIT 1', [$code]);
if ($duplicate && (!$id || (int) $duplicate['id'] !== $id)) {
throw new \InvalidArgumentException('Источник с таким code уже существует.');
}
$payload = [
'code' => $code,
'name' => $name,
'provider' => $provider,
'enabled' => $data->has('enabled') ? 1 : 0,
'settings_json' => $settingsJson !== '' ? $settingsJson : '[]',
'updated_at' => $this->now(),
];
if ($id) {
$db->update('catalog_sources', $payload, ['id' => $id]);
return;
}
$payload['created_at'] = $this->now();
$db->insert('catalog_sources', $payload);
}
private function persistCategory(Request $request, Connection $db): void
{
$data = $request->request;
$id = $this->nullableInt($data->get('id'));
$name = trim((string) $data->get('name', ''));
$sourceId = $this->nullableInt($data->get('source_id'));
$typeCode = trim((string) $data->get('type_code', '')) ?: 'manual';
$externalCode = trim((string) $data->get('external_code', '')) ?: strtoupper($this->slugify($name ?: 'manual'));
$slugInput = trim((string) $data->get('slug', ''));
if ($name === '') {
throw new \InvalidArgumentException('У категории должно быть название.');
}
$existing = $id ? $db->fetchAssociative('SELECT * FROM catalog_categories WHERE id = ? LIMIT 1', [$id]) : null;
$slug = $slugInput !== '' ? $this->slugify($slugInput) : $this->slugify($name . '-' . $typeCode . '-' . $externalCode);
if (!$existing || $slug !== (string) $existing['slug']) {
$slug = $this->uniqueSlugFor($db, $slug, null, $id);
}
$payload = [
'source_id' => $sourceId,
'type_code' => $typeCode,
'external_code' => $externalCode,
'name' => $name,
'slug' => $slug,
'description_html' => trim((string) $data->get('description_html', '')) ?: null,
'image_url' => trim((string) $data->get('image_url', '')) ?: null,
'is_active' => $data->has('is_active') ? 1 : 0,
'updated_at' => $this->now(),
];
if ($id) {
$db->update('catalog_categories', $payload, ['id' => $id]);
return;
}
$payload['raw_json'] = '[]';
$payload['is_manual'] = 1;
$payload['last_synced_at'] = null;
$payload['created_at'] = $this->now();
$db->insert('catalog_categories', $payload);
}
private function decorateProduct(array $row): array
{
$price = $row['price_amount'] !== null ? (float) $row['price_amount'] : null;
$discount = $this->normalizeDiscount($row['discount_percent'] ?? null);
$row['price_label'] = $this->formatMoney($price, (string) ($row['price_currency'] ?? 'USD'));
$row['discount_label'] = $discount === null ? '' : '−' . rtrim(rtrim(number_format($discount, 2, ',', ' '), '0'), ',') . '%';
$row['delivery_label'] = $this->deliveryLabel($row);
$row['forms_count'] = count($this->decodeJson((string) ($row['forms_json'] ?? '[]')));
$row['gallery_count'] = count($this->decodeJson((string) ($row['gallery_json'] ?? '[]')));
$row['product_url'] = '/product?slug=' . rawurlencode((string) ($row['slug'] ?? ''));
return $row;
}
private function decorateUser(array $row): array
{
$balance = (float) ($row['balance'] ?? 0);
$img = trim((string) ($row['img'] ?? ''));
$row['img_url'] = $img !== '' ? $img : '/assets/profile.svg';
$row['balance_label'] = number_format($balance, 2, ',', ' ');
$row['has_session'] = !empty($row['auth_token_hash']);
return $row;
}
private function deliveryLabel(array $row): string
{
$forms = array_map('strtolower', array_map('strval', $this->decodeJson((string) ($row['forms_json'] ?? '[]'))));
$category = strtoupper((string) ($row['provider_category_code'] ?? $row['category_external_code'] ?? ''));
$categoryName = strtoupper((string) ($row['category_name'] ?? ''));
$text = $category . ' ' . $categoryName;
if (in_array('invite_url', $forms, true) || strpos($text, 'GIFT') !== false) {
return 'Подарок Steam';
}
if (strpos($text, 'STEAM') !== false && strpos($text, 'KEY') !== false) {
return 'Ключ Steam';
}
if (strpos($text, 'KEY') !== false) {
return 'Цифровой ключ';
}
if (strpos($text, 'TOPUP') !== false || strpos($text, 'WALLET') !== false) {
return 'Пополнение';
}
return 'Цифровой товар';
}
private function productStatsGroupKey(array $row): string
{
$category = (string) ($row['provider_category_code'] ?? '');
if (!$this->isSteamCatalogCategory($category)) {
return 'product:' . (string) $row['id'];
}
$appid = trim((string) ($row['appid'] ?? ''));
if ($appid !== '') {
return 'steam:' . $appid;
}
$titleKey = $this->steamTitleGroupKey((string) ($row['name'] ?? ''));
if ($titleKey !== '') {
return 'steam-title:' . $titleKey;
}
return 'product:' . (string) $row['id'];
}
private function steamTitleGroupKey(string $name): string
{
$name = html_entity_decode($name, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$name = trim((string) preg_replace('/\s+/', ' ', $name));
return $name === '' ? '' : mb_strtolower($name, 'UTF-8');
}
private function ensureManualSource(Connection $db): int
{
$existing = $db->fetchOne('SELECT id FROM catalog_sources WHERE code = ? LIMIT 1', ['manual']);
if ($existing) {
return (int) $existing;
}
$now = $this->now();
$db->insert('catalog_sources', [
'code' => 'manual',
'name' => 'Manual',
'provider' => 'manual',
'enabled' => 1,
'settings_json' => '[]',
'created_at' => $now,
'updated_at' => $now,
]);
return (int) $db->lastInsertId();
}
private function ensureManualCategory(Connection $db, int $sourceId): int
{
$existing = $db->fetchOne('SELECT id FROM catalog_categories WHERE source_id = ? AND external_code = ? LIMIT 1', [$sourceId, 'MANUAL']);
if ($existing) {
return (int) $existing;
}
$now = $this->now();
$db->insert('catalog_categories', [
'source_id' => $sourceId,
'type_code' => 'manual',
'external_code' => 'MANUAL',
'name' => 'Ручные товары',
'slug' => $this->uniqueSlug($db, 'manual-products'),
'description_html' => null,
'image_url' => null,
'raw_json' => '[]',
'is_manual' => 1,
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
return (int) $db->lastInsertId();
}
private function uniqueSlug(Connection $db, string $base): string
{
return $this->uniqueSlugFor($db, $base, null, null);
}
private function uniqueSlugFor(Connection $db, string $base, ?int $ignoreProductId, ?int $ignoreCategoryId): string
{
$slug = $this->slugify($base);
$candidate = $slug;
$index = 2;
while ($this->slugExists($db, $candidate, $ignoreProductId, $ignoreCategoryId)) {
$candidate = substr($slug, 0, 245) . '-' . $index;
$index++;
}
return $candidate;
}
private function slugExists(Connection $db, string $slug, ?int $ignoreProductId, ?int $ignoreCategoryId): bool
{
$productSql = 'SELECT 1 FROM catalog_products WHERE slug = ?';
$productParams = [$slug];
if ($ignoreProductId !== null) {
$productSql .= ' AND id <> ?';
$productParams[] = $ignoreProductId;
}
if ($db->fetchOne($productSql . ' LIMIT 1', $productParams)) {
return true;
}
$categorySql = 'SELECT 1 FROM catalog_categories WHERE slug = ?';
$categoryParams = [$slug];
if ($ignoreCategoryId !== null) {
$categorySql .= ' AND id <> ?';
$categoryParams[] = $ignoreCategoryId;
}
return (bool) $db->fetchOne($categorySql . ' LIMIT 1', $categoryParams);
}
private function slugify(string $value): string
{
$converted = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
$value = is_string($converted) && $converted !== '' ? $converted : $value;
$value = strtolower($value);
$value = preg_replace('/[^a-z0-9]+/', '-', $value);
$value = trim((string) $value, '-');
return $value === '' ? 'item' : substr($value, 0, 250);
}
private function nullableNumber($value): ?float
{
$value = str_replace(',', '.', trim((string) $value));
if ($value === '') {
return null;
}
return round((float) $value, 2);
}
private function nullableInt($value): ?int
{
$value = trim((string) $value);
return $value === '' ? null : (int) $value;
}
private function normalizeDiscount($value): ?float
{
$value = str_replace(',', '.', trim((string) $value));
if ($value === '') {
return null;
}
$discount = (float) $value;
if ($discount <= 0) {
return null;
}
return min(100.0, round($discount, 2));
}
private function formatMoney(?float $amount, string $currency): string
{
if ($amount === null) {
return 'Цена уточняется';
}
$suffix = $currency === 'RUB' ? ' ₽' : ($currency === 'USD' ? ' $' : ' ' . $currency);
return number_format($amount, 2, ',', ' ') . $suffix;
}
private function assertJson(string $value, string $label): void
{
if ($value === '') {
return;
}
json_decode($value, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \InvalidArgumentException($label . ': некорректный JSON.');
}
}
private function decodeJson(string $json): array
{
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : [];
}
private function safeCount(Connection $db, string $table): int
{
try {
return (int) $db->fetchOne('SELECT COUNT(*) FROM ' . $table);
} catch (\Throwable $error) {
return 0;
}
}
private function ensureUsersSchema(Connection $db): void
{
$platform = $db->getDatabasePlatform()->getName();
if (strpos($platform, 'mysql') !== false) {
$db->executeStatement(
"CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT NOT NULL,
login VARCHAR(50) NOT NULL,
email VARCHAR(180) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
balance DECIMAL(10, 2) NOT NULL DEFAULT 0,
hash VARCHAR(50) NOT NULL,
img VARCHAR(512) NOT NULL DEFAULT '/assets/profile.svg',
auth_token VARCHAR(225) DEFAULT NULL,
auth_token_hash VARCHAR(64) DEFAULT NULL,
ip VARCHAR(50) DEFAULT '',
ref_id INT NOT NULL DEFAULT 0,
refs INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX uniq_users_login (login),
UNIQUE INDEX uniq_users_email (email),
UNIQUE INDEX uniq_users_hash (hash),
UNIQUE INDEX uniq_users_auth_token_hash (auth_token_hash),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB"
);
return;
}
$db->executeStatement(
"CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
login VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(180) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
balance NUMERIC NOT NULL DEFAULT 0,
hash VARCHAR(50) NOT NULL UNIQUE,
img VARCHAR(512) NOT NULL DEFAULT '/assets/profile.svg',
auth_token VARCHAR(225) DEFAULT NULL,
auth_token_hash VARCHAR(64) DEFAULT NULL UNIQUE,
ip VARCHAR(50) DEFAULT '',
ref_id INTEGER NOT NULL DEFAULT 0,
refs INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)"
);
}
private function emptySource(): array
{
return [
'id' => '',
'code' => '',
'name' => '',
'provider' => 'manual',
'enabled' => 1,
'settings_json' => '[]',
'products_count' => 0,
];
}
private function emptyCategory(): array
{
return [
'id' => '',
'source_id' => '',
'type_code' => 'manual',
'external_code' => '',
'name' => '',
'slug' => '',
'description_html' => '',
'image_url' => '',
'is_active' => 1,
'products_count' => 0,
];
}
private function emptyProduct(): array
{
return [
'id' => null,
'source_id' => null,
'category_id' => null,
'external_code' => '',
'provider' => 'manual',
'provider_type' => 'manual',
'provider_category_code' => 'MANUAL',
'name' => '',
'slug' => '',
'short_description_html' => '',
'description_html' => '',
'image_url' => '',
'gallery_json' => '[]',
'price_amount' => '',
'price_currency' => 'USD',
'region' => '',
'appid' => '',
'in_stock' => 1,
'stock_quantity' => '',
'discount_percent' => '',
'forms_json' => '[]',
'metadata_json' => '[]',
'is_active' => 1,
'is_manual' => 1,
'category_name' => 'Ручные товары',
'source_name' => 'Manual',
'delivery_label' => 'Цифровой товар',
'price_label' => 'Цена уточняется',
'discount_label' => '',
'gallery_count' => 0,
'forms_count' => 0,
];
}
private function now(): string
{
return (new \DateTimeImmutable())->format('Y-m-d H:i:s');
}
private function nowIso(): string
{
return (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM);
}
}