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');
+ });
+ }
+};