From 3ebd9845b08a0b4a3fdcbefe918dcbbf318a8de8 Mon Sep 17 00:00:00 2001 From: Malek Tellissi Date: Sat, 12 Sep 2026 17:20:26 +0200 Subject: [PATCH] Add Shopify publish/sync per product variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Console/Commands/ShopifyImport.php | 20 ++++-- .../User/Resources/ProductResource.php | 68 ++++++++++++++++--- .../ProductResource/Pages/EditProduct.php | 44 ++++++++++++ app/Models/Product.php | 2 +- app/Models/ProductSize.php | 2 +- app/Services/ShopifyService.php | 51 +++++++++++++- ...000_add_shopify_product_id_to_products.php | 22 ++++++ 7 files changed, 189 insertions(+), 20 deletions(-) create mode 100644 database/migrations/2026_09_12_100000_add_shopify_product_id_to_products.php diff --git a/app/Console/Commands/ShopifyImport.php b/app/Console/Commands/ShopifyImport.php index 1288162..e378e6f 100644 --- a/app/Console/Commands/ShopifyImport.php +++ b/app/Console/Commands/ShopifyImport.php @@ -58,17 +58,29 @@ class ShopifyImport extends Command $product = Product::firstOrCreate( ['title' => $variant['product_title']], - ['recipes_id' => null, 'description' => $variant['product_description']], + [ + 'recipes_id' => null, + 'description' => $variant['product_description'], + 'shopify_product_id' => $variant['shopify_product_id'], + ], ); 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']]); + } 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( diff --git a/app/Filament/User/Resources/ProductResource.php b/app/Filament/User/Resources/ProductResource.php index 1a54871..e8ebd86 100644 --- a/app/Filament/User/Resources/ProductResource.php +++ b/app/Filament/User/Resources/ProductResource.php @@ -18,9 +18,12 @@ use Filament\Tables; use Filament\Tables\Table; use Illuminate\Support\HtmlString; use App\Models\Packing; +use App\Models\ProductSize; use App\Models\Recipe; use App\Models\Spice; use App\Rules\SumEquals100; +use Filament\Forms\Components\Actions\Action as FormAction; +use Filament\Forms\Components\Hidden; use Picqer\Barcode\BarcodeGeneratorSVG; class ProductResource extends Resource { @@ -94,6 +97,15 @@ class ProductResource extends Resource { ->columnSpanFull(), Repeater::make('sizes') ->schema([ + Hidden::make('product_size_id'), + Hidden::make('shopify_variant_id'), + Placeholder::make('shopify_status') + ->label('Shopify') + ->content(fn (Get $get): HtmlString => + $get('shopify_variant_id') + ? new HtmlString('✓ Shopify') + : new HtmlString('Lokal') + ), Select::make('packing_id') ->label('Verpackung') ->options(function () { @@ -141,19 +153,53 @@ class ProductResource extends Resource { ->label('Größen & Preise') ->columns(2) ->addActionLabel('Option hinzufügen') + ->extraItemActions([ + FormAction::make('shopify') + ->label(fn (array $arguments, Repeater $component): string => + ($component->getState()[$arguments['item']]['shopify_variant_id'] ?? null) + ? 'Shopify sync' : 'Zu Shopify' + ) + ->icon(fn (array $arguments, Repeater $component): string => + ($component->getState()[$arguments['item']]['shopify_variant_id'] ?? null) + ? 'heroicon-o-arrow-path' : 'heroicon-o-arrow-up-tray' + ) + ->color(fn (array $arguments, Repeater $component): string => + ($component->getState()[$arguments['item']]['shopify_variant_id'] ?? null) + ? 'info' : 'warning' + ) + ->requiresConfirmation() + ->action(function (array $arguments, Repeater $component): void { + $item = $component->getState()[$arguments['item']] ?? []; + $sizeId = (int) ($item['product_size_id'] ?? 0); + if (! $sizeId) { + return; + } + $page = $component->getContainer()->getLivewire(); + if ($item['shopify_variant_id'] ?? null) { + $page->syncVariantWithShopify($sizeId); + } else { + $page->publishVariantToShopify($sizeId); + } + }) + ->visible(fn (array $arguments, Repeater $component): bool => + ! empty($component->getState()[$arguments['item']]['product_size_id'] ?? null) + ), + ]) ->afterStateHydrated(function ($state, $set, $record) { if ($record) { - $record->load('sizes'); - - $sizesData = $record->sizes->map(function ($size) { - return [ - 'packing_id' => $size->id, - 'price' => $size->pivot->price, - 'description' => $size->pivot->description, - 'sku' => $size->pivot->sku, - 'gtin' => $size->pivot->gtin, - ]; - })->toArray(); + $sizesData = ProductSize::with('packing') + ->where('product_id', $record->id) + ->get() + ->map(fn ($ps) => [ + 'product_size_id' => $ps->id, + 'shopify_variant_id' => $ps->shopify_variant_id, + 'packing_id' => $ps->packing_id, + 'price' => $ps->price, + 'sku' => $ps->sku, + 'gtin' => $ps->gtin, + 'description' => $ps->description, + ]) + ->toArray(); $set('sizes', $sizesData); } diff --git a/app/Filament/User/Resources/ProductResource/Pages/EditProduct.php b/app/Filament/User/Resources/ProductResource/Pages/EditProduct.php index 5f48e7d..8301292 100644 --- a/app/Filament/User/Resources/ProductResource/Pages/EditProduct.php +++ b/app/Filament/User/Resources/ProductResource/Pages/EditProduct.php @@ -5,7 +5,9 @@ namespace App\Filament\User\Resources\ProductResource\Pages; use App\Filament\User\Resources\ProductResource; use App\Models\ProductSize; use App\Models\Recipe; +use App\Services\ShopifyService; use Filament\Actions; +use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; class EditProduct extends EditRecord @@ -109,4 +111,46 @@ class EditProduct extends EditRecord $product->update(['recipes_id' => $recipe->id]); } + + public function publishVariantToShopify(int $sizeId): void + { + $size = ProductSize::with('packing')->find($sizeId); + $product = $this->record; + + if (! $size || $size->shopify_variant_id || ! $product->shopify_product_id) { + Notification::make() + ->title('Nicht möglich') + ->body($product->shopify_product_id + ? 'Variante bereits in Shopify verknüpft.' + : 'Produkt hat keine Shopify-ID — zuerst shopify:import ausführen.') + ->warning() + ->send(); + return; + } + + try { + $variantId = app(ShopifyService::class)->createVariant($product->shopify_product_id, $size); + $size->update(['shopify_variant_id' => $variantId]); + Notification::make()->title('Variante in Shopify veröffentlicht')->success()->send(); + } catch (\Throwable $e) { + Notification::make()->title('Shopify-Fehler')->body($e->getMessage())->danger()->send(); + } + } + + public function syncVariantWithShopify(int $sizeId): void + { + $size = ProductSize::find($sizeId); + + if (! $size || ! $size->shopify_variant_id) { + Notification::make()->title('Keine Shopify-Verknüpfung')->warning()->send(); + return; + } + + try { + app(ShopifyService::class)->updateVariant($size); + Notification::make()->title('Shopify synchronisiert')->success()->send(); + } catch (\Throwable $e) { + Notification::make()->title('Shopify-Fehler')->body($e->getMessage())->danger()->send(); + } + } } diff --git a/app/Models/Product.php b/app/Models/Product.php index b9740c6..7f0000c 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Model; class Product extends Model { - protected $fillable = ['title', 'description', 'images', 'package_content', 'recipes_id']; + protected $fillable = ['title', 'description', 'images', 'package_content', 'recipes_id', 'shopify_product_id']; protected $casts = [ 'images' => 'array', diff --git a/app/Models/ProductSize.php b/app/Models/ProductSize.php index 3428256..25e002b 100644 --- a/app/Models/ProductSize.php +++ b/app/Models/ProductSize.php @@ -9,7 +9,7 @@ class ProductSize extends Model { use HasFactory; protected $table = 'products_sizes'; - protected $fillable = ['product_id', 'packing_id', 'sku', 'shopify_variant_id', 'price', 'description']; + protected $fillable = ['product_id', 'packing_id', 'sku', 'shopify_variant_id', 'gtin', 'price', 'description']; public function product() { return $this->belongsTo(Product::class); diff --git a/app/Services/ShopifyService.php b/app/Services/ShopifyService.php index 9bc2dc6..7dc9d89 100644 --- a/app/Services/ShopifyService.php +++ b/app/Services/ShopifyService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\ProductSize; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -51,11 +52,12 @@ class ShopifyService $imageUrls = collect($product['images'] ?? [])->pluck('src')->all(); foreach ($product['variants'] as $variant) { $variants[] = [ - 'id' => (string) $variant['id'], - 'sku' => $variant['sku'] ?? '', - 'price' => (float) ($variant['price'] ?? 0), + 'id' => (string) $variant['id'], + 'sku' => $variant['sku'] ?? '', + 'price' => (float) ($variant['price'] ?? 0), 'inventory_quantity' => (int) ($variant['inventory_quantity'] ?? 0), 'product_title' => $product['title'], + 'shopify_product_id' => (string) $product['id'], 'product_description' => strip_tags($product['body_html'] ?? ''), 'option1' => $variant['option1'] ?? '', 'image_urls' => $imageUrls, @@ -74,6 +76,49 @@ class ShopifyService return $variants; } + public function createVariant(string $shopifyProductId, ProductSize $size): string + { + $packing = $size->packing; + $option1 = $packing->name . ' ' . $packing->size . 'g'; + + $response = Http::withHeader('X-Shopify-Access-Token', $this->token) + ->post("{$this->baseUrl}/products/{$shopifyProductId}/variants.json", [ + 'variant' => array_filter([ + 'option1' => $option1, + 'price' => number_format((float) $size->price, 2, '.', ''), + 'sku' => $size->sku ?: null, + 'barcode' => $size->gtin ?: null, + ]), + ]); + + if ($response->failed()) { + throw new \RuntimeException( + 'Shopify-Fehler ' . $response->status() . ': ' . $response->body() + ); + } + + return (string) $response->json('variant.id'); + } + + public function updateVariant(ProductSize $size): void + { + $response = Http::withHeader('X-Shopify-Access-Token', $this->token) + ->put("{$this->baseUrl}/variants/{$size->shopify_variant_id}.json", [ + 'variant' => array_filter([ + 'id' => $size->shopify_variant_id, + 'price' => number_format((float) $size->price, 2, '.', ''), + 'sku' => $size->sku ?: null, + 'barcode' => $size->gtin ?: null, + ], fn ($v) => $v !== null), + ]); + + if ($response->failed()) { + throw new \RuntimeException( + 'Shopify-Fehler ' . $response->status() . ': ' . $response->body() + ); + } + } + private function nextPageUrl(?string $linkHeader): ?string { if (!$linkHeader) { diff --git a/database/migrations/2026_09_12_100000_add_shopify_product_id_to_products.php b/database/migrations/2026_09_12_100000_add_shopify_product_id_to_products.php new file mode 100644 index 0000000..c6c6c64 --- /dev/null +++ b/database/migrations/2026_09_12_100000_add_shopify_product_id_to_products.php @@ -0,0 +1,22 @@ +string('shopify_product_id')->nullable()->after('id'); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('shopify_product_id'); + }); + } +};