22 Commits

Author SHA1 Message Date
24235c5ca9 feat: add brand and category relationships to products, update requests and resources, and seed product catalog from images 2026-06-29 13:47:05 -03:00
49771ebb13 feat: implement product and attachment management service with supporting migrations and API controllers 2026-06-29 12:13:21 -03:00
cd420e02d8 feat: create ProductService to handle product, variant, and attribute management operations 2026-06-29 12:04:14 -03:00
0206ee2985 feat: implement product variant CRUD operations and tenant configuration seeding 2026-06-29 11:59:07 -03:00
9b7d728117 feat: implement product and attribute management services with corresponding controllers and feature tests 2026-06-29 11:37:22 -03:00
b5b57a753c feat: implement product and variant management controllers with request validation and feature tests 2026-06-29 11:10:04 -03:00
18504594eb feat: implement product creation workflow with variants, attribute definitions, and validation logic 2026-06-29 11:02:04 -03:00
9d3ed17c00 feat: implement BrandSeeder and CategorySeeder and register them in DatabaseSeeder 2026-06-29 10:02:12 -03:00
74e443ece1 feat: add attachment relationship to ProductVariant model and database schema 2026-06-29 09:56:15 -03:00
6efda4727f feat: implement product attribute options with database migration, resource, and validation requests 2026-06-29 09:48:56 -03:00
b0508d79b3 feat: implement Attachment domain for file storage and integrate into Tenant service for logo management 2026-06-29 09:43:46 -03:00
67bc9e6cb4 feat: add DatabaseSeeder and TenantSeeder with sample tenant configuration and assets 2026-06-29 09:32:09 -03:00
4125d8353d feat: add multi-tenant controllers for catalog and tenant management and implement pagination macro in AppServiceProvider 2026-06-29 09:02:39 -03:00
31b65eb676 feat: implement full CRUD support and database schema for multi-tenant architecture 2026-06-26 16:57:59 -03:00
8708cea122 feat: implement tenant update request validation and add feature tests for domain bootstrapping 2026-06-26 16:23:14 -03:00
54aa696145 feat: implement tenant management system with attachment support and bootstrap controller 2026-06-26 13:17:48 -03:00
1f9d876bc3 feat: implement tenant management system including database schema, CRUD services, and bootstrap functionality 2026-06-26 12:16:50 -03:00
0dae985230 feat: implement tenant management system including CRUD operations and domain bootstrapping functionality 2026-06-26 11:55:05 -03:00
4c237fb729 feat: implement product attributes and tenant properties management with CRUD controllers, requests, and domain models 2026-06-26 11:40:07 -03:00
edb4c128d6 Merge branch 'feature/compras' into dev 2026-06-26 11:16:11 -03:00
6c45f45815 feat(purchase): implement PurchaseController with CRUD operations and request validation 2026-06-25 10:58:55 -03:00
70f51ac863 feat(purchase): add Purchase and PurchaseItem models with migrations for compras and compra_items tables 2026-06-25 10:34:33 -03:00
103 changed files with 3684 additions and 1036 deletions

View File

@@ -0,0 +1,357 @@
{
"info": {
"_postman_id": "76df1733-a3d8-4f2b-9ffb-7c70e3032549",
"name": "ShopIt - Tenant CRUD",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "List Tenants",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/api/tenants",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants"
]
}
},
"response": []
},
{
"name": "Bootstrap Tenant",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/api/tenants/bootstrap/acme.com",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"bootstrap",
"acme.com"
]
}
},
"response": []
},
{
"name": "Create Tenant (JSON)",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"codigo\": \"acme\",\n \"nombre\": \"Acme Corporation\",\n \"dominio\": \"acme.com\",\n \"primary_color\": \"#111111\",\n \"secondary_color\": \"#222222\",\n \"danger_color\": \"#333333\",\n \"header_footer_bg_color\": \"#444444\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"\n}"
},
"url": {
"raw": "{{base_url}}/api/tenants",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants"
]
}
},
"response": []
},
{
"name": "Create Tenant (Form Data)",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "codigo",
"value": "acme",
"type": "text"
},
{
"key": "nombre",
"value": "Acme Corporation",
"type": "text"
},
{
"key": "dominio",
"value": "acme.com",
"type": "text"
},
{
"key": "primary_color",
"value": "#111111",
"type": "text"
},
{
"key": "secondary_color",
"value": "#222222",
"type": "text"
},
{
"key": "danger_color",
"value": "#333333",
"type": "text"
},
{
"key": "header_footer_bg_color",
"value": "#444444",
"type": "text"
},
{
"key": "header_logo",
"type": "file",
"src": []
},
{
"key": "footer_logo",
"type": "file",
"src": []
}
]
},
"url": {
"raw": "{{base_url}}/api/tenants",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants"
]
}
},
"response": []
},
{
"name": "Show Tenant",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/api/tenants/acme",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"acme"
]
}
},
"response": []
},
{
"name": "Update Tenant (JSON)",
"request": {
"method": "PUT",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"codigo\": \"acme\",\n \"nombre\": \"Acme Corporation Updated\",\n \"dominio\": \"acme.com\",\n \"primary_color\": \"#555555\",\n \"secondary_color\": \"#666666\",\n \"danger_color\": \"#777777\",\n \"header_footer_bg_color\": \"#888888\"\n}"
},
"url": {
"raw": "{{base_url}}/api/tenants/acme",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"acme"
]
}
},
"response": []
},
{
"name": "Update Tenant (Form Data)",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "_method",
"value": "PUT",
"type": "text"
},
{
"key": "codigo",
"value": "acme",
"type": "text"
},
{
"key": "nombre",
"value": "Acme Corporation Updated",
"type": "text"
},
{
"key": "dominio",
"value": "acme.com",
"type": "text"
},
{
"key": "primary_color",
"value": "#555555",
"type": "text"
},
{
"key": "secondary_color",
"value": "#666666",
"type": "text"
},
{
"key": "danger_color",
"value": "#777777",
"type": "text"
},
{
"key": "header_footer_bg_color",
"value": "#888888",
"type": "text"
},
{
"key": "header_logo",
"type": "file",
"src": []
},
{
"key": "footer_logo",
"type": "file",
"src": []
}
]
},
"url": {
"raw": "{{base_url}}/api/tenants/acme",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"acme"
]
}
},
"response": []
},
{
"name": "Delete Tenant",
"request": {
"method": "DELETE",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/api/tenants/acme",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"acme"
]
}
},
"response": []
}
],
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"exec": [
""
]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
""
]
}
}
],
"variable": [
{
"key": "base_url",
"value": "http://localhost:8000",
"type": "string"
}
]
}

View File

@@ -1,44 +0,0 @@
<?php
namespace App\Domains\Attachable\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Fillable([
'attachable_type',
'attachable_id',
'attachment_id',
])]
class AttachableAttachment extends Model
{
protected $table = 'attachable_attachments';
public $timestamps = false;
protected function casts(): array
{
return [
'attachable_id' => 'integer',
'attachment_id' => 'integer',
];
}
/**
* @return MorphTo<Model, $this>
*/
public function attachable(): MorphTo
{
return $this->morphTo();
}
/**
* @return BelongsTo<Attachment, $this>
*/
public function attachment(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'attachment_id');
}
}

View File

@@ -6,7 +6,6 @@ use App\Domains\Attachable\Enums\AttachmentType;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
#[Fillable([
@@ -42,10 +41,13 @@ class Attachment extends Model
}
/**
* @return HasMany<AttachableAttachment, $this>
* Get the pre-signed temporary S3 URL for this attachment.
*/
public function attachables(): HasMany
public function getTemporaryUrl(int $expiresInMinutes = 10): string
{
return $this->hasMany(AttachableAttachment::class, 'attachment_id');
return \Illuminate\Support\Facades\Storage::disk('s3')->temporaryUrl(
$this->path,
now()->addMinutes($expiresInMinutes)
);
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Domains\Attachable\Models\Concerns;
use App\Domains\Attachable\Models\Attachment;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
trait HasAttachments
{
/**
* @return MorphToMany<Attachment, Model, $this>
*/
public function attachments(): MorphToMany
{
return $this->morphToMany(
Attachment::class,
'attachable',
'attachable_attachments',
'attachable_id',
'attachment_id',
);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Requests\StoreAttributeRequest;
use App\Domains\Catalog\Requests\UpdateAttributeRequest;
use App\Domains\Catalog\Resources\AttributeResource;
use App\Domains\Catalog\Services\ProductService;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class AttributeController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
$query = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->with('options')
->latest();
return AttributeResource::collection($query->paginateFromRequest())->response();
}
public function store(StoreAttributeRequest $request, Tenant $tenant): JsonResponse
{
$attribute = ProductService::createAttribute($tenant, $request->validated());
return AttributeResource::make($attribute)->response()->setStatusCode(201);
}
public function show(Tenant $tenant, Attribute $attribute): AttributeResource
{
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
return AttributeResource::make($attribute->load('options'));
}
public function update(UpdateAttributeRequest $request, Tenant $tenant, Attribute $attribute): AttributeResource
{
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
$attribute = ProductService::updateAttribute($attribute, $request->validated());
return AttributeResource::make($attribute);
}
public function destroy(Tenant $tenant, Attribute $attribute): Response
{
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
ProductService::deleteAttribute($attribute);
return response()->noContent();
}
protected function resolveScopedAttribute(Tenant $tenant, Attribute $attribute): Attribute
{
if ($attribute->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Attribute not found for tenant.');
}
return $attribute;
}
}

View File

@@ -17,7 +17,7 @@ class BrandController extends Controller
public function index(Tenant $tenant): JsonResponse
{
return BrandResource::collection(
Brand::query()->where('tenant_codigo', $tenant->codigo)->orderByDesc('id')->get()
Brand::query()->where('tenant_codigo', $tenant->codigo)->orderByDesc('id')->paginateFromRequest()
)->response();
}

View File

@@ -21,7 +21,7 @@ class CategoryController extends Controller
->where('tenant_code', $tenant->codigo)
->with(['parent', 'subCategories', 'tenant'])
->orderByDesc('id')
->get()
->paginateFromRequest()
)->response();
}

View File

@@ -1,103 +0,0 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\ProductAttribute;
use App\Domains\Catalog\Requests\StoreProductAttributeRequest;
use App\Domains\Catalog\Requests\UpdateProductAttributeRequest;
use App\Domains\Catalog\Resources\ProductAttributeResource;
use App\Domains\Catalog\Support\ProductAttributeType;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ProductAttributeController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
$query = ProductAttribute::query()
->where('tenant_codigo', $tenant->codigo)
->with('options')
->latest();
return ProductAttributeResource::collection($query->get())->response();
}
public function store(StoreProductAttributeRequest $request, Tenant $tenant): JsonResponse
{
$attribute = DB::transaction(function () use ($request, $tenant): ProductAttribute {
$validated = $request->validated();
$options = $validated['options'] ?? [];
unset($validated['options']);
$type = ProductAttributeType::from((string) $validated['type']);
if (! $type->supportsOptions()) {
$validated['metadata_schema'] = null;
$options = [];
}
/** @var ProductAttribute $attribute */
$attribute = ProductAttribute::query()->create([
...$validated,
'tenant_codigo' => $tenant->codigo,
]);
$attribute->options()->createMany($options);
return $attribute->load('options');
});
return ProductAttributeResource::make($attribute)->response()->setStatusCode(201);
}
public function show(Tenant $tenant, ProductAttribute $productAttribute): ProductAttributeResource
{
$productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute);
return ProductAttributeResource::make($productAttribute->load('options'));
}
public function update(UpdateProductAttributeRequest $request, Tenant $tenant, ProductAttribute $productAttribute): ProductAttributeResource
{
$productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute);
$productAttribute = DB::transaction(function () use ($request, $productAttribute): ProductAttribute {
$validated = $request->validated();
$options = $validated['options'] ?? [];
unset($validated['options']);
$type = ProductAttributeType::from((string) $validated['type']);
if (! $type->supportsOptions()) {
$validated['metadata_schema'] = null;
$options = [];
}
$productAttribute->update($validated);
$productAttribute->options()->delete();
$productAttribute->options()->createMany($options);
return $productAttribute->load('options');
});
return ProductAttributeResource::make($productAttribute);
}
public function destroy(Tenant $tenant, ProductAttribute $productAttribute): Response
{
$productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute);
$productAttribute->delete();
return response()->noContent();
}
protected function resolveScopedAttribute(Tenant $tenant, ProductAttribute $attribute): ProductAttribute
{
if ($attribute->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Product attribute not found for tenant.');
}
return $attribute;
}
}

View File

@@ -6,6 +6,7 @@ use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Requests\StoreProductRequest;
use App\Domains\Catalog\Requests\UpdateProductRequest;
use App\Domains\Catalog\Resources\ProductResource;
use App\Domains\Catalog\Services\ProductService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
@@ -17,16 +18,17 @@ class ProductController extends Controller
public function index(Tenant $tenant): JsonResponse
{
return ProductResource::collection(
Product::query()->where('tenant_codigo', $tenant->codigo)->latest()->get()
Product::query()
->where('tenant_codigo', $tenant->codigo)
->with(['attachments', 'brand', 'category'])
->latest()
->paginateFromRequest()
)->response();
}
public function store(StoreProductRequest $request, Tenant $tenant): JsonResponse
public function store(StoreProductRequest $request, Tenant $tenant, ProductService $productService): JsonResponse
{
$product = Product::query()->create([
...$request->validated(),
'tenant_codigo' => $tenant->codigo,
]);
$product = $productService->create($tenant, $request->validated());
return ProductResource::make($product)->response()->setStatusCode(201);
}
@@ -35,21 +37,27 @@ class ProductController extends Controller
{
$producto = $this->resolveScopedProduct($tenant, $producto);
return ProductResource::make($producto);
return ProductResource::make($producto->load([
'attachments',
'brand',
'category',
'variants.definitions.attribute',
'variants.attachments',
]));
}
public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto): ProductResource
public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$producto->update($request->validated());
$producto = $productService->update($producto, $request->validated());
return ProductResource::make($producto);
}
public function destroy(Tenant $tenant, Product $producto): Response
public function destroy(Tenant $tenant, Product $producto, ProductService $productService): Response
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$producto->delete();
$productService->delete($producto);
return response()->noContent();
}

