gruene-seele/app/Services/WhiteLabel/WhiteLabelResolver.php
Kevin Adametz cff749b133 White Label WL-02: verbindliche Tiegelfarbe, Etikettposition und gemeinsamer Resolver
- products.whitelabel_variant_id, product_images.position/size_cm mit Backfill aus Produktnamen
- WhiteLabelResolver: Farbe vom Produkt, Kundenlogo je Position, Fehler statt stillem Rückfall; Altlogik für Produkte ohne Variante
- Attribute: Variantenwert-Parsing und Farbname ohne Größe
- Produkt- und Kundenformular um Variante, Position und Größe ergänzt
- Lieferschein noch nicht umgestellt (WL-03, wartet auf K6/K7)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:33:44 +00:00

254 lines
10 KiB
PHP

<?php
namespace App\Services\WhiteLabel;
use App\Models\Attribute;
use App\Models\ProductImage;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\UserWhitelabelProduct;
use App\User;
use Illuminate\Support\Collection;
/**
* Gemeinsame Auflösung von Tiegelfarbe und Etiketten je Bestellposition (WL-02, Abschnitt 4.2 des Plans).
*
* Grundregel: Das Produkt bestimmt die Farbe (whitelabel_variant), Kunde + Produkt bestimmen die
* Kundenetiketten, die Position bestimmt, welches Standardetikett ersetzt wird.
* Produkte ohne verbindliche Variante (Klärung K5) laufen über die Altlogik (Attributüberschneidung).
*
* Der Resolver liest nur; er speichert keine Bestellung und erzeugt keine PDF.
*/
class WhiteLabelResolver
{
public const DEFAULT_POSITION = 'deckel';
/**
* @return Collection<int, ResolvedItem> Bestellpositions-ID => Ergebnis (nur White-Label-Positionen)
*/
public function resolveOrder(ShoppingOrder $order): Collection
{
$partner = $order->auth_user;
$results = collect();
foreach ($order->shopping_order_items as $item) {
$resolved = $this->resolveItem($item, $partner);
if ($resolved) {
$results->put($item->id, $resolved);
}
}
return $results;
}
public function resolveItem(ShoppingOrderItem $item, ?User $partner): ?ResolvedItem
{
$product = $item->product;
if (! $product || ! $product->whitelabel) {
return null;
}
$result = new ResolvedItem($item, $product);
$variant = $product->whitelabel_variant;
if ($variant) {
$result->color = $variant->colorName();
$result->variantId = $variant->id;
$result->volumeMl = $variant->variantVolume();
$result->sizeCm = $variant->variantSize();
$result->colorSource = ResolvedItem::COLOR_SOURCE_VARIANT;
} else {
$result->addWarning('Produkt ohne verbindliche Tiegelfarbe; Farbe wird aus dem Etikett abgeleitet (Altlogik).');
}
$standard = $this->groupByPosition($product->whitelabel_images, $result, 'Standardetikett');
$assignment = $this->findAssignment($partner, $product->id);
$customer = $assignment
? $this->groupByPosition($assignment->whitelabel_images, $result, 'Kundenlogo')
: [];
if (! $assignment && $partner && $this->partnerHasAssignments($partner)) {
$result->addWarning('Für dieses Produkt keine Kundenzuordnung vorhanden; Partner hat Logos bei anderen Produkten.');
}
$positions = array_unique(array_merge(array_keys($standard), array_keys($customer)));
foreach ($positions as $position) {
$label = $variant
? $this->resolveWithVariant($position, $standard[$position] ?? null, $customer[$position] ?? null, $result)
: $this->resolveLegacy($position, $standard[$position] ?? null, $customer[$position] ?? null, $product, $result);
if ($label) {
$result->labels[$position] = $label;
}
}
if ($result->labels === [] && $result->errors === []) {
$result->addError('Kein Etikett vorhanden (weder Standardetikett noch Kundenlogo).');
}
if ($variant === null && $result->color === null && $result->labels !== []) {
$result->color = 'Standard';
$result->colorSource = ResolvedItem::COLOR_SOURCE_LEGACY;
}
if ($result->color === null) {
$result->addError('Tiegelfarbe nicht bestimmbar.');
}
ksort($result->labels);
return $result;
}
/**
* Variantenmodus: Position entscheidet, Farbe kommt vom Produkt.
*/
private function resolveWithVariant(string $position, ?ProductImage $standard, ?ProductImage $customer, ResolvedItem $result): ?ResolvedLabel
{
if ($customer) {
$imageAttributes = array_map('intval', (array) $customer->attributes);
if ($imageAttributes !== [] && ! in_array($result->variantId, $imageAttributes, true)) {
$colors = collect($imageAttributes)
->map(fn ($id) => Attribute::find($id)?->colorName())
->filter()->unique()->join(', ');
$result->addError('Kundenlogo (Bild '.$customer->id.', Position '.$position.') ist für '.$colors.' hinterlegt, Produktfarbe ist '.$result->color.'. Zuordnung prüfen.');
return null;
}
$size = $this->sizeFor($customer, $result->sizeCm, $standard);
if ($size <= 0) {
$result->addError('Kundenlogo (Bild '.$customer->id.', Position '.$position.'): Etikettgröße nicht bestimmbar.');
return null;
}
$label = new ResolvedLabel($customer, ResolvedLabel::SOURCE_CUSTOMER, $position, $size, $result->volumeMl, $result->variantId);
if (! $label->fileExists()) {
$result->addError('Kundenlogo (Bild '.$customer->id.', Position '.$position.'): Datei fehlt. Kein Rückfall auf das Standardetikett.');
return null;
}
return $label;
}
if ($standard) {
$size = $this->sizeFor($standard, $result->sizeCm, null);
if ($size <= 0) {
$result->addError('Standardetikett (Bild '.$standard->id.', Position '.$position.'): Etikettgröße nicht bestimmbar.');
return null;
}
$label = new ResolvedLabel($standard, ResolvedLabel::SOURCE_STANDARD, $position, $size, $result->volumeMl, $result->variantId);
if (! $label->fileExists()) {
$result->addError('Standardetikett (Bild '.$standard->id.', Position '.$position.'): Datei fehlt.');
return null;
}
return $label;
}
return null;
}
/**
* Altlogik für Produkte ohne verbindliche Variante: Kundenlogo ersetzt das Standardetikett nur bei
* Attributüberschneidung; Farbe und Größe kommen aus dem getroffenen Attribut.
*/
private function resolveLegacy(string $position, ?ProductImage $standard, ?ProductImage $customer, $product, ResolvedItem $result): ?ResolvedLabel
{
$standardAttributes = $standard ? array_map('intval', (array) $standard->attributes) : [];
$customerAttributes = $customer ? array_map('intval', (array) $customer->attributes) : [];
$overlap = array_values(array_intersect($customerAttributes, $standardAttributes));
if ($customer && $overlap !== []) {
$attribute = Attribute::find($overlap[0]);
$label = new ResolvedLabel($customer, ResolvedLabel::SOURCE_CUSTOMER, $position, $customer->size_cm ?: $attribute?->variantSize() ?: 0.0, $attribute?->variantVolume() ?? 0.0, $attribute?->id);
if (! $label->fileExists()) {
$result->addError('Kundenlogo (Bild '.$customer->id.', Position '.$position.'): Datei fehlt. Kein Rückfall auf das Standardetikett.');
return null;
}
if ($attribute) {
$result->color = $attribute->colorName();
$result->variantId = $attribute->id;
$result->volumeMl = $attribute->variantVolume();
$result->sizeCm = $attribute->variantSize();
$result->colorSource = ResolvedItem::COLOR_SOURCE_LEGACY;
}
return $label;
}
if ($customer) {
$result->addWarning('Kundenlogo (Bild '.$customer->id.', Position '.$position.') greift nicht: keine Variantenüberschneidung mit dem Standardetikett.');
}
if (! $standard) {
return null;
}
// Standard: erstes Attribut des Etiketts, sonst Produktvarianten (wie bisher)
$attribute = $standardAttributes !== [] ? Attribute::find($standardAttributes[0]) : null;
if (! $attribute) {
$attribute = $product->attribute_variants->first()?->attribute;
}
$size = $standard->size_cm ?: ($attribute?->variantSize() ?? 0.0);
if ($size <= 0) {
$result->addError('Standardetikett (Bild '.$standard->id.', Position '.$position.'): Etikettgröße nicht bestimmbar.');
return null;
}
$label = new ResolvedLabel($standard, ResolvedLabel::SOURCE_STANDARD, $position, $size, $attribute?->variantVolume() ?? 0.0, $attribute?->id);
if (! $label->fileExists()) {
$result->addError('Standardetikett (Bild '.$standard->id.', Position '.$position.'): Datei fehlt.');
return null;
}
$result->volumeMl = $label->volumeMl;
$result->sizeCm = $size;
return $label;
}
private function sizeFor(ProductImage $image, float $variantSize, ?ProductImage $fallback): float
{
if ($image->size_cm) {
return (float) $image->size_cm;
}
if ($variantSize > 0) {
return $variantSize;
}
return $fallback && $fallback->size_cm ? (float) $fallback->size_cm : 0.0;
}
/**
* @param iterable<ProductImage> $images
* @return array<string, ProductImage>
*/
private function groupByPosition(iterable $images, ResolvedItem $result, string $kind): array
{
$byPosition = [];
foreach ($images as $image) {
$position = $image->position ?: self::DEFAULT_POSITION;
if (isset($byPosition[$position])) {
$result->addError($kind.': mehrere Bilder für Position "'.$position.'" (Bilder '.$byPosition[$position]->id.' und '.$image->id.'). Keine Auswahl nach Reihenfolge.');
continue;
}
$byPosition[$position] = $image;
}
return $byPosition;
}
private function findAssignment(?User $partner, int $productId): ?UserWhitelabelProduct
{
if (! $partner) {
return null;
}
return $partner->whitelabel_products()->where('product_id', $productId)->where('active', true)->first();
}
private function partnerHasAssignments(User $partner): bool
{
return $partner->whitelabel_products()->where('active', true)->exists();
}
}