Files
SpiceManagementSystem/app/Console/Commands/ShopifyImport.php
T
Malek TellissiandClaude Sonnet 4.6 3ebd9845b0
Deploy / deploy (push) Successful in 14s
Add Shopify publish/sync per product variant
Each size row in the product form now shows a Shopify status badge
(green ✓ Shopify / gray Lokal). For saved variants, an action button
appears: 'Zu Shopify' for local-only variants (creates via Shopify
API and stores the returned variant ID), 'Shopify sync' for linked
variants (pushes price/SKU/GTIN to Shopify).

Also fixes:
- gtin missing from ProductSize::$fillable (was silently not saved)
- shopify_product_id added to products table and stored on import
- sizes afterStateHydrated now loads ProductSize directly instead of
  via the belongsToMany pivot (gets product_size_id, shopify_variant_id)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-12 17:20:26 +02:00

198 lines
7.5 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'],
'shopify_product_id' => $variant['shopify_product_id'],
],
);
if ($product->wasRecentlyCreated) {
$stats['products']++;
$newProducts[$product->id] = [
'product' => $product,
'image_urls' => $variant['image_urls'],
];
} else {
$updates = [];
if (empty($product->description) && ! empty($variant['product_description'])) {
$updates['description'] = $variant['product_description'];
}
if (empty($product->shopify_product_id)) {
$updates['shopify_product_id'] = $variant['shopify_product_id'];
}
if ($updates) {
$product->update($updates);
}
}
$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;
}
}