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:
1 parent
e187465553
commit
3ebd9845b0
7 files changed
+189
-20
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user