View File

@@ -7,6 +7,7 @@ use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Requests\StoreProductVariantRequest;
use App\Domains\Catalog\Requests\UpdateProductVariantRequest;
use App\Domains\Catalog\Resources\ProductVariantResource;
use App\Domains\Catalog\Services\ProductService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
@@ -20,28 +21,18 @@ class ProductVariantController extends Controller
{
$query = ProductVariant::query()
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $tenant->codigo))
->with(['product', 'definitions.attribute'])
->with(['product', 'definitions.attribute', 'attachments'])
->latest();
return ProductVariantResource::collection($query->get())->response();
return ProductVariantResource::collection($query->paginateFromRequest())->response();
}
public function store(StoreProductVariantRequest $request, Tenant $tenant): JsonResponse
public function store(StoreProductVariantRequest $request, Tenant $tenant, ProductService $productService): JsonResponse
{
$validated = $request->validated();
$variant = DB::transaction(function () use ($request, $tenant): ProductVariant {
$definitions = $validated['definitions'] ?? [];
unset($validated['definitions']);
$product = $this->resolveTenantProduct($tenant, (int) $validated['producto_id']);
$product = $this->resolveTenantProduct($tenant, (int) $validated['producto_id']);
/** @var ProductVariant $variant */
$variant = $product->variants()->create($validated);
$variant->definitions()->createMany($definitions);
return $variant->load(['product', 'definitions.attribute']);
});
$variant = $productService->createVariant($product, $validated);
return ProductVariantResource::make($variant)->response()->setStatusCode(201);
}
@@ -50,34 +41,25 @@ class ProductVariantController extends Controller
{
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
return ProductVariantResource::make($productVariant->load(['product', 'definitions.attribute']));
return ProductVariantResource::make($productVariant->load(['product', 'definitions.attribute', 'attachments']));
}
public function update(UpdateProductVariantRequest $request, Tenant $tenant, ProductVariant $productVariant): ProductVariantResource
public function update(UpdateProductVariantRequest $request, Tenant $tenant, ProductVariant $productVariant, ProductService $productService): ProductVariantResource
{
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
$validated = $request->validated();
$productVariant = DB::transaction(function () use ($request, $tenant, $productVariant): ProductVariant {
$validated = $request->validated();
$definitions = $validated['definitions'] ?? [];
unset($validated['definitions']);
$this->resolveTenantProduct($tenant, (int) $validated['producto_id']);
$this->resolveTenantProduct($tenant, (int) $validated['producto_id']);
$productVariant->update($validated);
$productVariant->definitions()->delete();
$productVariant->definitions()->createMany($definitions);
return $productVariant->load(['product', 'definitions.attribute']);
});
$productVariant = $productService->updateVariant($productVariant, $validated);
return ProductVariantResource::make($productVariant);
}
public function destroy(Tenant $tenant, ProductVariant $productVariant): Response
public function destroy(Tenant $tenant, ProductVariant $productVariant, ProductService $productService): Response
{
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
$productVariant->delete();
$productService->deleteVariant($productVariant);
return response()->noContent();
}

View File

@@ -2,7 +2,7 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Catalog\Support\ProductAttributeType;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -18,11 +18,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'metadata_schema',
'type',
])]
class ProductAttribute extends Model
class Attribute extends Model
{
use HasFactory;
protected $table = 'productos_attributes';
protected $table = 'attribute';
/**
* @return array<string, string>
@@ -32,7 +32,7 @@ class ProductAttribute extends Model
return [
'is_required' => 'boolean',
'metadata_schema' => 'array',
'type' => ProductAttributeType::class,
'type' => FieldType::class,
];
}
@@ -45,11 +45,11 @@ class ProductAttribute extends Model
}
/**
* @return HasMany<ProductAttributeOption, $this>
* @return HasMany<AttributeOption, $this>
*/
public function options(): HasMany
{
return $this->hasMany(ProductAttributeOption::class, 'attribute_id')->orderBy('sort_order');
return $this->hasMany(AttributeOption::class, 'attribute_id')->orderBy('sort_order');
}
/**

View File

@@ -9,11 +9,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'attribute_id',
'value',
'label',
'sort_order',
'metadata',
])]
class ProductAttributeOption extends Model
class AttributeOption extends Model
{
use HasFactory;
@@ -31,10 +32,10 @@ class ProductAttributeOption extends Model
}
/**
* @return BelongsTo<ProductAttribute, $this>
* @return BelongsTo<Attribute, $this>
*/
public function attribute(): BelongsTo
{
return $this->belongsTo(ProductAttribute::class, 'attribute_id');
return $this->belongsTo(Attribute::class, 'attribute_id');
}
}

View File

@@ -2,16 +2,21 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\Brand;
use App\Domains\Catalog\Models\Category;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_codigo',
'categoria_id',
'brand_id',
'slug',
'nombre',
'descripcion',
@@ -27,6 +32,7 @@ class Product extends Model
{
return [
'categoria_id' => 'integer',
'brand_id' => 'integer',
'precio' => 'decimal:2',
];
}
@@ -39,6 +45,22 @@ class Product extends Model
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return BelongsTo<Category, $this>
*/
public function category(): BelongsTo
{
return $this->belongsTo(Category::class, 'categoria_id');
}
/**
* @return BelongsTo<Brand, $this>
*/
public function brand(): BelongsTo
{
return $this->belongsTo(Brand::class, 'brand_id');
}
/**
* @return HasMany<ProductVariant, $this>
*/
@@ -46,4 +68,210 @@ class Product extends Model
{
return $this->hasMany(ProductVariant::class, 'producto_id');
}
/**
* @return BelongsToMany<Attribute, $this>
*/
public function attributes(): BelongsToMany
{
return $this->belongsToMany(
Attribute::class,
'products_attributes',
'product_id',
'attribute_id'
)->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.
*
* @param array<string, mixed> $data
*/
public function createVariant(array $data): ProductVariant
{
$definitions = $data['definitions'] ?? [];
$this->validateVariantDefinitions($definitions);
unset($data['definitions']);
/** @var ProductVariant $variant */
$variant = $this->variants()->create($data);
$variant->definitions()->createMany($definitions);
return $variant;
}
/**
* Create multiple variants for this product.
*
* @param array<int, array<string, mixed>> $variantsData
* @return \Illuminate\Database\Eloquent\Collection<int, ProductVariant>
*/
public function createVariants(array $variantsData): \Illuminate\Database\Eloquent\Collection
{
$variants = new \Illuminate\Database\Eloquent\Collection();
foreach ($variantsData as $variantData) {
$variants->push($this->createVariant($variantData));
}
return $variants;
}
/**
* Update a product variant.
*
* @param array<string, mixed> $data
*/
public function updateVariant(ProductVariant $variant, array $data): ProductVariant
{
$definitions = $data['definitions'] ?? [];
$this->validateVariantDefinitions($definitions);
unset($data['definitions']);
unset($data['producto_id']);
$variant->update($data);
$variant->definitions()->delete();
$variant->definitions()->createMany($definitions);
return $variant;
}
/**
* Delete a product variant.
*/
public function deleteVariant(ProductVariant $variant): void
{
$variant->definitions()->delete();
$variant->delete();
}
/**
* Validate variant definitions options against Attribute configuration.
*
* @param array $definitions
* @throws \InvalidArgumentException
*/
protected function validateVariantDefinitions(array $definitions): void
{
foreach ($definitions as $definition) {
$attributeId = $definition['attribute_id'] ?? null;
if (!$attributeId) {
continue;
}
$attribute = Attribute::find($attributeId);
if (!$attribute) {
throw new \InvalidArgumentException("Attribute with ID {$attributeId} not found.");
}
if ($attribute->type === \App\Domains\Shared\Enums\FieldType::Select) {
$allowedValues = $attribute->options()->pluck('value')->toArray();
$val = $definition['value'] ?? null;
if ($val !== null && !in_array($val, $allowedValues, true)) {
throw new \InvalidArgumentException("The value '{$val}' is not a valid option for the select attribute '{$attribute->nombre}'.");
}
} elseif ($attribute->type === \App\Domains\Shared\Enums\FieldType::Multiselect) {
$allowedValues = $attribute->options()->pluck('value')->toArray();
$val = $definition['value'] ?? null;
if ($val !== null) {
$values = [];
if (is_array($val)) {
$values = $val;
} else {
$decoded = json_decode($val, true);
if (is_array($decoded)) {
$values = $decoded;
} else {
$values = array_map('trim', explode(',', $val));
}
}
foreach ($values as $v) {
if (!in_array($v, $allowedValues, true)) {
throw new \InvalidArgumentException("The value '{$v}' is not a valid option for the multiselect attribute '{$attribute->nombre}'.");
}
}
}
}
}
}
/**
* Create an attribute.
*
* @param array<string, mixed> $data
*/
public static function createAttribute(Tenant $tenant, array $data): Attribute
{
$options = $data['options'] ?? [];
unset($data['options']);
$type = \App\Domains\Shared\Enums\FieldType::from((string) $data['type']);
if (! $type->supportsOptions() && ! empty($options)) {
throw new \InvalidArgumentException('Options are only allowed for select and multiselect attributes.');
}
if (! $type->supportsOptions()) {
$data['metadata_schema'] = null;
}
/** @var Attribute $attribute */
$attribute = Attribute::query()->create([
...$data,
'tenant_codigo' => $tenant->codigo,
]);
$attribute->options()->createMany($options);
return $attribute->load('options');
}
/**
* Update an attribute.
*
* @param array<string, mixed> $data
*/
public static function updateAttribute(Attribute $attribute, array $data): Attribute
{
$options = $data['options'] ?? [];
unset($data['options']);
$typeStr = $data['type'] ?? $attribute->type->value;
$type = \App\Domains\Shared\Enums\FieldType::from((string) $typeStr);
if (! $type->supportsOptions() && ! empty($options)) {
throw new \InvalidArgumentException('Options are only allowed for select and multiselect attributes.');
}
if (! $type->supportsOptions()) {
$data['metadata_schema'] = null;
}
$attribute->update($data);
$attribute->options()->delete();
$attribute->options()->createMany($options);
return $attribute->load('options');
}
/**
* Delete an attribute.
*/
public static function deleteAttribute(Attribute $attribute): void
{
$attribute->options()->delete();
$attribute->delete();
}
}

View File

@@ -2,10 +2,12 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
@@ -46,4 +48,17 @@ class ProductVariant extends Model
{
return $this->hasMany(ProductVariantDefinition::class, 'producto_variante_id');
}
/**
* @return BelongsToMany<Attachment, $this>
*/
public function attachments(): BelongsToMany
{
return $this->belongsToMany(
Attachment::class,
'variantes_attachments',
'variante_id',
'attachment_id'
)->withTimestamps();
}
}

View File

@@ -16,7 +16,7 @@ class ProductVariantDefinition extends Model
{
use HasFactory;
protected $table = 'productos_variantes_definiciones';
protected $table = 'productos_variantes_values';
/**
* @return BelongsTo<ProductVariant, $this>
@@ -27,10 +27,10 @@ class ProductVariantDefinition extends Model
}
/**
* @return BelongsTo<ProductAttribute, $this>
* @return BelongsTo<Attribute, $this>
*/
public function attribute(): BelongsTo
{
return $this->belongsTo(ProductAttribute::class, 'attribute_id');
return $this->belongsTo(Attribute::class, 'attribute_id');
}
}

View File

