<?php
namespace App\Manager;
use Doctrine\ORM\EntityManagerInterface;
use App\Entity\Product;
use App\Entity\ProductModel;
use App\Entity\Category;
use App\Entity\Attribute;
use App\Entity\Language;
use App\Entity\ProductAttribute;
class AttributeManager {
private $em;
private $projectDir;
private $attributeCache = [];
/**
* Ne garde que les id (pas les entités Language elles-mêmes) : contrairement
* à $attributeCache, ce cache reste valide même après un em->clear().
*/
private array $languageIdCache = [];
public function __construct(EntityManagerInterface $em, \Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface $parameter)
{
$this->em = $em;
$this->projectDir = $parameter->get('kernel.project_dir').'/';
}
public function createAttribute(string $code): Attribute{
$attribute = new Attribute();
$attribute->setCode($code);
$attribute->setStatus(0);
$this->em->persist($attribute);
return $attribute;
}
public function getAttribute(string $code): ?Attribute{
if (isset($this->attributeCache[$code])) {
return $this->attributeCache[$code];
}
$attribute = $this->em->getRepository(Attribute::class)->findOneByCode($code);
if (!$attribute) {
$attribute = $this->createAttribute($code);
}
$this->attributeCache[$code] = $attribute;
return $attribute;
}
/**
* The cached Attribute entities become detached after an em->clear(); call this right after
* to avoid reusing stale references on the next lookups.
*/
public function resetCache(): void {
$this->attributeCache = [];
}
public function createAttributeDescription(Attribute $attribute, $isoCode = 'fr'): \App\Entity\AttributeDescription{
$language = $this->getLanguageByCode($isoCode);
$ad = new \App\Entity\AttributeDescription();
$ad->setAttribute($attribute);
$ad->setLanguage($language);
$ad->setTitle('');
return $ad;
}
public function createProductAttributeDescription(ProductAttribute $pa, $isoCode = 'fr'): \App\Entity\ProductAttributeDescription{
$language = $this->getLanguageByCode($isoCode);
$ad = new \App\Entity\ProductAttributeDescription();
$ad->setProductAttribute($pa);
$ad->setLanguage($language);
$ad->setValue('');
return $ad;
}
/**
* Résout une langue par son code sans refaire une requête à chaque appel
* (le nombre de codes distincts utilisés dans un import est très faible).
*/
private function getLanguageByCode(string $isoCode): ?Language
{
if (!array_key_exists($isoCode, $this->languageIdCache)) {
$language = $this->em->getRepository(Language::class)->findOneByCode($isoCode);
$this->languageIdCache[$isoCode] = $language?->getId();
}
$id = $this->languageIdCache[$isoCode];
return $id !== null ? $this->em->getReference(Language::class, $id) : null;
}
public function addToProduct(Attribute $attribute, Product $product, string $value, $locale = null, bool $flush = true) {
$pa = $product->getAttribute($attribute->getCode());
if(empty($pa)) {
$pa = new \App\Entity\ProductAttribute();
$pa->setAttribute($attribute);
$pa->setProduct($product);
}
if(is_null($locale)) {
$pa->setValue($value);
}else{
// pas de getDescription() ici : son repli sur la première description écraserait une autre langue
$description = $pa->getDescriptionByLanguage($locale == 'fr' ? 4 : 1);
if(empty($description)) {
$description = $this->createProductAttributeDescription($pa, $locale);
}
$description->setValue($value);
$this->em->persist($description);
}
$this->em->persist($pa);
if ($flush) {
$this->em->flush();
}
}
public function removeFromProduct(Attribute $attribute, Product $product) {
$pas = $this->em->getRepository('App:ProductAttribute')->findBy([
'product' => $product,
'attribute' => $attribute
]);
foreach($pas as $pa){
$this->em->remove($pa);
}
$this->em->flush();
}
public function addToProductModel(Attribute $attribute, ProductModel $product, string $value) {
$pa = $this->em->getRepository('App:ProductModelAttribute')->findOneBy([
'attribute' => $attribute,
'productModel' => $product
]);
if(empty($pa)) {
$pa = new \App\Entity\ProductModelAttribute();
$pa->setAttribute($attribute);
$pa->setProductModel($product);
}
$pa->setValue($value);
$this->em->persist($pa);
$this->em->flush();
}
public function removeFromProductModel(Attribute $attribute, ProductModel $model) {
$pas = $this->em->getRepository('App:ProductModelAttribute')->findBy([
'productModel' => $model,
'attribute' => $attribute
]);
foreach($pas as $pa){
$this->em->remove($pa);
}
$this->em->flush();
}
public function getAttributesByProductModel(ProductModel $productModel): array {
$attributes = [];
$variant = $productModel->getVariant();
if (!$variant) {
return $attributes;
}
foreach ([$variant->getAxis1(), $variant->getAxis2()] as $axis) {
if (!$axis) {
continue;
}
$codes = array_values(array_filter(array_map('trim', explode(',', $axis))));
if (count($codes) === 1) {
$attribute = $this->getAttribute($codes[0]);
if ($attribute && $attribute->isVariant()) {
$attribute->setValues($this->getAttributeValuesByProductModel($attribute, $productModel));
$attributes[] = $attribute;
}
} else {
$composite = $this->buildCompositeAxis($codes, $productModel);
if ($composite) {
$attributes[] = $composite;
}
}
}
// Les axes affichés en couleur passent en premier (usort est stable : l'ordre des axes est conservé sinon)
$isColor = fn($attribute) => ($attribute instanceof Attribute ? $attribute->getDisplay() : $attribute->display) === Attribute::DISPLAY_TYPE_COLOR;
usort($attributes, fn($a, $b) => $isColor($b) <=> $isColor($a));
return $attributes;
}
/**
* Builds a synthetic axis object for multi-attribute axes (e.g. axis = "taille,couleur").
* Returns a stdClass with code/title/display/values so Twig can render it like a regular Attribute.
* Each value has ->value (combined label "XS / Rouge") and ->product (the matching Product).
*/
private function buildCompositeAxis(array $codes, ProductModel $productModel): ?\stdClass {
$axisAttributes = [];
foreach ($codes as $code) {
$attr = $this->em->getRepository(Attribute::class)->findOneByCode($code);
if ($attr) {
$axisAttributes[] = $attr;
}
}
if (empty($axisAttributes)) {
return null;
}
$values = [];
$seen = [];
$sortKeys = [];
foreach ($productModel->getAllProducts() as $product) {
$parts = [];
$productAttributes = [];
foreach ($axisAttributes as $attr) {
$pa = $product->getAttribute($attr->getCode());
$parts[] = $pa ? ($pa->getDisplayedValue() ?? '?') : '?';
$productAttributes[] = $pa;
}
$label = implode(' / ', $parts);
if (in_array($label, $seen)) {
continue;
}
$seen[] = $label;
$value = new \stdClass();
$value->value = $label;
$value->product = $product;
$values[] = $value;
$sortKeys[] = $productAttributes;
}
// Tri sur chaque attribut de l'axe, dans l'ordre de l'axe
$indexes = array_keys($values);
usort($indexes, function ($a, $b) use ($sortKeys) {
foreach ($sortKeys[$a] as $i => $pa) {
$cmp = $this->compareProductAttributes($pa, $sortKeys[$b][$i]);
if ($cmp !== 0) {
return $cmp;
}
}
return 0;
});
$values = array_map(fn($i) => $values[$i], $indexes);
$composite = new \stdClass();
$composite->code = implode(',', $codes);
$composite->title = implode(' / ', array_map(fn(Attribute $a) => $a->getTitle(), $axisAttributes));
$composite->display = Attribute::DISPLAY_TYPE_COMBOBOX;
$composite->values = $values;
return $composite;
}
public function getAttributeValuesByProductModel(Attribute $attribute, ProductModel $productModel) {
$values = [];
$tmp = [];
$products = $productModel->getAllProducts();
foreach($products as $product) {
$attr = $product->getAttribute($attribute->getCode());
if($attr && !in_array($attr->getValue(), $tmp)) {
$values[] = $attr;
$tmp[] = $attr->getValue();
}
}
usort($values, fn($a, $b) => $this->compareProductAttributes($a, $b));
return $values;
}
/**
* Compare deux valeurs d'attribut produit : numériquement si les deux valeurs brutes sont
* des nombres (500 ml avant 1 l), sinon par ordre naturel sur la valeur affichée (traduite).
* Les valeurs absentes sont placées en fin.
* Pour product_size, les tailles textiles suivent l'ordre XXXS … XS, S, M, L, X, XL … XXXL.
*/
private function compareProductAttributes(?ProductAttribute $a, ?ProductAttribute $b): int {
if (!$a || !$b) {
return ($a ? 0 : 1) - ($b ? 0 : 1);
}
if ($a->getAttribute() && $a->getAttribute()->getCode() === 'product_size') {
$rankA = $this->getSizeRank($a);
$rankB = $this->getSizeRank($b);
if (!is_null($rankA) || !is_null($rankB)) {
// tailles inconnues après les tailles reconnues
if (is_null($rankA) || is_null($rankB)) {
return is_null($rankA) ? 1 : -1;
}
if ($rankA != $rankB) {
return $rankA <=> $rankB;
}
}
}
$rawA = str_replace(',', '.', trim((string) $a->getValue()));
$rawB = str_replace(',', '.', trim((string) $b->getValue()));
if (is_numeric($rawA) && is_numeric($rawB)) {
return (float) $rawA <=> (float) $rawB;
}
return strnatcasecmp((string) $a->getDisplayedValue(), (string) $b->getDisplayedValue());
}
/**
* Rang d'une taille textile (M = 0, S/XS/… négatifs, L/X/XL/… positifs), à partir de la valeur
* brute puis de la valeur affichée. Accepte la forme numérique (2XL = XXL, 3XS = XXXS).
* Null si la valeur n'est pas une taille reconnue.
*/
private function getSizeRank(ProductAttribute $pa): ?float {
foreach ([$pa->getValue(), $pa->getDisplayedValue()] as $value) {
$value = strtoupper(preg_replace('/[\s._-]+/', '', (string) $value));
if ($value === 'M') {
return 0;
}
if ($value === 'X') {
return 1.5;
}
if (preg_match('/^(\d+)?(X*)(S|L)$/', $value, $m)) {
$nbX = $m[1] !== '' ? (int) $m[1] : strlen($m[2]);
if ($m[1] !== '' && $m[2] !== 'X') {
// forme numérique : seul "2XL" / "3XS" est valide
continue;
}
return $m[3] === 'S' ? -($nbX + 1) : $nbX + 1;
}
}
return null;
}
}