Files
SpiceManagementSystem/app/Console/Commands/ShopifyImport.php
T
2026-09-12 04:16:12 +02:00

184 lines
6.8 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],
);
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;
}
}