@@ -2,12 +2,12 @@
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Support\ProductAttributeType;
use App\Domains\Shared\Enums\FieldType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class StoreProductAttributeRequest extends FormRequest
class StoreAttributeRequest extends FormRequest
{
public function authorize(): bool
{
@@ -24,25 +24,26 @@ class StoreProductAttributeRequest extends FormRequest
'required',
'string',
'max:255',
Rule::unique('productos_attributes', 'codigo')->where(
Rule::unique('attribute', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'nombre' => ['required', 'string', 'max:255'],
'is_required' => ['sometimes', 'boolean'],
'metadata_schema' => ['nullable', 'array'],
'type' => ['required', Rule::enum(ProductAttributeType::class)],
'type' => ['required', Rule::enum(FieldType::class)],
'options' => [
Rule::requiredIf(fn (): bool => in_array($this->input('type'), [
ProductAttributeType::Select->value,
ProductAttributeType::Multiselect->value,
FieldType::Select->value,
FieldType::Multiselect->value,
], true)),
Rule::prohibitedIf(fn (): bool => ! in_array($this->input('type'), [
ProductAttributeType::Select->value,
ProductAttributeType::Multiselect->value,
FieldType::Select->value,
FieldType::Multiselect->value,
], true)),
'array',
],
'options.*.value' => ['required', 'string', 'max:255'],
'options.*.label' => ['required', 'string', 'max:255'],
'options.*.sort_order' => ['sometimes', 'integer'],
'options.*.metadata' => ['nullable', 'array'],
@@ -53,7 +54,7 @@ class StoreProductAttributeRequest extends FormRequest
{
$validator->after(function (Validator $validator): void {
$type = $this->input('type');
$supportsOptions = in_array($type, [ProductAttributeType::Select->value, ProductAttributeType::Multiselect->value], true);
$supportsOptions = in_array($type, [FieldType::Select->value, FieldType::Multiselect->value], true);
if (! $supportsOptions && $this->filled('metadata_schema')) {
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Catalog\Requests;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -19,10 +20,27 @@ class StoreProductRequest extends FormRequest
{
return [
'categoria_id' => ['required', 'integer'],
'brand_id' => [
'required',
'integer',
Rule::exists('brands', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'slug' => ['required', 'string', 'max:255', Rule::unique('productos', 'slug')],
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'attribute_ids' => ['sometimes', 'array'],
'attribute_ids.*' => [
'required',
'integer',
Rule::exists('attribute', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'images' => ['sometimes', 'nullable', 'array'],
'images.*' => ['required', new ImageOrBase64Rule()],
];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Catalog\Requests;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -42,11 +43,25 @@ class StoreProductVariantRequest extends FormRequest
'required',
'integer',
'distinct',
Rule::exists('productos_attributes', 'id')->where(
Rule::exists('attribute', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
function ($attribute, $value, $fail) {
$productId = $this->input('producto_id');
if ($productId) {
$exists = \Illuminate\Support\Facades\DB::table('products_attributes')
->where('product_id', $productId)
->where('attribute_id', $value)
->exists();
if (!$exists) {
$fail('The selected attribute is not associated with the product.');
}
}
},
],
'definitions.*.value' => ['nullable', 'string'],
'images' => ['sometimes', 'nullable', 'array'],
'images.*' => ['required', new ImageOrBase64Rule()],
];
}
}

View File

@@ -2,13 +2,13 @@
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Models\ProductAttribute;
use App\Domains\Catalog\Support\ProductAttributeType;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Shared\Enums\FieldType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpdateProductAttributeRequest extends FormRequest
class UpdateAttributeRequest extends FormRequest
{
public function authorize(): bool
{
@@ -20,23 +20,24 @@ class UpdateProductAttributeRequest extends FormRequest
*/
public function rules(): array
{
/** @var ProductAttribute|null $attribute */
$attribute = $this->route('productAttribute');
/** @var Attribute|null $attribute */
$attribute = $this->route('attribute');
return [
'codigo' => [
'required',
'string',
'max:255',
Rule::unique('productos_attributes', 'codigo')
Rule::unique('attribute', 'codigo')
->ignore($attribute?->id)
->where(fn ($query) => $query->where('tenant_codigo', $attribute?->tenant_codigo)),
],
'nombre' => ['required', 'string', 'max:255'],
'is_required' => ['sometimes', 'boolean'],
'metadata_schema' => ['nullable', 'array'],
'type' => ['required', Rule::enum(ProductAttributeType::class)],
'type' => ['required', Rule::enum(FieldType::class)],
'options' => ['sometimes', 'array'],
'options.*.value' => ['required', 'string', 'max:255'],
'options.*.label' => ['required', 'string', 'max:255'],
'options.*.sort_order' => ['sometimes', 'integer'],
'options.*.metadata' => ['nullable', 'array'],
@@ -47,7 +48,7 @@ class UpdateProductAttributeRequest extends FormRequest
{
$validator->after(function (Validator $validator): void {
$type = $this->input('type');
$supportsOptions = in_array($type, [ProductAttributeType::Select->value, ProductAttributeType::Multiselect->value], true);
$supportsOptions = in_array($type, [FieldType::Select->value, FieldType::Multiselect->value], true);
if (! $supportsOptions && $this->filled('metadata_schema')) {
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');

View File

@@ -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;
@@ -23,6 +24,13 @@ class UpdateProductRequest extends FormRequest
return [
'categoria_id' => ['required', 'integer'],
'brand_id' => [
'required',
'integer',
Rule::exists('brands', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'slug' => [
'required',
'string',
@@ -32,6 +40,16 @@ class UpdateProductRequest extends FormRequest
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'attribute_ids' => ['sometimes', 'array'],
'attribute_ids.*' => [
'required',
'integer',
Rule::exists('attribute', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'images' => ['sometimes', 'nullable', 'array'],
'images.*' => ['required', new ImageOrBase64Rule()],
];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -46,11 +47,25 @@ class UpdateProductVariantRequest extends FormRequest
'required',
'integer',
'distinct',
Rule::exists('productos_attributes', 'id')->where(
Rule::exists('attribute', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
function ($attribute, $value, $fail) {
$productId = $this->input('producto_id');
if ($productId) {
$exists = \Illuminate\Support\Facades\DB::table('products_attributes')
->where('product_id', $productId)
->where('attribute_id', $value)
->exists();
if (!$exists) {
$fail('The selected attribute is not associated with the product.');
}
}
},
],
'definitions.*.value' => ['nullable', 'string'],
'images' => ['sometimes', 'nullable', 'array'],
'images.*' => ['required', new ImageOrBase64Rule()],
];
}
}

View File

@@ -6,9 +6,9 @@ use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\ProductAttributeOption
* @mixin \App\Domains\Catalog\Models\AttributeOption
*/
class ProductAttributeOptionResource extends JsonResource
class AttributeOptionResource extends JsonResource
{
/**
* @return array<string, mixed>
@@ -17,6 +17,7 @@ class ProductAttributeOptionResource extends JsonResource
{
return [
'id' => $this->id,
'value' => $this->value,
'label' => $this->label,
'sort_order' => $this->sort_order,
'metadata' => $this->metadata,

View File

@@ -6,9 +6,9 @@ use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\ProductAttribute
* @mixin \App\Domains\Catalog\Models\Attribute
*/
class ProductAttributeResource extends JsonResource
class AttributeResource extends JsonResource
{
/**
* @return array<string, mixed>
@@ -23,7 +23,7 @@ class ProductAttributeResource extends JsonResource
'is_required' => $this->is_required,
'metadata_schema' => $this->metadata_schema,
'type' => $this->type?->value ?? $this->type,
'options' => ProductAttributeOptionResource::collection($this->whenLoaded('options')),
'options' => AttributeOptionResource::collection($this->whenLoaded('options')),
];
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Resources\BrandResource;
use App\Domains\Catalog\Resources\CategoryResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -19,10 +21,17 @@ class ProductResource extends JsonResource
'id' => $this->id,
'tenant_codigo' => $this->tenant_codigo,
'categoria_id' => $this->categoria_id,
'brand_id' => $this->brand_id,
'slug' => $this->slug,
'nombre' => $this->nombre,
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'category' => CategoryResource::make($this->whenLoaded('category')),
'brand' => BrandResource::make($this->whenLoaded('brand')),
'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,
];

View File

@@ -20,7 +20,7 @@ class ProductVariantDefinitionResource extends JsonResource
'producto_variante_id' => $this->producto_variante_id,
'attribute_id' => $this->attribute_id,
'value' => $this->value,
'attribute' => ProductAttributeResource::make($this->whenLoaded('attribute')),
'attribute' => AttributeResource::make($this->whenLoaded('attribute')),
];
}
}

View File

@@ -25,6 +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', 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,
];

View File

@@ -2,7 +2,246 @@
namespace App\Domains\Catalog\Services;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
class ProductService
{
// Reserved for product domain business logic.
public function __construct(protected AttachmentService $attachmentService) {}
/**
* Create a product.
*
* @param array<string, mixed> $data
*/
public function create(Tenant $tenant, array $data): Product
{
return DB::transaction(function () use ($tenant, $data) {
$attributeIds = $data['attribute_ids'] ?? [];
$images = $data['images'] ?? [];
unset($data['attribute_ids'], $data['images']);
/** @var Product $product */
$product = Product::query()->create([
...$data,
'tenant_codigo' => $tenant->codigo,
]);
$product->attributes()->sync($attributeIds);
if (! empty($images)) {
$this->syncProductImages($product, $images);
}
return $product->load(['attributes', 'attachments', 'brand', 'category']);
});
}
/**
* Update a product.
*
* @param array<string, mixed> $data
*/
public function update(Product $product, array $data): Product
{
return DB::transaction(function () use ($product, $data) {
$hasAttributeIds = array_key_exists('attribute_ids', $data);
$attributeIds = $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']);
$product->update($data);
if ($hasAttributeIds) {
$product->attributes()->sync($attributeIds);
}
if ($hasImages) {
$this->syncProductImages($product, $images);
}
return $product->load(['attributes', 'attachments', 'brand', 'category']);
});
}
/**
* Delete a product.
*/
public function delete(Product $product): void
{
DB::transaction(function () use ($product) {
foreach ($product->variants as $variant) {
$this->deleteVariantAttachments($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();
});
}
/**
* Create a product variant.
*
* @param array<string, mixed> $data
*/
public function createVariant(Product $product, array $data): ProductVariant
{
return DB::transaction(function () use ($product, $data) {
$images = $data['images'] ?? [];
unset($data['images']);
$variant = $product->createVariant($data);
if (! empty($images)) {
$this->syncVariantImages($variant, $images);
}
return $variant->load(['product', 'definitions.attribute', 'attachments']);
});
}
/**
* Update a product variant.
*
* @param array<string, mixed> $data
*/
public function updateVariant(ProductVariant $variant, array $data): ProductVariant
{
return DB::transaction(function () use ($variant, $data) {
$hasImages = array_key_exists('images', $data);
$images = $data['images'] ?? [];
unset($data['images']);
/** @var Product $product */
$product = $variant->product;
$updatedVariant = $product->updateVariant($variant, $data);
if ($hasImages) {
$this->syncVariantImages($updatedVariant, $images);
}
return $updatedVariant->load(['product', 'definitions.attribute', 'attachments']);
});
}
/**
* Delete a product variant.
*/
public function deleteVariant(ProductVariant $variant): void
{
DB::transaction(function () use ($variant) {
/** @var Product $product */
$product = $variant->product;
$this->deleteVariantAttachments($variant);
$product->deleteVariant($variant);
});
}
/**
* Create an attribute.
*
* @param array<string, mixed> $data
*/
public static function createAttribute(Tenant $tenant, array $data): Attribute
{
return DB::transaction(function () use ($tenant, $data) {
return Product::createAttribute($tenant, $data);
});
}
/**
* Update an attribute.
*
* @param array<string, mixed> $data
*/
public static function updateAttribute(Attribute $attribute, array $data): Attribute
{
return DB::transaction(function () use ($attribute, $data) {
return Product::updateAttribute($attribute, $data);
});
}
/**
* Upload a list of image files/base64 strings and sync them to a variant.
*
* When called on update, the existing attachments are detached first so the
* final set always matches exactly what was sent in the request.
*
* @param array<int, UploadedFile|string> $images
*/
protected function syncVariantImages(ProductVariant $variant, array $images): void
{
$this->deleteVariantAttachments($variant);
$attachmentIds = [];
foreach ($images as $image) {
$attachment = $this->attachmentService->store($image, 'variants');
$attachmentIds[] = $attachment->id;
}
$variant->attachments()->sync($attachmentIds);
}
/**
* Upload a list of image files/base64 strings and sync them to a product.
*
* Same logic as syncVariantImages 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);
}
protected function deleteVariantAttachments(ProductVariant $variant): void
{
$existing = $variant->attachments()->get();
$variant->attachments()->detach();
foreach ($existing as $attachment) {
$this->attachmentService->delete($attachment);
}
}
/**
* Delete an attribute.
*/
public static function deleteAttribute(Attribute $attribute): void
{
DB::transaction(function () use ($attribute) {
Product::deleteAttribute($attribute);
});
}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Domains\Catalog\Support;
enum ProductAttributeType: string
{
case Text = 'text';
case Numeric = 'numeric';
case Select = 'select';
case Multiselect = 'multiselect';
public function supportsOptions(): bool
{
return in_array($this, [self::Select, self::Multiselect], true);
}
}

View File

@@ -3,7 +3,7 @@
use App\Domains\Catalog\Controllers\BrandController;
use App\Domains\Catalog\Controllers\CategoryController;
use App\Domains\Catalog\Controllers\ProductController;
use App\Domains\Catalog\Controllers\ProductAttributeController;
use App\Domains\Catalog\Controllers\AttributeController;
use App\Domains\Catalog\Controllers\ProductVariantController;
use Illuminate\Support\Facades\Route;
@@ -11,8 +11,7 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
Route::apiResource('marcas', BrandController::class)->parameters(['marcas' => 'marca']);
Route::apiResource('categorias', CategoryController::class)->parameters(['categorias' => 'categoria']);
Route::apiResource('productos', ProductController::class);
Route::apiResource('product-attributes', ProductAttributeController::class)
->parameters(['product-attributes' => 'productAttribute']);
Route::apiResource('attributes', AttributeController::class);
Route::apiResource('product-variants', ProductVariantController::class)
->parameters(['product-variants' => 'productVariant']);
});

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Domains\Purchase\Controllers;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Requests\StorePurchaseRequest;
use App\Domains\Purchase\Resources\PurchaseResource;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class PurchaseController extends Controller
{
public function index(Request $request, Tenant $tenant): JsonResponse
{
return PurchaseResource::collection(
Purchase::query()
->with(['items.variant.product', 'items.variant.definitions'])
->where('tenant_codigo', $tenant->codigo)
->where('user_id', $request->user()->id)
->latest()
->paginateFromRequest()
)->response();
}
public function store(StorePurchaseRequest $request, Tenant $tenant): JsonResponse
{
$data = $request->validated();
$items = $data['items'];
unset($data['items']);
$variants = $this->resolveTenantVariants($tenant, $items);
$purchaseItems = $this->buildPurchaseItemsPayload($items, $variants);
$purchase = DB::transaction(function () use ($request, $tenant, $data, $purchaseItems): Purchase {
/** @var Purchase $purchase */
$purchase = Purchase::query()->create([
...$data,
'tenant_codigo' => $tenant->codigo,
'user_id' => $request->user()->id,
]);
$purchase->items()->createMany($purchaseItems);
return $purchase->load(['items.variant.product', 'items.variant.definitions']);
});
return PurchaseResource::make($purchase)->response()->setStatusCode(201);
}
public function show(Request $request, Tenant $tenant, Purchase $compra): PurchaseResource
{
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$compra->loadMissing(['items.variant.product', 'items.variant.definitions'])
);
}
/**
* @param array<int, array<string, mixed>> $items
* @return \Illuminate\Support\Collection<int, ProductVariant>
*/
protected function resolveTenantVariants(Tenant $tenant, array $items)
{
$variantIds = collect($items)
->pluck('producto_variante_id')
->filter()
->map(static fn (mixed $id): int => (int) $id)
->unique()
->values();
$variants = ProductVariant::query()
->with('product')
->whereIn('id', $variantIds)
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $tenant->codigo))
->get()
->keyBy('id');
if ($variants->count() !== $variantIds->count()) {
throw ValidationException::withMessages([
'items' => 'One or more product variants do not belong to the tenant.',
]);
}
return $variants;
}
/**
* @param array<int, array<string, mixed>> $items
* @param \Illuminate\Support\Collection<int, ProductVariant> $variants
* @return array<int, array<string, mixed>>
*/
protected function buildPurchaseItemsPayload(array $items, $variants): array
{
return collect($items)
->map(function (array $item) use ($variants): array {
/** @var ProductVariant $variant */
$variant = $variants->get((int) $item['producto_variante_id']);
$quantity = (int) $item['cantidad'];
$unitPrice = (float) $variant->precio;
return [
'producto_variante_id' => $variant->getKey(),
'cantidad' => $quantity,
'precio_unitario' => $unitPrice,
'discount_total' => null,
'tax_total' => null,
'total' => $unitPrice * $quantity,
];
})
->all();
}
protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase
{
if ($purchase->tenant_codigo !== $tenant->codigo || $purchase->user_id !== $userId) {
throw new NotFoundHttpException('Purchase not found for tenant.');
}
return $purchase;
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Domains\Purchase\Models;
use App\Domains\Tenant\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_codigo',
'user_id',
'status',
'payment_status',
'payment_method',
])]
class Purchase extends Model
{
use HasFactory;
protected $table = 'compras';
protected function casts(): array
{
return [
'user_id' => 'integer',
];
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return HasMany<PurchaseItem, $this>
*/
public function items(): HasMany
{
return $this->hasMany(PurchaseItem::class, 'compra_id');
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Domains\Purchase\Models;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'compra_id',
'producto_variante_id',
'cantidad',
'precio_unitario',
'discount_total',
'tax_total',
'total',
])]
class PurchaseItem extends Model
{
use HasFactory;
protected $table = 'compra_items';
protected function casts(): array
{
return [
'compra_id' => 'integer',
'producto_variante_id' => 'integer',
'cantidad' => 'integer',
'precio_unitario' => 'decimal:2',
'discount_total' => 'decimal:2',
'tax_total' => 'decimal:2',
'total' => 'decimal:2',
];
}
/**
* @return BelongsTo<Purchase, $this>
*/
public function purchase(): BelongsTo
{
return $this->belongsTo(Purchase::class, 'compra_id');
}
/**
* @return BelongsTo<ProductVariant, $this>
*/
public function variant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Purchase\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePurchaseRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'status' => ['sometimes', 'string', Rule::in(['pending', 'paid', 'cancelled'])],
'payment_status' => ['sometimes', 'string', Rule::in(['pending', 'approved', 'rejected'])],
'payment_method' => ['nullable', 'string', 'max:255'],
'items' => ['required', 'array', 'min:1'],
'items.*.producto_variante_id' => ['required', 'integer', 'exists:productos_variantes,id'],
'items.*.cantidad' => ['required', 'integer', 'min:1'],
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Domains\Purchase\Resources;
use App\Domains\Catalog\Resources\ProductVariantDefinitionResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Purchase\Models\PurchaseItem
*/
class PurchaseItemResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$variant = $this->variant;
$product = $variant?->product;
return [
'id' => $this->id,
'cantidad' => $this->cantidad,
'precio_unitario' => $this->formatMoney($this->precio_unitario),
'total' => $this->formatMoney($this->total),
'product' => $product === null ? null : [
'id' => $product->id,
'nombre' => $product->nombre,
'slug' => $product->slug,
],
'variant' => $variant === null ? null : [
'id' => $variant->id,
'nombre' => $variant->nombre,
'slug' => $variant->slug,
'definitions' => ProductVariantDefinitionResource::collection($variant->definitions),
],
];
}
protected function formatMoney(float|int|string|null $amount): ?string
{
if ($amount === null) {
return null;
}
return number_format((float) $amount, 2, '.', '');
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Domains\Purchase\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Purchase\Models\Purchase
*/
class PurchaseResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$items = $this->resource->relationLoaded('items')
? $this->resource->getRelation('items')
: collect();
$subtotal = $items->reduce(
fn (float $carry, $item): float => $carry + ((float) $item->precio_unitario * $item->cantidad),
0.0,
);
$total = $items->reduce(
fn (float $carry, $item): float => $carry + (float) $item->total,
0.0,
);
return [
'id' => $this->id,
'tenant_codigo' => $this->tenant_codigo,
'user_id' => $this->user_id,
'status' => $this->status,
'payment_status' => $this->payment_status,
'payment_method' => $this->payment_method,
'items' => PurchaseItemResource::collection($items),
'subtotal' => $this->formatMoney($subtotal),
'total' => $this->formatMoney($total),
];
}
protected function formatMoney(float|int|string|null $amount): string
{
return number_format((float) ($amount ?? 0), 2, '.', '');
}
}

View File

@@ -0,0 +1,8 @@
<?php
use App\Domains\Purchase\Controllers\PurchaseController;
use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(function (): void {
Route::apiResource('compras', PurchaseController::class)->only(['index', 'store', 'show']);
});

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Shared\Enums;
enum FieldType: string
{
case String = 'string';
case Number = 'number';
case Boolean = 'boolean';
case Select = 'select';
case Multiselect = 'multiselect';
case Color = 'color';
case Image = 'image';
public function supportsOptions(): bool
{
return in_array($this, [self::Select, self::Multiselect], true);
}
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Domains\Shared\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Http\UploadedFile;
class ImageOrBase64Rule implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value instanceof UploadedFile) {
$mime = $value->getMimeType();
if (! str_starts_with((string) $mime, 'image/')) {
$fail("The :attribute must be a valid image file.");
}
return;
}
if (is_string($value)) {
$payload = trim($value);
if ($payload === '') {
$fail("The :attribute must not be empty.");
return;
}
$declaredMimeType = null;
if (preg_match('/^data:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/s', $payload, $matches) === 1) {
$declaredMimeType = strtolower($matches['mime']);
$payload = $matches['data'];
}
$decoded = base64_decode(preg_replace('/\s+/', '', $payload), true);
if ($decoded === false || $decoded === '') {
$fail("The :attribute must be a valid base64-encoded image.");
return;
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$detectedMime = $finfo->buffer($decoded);
$mime = $detectedMime ?: $declaredMimeType;
if (! $mime || ! str_starts_with((string) $mime, 'image/')) {
$fail("The :attribute must be a valid image (file or base64).");
}
return;
}
$fail("The :attribute must be an image file or a base64-encoded image string.");
}
}

View File

@@ -15,7 +15,7 @@ class BootstrapTenantController extends Controller
$dominio = $request->validated('dominio');
return TenantResource::make(
Tenant::query()->where('dominio', $dominio)->firstOrFail()
Tenant::query()->with(['headerLogo', 'footerLogo'])->where('dominio', $dominio)->firstOrFail()
);
}
}

View File

@@ -6,40 +6,39 @@ use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Requests\StoreTenantRequest;
use App\Domains\Tenant\Requests\UpdateTenantRequest;
use App\Domains\Tenant\Resources\TenantResource;
use App\Domains\Tenant\Services\TenantService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class TenantController extends Controller
{
public function __construct(protected TenantService $tenantService)
{
}
public function index(): JsonResponse
{
return TenantResource::collection(Tenant::query()->latest()->get())->response();
return TenantResource::collection(Tenant::query()->with(['headerLogo', 'footerLogo'])->latest()->paginateFromRequest())->response();
}
public function store(StoreTenantRequest $request): JsonResponse
{
$validated = $request->validated();
$props = $validated['props'] ?? [];
$tenant = $this->tenantService->create($request->validated());
$tenant = Tenant::createWithProps($validated, $props);
return TenantResource::make($tenant)->response()->setStatusCode(201);
return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']))->response()->setStatusCode(201);
}
public function show(Tenant $tenant): TenantResource
{
return TenantResource::make($tenant);
return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']));
}
public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource
{
$validated = $request->validated();
$props = $validated['props'] ?? [];
$tenant = $this->tenantService->update($tenant, $request->validated());
$tenant->updateWithProps($validated, $props);
return TenantResource::make($tenant);
return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']));
}
public function destroy(Tenant $tenant): Response

View File

@@ -1,45 +0,0 @@
<?php
namespace App\Domains\Tenant\Controllers;
use App\Domains\Tenant\Models\TenantProp;
use App\Domains\Tenant\Requests\StoreTenantPropRequest;
use App\Domains\Tenant\Requests\UpdateTenantPropRequest;
use App\Domains\Tenant\Resources\TenantPropResource;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class TenantPropController extends Controller
{
public function index(): JsonResponse
{
return TenantPropResource::collection(TenantProp::query()->latest()->get())->response();
}
public function store(StoreTenantPropRequest $request): JsonResponse
{
$tenantProp = TenantProp::query()->create($request->validated());
return TenantPropResource::make($tenantProp)->response()->setStatusCode(201);
}
public function show(TenantProp $tenantProp): TenantPropResource
{
return TenantPropResource::make($tenantProp);
}
public function update(UpdateTenantPropRequest $request, TenantProp $tenantProp): TenantPropResource
{
$tenantProp->update($request->validated());
return TenantPropResource::make($tenantProp);
}
public function destroy(TenantProp $tenantProp): Response
{
$tenantProp->delete();
return response()->noContent();
}
}

View File

@@ -2,44 +2,48 @@
namespace App\Domains\Tenant\Models;
use App\Domains\Attachable\Models\Concerns\HasAttachments;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\Product;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
use LogicException;
#[Fillable([
'codigo',
'nombre',
'dominio',
'primary_color',
'secondary_color',
'danger_color',
'header_footer_bg_color',
'header_logo_id',
'footer_logo_id',
])]
class Tenant extends Model
{
use HasAttachments;
use HasFactory;
/**
* @param array<string, mixed> $attributes
* @param array<string, mixed> $props
* @return static
*/
public static function createWithProps(array $attributes, array $props = []): static
public function getRouteKeyName(): string
{
unset($attributes['props']);
return 'codigo';
}
/** @var static $tenant */
$tenant = DB::transaction(function () use ($attributes, $props): self {
/** @var static $createdTenant */
$createdTenant = static::query()->create($attributes);
$createdTenant->syncPropValues($props);
/**
* @return BelongsTo<Attachment, $this>
*/
public function headerLogo(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'header_logo_id');
}
return $createdTenant;
});
return $tenant;
/**
* @return BelongsTo<Attachment, $this>
*/
public function footerLogo(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'footer_logo_id');
}
/**
@@ -49,77 +53,5 @@ class Tenant extends Model
{
return $this->hasMany(Product::class, 'tenant_codigo', 'codigo');
}
/**
* @return HasMany<TenantPropValue, $this>
*/
public function propValues(): HasMany
{
return $this->hasMany(TenantPropValue::class, 'tenant_codigo', 'codigo');
}
public function getPropValue(TenantProp|string $prop): ?TenantPropValue
{
$resolvedProp = $this->resolveProp($prop);
return $this->propValues()
->where('tenant_prop_codigo', $resolvedProp->codigo)
->first();
}
public function setPropValue(TenantProp|string $prop, mixed $value): TenantPropValue
{
$this->ensurePropValuesCanBeManaged();
$resolvedProp = $this->resolveProp($prop);
return $this->propValues()->updateOrCreate(
['tenant_prop_codigo' => $resolvedProp->codigo],
['value' => $value],
);
}
/**
* @param array<string, mixed> $props
*/
public function syncPropValues(array $props): void
{
foreach ($props as $codigo => $value) {
$this->setPropValue((string) $codigo, $value);
}
}
/**
* @param array<string, mixed> $attributes
* @param array<string, mixed> $props
* @return static
*/
public function updateWithProps(array $attributes, array $props = []): static
{
unset($attributes['props']);
DB::transaction(function () use ($attributes, $props): void {
$this->update($attributes);
$this->syncPropValues($props);
});
return $this;
}
protected function ensurePropValuesCanBeManaged(): void
{
if (! $this->exists) {
throw new LogicException('Cannot manage prop values for an unsaved tenant.');
}
}
protected function resolveProp(TenantProp|string $prop): TenantProp
{
if ($prop instanceof TenantProp) {
return $prop;
}
return TenantProp::query()
->where('codigo', $prop)
->firstOrFail();
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Domains\Tenant\Models;
use App\Domains\Tenant\Support\TenantPropDataType;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'codigo',
'nombre',
'descripcion',
'is_required',
'data_type',
])]
class TenantProp extends Model
{
use HasFactory;
protected $table = 'tenant_props';
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_required' => 'boolean',
'data_type' => TenantPropDataType::class,
];
}
/**
* @return HasMany<TenantPropValue, $this>
*/
public function values(): HasMany
{
return $this->hasMany(TenantPropValue::class, 'tenant_prop_codigo', 'codigo');
}
}

View File

@@ -1,36 +0,0 @@
<?php
namespace App\Domains\Tenant\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'tenant_codigo',
'tenant_prop_codigo',
'value',
])]
class TenantPropValue extends Model
{
use HasFactory;
protected $table = 'tenant_prop_values';
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return BelongsTo<TenantProp, $this>
*/
public function prop(): BelongsTo
{
return $this->belongsTo(TenantProp::class, 'tenant_prop_codigo', 'codigo');
}
}

View File

@@ -1,29 +0,0 @@
<?php
namespace App\Domains\Tenant\Requests;
use App\Domains\Tenant\Support\TenantPropDataType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreTenantPropRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenant_props', 'codigo')],
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
'is_required' => ['sometimes', 'boolean'],
'data_type' => ['required', Rule::enum(TenantPropDataType::class)],
];
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Domains\Tenant\Requests;
use App\Domains\Tenant\Support\TenantPropRules;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
@@ -35,6 +35,9 @@ class StoreTenantRequest extends FormRequest
*/
public function rules(): array
{
$logoRule = ['required', new ImageOrBase64Rule()];
return [
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
'nombre' => ['required', 'string', 'max:255'],
@@ -45,12 +48,17 @@ class StoreTenantRequest extends FormRequest
$fail("The {$attribute} field must contain a valid domain or URL.");
}
},
'nullable',
'required',
'string',
'max:255',
Rule::unique('tenants', 'dominio'),
],
...TenantPropRules::sync(),
'primary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'secondary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'danger_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_footer_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_logo' => $logoRule,
'footer_logo' => $logoRule,
];
}
}

View File

@@ -1,33 +0,0 @@
<?php
namespace App\Domains\Tenant\Requests;
use App\Domains\Tenant\Models\TenantProp;
use App\Domains\Tenant\Support\TenantPropDataType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateTenantPropRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var TenantProp|null $tenantProp */
$tenantProp = $this->route('tenantProp');
return [
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenant_props', 'codigo')->ignore($tenantProp?->id)],
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
'is_required' => ['sometimes', 'boolean'],
'data_type' => ['required', Rule::enum(TenantPropDataType::class)],
];
}
}

