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
+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.
*/