@@ -497,6 +506,20 @@
multiple>
{!! HTMLHelper::getProductAttributesOptions($product->attribute_variants, $image->attributes, false, 1) !!}
+
Etikett löschen
diff --git a/tests/Feature/WhiteLabelResolverTest.php b/tests/Feature/WhiteLabelResolverTest.php
new file mode 100644
index 0000000..baaee61
--- /dev/null
+++ b/tests/Feature/WhiteLabelResolverTest.php
@@ -0,0 +1,275 @@
+create(['email' => uniqid('wlr_', true).'@test.example', 'password' => bcrypt('password')]);
+ $user->forceFill(['admin' => $admin, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
+
+ return $user->fresh();
+}
+
+function wlrVariant(string $name, string $value): Attribute
+{
+ $type = AttributeType::query()->firstOrCreate(['id' => 1], ['name' => 'Variante', 'slug' => 'variante']);
+
+ return Attribute::query()->create(['attribute_type_id' => $type->id, 'name' => $name, 'value' => $value, 'slug' => uniqid('v_'), 'active' => true, 'pos' => 0]);
+}
+
+function wlrProduct(string $name, ?Attribute $variant, array $possibleVariants = []): Product
+{
+ $product = Product::query()->create(['name' => $name, 'title' => $name, 'active' => true, 'is_set' => false, 'whitelabel' => true, 'whitelabel_variant_id' => $variant?->id]);
+ foreach ($possibleVariants as $attribute) {
+ ProductAttribute::create(['product_id' => $product->id, 'type_id' => 1, 'attribute_id' => $attribute->id]);
+ }
+
+ return $product;
+}
+
+function wlrStandardLabel(Product $product, array $attributes, ?float $sizeCm = null, string $position = 'deckel', bool $withFile = true): ProductImage
+{
+ $name = uniqid('std_').'.png';
+ if ($withFile) {
+ Storage::disk('public')->put('images/product/'.$product->id.'/'.$name, 'png');
+ }
+
+ return ProductImage::create(['product_id' => $product->id, 'type' => 'wllogo', 'filename' => $name, 'original_name' => $name, 'ext' => 'png', 'mine' => 'image/png', 'size' => 3, 'pos' => 0, 'active' => true, 'attributes' => $attributes, 'position' => $position, 'size_cm' => $sizeCm]);
+}
+
+function wlrAssignment(User $user, Product $product, array $attributes, string $position = 'deckel', bool $withFile = true): UserWhitelabelProduct
+{
+ $assignment = UserWhitelabelProduct::firstOrCreate(['user_id' => $user->id, 'product_id' => $product->id], ['active' => true]);
+ $name = uniqid('logo_').'.png';
+ if ($withFile) {
+ Storage::disk('public')->put('images/user_product/'.$assignment->id.'/'.$name, 'png');
+ }
+ ProductImage::create(['user_wl_product_id' => $assignment->id, 'type' => 'uwllogo', 'filename' => $name, 'original_name' => $name, 'ext' => 'png', 'mine' => 'image/png', 'size' => 3, 'pos' => 0, 'active' => true, 'attributes' => $attributes, 'position' => $position]);
+
+ return $assignment->fresh();
+}
+
+function wlrOrder(User $partner, array $products): ShoppingOrder
+{
+ $country = Country::query()->firstOrCreate(['code' => 'DE'], ['en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Germanija']);
+ $shoppingUser = ShoppingUser::query()->forceCreate(['billing_country_id' => $country->id, 'shipping_country_id' => $country->id, 'billing_firstname' => 'Test', 'billing_lastname' => 'Partner', 'billing_address' => 'Weg 1', 'billing_zipcode' => '11111', 'billing_city' => 'Stadt', 'same_as_billing' => true]);
+ $shipping = Shipping::query()->firstOrCreate(['name' => 'Standard']);
+ $shippingCountry = ShippingCountry::query()->firstOrCreate(['shipping_id' => $shipping->id, 'country_id' => $country->id]);
+ $order = ShoppingOrder::query()->forceCreate(['shopping_user_id' => $shoppingUser->id, 'auth_user_id' => $partner->id, 'country_id' => $shippingCountry->id, 'shipped' => 0, 'txaction' => 'paid', 'total' => 10, 'user_white_label' => false]);
+ foreach ($products as $product) {
+ ShoppingOrderItem::query()->forceCreate(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 1, 'price' => 10]);
+ }
+
+ return $order->fresh();
+}
+
+beforeEach(function () {
+ Storage::fake('public');
+ $this->schwarz40 = wlrVariant('Schwarz 40 ml', '40#3,5');
+ $this->gold40 = wlrVariant('Gold 40 ml', '40#3,5');
+ $this->schwarz15 = wlrVariant('Schwarz 15 ml', '15#2');
+});
+
+test('kundenlogo mit vorhandener datei ergibt kundenlogo und produktfarbe', function () {
+ $partner = wlrUser();
+ $black = wlrProduct('Creme 40 ml (Tiegel SCHWARZ)', $this->schwarz40);
+ wlrStandardLabel($black, [(string) $this->schwarz40->id], 3.5);
+ wlrAssignment($partner, $black, [(string) $this->schwarz40->id]);
+
+ $result = (new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$black]))->first();
+
+ expect($result)->toBeInstanceOf(ResolvedItem::class)
+ ->and($result->isComplete())->toBeTrue()
+ ->and($result->color)->toBe('Schwarz')
+ ->and($result->colorSource)->toBe(ResolvedItem::COLOR_SOURCE_VARIANT)
+ ->and($result->hasCustomerLogo())->toBeTrue()
+ ->and($result->labels['deckel']->sizeCm)->toBe(3.5)
+ ->and($result->labels['deckel']->volumeMl)->toBe(40.0)
+ ->and($result->colorLine())->toBe('Tiegel: Schwarz')
+ ->and($result->labelSourceLine())->toBe('Etikett: Kundenlogo');
+});
+
+test('standardetikett auf schwarzem produkt behaelt die produktfarbe und meldet fehlende zuordnung nur als hinweis', function () {
+ $partner = wlrUser();
+ $black = wlrProduct('Creme 40 ml (Tiegel SCHWARZ)', $this->schwarz40);
+ $other = wlrProduct('Creme 15 ml (Tiegel SCHWARZ)', $this->schwarz15);
+ wlrStandardLabel($black, [(string) $this->schwarz40->id], 3.5);
+ wlrAssignment($partner, $other, [(string) $this->schwarz15->id]);
+
+ $result = (new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$black]))->first();
+
+ expect($result->isComplete())->toBeTrue()
+ ->and($result->color)->toBe('Schwarz')
+ ->and($result->hasCustomerLogo())->toBeFalse()
+ ->and($result->labelSourceLine())->toBe('Etikett: Standardetikett')
+ ->and($result->warnings)->toHaveCount(1)
+ ->and($result->warnings[0])->toContain('keine Kundenzuordnung');
+});
+
+test('kundenlogo ohne standardetikett ist zulaessig wenn die groesse aus der variante kommt', function () {
+ $partner = wlrUser();
+ $black = wlrProduct('Creme 40 ml (Tiegel SCHWARZ)', $this->schwarz40);
+ wlrAssignment($partner, $black, []);
+
+ $result = (new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$black]))->first();
+
+ expect($result->isComplete())->toBeTrue()
+ ->and($result->hasCustomerLogo())->toBeTrue()
+ ->and($result->labels['deckel']->sizeCm)->toBe(3.5);
+});
+
+test('fehlende logodatei ist ein fehler ohne rueckfall auf das standardetikett', function () {
+ $partner = wlrUser();
+ $black = wlrProduct('Creme 40 ml (Tiegel SCHWARZ)', $this->schwarz40);
+ wlrStandardLabel($black, [(string) $this->schwarz40->id], 3.5);
+ wlrAssignment($partner, $black, [(string) $this->schwarz40->id], 'deckel', false);
+
+ $result = (new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$black]))->first();
+
+ expect($result->isComplete())->toBeFalse()
+ ->and($result->labels)->toBe([])
+ ->and($result->errors[0])->toContain('Datei fehlt');
+});
+
+test('farbwiderspruch zwischen kundenbild und produkt ist ein fehler', function () {
+ $partner = wlrUser();
+ $gold = wlrProduct('Creme 40 ml (Tiegel GOLD)', $this->gold40);
+ wlrStandardLabel($gold, [(string) $this->gold40->id], 3.5);
+ wlrAssignment($partner, $gold, [(string) $this->schwarz40->id]);
+
+ $result = (new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$gold]))->first();
+
+ expect($result->isComplete())->toBeFalse()
+ ->and($result->errors)->toHaveCount(1)
+ ->and($result->errors[0])->toContain('für Schwarz hinterlegt, Produktfarbe ist Gold');
+});
+
+test('mehrere kundenlogos auf derselben position sind ein konflikt ohne auswahl nach reihenfolge', function () {
+ $partner = wlrUser();
+ $black = wlrProduct('Creme 40 ml (Tiegel SCHWARZ)', $this->schwarz40);
+ wlrStandardLabel($black, [(string) $this->schwarz40->id], 3.5);
+ wlrAssignment($partner, $black, [(string) $this->schwarz40->id]);
+ wlrAssignment($partner, $black, [(string) $this->schwarz40->id]);
+
+ $result = (new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$black]))->first();
+
+ expect($result->isComplete())->toBeFalse()
+ ->and($result->errors[0])->toContain('mehrere Bilder für Position "deckel"');
+});
+
+test('positionen bleiben getrennt: kundenlogo am deckel ersetzt nicht das bodenetikett', function () {
+ $partner = wlrUser();
+ $black = wlrProduct('Creme 40 ml (Tiegel SCHWARZ)', $this->schwarz40);
+ wlrStandardLabel($black, [(string) $this->schwarz40->id], 3.5, 'deckel');
+ $boden = wlrStandardLabel($black, [], 4.7, 'boden');
+ wlrAssignment($partner, $black, [(string) $this->schwarz40->id], 'deckel');
+
+ $result = (new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$black]))->first();
+
+ expect($result->isComplete())->toBeTrue()
+ ->and($result->labels)->toHaveCount(2)
+ ->and($result->labels['deckel']->isCustomer())->toBeTrue()
+ ->and($result->labels['boden']->isCustomer())->toBeFalse()
+ ->and($result->labels['boden']->image->id)->toBe($boden->id)
+ ->and($result->labels['boden']->sizeCm)->toBe(4.7)
+ ->and($result->maxLabelSize())->toBe(4.7);
+});
+
+test('altlogik ohne verbindliche variante: kundenlogo greift nur bei attributueberschneidung', function () {
+ $partner = wlrUser();
+ $legacy = wlrProduct('Tattoobalm 30 ml', null, [$this->schwarz40, $this->gold40]);
+ wlrStandardLabel($legacy, [(string) $this->schwarz40->id, (string) $this->gold40->id]);
+
+ $noOverlap = wlrAssignment($partner, $legacy, [(string) $this->schwarz15->id]);
+ $result = (new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$legacy]))->first();
+ expect($result->isComplete())->toBeTrue()
+ ->and($result->hasCustomerLogo())->toBeFalse()
+ ->and($result->color)->toBe('Standard')
+ ->and($result->colorSource)->toBe(ResolvedItem::COLOR_SOURCE_LEGACY)
+ ->and($result->labels['deckel']->sizeCm)->toBe(3.5)
+ ->and(collect($result->warnings)->first(fn ($w) => str_contains($w, 'greift nicht')))->not->toBeNull();
+
+ $noOverlap->whitelabel_images()->update(['attributes' => json_encode([(string) $this->schwarz40->id])]);
+ $result = (new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$legacy]))->first();
+ expect($result->hasCustomerLogo())->toBeTrue()
+ ->and($result->color)->toBe('Schwarz')
+ ->and($result->volumeMl)->toBe(40.0);
+});
+
+test('resolver schreibt weder bestellung noch kennzeichen', function () {
+ $partner = wlrUser();
+ $black = wlrProduct('Creme 40 ml (Tiegel SCHWARZ)', $this->schwarz40);
+ wlrStandardLabel($black, [(string) $this->schwarz40->id], 3.5);
+ wlrAssignment($partner, $black, [(string) $this->schwarz40->id]);
+ $order = wlrOrder($partner, [$black]);
+
+ (new WhiteLabelResolver)->resolveOrder($order);
+
+ expect($order->fresh()->user_white_label)->toBeFalse();
+});
+
+test('nicht-white-label-positionen werden nicht aufgeloest', function () {
+ $partner = wlrUser();
+ $plain = Product::query()->create(['name' => 'Seife', 'title' => 'Seife', 'active' => true, 'is_set' => false, 'whitelabel' => false]);
+
+ expect((new WhiteLabelResolver)->resolveOrder(wlrOrder($partner, [$plain])))->toHaveCount(0);
+});
+
+test('produktformular speichert verbindliche variante sowie position und groesse je etikett', function () {
+ $black = wlrProduct('Creme 40 ml (Tiegel SCHWARZ)', null);
+ $label = wlrStandardLabel($black, [(string) $this->schwarz40->id]);
+
+ (new ProductRepository($black))->update([
+ 'id' => (string) $black->id,
+ 'name' => $black->name,
+ 'title' => $black->title,
+ 'active' => '1',
+ 'whitelabel' => '1',
+ 'whitelabel_variant_id' => (string) $this->schwarz40->id,
+ 'whitelabel_variants' => [(string) $this->schwarz40->id],
+ 'image_wl_attributes' => [$label->id => [(string) $this->schwarz40->id]],
+ 'image_wl_position' => [$label->id => 'boden'],
+ 'image_wl_size' => [$label->id => '4,7'],
+ ]);
+
+ $black->refresh();
+ $label->refresh();
+ expect($black->whitelabel_variant_id)->toBe($this->schwarz40->id)
+ ->and($black->whitelabel_variant->colorName())->toBe('Schwarz')
+ ->and($label->position)->toBe('boden')
+ ->and($label->size_cm)->toBe(4.7);
+
+ $this->actingAs(wlrUser(9), 'user');
+ $this->get(route('admin_product_edit', $black->id))
+ ->assertOk()
+ ->assertSee('Tiegelfarbe / Größe (verbindlich)')
+ ->assertSee('Etikettposition');
+});
+
+test('attribut-farbname trennt die groesse ab', function () {
+ expect($this->schwarz40->colorName())->toBe('Schwarz')
+ ->and(wlrVariant('Braunglasflasche 30 ml', '30#5')->colorName())->toBe('Braunglasflasche')
+ ->and(wlrVariant('Bodenetikett', '40#3,5')->colorName())->toBe('Bodenetikett')
+ ->and($this->schwarz40->variantSize())->toBe(3.5)
+ ->and($this->schwarz40->variantVolume())->toBe(40.0);
+});