View File

@@ -2,9 +2,9 @@
namespace App\Domains\Tenant\Requests;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
use App\Domains\Tenant\Support\TenantPropRules;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -20,15 +20,17 @@ class UpdateTenantRequest extends FormRequest
protected function prepareForValidation(): void
{
$rawDomain = $this->input('dominio');
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
if ($this->has('dominio')) {
$rawDomain = $this->input('dominio');
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
&& $normalizedDomain === null;
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
&& $normalizedDomain === null;
$this->merge([
'dominio' => $normalizedDomain,
]);
$this->merge([
'dominio' => $normalizedDomain,
]);
}
}
/**
@@ -39,14 +41,17 @@ class UpdateTenantRequest extends FormRequest
/** @var Tenant|null $tenant */
$tenant = $this->route('tenant');
$logoRule = ['nullable', new ImageOrBase64Rule()];
return [
'codigo' => [
'required',
'nullable',
'string',
'max:255',
Rule::unique('tenants', 'codigo')->ignore($tenant?->id),
],
'nombre' => ['required', 'string', 'max:255'],
'nombre' => ['nullable', 'string', 'max:255'],
'dominio' => [
'bail',
function (string $attribute, mixed $value, Closure $fail): void {
@@ -59,7 +64,12 @@ class UpdateTenantRequest extends FormRequest
'max:255',
Rule::unique('tenants', 'dominio')->ignore($tenant?->id),
],
...TenantPropRules::sync(),
'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'secondary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'danger_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_logo' => $logoRule,
'footer_logo' => $logoRule,
];
}
}

