policy = $policy ?? new MergePolicy([ 'description' => MergeStrategy::LONGEST_DESCRIPTION, 'short_description' => MergeStrategy::LONGEST_DESCRIPTION, 'meta_description' => MergeStrategy::LONGEST_DESCRIPTION, ]); $this->strategy = $strategy ?? new MergeStrategy(); $this->quality = $quality ?? new MergeQuality(); } public function merge(array $products): MergeHistory { $products = array_values(array_filter($products, static fn ($product): bool => $product instanceof ProductDTO)); if ($products === []) throw new \InvalidArgumentException('At least one normalized ProductDTO is required.'); $evidenceByField = []; foreach ($products as $product) { $provider = $product->getProviderInfo()?->getName() ?? 'unknown'; $confidence = $this->policy->getProviderConfidence($provider); $observedAt = $this->observedAt($product); foreach ($this->fields($product) as $field => $value) { if ($value === null || $value === '' || $value === []) continue; $evidenceByField[$field][] = new MergeEvidence( $field, $value, $provider, $confidence, $this->quality->evaluate($value), $observedAt ); } } $decisions = []; $conflicts = []; foreach ($evidenceByField as $field => $evidence) { $winner = $this->strategy->select($field, $evidence, $this->policy); $distinct = $this->distinct($evidence); if ($winner === null) { if (count($distinct) > 1) $conflicts[] = new MergeConflict($field, $evidence, false); continue; } $losers = array_values(array_filter($evidence, static fn (MergeEvidence $candidate): bool => $candidate !== $winner)); $decisions[$field] = new MergeDecision( $field, $winner, $losers, $this->reason($field, $winner) ); if (count($distinct) > 1) $conflicts[] = new MergeConflict($field, $evidence, true); } return new MergeHistory($this->buildProduct($products[0], $decisions), array_values($decisions), $conflicts); } public function mergeWithAi(array $products): ?MergeHistory { return null; } public function mergeSeo(array $products): ?MergeHistory { return null; } public function mergeMedia(array $products): ?MergeHistory { return null; } private function fields(ProductDTO $product): array { $attributes = []; $identifiers = ['id' => $product->getId()]; foreach ($product->getAttributes() as $attribute) { if (! $attribute instanceof AttributeDTO) continue; $name = strtolower(trim($attribute->getName())); $attributes[$name] = $attribute->getValue(); if (in_array($name, ['isbn', 'isbn_10', 'isbn_13', 'ean', 'upc', 'gtin', 'asin', 'mpn', 'sku'], true)) { $identifiers[$name] = $attribute->getValue(); } } if ($product->getSku() !== null) $identifiers['sku'] = $product->getSku()->getValue(); if ($product->getBarcode() !== null) $identifiers['barcode'] = $product->getBarcode()->getValue(); return [ 'identifiers' => $identifiers, 'title' => $product->getName(), 'subtitle' => $attributes['subtitle'] ?? null, 'brand' => $attributes['brand'] ?? null, 'manufacturer' => $attributes['manufacturer'] ?? $attributes['publisher'] ?? null, 'description' => $product->getDescription(), 'short_description' => $product->getShortDescription(), 'categories' => $product->getCategories(), 'tags' => $attributes['tags'] ?? $attributes['subjects'] ?? null, 'images' => $product->getImages(), 'gallery' => $attributes['gallery'] ?? null, 'attributes' => $attributes, 'specifications' => $attributes['specifications'] ?? null, 'dimensions' => $attributes['dimensions'] ?? null, 'weight' => $attributes['weight'] ?? null, 'seo_title' => $product->getSeo()?->getTitle(), 'meta_description' => $product->getSeo()?->getDescription(), 'keywords' => $product->getSeo()?->getKeywords(), 'slug' => $attributes['slug'] ?? null, ]; } private function buildProduct(ProductDTO $primary, array $decisions): ProductDTO { $value = static fn (string $field, $fallback = null) => isset($decisions[$field]) ? $decisions[$field]->getWinningValue() : $fallback; $identifiers = (array) $value('identifiers', ['id' => $primary->getId()]); $attributes = (array) $value('attributes', []); foreach (['subtitle', 'brand', 'manufacturer', 'tags', 'gallery', 'specifications', 'dimensions', 'weight', 'slug'] as $field) { $fieldValue = $value($field); if ($fieldValue !== null && $fieldValue !== '') $attributes[$field] = is_array($fieldValue) ? implode(', ', $fieldValue) : (string) $fieldValue; } foreach ($identifiers as $name => $identifier) { if ($name !== 'id' && $identifier !== null && $identifier !== '') $attributes[$name] = (string) $identifier; } $attributeDtos = []; foreach ($attributes as $name => $attributeValue) { if (is_scalar($attributeValue) && trim((string) $attributeValue) !== '') $attributeDtos[] = new AttributeDTO((string) $name, (string) $attributeValue); } $providers = array_values(array_unique(array_map(static fn (MergeDecision $decision): string => $decision->getSourceProvider(), $decisions))); $providers = array_values(array_filter($providers, static fn (string $provider): bool => $provider !== 'Manual')); $attributeDtos[] = new AttributeDTO('source_providers', implode(', ', $providers)); return new ProductDTO( (string) ($identifiers['isbn_13'] ?? $identifiers['ean'] ?? $identifiers['id'] ?? $primary->getId()), (string) $value('title', $primary->getName()), $this->nullableString($value('description', $primary->getDescription())), $this->nullableString($value('short_description', $primary->getShortDescription())), $primary->getPrice(), $primary->getInventory(), (array) $value('categories', $primary->getCategories()), (array) $value('images', $primary->getImages()), $attributeDtos, new SeoDTO( $this->nullableString($value('seo_title')), $this->nullableString($value('meta_description')), $this->nullableString($value('keywords')) ), new ProviderInfoDTO('UniversalMerge', 'foundation', implode('+', $providers)), $primary->getSku(), $primary->getBarcode() ); } private function observedAt(ProductDTO $product): ?\DateTimeImmutable { foreach ($product->getAttributes() as $attribute) { if ($attribute instanceof AttributeDTO && strtolower($attribute->getName()) === 'updated_at') { try { return new \DateTimeImmutable($attribute->getValue()); } catch (\Exception $exception) { return null; } } } return null; } private function distinct(array $evidence): array { $values = []; foreach ($evidence as $candidate) $values[sha1(serialize($candidate->getValue()))] = true; return array_keys($values); } private function reason(string $field, MergeEvidence $winner): string { return sprintf('%s selected %s from %s.', $this->policy->getStrategy($field), $field, $winner->getProvider()); } private function nullableString($value): ?string { return $value === null || $value === '' ? null : (string) $value; } }