Implement MakerWorld collections sync every 10 minutes and Artikel Beheer staging workflow
This commit is contained in:
@@ -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}.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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, ',', '.');
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user