View File

@@ -1,29 +0,0 @@
<?php
namespace App\Domains\Tenant\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Tenant\Models\TenantProp
*/
class TenantPropResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'codigo' => $this->codigo,
'nombre' => $this->nombre,
'descripcion' => $this->descripcion,
'is_required' => $this->is_required,
'data_type' => $this->data_type?->value ?? $this->data_type,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@@ -10,29 +10,6 @@ use Illuminate\Http\Resources\Json\JsonResource;
*/
class TenantResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
protected function collapseProps(): array
{
return $this->resource->propValues()
->with('prop')
->get()
->mapWithKeys(fn ($propValue): array => [
$propValue->tenant_prop_codigo => $this->normalizePropValue($propValue->value, $propValue->prop?->data_type?->value),
])
->all();
}
protected function normalizePropValue(mixed $value, ?string $dataType): mixed
{
return match ($dataType) {
'boolean' => filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? $value,
'number' => is_numeric($value) ? $value + 0 : $value,
default => $value,
};
}
/**
* @return array<string, mixed>
*/
@@ -43,7 +20,13 @@ class TenantResource extends JsonResource
'codigo' => $this->codigo,
'nombre' => $this->nombre,
'dominio' => $this->dominio,
'props' => $this->collapseProps(),
'primary_color' => $this->primary_color,
'secondary_color' => $this->secondary_color,
'danger_color' => $this->danger_color,
'header_footer_bg_color' => $this->header_footer_bg_color,
// 1 day
'header_logo' => $this->headerLogo?->getTemporaryUrl(1440),
'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440 ),
];
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Domains\Tenant\Services;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class TenantService
{
public function __construct(protected AttachmentService $attachmentService)
{
}
/**
* Create a new tenant and store its logos.
*
* @param array<string, mixed> $data
* @return Tenant
*/
public function create(array $data): Tenant
{
return DB::transaction(function () use ($data): Tenant {
$headerLogo = $data['header_logo'] ?? null;
$footerLogo = $data['footer_logo'] ?? null;
unset($data['header_logo'], $data['footer_logo']);
$headerAttachmentId = null;
if ($headerLogo) {
$attachment = Str::isUuid($headerLogo)
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $headerLogo)->first()
: $this->attachmentService->store($headerLogo, 'tenants');
if ($attachment) {
$headerAttachmentId = $attachment->id;
}
}
$footerAttachmentId = null;
if ($footerLogo) {
$attachment = Str::isUuid($footerLogo)
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $footerLogo)->first()
: $this->attachmentService->store($footerLogo, 'tenants');
if ($attachment) {
$footerAttachmentId = $attachment->id;
}
}
$data['header_logo_id'] = $headerAttachmentId;
$data['footer_logo_id'] = $footerAttachmentId;
/** @var Tenant $tenant */
$tenant = Tenant::query()->create($data);
return $tenant;
});
}
/**
* Update an existing tenant and store new logos if uploaded.
*
* @param Tenant $tenant
* @param array<string, mixed> $data
* @return Tenant
*/
public function update(Tenant $tenant, array $data): Tenant
{
return DB::transaction(function () use ($tenant, $data): Tenant {
$hasHeaderLogoKey = array_key_exists('header_logo', $data);
$hasFooterLogoKey = array_key_exists('footer_logo', $data);
$headerLogo = $data['header_logo'] ?? null;
$footerLogo = $data['footer_logo'] ?? null;
unset($data['header_logo'], $data['footer_logo']);
$tenant->fill($data);
if ($hasHeaderLogoKey) {
if ($headerLogo) {
$attachment = Str::isUuid($headerLogo)
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $headerLogo)->first()
: $this->attachmentService->store($headerLogo, 'tenants');
if ($attachment) {
$tenant->header_logo_id = $attachment->id;
} else {
$tenant->header_logo_id = null;
}
} else {
$tenant->header_logo_id = null;
}
}
if ($hasFooterLogoKey) {
if ($footerLogo) {
$attachment = Str::isUuid($footerLogo)
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $footerLogo)->first()
: $this->attachmentService->store($footerLogo, 'tenants');
if ($attachment) {
$tenant->footer_logo_id = $attachment->id;
} else {
$tenant->footer_logo_id = null;
}
} else {
$tenant->footer_logo_id = null;
}
}
$tenant->save();
return $tenant;
});
}
}

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Domains\Tenant\Support;
enum TenantPropDataType: string
{
case String = 'string';
case Number = 'number';
case Boolean = 'boolean';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -1,51 +0,0 @@
<?php
namespace App\Domains\Tenant\Support;
use App\Domains\Tenant\Models\TenantProp;
use Closure;
class TenantPropRules
{
/**
* @return array<string, mixed>
*/
public static function sync(): array
{
return [
'props' => [
'sometimes',
'array',
static function (string $attribute, mixed $value, Closure $fail): void {
static::validateExistingProps($attribute, $value, $fail);
},
],
'props.*' => ['nullable'],
];
}
protected static function validateExistingProps(string $attribute, mixed $value, Closure $fail): void
{
if (! is_array($value) || $value === []) {
return;
}
$propCodes = array_map('strval', array_keys($value));
$existingPropCodes = TenantProp::query()
->whereIn('codigo', $propCodes)
->pluck('codigo')
->all();
$missingPropCodes = array_values(array_diff($propCodes, $existingPropCodes));
if ($missingPropCodes === []) {
return;
}
$fail(sprintf(
'The selected %s are invalid for Tenant: %s.',
$attribute,
implode(', ', $missingPropCodes),
));
}
}

View File

@@ -2,12 +2,9 @@
use App\Domains\Tenant\Controllers\BootstrapTenantController;
use App\Domains\Tenant\Controllers\TenantController;
use App\Domains\Tenant\Controllers\TenantPropController;
use Illuminate\Support\Facades\Route;
Route::get('tenants/bootstrap/{dominio}', BootstrapTenantController::class)
->where('dominio', '.*');
Route::apiResource('tenants', TenantController::class);
Route::apiResource('tenant-props', TenantPropController::class)
->parameters(['tenant-props' => 'tenantProp']);

View File

@@ -19,6 +19,22 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
\Illuminate\Database\Eloquent\Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
/** @var \Illuminate\Database\Eloquent\Builder $this */
$perPage = (int) request()->query('per_page', $defaultPerPage);
$page = $page ?? (int) request()->query('page', 1);
if ($perPage <= 0) {
$perPage = $defaultPerPage;
}
if ($perPage > $maxPerPage) {
$perPage = $maxPerPage;
}
if ($page <= 0) {
$page = 1;
}
return $this->paginate(perPage: $perPage, page: $page);
});
}
}

View File

@@ -15,7 +15,13 @@ return new class extends Migration
$table->id();
$table->string('codigo')->unique();
$table->string('nombre');
$table->string('dominio')->nullable();
$table->string('dominio');
$table->string('primary_color');
$table->string('secondary_color');
$table->string('danger_color');
$table->string('header_footer_bg_color');
$table->unsignedBigInteger('header_logo_id');
$table->unsignedBigInteger('footer_logo_id');
$table->timestamps();
});
}

View File

@@ -1,32 +0,0 @@
<?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('tenant_props', function (Blueprint $table) {
$table->id();
$table->string('codigo')->unique();
$table->string('nombre');
$table->text('descripcion')->nullable();
$table->boolean('is_required')->default(false);
$table->string('data_type');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tenant_props');
}
};

View File

@@ -14,6 +14,7 @@ return new class extends Migration
Schema::create('attribute_options', function (Blueprint $table) {
$table->id();
$table->foreignId('attribute_id')->constrained('productos_attributes')->cascadeOnUpdate()->cascadeOnDelete();
$table->string('value');
$table->string('label');
$table->unsignedInteger('sort_order')->default(0);
$table->json('metadata')->nullable();

View File

@@ -11,26 +11,20 @@ return new class extends Migration
*/
public function up(): void
{
Schema::create('tenant_prop_values', function (Blueprint $table) {
Schema::create('compras', function (Blueprint $table) {
$table->id();
$table->string('tenant_codigo');
$table->string('tenant_prop_codigo');
$table->text('value')->nullable();
$table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnUpdate()->nullOnDelete();
$table->enum('status', ['pending', 'paid', 'cancelled'])->default('pending');
$table->enum('payment_status', ['pending', 'approved', 'rejected'])->default('pending');
$table->string('payment_method')->nullable();
$table->timestamps();
$table->foreign('tenant_codigo')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->foreign('tenant_prop_codigo')
->references('codigo')
->on('tenant_props')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->unique(['tenant_codigo', 'tenant_prop_codigo']);
->restrictOnDelete();
});
}
@@ -39,6 +33,6 @@ return new class extends Migration
*/
public function down(): void
{
Schema::dropIfExists('tenant_prop_values');
Schema::dropIfExists('compras');
}
};

View File

@@ -0,0 +1,40 @@
<?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('compra_items', function (Blueprint $table) {
$table->id();
$table->foreignId('compra_id')->constrained('compras')->cascadeOnUpdate()->cascadeOnDelete();
$table->unsignedBigInteger('producto_variante_id');
$table->unsignedInteger('cantidad');
$table->decimal('precio_unitario', 10, 2);
$table->decimal('discount_total', 10, 2)->nullable();
$table->decimal('tax_total', 10, 2)->nullable();
$table->decimal('total', 10, 2);
$table->timestamps();
$table->foreign('producto_variante_id')
->references('id')
->on('productos_variantes')
->cascadeOnUpdate()
->restrictOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('compra_items');
}
};

View File

@@ -13,8 +13,6 @@ return new class extends Migration
{
Schema::create('attachments', function (Blueprint $table) {
$table->id();
$table->string('attachable_type');
$table->unsignedBigInteger('attachable_id');
$table->uuid('key')->unique();
$table->string('path');
$table->string('filename');
@@ -24,10 +22,21 @@ return new class extends Migration
$table->unsignedBigInteger('size')->default(0);
$table->timestamps();
$table->index(['attachable_type', 'attachable_id']);
$table->index('type');
$table->index('mime_type');
});
Schema::table('tenants', function (Blueprint $table) {
$table->foreign('header_logo_id')
->references('id')
->on('attachments')
->cascadeOnDelete();
$table->foreign('footer_logo_id')
->references('id')
->on('attachments')
->cascadeOnDelete();
});
}
/**
@@ -35,6 +44,11 @@ return new class extends Migration
*/
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropForeign(['header_logo_id']);
$table->dropForeign(['footer_logo_id']);
});
Schema::dropIfExists('attachments');
}
};

