- 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>
254 lines
10 KiB
PHP
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();
|
|
}
|
|
}
|