- Migration: shopify_variant_id on products_sizes, shopify_order_id on products_sales - ShopifyService: paginated REST client (2024-10 API) - shopify:map — one-time command to link variants by SKU - shopify:sync — reconciliation command (Shopify is master) - VerifyShopifyWebhook middleware: HMAC-SHA256 verification - ShopifyWebhookController: idempotent orders/paid handler, FIFO inventory deduction - Webhook route: POST /api/webhooks/shopify/orders-paid Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
f54b471848
commit
467d85dfec
@@ -19,6 +19,9 @@ jobs:
|
|||||||
APP_KEY=${{ secrets.APP_KEY }}
|
APP_KEY=${{ secrets.APP_KEY }}
|
||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
APP_URL=${{ vars.APP_URL }}
|
APP_URL=${{ vars.APP_URL }}
|
||||||
|
SHOPIFY_SHOP_DOMAIN=${{ vars.SHOPIFY_SHOP_DOMAIN }}
|
||||||
|
SHOPIFY_ACCESS_TOKEN=${{ secrets.SHOPIFY_ACCESS_TOKEN }}
|
||||||
|
SHOPIFY_WEBHOOK_SECRET=${{ secrets.SHOPIFY_WEBHOOK_SECRET }}
|
||||||
DB_CONNECTION=mariadb
|
DB_CONNECTION=mariadb
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\ProductSize;
|
||||||
|
use App\Services\ShopifyService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class ShopifyMap extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'shopify:map {--dry-run : Änderungen nur anzeigen, nichts speichern}';
|
||||||
|
protected $description = 'Verknüpft Shopify-Variant-IDs mit lokalen ProductSizes per SKU';
|
||||||
|
|
||||||
|
public function handle(ShopifyService $shopify): int
|
||||||
|
{
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
$variants = $shopify->getAllVariants();
|
||||||
|
|
||||||
|
$this->info('Shopify-Varianten geladen: ' . count($variants));
|
||||||
|
|
||||||
|
$mapped = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$noSku = 0;
|
||||||
|
|
||||||
|
foreach ($variants as $variant) {
|
||||||
|
if (empty($variant['sku'])) {
|
||||||
|
$noSku++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = ProductSize::where('sku', $variant['sku'])->first();
|
||||||
|
|
||||||
|
if (!$size) {
|
||||||
|
$this->warn(" SKU nicht gefunden: {$variant['sku']} ({$variant['product_title']})");
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($size->shopify_variant_id === $variant['id']) {
|
||||||
|
continue; // already correct
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$dryRun) {
|
||||||
|
$size->update(['shopify_variant_id' => $variant['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(" {$variant['sku']} → Variant-ID {$variant['id']}");
|
||||||
|
$mapped++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("✓ {$mapped} verknüpft" . ($dryRun ? ' (dry-run)' : ''));
|
||||||
|
if ($skipped) $this->warn("⚠ {$skipped} SKUs lokal nicht gefunden");
|
||||||
|
if ($noSku) $this->warn("⚠ {$noSku} Shopify-Varianten ohne SKU übersprungen");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\ProductInventory;
|
||||||
|
use App\Models\ProductSize;
|
||||||
|
use App\Services\ShopifyService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class ShopifySync extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'shopify:sync {--dry-run : Änderungen nur anzeigen, nichts speichern}';
|
||||||
|
protected $description = 'Gleicht lokalen Lagerbestand mit Shopify-Inventar ab';
|
||||||
|
|
||||||
|
public function handle(ShopifyService $shopify): int
|
||||||
|
{
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
$variants = $shopify->getAllVariants();
|
||||||
|
|
||||||
|
$added = 0;
|
||||||
|
$removed = 0;
|
||||||
|
$inSync = 0;
|
||||||
|
|
||||||
|
DB::transaction(function () use ($variants, $dryRun, &$added, &$removed, &$inSync) {
|
||||||
|
foreach ($variants as $variant) {
|
||||||
|
$size = ProductSize::where('shopify_variant_id', $variant['id'])->first();
|
||||||
|
|
||||||
|
if (!$size) {
|
||||||
|
continue; // not mapped yet — run shopify:map first
|
||||||
|
}
|
||||||
|
|
||||||
|
$shopifyQty = $variant['inventory_quantity'];
|
||||||
|
$localQty = ProductInventory::where('size_id', $size->id)
|
||||||
|
->whereNull('out_at')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($localQty === $shopifyQty) {
|
||||||
|
$inSync++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$diff = $shopifyQty - $localQty;
|
||||||
|
|
||||||
|
if ($diff > 0) {
|
||||||
|
// Shopify hat mehr → Einträge hinzufügen
|
||||||
|
$this->line(" +{$diff} {$variant['product_title']} (SKU {$variant['sku']})");
|
||||||
|
if (!$dryRun) {
|
||||||
|
for ($i = 0; $i < $diff; $i++) {
|
||||||
|
ProductInventory::create([
|
||||||
|
'product_id' => $size->product_id,
|
||||||
|
'size_id' => $size->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$added += $diff;
|
||||||
|
} else {
|
||||||
|
// Shopify hat weniger → älteste Einträge als ausgebucht markieren
|
||||||
|
$excess = abs($diff);
|
||||||
|
$this->line(" -{$excess} {$variant['product_title']} (SKU {$variant['sku']})");
|
||||||
|
if (!$dryRun) {
|
||||||
|
ProductInventory::where('size_id', $size->id)
|
||||||
|
->whereNull('out_at')
|
||||||
|
->oldest()
|
||||||
|
->limit($excess)
|
||||||
|
->update(['out_at' => now()]);
|
||||||
|
}
|
||||||
|
$removed += $excess;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info("✓ Sync abgeschlossen" . ($dryRun ? ' (dry-run)' : ''));
|
||||||
|
$this->info(" In Sync: {$inSync} | Hinzugefügt: {$added} | Ausgebucht: {$removed}");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\ProductInventory;
|
||||||
|
use App\Models\ProductSale;
|
||||||
|
use App\Models\ProductSize;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ShopifyWebhookController extends Controller
|
||||||
|
{
|
||||||
|
public function ordersPaid(Request $request): Response
|
||||||
|
{
|
||||||
|
$order = $request->json()->all();
|
||||||
|
$orderId = (string) ($order['id'] ?? '');
|
||||||
|
|
||||||
|
// Idempotency: ignore already-processed orders
|
||||||
|
if (ProductSale::where('shopify_order_id', $orderId)->exists()) {
|
||||||
|
return response('', 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($order, $orderId) {
|
||||||
|
foreach ($order['line_items'] ?? [] as $item) {
|
||||||
|
$variantId = (string) ($item['variant_id'] ?? '');
|
||||||
|
$quantity = (int) ($item['quantity'] ?? 0);
|
||||||
|
|
||||||
|
if (!$variantId || $quantity <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = ProductSize::where('shopify_variant_id', $variantId)->first();
|
||||||
|
|
||||||
|
if (!$size) {
|
||||||
|
Log::warning('Shopify webhook: unbekannte Variant-ID', [
|
||||||
|
'variant_id' => $variantId,
|
||||||
|
'order_id' => $orderId,
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIFO: älteste verfügbare Einheiten ausbuchen
|
||||||
|
ProductInventory::where('size_id', $size->id)
|
||||||
|
->whereNull('out_at')
|
||||||
|
->oldest()
|
||||||
|
->limit($quantity)
|
||||||
|
->update(['out_at' => now()]);
|
||||||
|
|
||||||
|
ProductSale::create([
|
||||||
|
'product_id' => $size->product_id,
|
||||||
|
'size_id' => $size->id,
|
||||||
|
'amount' => $quantity,
|
||||||
|
'sold_at' => now(),
|
||||||
|
'shopify_order_id' => $orderId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response('', 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class VerifyShopifyWebhook
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$hmac = $request->header('X-Shopify-Hmac-Sha256');
|
||||||
|
$secret = config('services.shopify.webhook_secret');
|
||||||
|
|
||||||
|
if (!$hmac || !$secret) {
|
||||||
|
abort(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$computed = base64_encode(hash_hmac('sha256', $request->getContent(), $secret, true));
|
||||||
|
|
||||||
|
if (!hash_equals($computed, $hmac)) {
|
||||||
|
abort(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ class ProductSale extends Model
|
|||||||
'size_id',
|
'size_id',
|
||||||
'amount',
|
'amount',
|
||||||
'sold_at',
|
'sold_at',
|
||||||
|
'shopify_order_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Beziehungen
|
// Beziehungen
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class ProductSize extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
protected $table = 'products_sizes';
|
protected $table = 'products_sizes';
|
||||||
protected $fillable = ['product_id', 'packing_id', 'sku', 'price', 'description'];
|
protected $fillable = ['product_id', 'packing_id', 'sku', 'shopify_variant_id', 'price', 'description'];
|
||||||
|
|
||||||
public function product() {
|
public function product() {
|
||||||
return $this->belongsTo(Product::class);
|
return $this->belongsTo(Product::class);
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ShopifyService
|
||||||
|
{
|
||||||
|
private string $baseUrl;
|
||||||
|
private string $token;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$domain = config('services.shopify.domain');
|
||||||
|
$version = config('services.shopify.api_version');
|
||||||
|
$this->baseUrl = "https://{$domain}/admin/api/{$version}";
|
||||||
|
$this->token = config('services.shopify.access_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all product variants from Shopify.
|
||||||
|
* Each entry: ['id', 'sku', 'inventory_quantity', 'product_title']
|
||||||
|
*/
|
||||||
|
public function getAllVariants(): array
|
||||||
|
{
|
||||||
|
$variants = [];
|
||||||
|
$url = "{$this->baseUrl}/products.json?limit=250&fields=id,title,variants";
|
||||||
|
|
||||||
|
while ($url) {
|
||||||
|
$response = Http::withHeader('X-Shopify-Access-Token', $this->token)->get($url);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
Log::error('Shopify API error', ['status' => $response->status(), 'body' => $response->body()]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($response->json('products', []) as $product) {
|
||||||
|
foreach ($product['variants'] as $variant) {
|
||||||
|
$variants[] = [
|
||||||
|
'id' => (string) $variant['id'],
|
||||||
|
'sku' => $variant['sku'] ?? '',
|
||||||
|
'inventory_quantity' => (int) ($variant['inventory_quantity'] ?? 0),
|
||||||
|
'product_title' => $product['title'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow Shopify's Link header pagination
|
||||||
|
$url = $this->nextPageUrl($response->header('Link'));
|
||||||
|
|
||||||
|
if ($url) {
|
||||||
|
usleep(500_000); // 0.5s — stay well within 2 req/s limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $variants;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nextPageUrl(?string $linkHeader): ?string
|
||||||
|
{
|
||||||
|
if (!$linkHeader) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link: <https://...?page_info=abc>; rel="next"
|
||||||
|
if (preg_match('/<([^>]+)>;\s*rel="next"/', $linkHeader, $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,4 +35,11 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'shopify' => [
|
||||||
|
'domain' => env('SHOPIFY_SHOP_DOMAIN'),
|
||||||
|
'access_token' => env('SHOPIFY_ACCESS_TOKEN'),
|
||||||
|
'webhook_secret' => env('SHOPIFY_WEBHOOK_SECRET'),
|
||||||
|
'api_version' => '2024-10',
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?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_sizes', function (Blueprint $table) {
|
||||||
|
$table->string('shopify_variant_id')->nullable()->unique()->after('sku');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('products_sales', function (Blueprint $table) {
|
||||||
|
$table->string('shopify_order_id')->nullable()->after('sold_at');
|
||||||
|
$table->index('shopify_order_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('products_sales', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['shopify_order_id']);
|
||||||
|
$table->dropColumn('shopify_order_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('products_sizes', function (Blueprint $table) {
|
||||||
|
$table->dropUnique(['shopify_variant_id']);
|
||||||
|
$table->dropColumn('shopify_variant_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Api\LotController;
|
use App\Http\Controllers\Api\LotController;
|
||||||
|
use App\Http\Controllers\ShopifyWebhookController;
|
||||||
|
use App\Http\Middleware\VerifyShopifyWebhook;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/lot/{lot_number}', [LotController::class, 'show'])
|
Route::get('/lot/{lot_number}', [LotController::class, 'show'])
|
||||||
->middleware('throttle:30,1')
|
->middleware('throttle:30,1')
|
||||||
->name('api.lot');
|
->name('api.lot');
|
||||||
|
|
||||||
|
Route::post('/webhooks/shopify/orders-paid', [ShopifyWebhookController::class, 'ordersPaid'])
|
||||||
|
->middleware(VerifyShopifyWebhook::class);
|
||||||
|
|||||||
Reference in New Issue
Block a user