View File

@@ -1,43 +0,0 @@
<?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::table('attachments', function (Blueprint $table): void {
$table->dropIndex(['attachable_type', 'attachable_id']);
$table->dropColumn(['attachable_type', 'attachable_id']);
});
Schema::create('attachable_attachments', function (Blueprint $table): void {
$table->id();
$table->string('attachable_type');
$table->unsignedBigInteger('attachable_id');
$table->foreignId('attachment_id')->constrained('attachments')->cascadeOnDelete();
$table->unique(['attachable_type', 'attachable_id', 'attachment_id'], 'attachable_attachments_unique');
$table->index(['attachable_type', 'attachable_id'], 'attachable_attachments_attachable_index');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('attachable_attachments');
Schema::table('attachments', function (Blueprint $table): void {
$table->string('attachable_type');
$table->unsignedBigInteger('attachable_id');
$table->index(['attachable_type', 'attachable_id']);
});
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('productos_attributes')
->where('type', 'text')
->update(['type' => 'string']);
DB::table('productos_attributes')
->where('type', 'numeric')
->update(['type' => 'number']);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::table('productos_attributes')
->where('type', 'string')
->update(['type' => 'text']);
DB::table('productos_attributes')
->where('type', 'number')
->update(['type' => 'numeric']);
}
};

View File

@@ -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('variantes_attachments', function (Blueprint $table) {
$table->id();
$table->foreignId('variante_id')
->constrained('productos_variantes')
->cascadeOnDelete();
$table->foreignId('attachment_id')
->constrained('attachments')
->cascadeOnDelete();
$table->timestamps();
$table->unique(['variante_id', 'attachment_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('variantes_attachments');
}
};

View File

@@ -0,0 +1,50 @@
<?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
{
// 1. Rename table productos_attributes to attribute
Schema::rename('productos_attributes', 'attribute');
// 2. Rename table productos_variantes_definiciones to productos_variantes_values
Schema::rename('productos_variantes_definiciones', 'productos_variantes_values');
// 3. Create products_attributes intermediate table
Schema::create('products_attributes', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('product_id');
$table->unsignedBigInteger('attribute_id');
$table->timestamps();
$table->foreign('product_id')
->references('id')
->on('productos')
->cascadeOnDelete();
$table->foreign('attribute_id')
->references('id')
->on('attribute')
->cascadeOnDelete();
$table->unique(['product_id', 'attribute_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('products_attributes');
Schema::rename('productos_variantes_values', 'productos_variantes_definiciones');
Schema::rename('attribute', 'productos_attributes');
}
};

View File

@@ -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');
}
};

View File

@@ -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
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('productos', function (Blueprint $table) {
$table->foreignId('brand_id')
->nullable()
->after('categoria_id')
->constrained('brands')
->cascadeOnUpdate()
->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('productos', function (Blueprint $table) {
$table->dropConstrainedForeignId('brand_id');
});
}
};

View File

@@ -0,0 +1,114 @@
<?php
namespace Database\Seeders;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\Product;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Seeder;
class AttributeSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$tenants = Tenant::all();
foreach ($tenants as $tenant) {
// Seed Talle (Size - Text options) attribute
$existingTalle = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->where('codigo', 'talle')
->first();
if ($existingTalle) {
Product::deleteAttribute($existingTalle);
}
Product::createAttribute($tenant, [
'codigo' => 'talle',
'nombre' => 'Talle',
'type' => FieldType::Select->value,
'is_required' => true,
'options' => [
['value' => 'S', 'label' => 'S', 'sort_order' => 1],
['value' => 'M', 'label' => 'M', 'sort_order' => 2],
['value' => 'L', 'label' => 'L', 'sort_order' => 3],
['value' => 'XL', 'label' => 'XL', 'sort_order' => 4],
],
]);
// Seed Talle Numérico (Numeric Size options) attribute
$existingTalleNumerico = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->where('codigo', 'talle_numerico')
->first();
if ($existingTalleNumerico) {
Product::deleteAttribute($existingTalleNumerico);
}
Product::createAttribute($tenant, [
'codigo' => 'talle_numerico',
'nombre' => 'Talle Numérico',
'type' => FieldType::Select->value,
'is_required' => true,
'options' => [
['value' => '38', 'label' => '38', 'sort_order' => 1],
['value' => '40', 'label' => '40', 'sort_order' => 2],
['value' => '42', 'label' => '42', 'sort_order' => 3],
['value' => '44', 'label' => '44', 'sort_order' => 4],
],
]);
// Seed Color attribute
$existingColor = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->where('codigo', 'color')
->first();
if ($existingColor) {
Product::deleteAttribute($existingColor);
}
Product::createAttribute($tenant, [
'codigo' => 'color',
'nombre' => 'Color',
'type' => FieldType::Select->value,
'is_required' => true,
'metadata_schema' => [
'hex' => ['type' => 'string'],
],
'options' => [
[
'value' => 'Negro',
'label' => 'Negro',
'sort_order' => 1,
'metadata' => ['hex' => '#000000'],
],
[
'value' => 'Gris',
'label' => 'Gris',
'sort_order' => 2,
'metadata' => ['hex' => '#808080'],
],
[
'value' => 'Blanco',
'label' => 'Blanco',
'sort_order' => 3,
'metadata' => ['hex' => '#FFFFFF'],
],
[
'value' => 'Azul',
'label' => 'Azul',
'sort_order' => 4,
'metadata' => ['hex' => '#0000FF'],
],
],
]);
}
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Database\Seeders;
use App\Domains\Catalog\Models\Brand;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Seeder;
class BrandSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$brands = [
'Le Coq',
'Adidas',
'Under Armour',
'Rosario Central',
'Topper',
'Nike',
];
$tenants = Tenant::all();
if ($tenants->isEmpty()) {
return;
}
foreach ($tenants as $tenant) {
foreach ($brands as $nombre) {
Brand::firstOrCreate([
'tenant_codigo' => $tenant->codigo,
'nombre' => $nombre,
]);
}
}
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Database\Seeders;
use App\Domains\Catalog\Models\Category;
use Illuminate\Database\Seeder;
class CategorySeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$categories = [
'Remeras',
'Pantalones',
'Zapatillas',
'Buzos'
];
foreach ($categories as $nombre) {
Category::firstOrCreate([
'nombre' => $nombre,
'tenant_code' => null,
]);
}
}
}

View File

@@ -21,5 +21,13 @@ class DatabaseSeeder extends Seeder
'name' => 'Test User',
'email' => 'test@example.com',
]);
$this->call([
TenantSeeder::class,
AttributeSeeder::class,
CategorySeeder::class,
BrandSeeder::class,
ProductCatalogFromImagesSeeder::class,
]);
}
}

View File

