src/Command/DetectInvalidPricesCommand.php line 43

Open in your IDE?
  1. <?php
  2. namespace App\Command;
  3. use App\Entity\Currency;
  4. use App\Entity\ProductPrice;
  5. use Doctrine\ORM\EntityManagerInterface;
  6. use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
  7. use Symfony\Component\Console\Helper\Table;
  8. use Symfony\Component\Console\Input\InputInterface;
  9. use Symfony\Component\Console\Output\OutputInterface;
  10. use Symfony\Component\DependencyInjection\ContainerInterface;
  11. use Symfony\Component\Console\Command\Command;
  12. /**
  13.  * Symfony console command that scans every price in every language version of the store,
  14.  * compares it with the reference Polish (language‑id = 1) price and reports anomalies.
  15.  *
  16.  * An anomaly is logged when:
  17.  *   1. A numeric price is identical in the foreign language and in Polish
  18.  *      (e.g. 400 PLN vs. 400 EUR).
  19.  *   2. After converting the foreign price to PLN using Currency.factorPln the value differs
  20.  *      by more than ±50 % (ratio > 1.5 or < 0.6667) from the Polish price.
  21.  * Only prices that belong to the same *variant combination* are compared.
  22.  * A variant combination is identified by the set of ProductParameterValue ids linked
  23.  * to a ProductPrice through ProductPriceVariants.
  24.  *
  25.  * The command prints a nice table to the CLI **and** mails an HTML report via
  26.  * GetResponse transactional‑emails API.  Hook it into a cron‑job (e.g. at 6:00 every morning)
  27.  * or use Symfony Scheduler so that your team has fresh data each day.
  28.  */
  29. class DetectInvalidPricesCommand extends Command
  30. {
  31.     protected static $defaultName 'app:detect-invalid-prices';
  32.     protected function configure()
  33.     {
  34.         $this->setName('app:detect-invalid-prices')
  35.             ->setDescription('Detect invalid prices');
  36.     }
  37.     public function __construct($name nullContainerInterface $container)
  38.     {
  39.         parent::__construct($name);
  40.         $this->container $container;
  41.     }
  42.     protected function execute(InputInterface $inputOutputInterface $output): int
  43.     {
  44.         $em   $this->container->get('doctrine')->getManager();
  45.         $twig $this->container->get('twig');
  46.         // 1. Cache currencies.
  47.         $currencies = [];
  48.         foreach ($em->getRepository(Currency::class)->findAll() as $currency) {
  49.             $currencies[$currency->getId()] = $currency;
  50.         }
  51.         // 2. Reference (Polish) prices.
  52.         $plPrices $this->getPricesIndexed($em1);
  53.         // 3. Active foreign languages.
  54.         $foreignLangs $em->getRepository(\App\Entity\Language::class)
  55.             ->createQueryBuilder('l')
  56.             ->where('l.id != 1')
  57.             ->andWhere('l.isActive = 1')
  58.             ->getQuery()
  59.             ->getResult();
  60.         $rows = [];
  61.         foreach ($foreignLangs as $lang) {
  62.             $langPrices $this->getPricesIndexed($em$lang->getId());
  63.             foreach ($langPrices as $productId => $variants) {
  64.                 foreach ($variants as $variantKey => $pp) {
  65.                     if (!isset($plPrices[$productId][$variantKey])) {
  66.                         continue; // No reference price.
  67.                     }
  68.                     $plPP         $plPrices[$productId][$variantKey];
  69.                     $plPrice      = (float) $plPP->getPrice();
  70.                     $foreignPrice = (float) $pp->getPrice();
  71.                     $currency     $currencies[$pp->getCurrency()->getId()];
  72.                     $factor = (float) $currency->getFactorPln();
  73.                     if ($factor <= 0) {
  74.                         // mis-configured currency – skip comparison
  75.                         continue;
  76.                     }
  77.                     $foreignInPln $foreignPrice $factor;   // correct way
  78.                     $ratio        $plPrice $foreignInPln $plPrice 1;
  79.                     $numericEqual abs($foreignPrice $plPrice) < 0.01;
  80.                     $outOfRange   $ratio 1.5 || $ratio < (1.5);
  81.                     if (!$numericEqual && !$outOfRange) {
  82.                         continue; // looks fine
  83.                     }
  84.                     $rows[] = [
  85.                         'lang'          => strtoupper($lang->getLocale()),
  86.                         'product'       => sprintf('%s (ID %d)'$pp->getProduct()->getName(), $productId),
  87.                         'variant'       => $this->getVariantLabel($pp),
  88.                         'pl_price'      => number_format($plPrice2','' ') . ' zł',
  89.                         'foreign_price' => number_format($foreignPrice2','' ') . ' ' $currency->getSign(),
  90.                         'pln_value'     => number_format($foreignInPln2','' ') . ' zł',
  91.                         'ratio'         => number_format($ratio2),
  92.                         'reason'        => $numericEqual 'Same numeric price' 'Outside ±50 %',
  93.                     ];
  94.                 }
  95.             }
  96.         }
  97.         if (!$rows) {
  98.             $output->writeln('<info>No anomalies detected – prices look good 🙂</info>');
  99.             return 0;
  100.         }
  101.         (new Table($output))->setHeaders(array_keys($rows[0]))->setRows($rows)->render();
  102.         $html $twig->render('emails/invalid_prices.html.twig', [
  103.             'rows'        => $rows,
  104.             'generatedAt' => new \DateTimeImmutable('now', new \DateTimeZone('Europe/Warsaw')),
  105.         ]);
  106.         $this->sendEmailViaGetResponse($html);
  107.         $output->writeln(sprintf('<info>Email sent with %d anomaly%s.</info>'count($rows), count($rows) === '' 'ies'));
  108.         return 0;
  109.     }
  110.     /**
  111.      * Returns active prices **for visible products** in the given language, indexed by [productId][variantKey].
  112.      */
  113.     private function getPricesIndexed(EntityManagerInterface $emint $languageId): array
  114.     {
  115.         $qb $em->createQueryBuilder()
  116.             ->select('pp, v, p')
  117.             ->from(ProductPrice::class, 'pp')
  118.             ->leftJoin('pp.variants''v')
  119.             ->innerJoin('pp.product''p')
  120.             ->innerJoin('App\\Entity\\ProductLangParam''plp''WITH''plp.product = p AND plp.language = :lang AND plp.deletedBy IS NULL')
  121.             ->where('pp.language = :lang')
  122.             ->andWhere('pp.active = 1')
  123.             ->andWhere('pp.deletedBy IS NULL')
  124.             ->andWhere('p.deletedBy IS NULL')
  125.             // Product must be visible in that language; if no ProductLangParam row exists, we treat it as visible.
  126.             ->andWhere('(plp.id IS NULL OR plp.visible = 1)')
  127.             ->setParameter('lang'$languageId);
  128.         $indexed = [];
  129.         foreach ($qb->getQuery()->getResult() as $pp) {
  130.             $productId  $pp->getProduct()->getId();
  131.             $variantKey $this->buildVariantKey($pp);
  132.             $indexed[$productId][$variantKey] = $pp;
  133.         }
  134.         return $indexed;
  135.     }
  136.     /**
  137.      * Builds a stable key (e.g. v12‑g3) covering both single values and value‑groups.
  138.      */
  139.     private function buildVariantKey(ProductPrice $pp): string
  140.     {
  141.         if ($pp->getVariants()->isEmpty()) {
  142.             return '_base';
  143.         }
  144.         $keys = [];
  145.         foreach ($pp->getVariants() as $variant) {
  146.             if ($variant->getParameterValue()) {
  147.                 $keys[] = 'v' $variant->getParameterValue()->getId();
  148.             } elseif ($variant->getParameterValueGroup()) {
  149.                 $keys[] = 'g' $variant->getParameterValueGroup()->getId();
  150.             }
  151.         }
  152.         sort($keys);
  153.         return implode('-'$keys);
  154.     }
  155.     private function getVariantLabel(ProductPrice $pp): string
  156.     {
  157.         if ($pp->getVariants()->isEmpty()) {
  158.             return '—';
  159.         }
  160.         $parts = [];
  161.         foreach ($pp->getVariants() as $variant) {
  162.             $paramName $variant->getParameter()->getName();
  163.             if ($variant->getParameterValue()) {
  164.                 $valueName $variant->getParameterValue()->getName();
  165.             } elseif ($variant->getParameterValueGroup()) {
  166.                 $valueName $variant->getParameterValueGroup()->getName();
  167.             } else {
  168.                 $valueName '';
  169.             }
  170.             $parts[] = sprintf('%s: %s'$paramName$valueName);
  171.         }
  172.         return implode(', '$parts);
  173.     }
  174.     private function sendEmailViaGetResponse($htmlBody)
  175.     {
  176.         $content = [
  177.             'fromField' => ['fromFieldId' => 'f'],
  178.             'subject' => 'Invalid prices report',
  179.             'content' => [
  180.                 'html' => $htmlBody,
  181.                 'plain' => strip_tags($htmlBody),
  182.             ],
  183.             'recipients' => [
  184.                 'to' => ['email' => 'krzysiek.gaudy@gmail.com']
  185.             ],
  186.         ];
  187.         $body json_encode($content);
  188.         $ch curl_init();
  189.         curl_setopt($chCURLOPT_URL"https://api3.getresponse360.pl/v3/transactional-emails");
  190.         curl_setopt($chCURLOPT_RETURNTRANSFER1);
  191.         curl_setopt($chCURLOPT_POSTFIELDS$body);
  192.         curl_setopt($chCURLOPT_POST1);
  193.         curl_setopt($chCURLOPT_HTTPHEADER, [
  194.             "Content-Type: application/json",
  195.             "X-Auth-Token: api-key gs478s9uv59n5ekulmpmgn5p0uqpepbn",
  196.             "X-Domain: echairs.eu"
  197.         ]);
  198.         curl_exec($ch);
  199.         curl_close($ch);
  200.     }
  201. }