Add Shopify publish/sync per product variant
Deploy / deploy (push) Successful in 14s

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>
This commit is contained in:
Malek TellissiandClaude Sonnet 4.6 committed 2026-09-12 17:20:26 +02:00
1 parent e187465553
commit 3ebd9845b0
7 files changed
+189 -20

No files matched your search

+16 -4
View File
@@ -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(
+57 -11
View File
@@ -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('<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">✓ Shopify</span>')
: new HtmlString('<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">Lokal</span>')
),
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);
}
@@ -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();
}
}
}
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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);
+48 -3
View File
@@ -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) {
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->string('shopify_product_id')->nullable()->after('id');
});
}
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropColumn('shopify_product_id');
});
}
};