Files
SpiceManagementSystem/app/Console/Commands/ShopifyImport.php
T
Malek TellissiandClaude Sonnet 4.6 a7f5c4a83e
Deploy / deploy (push) Successful in 15s
Recipe versioning, inline recipe create, recipe summary, Shopify description
- 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>
2026-09-12 05:18:11 +02:00

186 lines
7.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}