src/Controller/AdminController.php line 156

Open in your IDE?
  1. <?php
  2. namespace App\Controller;
  3. use App\Controller\Catalog\CatalogRepository;
  4. use App\Controller\Catalog\CatalogSteamEnricher;
  5. use App\Controller\Catalog\GalaxyLinkCatalogProvider;
  6. use Doctrine\DBAL\Connection;
  7. use Symfony\Component\HttpFoundation\JsonResponse;
  8. use Symfony\Component\HttpFoundation\RedirectResponse;
  9. use Symfony\Component\HttpFoundation\Request;
  10. use Symfony\Component\HttpFoundation\Response;
  11. use Symfony\Component\Routing\Annotation\Route;
  12. class AdminController extends BaseController
  13. {
  14.     /**
  15.      * @Route("/admin", name="admin_dashboard", methods={"GET"})
  16.      */
  17.     public function dashboard(Request $requestConnection $dbCatalogRepository $catalog): Response
  18.     {
  19.         $catalog->ensureSchema();
  20.         $filters = [
  21.             'q' => trim((string) $request->query->get('q''')),
  22.             'source' => trim((string) $request->query->get('source''')),
  23.             'category' => trim((string) $request->query->get('category''')),
  24.             'active' => trim((string) $request->query->get('active''')),
  25.             'stock' => trim((string) $request->query->get('stock''')),
  26.             'discount' => trim((string) $request->query->get('discount''')),
  27.             'page' => max(1, (int) $request->query->get('page'1)),
  28.         ];
  29.         $products $this->fetchProducts($db$filters50);
  30.         $steamEnrichmentReport null;
  31.         if ($request->hasSession()) {
  32.             $steamEnrichmentReport $request->getSession()->get('admin_steam_enrichment_report');
  33.             $request->getSession()->remove('admin_steam_enrichment_report');
  34.         }
  35.         return $this->render('admin/dashboard.html.twig'$this->adminView($request$db, [
  36.             'stats' => $this->dashboardStats($db),
  37.             'sources' => $this->fetchSources($db),
  38.             'categories' => $this->fetchCategories($db),
  39.             'products' => $products['items'],
  40.             'pagination' => $products['pagination'],
  41.             'filters' => $filters,
  42.             'steam_enrichment_report' => $steamEnrichmentReport,
  43.             'steam_background_status' => $this->steamBackgroundStatus(),
  44.             'current_section' => 'dashboard',
  45.         ]));
  46.     }
  47.     /**
  48.      * @Route("/admin/references", name="admin_references", methods={"GET"})
  49.      */
  50.     public function references(Request $requestConnection $dbCatalogRepository $catalog): Response
  51.     {
  52.         $catalog->ensureSchema();
  53.         return $this->render('admin/references.html.twig'$this->adminView($request$db, [
  54.             'sources' => $this->fetchSources($db),
  55.             'categories' => $this->fetchCategories($db),
  56.             'empty_source' => $this->emptySource(),
  57.             'empty_category' => $this->emptyCategory(),
  58.             'current_section' => 'references',
  59.         ]));
  60.     }
  61.     /**
  62.      * @Route("/admin/users", name="admin_users", methods={"GET"})
  63.      */
  64.     public function users(Request $requestConnection $db): Response
  65.     {
  66.         $this->ensureUsersSchema($db);
  67.         $filters = [
  68.             'q' => trim((string) $request->query->get('q''')),
  69.             'page' => max(1, (int) $request->query->get('page'1)),
  70.         ];
  71.         $users $this->fetchUsers($db$filters40);
  72.         return $this->render('admin/users.html.twig'$this->adminView($request$db, [
  73.             'users' => $users['items'],
  74.             'pagination' => $users['pagination'],
  75.             'filters' => $filters,
  76.             'current_section' => 'users',
  77.         ]));
  78.     }
  79.     /**
  80.      * @Route("/admin/products/new", name="admin_product_new", methods={"GET", "POST"})
  81.      */
  82.     public function newProduct(Request $requestConnection $dbCatalogRepository $catalog): Response
  83.     {
  84.         $catalog->ensureSchema();
  85.         if ($request->isMethod('POST')) {
  86.             try {
  87.                 $id $this->saveProduct($request$dbnull);
  88.                 $this->addFlash('success''Товар создан.');
  89.                 return $this->redirectToRoute('admin_product_edit', ['id' => $id]);
  90.             } catch (\Throwable $error) {
  91.                 $this->addFlash('danger'$error->getMessage());
  92.             }
  93.         }
  94.         return $this->render('admin/product_form.html.twig'$this->adminView($request$db, [
  95.             'product' => $this->emptyProduct(),
  96.             'variants' => [],
  97.             'categories' => $this->fetchCategories($db),
  98.             'sources' => $this->fetchSources($db),
  99.             'mode' => 'new',
  100.             'current_section' => 'products',
  101.         ]));
  102.     }
  103.     /**
  104.      * @Route("/admin/products/{id}/edit", name="admin_product_edit", methods={"GET", "POST"}, requirements={"id"="\d+"})
  105.      */
  106.     public function editProduct(int $idRequest $requestConnection $dbCatalogRepository $catalog): Response
  107.     {
  108.         $catalog->ensureSchema();
  109.         $product $this->fetchProduct($db$id);
  110.         if (!$product) {
  111.             throw $this->createNotFoundException('Product not found.');
  112.         }
  113.         if ($request->isMethod('POST')) {
  114.             try {
  115.                 $this->saveProduct($request$db$id);
  116.                 $this->addFlash('success''Товар сохранен.');
  117.                 return $this->redirectToRoute('admin_product_edit', ['id' => $id]);
  118.             } catch (\Throwable $error) {
  119.                 $this->addFlash('danger'$error->getMessage());
  120.                 $product array_merge($product$request->request->all());
  121.             }
  122.         }
  123.         return $this->render('admin/product_form.html.twig'$this->adminView($request$db, [
  124.             'product' => $this->decorateProduct($product),
  125.             'variants' => $this->fetchProductVariants($db$product),
  126.             'categories' => $this->fetchCategories($db),
  127.             'sources' => $this->fetchSources($db),
  128.             'mode' => 'edit',
  129.             'current_section' => 'products',
  130.         ]));
  131.     }
  132.     /**
  133.      * @Route("/admin/users/{id}/edit", name="admin_user_edit", methods={"GET", "POST"}, requirements={"id"="\d+"})
  134.      */
  135.     public function editUser(int $idRequest $requestConnection $db): Response
  136.     {
  137.         $this->ensureUsersSchema($db);
  138.         $user $this->fetchUser($db$id);
  139.         if (!$user) {
  140.             throw $this->createNotFoundException('User not found.');
  141.         }
  142.         if ($request->isMethod('POST')) {
  143.             try {
  144.                 $this->saveUser($request$db$id);
  145.                 $this->addFlash('success''Пользователь сохранен.');
  146.                 return $this->redirectToRoute('admin_user_edit', ['id' => $id]);
  147.             } catch (\Throwable $error) {
  148.                 $this->addFlash('danger'$error->getMessage());
  149.                 $user array_merge($user$request->request->all());
  150.             }
  151.         }
  152.         return $this->render('admin/user_form.html.twig'$this->adminView($request$db, [
  153.             'user' => $this->decorateUser($user),
  154.             'current_section' => 'users',
  155.         ]));
  156.     }
  157.     /**
  158.      * @Route("/admin/products/bulk", name="admin_products_bulk", methods={"POST"})
  159.      */
  160.     public function bulkProducts(Request $requestConnection $dbCatalogRepository $catalog): RedirectResponse
  161.     {
  162.         $catalog->ensureSchema();
  163.         $ids array_values(array_filter(array_map('intval', (array) $request->request->get('ids', []))));
  164.         $action = (string) $request->request->get('action''');
  165.         if (count($ids) === 0) {
  166.             $this->addFlash('warning''Выберите товары для массового действия.');
  167.             return $this->redirectToRoute('admin_dashboard'$request->query->all());
  168.         }
  169.         $placeholders implode(','array_fill(0count($ids), '?'));
  170.         $now $this->now();
  171.         if ($action === 'activate') {
  172.             $db->executeStatement("UPDATE catalog_products SET is_active = 1, updated_at = ? WHERE id IN ({$placeholders})"array_merge([$now], $ids));
  173.             $this->addFlash('success''Выбранные товары включены.');
  174.         } elseif ($action === 'deactivate') {
  175.             $db->executeStatement("UPDATE catalog_products SET is_active = 0, updated_at = ? WHERE id IN ({$placeholders})"array_merge([$now], $ids));
  176.             $this->addFlash('success''Выбранные товары скрыты.');
  177.         } elseif ($action === 'stock_on') {
  178.             $db->executeStatement("UPDATE catalog_products SET in_stock = 1, updated_at = ? WHERE id IN ({$placeholders})"array_merge([$now], $ids));
  179.             $this->addFlash('success''Наличие включено.');
  180.         } elseif ($action === 'stock_off') {
  181.             $db->executeStatement("UPDATE catalog_products SET in_stock = 0, updated_at = ? WHERE id IN ({$placeholders})"array_merge([$now], $ids));
  182.             $this->addFlash('success''Наличие выключено.');
  183.         } elseif ($action === 'clear_discount') {
  184.             $db->executeStatement("UPDATE catalog_products SET discount_percent = NULL, updated_at = ? WHERE id IN ({$placeholders})"array_merge([$now], $ids));
  185.             $this->addFlash('success''Скидки очищены.');
  186.         } elseif ($action === 'set_discount') {
  187.             $discount $this->normalizeDiscount($request->request->get('discount_percent'));
  188.             $db->executeStatement("UPDATE catalog_products SET discount_percent = ?, updated_at = ? WHERE id IN ({$placeholders})"array_merge([$discount$now], $ids));
  189.             $this->addFlash('success'$discount === null 'Скидки очищены.' 'Скидка применена.');
  190.         } else {
  191.             $this->addFlash('warning''Неизвестное массовое действие.');
  192.         }
  193.         return $this->redirectToRoute('admin_dashboard'$request->query->all());
  194.     }
  195.     /**
  196.      * @Route("/admin/products/{id}/toggle", name="admin_product_toggle", methods={"POST"}, requirements={"id"="\d+"})
  197.      */
  198.     public function toggleProduct(int $idRequest $requestConnection $dbCatalogRepository $catalog): RedirectResponse
  199.     {
  200.         $catalog->ensureSchema();
  201.         $field = (string) $request->request->get('field''');
  202.         if (!in_array($field, ['is_active''in_stock'], true)) {
  203.             $this->addFlash('warning''Нельзя изменить это поле.');
  204.             return $this->redirectToRoute('admin_dashboard');
  205.         }
  206.         $value = (int) $db->fetchOne("SELECT {$field} FROM catalog_products WHERE id = ? LIMIT 1", [$id]);
  207.         $db->update('catalog_products', [
  208.             $field => $value 1,
  209.             'updated_at' => $this->now(),
  210.         ], ['id' => $id]);
  211.         $this->addFlash('success'$field === 'is_active' 'Видимость обновлена.' 'Наличие обновлено.');
  212.         return $this->redirect($request->headers->get('referer') ?: $this->generateUrl('admin_dashboard'));
  213.     }
  214.     /**
  215.      * @Route("/admin/sync/galaxylink", name="admin_sync_galaxylink", methods={"POST"})
  216.      */
  217.     public function syncGalaxyLink(GalaxyLinkCatalogProvider $provider): RedirectResponse
  218.     {
  219.         @set_time_limit(1200);
  220.         @ini_set('memory_limit''512M');
  221.         try {
  222.             $giftResult $provider->sync([
  223.                 'types' => [GalaxyLinkCatalogProvider::KEY_GIFT_TYPE],
  224.                 'categories' => ['STEAM_GIFTS'],
  225.                 'rows' => 500,
  226.                 'region' => 'RU',
  227.                 'limit_products' => 0,
  228.                 'lang' => 'russian',
  229.                 'cc' => 'us',
  230.                 'image_limit' => 0,
  231.                 'enrich_steam' => false,
  232.                 'dry_run' => false,
  233.             ]);
  234.             $keyCategories array_values(array_filter(
  235.                 GalaxyLinkCatalogProvider::STEAM_CATALOG_CATEGORIES,
  236.                 function (string $category): bool {
  237.                     return $category !== 'STEAM_GIFTS';
  238.                 }
  239.             ));
  240.             $keyResult $provider->sync([
  241.                 'types' => [GalaxyLinkCatalogProvider::KEY_GIFT_TYPE],
  242.                 'categories' => $keyCategories,
  243.                 'rows' => 500,
  244.                 'region' => 'RU',
  245.                 'limit_products' => 0,
  246.                 'lang' => 'russian',
  247.                 'cc' => 'us',
  248.                 'image_limit' => 0,
  249.                 'enrich_steam' => false,
  250.                 'dry_run' => false,
  251.             ]);
  252.             $warnings array_merge($giftResult->warnings$keyResult->warnings);
  253.             $errors array_merge($giftResult->errors$keyResult->errors);
  254.             $message sprintf(
  255.                 'GalaxyLink синхронизирован: Steam Gifts %d, Steam Keys %d, категорий %d, предупреждений %d.',
  256.                 $giftResult->products,
  257.                 $keyResult->products,
  258.                 $giftResult->categories $keyResult->categories,
  259.                 count($warnings)
  260.             );
  261.             $this->addFlash(count($errors) > 'warning' 'success'$message);
  262.         } catch (\Throwable $error) {
  263.             $this->addFlash('danger''Ошибка синхронизации GalaxyLink: ' $error->getMessage());
  264.         }
  265.         return $this->redirectToRoute('admin_dashboard');
  266.     }
  267.     /**
  268.      * @Route("/admin/sync/steam-content", name="admin_sync_steam_content", methods={"POST"})
  269.      */
  270.     public function syncSteamContent(Request $requestCatalogRepository $catalogCatalogSteamEnricher $enricher): RedirectResponse
  271.     {
  272.         @set_time_limit(1200);
  273.         @ini_set('memory_limit''512M');
  274.         $catalog->ensureSchema();
  275.         try {
  276.             $report $enricher->enrichMissing([
  277.                 'lang' => 'russian',
  278.                 'cc' => 'us',
  279.                 'image_limit' => 0,
  280.                 'limit' => 300,
  281.                 'batch_size' => 80,
  282.             ]);
  283.             if ($request->hasSession()) {
  284.                 $request->getSession()->set('admin_steam_enrichment_report'$report);
  285.             }
  286.             $message sprintf(
  287.                 'Steam-дозаполнение завершено: обновлено товаров %d, игровых групп %d, не получилось %d.',
  288.                 (int) $report['updated_products'],
  289.                 (int) $report['updated_games'],
  290.                 (int) $report['failed_count']
  291.             );
  292.             $this->addFlash((int) $report['failed_count'] > 'warning' 'success'$message);
  293.         } catch (\Throwable $error) {
  294.             $this->addFlash('danger''Ошибка Steam-дозаполнения: ' $error->getMessage());
  295.         }
  296.         return $this->redirectToRoute('admin_dashboard');
  297.     }
  298.     /**
  299.      * @Route("/admin/sync/steam-content/background/start", name="admin_sync_steam_background_start", methods={"POST"})
  300.      */
  301.     public function startSteamBackgroundSync(CatalogRepository $catalog): RedirectResponse
  302.     {
  303.         $catalog->ensureSchema();
  304.         $status $this->steamBackgroundStatus();
  305.         if (!empty($status['running'])) {
  306.             $this->addFlash('warning''Фоновое Steam-дозаполнение уже идет.');
  307.             return $this->redirectToRoute('admin_dashboard');
  308.         }
  309.         try {
  310.             $paths $this->steamBackgroundPaths();
  311.             if (!is_dir($paths['dir'])) {
  312.                 @mkdir($paths['dir'], 0775true);
  313.             }
  314.             if (is_file($paths['stop'])) {
  315.                 @unlink($paths['stop']);
  316.             }
  317.             $projectDir = (string) $this->getParameter('kernel.project_dir');
  318.             $arguments = [
  319.                 '/usr/bin/env',
  320.                 'APP_ENV=prod',
  321.                 'APP_DEBUG=0',
  322.                 PHP_BINARY,
  323.                 '-d',
  324.                 'memory_limit=512M',
  325.                 $projectDir '/bin/console',
  326.                 '--env=prod',
  327.                 '--no-debug',
  328.                 'app:catalog:enrich-steam',
  329.                 '--continuous',
  330.                 '--limit=300',
  331.                 '--batch-size=80',
  332.                 '--lang=russian',
  333.                 '--cc=us',
  334.                 '--image-limit=0',
  335.                 '--status-file=' $paths['status'],
  336.                 '--stop-file=' $paths['stop'],
  337.                 '--rate-limit-sleep=90',
  338.             ];
  339.             $command implode(' 'array_map('escapeshellarg'$arguments)) . ' >> ' escapeshellarg($paths['log']) . ' 2>&1 & echo $!';
  340.             $output = [];
  341.             $exitCode 0;
  342.             exec($command$output$exitCode);
  343.             $pid = isset($output[0]) ? (int) trim((string) $output[0]) : 0;
  344.             if ($exitCode !== || $pid <= 0) {
  345.                 throw new \RuntimeException('Не удалось запустить фоновый процесс Steam-дозаполнения.');
  346.             }
  347.             $now $this->nowIso();
  348.             $this->writeSteamBackgroundStatus([
  349.                 'state' => 'running',
  350.                 'running' => true,
  351.                 'requested_stop' => false,
  352.                 'pid' => $pid,
  353.                 'started_at' => $now,
  354.                 'updated_at' => $now,
  355.                 'finished_at' => null,
  356.                 'pass' => 0,
  357.                 'message' => 'Фоновое Steam-дозаполнение запускается.',
  358.                 'totals' => $this->emptySteamBackgroundReport(),
  359.                 'last_report' => $this->emptySteamBackgroundReport(),
  360.             ]);
  361.             $this->addFlash('success''Фоновое Steam-дозаполнение запущено. Страницу можно закрыть.');
  362.         } catch (\Throwable $error) {
  363.             $this->addFlash('danger''Не удалось запустить фоновое Steam-дозаполнение: ' $error->getMessage());
  364.         }
  365.         return $this->redirectToRoute('admin_dashboard');
  366.     }
  367.     /**
  368.      * @Route("/admin/sync/steam-content/background/stop", name="admin_sync_steam_background_stop", methods={"POST"})
  369.      */
  370.     public function stopSteamBackgroundSync(): RedirectResponse
  371.     {
  372.         $status $this->steamBackgroundStatus();
  373.         if (empty($status['running'])) {
  374.             $this->addFlash('warning''Фоновое Steam-дозаполнение сейчас не запущено.');
  375.             return $this->redirectToRoute('admin_dashboard');
  376.         }
  377.         $paths $this->steamBackgroundPaths();
  378.         if (!is_dir($paths['dir'])) {
  379.             @mkdir($paths['dir'], 0775true);
  380.         }
  381.         file_put_contents($paths['stop'], $this->nowIso(), LOCK_EX);
  382.         $status['state'] = 'stopping';
  383.         $status['requested_stop'] = true;
  384.         $status['message'] = 'Остановка запрошена. Процесс завершит текущую операцию и остановится.';
  385.         $status['updated_at'] = $this->nowIso();
  386.         $this->writeSteamBackgroundStatus($status);
  387.         $this->addFlash('success''Остановка фонового Steam-дозаполнения запрошена.');
  388.         return $this->redirectToRoute('admin_dashboard');
  389.     }
  390.     /**
  391.      * @Route("/admin/sync/steam-content/background/status", name="admin_sync_steam_background_status", methods={"GET"})
  392.      */
  393.     public function steamBackgroundSyncStatus(): JsonResponse
  394.     {
  395.         return new JsonResponse($this->steamBackgroundStatus());
  396.     }
  397.     /**
  398.      * @Route("/admin/references/sources/save", name="admin_source_save", methods={"POST"})
  399.      */
  400.     public function saveSource(Request $requestConnection $dbCatalogRepository $catalog): RedirectResponse
  401.     {
  402.         $catalog->ensureSchema();
  403.         try {
  404.             $this->persistSource($request$db);
  405.             $this->addFlash('success''Источник сохранен.');
  406.         } catch (\Throwable $error) {
  407.             $this->addFlash('danger'$error->getMessage());
  408.         }
  409.         return $this->redirectToRoute('admin_references');
  410.     }
  411.     /**
  412.      * @Route("/admin/references/sources/{id}/toggle", name="admin_source_toggle", methods={"POST"}, requirements={"id"="\d+"})
  413.      */
  414.     public function toggleSource(int $idConnection $dbCatalogRepository $catalog): RedirectResponse
  415.     {
  416.         $catalog->ensureSchema();
  417.         $value $db->fetchOne('SELECT enabled FROM catalog_sources WHERE id = ? LIMIT 1', [$id]);
  418.         if ($value === false) {
  419.             $this->addFlash('warning''Источник не найден.');
  420.             return $this->redirectToRoute('admin_references');
  421.         }
  422.         $db->update('catalog_sources', [
  423.             'enabled' => (int) $value 1,
  424.             'updated_at' => $this->now(),
  425.         ], ['id' => $id]);
  426.         $this->addFlash('success''Статус источника обновлен.');
  427.         return $this->redirectToRoute('admin_references');
  428.     }
  429.     /**
  430.      * @Route("/admin/references/categories/save", name="admin_category_save", methods={"POST"})
  431.      */
  432.     public function saveCategory(Request $requestConnection $dbCatalogRepository $catalog): RedirectResponse
  433.     {
  434.         $catalog->ensureSchema();
  435.         try {
  436.             $this->persistCategory($request$db);
  437.             $this->addFlash('success''Категория сохранена.');
  438.         } catch (\Throwable $error) {
  439.             $this->addFlash('danger'$error->getMessage());
  440.         }
  441.         return $this->redirectToRoute('admin_references');
  442.     }
  443.     /**
  444.      * @Route("/admin/references/categories/{id}/toggle", name="admin_category_toggle", methods={"POST"}, requirements={"id"="\d+"})
  445.      */
  446.     public function toggleCategory(int $idConnection $dbCatalogRepository $catalog): RedirectResponse
  447.     {
  448.         $catalog->ensureSchema();
  449.         $value $db->fetchOne('SELECT is_active FROM catalog_categories WHERE id = ? LIMIT 1', [$id]);
  450.         if ($value === false) {
  451.             $this->addFlash('warning''Категория не найдена.');
  452.             return $this->redirectToRoute('admin_references');
  453.         }
  454.         $db->update('catalog_categories', [
  455.             'is_active' => (int) $value 1,
  456.             'updated_at' => $this->now(),
  457.         ], ['id' => $id]);
  458.         $this->addFlash('success''Статус категории обновлен.');
  459.         return $this->redirectToRoute('admin_references');
  460.     }
  461.     private function adminView(Request $requestConnection $db, array $params): array
  462.     {
  463.         try {
  464.             $user $this->getAuthorizedUser($request$db);
  465.         } catch (\Throwable $error) {
  466.             $user null;
  467.         }
  468.         $params['admin_user'] = [
  469.             'id' => $user ? (int) $user['id'] : 0,
  470.             'login' => $user ? (string) $user['login'] : 'Admin',
  471.             'img' => $user && !empty($user['img']) ? (string) $user['img'] : '/assets/profile.svg',
  472.         ];
  473.         return $params;
  474.     }
  475.     private function steamBackgroundPaths(): array
  476.     {
  477.         $projectDir rtrim((string) $this->getParameter('kernel.project_dir'), DIRECTORY_SEPARATOR);
  478.         $dir $projectDir '/var/admin-steam-enrichment';
  479.         return [
  480.             'dir' => $dir,
  481.             'status' => $dir '/status.json',
  482.             'stop' => $dir '/stop.flag',
  483.             'log' => $dir '/steam-enrichment.log',
  484.         ];
  485.     }
  486.     private function steamBackgroundStatus(): array
  487.     {
  488.         $paths $this->steamBackgroundPaths();
  489.         $status $this->readSteamBackgroundStatus($paths['status']);
  490.         $state = (string) ($status['state'] ?? 'idle');
  491.         $pid = (int) ($status['pid'] ?? 0);
  492.         $running $pid && $this->processIsRunning($pid);
  493.         $requestedStop is_file($paths['stop']);
  494.         if (in_array($state, ['running''stopping'], true) && !$running) {
  495.             $state $requestedStop 'stopped' 'error';
  496.             $status['state'] = $state;
  497.             $status['running'] = false;
  498.             $status['finished_at'] = $status['finished_at'] ?? $this->nowIso();
  499.             $status['updated_at'] = $this->nowIso();
  500.             if (empty($status['message']) || $state === 'error') {
  501.                 $status['message'] = $state === 'error'
  502.                     'Фоновый процесс завершился без финального статуса. Проверьте лог.'
  503.                     'Фоновое Steam-дозаполнение остановлено.';
  504.             }
  505.             $this->writeSteamBackgroundStatus($status);
  506.         }
  507.         $status array_merge([
  508.             'state' => 'idle',
  509.             'running' => false,
  510.             'requested_stop' => false,
  511.             'pid' => 0,
  512.             'started_at' => null,
  513.             'updated_at' => null,
  514.             'finished_at' => null,
  515.             'pass' => 0,
  516.             'message' => 'Фоновое Steam-дозаполнение ещё не запускалось.',
  517.             'totals' => $this->emptySteamBackgroundReport(),
  518.             'last_report' => $this->emptySteamBackgroundReport(),
  519.         ], $status);
  520.         $status['state'] = $state;
  521.         $status['running'] = $running && in_array($state, ['running''stopping'], true);
  522.         $status['requested_stop'] = $requestedStop;
  523.         $status['state_label'] = $this->steamBackgroundStateLabel($state);
  524.         $status['state_class'] = $this->steamBackgroundStateClass($state);
  525.         $status['failed'] = array_slice((array) ($status['totals']['failed'] ?? []), 020);
  526.         $status['failed_truncated'] = max(
  527.             (int) ($status['totals']['failed_truncated'] ?? 0),
  528.             max(0, (int) ($status['totals']['failed_count'] ?? 0) - count((array) ($status['totals']['failed'] ?? [])))
  529.         );
  530.         return $status;
  531.     }
  532.     private function readSteamBackgroundStatus(string $path): array
  533.     {
  534.         if (!is_file($path)) {
  535.             return [];
  536.         }
  537.         $contents file_get_contents($path);
  538.         if (!is_string($contents) || trim($contents) === '') {
  539.             return [];
  540.         }
  541.         $decoded json_decode($contentstrue);
  542.         return is_array($decoded) ? $decoded : [];
  543.     }
  544.     private function writeSteamBackgroundStatus(array $status): void
  545.     {
  546.         $paths $this->steamBackgroundPaths();
  547.         if (!is_dir($paths['dir'])) {
  548.             @mkdir($paths['dir'], 0775true);
  549.         }
  550.         $json json_encode($statusJSON_UNESCAPED_UNICODE JSON_UNESCAPED_SLASHES JSON_PRETTY_PRINT);
  551.         if ($json === false) {
  552.             return;
  553.         }
  554.         file_put_contents($paths['status'], $jsonLOCK_EX);
  555.     }
  556.     private function emptySteamBackgroundReport(): array
  557.     {
  558.         return [
  559.             'scanned_products' => 0,
  560.             'scanned_games' => 0,
  561.             'updated_products' => 0,
  562.             'updated_games' => 0,
  563.             'failed_count' => 0,
  564.             'failed' => [],
  565.             'failed_ids' => [],
  566.             'failed_truncated' => 0,
  567.             'stopped' => false,
  568.         ];
  569.     }
  570.     private function processIsRunning(int $pid): bool
  571.     {
  572.         if ($pid <= 0) {
  573.             return false;
  574.         }
  575.         if (function_exists('posix_kill')) {
  576.             return @posix_kill($pid0);
  577.         }
  578.         $exitCode 1;
  579.         exec('kill -0 ' . (int) $pid ' 2>/dev/null'$output$exitCode);
  580.         return $exitCode === 0;
  581.     }
  582.     private function steamBackgroundStateLabel(string $state): string
  583.     {
  584.         switch ($state) {
  585.             case 'running':
  586.                 return 'Идёт';
  587.             case 'stopping':
  588.                 return 'Останавливается';
  589.             case 'completed':
  590.                 return 'Завершено';
  591.             case 'stopped':
  592.                 return 'Остановлено';
  593.             case 'error':
  594.                 return 'Ошибка';
  595.             default:
  596.                 return 'Ожидает';
  597.         }
  598.     }
  599.     private function steamBackgroundStateClass(string $state): string
  600.     {
  601.         switch ($state) {
  602.             case 'running':
  603.                 return 'primary';
  604.             case 'stopping':
  605.                 return 'warning';
  606.             case 'completed':
  607.                 return 'success';
  608.             case 'stopped':
  609.                 return 'secondary';
  610.             case 'error':
  611.                 return 'danger';
  612.             default:
  613.                 return 'secondary';
  614.         }
  615.     }
  616.     private function isSteamCatalogCategory(string $category): bool
  617.     {
  618.         $category strtoupper(trim($category));
  619.         return $category === 'STEAM_GIFTS' || $category === 'OTHERS' || strpos($category'STEAM_KEYS') === 0;
  620.     }
  621.     private function dashboardStats(Connection $db): array
  622.     {
  623.         $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');
  624.         $groups = [];
  625.         $delivery = [
  626.             'gifts' => 0,
  627.             'keys' => 0,
  628.             'other' => 0,
  629.         ];
  630.         $active 0;
  631.         $stock 0;
  632.         $manual 0;
  633.         $discounted 0;
  634.         $sumUsd 0.0;
  635.         foreach ($rows as $row) {
  636.             $category strtoupper((string) ($row['provider_category_code'] ?? ''));
  637.             $groups[$this->productStatsGroupKey($row)] = true;
  638.             if (!empty($row['is_active'])) {
  639.                 $active++;
  640.             }
  641.             if (!empty($row['in_stock'])) {
  642.                 $stock++;
  643.             }
  644.             if (!empty($row['is_manual'])) {
  645.                 $manual++;
  646.             }
  647.             if ((float) ($row['discount_percent'] ?? 0) > 0) {
  648.                 $discounted++;
  649.             }
  650.             if ((string) ($row['price_currency'] ?? '') === 'USD') {
  651.                 $sumUsd += (float) ($row['price_amount'] ?? 0);
  652.             }
  653.             if (strpos($category'GIFT') !== false) {
  654.                 $delivery['gifts']++;
  655.             } elseif (strpos($category'KEY') !== false || $category === 'OTHERS') {
  656.                 $delivery['keys']++;
  657.             } else {
  658.                 $delivery['other']++;
  659.             }
  660.         }
  661.         return [
  662.             'raw_products' => count($rows),
  663.             'catalog_products' => count($groups),
  664.             'active_products' => $active,
  665.             'in_stock' => $stock,
  666.             'manual_products' => $manual,
  667.             'discounted_products' => $discounted,
  668.             'sources' => (int) $db->fetchOne('SELECT COUNT(*) FROM catalog_sources'),
  669.             'categories' => (int) $db->fetchOne('SELECT COUNT(*) FROM catalog_categories'),
  670.             'users' => $this->safeCount($db'users'),
  671.             'usd_inventory' => $this->formatMoney($sumUsd'USD'),
  672.             'delivery' => $delivery,
  673.         ];
  674.     }
  675.     private function fetchProducts(Connection $db, array $filtersint $perPage): array
  676.     {
  677.         $where = ['1 = 1'];
  678.         $params = [];
  679.         if ($filters['q'] !== '') {
  680.             $where[] = '(LOWER(p.name) LIKE ? OR LOWER(p.external_code) LIKE ? OR p.appid LIKE ?)';
  681.             $needle '%' strtolower($filters['q']) . '%';
  682.             $params[] = $needle;
  683.             $params[] = $needle;
  684.             $params[] = '%' $filters['q'] . '%';
  685.         }
  686.         if ($filters['source'] !== '') {
  687.             $where[] = 'p.source_id = ?';
  688.             $params[] = (int) $filters['source'];
  689.         }
  690.         if ($filters['category'] !== '') {
  691.             $where[] = 'p.category_id = ?';
  692.             $params[] = (int) $filters['category'];
  693.         }
  694.         if ($filters['active'] !== '') {
  695.             $where[] = 'p.is_active = ?';
  696.             $params[] = (int) $filters['active'];
  697.         }
  698.         if ($filters['stock'] !== '') {
  699.             $where[] = 'p.in_stock = ?';
  700.             $params[] = (int) $filters['stock'];
  701.         }
  702.         if ($filters['discount'] === 'yes') {
  703.             $where[] = 'p.discount_percent IS NOT NULL AND p.discount_percent > 0';
  704.         } elseif ($filters['discount'] === 'no') {
  705.             $where[] = '(p.discount_percent IS NULL OR p.discount_percent <= 0)';
  706.         }
  707.         $whereSql implode(' AND '$where);
  708.         $total = (int) $db->fetchOne(
  709.             "SELECT COUNT(*) FROM catalog_products p
  710.              LEFT JOIN catalog_categories c ON c.id = p.category_id
  711.              LEFT JOIN catalog_sources s ON s.id = p.source_id
  712.              WHERE {$whereSql}",
  713.             $params
  714.         );
  715.         $page max(1, (int) $filters['page']);
  716.         $totalPages max(1, (int) ceil($total $perPage));
  717.         $page min($page$totalPages);
  718.         $offset = ($page 1) * $perPage;
  719.         $rows $db->fetchAllAssociative(
  720.             "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
  721.              FROM catalog_products p
  722.              LEFT JOIN catalog_categories c ON c.id = p.category_id
  723.              LEFT JOIN catalog_sources s ON s.id = p.source_id
  724.              WHERE {$whereSql}
  725.              ORDER BY p.updated_at DESC, p.id DESC
  726.              LIMIT {$perPage} OFFSET {$offset}",
  727.             $params
  728.         );
  729.         return [
  730.             'items' => array_map([$this'decorateProduct'], $rows),
  731.             'pagination' => [
  732.                 'total' => $total,
  733.                 'page' => $page,
  734.                 'per_page' => $perPage,
  735.                 'total_pages' => $totalPages,
  736.                 'from' => $total === $offset 1,
  737.                 'to' => min($offset $perPage$total),
  738.             ],
  739.         ];
  740.     }
  741.     private function fetchProduct(Connection $dbint $id): ?array
  742.     {
  743.         $row $db->fetchAssociative(
  744.             '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
  745.              FROM catalog_products p
  746.              LEFT JOIN catalog_categories c ON c.id = p.category_id
  747.              LEFT JOIN catalog_sources s ON s.id = p.source_id
  748.              WHERE p.id = ? LIMIT 1',
  749.             [$id]
  750.         );
  751.         return $row ?: null;
  752.     }
  753.     private function fetchProductVariants(Connection $db, array $product): array
  754.     {
  755.         $appid trim((string) ($product['appid'] ?? ''));
  756.         if ($appid !== '') {
  757.             $rows $db->fetchAllAssociative(
  758.                 'SELECT p.*, c.name AS category_name, s.code AS source_code
  759.                  FROM catalog_products p
  760.                  LEFT JOIN catalog_categories c ON c.id = p.category_id
  761.                  LEFT JOIN catalog_sources s ON s.id = p.source_id
  762.                  WHERE p.appid = ? AND p.id <> ?
  763.                  ORDER BY p.in_stock DESC, p.price_amount ASC, p.id ASC',
  764.                 [$appid, (int) $product['id']]
  765.             );
  766.             return array_map([$this'decorateProduct'], $rows);
  767.         }
  768.         $category = (string) ($product['provider_category_code'] ?? $product['category_external_code'] ?? '');
  769.         $titleKey $this->steamTitleGroupKey((string) ($product['name'] ?? ''));
  770.         if (!$this->isSteamCatalogCategory($category) || $titleKey === '') {
  771.             return [];
  772.         }
  773.         $rows $db->fetchAllAssociative(
  774.             'SELECT p.*, c.name AS category_name, s.code AS source_code
  775.              FROM catalog_products p
  776.              LEFT JOIN catalog_categories c ON c.id = p.category_id
  777.              LEFT JOIN catalog_sources s ON s.id = p.source_id
  778.              WHERE LOWER(TRIM(p.name)) = ? AND p.id <> ?
  779.                AND (p.provider_category_code = ? OR p.provider_category_code = ? OR p.provider_category_code LIKE ?)
  780.              ORDER BY p.in_stock DESC, p.price_amount ASC, p.id ASC',
  781.             [$titleKey, (int) $product['id'], 'STEAM_GIFTS''OTHERS''STEAM_KEYS%']
  782.         );
  783.         return array_map([$this'decorateProduct'], $rows);
  784.     }
  785.     private function fetchSources(Connection $db): array
  786.     {
  787.         return $db->fetchAllAssociative(
  788.             'SELECT s.*, COALESCE(pc.products_count, 0) AS products_count
  789.              FROM catalog_sources s
  790.              LEFT JOIN (
  791.                  SELECT source_id, COUNT(*) AS products_count
  792.                  FROM catalog_products
  793.                  GROUP BY source_id
  794.              ) pc ON pc.source_id = s.id
  795.              ORDER BY s.name ASC'
  796.         );
  797.     }
  798.     private function fetchCategories(Connection $db): array
  799.     {
  800.         return $db->fetchAllAssociative(
  801.             'SELECT c.*, s.name AS source_name, COALESCE(pc.products_count, 0) AS products_count
  802.              FROM catalog_categories c
  803.              LEFT JOIN catalog_sources s ON s.id = c.source_id
  804.              LEFT JOIN (
  805.                  SELECT category_id, COUNT(*) AS products_count
  806.                  FROM catalog_products
  807.                  GROUP BY category_id
  808.              ) pc ON pc.category_id = c.id
  809.              ORDER BY c.name ASC'
  810.         );
  811.     }
  812.     private function fetchUsers(Connection $db, array $filtersint $perPage): array
  813.     {
  814.         $where = ['1 = 1'];
  815.         $params = [];
  816.         if ($filters['q'] !== '') {
  817.             $where[] = '(LOWER(login) LIKE ? OR LOWER(email) LIKE ? OR LOWER(hash) LIKE ? OR ip LIKE ?)';
  818.             $needle '%' strtolower($filters['q']) . '%';
  819.             $params[] = $needle;
  820.             $params[] = $needle;
  821.             $params[] = $needle;
  822.             $params[] = '%' $filters['q'] . '%';
  823.         }
  824.         $whereSql implode(' AND '$where);
  825.         $total = (int) $db->fetchOne("SELECT COUNT(*) FROM users WHERE {$whereSql}"$params);
  826.         $page max(1, (int) $filters['page']);
  827.         $totalPages max(1, (int) ceil($total $perPage));
  828.         $page min($page$totalPages);
  829.         $offset = ($page 1) * $perPage;
  830.         $rows $db->fetchAllAssociative(
  831.             "SELECT id, login, email, balance, hash, img, auth_token_hash, ip, ref_id, refs, created_at
  832.              FROM users
  833.              WHERE {$whereSql}
  834.              ORDER BY id DESC
  835.              LIMIT {$perPage} OFFSET {$offset}",
  836.             $params
  837.         );
  838.         return [
  839.             'items' => array_map([$this'decorateUser'], $rows),
  840.             'pagination' => [
  841.                 'total' => $total,
  842.                 'page' => $page,
  843.                 'per_page' => $perPage,
  844.                 'total_pages' => $totalPages,
  845.                 'from' => $total === $offset 1,
  846.                 'to' => min($offset $perPage$total),
  847.             ],
  848.         ];
  849.     }
  850.     private function fetchUser(Connection $dbint $id): ?array
  851.     {
  852.         $row $db->fetchAssociative(
  853.             'SELECT id, login, email, balance, hash, img, auth_token_hash, ip, ref_id, refs, created_at
  854.              FROM users
  855.              WHERE id = ? LIMIT 1',
  856.             [$id]
  857.         );
  858.         return $row ?: null;
  859.     }
  860.     private function saveProduct(Request $requestConnection $db, ?int $id): int
  861.     {
  862.         $data $request->request;
  863.         $now $this->now();
  864.         $price $this->nullableNumber($data->get('price_amount'));
  865.         $discount $this->normalizeDiscount($data->get('discount_percent'));
  866.         $galleryJson trim((string) $data->get('gallery_json'''));
  867.         $formsJson trim((string) $data->get('forms_json'''));
  868.         $metadataJson trim((string) $data->get('metadata_json'''));
  869.         $this->assertJson($galleryJson'Галерея');
  870.         $this->assertJson($formsJson'Формы');
  871.         $this->assertJson($metadataJson'Метаданные');
  872.         $sourceId $this->nullableInt($data->get('source_id'));
  873.         if ($sourceId === null) {
  874.             $sourceId $this->ensureManualSource($db);
  875.         }
  876.         $categoryId $this->nullableInt($data->get('category_id'));
  877.         if ($categoryId === null) {
  878.             $categoryId $this->ensureManualCategory($db$sourceId);
  879.         }
  880.         $payload = [
  881.             'source_id' => $sourceId,
  882.             'category_id' => $categoryId,
  883.             'provider' => trim((string) $data->get('provider''manual')) ?: 'manual',
  884.             'provider_type' => trim((string) $data->get('provider_type''manual')) ?: null,
  885.             'provider_category_code' => trim((string) $data->get('provider_category_code''MANUAL')) ?: null,
  886.             'name' => trim((string) $data->get('name''Товар PlayPapa')),
  887.             'short_description_html' => trim((string) $data->get('short_description_html''')) ?: null,
  888.             'description_html' => trim((string) $data->get('description_html''')) ?: null,
  889.             'image_url' => trim((string) $data->get('image_url''')) ?: null,
  890.             'gallery_json' => $galleryJson !== '' $galleryJson '[]',
  891.             'price_amount' => $price,
  892.             'price_currency' => strtoupper(trim((string) $data->get('price_currency''USD'))) ?: 'USD',
  893.             'region' => strtoupper(trim((string) $data->get('region'''))) ?: null,
  894.             'appid' => trim((string) $data->get('appid''')) ?: null,
  895.             'in_stock' => $data->has('in_stock') ? 0,
  896.             'stock_quantity' => $this->nullableInt($data->get('stock_quantity')),
  897.             'promo' => $discount !== null 0,
  898.             'discount_percent' => $discount,
  899.             'forms_json' => $formsJson !== '' $formsJson '[]',
  900.             'metadata_json' => $metadataJson !== '' $metadataJson '[]',
  901.             'is_active' => $data->has('is_active') ? 0,
  902.             'updated_at' => $now,
  903.         ];
  904.         if ($id !== null) {
  905.             $db->update('catalog_products'$payload, ['id' => $id]);
  906.             return $id;
  907.         }
  908.         $externalCode trim((string) $data->get('external_code'''));
  909.         if ($externalCode === '') {
  910.             $externalCode 'manual_' strtolower(bin2hex(random_bytes(5)));
  911.         }
  912.         $payload['external_code'] = $externalCode;
  913.         $payload['slug'] = $this->uniqueSlug($db, (string) $payload['name'] . '-' $externalCode);
  914.         $payload['raw_json'] = '[]';
  915.         $payload['is_manual'] = 1;
  916.         $payload['is_active'] = $payload['is_active'];
  917.         $payload['last_synced_at'] = null;
  918.         $payload['created_at'] = $now;
  919.         $db->insert('catalog_products'$payload);
  920.         return (int) $db->lastInsertId();
  921.     }
  922.     private function saveUser(Request $requestConnection $dbint $id): void
  923.     {
  924.         $data $request->request;
  925.         $login trim((string) $data->get('login'''));
  926.         $email trim((string) $data->get('email'''));
  927.         $password trim((string) $data->get('password'''));
  928.         if (mb_strlen($login) < || mb_strlen($login) > 50) {
  929.             throw new \InvalidArgumentException('Логин должен быть от 3 до 50 символов.');
  930.         }
  931.         if (!filter_var($emailFILTER_VALIDATE_EMAIL)) {
  932.             throw new \InvalidArgumentException('Некорректный e-mail.');
  933.         }
  934.         if ($password !== '' && mb_strlen($password) < 6) {
  935.             throw new \InvalidArgumentException('Новый пароль должен быть не короче 6 символов.');
  936.         }
  937.         $duplicate $db->fetchOne(
  938.             'SELECT id FROM users WHERE (login = ? OR email = ?) AND id <> ? LIMIT 1',
  939.             [$login$email$id]
  940.         );
  941.         if ($duplicate) {
  942.             throw new \InvalidArgumentException('Такой логин или e-mail уже занят.');
  943.         }
  944.         $payload = [
  945.             'login' => $login,
  946.             'email' => $email,
  947.             'balance' => $this->nullableNumber($data->get('balance')) ?? 0,
  948.             'img' => trim((string) $data->get('img''')) ?: '/assets/profile.svg',
  949.             'ip' => trim((string) $data->get('ip''')),
  950.             'ref_id' => $this->nullableInt($data->get('ref_id')) ?? 0,
  951.             'refs' => $this->nullableInt($data->get('refs')) ?? 0,
  952.         ];
  953.         if ($password !== '') {
  954.             $payload['password_hash'] = password_hash($passwordPASSWORD_DEFAULT);
  955.         }
  956.         if ($data->has('clear_session')) {
  957.             $payload['auth_token_hash'] = null;
  958.         }
  959.         $db->update('users'$payload, ['id' => $id]);
  960.     }
  961.     private function persistSource(Request $requestConnection $db): void
  962.     {
  963.         $data $request->request;
  964.         $id $this->nullableInt($data->get('id'));
  965.         $code strtolower(trim((string) $data->get('code''')));
  966.         $code trim((string) preg_replace('/[^a-z0-9_-]+/''-'$code), '-');
  967.         $name trim((string) $data->get('name'''));
  968.         $provider trim((string) $data->get('provider'''));
  969.         $settingsJson trim((string) $data->get('settings_json'''));
  970.         if ($code === '') {
  971.             $code $this->slugify($name ?: $provider ?: 'source');
  972.         }
  973.         if ($name === '' || $provider === '') {
  974.             throw new \InvalidArgumentException('У источника должны быть название и provider.');
  975.         }
  976.         $this->assertJson($settingsJson'Настройки источника');
  977.         $duplicate $db->fetchAssociative('SELECT id FROM catalog_sources WHERE code = ? LIMIT 1', [$code]);
  978.         if ($duplicate && (!$id || (int) $duplicate['id'] !== $id)) {
  979.             throw new \InvalidArgumentException('Источник с таким code уже существует.');
  980.         }
  981.         $payload = [
  982.             'code' => $code,
  983.             'name' => $name,
  984.             'provider' => $provider,
  985.             'enabled' => $data->has('enabled') ? 0,
  986.             'settings_json' => $settingsJson !== '' $settingsJson '[]',
  987.             'updated_at' => $this->now(),
  988.         ];
  989.         if ($id) {
  990.             $db->update('catalog_sources'$payload, ['id' => $id]);
  991.             return;
  992.         }
  993.         $payload['created_at'] = $this->now();
  994.         $db->insert('catalog_sources'$payload);
  995.     }
  996.     private function persistCategory(Request $requestConnection $db): void
  997.     {
  998.         $data $request->request;
  999.         $id $this->nullableInt($data->get('id'));
  1000.         $name trim((string) $data->get('name'''));
  1001.         $sourceId $this->nullableInt($data->get('source_id'));
  1002.         $typeCode trim((string) $data->get('type_code''')) ?: 'manual';
  1003.         $externalCode trim((string) $data->get('external_code''')) ?: strtoupper($this->slugify($name ?: 'manual'));
  1004.         $slugInput trim((string) $data->get('slug'''));
  1005.         if ($name === '') {
  1006.             throw new \InvalidArgumentException('У категории должно быть название.');
  1007.         }
  1008.         $existing $id $db->fetchAssociative('SELECT * FROM catalog_categories WHERE id = ? LIMIT 1', [$id]) : null;
  1009.         $slug $slugInput !== '' $this->slugify($slugInput) : $this->slugify($name '-' $typeCode '-' $externalCode);
  1010.         if (!$existing || $slug !== (string) $existing['slug']) {
  1011.             $slug $this->uniqueSlugFor($db$slugnull$id);
  1012.         }
  1013.         $payload = [
  1014.             'source_id' => $sourceId,
  1015.             'type_code' => $typeCode,
  1016.             'external_code' => $externalCode,
  1017.             'name' => $name,
  1018.             'slug' => $slug,
  1019.             'description_html' => trim((string) $data->get('description_html''')) ?: null,
  1020.             'image_url' => trim((string) $data->get('image_url''')) ?: null,
  1021.             'is_active' => $data->has('is_active') ? 0,
  1022.             'updated_at' => $this->now(),
  1023.         ];
  1024.         if ($id) {
  1025.             $db->update('catalog_categories'$payload, ['id' => $id]);
  1026.             return;
  1027.         }
  1028.         $payload['raw_json'] = '[]';
  1029.         $payload['is_manual'] = 1;
  1030.         $payload['last_synced_at'] = null;
  1031.         $payload['created_at'] = $this->now();
  1032.         $db->insert('catalog_categories'$payload);
  1033.     }
  1034.     private function decorateProduct(array $row): array
  1035.     {
  1036.         $price $row['price_amount'] !== null ? (float) $row['price_amount'] : null;
  1037.         $discount $this->normalizeDiscount($row['discount_percent'] ?? null);
  1038.         $row['price_label'] = $this->formatMoney($price, (string) ($row['price_currency'] ?? 'USD'));
  1039.         $row['discount_label'] = $discount === null '' '−' rtrim(rtrim(number_format($discount2','' '), '0'), ',') . '%';
  1040.         $row['delivery_label'] = $this->deliveryLabel($row);
  1041.         $row['forms_count'] = count($this->decodeJson((string) ($row['forms_json'] ?? '[]')));
  1042.         $row['gallery_count'] = count($this->decodeJson((string) ($row['gallery_json'] ?? '[]')));
  1043.         $row['product_url'] = '/product?slug=' rawurlencode((string) ($row['slug'] ?? ''));
  1044.         return $row;
  1045.     }
  1046.     private function decorateUser(array $row): array
  1047.     {
  1048.         $balance = (float) ($row['balance'] ?? 0);
  1049.         $img trim((string) ($row['img'] ?? ''));
  1050.         $row['img_url'] = $img !== '' $img '/assets/profile.svg';
  1051.         $row['balance_label'] = number_format($balance2','' ');
  1052.         $row['has_session'] = !empty($row['auth_token_hash']);
  1053.         return $row;
  1054.     }
  1055.     private function deliveryLabel(array $row): string
  1056.     {
  1057.         $forms array_map('strtolower'array_map('strval'$this->decodeJson((string) ($row['forms_json'] ?? '[]'))));
  1058.         $category strtoupper((string) ($row['provider_category_code'] ?? $row['category_external_code'] ?? ''));
  1059.         $categoryName strtoupper((string) ($row['category_name'] ?? ''));
  1060.         $text $category ' ' $categoryName;
  1061.         if (in_array('invite_url'$formstrue) || strpos($text'GIFT') !== false) {
  1062.             return 'Подарок Steam';
  1063.         }
  1064.         if (strpos($text'STEAM') !== false && strpos($text'KEY') !== false) {
  1065.             return 'Ключ Steam';
  1066.         }
  1067.         if (strpos($text'KEY') !== false) {
  1068.             return 'Цифровой ключ';
  1069.         }
  1070.         if (strpos($text'TOPUP') !== false || strpos($text'WALLET') !== false) {
  1071.             return 'Пополнение';
  1072.         }
  1073.         return 'Цифровой товар';
  1074.     }
  1075.     private function productStatsGroupKey(array $row): string
  1076.     {
  1077.         $category = (string) ($row['provider_category_code'] ?? '');
  1078.         if (!$this->isSteamCatalogCategory($category)) {
  1079.             return 'product:' . (string) $row['id'];
  1080.         }
  1081.         $appid trim((string) ($row['appid'] ?? ''));
  1082.         if ($appid !== '') {
  1083.             return 'steam:' $appid;
  1084.         }
  1085.         $titleKey $this->steamTitleGroupKey((string) ($row['name'] ?? ''));
  1086.         if ($titleKey !== '') {
  1087.             return 'steam-title:' $titleKey;
  1088.         }
  1089.         return 'product:' . (string) $row['id'];
  1090.     }
  1091.     private function steamTitleGroupKey(string $name): string
  1092.     {
  1093.         $name html_entity_decode($nameENT_QUOTES ENT_HTML5'UTF-8');
  1094.         $name trim((string) preg_replace('/\s+/'' '$name));
  1095.         return $name === '' '' mb_strtolower($name'UTF-8');
  1096.     }
  1097.     private function ensureManualSource(Connection $db): int
  1098.     {
  1099.         $existing $db->fetchOne('SELECT id FROM catalog_sources WHERE code = ? LIMIT 1', ['manual']);
  1100.         if ($existing) {
  1101.             return (int) $existing;
  1102.         }
  1103.         $now $this->now();
  1104.         $db->insert('catalog_sources', [
  1105.             'code' => 'manual',
  1106.             'name' => 'Manual',
  1107.             'provider' => 'manual',
  1108.             'enabled' => 1,
  1109.             'settings_json' => '[]',
  1110.             'created_at' => $now,
  1111.             'updated_at' => $now,
  1112.         ]);
  1113.         return (int) $db->lastInsertId();
  1114.     }
  1115.     private function ensureManualCategory(Connection $dbint $sourceId): int
  1116.     {
  1117.         $existing $db->fetchOne('SELECT id FROM catalog_categories WHERE source_id = ? AND external_code = ? LIMIT 1', [$sourceId'MANUAL']);
  1118.         if ($existing) {
  1119.             return (int) $existing;
  1120.         }
  1121.         $now $this->now();
  1122.         $db->insert('catalog_categories', [
  1123.             'source_id' => $sourceId,
  1124.             'type_code' => 'manual',
  1125.             'external_code' => 'MANUAL',
  1126.             'name' => 'Ручные товары',
  1127.             'slug' => $this->uniqueSlug($db'manual-products'),
  1128.             'description_html' => null,
  1129.             'image_url' => null,
  1130.             'raw_json' => '[]',
  1131.             'is_manual' => 1,
  1132.             'is_active' => 1,
  1133.             'created_at' => $now,
  1134.             'updated_at' => $now,
  1135.         ]);
  1136.         return (int) $db->lastInsertId();
  1137.     }
  1138.     private function uniqueSlug(Connection $dbstring $base): string
  1139.     {
  1140.         return $this->uniqueSlugFor($db$basenullnull);
  1141.     }
  1142.     private function uniqueSlugFor(Connection $dbstring $base, ?int $ignoreProductId, ?int $ignoreCategoryId): string
  1143.     {
  1144.         $slug $this->slugify($base);
  1145.         $candidate $slug;
  1146.         $index 2;
  1147.         while ($this->slugExists($db$candidate$ignoreProductId$ignoreCategoryId)) {
  1148.             $candidate substr($slug0245) . '-' $index;
  1149.             $index++;
  1150.         }
  1151.         return $candidate;
  1152.     }
  1153.     private function slugExists(Connection $dbstring $slug, ?int $ignoreProductId, ?int $ignoreCategoryId): bool
  1154.     {
  1155.         $productSql 'SELECT 1 FROM catalog_products WHERE slug = ?';
  1156.         $productParams = [$slug];
  1157.         if ($ignoreProductId !== null) {
  1158.             $productSql .= ' AND id <> ?';
  1159.             $productParams[] = $ignoreProductId;
  1160.         }
  1161.         if ($db->fetchOne($productSql ' LIMIT 1'$productParams)) {
  1162.             return true;
  1163.         }
  1164.         $categorySql 'SELECT 1 FROM catalog_categories WHERE slug = ?';
  1165.         $categoryParams = [$slug];
  1166.         if ($ignoreCategoryId !== null) {
  1167.             $categorySql .= ' AND id <> ?';
  1168.             $categoryParams[] = $ignoreCategoryId;
  1169.         }
  1170.         return (bool) $db->fetchOne($categorySql ' LIMIT 1'$categoryParams);
  1171.     }
  1172.     private function slugify(string $value): string
  1173.     {
  1174.         $converted = @iconv('UTF-8''ASCII//TRANSLIT//IGNORE'$value);
  1175.         $value is_string($converted) && $converted !== '' $converted $value;
  1176.         $value strtolower($value);
  1177.         $value preg_replace('/[^a-z0-9]+/''-'$value);
  1178.         $value trim((string) $value'-');
  1179.         return $value === '' 'item' substr($value0250);
  1180.     }
  1181.     private function nullableNumber($value): ?float
  1182.     {
  1183.         $value str_replace(',''.'trim((string) $value));
  1184.         if ($value === '') {
  1185.             return null;
  1186.         }
  1187.         return round((float) $value2);
  1188.     }
  1189.     private function nullableInt($value): ?int
  1190.     {
  1191.         $value trim((string) $value);
  1192.         return $value === '' null : (int) $value;
  1193.     }
  1194.     private function normalizeDiscount($value): ?float
  1195.     {
  1196.         $value str_replace(',''.'trim((string) $value));
  1197.         if ($value === '') {
  1198.             return null;
  1199.         }
  1200.         $discount = (float) $value;
  1201.         if ($discount <= 0) {
  1202.             return null;
  1203.         }
  1204.         return min(100.0round($discount2));
  1205.     }
  1206.     private function formatMoney(?float $amountstring $currency): string
  1207.     {
  1208.         if ($amount === null) {
  1209.             return 'Цена уточняется';
  1210.         }
  1211.         $suffix $currency === 'RUB' ' ₽' : ($currency === 'USD' ' $' ' ' $currency);
  1212.         return number_format($amount2','' ') . $suffix;
  1213.     }
  1214.     private function assertJson(string $valuestring $label): void
  1215.     {
  1216.         if ($value === '') {
  1217.             return;
  1218.         }
  1219.         json_decode($valuetrue);
  1220.         if (json_last_error() !== JSON_ERROR_NONE) {
  1221.             throw new \InvalidArgumentException($label ': некорректный JSON.');
  1222.         }
  1223.     }
  1224.     private function decodeJson(string $json): array
  1225.     {
  1226.         $decoded json_decode($jsontrue);
  1227.         return is_array($decoded) ? $decoded : [];
  1228.     }
  1229.     private function safeCount(Connection $dbstring $table): int
  1230.     {
  1231.         try {
  1232.             return (int) $db->fetchOne('SELECT COUNT(*) FROM ' $table);
  1233.         } catch (\Throwable $error) {
  1234.             return 0;
  1235.         }
  1236.     }
  1237.     private function ensureUsersSchema(Connection $db): void
  1238.     {
  1239.         $platform $db->getDatabasePlatform()->getName();
  1240.         if (strpos($platform'mysql') !== false) {
  1241.             $db->executeStatement(
  1242.                 "CREATE TABLE IF NOT EXISTS users (
  1243.                     id INT AUTO_INCREMENT NOT NULL,
  1244.                     login VARCHAR(50) NOT NULL,
  1245.                     email VARCHAR(180) NOT NULL,
  1246.                     password_hash VARCHAR(255) NOT NULL,
  1247.                     balance DECIMAL(10, 2) NOT NULL DEFAULT 0,
  1248.                     hash VARCHAR(50) NOT NULL,
  1249.                     img VARCHAR(512) NOT NULL DEFAULT '/assets/profile.svg',
  1250.                     auth_token VARCHAR(225) DEFAULT NULL,
  1251.                     auth_token_hash VARCHAR(64) DEFAULT NULL,
  1252.                     ip VARCHAR(50) DEFAULT '',
  1253.                     ref_id INT NOT NULL DEFAULT 0,
  1254.                     refs INT NOT NULL DEFAULT 0,
  1255.                     created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1256.                     UNIQUE INDEX uniq_users_login (login),
  1257.                     UNIQUE INDEX uniq_users_email (email),
  1258.                     UNIQUE INDEX uniq_users_hash (hash),
  1259.                     UNIQUE INDEX uniq_users_auth_token_hash (auth_token_hash),
  1260.                     PRIMARY KEY(id)
  1261.                 ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB"
  1262.             );
  1263.             return;
  1264.         }
  1265.         $db->executeStatement(
  1266.             "CREATE TABLE IF NOT EXISTS users (
  1267.                 id INTEGER PRIMARY KEY AUTOINCREMENT,
  1268.                 login VARCHAR(50) NOT NULL UNIQUE,
  1269.                 email VARCHAR(180) NOT NULL UNIQUE,
  1270.                 password_hash VARCHAR(255) NOT NULL,
  1271.                 balance NUMERIC NOT NULL DEFAULT 0,
  1272.                 hash VARCHAR(50) NOT NULL UNIQUE,
  1273.                 img VARCHAR(512) NOT NULL DEFAULT '/assets/profile.svg',
  1274.                 auth_token VARCHAR(225) DEFAULT NULL,
  1275.                 auth_token_hash VARCHAR(64) DEFAULT NULL UNIQUE,
  1276.                 ip VARCHAR(50) DEFAULT '',
  1277.                 ref_id INTEGER NOT NULL DEFAULT 0,
  1278.                 refs INTEGER NOT NULL DEFAULT 0,
  1279.                 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  1280.             )"
  1281.         );
  1282.     }
  1283.     private function emptySource(): array
  1284.     {
  1285.         return [
  1286.             'id' => '',
  1287.             'code' => '',
  1288.             'name' => '',
  1289.             'provider' => 'manual',
  1290.             'enabled' => 1,
  1291.             'settings_json' => '[]',
  1292.             'products_count' => 0,
  1293.         ];
  1294.     }
  1295.     private function emptyCategory(): array
  1296.     {
  1297.         return [
  1298.             'id' => '',
  1299.             'source_id' => '',
  1300.             'type_code' => 'manual',
  1301.             'external_code' => '',
  1302.             'name' => '',
  1303.             'slug' => '',
  1304.             'description_html' => '',
  1305.             'image_url' => '',
  1306.             'is_active' => 1,
  1307.             'products_count' => 0,
  1308.         ];
  1309.     }
  1310.     private function emptyProduct(): array
  1311.     {
  1312.         return [
  1313.             'id' => null,
  1314.             'source_id' => null,
  1315.             'category_id' => null,
  1316.             'external_code' => '',
  1317.             'provider' => 'manual',
  1318.             'provider_type' => 'manual',
  1319.             'provider_category_code' => 'MANUAL',
  1320.             'name' => '',
  1321.             'slug' => '',
  1322.             'short_description_html' => '',
  1323.             'description_html' => '',
  1324.             'image_url' => '',
  1325.             'gallery_json' => '[]',
  1326.             'price_amount' => '',
  1327.             'price_currency' => 'USD',
  1328.             'region' => '',
  1329.             'appid' => '',
  1330.             'in_stock' => 1,
  1331.             'stock_quantity' => '',
  1332.             'discount_percent' => '',
  1333.             'forms_json' => '[]',
  1334.             'metadata_json' => '[]',
  1335.             'is_active' => 1,
  1336.             'is_manual' => 1,
  1337.             'category_name' => 'Ручные товары',
  1338.             'source_name' => 'Manual',
  1339.             'delivery_label' => 'Цифровой товар',
  1340.             'price_label' => 'Цена уточняется',
  1341.             'discount_label' => '',
  1342.             'gallery_count' => 0,
  1343.             'forms_count' => 0,
  1344.         ];
  1345.     }
  1346.     private function now(): string
  1347.     {
  1348.         return (new \DateTimeImmutable())->format('Y-m-d H:i:s');
  1349.     }
  1350.     private function nowIso(): string
  1351.     {
  1352.         return (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM);
  1353.     }
  1354. }