src/Controller/Business/IndexController.php line 437

Open in your IDE?
  1. <?php
  2. namespace App\Controller\Business;
  3. use App\Entity\Brand;
  4. use App\Entity\PackageRecipe;
  5. use App\Entity\Shop;
  6. use App\Entity\Shoppingmall;
  7. use App\Entity\User;
  8. use App\Form\HistoryStoresType;
  9. use App\Form\StoresFilterType;
  10. use App\Repository\ShopRepository;
  11. use DateTime;
  12. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  13. use Symfony\Component\HttpFoundation\JsonResponse;
  14. use Symfony\Component\HttpFoundation\Response;
  15. use function array_slice;
  16. use Exception;
  17. use function fgets;
  18. use function fopen;
  19. use Symfony\Component\Form\FormError;
  20. use Symfony\Component\HttpFoundation\Request;
  21. use Symfony\Component\Routing\Annotation\Route;
  22. use Symfony\Contracts\Cache\CacheInterface;
  23. use Symfony\Contracts\Cache\ItemInterface;
  24. class IndexController extends AbstractController
  25. {
  26.     protected $i 0;
  27.     protected $counter 0;
  28.     /**
  29.      * Domovska stranka Business
  30.      *
  31.      *
  32.      * @Route("/", name="index")
  33.      */
  34.     public function index(Request $request)
  35.     {
  36.         return $this->render('business/index/index.html.twig', [
  37.             'controller_name' => 'IndexController',
  38.         ]);
  39.     }
  40.     /**
  41.      * Dashboard (homepage for logged-in users). Renders only widget skeletons;
  42.      * each widget fetches its own data via AJAX so the page is interactive
  43.      * before any data arrives.
  44.      *
  45.      * @Route("/loginsuccess", name="index_loginsuccess")
  46.      */
  47.     public function loginSuccess(Request $request)
  48.     {
  49.         return $this->render('admin/index/dashboard.html.twig');
  50.     }
  51.     /**
  52.      * @Route("/dashboard/packages", name="dashboard_packages", methods={"GET"})
  53.      */
  54.     public function dashboardPackages(CacheInterface $cache)
  55.     {
  56.         $user $this->getUser();
  57.         $isAdmin $this->isGranted('ROLE_ADMIN');
  58.         // Recipe IDs the user is actively subscribed to.
  59.         $activeRecipeIds = [];
  60.         if ($user instanceof User) {
  61.             foreach ($user->getActivePackages() as $package) {
  62.                 $recipe $package->getPackageRecipe();
  63.                 if ($recipe !== null) {
  64.                     $activeRecipeIds[$recipe->getId()] = true;
  65.                 }
  66.             }
  67.         }
  68.         // Per-country malls/stores/brands counts. User-independent and only
  69.         // changes on import, so cache the whole map.
  70.         $countsByCountry $cache->get('dashboard_pkg_counts', function (ItemInterface $item) {
  71.             $item->expiresAfter(3600);
  72.             return $this->computeCountsByCountry();
  73.         });
  74.         // List every package (recipe); flag the user's active ones. Admins have
  75.         // full access, so every package counts as active for them.
  76.         $recipes $this->getDoctrine()->getRepository(PackageRecipe::class)->findBy([], ['name' => 'ASC']);
  77.         $iso self::countryLabelToIso();
  78.         $packages = [];
  79.         foreach ($recipes as $recipe) {
  80.             $countries array_values(array_filter(array_map('trim'$recipe->getCountries())));
  81.             $countriesData array_map(function ($country) use ($countsByCountry$iso) {
  82.                 $c $countsByCountry[$country] ?? ['malls' => 0'stores' => 0'brands' => 0];
  83.                 return [
  84.                     'name'   => $country,
  85.                     'code'   => $iso[$country] ?? null,
  86.                     'malls'  => $c['malls'],
  87.                     'stores' => $c['stores'],
  88.                     'brands' => $c['brands'],
  89.                 ];
  90.             }, $countries);
  91.             $lastUpdate $recipe->getLastUpdateDate();
  92.             $activeFrom $recipe->getActiveFrom();
  93.             $packages[] = [
  94.                 'name'           => $recipe->getName(),
  95.                 'countries'      => $countriesData,
  96.                 'active'         => $isAdmin || isset($activeRecipeIds[$recipe->getId()]),
  97.                 'activeFrom'     => $activeFrom $activeFrom->format('m/Y') : null,
  98.                 'lastUpdateDate' => $lastUpdate $lastUpdate->format('m/Y') : null,
  99.             ];
  100.         }
  101.         // Active packages first (alphabetical within each group — recipes were
  102.         // loaded name-sorted and usort is stable on PHP 8+).
  103.         usort($packages, fn($a$b) => ($b['active'] <=> $a['active']));
  104.         return new JsonResponse([
  105.             'packages' => $packages,
  106.             'isAdmin'  => $isAdmin,
  107.         ]);
  108.     }
  109.     /**
  110.      * Per-country active malls / stores / brands counts, keyed by country label.
  111.      * Three separate GROUP BY aggregations on purpose: a single triple LEFT JOIN
  112.      * over cities × shops × shop_has_brand explodes into a cartesian product
  113.      * (~20s). Split, each grouping stays cheap (~2s total, then cached).
  114.      *
  115.      * All three lean on idx_shops_active_deleted_city (see
  116.      * Version20260908120000) so the shops side is covering and the rows arrive
  117.      * already grouped by country.
  118.      *
  119.      * @return array<string, array{malls:int, stores:int, brands:int}>
  120.      */
  121.     private function computeCountsByCountry(): array
  122.     {
  123.         $conn $this->getDoctrine()->getManager()->getConnection();
  124.         $counts = [];
  125.         $touch = function (string $country) use (&$counts) {
  126.             if (!isset($counts[$country])) {
  127.                 $counts[$country] = ['malls' => 0'stores' => 0'brands' => 0];
  128.             }
  129.         };
  130.         $malls $conn->executeQuery("
  131.             SELECT c.country AS country, COUNT(DISTINCT m.shoppingmall_id) AS cnt
  132.             FROM shoppingmalls m
  133.             INNER JOIN cities c ON m.city = c.city_id
  134.             WHERE m.is_active = 1 AND c.country IS NOT NULL AND c.country <> ''
  135.             GROUP BY c.country
  136.         ")->fetchAllAssociative();
  137.         foreach ($malls as $r) { $touch($r['country']); $counts[$r['country']]['malls'] = (int) $r['cnt']; }
  138.         $stores $conn->executeQuery("
  139.             SELECT c.country AS country, COUNT(*) AS cnt
  140.             FROM shops s
  141.             INNER JOIN cities c ON s.city = c.city_id
  142.             WHERE s.is_active = 1 AND s.is_deleted = 0 AND c.country IS NOT NULL AND c.country <> ''
  143.             GROUP BY c.country
  144.         ")->fetchAllAssociative();
  145.         foreach ($stores as $r) { $touch($r['country']); $counts[$r['country']]['stores'] = (int) $r['cnt']; }
  146.         $brands $conn->executeQuery("
  147.             SELECT t.country AS country, COUNT(*) AS cnt
  148.             FROM (
  149.                 SELECT DISTINCT c.country AS country, sb.brand_id AS brand_id
  150.                 FROM shop_has_brand sb
  151.                 INNER JOIN shops s ON sb.shop_id = s.shop_id
  152.                 INNER JOIN cities c ON s.city = c.city_id
  153.                 WHERE s.is_active = 1 AND s.is_deleted = 0 AND c.country IS NOT NULL AND c.country <> ''
  154.             ) t
  155.             GROUP BY t.country
  156.         ")->fetchAllAssociative();
  157.         foreach ($brands as $r) { $touch($r['country']); $counts[$r['country']]['brands'] = (int) $r['cnt']; }
  158.         return $counts;
  159.     }
  160.     /**
  161.      * Latest published news for the dashboard teaser (max 3).
  162.      *
  163.      * @Route("/dashboard/news", name="dashboard_news", methods={"GET"})
  164.      */
  165.     public function dashboardNews(\App\Repository\NewsRepository $newsRepository)
  166.     {
  167.         $items = [];
  168.         foreach ($newsRepository->findPublished(3) as $news) {
  169.             $items[] = [
  170.                 'id'        => $news->getId(),
  171.                 'title'     => $news->getTitle(),
  172.                 'shortDesc' => $news->getShortDescription(),
  173.                 'createdAt' => $news->getCreatedAt() ? $news->getCreatedAt()->format('d.m.Y') : null,
  174.             ];
  175.         }
  176.         return new JsonResponse([
  177.             'news'    => $items,
  178.             'allUrl'  => $this->generateUrl('news_index'),
  179.         ]);
  180.     }
  181.     /**
  182.      * @Route("/dashboard/counts", name="dashboard_counts", methods={"GET"})
  183.      */
  184.     public function dashboardCounts()
  185.     {
  186.         $doctrine $this->getDoctrine();
  187.         $countMalls   $doctrine->getRepository(Shoppingmall::class)->getCountActive()[1];
  188.         $countStores  $doctrine->getRepository(Shop::class)->getCountActive()[1];
  189.         $countBrands  $doctrine->getRepository(Brand::class)->getCountActive()[1];
  190.         return new JsonResponse([
  191.             'malls'  => (int) $countMalls,
  192.             'stores' => (int) $countStores,
  193.             'brands' => (int) $countBrands,
  194.         ]);
  195.     }
  196.     /**
  197.      * Newcomers AJAX feed for the dashboard map widget.
  198.      *
  199.      * A "newcomer" = first appearance of a (country, shop_name) pair.
  200.      * shops.created_at = '0000-00-00 00:00:00' is excluded as unreliable.
  201.      *
  202.      * @Route("/dashboard/newcomers", name="dashboard_newcomers", methods={"GET"})
  203.      */
  204.     public function newcomers(Request $requestCacheInterface $cache)
  205.     {
  206.         $em $this->getDoctrine()->getManager();
  207.         $conn $em->getConnection();
  208.         // Default year/month is the latest is_new_date in DB; the widget uses
  209.         // these on first load (without explicit year/month query params) and
  210.         // also picks up the available years for its selector. Cached — it only
  211.         // moves when a new import lands.
  212.         $defaults $cache->get('dashboard_newcomers_defaults', function (ItemInterface $item) use ($conn) {
  213.             $item->expiresAfter(3600);
  214.             $raw $conn->executeQuery(
  215.                 "SELECT MAX(is_new_date) FROM shops WHERE is_new_date IS NOT NULL"
  216.             )->fetchOne();
  217.             $latest $raw ? new DateTime($raw) : new DateTime();
  218.             return ['year' => (int) $latest->format('Y'), 'month' => (int) $latest->format('n')];
  219.         });
  220.         $defaultYear  $defaults['year'];
  221.         $defaultMonth $defaults['month'];
  222.         $year  = (int) $request->query->get('year'$defaultYear);
  223.         $month = (int) $request->query->get('month'$defaultMonth);
  224.         // Per-user accessibility — cheap, computed fresh (never cached, since it
  225.         // depends on the logged-in user's packages).
  226.         $isAdmin $this->isGranted('ROLE_ADMIN');
  227.         $allowedCountries = [];
  228.         $user $this->getUser();
  229.         if ($user instanceof User) {
  230.             foreach ($user->getActivePackages() as $package) {
  231.                 $recipe $package->getPackageRecipe();
  232.                 if ($recipe === null) continue;
  233.                 foreach ($recipe->getCountries() as $country) {
  234.                     $country trim($country);
  235.                     if ($country !== '') {
  236.                         $allowedCountries[$country] = true;
  237.                     }
  238.                 }
  239.             }
  240.         }
  241.         // Heavy, user-independent aggregation. A single window-function pass over
  242.         // all shops yields, for the chosen month, every shop whose (country, name)
  243.         // makes its first-ever appearance that month. Country counts, the
  244.         // newcomer name list and the per-country shop IDs are all derived from
  245.         // this one result set in PHP — the previous version ran the same
  246.         // "first appearance" scan three times (~6s). Cached per (year, month);
  247.         // the underlying data only changes when an import adds is_new_date.
  248.         $raw $cache->get("dashboard_newcomers_{$year}_{$month}", function (ItemInterface $item) use ($conn$year$month) {
  249.             $item->expiresAfter(3600);
  250.             $sql "
  251.                 SELECT shop_id, country, shop_name, first_seen FROM (
  252.                     SELECT s.shop_id, c.country AS country, s.name AS shop_name,
  253.                            s.is_new_date AS first_seen,
  254.                            MIN(s.is_new_date) OVER (PARTITION BY c.country, s.name) AS min_d
  255.                     FROM shops s
  256.                     INNER JOIN cities c ON s.city = c.city_id
  257.                     WHERE s.is_new_date IS NOT NULL
  258.                       AND s.name IS NOT NULL AND s.name <> ''
  259.                       AND c.country IS NOT NULL AND c.country <> ''
  260.                 ) t
  261.                 WHERE first_seen = min_d
  262.                   AND YEAR(first_seen) = :year AND MONTH(first_seen) = :month
  263.                 ORDER BY country, shop_name
  264.             ";
  265.             $stmt $conn->prepare($sql);
  266.             $stmt->bindValue('year'$year\PDO::PARAM_INT);
  267.             $stmt->bindValue('month'$month\PDO::PARAM_INT);
  268.             $rows $stmt->executeQuery()->fetchAllAssociative();
  269.             $iso self::countryLabelToIso();
  270.             $idsByCountry  = []; // country => [shop_id, ...] — all ties; drives the Stores pre-filter
  271.             $countByCountry = []; // country => number of distinct (country, name) newcomers
  272.             $items   = [];        // distinct (country, name), already ordered by the SQL
  273.             $seenName = [];
  274.             foreach ($rows as $row) {
  275.                 $country $row['country'];
  276.                 $idsByCountry[$country][] = (int) $row['shop_id'];
  277.                 $key $country '|' $row['shop_name'];
  278.                 if (!isset($seenName[$key])) {
  279.                     $seenName[$key] = true;
  280.                     $countByCountry[$country] = ($countByCountry[$country] ?? 0) + 1;
  281.                     $items[] = [
  282.                         'name'         => $row['shop_name'],
  283.                         'country'      => $country,
  284.                         'country_code' => $iso[$country] ?? null,
  285.                         'first_seen'   => $row['first_seen'],
  286.                     ];
  287.                 }
  288.             }
  289.             $countries = [];
  290.             foreach ($countByCountry as $country => $count) {
  291.                 $countries[] = [
  292.                     'country'  => $country,
  293.                     'code'     => $iso[$country] ?? null,
  294.                     'count'    => $count,
  295.                     'shop_ids' => $idsByCountry[$country] ?? [],
  296.                 ];
  297.             }
  298.             usort($countries, fn($a$b) => $b['count'] <=> $a['count']);
  299.             return [
  300.                 'countries' => $countries,
  301.                 'items'     => array_slice($items0200),
  302.             ];
  303.         });
  304.         // Layer per-user accessibility onto the cached, user-independent aggregate.
  305.         $countries = [];
  306.         $totalAccessible 0;
  307.         foreach ($raw['countries'] as $row) {
  308.             $accessible $isAdmin || isset($allowedCountries[$row['country']]);
  309.             if ($accessible$totalAccessible += $row['count'];
  310.             $countries[] = $row + ['accessible' => $accessible];
  311.         }
  312.         $items = [];
  313.         foreach ($raw['items'] as $row) {
  314.             $accessible $isAdmin || isset($allowedCountries[$row['country']]);
  315.             $items[] = $row + ['accessible' => $accessible];
  316.         }
  317.         return new JsonResponse([
  318.             'year'           => $year,
  319.             'month'          => $month,
  320.             'countries'      => $countries,
  321.             'items'          => $items,
  322.             'total'          => $totalAccessible,
  323.             'defaultYear'    => $defaultYear,
  324.             'defaultMonth'   => $defaultMonth,
  325.             'availableYears' => range($defaultYear2020),
  326.         ]);
  327.     }
  328.     /**
  329.      * Country label (as stored in cities.country) to ISO 3166-1 alpha-2.
  330.      * Used by jvectormap world_mill region keys.
  331.      *
  332.      * @return array<string,string>
  333.      */
  334.     public static function countryLabelToIso(): array
  335.     {
  336.         return [
  337.             'Austria'        => 'AT',
  338.             'Belgium'        => 'BE',
  339.             'Bulgaria'       => 'BG',
  340.             'Canada'         => 'CA',
  341.             'Croatia'        => 'HR',
  342.             'Cyprus'         => 'CY',
  343.             'Czech Republic' => 'CZ',
  344.             'Denmark'        => 'DK',
  345.             'Estonia'        => 'EE',
  346.             'Finland'        => 'FI',
  347.             'France'         => 'FR',
  348.             'Germany'        => 'DE',
  349.             'Greece'         => 'GR',
  350.             'Hungary'        => 'HU',
  351.             'Ireland'        => 'IE',
  352.             'Italy'          => 'IT',
  353.             'Latvia'         => 'LV',
  354.             'Lithuania'      => 'LT',
  355.             'Mexico'         => 'MX',
  356.             'Montenegro'     => 'ME',
  357.             'Netherlands'    => 'NL',
  358.             'Norway'         => 'NO',
  359.             'Poland'         => 'PL',
  360.             'Portugal'       => 'PT',
  361.             'Romania'        => 'RO',
  362.             'Russia'         => 'RU',
  363.             'San Marino'     => 'SM',
  364.             'Serbia'         => 'RS',
  365.             'Slovakia'       => 'SK',
  366.             'Slovenia'       => 'SI',
  367.             'Spain'          => 'ES',
  368.             'Sweden'         => 'SE',
  369.             'Switzerland'    => 'CH',
  370.             'UAE'            => 'AE',
  371.             'Ukraine'        => 'UA',
  372.             'United Kingdom' => 'GB',
  373.             'USA'            => 'US',
  374.         ];
  375.     }
  376.     /**
  377.      * FAQ Page
  378.      *
  379.      * @Route("/faq", name="faq")
  380.      */
  381.     public function faq(Request $request)
  382.     {
  383.         return $this->render('admin/index/faq.html.twig', [
  384.             'controller_name' => 'IndexController',
  385.         ]);
  386.     }
  387.      /**
  388.      * Domovska stranka Business - rozcestnik
  389.      *
  390.      *
  391.      * @Route("/welcome", name="admin_index_welcome")
  392.      */
  393.     public function welcome(Request $request)
  394.     {
  395.         return $this->render('admin/index/index.html.twig', [
  396.             'controller_name' => 'IndexController',
  397.         ]);
  398.     }
  399.     protected function render(string $view, array $parameters = [], Response $response null): Response
  400.     {
  401.         $docrine $this->getDoctrine();
  402.         $countBrands $docrine->getRepository(Brand::class)->getCountActive();
  403.         $countStores $docrine->getRepository(Shop::class)->getCountActive();
  404.         $countMalls $docrine->getRepository(Shoppingmall::class)->getCountActive();
  405.         $parameters['countBrands'] = $countBrands[1];
  406.         $parameters['countStores'] = $countStores[1];
  407.         $parameters['countMalls'] = $countMalls[1];
  408.         return parent::render($view$parameters$response);
  409.     }
  410. }