<?php
namespace App\Command;
use App\Entity\Currency;
use App\Entity\ProductPrice;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Console\Command\Command;
/**
* Symfony console command that scans every price in every language version of the store,
* compares it with the reference Polish (language‑id = 1) price and reports anomalies.
*
* An anomaly is logged when:
* 1. A numeric price is identical in the foreign language and in Polish
* (e.g. 400 PLN vs. 400 EUR).
* 2. After converting the foreign price to PLN using Currency.factorPln the value differs
* by more than ±50 % (ratio > 1.5 or < 0.6667) from the Polish price.
* Only prices that belong to the same *variant combination* are compared.
* A variant combination is identified by the set of ProductParameterValue ids linked
* to a ProductPrice through ProductPriceVariants.
*
* The command prints a nice table to the CLI **and** mails an HTML report via
* GetResponse transactional‑emails API. Hook it into a cron‑job (e.g. at 6:00 every morning)
* or use Symfony Scheduler so that your team has fresh data each day.
*/
class DetectInvalidPricesCommand extends Command
{
protected static $defaultName = 'app:detect-invalid-prices';
protected function configure()
{
$this->setName('app:detect-invalid-prices')
->setDescription('Detect invalid prices');
}
public function __construct($name = null, ContainerInterface $container)
{
parent::__construct($name);
$this->container = $container;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$em = $this->container->get('doctrine')->getManager();
$twig = $this->container->get('twig');
// 1. Cache currencies.
$currencies = [];
foreach ($em->getRepository(Currency::class)->findAll() as $currency) {
$currencies[$currency->getId()] = $currency;
}
// 2. Reference (Polish) prices.
$plPrices = $this->getPricesIndexed($em, 1);
// 3. Active foreign languages.
$foreignLangs = $em->getRepository(\App\Entity\Language::class)
->createQueryBuilder('l')
->where('l.id != 1')
->andWhere('l.isActive = 1')
->getQuery()
->getResult();
$rows = [];
foreach ($foreignLangs as $lang) {
$langPrices = $this->getPricesIndexed($em, $lang->getId());
foreach ($langPrices as $productId => $variants) {
foreach ($variants as $variantKey => $pp) {
if (!isset($plPrices[$productId][$variantKey])) {
continue; // No reference price.
}
$plPP = $plPrices[$productId][$variantKey];
$plPrice = (float) $plPP->getPrice();
$foreignPrice = (float) $pp->getPrice();
$currency = $currencies[$pp->getCurrency()->getId()];
$factor = (float) $currency->getFactorPln();
if ($factor <= 0) {
// mis-configured currency – skip comparison
continue;
}
$foreignInPln = $foreignPrice / $factor; // correct way
$ratio = $plPrice > 0 ? $foreignInPln / $plPrice : 1;
$numericEqual = abs($foreignPrice - $plPrice) < 0.01;
$outOfRange = $ratio > 1.5 || $ratio < (1 / 1.5);
if (!$numericEqual && !$outOfRange) {
continue; // looks fine
}
$rows[] = [
'lang' => strtoupper($lang->getLocale()),
'product' => sprintf('%s (ID %d)', $pp->getProduct()->getName(), $productId),
'variant' => $this->getVariantLabel($pp),
'pl_price' => number_format($plPrice, 2, ',', ' ') . ' zł',
'foreign_price' => number_format($foreignPrice, 2, ',', ' ') . ' ' . $currency->getSign(),
'pln_value' => number_format($foreignInPln, 2, ',', ' ') . ' zł',
'ratio' => number_format($ratio, 2),
'reason' => $numericEqual ? 'Same numeric price' : 'Outside ±50 %',
];
}
}
}
if (!$rows) {
$output->writeln('<info>No anomalies detected – prices look good 🙂</info>');
return 0;
}
(new Table($output))->setHeaders(array_keys($rows[0]))->setRows($rows)->render();
$html = $twig->render('emails/invalid_prices.html.twig', [
'rows' => $rows,
'generatedAt' => new \DateTimeImmutable('now', new \DateTimeZone('Europe/Warsaw')),
]);
$this->sendEmailViaGetResponse($html);
$output->writeln(sprintf('<info>Email sent with %d anomaly%s.</info>', count($rows), count($rows) === 1 ? '' : 'ies'));
return 0;
}
/**
* Returns active prices **for visible products** in the given language, indexed by [productId][variantKey].
*/
private function getPricesIndexed(EntityManagerInterface $em, int $languageId): array
{
$qb = $em->createQueryBuilder()
->select('pp, v, p')
->from(ProductPrice::class, 'pp')
->leftJoin('pp.variants', 'v')
->innerJoin('pp.product', 'p')
->innerJoin('App\\Entity\\ProductLangParam', 'plp', 'WITH', 'plp.product = p AND plp.language = :lang AND plp.deletedBy IS NULL')
->where('pp.language = :lang')
->andWhere('pp.active = 1')
->andWhere('pp.deletedBy IS NULL')
->andWhere('p.deletedBy IS NULL')
// Product must be visible in that language; if no ProductLangParam row exists, we treat it as visible.
->andWhere('(plp.id IS NULL OR plp.visible = 1)')
->setParameter('lang', $languageId);
$indexed = [];
foreach ($qb->getQuery()->getResult() as $pp) {
$productId = $pp->getProduct()->getId();
$variantKey = $this->buildVariantKey($pp);
$indexed[$productId][$variantKey] = $pp;
}
return $indexed;
}
/**
* Builds a stable key (e.g. v12‑g3) covering both single values and value‑groups.
*/
private function buildVariantKey(ProductPrice $pp): string
{
if ($pp->getVariants()->isEmpty()) {
return '_base';
}
$keys = [];
foreach ($pp->getVariants() as $variant) {
if ($variant->getParameterValue()) {
$keys[] = 'v' . $variant->getParameterValue()->getId();
} elseif ($variant->getParameterValueGroup()) {
$keys[] = 'g' . $variant->getParameterValueGroup()->getId();
}
}
sort($keys);
return implode('-', $keys);
}
private function getVariantLabel(ProductPrice $pp): string
{
if ($pp->getVariants()->isEmpty()) {
return '—';
}
$parts = [];
foreach ($pp->getVariants() as $variant) {
$paramName = $variant->getParameter()->getName();
if ($variant->getParameterValue()) {
$valueName = $variant->getParameterValue()->getName();
} elseif ($variant->getParameterValueGroup()) {
$valueName = $variant->getParameterValueGroup()->getName();
} else {
$valueName = '';
}
$parts[] = sprintf('%s: %s', $paramName, $valueName);
}
return implode(', ', $parts);
}
private function sendEmailViaGetResponse($htmlBody)
{
$content = [
'fromField' => ['fromFieldId' => 'f'],
'subject' => 'Invalid prices report',
'content' => [
'html' => $htmlBody,
'plain' => strip_tags($htmlBody),
],
'recipients' => [
'to' => ['email' => 'krzysiek.gaudy@gmail.com']
],
];
$body = json_encode($content);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "https://api3.getresponse360.pl/v3/transactional-emails");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Content-Type: application/json",
"X-Auth-Token: api-key gs478s9uv59n5ekulmpmgn5p0uqpepbn",
"X-Domain: echairs.eu"
]);
curl_exec($ch);
curl_close($ch);
}
}