feat: implement product and attachment management service with supporting migrations and API controllers
This commit is contained in:
@@ -18,7 +18,11 @@ class ProductController extends Controller
|
||||
public function index(Tenant $tenant): JsonResponse
|
||||
{
|
||||
return ProductResource::collection(
|
||||
Product::query()->where('tenant_codigo', $tenant->codigo)->latest()->paginateFromRequest()
|
||||
Product::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->with('attachments')
|
||||
->latest()
|
||||
->paginateFromRequest()
|
||||
)->response();
|
||||
}
|
||||
|
||||
@@ -33,7 +37,7 @@ class ProductController extends Controller
|
||||
{
|
||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||
|
||||
return ProductResource::make($producto->load('variants.definitions.attribute'));
|
||||
return ProductResource::make($producto->load(['attachments', 'variants.definitions.attribute', 'variants.attachments']));
|
||||
}
|
||||
|
||||
public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -61,6 +62,19 @@ class Product extends Model
|
||||
)->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Attachment, $this>
|
||||
*/
|
||||
public function attachments(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Attachment::class,
|
||||
'productos_attachments',
|
||||
'producto_id',
|
||||
'attachment_id'
|
||||
)->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a variant for this product with its definitions.
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -31,6 +32,8 @@ class StoreProductRequest extends FormRequest
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
),
|
||||
],
|
||||
'images' => ['sometimes', 'nullable', 'array'],
|
||||
'images.*' => ['required', new ImageOrBase64Rule()],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Catalog\Models\Product;
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -40,6 +41,8 @@ class UpdateProductRequest extends FormRequest
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
),
|
||||
],
|
||||
'images' => ['sometimes', 'nullable', 'array'],
|
||||
'images.*' => ['required', new ImageOrBase64Rule()],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ class ProductResource extends JsonResource
|
||||
'nombre' => $this->nombre,
|
||||
'descripcion' => $this->descripcion,
|
||||
'precio' => $this->precio,
|
||||
'images' => $this->whenLoaded('attachments', fn () =>
|
||||
$this->attachments->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))->values()
|
||||
),
|
||||
'variants' => ProductVariantResource::collection($this->whenLoaded('variants')),
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
|
||||
@@ -25,9 +25,21 @@ class ProductVariantResource extends JsonResource
|
||||
'precio' => $this->precio,
|
||||
'product' => ProductResource::make($this->whenLoaded('product')),
|
||||
'definitions' => ProductVariantDefinitionResource::collection($this->whenLoaded('definitions')),
|
||||
'images' => $this->whenLoaded('attachments', fn () =>
|
||||
$this->attachments->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))->values()
|
||||
),
|
||||
'images' => $this->whenLoaded('attachments', function () {
|
||||
if ($this->attachments->isNotEmpty()) {
|
||||
return $this->attachments
|
||||
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
||||
->values();
|
||||
}
|
||||
|
||||
// Fallback: use product-level attachments when the variant has none
|
||||
$this->loadMissing('product.attachments');
|
||||
|
||||
return $this->product?->attachments
|
||||
?->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
||||
?->values()
|
||||
?? collect();
|
||||
}),
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
|
||||
@@ -22,7 +22,8 @@ class ProductService
|
||||
{
|
||||
return DB::transaction(function () use ($tenant, $data) {
|
||||
$attributeIds = $data['attribute_ids'] ?? [];
|
||||
unset($data['attribute_ids']);
|
||||
$images = $data['images'] ?? [];
|
||||
unset($data['attribute_ids'], $data['images']);
|
||||
|
||||
/** @var Product $product */
|
||||
$product = Product::query()->create([
|
||||
@@ -32,7 +33,11 @@ class ProductService
|
||||
|
||||
$product->attributes()->sync($attributeIds);
|
||||
|
||||
return $product->load('attributes');
|
||||
if (! empty($images)) {
|
||||
$this->syncProductImages($product, $images);
|
||||
}
|
||||
|
||||
return $product->load(['attributes', 'attachments']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -46,7 +51,9 @@ class ProductService
|
||||
return DB::transaction(function () use ($product, $data) {
|
||||
$hasAttributeIds = array_key_exists('attribute_ids', $data);
|
||||
$attributeIds = $data['attribute_ids'] ?? [];
|
||||
unset($data['attribute_ids']);
|
||||
$hasImages = array_key_exists('images', $data);
|
||||
$images = $data['images'] ?? [];
|
||||
unset($data['attribute_ids'], $data['images']);
|
||||
|
||||
// Ensure tenant_codigo cannot be updated/changed
|
||||
unset($data['tenant_codigo']);
|
||||
@@ -57,7 +64,11 @@ class ProductService
|
||||
$product->attributes()->sync($attributeIds);
|
||||
}
|
||||
|
||||
return $product->load('attributes');
|
||||
if ($hasImages) {
|
||||
$this->syncProductImages($product, $images);
|
||||
}
|
||||
|
||||
return $product->load(['attributes', 'attachments']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,6 +81,14 @@ class ProductService
|
||||
foreach ($product->variants as $variant) {
|
||||
$product->deleteVariant($variant);
|
||||
}
|
||||
|
||||
// Delete product-level attachments from S3 and database
|
||||
$existing = $product->attachments()->get();
|
||||
$product->attachments()->detach();
|
||||
foreach ($existing as $attachment) {
|
||||
$this->attachmentService->delete($attachment);
|
||||
}
|
||||
|
||||
$product->attributes()->detach();
|
||||
$product->delete();
|
||||
});
|
||||
@@ -183,6 +202,32 @@ class ProductService
|
||||
$variant->attachments()->sync($attachmentIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a list of image files/base64 strings and sync them to a product.
|
||||
*
|
||||
* Same logic as syncImages but for products without variants.
|
||||
*
|
||||
* @param array<int, UploadedFile|string> $images
|
||||
*/
|
||||
protected function syncProductImages(Product $product, array $images): void
|
||||
{
|
||||
// Detach pivot record and delete attachment from S3 and database
|
||||
$existing = $product->attachments()->get();
|
||||
$product->attachments()->detach();
|
||||
foreach ($existing as $attachment) {
|
||||
$this->attachmentService->delete($attachment);
|
||||
}
|
||||
|
||||
$attachmentIds = [];
|
||||
|
||||
foreach ($images as $image) {
|
||||
$attachment = $this->attachmentService->store($image, 'products');
|
||||
$attachmentIds[] = $attachment->id;
|
||||
}
|
||||
|
||||
$product->attachments()->sync($attachmentIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an attribute.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('productos_attachments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('producto_id')
|
||||
->constrained('productos')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignId('attachment_id')
|
||||
->constrained('attachments')
|
||||
->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['producto_id', 'attachment_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('productos_attachments');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user