@@ -0,0 +1,354 @@
<?php
namespace Database\Seeders;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\Brand;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Services\ProductService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Seeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use RuntimeException;
class ProductCatalogFromImagesSeeder extends Seeder
{
/**
* @var array<string, array{price: int, stock: int}>
*/
private const PRODUCT_CATALOG = [
'buzo_blanco_adidas' => ['price' => 89999, 'stock' => 12],
'buzo_gris_nike' => ['price' => 94999, 'stock' => 10],
'buzo_gris_under_armour' => ['price' => 96999, 'stock' => 8],
'buzo_negro_adidas' => ['price' => 91999, 'stock' => 9],
'camiseta_rosario_central' => ['price' => 69999, 'stock' => 20],
'pantalon_blanco' => ['price' => 55999, 'stock' => 14],
'pantalon_negro' => ['price' => 57999, 'stock' => 14],
'remera_blanca' => ['price' => 35999, 'stock' => 22],
'remera_negra' => ['price' => 36999, 'stock' => 22],
'zapatillas_lecoq' => ['price' => 109999, 'stock' => 6],
'zapatillas_topper' => ['price' => 104999, 'stock' => 7],
];
/**
* @var array<int, string>
*/
private const CLOTHING_SIZES = ['S', 'M', 'L', 'XL'];
/**
* @var array<int, string>
*/
private const SHOE_SIZES = ['38', '40', '42', '44'];
/**
* @var array<int, string>
*/
private const IGNORED_FILES = [
'istockphoto-1675347112-2048x2048.jpg',
];
public function __construct(private readonly ProductService $productService)
{
}
/**
* Run the database seeds.
*/
public function run(): void
{
$tenant = Tenant::query()->where('codigo', 'sonder')->first();
if (! $tenant instanceof Tenant) {
throw new RuntimeException("Tenant 'sonder' not found.");
}
$catalogPath = storage_path('public/catalog');
if (! is_dir($catalogPath)) {
throw new RuntimeException("Catalog directory not found at path: {$catalogPath}");
}
$files = collect(File::files($catalogPath))
->filter(fn (\SplFileInfo $file) => ! in_array($file->getFilename(), self::IGNORED_FILES, true))
->values();
if ($files->isEmpty()) {
throw new RuntimeException("Catalog directory is empty at path: {$catalogPath}");
}
$groupedFiles = $files->groupBy(function (\SplFileInfo $file): string {
return $this->normalizeGroupKey($file->getBasename('.'.$file->getExtension()));
});
$productsToSeed = collect(self::PRODUCT_CATALOG)
->map(function (array $pricing, string $groupKey) use ($groupedFiles, $tenant): array {
$filesForGroup = $groupedFiles->get($groupKey);
if ($filesForGroup === null || $filesForGroup->isEmpty()) {
throw new RuntimeException("No images found for catalog group '{$groupKey}'.");
}
$metadata = $this->parseGroupKey($groupKey);
return [
'group_key' => $groupKey,
'pricing' => $pricing,
'tenant' => $tenant,
'files' => $filesForGroup->all(),
'metadata' => $metadata,
];
})
->values();
$existingProducts = Product::query()
->where('tenant_codigo', $tenant->codigo)
->whereIn('slug', $productsToSeed->pluck('metadata.product_slug'))
->with(['attachments', 'variants.attachments'])
->get();
foreach ($existingProducts as $existingProduct) {
$this->productService->delete($existingProduct);
}
foreach ($productsToSeed as $catalogProduct) {
$this->seedProduct(
$tenant,
$catalogProduct['metadata'],
$catalogProduct['pricing'],
$catalogProduct['files'],
);
}
}
/**
* @param array{attribute_codes: array<int, string>, brand_name: string|null, category_name: string, color: string|null, description: string, product_name: string, product_slug: string, type: string} $metadata
* @param array{price: int, stock: int} $pricing
* @param array<int, \SplFileInfo> $files
*/
private function seedProduct(Tenant $tenant, array $metadata, array $pricing, array $files): void
{
$brand = null;
if ($metadata['brand_name'] !== null) {
$brand = Brand::query()
->where('tenant_codigo', $tenant->codigo)
->where('nombre', $metadata['brand_name'])
->first();
if (! $brand instanceof Brand) {
throw new RuntimeException("Brand '{$metadata['brand_name']}' not found for tenant '{$tenant->codigo}'.");
}
}
$category = Category::query()
->where('nombre', $metadata['category_name'])
->whereNull('tenant_code')
->first();
if (! $category instanceof Category) {
throw new RuntimeException("Category '{$metadata['category_name']}' not found.");
}
$attributeIds = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->whereIn('codigo', $metadata['attribute_codes'])
->pluck('id', 'codigo');
if ($attributeIds->count() !== count($metadata['attribute_codes'])) {
throw new RuntimeException("Missing attributes for product '{$metadata['product_slug']}'.");
}
$product = $this->productService->create($tenant, [
'categoria_id' => $category->id,
'brand_id' => $brand?->id,
'slug' => $metadata['product_slug'],
'nombre' => $metadata['product_name'],
'descripcion' => $metadata['description'],
'precio' => $pricing['price'],
'attribute_ids' => array_values($attributeIds->all()),
]);
$sizes = $metadata['type'] === 'zapatillas'
? self::SHOE_SIZES
: self::CLOTHING_SIZES;
$images = array_map(
fn (\SplFileInfo $file): UploadedFile => $this->createUploadedFile($file->getPathname()),
$files
);
foreach ($sizes as $size) {
$definitions = [];
if (isset($attributeIds['color']) && $metadata['color'] !== null) {
$definitions[] = [
'attribute_id' => $attributeIds['color'],
'value' => $metadata['color'],
];
}
$sizeAttributeCode = $metadata['type'] === 'zapatillas'
? 'talle_numerico'
: 'talle';
$definitions[] = [
'attribute_id' => $attributeIds[$sizeAttributeCode],
'value' => $size,
];
$this->productService->createVariant($product, [
'slug' => $metadata['product_slug'].'-'.Str::slug((string) $size),
'nombre' => $metadata['product_name'].' - Talle '.$size,
'stock' => $pricing['stock'],
'descripcion' => $metadata['description'],
'precio' => $pricing['price'],
'definitions' => $definitions,
'images' => $images,
]);
}
}
/**
* @return array{attribute_codes: array<int, string>, brand_name: string|null, category_name: string, color: string|null, description: string, product_name: string, product_slug: string, type: string}
*/
private function parseGroupKey(string $groupKey): array
{
if ($groupKey === 'camiseta_rosario_central') {
return [
'type' => 'camiseta',
'category_name' => 'Remeras',
'brand_name' => 'Rosario Central',
'color' => null,
'attribute_codes' => ['talle'],
'product_slug' => $groupKey,
'product_name' => 'Camiseta Rosario Central',
'description' => 'Camiseta Rosario Central con variantes por talle.',
];
}
$segments = explode('_', $groupKey);
$type = array_shift($segments);
if (! is_string($type) || $type === '') {
throw new RuntimeException("Unable to infer product type from '{$groupKey}'.");
}
$color = null;
$brandSegments = $segments;
if (isset($segments[0]) && in_array($segments[0], ['blanco', 'blanca', 'negro', 'negra', 'gris'], true)) {
$color = $this->normalizeColor($segments[0]);
$brandSegments = array_slice($segments, 1);
}
$brandKey = implode('_', $brandSegments);
$brandName = $brandKey !== ''
? $this->normalizeBrandName($brandKey)
: null;
$categoryName = $this->resolveCategoryName($type);
$productName = $this->buildProductName($type, $color, $brandName);
$description = $this->buildDescription($type, $color, $brandName);
$attributeCodes = $type === 'zapatillas'
? ['talle_numerico']
: ['talle'];
if ($color !== null) {
array_unshift($attributeCodes, 'color');
}
return [
'type' => $type,
'category_name' => $categoryName,
'brand_name' => $brandName,
'color' => $color,
'attribute_codes' => $attributeCodes,
'product_slug' => $groupKey,
'product_name' => $productName,
'description' => $description,
];
}
private function normalizeGroupKey(string $basename): string
{
$normalized = Str::of($basename)
->lower()
->replace('-', '_')
->replace(' ', '_')
->replaceMatches('/_\d+$/', '')
->value();
return str_replace('nije', 'nike', $normalized);
}
private function normalizeColor(string $color): string
{
return match ($color) {
'blanco', 'blanca' => 'Blanco',
'negro', 'negra' => 'Negro',
'gris' => 'Gris',
default => throw new RuntimeException("Unsupported color '{$color}'."),
};
}
private function normalizeBrandName(string $brandKey): string
{
return match ($brandKey) {
'adidas' => 'Adidas',
'nike' => 'Nike',
'under_armour' => 'Under Armour',
'topper' => 'Topper',
'lecoq' => 'Le Coq',
default => throw new RuntimeException("Unsupported brand '{$brandKey}'."),
};
}
private function resolveCategoryName(string $type): string
{
return match ($type) {
'buzo' => 'Buzos',
'remera', 'camiseta' => 'Remeras',
'pantalon' => 'Pantalones',
'zapatillas' => 'Zapatillas',
default => throw new RuntimeException("Unsupported product type '{$type}'."),
};
}
private function buildProductName(string $type, ?string $color, ?string $brandName): string
{
$typeLabel = match ($type) {
'buzo' => 'Buzo',
'remera' => 'Remera',
'camiseta' => 'Camiseta',
'pantalon' => 'Pantalon',
'zapatillas' => 'Zapatillas',
default => Str::headline($type),
};
return trim(collect([$typeLabel, $color, $brandName])->filter()->implode(' '));
}
private function buildDescription(string $type, ?string $color, ?string $brandName): string
{
$category = Str::lower($this->resolveCategoryName($type));
$colorText = $color !== null ? " color {$color}" : '';
$brandText = $brandName !== null ? " {$brandName}" : '';
return "Producto seed de {$category}{$brandText}{$colorText} con variantes por talle.";
}
private function createUploadedFile(string $path): UploadedFile
{
$mimeType = mime_content_type($path);
return new UploadedFile(
$path,
basename($path),
is_string($mimeType) ? $mimeType : null,
null,
true
);
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Database\Seeders;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Services\TenantService;
use Illuminate\Database\Seeder;
use Illuminate\Http\UploadedFile;
class TenantSeeder extends Seeder
{
public function __construct(protected TenantService $tenantService)
{
}
/**
* Run the database seeds.
*/
public function run(): void
{
// Check if tenant 'sonder' already exists and delete it to prevent duplicates
$existing = Tenant::query()->where('codigo', 'sonder')->first();
if ($existing) {
$attachmentService = app(\App\Domains\Attachable\Services\AttachmentService::class);
if ($existing->headerLogo) {
try {
$attachmentService->delete($existing->headerLogo);
} catch (\Throwable $e) {
// Ignore exception on cleanup
}
}
if ($existing->footerLogo && $existing->footer_logo_id !== $existing->header_logo_id) {
try {
$attachmentService->delete($existing->footerLogo);
} catch (\Throwable $e) {
// Ignore exception on cleanup
}
}
$existing->delete();
}
// Check if domain 'localhost' is already in use by another tenant and delete it
$existingDomain = Tenant::query()->where('dominio', 'localhost')->first();
if ($existingDomain) {
$existingDomain->delete();
}
$imagePath = public_path('images/sonder.png');
if (! file_exists($imagePath)) {
throw new \RuntimeException("Image not found at path: {$imagePath}");
}
$headerLogo = new UploadedFile(
$imagePath,
'sonder.png',
'image/png',
null,
true
);
$footerLogo = new UploadedFile(
$imagePath,
'sonder.png',
'image/png',
null,
true
);
$this->tenantService->create([
'codigo' => 'sonder',
'nombre' => 'Sonder',
'dominio' => 'localhost',
'primary_color' => '#6376F3',
'secondary_color' => '#A0A0A0',
'danger_color' => '#FF8888',
'header_footer_bg_color' => '#313131',
'header_logo' => $headerLogo,
'footer_logo' => $footerLogo,
]);
}
}

View File

@@ -23,8 +23,8 @@
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_CONNECTION" value="mysql"/>
<env name="DB_DATABASE" value="shopit_test"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>

BIN
public/images/sonder.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -10,4 +10,5 @@ Route::get('/user', function (Request $request) {
require __DIR__.'/../app/Domains/Catalog/routes/api.php';
require __DIR__.'/../app/Domains/Cart/routes/api.php';
require __DIR__.'/../app/Domains/StorageTest/routes/api.php';
require __DIR__.'/../app/Domains/Purchase/routes/api.php';
require __DIR__.'/../app/Domains/Tenant/routes/api.php';

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

View File

@@ -6,7 +6,6 @@ use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Exceptions\AttachmentStorageException;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
@@ -22,19 +21,12 @@ class AttachmentTest extends TestCase
{
Storage::fake('s3');
$tenant = Tenant::query()->create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.com',
]);
$file = UploadedFile::fake()->image('logo.png');
$attachment = app(AttachmentService::class)->store(
$file,
'attachments/acme/logo.png',
);
$tenant->attachments()->attach($attachment->getKey());
$this->assertSame(AttachmentType::Image, $attachment->type);
$this->assertTrue(Str::isUuid($attachment->key));
@@ -48,26 +40,14 @@ class AttachmentTest extends TestCase
'filename' => 'logo.png',
'type' => AttachmentType::Image->value,
]);
$this->assertDatabaseHas('attachable_attachments', [
'attachable_type' => $tenant->getMorphClass(),
'attachable_id' => $tenant->getKey(),
'attachment_id' => $attachment->id,
]);
$freshAttachment = Attachment::query()->findOrFail($attachment->id);
$this->assertSame(AttachmentType::Image, $freshAttachment->type);
$this->assertTrue($tenant->attachments->contains($freshAttachment));
}
public function test_it_does_not_persist_the_attachment_when_the_s3_upload_fails(): void
{
$tenant = Tenant::query()->create([
'codigo' => 'globex',
'nombre' => 'Globex',
'dominio' => 'globex.com',
]);
$disk = Mockery::mock();
Storage::shouldReceive('disk')
->once()
@@ -94,12 +74,6 @@ class AttachmentTest extends TestCase
{
Storage::fake('s3');
$tenant = Tenant::query()->create([
'codigo' => 'initech',
'nombre' => 'Initech',
'dominio' => 'initech.com',
]);
Storage::disk('s3')->put('attachments/initech/spec.pdf', 'spec');
$attachment = Attachment::query()->create([
@@ -111,7 +85,6 @@ class AttachmentTest extends TestCase
'extension' => 'pdf',
'size' => 512,
]);
$tenant->attachments()->attach($attachment->getKey());
app(AttachmentService::class)->delete($attachment);
@@ -119,19 +92,10 @@ class AttachmentTest extends TestCase
$this->assertDatabaseMissing('attachments', [
'id' => $attachment->id,
]);
$this->assertDatabaseMissing('attachable_attachments', [
'attachment_id' => $attachment->id,
]);
}
public function test_it_keeps_the_database_record_when_the_s3_delete_fails(): void
{
$tenant = Tenant::query()->create([
'codigo' => 'umbrella',
'nombre' => 'Umbrella',
'dominio' => 'umbrella.com',
]);
$attachment = Attachment::query()->create([
'path' => 'attachments/umbrella/audio.mp3',
'key' => (string) Str::uuid(),
@@ -141,7 +105,6 @@ class AttachmentTest extends TestCase
'extension' => 'mp3',
'size' => 1024,
]);
$tenant->attachments()->attach($attachment->getKey());
$disk = Mockery::mock();
Storage::shouldReceive('disk')
@@ -161,11 +124,6 @@ class AttachmentTest extends TestCase
$this->assertDatabaseHas('attachments', [
'id' => $attachment->id,
]);
$this->assertDatabaseHas('attachable_attachments', [
'attachment_id' => $attachment->id,
'attachable_type' => $tenant->getMorphClass(),
'attachable_id' => $tenant->getKey(),
]);
}
}
}

View File

