src/Controller/Business/IndexController.php line 38

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 (~6s total, then cached).
  114.      *
  115.      * @return array<string, array{malls:int, stores:int, brands:int}>
  116.      */
  117.     private function computeCountsByCountry(): array
  118.     {
  119.         $conn $this->getDoctrine()->getManager()->getConnection();
  120.         $counts = [];
  121.         $touch = function (string $country) use (&$counts) {
  122.             if (!isset($counts[$country])) {
  123.                 $counts[$country] = ['malls' => 0'stores' => 0'brands' => 0];
  124.             }
  125.         };
  126.         $malls $conn->executeQuery("
  127.             SELECT c.country AS country, COUNT(DISTINCT m.shoppingmall_id) AS cnt
  128.             FROM shoppingmalls m
  129.             INNER JOIN cities c ON m.city = c.city_id
  130.             WHERE m.is_active = 1 AND c.country IS NOT NULL AND c.country <> ''
  131.             GROUP BY c.country
  132.         ")->fetchAllAssociative();
  133.         foreach ($malls as $r) { $touch($r['country']); $counts[$r['country']]['malls'] = (int) $r['cnt']; }
  134.         $stores $conn->executeQuery("
  135.             SELECT c.country AS country, COUNT(DISTINCT s.shop_id) AS cnt
  136.             FROM shops s
  137.             INNER JOIN cities c ON s.city = c.city_id
  138.             WHERE s.is_active = 1 AND s.is_deleted = 0 AND c.country IS NOT NULL AND c.country <> ''
  139.             GROUP BY c.country
  140.         ")->fetchAllAssociative();
  141.         foreach ($stores as $r) { $touch($r['country']); $counts[$r['country']]['stores'] = (int) $r['cnt']; }
  142.         $brands $conn->executeQuery("
  143.             SELECT c.country AS country, COUNT(DISTINCT sb.brand_id) AS cnt
  144.             FROM shop_has_brand sb
  145.             INNER JOIN shops s ON sb.shop_id = s.shop_id
  146.             INNER JOIN cities c ON s.city = c.city_id
  147.             WHERE s.is_active = 1 AND s.is_deleted = 0 AND c.country IS NOT NULL AND c.country <> ''
  148.             GROUP BY c.country
  149.         ")->fetchAllAssociative();
  150.         foreach ($brands as $r) { $touch($r['country']); $counts[$r['country']]['brands'] = (int) $r['cnt']; }
  151.         return $counts;
  152.     }
  153.     /**
  154.      * Latest published news for the dashboard teaser (max 3).
  155.      *
  156.      * @Route("/dashboard/news", name="dashboard_news", methods={"GET"})
  157.      */
  158.     public function dashboardNews(\App\Repository\NewsRepository $newsRepository)
  159.     {
  160.         $items = [];
  161.         foreach ($newsRepository->findPublished(3) as $news) {
  162.             $items[] = [
  163.                 'id'        => $news->getId(),
  164.                 'title'     => $news->getTitle(),
  165.                 'shortDesc' => $news->getShortDescription(),
  166.                 'createdAt' => $news->getCreatedAt() ? $news->getCreatedAt()->format('d.m.Y') : null,
  167.             ];
  168.         }
  169.         return new JsonResponse([
  170.             'news'    => $items,
  171.             'allUrl'  => $this->generateUrl('news_index'),
  172.         ]);
  173.     }
  174.     /**
  175.      * @Route("/dashboard/counts", name="dashboard_counts", methods={"GET"})
  176.      */
  177.     public function dashboardCounts()
  178.     {
  179.         $doctrine $this->getDoctrine();
  180.         $countMalls   $doctrine->getRepository(Shoppingmall::class)->getCountActive()[1];
  181.         $countStores  $doctrine->getRepository(Shop::class)->getCountActive()[1];
  182.         $countBrands  $doctrine->getRepository(Brand::class)->getCountActive()[1];
  183.         return new JsonResponse([
  184.             'malls'  => (int) $countMalls,
  185.             'stores' => (int) $countStores,
  186.             'brands' => (int) $countBrands,
  187.         ]);
  188.     }
  189.     /**
  190.      * Newcomers AJAX feed for the dashboard map widget.
  191.      *
  192.      * A "newcomer" = first appearance of a (country, shop_name) pair.
  193.      * shops.created_at = '0000-00-00 00:00:00' is excluded as unreliable.
  194.      *
  195.      * @Route("/dashboard/newcomers", name="dashboard_newcomers", methods={"GET"})
  196.      */
  197.     public function newcomers(Request $requestCacheInterface $cache)
  198.     {
  199.         $em $this->getDoctrine()->getManager();
  200.         $conn $em->getConnection();
  201.         // Default year/month is the latest is_new_date in DB; the widget uses
  202.         // these on first load (without explicit year/month query params) and
  203.         // also picks up the available years for its selector. Cached — it only
  204.         // moves when a new import lands.
  205.         $defaults $cache->get('dashboard_newcomers_defaults', function (ItemInterface $item) use ($conn) {
  206.             $item->expiresAfter(3600);
  207.             $raw $conn->executeQuery(
  208.                 "SELECT MAX(is_new_date) FROM shops WHERE is_new_date IS NOT NULL"
  209.             )->fetchOne();
  210.             $latest $raw ? new DateTime($raw) : new DateTime();
  211.             return ['year' => (int) $latest->format('Y'), 'month' => (int) $latest->format('n')];
  212.         });
  213.         $defaultYear  $defaults['year'];
  214.         $defaultMonth $defaults['month'];
  215.         $year  = (int) $request->query->get('year'$defaultYear);
  216.         $month = (int) $request->query->get('month'$defaultMonth);
  217.         // Per-user accessibility — cheap, computed fresh (never cached, since it
  218.         // depends on the logged-in user's packages).
  219.         $isAdmin $this->isGranted('ROLE_ADMIN');
  220.         $allowedCountries = [];
  221.         $user $this->getUser();
  222.         if ($user instanceof User) {
  223.             foreach ($user->getActivePackages() as $package) {
  224.                 $recipe $package->getPackageRecipe();
  225.                 if ($recipe === null) continue;
  226.                 foreach ($recipe->getCountries() as $country) {
  227.                     $country trim($country);
  228.                     if ($country !== '') {
  229.                         $allowedCountries[$country] = true;
  230.                     }
  231.                 }
  232.             }
  233.         }
  234.         // Heavy, user-independent aggregation. A single window-function pass over
  235.         // all shops yields, for the chosen month, every shop whose (country, name)
  236.         // makes its first-ever appearance that month. Country counts, the
  237.         // newcomer name list and the per-country shop IDs are all derived from
  238.         // this one result set in PHP — the previous version ran the same
  239.         // "first appearance" scan three times (~6s). Cached per (year, month);
  240.         // the underlying data only changes when an import adds is_new_date.
  241.         $raw $cache->get("dashboard_newcomers_{$year}_{$month}", function (ItemInterface $item) use ($conn$year$month) {
  242.             $item->expiresAfter(3600);
  243.             $sql "
  244.                 SELECT shop_id, country, shop_name, first_seen FROM (
  245.                     SELECT s.shop_id, c.country AS country, s.name AS shop_name,
  246.                            s.is_new_date AS first_seen,
  247.                            MIN(s.is_new_date) OVER (PARTITION BY c.country, s.name) AS min_d
  248.                     FROM shops s
  249.                     INNER JOIN cities c ON s.city = c.city_id
  250.                     WHERE s.is_new_date IS NOT NULL
  251.                       AND s.name IS NOT NULL AND s.name <> ''
  252.                       AND c.country IS NOT NULL AND c.country <> ''
  253.                 ) t
  254.                 WHERE first_seen = min_d
  255.                   AND YEAR(first_seen) = :year AND MONTH(first_seen) = :month
  256.                 ORDER BY country, shop_name
  257.             ";
  258.             $stmt $conn->prepare($sql);
  259.             $stmt->bindValue('year'$year\PDO::PARAM_INT);
  260.             $stmt->bindValue('month'$month\PDO::PARAM_INT);
  261.             $rows $stmt->executeQuery()->fetchAllAssociative();
  262.             $iso self::countryLabelToIso();
  263.             $idsByCountry  = []; // country => [shop_id, ...] — all ties; drives the Stores pre-filter
  264.             $countByCountry = []; // country => number of distinct (country, name) newcomers
  265.             $items   = [];        // distinct (country, name), already ordered by the SQL
  266.             $seenName = [];
  267.             foreach ($rows as $row) {
  268.                 $country $row['country'];
  269.                 $idsByCountry[$country][] = (int) $row['shop_id'];
  270.                 $key $country '|' $row['shop_name'];
  271.                 if (!isset($seenName[$key])) {
  272.                     $seenName[$key] = true;
  273.                     $countByCountry[$country] = ($countByCountry[$country] ?? 0) + 1;
  274.                     $items[] = [
  275.                         'name'         => $row['shop_name'],
  276.                         'country'      => $country,
  277.                         'country_code' => $iso[$country] ?? null,
  278.                         'first_seen'   => $row['first_seen'],
  279.                     ];
  280.                 }
  281.             }
  282.             $countries = [];
  283.             foreach ($countByCountry as $country => $count) {
  284.                 $countries[] = [
  285.                     'country'  => $country,
  286.                     'code'     => $iso[$country] ?? null,
  287.                     'count'    => $count,
  288.                     'shop_ids' => $idsByCountry[$country] ?? [],
  289.                 ];
  290.             }
  291.             usort($countries, fn($a$b) => $b['count'] <=> $a['count']);
  292.             return [
  293.                 'countries' => $countries,
  294.                 'items'     => array_slice($items0200),
  295.             ];
  296.         });
  297.         // Layer per-user accessibility onto the cached, user-independent aggregate.
  298.         $countries = [];
  299.         $totalAccessible 0;
  300.         foreach ($raw['countries'] as $row) {
  301.             $accessible $isAdmin || isset($allowedCountries[$row['country']]);
  302.             if ($accessible$totalAccessible += $row['count'];
  303.             $countries[] = $row + ['accessible' => $accessible];
  304.         }
  305.         $items = [];
  306.         foreach ($raw['items'] as $row) {
  307.             $accessible $isAdmin || isset($allowedCountries[$row['country']]);
  308.             $items[] = $row + ['accessible' => $accessible];
  309.         }
  310.         return new JsonResponse([
  311.             'year'           => $year,
  312.             'month'          => $month,
  313.             'countries'      => $countries,
  314.             'items'          => $items,
  315.             'total'          => $totalAccessible,
  316.             'defaultYear'    => $defaultYear,
  317.             'defaultMonth'   => $defaultMonth,
  318.             'availableYears' => range($defaultYear2020),
  319.         ]);
  320.     }
  321.     /**
  322.      * Country label (as stored in cities.country) to ISO 3166-1 alpha-2.
  323.      * Used by jvectormap world_mill region keys.
  324.      *
  325.      * @return array<string,string>
  326.      */
  327.     public static function countryLabelToIso(): array
  328.     {
  329.         return [
  330.             'Austria'        => 'AT',
  331.             'Belgium'        => 'BE',
  332.             'Bulgaria'       => 'BG',
  333.             'Canada'         => 'CA',
  334.             'Croatia'        => 'HR',
  335.             'Cyprus'         => 'CY',
  336.             'Czech Republic' => 'CZ',
  337.             'Denmark'        => 'DK',
  338.             'Estonia'        => 'EE',
  339.             'Finland'        => 'FI',
  340.             'France'         => 'FR',
  341.             'Germany'        => 'DE',
  342.             'Greece'         => 'GR',
  343.             'Hungary'        => 'HU',
  344.             'Ireland'        => 'IE',
  345.             'Italy'          => 'IT',
  346.             'Latvia'         => 'LV',
  347.             'Lithuania'      => 'LT',
  348.             'Mexico'         => 'MX',
  349.             'Montenegro'     => 'ME',
  350.             'Netherlands'    => 'NL',
  351.             'Norway'         => 'NO',
  352.             'Poland'         => 'PL',
  353.             'Portugal'       => 'PT',
  354.             'Romania'        => 'RO',
  355.             'Russia'         => 'RU',
  356.             'San Marino'     => 'SM',
  357.             'Serbia'         => 'RS',
  358.             'Slovakia'       => 'SK',
  359.             'Slovenia'       => 'SI',
  360.             'Spain'          => 'ES',
  361.             'Sweden'         => 'SE',
  362.             'Switzerland'    => 'CH',
  363.             'UAE'            => 'AE',
  364.             'Ukraine'        => 'UA',
  365.             'United Kingdom' => 'GB',
  366.             'USA'            => 'US',
  367.         ];
  368.     }
  369.     /**
  370.      * FAQ Page
  371.      *
  372.      * @Route("/faq", name="faq")
  373.      */
  374.     public function faq(Request $request)
  375.     {
  376.         return $this->render('admin/index/faq.html.twig', [
  377.             'controller_name' => 'IndexController',
  378.         ]);
  379.     }
  380.      /**
  381.      * Domovska stranka Business - rozcestnik
  382.      *
  383.      *
  384.      * @Route("/welcome", name="admin_index_welcome")
  385.      */
  386.     public function welcome(Request $request)
  387.     {
  388.         return $this->render('admin/index/index.html.twig', [
  389.             'controller_name' => 'IndexController',
  390.         ]);
  391.     }
  392.     protected function render(string $view, array $parameters = [], Response $response null): Response
  393.     {
  394.         $docrine $this->getDoctrine();
  395.         $countBrands $docrine->getRepository(Brand::class)->getCountActive();
  396.         $countStores $docrine->getRepository(Shop::class)->getCountActive();
  397.         $countMalls $docrine->getRepository(Shoppingmall::class)->getCountActive();
  398.         $parameters['countBrands'] = $countBrands[1];
  399.         $parameters['countStores'] = $countStores[1];
  400.         $parameters['countMalls'] = $countMalls[1];
  401.         return parent::render($view$parameters$response);
  402.     }
  403. }