Implement MakerWorld collections sync every 10 minutes and Artikel Beheer staging workflow

This commit is contained in:
2026-11-01 15:39:10 +01:00
parent 121ff624de
commit 2d78d80934
20 changed files with 1984 additions and 675 deletions
@@ -0,0 +1,77 @@
<?php
namespace App\Console\Commands;
use App\Models\MakerWorldCollection;
use App\Services\MakerWorldService;
use Illuminate\Console\Command;
class SyncMakerWorldCollectionsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:sync-makerworld-collections
{--collection= : Specifieke collectie ID (MakerWorld ID of database ID)}
{--all : Synchroniseer alle collecties, ook niet-actieve}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Controleert MakerWorld collecties en haalt nieuwe 3D modellen automatisch binnen als concept.';
/**
* Execute the console command.
*/
public function handle(MakerWorldService $makerWorld): int
{
$this->info('MakerWorld Collecties Synchronisatie gestart...');
$query = MakerWorldCollection::query();
if ($specificId = $this->option('collection')) {
$query->where(function ($q) use ($specificId) {
$q->where('id', $specificId)
->orWhere('makerworld_collection_id', $specificId);
});
} elseif (! $this->option('all')) {
$query->where('is_active_sync', true);
}
$collections = $query->get();
if ($collections->isEmpty()) {
$this->comment('Geen actieve MakerWorld collecties gevonden om te synchroniseren.');
return self::SUCCESS;
}
$this->line("Aantal collecties te synchroniseren: {$collections->count()}");
$totalImported = 0;
$totalUpdated = 0;
foreach ($collections as $collection) {
$this->info("Synchroniseren van '{$collection->name}' (#{$collection->makerworld_collection_id})...");
$result = $makerWorld->syncCollection($collection);
if ($result['error']) {
$this->error("Fout bij '{$collection->name}': {$result['error']}");
} else {
$this->line(" ✓ {$result['imported']} nieuwe modellen binnengehaald als concept.");
$this->line(" ✓ {$result['updated']} bestaande modellen bijgewerkt.");
$totalImported += $result['imported'];
$totalUpdated += $result['updated'];
}
}
$this->info("Klaar! Totaal {$totalImported} nieuwe modellen binnengehaald en {$totalUpdated} bijgewerkt.");
return self::SUCCESS;
}
}
@@ -68,4 +68,3 @@ class AccountController extends Controller
return back()->with('success', 'Jouw wachtwoord is succesvol gewijzigd.');
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\MakerWorldCollection;
use App\Models\Product;
use App\Services\MakerWorldService;
use Illuminate\Http\JsonResponse;
@@ -18,17 +19,22 @@ class MakerWorldController extends Controller
) {}
/**
* Display the MakerWorld importer dashboard.
* Display the MakerWorld importer & collections dashboard.
*/
public function index(Request $request): View
{
$isAuthenticated = $this->makerWorld->isAuthenticated();
$userProfile = $this->makerWorld->getUserProfile();
$tab = $request->query('tab', $isAuthenticated ? 'favorites' : 'community');
$tab = $request->query('tab', 'collections');
$models = [];
$apiCollections = [];
if ($tab === 'favorites' && $isAuthenticated) {
if ($tab === 'collections') {
if ($isAuthenticated) {
$apiCollections = $this->makerWorld->getFavoritesCollections();
}
} elseif ($tab === 'favorites' && $isAuthenticated) {
$models = $this->makerWorld->getFavorites();
} elseif ($tab === 'published' && $isAuthenticated) {
$models = $this->makerWorld->getPublished();
@@ -36,15 +42,11 @@ class MakerWorldController extends Controller
$models = $this->makerWorld->getCommunityShowcase();
}
$importedProducts = Product::with('category')
->whereNotNull('makerworld_id')
$trackedCollections = MakerWorldCollection::withCount(['products', 'draftProducts', 'publishedProducts'])
->latest()
->paginate(15);
$importedIds = Product::whereNotNull('makerworld_id')
->pluck('makerworld_id')
->toArray();
->get();
$stagedCount = Product::where('status', 'draft')->count();
$categories = Category::orderBy('name')->get();
return view('admin.makerworld.index', [
@@ -52,12 +54,266 @@ class MakerWorldController extends Controller
'userProfile' => $userProfile,
'currentTab' => $tab,
'models' => $models,
'importedProducts' => $importedProducts,
'importedIds' => $importedIds,
'apiCollections' => $apiCollections,
'trackedCollections' => $trackedCollections,
'stagedCount' => $stagedCount,
'categories' => $categories,
]);
}
/**
* Display the "Artikel Beheer" staging area where imported models wait for review.
*/
public function staging(Request $request): View
{
$query = Product::with(['category', 'makerworldCollection'])
->where('status', 'draft');
if ($request->filled('collection_id')) {
$query->where('makerworld_collection_id', $request->input('collection_id'));
}
if ($request->filled('q')) {
$q = $request->input('q');
$query->where(function ($sub) use ($q) {
$sub->where('name', 'like', "%{$q}%")
->orWhere('makerworld_author', 'like', "%{$q}%")
->orWhere('makerworld_id', 'like', "%{$q}%");
});
}
$stagedProducts = $query->latest()->paginate(24)->withQueryString();
$categories = Category::orderBy('name')->get();
$collections = MakerWorldCollection::all();
$totalStaged = Product::where('status', 'draft')->count();
return view('admin.makerworld.staging', compact('stagedProducts', 'categories', 'collections', 'totalStaged'));
}
/**
* Add a MakerWorld collection to be tracked and auto-synced.
*/
public function addCollection(Request $request): RedirectResponse
{
$validated = $request->validate([
'collection_input' => 'required|string',
'is_active_sync' => 'nullable|boolean',
'sync_now' => 'nullable|boolean',
]);
$input = trim($validated['collection_input']);
$parsed = $this->makerWorld->parseCollectionIdentifier($input);
$collectionId = $parsed['id'];
$userHandle = $parsed['handle'] ?? 'user_1718037659';
// If user entered a user collections URL or handle without specific collection ID
if ($parsed['type'] === 'user_collections' && ! $collectionId) {
// Attempt to fetch collection list for user
$userCollections = $this->makerWorld->getFavoritesCollections();
if (! empty($userCollections)) {
$addedCount = 0;
foreach ($userCollections as $c) {
$cId = (string) ($c['id'] ?? '');
if ($cId !== '') {
$collection = MakerWorldCollection::firstOrCreate(
['makerworld_collection_id' => $cId],
[
'name' => $c['title'] ?? 'MakerWorld Collectie #'.$cId,
'description' => $c['description'] ?? null,
'cover_url' => is_array($c['designCover'] ?? null) ? ($c['designCover'][0] ?? null) : ($c['cover'] ?? null),
'user_handle' => $userHandle,
'source_url' => $input,
'is_active_sync' => true,
'sync_interval_minutes' => 10,
]
);
$this->makerWorld->syncCollection($collection);
$addedCount++;
}
}
return redirect()->route('admin.makerworld.index', ['tab' => 'collections'])
->with('success', "{$addedCount} collecties van @{$userHandle} succesvol gekoppeld en gesynchroniseerd!");
}
return redirect()->route('admin.makerworld.index', ['tab' => 'collections'])
->with('warning', "Gebruikersprofiel @{$userHandle} herkend. Voer a.u.b. een specifieke collectie-link of ID in (bijv. https://makerworld.com/nl/collections/123456) of log in bij MakerWorld.");
}
if (! $collectionId) {
return redirect()->route('admin.makerworld.index', ['tab' => 'collections'])
->with('error', 'Kon geen geldige MakerWorld collectie ID of URL herkennen.');
}
// Fetch collection details from MakerWorld
$details = $this->makerWorld->getCollectionDetails($collectionId);
$name = $details['title'] ?? 'MakerWorld Collectie #'.$collectionId;
$cover = is_array($details['designCover'] ?? null) ? ($details['designCover'][0] ?? null) : ($details['cover'] ?? null);
$desc = $details['description'] ?? null;
$collection = MakerWorldCollection::updateOrCreate(
['makerworld_collection_id' => (string) $collectionId],
[
'name' => $name,
'description' => $desc,
'cover_url' => $cover,
'user_handle' => $userHandle,
'source_url' => $input,
'is_active_sync' => $request->boolean('is_active_sync', true),
'sync_interval_minutes' => 10,
'raw_metadata' => $details,
]
);
$msg = "Collectie '{$collection->name}' is succesvol toegevoegd!";
if ($request->boolean('sync_now', true)) {
$syncResult = $this->makerWorld->syncCollection($collection);
$msg .= " Direct {$syncResult['imported']} modellen binnengehaald als concept voor Artikel Beheer.";
}
return redirect()->route('admin.makerworld.index', ['tab' => 'collections'])
->with('success', $msg);
}
/**
* Toggle active 10-minute sync for a collection.
*/
public function toggleCollectionSync(MakerWorldCollection $collection): RedirectResponse
{
$collection->update([
'is_active_sync' => ! $collection->is_active_sync,
]);
$statusStr = $collection->is_active_sync ? 'geactiveerd (elke 10 min)' : 'gepauzeerd';
return redirect()->back()
->with('success', "Automatische synchronisatie voor '{$collection->name}' is {$statusStr}.");
}
/**
* Trigger immediate synchronization for a collection.
*/
public function syncCollectionNow(MakerWorldCollection $collection): RedirectResponse
{
$result = $this->makerWorld->syncCollection($collection);
if ($result['error']) {
return redirect()->back()
->with('error', "Synchronisatie mislukt: {$result['error']}");
}
return redirect()->back()
->with('success', "Collectie '{$collection->name}' gesynchroniseerd: {$result['imported']} nieuwe modellen binnengehaald, {$result['updated']} bijgewerkt.");
}
/**
* Remove a tracked collection.
*/
public function deleteCollection(MakerWorldCollection $collection): RedirectResponse
{
$name = $collection->name;
$collection->delete();
return redirect()->route('admin.makerworld.index', ['tab' => 'collections'])
->with('success', "Collectie '{$name}' is verwijderd uit de synchronisatie.");
}
/**
* Publish a single staged model to the active webshop catalog.
*/
public function publishStaged(Request $request, Product $product): RedirectResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'base_price' => 'required|numeric|min:0.5',
'short_description' => 'nullable|string|max:500',
'is_featured' => 'nullable|boolean',
]);
$product->update([
'name' => $validated['name'],
'category_id' => $validated['category_id'],
'base_price' => $validated['base_price'],
'short_description' => $validated['short_description'] ?? $product->short_description,
'is_featured' => $request->boolean('is_featured', false),
'status' => 'published',
'is_active' => true,
]);
return redirect()->back()
->with('success', "Artikel '{$product->name}' is succesvol goedgekeurd en gepubliceerd in de webshop!");
}
/**
* Bulk publish selected staged models.
*/
public function bulkPublishStaged(Request $request): RedirectResponse
{
$validated = $request->validate([
'product_ids' => 'required|array',
'product_ids.*' => 'integer|exists:products,id',
'category_id' => 'required|exists:categories,id',
'custom_markup' => 'nullable|numeric',
]);
$products = Product::whereIn('id', $validated['product_ids'])
->where('status', 'draft')
->get();
$count = 0;
foreach ($products as $p) {
$price = $p->base_price;
if (! empty($validated['custom_markup']) && $validated['custom_markup'] > 0) {
$price = round($price * (1 + ($validated['custom_markup'] / 100)), 2);
}
$p->update([
'category_id' => $validated['category_id'],
'base_price' => $price,
'status' => 'published',
'is_active' => true,
]);
$count++;
}
return redirect()->back()
->with('success', "{$count} artikelen succesvol gepubliceerd in de webshop!");
}
/**
* Reject and delete a staged model.
*/
public function rejectStaged(Product $product): RedirectResponse
{
$name = $product->name;
$product->delete();
return redirect()->back()
->with('success', "Concept '{$name}' is verwijderd.");
}
/**
* Bulk reject staged models.
*/
public function bulkRejectStaged(Request $request): RedirectResponse
{
$validated = $request->validate([
'product_ids' => 'required|array',
'product_ids.*' => 'integer|exists:products,id',
]);
$count = Product::whereIn('id', $validated['product_ids'])
->where('status', 'draft')
->delete();
return redirect()->back()
->with('success', "{$count} concepten zijn verwijderd.");
}
/**
* Authenticate with MakerWorld credentials.
*/
@@ -75,7 +331,7 @@ class MakerWorldController extends Controller
if ($result['status'] === 'success') {
return redirect()
->route('admin.makerworld.index', ['tab' => 'favorites'])
->route('admin.makerworld.index', ['tab' => 'collections'])
->with('success', $result['message']);
}
@@ -108,7 +364,7 @@ class MakerWorldController extends Controller
if ($result['status'] === 'success') {
return redirect()
->route('admin.makerworld.index', ['tab' => 'favorites'])
->route('admin.makerworld.index', ['tab' => 'collections'])
->with('success', $result['message']);
}
@@ -130,7 +386,7 @@ class MakerWorldController extends Controller
$this->makerWorld->setAccessToken($validated['token']);
return redirect()
->route('admin.makerworld.index', ['tab' => 'favorites'])
->route('admin.makerworld.index', ['tab' => 'collections'])
->with('success', 'MakerWorld token succesvol opgeslagen en verbonden!');
}
@@ -142,7 +398,7 @@ class MakerWorldController extends Controller
$this->makerWorld->logout();
return redirect()
->route('admin.makerworld.index', ['tab' => 'community'])
->route('admin.makerworld.index', ['tab' => 'collections'])
->with('success', 'MakerWorld account succesvol ontkoppeld.');
}
@@ -191,6 +447,7 @@ class MakerWorldController extends Controller
'custom_price' => 'nullable|numeric|min:0.5',
'custom_name' => 'nullable|string|max:255',
'is_active' => 'nullable|boolean',
'status' => 'nullable|string|in:draft,published',
]);
$details = $this->makerWorld->getDesignDetails($validated['makerworld_id']);
@@ -204,15 +461,18 @@ class MakerWorldController extends Controller
return redirect()->back()->with('error', $msg);
}
$status = $validated['status'] ?? ($request->boolean('is_active', true) ? 'published' : 'draft');
$product = $this->makerWorld->importDesign(
$details,
$validated['category_id'] ?? null,
$validated['custom_price'] ?? null,
$validated['custom_name'] ?? null,
$request->boolean('is_active', true)
$status === 'published',
$status
);
$msg = "Model \"{$product->name}\" is succesvol toegevoegd aan je webshop!";
$msg = "Model \"{$product->name}\" is succesvol toegevoegd!";
if ($request->wantsJson()) {
return response()->json([
@@ -249,12 +509,13 @@ class MakerWorldController extends Controller
$categoryId,
null,
null,
$isActive
$isActive,
$isActive ? 'published' : 'draft'
);
$count++;
}
}
return redirect()->back()->with('success', "{$count} modellen succesvol geïmporteerd naar je webshop!");
return redirect()->back()->with('success', "{$count} modellen succesvol geïmporteerd!");
}
}
@@ -122,4 +122,3 @@ class ForgotPasswordController extends Controller
->with('success', 'Jouw wachtwoord is succesvol gewijzigd! Je bent direct ingelogd.');
}
}
@@ -70,4 +70,3 @@ class LoginController extends Controller
->with('success', 'Je bent succesvol uitgelogd.');
}
}
@@ -65,4 +65,3 @@ class RegisterController extends Controller
->with('success', "Welkom bij Jouwdriedee.nl, {$user->name}! Je account is aangemaakt en er is een bevestiging gestuurd naar {$user->email}.");
}
}
+5 -5
View File
@@ -19,7 +19,7 @@ class ShopController extends Controller
->get();
$featuredProducts = Product::with('category')
->where('is_active', true)
->published()
->where('is_featured', true)
->latest()
->take(6)
@@ -32,7 +32,7 @@ class ShopController extends Controller
public function catalog(Request $request)
{
$query = Product::with('category')->where('is_active', true);
$query = Product::with('category')->published();
// Filter op categorie
if ($request->filled('category')) {
@@ -75,13 +75,13 @@ class ShopController extends Controller
public function show($slug)
{
$product = Product::with('category')->where('slug', $slug)->firstOrFail();
$product = Product::with('category')->published()->where('slug', $slug)->firstOrFail();
$allMaterials = Material::where('is_active', true)->get()->keyBy('code');
$allColors = Color::where('in_stock', true)->get();
$relatedProducts = Product::where('id', '!=', $product->id)
$relatedProducts = Product::published()
->where('id', '!=', $product->id)
->where('category_id', $product->category_id)
->where('is_active', true)
->take(3)
->get();
@@ -26,4 +26,3 @@ class EnsureUserIsAdmin
return $next($request);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class MakerWorldCollection extends Model
{
use HasFactory;
protected $table = 'makerworld_collections';
protected $fillable = [
'makerworld_collection_id',
'name',
'slug',
'description',
'cover_url',
'user_handle',
'source_url',
'is_active_sync',
'sync_interval_minutes',
'items_count',
'last_synced_at',
'last_sync_status',
'last_sync_message',
'raw_metadata',
];
protected $casts = [
'is_active_sync' => 'boolean',
'sync_interval_minutes' => 'integer',
'items_count' => 'integer',
'last_synced_at' => 'datetime',
'raw_metadata' => 'array',
];
public function products(): HasMany
{
return $this->hasMany(Product::class, 'makerworld_collection_id');
}
public function draftProducts(): HasMany
{
return $this->hasMany(Product::class, 'makerworld_collection_id')
->where('status', 'draft');
}
public function publishedProducts(): HasMany
{
return $this->hasMany(Product::class, 'makerworld_collection_id')
->where('status', 'published');
}
}
+24
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -13,6 +14,7 @@ class Product extends Model
protected $fillable = [
'makerworld_id',
'makerworld_collection_id',
'category_id',
'name',
'slug',
@@ -24,9 +26,11 @@ class Product extends Model
'stock',
'is_featured',
'is_active',
'status',
'dimensions',
'weight_grams',
'print_time_hours',
'print_info',
'image_url',
'makerworld_url',
'makerworld_author',
@@ -43,6 +47,7 @@ class Product extends Model
'is_featured' => 'boolean',
'is_active' => 'boolean',
'print_time_hours' => 'float',
'print_info' => 'array',
'gallery_images' => 'array',
'available_materials' => 'array',
'available_colors' => 'array',
@@ -53,11 +58,30 @@ class Product extends Model
return $this->belongsTo(Category::class);
}
public function makerworldCollection(): BelongsTo
{
return $this->belongsTo(MakerWorldCollection::class, 'makerworld_collection_id');
}
public function orderItems(): HasMany
{
return $this->hasMany(OrderItem::class);
}
public function scopePublished(Builder $query): Builder
{
return $query->where('is_active', true)
->where(function ($q) {
$q->where('status', 'published')
->orWhereNull('status');
});
}
public function scopeDraft(Builder $query): Builder
{
return $query->where('status', 'draft');
}
public function getFormattedPriceAttribute(): string
{
return '€ '.number_format((float) ($this->base_price ?? 0), 2, ',', '.');
+291 -4
View File
@@ -2,6 +2,7 @@
namespace App\Services;
use App\Models\MakerWorldCollection;
use App\Models\Product;
use App\Models\Setting;
use Illuminate\Http\Client\PendingRequest;
@@ -404,6 +405,160 @@ class MakerWorldService
return null;
}
/**
* Parse a MakerWorld collection URL, handle, or ID.
* Examples:
* - https://makerworld.com/nl/@user_1718037659/collections -> type: user_collections, handle: user_1718037659
* - https://makerworld.com/nl/collections/9205051 -> type: collection, id: 9205051
* - 9205051 -> type: collection, id: 9205051
*
* @return array{type: string, id: ?int, handle: ?string, raw: string}
*/
public function parseCollectionIdentifier(string $input): array
{
$trimmed = trim($input);
// Direct numeric ID
if (is_numeric($trimmed)) {
return [
'type' => 'collection',
'id' => (int) $trimmed,
'handle' => null,
'raw' => $trimmed,
];
}
// Collection URL: /collections/12345
if (preg_match('/collections\/(\d+)/i', $trimmed, $matches)) {
return [
'type' => 'collection',
'id' => (int) $matches[1],
'handle' => null,
'raw' => $trimmed,
];
}
// User collections URL: /@user_1718037659/collections
if (preg_match('/@([a-zA-Z0-9_\-]+)\/collections/i', $trimmed, $matches)) {
return [
'type' => 'user_collections',
'id' => null,
'handle' => $matches[1],
'raw' => $trimmed,
];
}
// Just handle: @user_1718037659
if (str_starts_with($trimmed, '@')) {
return [
'type' => 'user_collections',
'id' => null,
'handle' => ltrim($trimmed, '@'),
'raw' => $trimmed,
];
}
return [
'type' => 'unknown',
'id' => null,
'handle' => null,
'raw' => $trimmed,
];
}
/**
* Fetch user's favorite collections from MakerWorld.
*
* @return array<int, array<string, mixed>>
*/
public function getFavoritesCollections(): array
{
$collections = [];
// If authenticated, get logged-in user's collections
if ($this->isAuthenticated()) {
try {
$response = $this->client()->get("{$this->baseUrl}/v1/design-service/my/favorites");
if ($response->successful()) {
$data = $response->json();
$list = $this->extractModelsList($data);
if (! empty($list)) {
return $list;
}
}
} catch (\Throwable $e) {
Log::warning('MakerWorld getFavoritesCollections (auth) failed: '.$e->getMessage());
}
}
// Public/featured collections tab fallback
try {
$response = $this->client()->get("{$this->baseUrl}/v1/design-service/favorites-collections/tab");
if ($response->successful()) {
$data = $response->json();
if (! empty($data['hits']) && is_array($data['hits'])) {
return $data['hits'];
}
}
} catch (\Throwable $e) {
Log::warning('MakerWorld getFavoritesCollections fallback failed: '.$e->getMessage());
}
return $collections;
}
/**
* Fetch details of a specific collection by collection ID.
*/
public function getCollectionDetails(int|string $collectionId): ?array
{
try {
$response = $this->client()->get("{$this->baseUrl}/v1/design-service/favorites/{$collectionId}");
if ($response->successful()) {
$data = $response->json();
if (! empty($data['id']) || ! empty($data['title'])) {
return $data;
}
}
} catch (\Throwable $e) {
Log::error("MakerWorld getCollectionDetails({$collectionId}) error: ".$e->getMessage());
}
return null;
}
/**
* Fetch all design models inside a MakerWorld collection.
*
* @return array<int, array<string, mixed>>
*/
public function getCollectionDesigns(int|string $collectionId, int $page = 1, int $limit = 100): array
{
$details = $this->getCollectionDetails($collectionId);
if ($details && ! empty($details['designs']) && is_array($details['designs'])) {
return $details['designs'];
}
try {
$response = $this->client()->get("{$this->baseUrl}/v1/design-service/favorites/designs/{$collectionId}", [
'page' => $page,
'limit' => $limit,
]);
if ($response->successful()) {
$data = $response->json();
return $this->extractModelsList($data);
}
} catch (\Throwable $e) {
Log::error("MakerWorld getCollectionDesigns({$collectionId}) error: ".$e->getMessage());
}
return [];
}
/**
* Transform raw MakerWorld API design details into a clean format for product creation.
*/
@@ -423,7 +578,7 @@ class MakerWorldService
if (! empty($design['designExtension']['design_pictures']) && is_array($design['designExtension']['design_pictures'])) {
foreach ($design['designExtension']['design_pictures'] as $pic) {
if (! empty($pic['url']) && $pic['url'] !== $coverUrl) {
if (! empty($pic['url']) && $pic['url'] !== $coverUrl && ! in_array($pic['url'], $gallery, true)) {
$gallery[] = $pic['url'];
}
}
@@ -437,10 +592,12 @@ class MakerWorldService
}
}
// Extract print profile stats (first/default instance)
// Extract print profile stats & instance information
$weightGrams = null;
$printTimeHours = null;
$materials = ['pla', 'petg'];
$printInfo = [];
$dimensions = null;
if (! empty($design['instances']) && is_array($design['instances'])) {
$profile = $design['instances'][0];
@@ -453,6 +610,33 @@ class MakerWorldService
$seconds = (int) $profile['prediction'];
$printTimeHours = round($seconds / 3600, 1);
}
// Extract instance photos if available
if (! empty($profile['pictures']) && is_array($profile['pictures'])) {
foreach ($profile['pictures'] as $p) {
$url = is_array($p) ? ($p['url'] ?? null) : $p;
if ($url && $url !== $coverUrl && ! in_array($url, $gallery, true)) {
$gallery[] = $url;
}
}
}
// Structured print info
$printInfo = [
'profile_title' => $profile['title'] ?? null,
'printer' => $profile['printerName'] ?? $profile['modelName'] ?? 'Bambu Lab Printer',
'nozzle' => $profile['nozzleDiameter'] ?? '0.4 mm',
'weight_grams' => $weightGrams,
'print_time_hours' => $printTimeHours,
'prediction_seconds' => $profile['prediction'] ?? null,
'plate_count' => count($profile['plates'] ?? []) ?: 1,
'filaments' => $profile['filamentConfig'] ?? $profile['filaments'] ?? [],
];
}
// Dimensions if specified
if (! empty($design['dimensions'])) {
$dimensions = is_array($design['dimensions']) ? implode(' x ', $design['dimensions']) : (string) $design['dimensions'];
}
// Calculate a smart suggested retail price
@@ -486,13 +670,16 @@ class MakerWorldService
'stock' => 15,
'is_featured' => false,
'is_active' => true,
'status' => 'published',
'dimensions' => $dimensions,
'weight_grams' => $weightGrams,
'print_time_hours' => $printTimeHours,
'print_info' => $printInfo,
'image_url' => $coverUrl,
'makerworld_url' => $makerworldUrl,
'makerworld_author' => $authorName,
'makerworld_license' => $license,
'gallery_images' => array_slice($gallery, 0, 8),
'gallery_images' => $gallery,
'available_materials' => $materials,
'available_colors' => ['Mat Zwart', 'Sneeuwwit', 'Leisteen Grijs', 'Koningsblauw'],
];
@@ -506,7 +693,9 @@ class MakerWorldService
?int $categoryId = null,
?float $customPrice = null,
?string $customName = null,
bool $isActive = true
bool $isActive = true,
string $status = 'published',
?int $collectionId = null
): Product {
$formatted = $this->formatDesignForImport($designData);
@@ -524,6 +713,11 @@ class MakerWorldService
}
$formatted['is_active'] = $isActive;
$formatted['status'] = $status;
if ($collectionId !== null) {
$formatted['makerworld_collection_id'] = $collectionId;
}
// Ensure slug is unique
$baseSlug = $formatted['slug'];
@@ -539,6 +733,99 @@ class MakerWorldService
);
}
/**
* Synchronize designs from a MakerWorld collection.
* New items are staged in 'draft' status (is_active = false) for admin curation.
*
* @return array{imported: int, updated: int, total: int, error: ?string}
*/
public function syncCollection(MakerWorldCollection $collection): array
{
$collection->update([
'last_sync_status' => 'running',
'last_sync_message' => 'Synchronisatie bezig...',
]);
try {
$designs = $this->getCollectionDesigns($collection->makerworld_collection_id);
$imported = 0;
$updated = 0;
foreach ($designs as $design) {
$designId = (int) ($design['id'] ?? 0);
if ($designId <= 0) {
continue;
}
$existingProduct = Product::where('makerworld_id', $designId)->first();
if ($existingProduct) {
// Associate with collection if not yet linked
$updates = [];
if (! $existingProduct->makerworld_collection_id) {
$updates['makerworld_collection_id'] = $collection->id;
}
if (empty($existingProduct->gallery_images) && ! empty($design['designExtension']['design_pictures'])) {
$formatted = $this->formatDesignForImport($design);
$updates['gallery_images'] = $formatted['gallery_images'];
}
if (empty($existingProduct->print_info)) {
$formatted = $this->formatDesignForImport($design);
$updates['print_info'] = $formatted['print_info'];
}
if (! empty($updates)) {
$existingProduct->update($updates);
}
$updated++;
} else {
// Fetch full details if necessary to get all high-res photos and instances
$fullDetails = $this->getDesignDetails($designId) ?? $design;
$this->importDesign(
designData: $fullDetails,
categoryId: null,
customPrice: null,
customName: null,
isActive: false,
status: 'draft',
collectionId: $collection->id
);
$imported++;
}
}
$collection->update([
'items_count' => count($designs),
'last_synced_at' => now(),
'last_sync_status' => 'success',
'last_sync_message' => "{$imported} nieuwe modellen geïmporteerd als concept, {$updated} bijgewerkt.",
]);
return [
'imported' => $imported,
'updated' => $updated,
'total' => count($designs),
'error' => null,
];
} catch (\Throwable $e) {
Log::error("Sync collection {$collection->id} ({$collection->makerworld_collection_id}) error: ".$e->getMessage());
$collection->update([
'last_synced_at' => now(),
'last_sync_status' => 'error',
'last_sync_message' => 'Fout bij synchroniseren: '.$e->getMessage(),
]);
return [
'imported' => 0,
'updated' => 0,
'total' => 0,
'error' => $e->getMessage(),
];
}
}
/**
* Basic sanitization of MakerWorld HTML descriptions.
*/