@@ -3,7 +3,7 @@
namespace Tests\Feature\Cart;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductAttribute;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Models\ProductVariantDefinition;
use App\Domains\Tenant\Models\Tenant;
@@ -17,11 +17,7 @@ class CartControllerTest extends TestCase
public function test_it_returns_an_empty_guest_cart_when_cart_does_not_exist(): void
{
Tenant::create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.com',
]);
$this->createTenant('acme', 'Acme', 'acme.com');
$this->getJson('/api/tenants/acme/cart')
->assertOk()
@@ -37,11 +33,11 @@ class CartControllerTest extends TestCase
public function test_it_creates_a_guest_cart_and_returns_the_cart_snapshot(): void
{
$variant = $this->createVariantForTenant('acme', 10, '49.90');
$attribute = ProductAttribute::query()->create([
$attribute = Attribute::query()->create([
'tenant_codigo' => 'acme',
'codigo' => 'color',
'nombre' => 'Color',
'type' => 'text',
'type' => 'string',
]);
ProductVariantDefinition::query()->create([
@@ -272,10 +268,10 @@ class CartControllerTest extends TestCase
string $price,
string $slugPrefix = 'shirt',
): ProductVariant {
Tenant::query()->firstOrCreate(
['codigo' => $tenantCode],
['nombre' => ucfirst($tenantCode), 'dominio' => "{$tenantCode}.com"],
);
$tenant = Tenant::query()->where('codigo', $tenantCode)->first();
if (! $tenant) {
$this->createTenant($tenantCode, ucfirst($tenantCode), "{$tenantCode}.com");
}
$category = \App\Domains\Catalog\Models\Category::query()->create([
'tenant_code' => $tenantCode,
@@ -300,4 +296,37 @@ class CartControllerTest extends TestCase
'precio' => $price,
])->load('product');
}
protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant
{
$hdrKey = (string) \Illuminate\Support\Str::uuid();
$ftrKey = (string) \Illuminate\Support\Str::uuid();
$headerAttachment = \App\Domains\Attachable\Models\Attachment::create([
'key' => $hdrKey,
'path' => 'tenants/' . $hdrKey . '.png',
'filename' => 'logo_header.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
$footerAttachment = \App\Domains\Attachable\Models\Attachment::create([
'key' => $ftrKey,
'path' => 'tenants/' . $ftrKey . '.png',
'filename' => 'logo_footer.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
return Tenant::create([
'codigo' => $codigo,
'nombre' => $nombre,
'dominio' => $dominio,
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
'header_footer_bg_color' => '#444444',
'header_logo_id' => $headerAttachment->id,
'footer_logo_id' => $footerAttachment->id,
]);
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace Tests\Feature\Catalog;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AttributeControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_a_select_attribute_with_options(): void
{
$this->createTenant('acme', 'Acme', 'acme.com');
$response = $this->postJson('/api/tenants/acme/attributes', [
'codigo' => 'color',
'nombre' => 'Color',
'is_required' => true,
'type' => 'select',
'metadata_schema' => [
'swatch' => ['type' => 'hex'],
],
'options' => [
[
'value' => 'red',
'label' => 'Red',
'sort_order' => 1,
'metadata' => ['hex' => '#ff0000'],
],
[
'value' => 'blue',
'label' => 'Blue',
'sort_order' => 2,
'metadata' => ['hex' => '#0000ff'],
],
],
]);
$response
->assertCreated()
->assertJsonPath('data.tenant_codigo', 'acme')
->assertJsonPath('data.codigo', 'color')
->assertJsonPath('data.type', 'select')
->assertJsonPath('data.options.0.value', 'red')
->assertJsonPath('data.options.0.label', 'Red')
->assertJsonPath('data.options.1.metadata.hex', '#0000ff');
$this->assertDatabaseHas('attribute', [
'tenant_codigo' => 'acme',
'codigo' => 'color',
'type' => 'select',
]);
$this->assertDatabaseHas('attribute_options', [
'value' => 'red',
'label' => 'Red',
'sort_order' => 1,
]);
}
public function test_it_rejects_options_for_string_attributes(): void
{
$this->createTenant('acme', 'Acme', 'acme.com');
$response = $this->postJson('/api/tenants/acme/attributes', [
'codigo' => 'material',
'nombre' => 'Material',
'type' => 'string',
'options' => [
['label' => 'Cotton'],
],
]);
$response
->assertUnprocessable()
->assertJsonValidationErrors(['options']);
}
public function test_it_throws_exception_when_creating_non_select_attribute_with_options_directly_on_model(): void
{
$tenant = $this->createTenant('acme2', 'Acme 2', 'acme2.com');
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Options are only allowed for select and multiselect attributes.');
\App\Domains\Catalog\Models\Product::createAttribute($tenant, [
'codigo' => 'material2',
'nombre' => 'Material 2',
'type' => 'string',
'options' => [
['label' => 'Cotton', 'value' => 'cotton'],
],
]);
}
protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant
{
$hdrKey = (string) \Illuminate\Support\Str::uuid();
$ftrKey = (string) \Illuminate\Support\Str::uuid();
$headerAttachment = \App\Domains\Attachable\Models\Attachment::create([
'key' => $hdrKey,
'path' => 'tenants/' . $hdrKey . '.png',
'filename' => 'logo_header.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
$footerAttachment = \App\Domains\Attachable\Models\Attachment::create([
'key' => $ftrKey,
'path' => 'tenants/' . $ftrKey . '.png',
'filename' => 'logo_footer.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
return Tenant::create([
'codigo' => $codigo,
'nombre' => $nombre,
'dominio' => $dominio,
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
'header_footer_bg_color' => '#444444',
'header_logo_id' => $headerAttachment->id,
'footer_logo_id' => $footerAttachment->id,
]);
}
}

View File

@@ -1,84 +0,0 @@
<?php
namespace Tests\Feature\Catalog;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProductAttributeControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_a_select_attribute_with_options(): void
{
Tenant::create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.com',
]);
$response = $this->postJson('/api/tenants/acme/product-attributes', [
'codigo' => 'color',
'nombre' => 'Color',
'is_required' => true,
'type' => 'select',
'metadata_schema' => [
'swatch' => ['type' => 'hex'],
],
'options' => [
[
'label' => 'Red',
'sort_order' => 1,
'metadata' => ['hex' => '#ff0000'],
],
[
'label' => 'Blue',
'sort_order' => 2,
'metadata' => ['hex' => '#0000ff'],
],
],
]);
$response
->assertCreated()
->assertJsonPath('tenant_codigo', 'acme')
->assertJsonPath('codigo', 'color')
->assertJsonPath('type', 'select')
->assertJsonPath('options.0.label', 'Red')
->assertJsonPath('options.1.metadata.hex', '#0000ff');
$this->assertDatabaseHas('productos_attributes', [
'tenant_codigo' => 'acme',
'codigo' => 'color',
'type' => 'select',
]);
$this->assertDatabaseHas('attribute_options', [
'label' => 'Red',
'sort_order' => 1,
]);
}
public function test_it_rejects_options_for_text_attributes(): void
{
Tenant::create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.com',
]);
$response = $this->postJson('/api/tenants/acme/product-attributes', [
'codigo' => 'material',
'nombre' => 'Material',
'type' => 'text',
'options' => [
['label' => 'Cotton'],
],
]);
$response
->assertUnprocessable()
->assertJsonValidationErrors(['options']);
}
}

View File

@@ -0,0 +1,424 @@
<?php
namespace Tests\Feature\Catalog;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class ProductControllerTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
private Attribute $sizeAttr;
private Attribute $colorAttr;
private Attribute $extraAttr;
protected function setUp(): void
{
parent::setUp();
$this->tenant = $this->createTenant('acme', 'Acme Inc.', 'acme.com');
// Create attributes for variants
$this->sizeAttr = Attribute::create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => 'talle',
'nombre' => 'Talle',
'is_required' => true,
'type' => 'select',
]);
$this->sizeAttr->options()->createMany([
['value' => 'S', 'label' => 'S'],
['value' => '38', 'label' => '38'],
]);
$this->colorAttr = Attribute::create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => 'color',
'nombre' => 'Color',
'is_required' => true,
'type' => 'select',
]);
$this->colorAttr->options()->createMany([
['value' => 'Azul', 'label' => 'Azul'],
['value' => 'Rojo', 'label' => 'Rojo'],
]);
$this->extraAttr = Attribute::create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => 'extra',
'nombre' => 'Extra Attribute',
'is_required' => false,
'type' => 'string',
]);
}
public function test_it_creates_product_with_attributes_and_then_creates_variants(): void
{
$payload = [
'categoria_id' => 1,
'slug' => 'remera-sport',
'nombre' => 'Remera Sport',
'descripcion' => 'Remera para hacer deportes',
'precio' => 15000.00,
'attribute_ids' => [
$this->extraAttr->id,
$this->sizeAttr->id,
$this->colorAttr->id,
],
];
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos", $payload);
$response->assertCreated();
// Assert JSON structure
$response->assertJsonPath('data.nombre', 'Remera Sport');
// Assert Database
$this->assertDatabaseHas('productos', [
'tenant_codigo' => $this->tenant->codigo,
'slug' => 'remera-sport',
]);
$product = Product::where('slug', 'remera-sport')->firstOrFail();
// Assert that products_attributes has both explicit extraAttr and those from variants (sizeAttr, colorAttr)
$this->assertDatabaseHas('products_attributes', [
'product_id' => $product->id,
'attribute_id' => $this->extraAttr->id,
]);
$this->assertDatabaseHas('products_attributes', [
'product_id' => $product->id,
'attribute_id' => $this->sizeAttr->id,
]);
$this->assertDatabaseHas('products_attributes', [
'product_id' => $product->id,
'attribute_id' => $this->colorAttr->id,
]);
// Create a variant
$variantPayload = [
'producto_id' => $product->id,
'slug' => 'remera-sport-s-azul',
'nombre' => 'Remera Sport S Azul',
'stock' => 10,
'precio' => 15000.00,
'definitions' => [
[
'attribute_id' => $this->sizeAttr->id,
'value' => 'S',
],
[
'attribute_id' => $this->colorAttr->id,
'value' => 'Azul',
],
],
];
$variantResponse = $this->postJson("/api/tenants/{$this->tenant->codigo}/product-variants", $variantPayload);
$variantResponse->assertCreated();
$this->assertDatabaseHas('productos_variantes', [
'producto_id' => $product->id,
'slug' => 'remera-sport-s-azul',
'stock' => 10,
'precio' => 15000.00,
]);
$variantS = ProductVariant::where('slug', 'remera-sport-s-azul')->firstOrFail();
$this->assertDatabaseHas('productos_variantes_values', [
'producto_variante_id' => $variantS->id,
'attribute_id' => $this->sizeAttr->id,
'value' => 'S',
]);
$this->assertDatabaseHas('productos_variantes_values', [
'producto_variante_id' => $variantS->id,
'attribute_id' => $this->colorAttr->id,
'value' => 'Azul',
]);
}
public function test_it_updates_product_attributes_independently(): void
{
// 1. Create a product with extraAttr
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'slug' => 'pantalon-cargo',
'nombre' => 'Pantalon Cargo',
'precio' => 20000.00,
]);
$product->attributes()->sync([$this->extraAttr->id]);
// 2. Perform update payload - change name and update attribute_ids to sizeAttr
$payload = [
'categoria_id' => 1,
'slug' => 'pantalon-cargo-new-slug',
'nombre' => 'Pantalon Cargo V2',
'precio' => 22000.00,
'attribute_ids' => [$this->sizeAttr->id],
];
$response = $this->putJson(
"/api/tenants/{$this->tenant->codigo}/productos/{$product->id}",
$payload
);
$response->assertOk();
// Assert updated values
$response->assertJsonPath('data.nombre', 'Pantalon Cargo V2');
// Check DB state
// extraAttr must be detached
$this->assertDatabaseMissing('products_attributes', [
'product_id' => $product->id,
'attribute_id' => $this->extraAttr->id,
]);
// sizeAttr must be attached
$this->assertDatabaseHas('products_attributes', [
'product_id' => $product->id,
'attribute_id' => $this->sizeAttr->id,
]);
}
public function test_it_rejects_variant_creation_with_attributes_not_associated_with_product(): void
{
// Create product with only sizeAttr associated
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'slug' => 'pantalon-cargo',
'nombre' => 'Pantalon Cargo',
'precio' => 20000.00,
]);
$product->attributes()->sync([$this->sizeAttr->id]);
// Attempt to create variant with colorAttr (which is not associated with the product)
$variantPayload = [
'producto_id' => $product->id,
'slug' => 'pantalon-cargo-38-rojo',
'nombre' => 'Pantalon Cargo 38 Rojo',
'stock' => 5,
'precio' => 20000.00,
'definitions' => [
[
'attribute_id' => $this->sizeAttr->id,
'value' => '38',
],
[
'attribute_id' => $this->colorAttr->id, // not associated!
'value' => 'Rojo',
],
],
];
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/product-variants", $variantPayload);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['definitions.1.attribute_id']);
}
public function test_it_rejects_variant_update_with_attributes_not_associated_with_product(): void
{
// Create product with only sizeAttr associated
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'slug' => 'pantalon-cargo',
'nombre' => 'Pantalon Cargo',
'precio' => 20000.00,
]);
$product->attributes()->sync([$this->sizeAttr->id]);
$variant = $product->variants()->create([
'slug' => 'pantalon-cargo-38',
'nombre' => 'Pantalon Cargo 38',
'stock' => 5,
'precio' => 20000.00,
]);
$variant->definitions()->create([
'attribute_id' => $this->sizeAttr->id,
'value' => '38',
]);
// Attempt to update variant with colorAttr (which is not associated with the product)
$variantPayload = [
'producto_id' => $product->id,
'slug' => 'pantalon-cargo-38-rojo',
'nombre' => 'Pantalon Cargo 38 Rojo',
'stock' => 5,
'precio' => 20000.00,
'definitions' => [
[
'attribute_id' => $this->sizeAttr->id,
'value' => '38',
],
[
'attribute_id' => $this->colorAttr->id, // not associated!
'value' => 'Rojo',
],
],
];
$response = $this->putJson("/api/tenants/{$this->tenant->codigo}/product-variants/{$variant->id}", $variantPayload);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['definitions.1.attribute_id']);
}
public function test_it_does_not_modify_variants_if_not_present_in_update_payload(): void
{
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'slug' => 'short-running',
'nombre' => 'Short Running',
'precio' => 8000.00,
]);
$v1 = $product->variants()->create([
'slug' => 'short-running-m',
'nombre' => 'Short Running M',
'stock' => 5,
'precio' => 8000.00,
]);
$payload = [
'categoria_id' => 1,
'slug' => 'short-running',
'nombre' => 'Short Running Updated',
'precio' => 9000.00,
];
$response = $this->putJson(
"/api/tenants/{$this->tenant->codigo}/productos/{$product->id}",
$payload
);
$response->assertOk();
$this->assertDatabaseHas('productos', [
'id' => $product->id,
'nombre' => 'Short Running Updated',
]);
// Variant should still exist untouched
$this->assertDatabaseHas('productos_variantes', [
'id' => $v1->id,
'slug' => 'short-running-m',
'stock' => 5,
]);
}
public function test_it_rejects_variants_with_invalid_attributes(): void
{
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'slug' => 'pantalon-cargo',
'nombre' => 'Pantalon Cargo',
'precio' => 20000.00,
]);
$payload = [
'producto_id' => $product->id,
'slug' => 'remera-sport-s-azul',
'nombre' => 'Remera Sport S Azul',
'stock' => 10,
'precio' => 15000.00,
'definitions' => [
[
'attribute_id' => 99999, // Non-existent ID
'value' => 'S',
],
],
];
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/product-variants", $payload);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['definitions.0.attribute_id']);
}
public function test_it_throws_exception_when_variant_value_does_not_belong_to_attribute_options(): void
{
// 1. Create a select attribute with options
$selectAttr = Product::createAttribute($this->tenant, [
'codigo' => 'tamanho',
'nombre' => 'Tamanho',
'type' => 'select',
'options' => [
['value' => 'P', 'label' => 'Piqueno'],
['value' => 'M', 'label' => 'Medio'],
]
]);
// 2. Create product and associate attribute
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'slug' => 'test-prod-validation',
'nombre' => 'Test Prod Validation',
'precio' => 100.00,
]);
$product->attributes()->sync([$selectAttr->id]);
// 3. Expect exception when creating variant with invalid value 'G'
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The value 'G' is not a valid option for the select attribute 'Tamanho'.");
$product->createVariant([
'slug' => 'test-prod-validation-g',
'nombre' => 'Test Prod Validation G',
'stock' => 5,
'precio' => 100.00,
'definitions' => [
[
'attribute_id' => $selectAttr->id,
'value' => 'G', // Invalid value
]
]
]);
}
protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant
{
$hdrKey = (string) Str::uuid();
$ftrKey = (string) Str::uuid();
$headerAttachment = \App\Domains\Attachable\Models\Attachment::create([
'key' => $hdrKey,
'path' => 'tenants/' . $hdrKey . '.png',
'filename' => 'logo_header.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
$footerAttachment = \App\Domains\Attachable\Models\Attachment::create([
'key' => $ftrKey,
'path' => 'tenants/' . $ftrKey . '.png',
'filename' => 'logo_footer.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
return Tenant::create([
'codigo' => $codigo,
'nombre' => $nombre,
'dominio' => $dominio,
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
'header_footer_bg_color' => '#444444',
'header_logo_id' => $headerAttachment->id,
'footer_logo_id' => $footerAttachment->id,
]);
}
}

Some files were not shown because too many files have changed in this diff Show More