Deploy / deploy (push) Successful in 15s
- Add version + product_id to recipes table (migration) - Recipe select shows version label (e.g. "Kurkuma v2") - Creating recipe from product page pre-fills product name, auto-increments version - Recipe ingredient summary shown read-only below the select - Remove package_content field (redundant with recipe ingredients) - Pull product description from Shopify body_html on import - Update existing products with empty description on re-import Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
186 lines
7.1 KiB
PHP
186 lines
7.1 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Models\Packing;
|
||
use App\Models\Product;
|
||
use App\Models\ProductInventory;
|
||
use App\Models\ProductSize;
|
||
use App\Services\ShopifyService;
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Illuminate\Support\Str;
|
||
|
||
class ShopifyImport extends Command
|
||
{
|
||
protected $signature = 'shopify:import {--dry-run : Nur anzeigen, nichts speichern}';
|
||
protected $description = 'Importiert Produkte und Lagerbestand direkt aus der Shopify API';
|
||
|
||
public function handle(ShopifyService $shopify): int
|
||
{
|
||
$dryRun = $this->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, 'description' => $variant['product_description']],
|
||
);
|
||
if ($product->wasRecentlyCreated) {
|
||
$stats['products']++;
|
||
// Collect for image download after transaction
|
||
$newProducts[$product->id] = [
|
||
'product' => $product,
|
||
'image_urls' => $variant['image_urls'],
|
||
];
|
||
} elseif (empty($product->description) && ! empty($variant['product_description'])) {
|
||
$product->update(['description' => $variant['product_description']]);
|
||
}
|
||
|
||
$size = ProductSize::firstOrCreate(
|
||
['product_id' => $product->id, 'packing_id' => $packing->id],
|
||
['sku' => $variant['sku'], 'shopify_variant_id' => $variant['id'], 'price' => $variant['price']],
|
||
);
|
||
|
||
// 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;
|
||
}
|
||
}
|