src/Manager/AttributeManager.php line 249

Open in your IDE?
  1. <?php
  2. namespace App\Manager;
  3. use Doctrine\ORM\EntityManagerInterface;
  4. use App\Entity\Product;
  5. use App\Entity\ProductModel;
  6. use App\Entity\Category;
  7. use App\Entity\Attribute;
  8. use App\Entity\Language;
  9. use App\Entity\ProductAttribute;
  10. class AttributeManager {
  11. private $em;
  12. private $projectDir;
  13. private $attributeCache = [];
  14. /**
  15. * Ne garde que les id (pas les entités Language elles-mêmes) : contrairement
  16. * à $attributeCache, ce cache reste valide même après un em->clear().
  17. */
  18. private array $languageIdCache = [];
  19. public function __construct(EntityManagerInterface $em, \Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface $parameter)
  20. {
  21. $this->em = $em;
  22. $this->projectDir = $parameter->get('kernel.project_dir').'/';
  23. }
  24. public function createAttribute(string $code): Attribute{
  25. $attribute = new Attribute();
  26. $attribute->setCode($code);
  27. $attribute->setStatus(0);
  28. $this->em->persist($attribute);
  29. return $attribute;
  30. }
  31. public function getAttribute(string $code): ?Attribute{
  32. if (isset($this->attributeCache[$code])) {
  33. return $this->attributeCache[$code];
  34. }
  35. $attribute = $this->em->getRepository(Attribute::class)->findOneByCode($code);
  36. if (!$attribute) {
  37. $attribute = $this->createAttribute($code);
  38. }
  39. $this->attributeCache[$code] = $attribute;
  40. return $attribute;
  41. }
  42. /**
  43. * The cached Attribute entities become detached after an em->clear(); call this right after
  44. * to avoid reusing stale references on the next lookups.
  45. */
  46. public function resetCache(): void {
  47. $this->attributeCache = [];
  48. }
  49. public function createAttributeDescription(Attribute $attribute, $isoCode = 'fr'): \App\Entity\AttributeDescription{
  50. $language = $this->getLanguageByCode($isoCode);
  51. $ad = new \App\Entity\AttributeDescription();
  52. $ad->setAttribute($attribute);
  53. $ad->setLanguage($language);
  54. $ad->setTitle('');
  55. return $ad;
  56. }
  57. public function createProductAttributeDescription(ProductAttribute $pa, $isoCode = 'fr'): \App\Entity\ProductAttributeDescription{
  58. $language = $this->getLanguageByCode($isoCode);
  59. $ad = new \App\Entity\ProductAttributeDescription();
  60. $ad->setProductAttribute($pa);
  61. $ad->setLanguage($language);
  62. $ad->setValue('');
  63. return $ad;
  64. }
  65. /**
  66. * Résout une langue par son code sans refaire une requête à chaque appel
  67. * (le nombre de codes distincts utilisés dans un import est très faible).
  68. */
  69. private function getLanguageByCode(string $isoCode): ?Language
  70. {
  71. if (!array_key_exists($isoCode, $this->languageIdCache)) {
  72. $language = $this->em->getRepository(Language::class)->findOneByCode($isoCode);
  73. $this->languageIdCache[$isoCode] = $language?->getId();
  74. }
  75. $id = $this->languageIdCache[$isoCode];
  76. return $id !== null ? $this->em->getReference(Language::class, $id) : null;
  77. }
  78. public function addToProduct(Attribute $attribute, Product $product, string $value, $locale = null, bool $flush = true) {
  79. $pa = $product->getAttribute($attribute->getCode());
  80. if(empty($pa)) {
  81. $pa = new \App\Entity\ProductAttribute();
  82. $pa->setAttribute($attribute);
  83. $pa->setProduct($product);
  84. }
  85. if(is_null($locale)) {
  86. $pa->setValue($value);
  87. }else{
  88. // pas de getDescription() ici : son repli sur la première description écraserait une autre langue
  89. $description = $pa->getDescriptionByLanguage($locale == 'fr' ? 4 : 1);
  90. if(empty($description)) {
  91. $description = $this->createProductAttributeDescription($pa, $locale);
  92. }
  93. $description->setValue($value);
  94. $this->em->persist($description);
  95. }
  96. $this->em->persist($pa);
  97. if ($flush) {
  98. $this->em->flush();
  99. }
  100. }
  101. public function removeFromProduct(Attribute $attribute, Product $product) {
  102. $pas = $this->em->getRepository('App:ProductAttribute')->findBy([
  103. 'product' => $product,
  104. 'attribute' => $attribute
  105. ]);
  106. foreach($pas as $pa){
  107. $this->em->remove($pa);
  108. }
  109. $this->em->flush();
  110. }
  111. public function addToProductModel(Attribute $attribute, ProductModel $product, string $value) {
  112. $pa = $this->em->getRepository('App:ProductModelAttribute')->findOneBy([
  113. 'attribute' => $attribute,
  114. 'productModel' => $product
  115. ]);
  116. if(empty($pa)) {
  117. $pa = new \App\Entity\ProductModelAttribute();
  118. $pa->setAttribute($attribute);
  119. $pa->setProductModel($product);
  120. }
  121. $pa->setValue($value);
  122. $this->em->persist($pa);
  123. $this->em->flush();
  124. }
  125. public function removeFromProductModel(Attribute $attribute, ProductModel $model) {
  126. $pas = $this->em->getRepository('App:ProductModelAttribute')->findBy([
  127. 'productModel' => $model,
  128. 'attribute' => $attribute
  129. ]);
  130. foreach($pas as $pa){
  131. $this->em->remove($pa);
  132. }
  133. $this->em->flush();
  134. }
  135. public function getAttributesByProductModel(ProductModel $productModel): array {
  136. $attributes = [];
  137. $variant = $productModel->getVariant();
  138. if (!$variant) {
  139. return $attributes;
  140. }
  141. foreach ([$variant->getAxis1(), $variant->getAxis2()] as $axis) {
  142. if (!$axis) {
  143. continue;
  144. }
  145. $codes = array_values(array_filter(array_map('trim', explode(',', $axis))));
  146. if (count($codes) === 1) {
  147. $attribute = $this->getAttribute($codes[0]);
  148. if ($attribute && $attribute->isVariant()) {
  149. $attribute->setValues($this->getAttributeValuesByProductModel($attribute, $productModel));
  150. $attributes[] = $attribute;
  151. }
  152. } else {
  153. $composite = $this->buildCompositeAxis($codes, $productModel);
  154. if ($composite) {
  155. $attributes[] = $composite;
  156. }
  157. }
  158. }
  159. // Les axes affichés en couleur passent en premier (usort est stable : l'ordre des axes est conservé sinon)
  160. $isColor = fn($attribute) => ($attribute instanceof Attribute ? $attribute->getDisplay() : $attribute->display) === Attribute::DISPLAY_TYPE_COLOR;
  161. usort($attributes, fn($a, $b) => $isColor($b) <=> $isColor($a));
  162. return $attributes;
  163. }
  164. /**
  165. * Builds a synthetic axis object for multi-attribute axes (e.g. axis = "taille,couleur").
  166. * Returns a stdClass with code/title/display/values so Twig can render it like a regular Attribute.
  167. * Each value has ->value (combined label "XS / Rouge") and ->product (the matching Product).
  168. */
  169. private function buildCompositeAxis(array $codes, ProductModel $productModel): ?\stdClass {
  170. $axisAttributes = [];
  171. foreach ($codes as $code) {
  172. $attr = $this->em->getRepository(Attribute::class)->findOneByCode($code);
  173. if ($attr) {
  174. $axisAttributes[] = $attr;
  175. }
  176. }
  177. if (empty($axisAttributes)) {
  178. return null;
  179. }
  180. $values = [];
  181. $seen = [];
  182. $sortKeys = [];
  183. foreach ($productModel->getAllProducts() as $product) {
  184. $parts = [];
  185. $productAttributes = [];
  186. foreach ($axisAttributes as $attr) {
  187. $pa = $product->getAttribute($attr->getCode());
  188. $parts[] = $pa ? ($pa->getDisplayedValue() ?? '?') : '?';
  189. $productAttributes[] = $pa;
  190. }
  191. $label = implode(' / ', $parts);
  192. if (in_array($label, $seen)) {
  193. continue;
  194. }
  195. $seen[] = $label;
  196. $value = new \stdClass();
  197. $value->value = $label;
  198. $value->product = $product;
  199. $values[] = $value;
  200. $sortKeys[] = $productAttributes;
  201. }
  202. // Tri sur chaque attribut de l'axe, dans l'ordre de l'axe
  203. $indexes = array_keys($values);
  204. usort($indexes, function ($a, $b) use ($sortKeys) {
  205. foreach ($sortKeys[$a] as $i => $pa) {
  206. $cmp = $this->compareProductAttributes($pa, $sortKeys[$b][$i]);
  207. if ($cmp !== 0) {
  208. return $cmp;
  209. }
  210. }
  211. return 0;
  212. });
  213. $values = array_map(fn($i) => $values[$i], $indexes);
  214. $composite = new \stdClass();
  215. $composite->code = implode(',', $codes);
  216. $composite->title = implode(' / ', array_map(fn(Attribute $a) => $a->getTitle(), $axisAttributes));
  217. $composite->display = Attribute::DISPLAY_TYPE_COMBOBOX;
  218. $composite->values = $values;
  219. return $composite;
  220. }
  221. public function getAttributeValuesByProductModel(Attribute $attribute, ProductModel $productModel) {
  222. $values = [];
  223. $tmp = [];
  224. $products = $productModel->getAllProducts();
  225. foreach($products as $product) {
  226. $attr = $product->getAttribute($attribute->getCode());
  227. if($attr && !in_array($attr->getValue(), $tmp)) {
  228. $values[] = $attr;
  229. $tmp[] = $attr->getValue();
  230. }
  231. }
  232. usort($values, fn($a, $b) => $this->compareProductAttributes($a, $b));
  233. return $values;
  234. }
  235. /**
  236. * Compare deux valeurs d'attribut produit : numériquement si les deux valeurs brutes sont
  237. * des nombres (500 ml avant 1 l), sinon par ordre naturel sur la valeur affichée (traduite).
  238. * Les valeurs absentes sont placées en fin.
  239. * Pour product_size, les tailles textiles suivent l'ordre XXXS … XS, S, M, L, X, XL … XXXL.
  240. */
  241. private function compareProductAttributes(?ProductAttribute $a, ?ProductAttribute $b): int {
  242. if (!$a || !$b) {
  243. return ($a ? 0 : 1) - ($b ? 0 : 1);
  244. }
  245. if ($a->getAttribute() && $a->getAttribute()->getCode() === 'product_size') {
  246. $rankA = $this->getSizeRank($a);
  247. $rankB = $this->getSizeRank($b);
  248. if (!is_null($rankA) || !is_null($rankB)) {
  249. // tailles inconnues après les tailles reconnues
  250. if (is_null($rankA) || is_null($rankB)) {
  251. return is_null($rankA) ? 1 : -1;
  252. }
  253. if ($rankA != $rankB) {
  254. return $rankA <=> $rankB;
  255. }
  256. }
  257. }
  258. $rawA = str_replace(',', '.', trim((string) $a->getValue()));
  259. $rawB = str_replace(',', '.', trim((string) $b->getValue()));
  260. if (is_numeric($rawA) && is_numeric($rawB)) {
  261. return (float) $rawA <=> (float) $rawB;
  262. }
  263. return strnatcasecmp((string) $a->getDisplayedValue(), (string) $b->getDisplayedValue());
  264. }
  265. /**
  266. * Rang d'une taille textile (M = 0, S/XS/… négatifs, L/X/XL/… positifs), à partir de la valeur
  267. * brute puis de la valeur affichée. Accepte la forme numérique (2XL = XXL, 3XS = XXXS).
  268. * Null si la valeur n'est pas une taille reconnue.
  269. */
  270. private function getSizeRank(ProductAttribute $pa): ?float {
  271. foreach ([$pa->getValue(), $pa->getDisplayedValue()] as $value) {
  272. $value = strtoupper(preg_replace('/[\s._-]+/', '', (string) $value));
  273. if ($value === 'M') {
  274. return 0;
  275. }
  276. if ($value === 'X') {
  277. return 1.5;
  278. }
  279. if (preg_match('/^(\d+)?(X*)(S|L)$/', $value, $m)) {
  280. $nbX = $m[1] !== '' ? (int) $m[1] : strlen($m[2]);
  281. if ($m[1] !== '' && $m[2] !== 'X') {
  282. // forme numérique : seul "2XL" / "3XS" est valide
  283. continue;
  284. }
  285. return $m[3] === 'S' ? -($nbX + 1) : $nbX + 1;
  286. }
  287. }
  288. return null;
  289. }
  290. }