option('dry-run'); $variants = $shopify->getAllVariants(); if (empty($variants)) { $this->error('Keine Varianten von Shopify erhalten (Details im Log).'); return self::FAILURE; } $this->info('Shopify-Varianten geladen: ' . count($variants)); $stats = ['products' => 0, 'sizes' => 0, 'inventory' => 0, 'skipped' => 0]; $newProducts = []; DB::transaction(function () use ($variants, $dryRun, &$stats, &$newProducts) { foreach ($variants as $variant) { $packingRaw = $variant['option1']; $parsed = $this->parsePacking($packingRaw, $variant['sku']); if ($parsed === null) { $this->warn(" ⚠ Verpackungsformat nicht erkannt: '{$packingRaw}' ({$variant['sku']}) – übersprungen"); $stats['skipped']++; continue; } if ($dryRun) { $this->line(" {$variant['product_title']} | {$parsed['name']} {$parsed['size']} | SKU {$variant['sku']} | Bestand {$variant['inventory_quantity']}"); continue; } $packing = Packing::firstOrCreate([ 'name' => $parsed['name'], 'size' => $parsed['size'], ]); $product = Product::firstOrCreate( ['title' => $variant['product_title']], ['recipes_id' => null], ); if ($product->wasRecentlyCreated) { $stats['products']++; // Collect for image download after transaction $newProducts[$product->id] = [ 'product' => $product, 'image_urls' => $variant['image_urls'], ]; } $size = ProductSize::firstOrCreate( ['product_id' => $product->id, 'packing_id' => $packing->id], ['sku' => $variant['sku'], 'shopify_variant_id' => $variant['id'], 'price' => 0], ); // Bestehende Size ohne Variant-ID nachträglich verknüpfen if (! $size->wasRecentlyCreated && ! $size->shopify_variant_id) { $size->update(['shopify_variant_id' => $variant['id'], 'sku' => $variant['sku']]); } if ($size->wasRecentlyCreated) { $stats['sizes']++; } // Lagerbestand: aktuelle Differenz auffüllen $localQty = ProductInventory::where('size_id', $size->id)->whereNull('out_at')->count(); $diff = $variant['inventory_quantity'] - $localQty; if ($diff > 0) { for ($i = 0; $i < $diff; $i++) { ProductInventory::create([ 'product_id' => $product->id, 'size_id' => $size->id, ]); } $stats['inventory'] += $diff; } } }); // Download images outside the transaction to avoid holding DB locks during HTTP calls foreach ($newProducts as ['product' => $product, 'image_urls' => $urls]) { $this->downloadImages($product, $urls); } if (! $dryRun) { $this->info("✓ {$stats['products']} Produkte erstellt"); $this->info("✓ {$stats['sizes']} Varianten erstellt"); $this->info("✓ {$stats['inventory']} Lagereinträge erstellt"); } if ($stats['skipped']) { $this->warn("⚠ {$stats['skipped']} Varianten übersprungen (Verpackungsformat unbekannt)"); } return self::SUCCESS; } private function downloadImages(Product $product, array $urls): void { if (empty($urls) || ! empty($product->images)) { return; } $paths = []; foreach ($urls as $url) { try { $response = Http::timeout(30)->get($url); } catch (\Exception $e) { $this->warn(" Bild-Download fehlgeschlagen ({$product->title}): {$e->getMessage()}"); continue; } if ($response->failed()) { $this->warn(" HTTP {$response->status()} beim Bild-Download ({$product->title}): {$url}"); continue; } $body = $response->body(); if (empty($body)) { $this->warn(" Leere Bild-Antwort ({$product->title}): {$url}"); continue; } $ext = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION) ?: 'jpg'; $filename = 'product-images/' . Str::uuid() . '.' . $ext; Storage::disk('public')->put($filename, $body); $paths[] = $filename; } if (! empty($paths)) { $product->update(['images' => $paths]); $this->line(" ✓ Bilder gespeichert: {$product->title} (" . count($paths) . ')'); } } private function parsePacking(string $option, string $sku): ?array { // "Standbodenbeutel 100 g" / "Metalldose 40 g" if (preg_match('/^(.+?)\s+(\d+(?:[.,]\d+)?)\s*g$/i', $option, $m)) { return ['name' => trim($m[1]), 'size' => (float) str_replace(',', '.', $m[2])]; } // "Metalldose 5 Stück" / "Standbodenbeutel 3 Stück" if (preg_match('/^(.+?)\s+(\d+)\s*Stück$/i', $option, $m)) { return ['name' => trim($m[1]) . ' (Stück)', 'size' => (float) $m[2]]; } // "1 Stange" / "3 Stangen" / "5 Stangen" if (preg_match('/^(\d+)\s*Stangen?$/i', $option, $m)) { return ['name' => 'Stangen', 'size' => (float) $m[1]]; } // "Default Title" — Verpackung aus SKU ableiten: z.B. GWM-AAS-100SB → 100g Standbodenbeutel if ($option === 'Default Title' && preg_match('/(\d+)(SB|MD|GT|BB)$/i', $sku, $m)) { $names = ['SB' => 'Standbodenbeutel', 'MD' => 'Metalldose', 'GT' => 'Glas', 'BB' => 'Beutel']; return ['name' => $names[strtoupper($m[2])], 'size' => (float) $m[1]]; } return null; } }