Compare commits
14 Commits
b2c8f13d2e
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c45f45815 | |||
| 70f51ac863 | |||
| 492c6c4ac2 | |||
| a5b1539a80 | |||
| 9b82800921 | |||
| 0e2ffc1c74 | |||
| 1907178e52 | |||
| c6e5177df1 | |||
| 689d115a8f | |||
| 75129b7552 | |||
| 8e343ea9c3 | |||
| 696010a70e | |||
| e4139d48aa | |||
| 8d840d94a0 |
66
app/Domains/Cart/Controllers/CartController.php
Normal file
66
app/Domains/Cart/Controllers/CartController.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Cart\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Requests\AddCartItemRequest;
|
||||||
|
use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest;
|
||||||
|
use App\Domains\Cart\Resources\CartResource;
|
||||||
|
use App\Domains\Cart\Services\CartService;
|
||||||
|
use App\Domains\Catalog\Models\ProductVariant;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class CartController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected CartService $cartService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Tenant $tenant): CartResource
|
||||||
|
{
|
||||||
|
return CartResource::make($this->cartService->show($tenant, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addItem(AddCartItemRequest $request, Tenant $tenant): JsonResponse
|
||||||
|
{
|
||||||
|
$result = $this->cartService->addItem(
|
||||||
|
$tenant,
|
||||||
|
$request,
|
||||||
|
(int) $request->validated('product_variant_id'),
|
||||||
|
(int) $request->validated('cantidad'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = CartResource::make($result['cart'])->response();
|
||||||
|
|
||||||
|
if ($result['guest_token'] !== null) {
|
||||||
|
$response->withCookie($this->cartService->makeGuestTokenCookie($result['guest_token']));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateItemQuantity(
|
||||||
|
UpdateCartItemQuantityRequest $request,
|
||||||
|
Tenant $tenant,
|
||||||
|
ProductVariant $productVariant,
|
||||||
|
): CartResource {
|
||||||
|
return CartResource::make(
|
||||||
|
$this->cartService->updateItemQuantity(
|
||||||
|
$tenant,
|
||||||
|
$request,
|
||||||
|
$productVariant->getKey(),
|
||||||
|
(int) $request->validated('cantidad'),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeItem(Request $request, Tenant $tenant, ProductVariant $productVariant): CartResource
|
||||||
|
{
|
||||||
|
return CartResource::make(
|
||||||
|
$this->cartService->removeItem($tenant, $request, $productVariant->getKey())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
172
app/Domains/Cart/Models/Cart.php
Normal file
172
app/Domains/Cart/Models/Cart.php
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Cart\Models;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\ProductVariant;
|
||||||
|
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;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'tenant_codigo',
|
||||||
|
'user_id',
|
||||||
|
'guest_token',
|
||||||
|
'status',
|
||||||
|
])]
|
||||||
|
class Cart extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'carritos';
|
||||||
|
|
||||||
|
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<CartItem, $this>
|
||||||
|
*/
|
||||||
|
public function items(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CartItem::class, 'cart_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addItem(int $productVariantId, int $quantity): CartItem
|
||||||
|
{
|
||||||
|
if ($quantity <= 0) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'cantidad' => 'La cantidad debe ser mayor a cero.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($productVariantId, $quantity): CartItem {
|
||||||
|
$variant = $this->resolveScopedVariant($productVariantId, true);
|
||||||
|
|
||||||
|
if ($variant->stock < $quantity) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'cantidad' => 'Stock insuficiente para la variante solicitada.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CartItem|null $item */
|
||||||
|
$item = $this->items()
|
||||||
|
->where('producto_variante_id', $variant->getKey())
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($item === null) {
|
||||||
|
$item = $this->items()->create([
|
||||||
|
'producto_variante_id' => $variant->getKey(),
|
||||||
|
'cantidad' => $quantity,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$item->cantidad += $quantity;
|
||||||
|
$item->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant->decrement('stock', $quantity);
|
||||||
|
|
||||||
|
return $item->fresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateItem(int $productVariantId, int $quantity): CartItem
|
||||||
|
{
|
||||||
|
if ($quantity <= 0) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'cantidad' => 'La cantidad debe ser mayor a cero.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($productVariantId, $quantity): CartItem {
|
||||||
|
/** @var CartItem $item */
|
||||||
|
$item = $this->items()
|
||||||
|
->where('producto_variante_id', $productVariantId)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$variant = $this->resolveScopedVariant($productVariantId, true);
|
||||||
|
$delta = $quantity - $item->cantidad;
|
||||||
|
|
||||||
|
if ($delta > 0 && $variant->stock < $delta) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'cantidad' => 'Stock insuficiente para actualizar la cantidad solicitada.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$item->cantidad = $quantity;
|
||||||
|
$item->save();
|
||||||
|
|
||||||
|
if ($delta > 0) {
|
||||||
|
$variant->decrement('stock', $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($delta < 0) {
|
||||||
|
$variant->increment('stock', abs($delta));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $item->fresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeItem(int $productVariantId): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($productVariantId): void {
|
||||||
|
/** @var CartItem $item */
|
||||||
|
$item = $this->items()
|
||||||
|
->where('producto_variante_id', $productVariantId)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$variant = $this->resolveScopedVariant($productVariantId, true);
|
||||||
|
$variant->increment('stock', $item->cantidad);
|
||||||
|
$item->delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveScopedVariant(int $productVariantId, bool $lockForUpdate = false): ProductVariant
|
||||||
|
{
|
||||||
|
$query = ProductVariant::query()
|
||||||
|
->whereKey($productVariantId)
|
||||||
|
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $this->tenant_codigo));
|
||||||
|
|
||||||
|
if ($lockForUpdate) {
|
||||||
|
$query->lockForUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var ProductVariant|null $variant */
|
||||||
|
$variant = $query->first();
|
||||||
|
|
||||||
|
if ($variant === null) {
|
||||||
|
throw new NotFoundHttpException('Product variant not found for tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $variant;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Domains/Cart/Models/CartItem.php
Normal file
46
app/Domains/Cart/Models/CartItem.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Cart\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([
|
||||||
|
'cart_id',
|
||||||
|
'producto_variante_id',
|
||||||
|
'cantidad',
|
||||||
|
])]
|
||||||
|
class CartItem extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'carrito_items';
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'cart_id' => 'integer',
|
||||||
|
'producto_variante_id' => 'integer',
|
||||||
|
'cantidad' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Cart, $this>
|
||||||
|
*/
|
||||||
|
public function cart(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Cart::class, 'cart_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<ProductVariant, $this>
|
||||||
|
*/
|
||||||
|
public function variant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Domains/Cart/Requests/AddCartItemRequest.php
Normal file
24
app/Domains/Cart/Requests/AddCartItemRequest.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Cart\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class AddCartItemRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'product_variant_id' => ['required', 'integer'],
|
||||||
|
'cantidad' => ['required', 'integer', 'min:1'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php
Normal file
23
app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Cart\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateCartItemQuantityRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'cantidad' => ['required', 'integer', 'min:1'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Domains/Cart/Resources/CartItemResource.php
Normal file
45
app/Domains/Cart/Resources/CartItemResource.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Cart\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Resources\ProductVariantDefinitionResource;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin \App\Domains\Cart\Models\CartItem
|
||||||
|
*/
|
||||||
|
class CartItemResource 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($variant?->precio),
|
||||||
|
'subtotal' => $this->formatMoney(($variant?->precio ?? 0) * $this->cantidad),
|
||||||
|
'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
|
||||||
|
{
|
||||||
|
return number_format((float) ($amount ?? 0), 2, '.', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Domains/Cart/Resources/CartResource.php
Normal file
40
app/Domains/Cart/Resources/CartResource.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Cart\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin \App\Domains\Cart\Models\Cart
|
||||||
|
*/
|
||||||
|
class CartResource 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->variant?->precio ?? 0) * $item->cantidad),
|
||||||
|
0.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'tenant_codigo' => $this->tenant_codigo,
|
||||||
|
'status' => $this->status ?? 'active',
|
||||||
|
'items' => CartItemResource::collection($items),
|
||||||
|
'subtotal' => $this->formatMoney($subtotal),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function formatMoney(float|int|string|null $amount): string
|
||||||
|
{
|
||||||
|
return number_format((float) ($amount ?? 0), 2, '.', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
204
app/Domains/Cart/Services/CartService.php
Normal file
204
app/Domains/Cart/Services/CartService.php
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Cart\Services;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\Cart;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\Cookie;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
class CartService
|
||||||
|
{
|
||||||
|
public function show(Tenant $tenant, Request $request): Cart
|
||||||
|
{
|
||||||
|
$identity = $this->resolveIdentity($request);
|
||||||
|
|
||||||
|
if ($identity === null) {
|
||||||
|
return $this->makeEmptyCart($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cart = $this->findCart($tenant, $identity);
|
||||||
|
|
||||||
|
if ($cart === null) {
|
||||||
|
return $this->makeEmptyCart($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->loadCart($cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{cart: Cart, guest_token: ?string}
|
||||||
|
*/
|
||||||
|
public function addItem(Tenant $tenant, Request $request, int $productVariantId, int $quantity): array
|
||||||
|
{
|
||||||
|
$resolvedIdentity = $this->resolveIdentity($request, true);
|
||||||
|
$identity = $resolvedIdentity['identity'];
|
||||||
|
$cart = $this->findOrCreateCart($tenant, $identity);
|
||||||
|
$cart->addItem($productVariantId, $quantity);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'cart' => $this->loadCart($cart),
|
||||||
|
'guest_token' => $resolvedIdentity['generated_guest_token'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateItemQuantity(Tenant $tenant, Request $request, int $productVariantId, int $quantity): Cart
|
||||||
|
{
|
||||||
|
$identity = $this->requireIdentity($request);
|
||||||
|
$cart = $this->findCartOrFail($tenant, $identity);
|
||||||
|
$cart->updateItem($productVariantId, $quantity);
|
||||||
|
|
||||||
|
return $this->loadCart($cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeItem(Tenant $tenant, Request $request, int $productVariantId): Cart
|
||||||
|
{
|
||||||
|
$identity = $this->requireIdentity($request);
|
||||||
|
$cart = $this->findCartOrFail($tenant, $identity);
|
||||||
|
$cart->removeItem($productVariantId);
|
||||||
|
|
||||||
|
return $this->loadCart($cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function makeGuestTokenCookie(string $guestToken): Cookie
|
||||||
|
{
|
||||||
|
return cookie(
|
||||||
|
'guest_token',
|
||||||
|
$guestToken,
|
||||||
|
60 * 24 * 180,
|
||||||
|
'/',
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
'lax',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function makeEmptyCart(Tenant $tenant): Cart
|
||||||
|
{
|
||||||
|
$cart = new Cart([
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cart->setRelation('items', collect());
|
||||||
|
|
||||||
|
return $cart;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function loadCart(Cart $cart): Cart
|
||||||
|
{
|
||||||
|
return $cart->fresh()->load([
|
||||||
|
'items.variant.product',
|
||||||
|
'items.variant.definitions.attribute',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{identity: array{user_id: ?int, guest_token: ?string}, generated_guest_token: ?string}|null
|
||||||
|
*/
|
||||||
|
protected function resolveIdentity(Request $request, bool $generateGuestToken = false): ?array
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user instanceof User) {
|
||||||
|
return [
|
||||||
|
'identity' => [
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'guest_token' => null,
|
||||||
|
],
|
||||||
|
'generated_guest_token' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$guestToken = $request->cookie('guest_token');
|
||||||
|
|
||||||
|
if (is_string($guestToken) && $guestToken !== '') {
|
||||||
|
return [
|
||||||
|
'identity' => [
|
||||||
|
'user_id' => null,
|
||||||
|
'guest_token' => $guestToken,
|
||||||
|
],
|
||||||
|
'generated_guest_token' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $generateGuestToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$generatedGuestToken = (string) Str::uuid();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'identity' => [
|
||||||
|
'user_id' => null,
|
||||||
|
'guest_token' => $generatedGuestToken,
|
||||||
|
],
|
||||||
|
'generated_guest_token' => $generatedGuestToken,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{user_id: ?int, guest_token: ?string}
|
||||||
|
*/
|
||||||
|
protected function requireIdentity(Request $request): array
|
||||||
|
{
|
||||||
|
$resolvedIdentity = $this->resolveIdentity($request);
|
||||||
|
|
||||||
|
if ($resolvedIdentity === null) {
|
||||||
|
throw new NotFoundHttpException('Cart not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolvedIdentity['identity'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||||
|
*/
|
||||||
|
protected function findCart(Tenant $tenant, array $identity): ?Cart
|
||||||
|
{
|
||||||
|
return Cart::query()
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->when(
|
||||||
|
$identity['user_id'] !== null,
|
||||||
|
fn ($query) => $query->where('user_id', $identity['user_id']),
|
||||||
|
fn ($query) => $query->where('guest_token', $identity['guest_token']),
|
||||||
|
)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||||
|
*/
|
||||||
|
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
|
||||||
|
{
|
||||||
|
$cart = $this->findCart($tenant, $identity);
|
||||||
|
|
||||||
|
if ($cart === null) {
|
||||||
|
throw new NotFoundHttpException('Cart not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||||
|
*/
|
||||||
|
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
|
||||||
|
{
|
||||||
|
$attributes = ['tenant_codigo' => $tenant->codigo];
|
||||||
|
|
||||||
|
if ($identity['user_id'] !== null) {
|
||||||
|
$attributes['user_id'] = $identity['user_id'];
|
||||||
|
} else {
|
||||||
|
$attributes['guest_token'] = $identity['guest_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Cart::query()->firstOrCreate($attributes, ['status' => 'active']);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
11
app/Domains/Cart/routes/api.php
Normal file
11
app/Domains/Cart/routes/api.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Cart\Controllers\CartController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
||||||
|
Route::get('cart', [CartController::class, 'show']);
|
||||||
|
Route::post('cart/items', [CartController::class, 'addItem']);
|
||||||
|
Route::patch('cart/items/{productVariant}', [CartController::class, 'updateItemQuantity']);
|
||||||
|
Route::delete('cart/items/{productVariant}', [CartController::class, 'removeItem']);
|
||||||
|
});
|
||||||
@@ -6,48 +6,60 @@ use App\Domains\Catalog\Models\Brand;
|
|||||||
use App\Domains\Catalog\Requests\StoreBrandRequest;
|
use App\Domains\Catalog\Requests\StoreBrandRequest;
|
||||||
use App\Domains\Catalog\Requests\UpdateBrandRequest;
|
use App\Domains\Catalog\Requests\UpdateBrandRequest;
|
||||||
use App\Domains\Catalog\Resources\BrandResource;
|
use App\Domains\Catalog\Resources\BrandResource;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
class BrandController extends Controller
|
class BrandController extends Controller
|
||||||
{
|
{
|
||||||
public function index(): JsonResponse
|
public function index(Tenant $tenant): JsonResponse
|
||||||
{
|
{
|
||||||
return BrandResource::collection(Brand::query()->orderByDesc('id')->get())->response();
|
return BrandResource::collection(
|
||||||
|
Brand::query()->where('tenant_codigo', $tenant->codigo)->orderByDesc('id')->get()
|
||||||
|
)->response();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(StoreBrandRequest $request): JsonResponse
|
public function store(StoreBrandRequest $request, Tenant $tenant): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$brand = Brand::query()->create([
|
||||||
$props = $validated['props'] ?? [];
|
...$request->validated(),
|
||||||
unset($validated['props']);
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
$brand = Brand::createWithProps($validated, $props);
|
|
||||||
|
|
||||||
return BrandResource::make($brand)->response()->setStatusCode(201);
|
return BrandResource::make($brand)->response()->setStatusCode(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Brand $marca): BrandResource
|
public function show(Tenant $tenant, Brand $marca): BrandResource
|
||||||
{
|
{
|
||||||
return BrandResource::make($marca);
|
$marca = $this->resolveScopedBrand($tenant, $marca);
|
||||||
}
|
|
||||||
|
|
||||||
public function update(UpdateBrandRequest $request, Brand $marca): BrandResource
|
|
||||||
{
|
|
||||||
$validated = $request->validated();
|
|
||||||
$props = $validated['props'] ?? [];
|
|
||||||
unset($validated['props']);
|
|
||||||
|
|
||||||
$marca = $marca->updateWithProps($validated, $props);
|
|
||||||
|
|
||||||
return BrandResource::make($marca);
|
return BrandResource::make($marca);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Brand $marca): Response
|
public function update(UpdateBrandRequest $request, Tenant $tenant, Brand $marca): BrandResource
|
||||||
{
|
{
|
||||||
|
$marca = $this->resolveScopedBrand($tenant, $marca);
|
||||||
|
$marca->update($request->validated());
|
||||||
|
|
||||||
|
return BrandResource::make($marca);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Tenant $tenant, Brand $marca): Response
|
||||||
|
{
|
||||||
|
$marca = $this->resolveScopedBrand($tenant, $marca);
|
||||||
$marca->delete();
|
$marca->delete();
|
||||||
|
|
||||||
return response()->noContent();
|
return response()->noContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function resolveScopedBrand(Tenant $tenant, Brand $brand): Brand
|
||||||
|
{
|
||||||
|
if ($brand->tenant_codigo !== $tenant->codigo) {
|
||||||
|
throw new NotFoundHttpException('Brand not found for tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $brand;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,52 +6,66 @@ use App\Domains\Catalog\Models\Category;
|
|||||||
use App\Domains\Catalog\Requests\StoreCategoryRequest;
|
use App\Domains\Catalog\Requests\StoreCategoryRequest;
|
||||||
use App\Domains\Catalog\Requests\UpdateCategoryRequest;
|
use App\Domains\Catalog\Requests\UpdateCategoryRequest;
|
||||||
use App\Domains\Catalog\Resources\CategoryResource;
|
use App\Domains\Catalog\Resources\CategoryResource;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
class CategoryController extends Controller
|
class CategoryController extends Controller
|
||||||
{
|
{
|
||||||
public function index(): JsonResponse
|
public function index(Tenant $tenant): JsonResponse
|
||||||
{
|
{
|
||||||
return CategoryResource::collection(
|
return CategoryResource::collection(
|
||||||
Category::query()->with(['parent', 'subCategories','tenant'])->orderByDesc('id')->get()
|
Category::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->with(['parent', 'subCategories', 'tenant'])
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get()
|
||||||
)->response();
|
)->response();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(StoreCategoryRequest $request): JsonResponse
|
public function store(StoreCategoryRequest $request, Tenant $tenant): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$category = Category::query()->create([
|
||||||
$props = $validated['props'] ?? [];
|
...$request->validated(),
|
||||||
unset($validated['props']);
|
'tenant_code' => $tenant->codigo,
|
||||||
|
]);
|
||||||
$category = Category::createWithProps($validated, $props);
|
|
||||||
|
|
||||||
return CategoryResource::make($category->load(['parent', 'subCategories', 'tenant']))
|
return CategoryResource::make($category->load(['parent', 'subCategories', 'tenant']))
|
||||||
->response()
|
->response()
|
||||||
->setStatusCode(201);
|
->setStatusCode(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Category $categoria): CategoryResource
|
public function show(Tenant $tenant, Category $categoria): CategoryResource
|
||||||
{
|
{
|
||||||
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
|
$categoria = $this->resolveScopedCategory($tenant, $categoria);
|
||||||
}
|
|
||||||
|
|
||||||
public function update(UpdateCategoryRequest $request, Category $categoria): CategoryResource
|
|
||||||
{
|
|
||||||
$validated = $request->validated();
|
|
||||||
$props = $validated['props'] ?? [];
|
|
||||||
unset($validated['props']);
|
|
||||||
|
|
||||||
$categoria = $categoria->updateWithProps($validated, $props);
|
|
||||||
|
|
||||||
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
|
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Category $categoria): Response
|
public function update(UpdateCategoryRequest $request, Tenant $tenant, Category $categoria): CategoryResource
|
||||||
{
|
{
|
||||||
|
$categoria = $this->resolveScopedCategory($tenant, $categoria);
|
||||||
|
$categoria->update($request->validated());
|
||||||
|
|
||||||
|
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Tenant $tenant, Category $categoria): Response
|
||||||
|
{
|
||||||
|
$categoria = $this->resolveScopedCategory($tenant, $categoria);
|
||||||
$categoria->delete();
|
$categoria->delete();
|
||||||
|
|
||||||
return response()->noContent();
|
return response()->noContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function resolveScopedCategory(Tenant $tenant, Category $category): Category
|
||||||
|
{
|
||||||
|
if ($category->tenant_code !== $tenant->codigo) {
|
||||||
|
throw new NotFoundHttpException('Category not found for tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $category;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
103
app/Domains/Catalog/Controllers/ProductAttributeController.php
Normal file
103
app/Domains/Catalog/Controllers/ProductAttributeController.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,48 +6,60 @@ use App\Domains\Catalog\Models\Product;
|
|||||||
use App\Domains\Catalog\Requests\StoreProductRequest;
|
use App\Domains\Catalog\Requests\StoreProductRequest;
|
||||||
use App\Domains\Catalog\Requests\UpdateProductRequest;
|
use App\Domains\Catalog\Requests\UpdateProductRequest;
|
||||||
use App\Domains\Catalog\Resources\ProductResource;
|
use App\Domains\Catalog\Resources\ProductResource;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
class ProductController extends Controller
|
class ProductController extends Controller
|
||||||
{
|
{
|
||||||
public function index(): JsonResponse
|
public function index(Tenant $tenant): JsonResponse
|
||||||
{
|
{
|
||||||
return ProductResource::collection(Product::query()->latest()->get())->response();
|
return ProductResource::collection(
|
||||||
|
Product::query()->where('tenant_codigo', $tenant->codigo)->latest()->get()
|
||||||
|
)->response();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(StoreProductRequest $request): JsonResponse
|
public function store(StoreProductRequest $request, Tenant $tenant): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$product = Product::query()->create([
|
||||||
$props = $validated['props'] ?? [];
|
...$request->validated(),
|
||||||
unset($validated['props']);
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
$product = Product::createWithProps($validated, $props);
|
|
||||||
|
|
||||||
return ProductResource::make($product)->response()->setStatusCode(201);
|
return ProductResource::make($product)->response()->setStatusCode(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Product $producto): ProductResource
|
public function show(Tenant $tenant, Product $producto): ProductResource
|
||||||
{
|
{
|
||||||
return ProductResource::make($producto);
|
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||||
}
|
|
||||||
|
|
||||||
public function update(UpdateProductRequest $request, Product $producto): ProductResource
|
|
||||||
{
|
|
||||||
$validated = $request->validated();
|
|
||||||
$props = $validated['props'] ?? [];
|
|
||||||
unset($validated['props']);
|
|
||||||
|
|
||||||
$producto = $producto->updateWithProps($validated, $props);
|
|
||||||
|
|
||||||
return ProductResource::make($producto);
|
return ProductResource::make($producto);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Product $producto): Response
|
public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto): ProductResource
|
||||||
{
|
{
|
||||||
|
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||||
|
$producto->update($request->validated());
|
||||||
|
|
||||||
|
return ProductResource::make($producto);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Tenant $tenant, Product $producto): Response
|
||||||
|
{
|
||||||
|
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||||
$producto->delete();
|
$producto->delete();
|
||||||
|
|
||||||
return response()->noContent();
|
return response()->noContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function resolveScopedProduct(Tenant $tenant, Product $product): Product
|
||||||
|
{
|
||||||
|
if ($product->tenant_codigo !== $tenant->codigo) {
|
||||||
|
throw new NotFoundHttpException('Product not found for tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $product;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
109
app/Domains/Catalog/Controllers/ProductVariantController.php
Normal file
109
app/Domains/Catalog/Controllers/ProductVariantController.php
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\Product;
|
||||||
|
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\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 ProductVariantController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Tenant $tenant): JsonResponse
|
||||||
|
{
|
||||||
|
$query = ProductVariant::query()
|
||||||
|
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $tenant->codigo))
|
||||||
|
->with(['product', 'definitions.attribute'])
|
||||||
|
->latest();
|
||||||
|
|
||||||
|
return ProductVariantResource::collection($query->get())->response();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreProductVariantRequest $request, Tenant $tenant): 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']);
|
||||||
|
|
||||||
|
/** @var ProductVariant $variant */
|
||||||
|
$variant = $product->variants()->create($validated);
|
||||||
|
$variant->definitions()->createMany($definitions);
|
||||||
|
|
||||||
|
return $variant->load(['product', 'definitions.attribute']);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ProductVariantResource::make($variant)->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Tenant $tenant, ProductVariant $productVariant): ProductVariantResource
|
||||||
|
{
|
||||||
|
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
|
||||||
|
|
||||||
|
return ProductVariantResource::make($productVariant->load(['product', 'definitions.attribute']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateProductVariantRequest $request, Tenant $tenant, ProductVariant $productVariant): ProductVariantResource
|
||||||
|
{
|
||||||
|
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
|
||||||
|
|
||||||
|
$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']);
|
||||||
|
|
||||||
|
$productVariant->update($validated);
|
||||||
|
$productVariant->definitions()->delete();
|
||||||
|
$productVariant->definitions()->createMany($definitions);
|
||||||
|
|
||||||
|
return $productVariant->load(['product', 'definitions.attribute']);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ProductVariantResource::make($productVariant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Tenant $tenant, ProductVariant $productVariant): Response
|
||||||
|
{
|
||||||
|
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
|
||||||
|
$productVariant->delete();
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveScopedVariant(Tenant $tenant, ProductVariant $variant): ProductVariant
|
||||||
|
{
|
||||||
|
$variant->loadMissing('product');
|
||||||
|
|
||||||
|
if ($variant->product === null || $variant->product->tenant_codigo !== $tenant->codigo) {
|
||||||
|
throw new NotFoundHttpException('Product variant not found for tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $variant;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveTenantProduct(Tenant $tenant, int $productId): Product
|
||||||
|
{
|
||||||
|
$product = Product::query()
|
||||||
|
->whereKey($productId)
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($product === null) {
|
||||||
|
throw new NotFoundHttpException('Product not found for tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $product;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
use App\Domains\Prop\Concerns\Propable;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -17,7 +16,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
class Brand extends Model
|
class Brand extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
use Propable;
|
|
||||||
|
|
||||||
protected $table = 'brands';
|
protected $table = 'brands';
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
use App\Domains\Prop\Concerns\Propable;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -18,7 +17,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
class Category extends Model
|
class Category extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
use Propable;
|
|
||||||
|
|
||||||
protected $table = 'categorias';
|
protected $table = 'categorias';
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
use App\Domains\Prop\Concerns\Propable;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'tenant_codigo',
|
'tenant_codigo',
|
||||||
@@ -20,7 +20,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
class Product extends Model
|
class Product extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
use Propable;
|
|
||||||
|
|
||||||
protected $table = 'productos';
|
protected $table = 'productos';
|
||||||
|
|
||||||
@@ -39,4 +38,12 @@ class Product extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<ProductVariant, $this>
|
||||||
|
*/
|
||||||
|
public function variants(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ProductVariant::class, 'producto_id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
62
app/Domains/Catalog/Models/ProductAttribute.php
Normal file
62
app/Domains/Catalog/Models/ProductAttribute.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Support\ProductAttributeType;
|
||||||
|
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\HasMany;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'tenant_codigo',
|
||||||
|
'codigo',
|
||||||
|
'nombre',
|
||||||
|
'is_required',
|
||||||
|
'metadata_schema',
|
||||||
|
'type',
|
||||||
|
])]
|
||||||
|
class ProductAttribute extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'productos_attributes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_required' => 'boolean',
|
||||||
|
'metadata_schema' => 'array',
|
||||||
|
'type' => ProductAttributeType::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Tenant, $this>
|
||||||
|
*/
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<ProductAttributeOption, $this>
|
||||||
|
*/
|
||||||
|
public function options(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ProductAttributeOption::class, 'attribute_id')->orderBy('sort_order');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<ProductVariantDefinition, $this>
|
||||||
|
*/
|
||||||
|
public function variantDefinitions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ProductVariantDefinition::class, 'attribute_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Domains/Catalog/Models/ProductAttributeOption.php
Normal file
40
app/Domains/Catalog/Models/ProductAttributeOption.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\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([
|
||||||
|
'attribute_id',
|
||||||
|
'label',
|
||||||
|
'sort_order',
|
||||||
|
'metadata',
|
||||||
|
])]
|
||||||
|
class ProductAttributeOption extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'attribute_options';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
'metadata' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<ProductAttribute, $this>
|
||||||
|
*/
|
||||||
|
public function attribute(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProductAttribute::class, 'attribute_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Domains/Catalog/Models/ProductVariant.php
Normal file
49
app/Domains/Catalog/Models/ProductVariant.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
|
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([
|
||||||
|
'producto_id',
|
||||||
|
'slug',
|
||||||
|
'nombre',
|
||||||
|
'stock',
|
||||||
|
'descripcion',
|
||||||
|
'precio',
|
||||||
|
])]
|
||||||
|
class ProductVariant extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'productos_variantes';
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'producto_id' => 'integer',
|
||||||
|
'stock' => 'integer',
|
||||||
|
'precio' => 'decimal:2',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Product, $this>
|
||||||
|
*/
|
||||||
|
public function product(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Product::class, 'producto_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<ProductVariantDefinition, $this>
|
||||||
|
*/
|
||||||
|
public function definitions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ProductVariantDefinition::class, 'producto_variante_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Domains/Catalog/Models/ProductVariantDefinition.php
Normal file
36
app/Domains/Catalog/Models/ProductVariantDefinition.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\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([
|
||||||
|
'producto_variante_id',
|
||||||
|
'attribute_id',
|
||||||
|
'value',
|
||||||
|
])]
|
||||||
|
class ProductVariantDefinition extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'productos_variantes_definiciones';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<ProductVariant, $this>
|
||||||
|
*/
|
||||||
|
public function variant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<ProductAttribute, $this>
|
||||||
|
*/
|
||||||
|
public function attribute(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProductAttribute::class, 'attribute_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
namespace App\Domains\Catalog\Requests;
|
||||||
|
|
||||||
use App\Domains\Prop\Support\PropRules;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class StoreBrandRequest extends FormRequest
|
class StoreBrandRequest extends FormRequest
|
||||||
@@ -18,10 +17,8 @@ class StoreBrandRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
|
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
'descripcion' => ['nullable', 'string'],
|
'descripcion' => ['nullable', 'string'],
|
||||||
...PropRules::sync(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
namespace App\Domains\Catalog\Requests;
|
||||||
|
|
||||||
use App\Domains\Prop\Support\PropRules;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class StoreCategoryRequest extends FormRequest
|
class StoreCategoryRequest extends FormRequest
|
||||||
@@ -18,10 +17,8 @@ class StoreCategoryRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'tenant_code' => ['required', 'string', 'exists:tenants,codigo'],
|
|
||||||
'categoria_id' => ['nullable', 'integer', 'exists:categorias,id'],
|
'categoria_id' => ['nullable', 'integer', 'exists:categorias,id'],
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
...PropRules::sync(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Support\ProductAttributeType;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
class StoreProductAttributeRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'codigo' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('productos_attributes', '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)],
|
||||||
|
'options' => [
|
||||||
|
Rule::requiredIf(fn (): bool => in_array($this->input('type'), [
|
||||||
|
ProductAttributeType::Select->value,
|
||||||
|
ProductAttributeType::Multiselect->value,
|
||||||
|
], true)),
|
||||||
|
Rule::prohibitedIf(fn (): bool => ! in_array($this->input('type'), [
|
||||||
|
ProductAttributeType::Select->value,
|
||||||
|
ProductAttributeType::Multiselect->value,
|
||||||
|
], true)),
|
||||||
|
'array',
|
||||||
|
],
|
||||||
|
'options.*.label' => ['required', 'string', 'max:255'],
|
||||||
|
'options.*.sort_order' => ['sometimes', 'integer'],
|
||||||
|
'options.*.metadata' => ['nullable', 'array'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withValidator(Validator $validator): void
|
||||||
|
{
|
||||||
|
$validator->after(function (Validator $validator): void {
|
||||||
|
$type = $this->input('type');
|
||||||
|
$supportsOptions = in_array($type, [ProductAttributeType::Select->value, ProductAttributeType::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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $supportsOptions && $this->filled('options')) {
|
||||||
|
$validator->errors()->add('options', 'Options are only allowed for select and multiselect attributes.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
namespace App\Domains\Catalog\Requests;
|
||||||
|
|
||||||
use App\Domains\Prop\Support\PropRules;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@@ -19,13 +18,11 @@ class StoreProductRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
|
|
||||||
'categoria_id' => ['required', 'integer'],
|
'categoria_id' => ['required', 'integer'],
|
||||||
'slug' => ['required', 'string', 'max:255', Rule::unique('productos', 'slug')],
|
'slug' => ['required', 'string', 'max:255', Rule::unique('productos', 'slug')],
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
'descripcion' => ['nullable', 'string'],
|
'descripcion' => ['nullable', 'string'],
|
||||||
'precio' => ['required', 'numeric', 'min:0'],
|
'precio' => ['required', 'numeric', 'min:0'],
|
||||||
...PropRules::sync(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
app/Domains/Catalog/Requests/StoreProductVariantRequest.php
Normal file
52
app/Domains/Catalog/Requests/StoreProductVariantRequest.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreProductVariantRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'producto_id' => [
|
||||||
|
'required',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('productos', 'id')->where(
|
||||||
|
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'slug' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('productos_variantes', 'slug')->where(
|
||||||
|
fn ($query) => $query->where('producto_id', $this->input('producto_id'))
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'nombre' => ['nullable', 'string', 'max:255'],
|
||||||
|
'stock' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
'descripcion' => ['nullable', 'string'],
|
||||||
|
'precio' => ['required', 'numeric', 'min:0'],
|
||||||
|
'definitions' => ['sometimes', 'array'],
|
||||||
|
'definitions.*.attribute_id' => [
|
||||||
|
'required',
|
||||||
|
'integer',
|
||||||
|
'distinct',
|
||||||
|
Rule::exists('productos_attributes', 'id')->where(
|
||||||
|
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'definitions.*.value' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
namespace App\Domains\Catalog\Requests;
|
||||||
|
|
||||||
use App\Domains\Prop\Support\PropRules;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class UpdateBrandRequest extends FormRequest
|
class UpdateBrandRequest extends FormRequest
|
||||||
@@ -18,10 +17,8 @@ class UpdateBrandRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
|
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
'descripcion' => ['nullable', 'string'],
|
'descripcion' => ['nullable', 'string'],
|
||||||
...PropRules::sync(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Domains\Catalog\Requests;
|
namespace App\Domains\Catalog\Requests;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\Category;
|
use App\Domains\Catalog\Models\Category;
|
||||||
use App\Domains\Prop\Support\PropRules;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@@ -23,7 +22,6 @@ class UpdateCategoryRequest extends FormRequest
|
|||||||
$category = $this->route('categoria');
|
$category = $this->route('categoria');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tenant_code' => ['required', 'string', 'exists:tenants,codigo'],
|
|
||||||
'categoria_id' => [
|
'categoria_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
@@ -31,7 +29,6 @@ class UpdateCategoryRequest extends FormRequest
|
|||||||
Rule::notIn([$category?->id]),
|
Rule::notIn([$category?->id]),
|
||||||
],
|
],
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
...PropRules::sync(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\ProductAttribute;
|
||||||
|
use App\Domains\Catalog\Support\ProductAttributeType;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
class UpdateProductAttributeRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
/** @var ProductAttribute|null $attribute */
|
||||||
|
$attribute = $this->route('productAttribute');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'codigo' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('productos_attributes', '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)],
|
||||||
|
'options' => ['sometimes', 'array'],
|
||||||
|
'options.*.label' => ['required', 'string', 'max:255'],
|
||||||
|
'options.*.sort_order' => ['sometimes', 'integer'],
|
||||||
|
'options.*.metadata' => ['nullable', 'array'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withValidator(Validator $validator): void
|
||||||
|
{
|
||||||
|
$validator->after(function (Validator $validator): void {
|
||||||
|
$type = $this->input('type');
|
||||||
|
$supportsOptions = in_array($type, [ProductAttributeType::Select->value, ProductAttributeType::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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $supportsOptions && $this->filled('options')) {
|
||||||
|
$validator->errors()->add('options', 'Options are only allowed for select and multiselect attributes.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Domains\Catalog\Requests;
|
namespace App\Domains\Catalog\Requests;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\Product;
|
use App\Domains\Catalog\Models\Product;
|
||||||
use App\Domains\Prop\Support\PropRules;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@@ -23,7 +22,6 @@ class UpdateProductRequest extends FormRequest
|
|||||||
$product = $this->route('producto');
|
$product = $this->route('producto');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
|
|
||||||
'categoria_id' => ['required', 'integer'],
|
'categoria_id' => ['required', 'integer'],
|
||||||
'slug' => [
|
'slug' => [
|
||||||
'required',
|
'required',
|
||||||
@@ -34,7 +32,6 @@ class UpdateProductRequest extends FormRequest
|
|||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
'descripcion' => ['nullable', 'string'],
|
'descripcion' => ['nullable', 'string'],
|
||||||
'precio' => ['required', 'numeric', 'min:0'],
|
'precio' => ['required', 'numeric', 'min:0'],
|
||||||
...PropRules::sync(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
56
app/Domains/Catalog/Requests/UpdateProductVariantRequest.php
Normal file
56
app/Domains/Catalog/Requests/UpdateProductVariantRequest.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\ProductVariant;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateProductVariantRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
/** @var ProductVariant|null $productVariant */
|
||||||
|
$productVariant = $this->route('productVariant');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'producto_id' => [
|
||||||
|
'required',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('productos', 'id')->where(
|
||||||
|
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'slug' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('productos_variantes', 'slug')
|
||||||
|
->ignore($productVariant?->id)
|
||||||
|
->where(fn ($query) => $query->where('producto_id', $this->input('producto_id'))),
|
||||||
|
],
|
||||||
|
'nombre' => ['nullable', 'string', 'max:255'],
|
||||||
|
'stock' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
'descripcion' => ['nullable', 'string'],
|
||||||
|
'precio' => ['required', 'numeric', 'min:0'],
|
||||||
|
'definitions' => ['sometimes', 'array'],
|
||||||
|
'definitions.*.attribute_id' => [
|
||||||
|
'required',
|
||||||
|
'integer',
|
||||||
|
'distinct',
|
||||||
|
Rule::exists('productos_attributes', 'id')->where(
|
||||||
|
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'definitions.*.value' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin \App\Domains\Catalog\Models\ProductAttributeOption
|
||||||
|
*/
|
||||||
|
class ProductAttributeOptionResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'label' => $this->label,
|
||||||
|
'sort_order' => $this->sort_order,
|
||||||
|
'metadata' => $this->metadata,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Domains/Catalog/Resources/ProductAttributeResource.php
Normal file
29
app/Domains/Catalog/Resources/ProductAttributeResource.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin \App\Domains\Catalog\Models\ProductAttribute
|
||||||
|
*/
|
||||||
|
class ProductAttributeResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'tenant_codigo' => $this->tenant_codigo,
|
||||||
|
'codigo' => $this->codigo,
|
||||||
|
'nombre' => $this->nombre,
|
||||||
|
'is_required' => $this->is_required,
|
||||||
|
'metadata_schema' => $this->metadata_schema,
|
||||||
|
'type' => $this->type?->value ?? $this->type,
|
||||||
|
'options' => ProductAttributeOptionResource::collection($this->whenLoaded('options')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin \App\Domains\Catalog\Models\ProductVariantDefinition
|
||||||
|
*/
|
||||||
|
class ProductVariantDefinitionResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'producto_variante_id' => $this->producto_variante_id,
|
||||||
|
'attribute_id' => $this->attribute_id,
|
||||||
|
'value' => $this->value,
|
||||||
|
'attribute' => ProductAttributeResource::make($this->whenLoaded('attribute')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Domains/Catalog/Resources/ProductVariantResource.php
Normal file
32
app/Domains/Catalog/Resources/ProductVariantResource.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin \App\Domains\Catalog\Models\ProductVariant
|
||||||
|
*/
|
||||||
|
class ProductVariantResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'producto_id' => $this->producto_id,
|
||||||
|
'slug' => $this->slug,
|
||||||
|
'nombre' => $this->nombre,
|
||||||
|
'stock' => $this->stock,
|
||||||
|
'descripcion' => $this->descripcion,
|
||||||
|
'precio' => $this->precio,
|
||||||
|
'product' => ProductResource::make($this->whenLoaded('product')),
|
||||||
|
'definitions' => ProductVariantDefinitionResource::collection($this->whenLoaded('definitions')),
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'updated_at' => $this->updated_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Domains/Catalog/Support/ProductAttributeType.php
Normal file
16
app/Domains/Catalog/Support/ProductAttributeType.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,16 @@
|
|||||||
use App\Domains\Catalog\Controllers\BrandController;
|
use App\Domains\Catalog\Controllers\BrandController;
|
||||||
use App\Domains\Catalog\Controllers\CategoryController;
|
use App\Domains\Catalog\Controllers\CategoryController;
|
||||||
use App\Domains\Catalog\Controllers\ProductController;
|
use App\Domains\Catalog\Controllers\ProductController;
|
||||||
|
use App\Domains\Catalog\Controllers\ProductAttributeController;
|
||||||
|
use App\Domains\Catalog\Controllers\ProductVariantController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::apiResource('marcas', BrandController::class)->parameters(['marcas' => 'marca']);
|
Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
||||||
Route::apiResource('categorias', CategoryController::class)->parameters(['categorias' => 'categoria']);
|
Route::apiResource('marcas', BrandController::class)->parameters(['marcas' => 'marca']);
|
||||||
Route::apiResource('productos', ProductController::class);
|
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('product-variants', ProductVariantController::class)
|
||||||
|
->parameters(['product-variants' => 'productVariant']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Prop\Concerns;
|
|
||||||
|
|
||||||
use App\Domains\Prop\Models\ModelPropValue;
|
|
||||||
use App\Domains\Prop\Models\Prop;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use LogicException;
|
|
||||||
|
|
||||||
trait Propable
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $attributes
|
|
||||||
* @param array<string, mixed> $props
|
|
||||||
* @return static
|
|
||||||
*/
|
|
||||||
public static function createWithProps(array $attributes, array $props = []): static
|
|
||||||
{
|
|
||||||
/** @var static $model */
|
|
||||||
$model = DB::transaction(function () use ($attributes, $props): Model {
|
|
||||||
/** @var static $createdModel */
|
|
||||||
$createdModel = static::query()->create($attributes);
|
|
||||||
$createdModel->syncPropValues($props);
|
|
||||||
|
|
||||||
return $createdModel;
|
|
||||||
});
|
|
||||||
|
|
||||||
return $model;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $attributes
|
|
||||||
*/
|
|
||||||
public static function createProp(array $attributes): Prop
|
|
||||||
{
|
|
||||||
return Prop::query()->create([
|
|
||||||
...$attributes,
|
|
||||||
'propable_type' => static::class,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Builder<Prop>
|
|
||||||
*/
|
|
||||||
public function props(): Builder
|
|
||||||
{
|
|
||||||
return Prop::query()->forModel(static::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<ModelPropValue, $this>
|
|
||||||
*/
|
|
||||||
public function propValues(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(ModelPropValue::class, 'valuable_id')
|
|
||||||
->whereHas('prop', fn (Builder $query) => $query->forModel(static::class));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPropValue(Prop|string $prop): ?ModelPropValue
|
|
||||||
{
|
|
||||||
$resolvedProp = $this->resolveProp($prop);
|
|
||||||
|
|
||||||
return $this->propValues()
|
|
||||||
->where('prop_id', $resolvedProp->getKey())
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setPropValue(Prop|string $prop, mixed $value): ModelPropValue
|
|
||||||
{
|
|
||||||
$this->ensurePropValuesCanBeManaged();
|
|
||||||
|
|
||||||
$resolvedProp = $this->resolveProp($prop);
|
|
||||||
|
|
||||||
return $this->propValues()->updateOrCreate(
|
|
||||||
['prop_id' => $resolvedProp->getKey()],
|
|
||||||
['value' => $value],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $props
|
|
||||||
*/
|
|
||||||
public function syncPropValues(array $props): void
|
|
||||||
{
|
|
||||||
foreach ($props as $codigo => $value) {
|
|
||||||
$this->setPropValue($codigo, $value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $attributes
|
|
||||||
* @param array<string, mixed> $props
|
|
||||||
*/
|
|
||||||
public function updateWithProps(array $attributes, array $props = []): static
|
|
||||||
{
|
|
||||||
DB::transaction(function () use ($attributes, $props): void {
|
|
||||||
$this->update($attributes);
|
|
||||||
$this->syncPropValues($props);
|
|
||||||
});
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deletePropValue(Prop|string $prop): bool
|
|
||||||
{
|
|
||||||
$resolvedProp = $this->resolveProp($prop);
|
|
||||||
|
|
||||||
return $this->propValues()
|
|
||||||
->where('prop_id', $resolvedProp->getKey())
|
|
||||||
->delete() > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function ensurePropValuesCanBeManaged(): void
|
|
||||||
{
|
|
||||||
if (! $this->exists) {
|
|
||||||
throw new LogicException('Cannot manage prop values for an unsaved model.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function resolveProp(Prop|string $prop): Prop
|
|
||||||
{
|
|
||||||
if ($prop instanceof Prop) {
|
|
||||||
if ($prop->propable_type !== static::class) {
|
|
||||||
throw new LogicException('The given prop does not belong to this model type.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $prop;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Prop::query()
|
|
||||||
->forModel(static::class)
|
|
||||||
->where('codigo', $prop)
|
|
||||||
->firstOrFail();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Prop\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\Prop\Models\Prop;
|
|
||||||
use App\Domains\Prop\Requests\StorePropRequest;
|
|
||||||
use App\Domains\Prop\Requests\UpdatePropRequest;
|
|
||||||
use App\Domains\Prop\Resources\PropResource;
|
|
||||||
use App\Domains\Prop\Support\PropableModels;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
|
|
||||||
class PropController extends Controller
|
|
||||||
{
|
|
||||||
public function index(string $propableType): JsonResponse
|
|
||||||
{
|
|
||||||
$modelClass = PropableModels::resolveOrFail($propableType);
|
|
||||||
|
|
||||||
return PropResource::collection(
|
|
||||||
Prop::query()->forModel($modelClass)->latest()->get()
|
|
||||||
)->response();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(StorePropRequest $request, string $propableType): JsonResponse
|
|
||||||
{
|
|
||||||
$modelClass = PropableModels::resolveOrFail($propableType);
|
|
||||||
|
|
||||||
/** @var Prop $prop */
|
|
||||||
$prop = $modelClass::createProp($request->validated());
|
|
||||||
|
|
||||||
return PropResource::make($prop)->response()->setStatusCode(201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(string $propableType, Prop $prop): PropResource
|
|
||||||
{
|
|
||||||
$modelClass = PropableModels::resolveOrFail($propableType);
|
|
||||||
$prop = $this->resolveScopedProp($prop, $modelClass);
|
|
||||||
|
|
||||||
return PropResource::make($prop);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(UpdatePropRequest $request, string $propableType, Prop $prop): PropResource
|
|
||||||
{
|
|
||||||
$modelClass = PropableModels::resolveOrFail($propableType);
|
|
||||||
$prop = $this->resolveScopedProp($prop, $modelClass);
|
|
||||||
$prop->update($request->validated());
|
|
||||||
|
|
||||||
return PropResource::make($prop);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(string $propableType, Prop $prop): Response
|
|
||||||
{
|
|
||||||
$modelClass = PropableModels::resolveOrFail($propableType);
|
|
||||||
$prop = $this->resolveScopedProp($prop, $modelClass);
|
|
||||||
$prop->delete();
|
|
||||||
|
|
||||||
return response()->noContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function resolveScopedProp(Prop $prop, string $modelClass): Prop
|
|
||||||
{
|
|
||||||
if ($prop->propable_type !== $modelClass) {
|
|
||||||
throw new NotFoundHttpException('Prop not found for the given model type.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $prop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Prop\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([
|
|
||||||
'valuable_id',
|
|
||||||
'prop_id',
|
|
||||||
'value',
|
|
||||||
])]
|
|
||||||
class ModelPropValue extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'model_prop_values';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Prop, $this>
|
|
||||||
*/
|
|
||||||
public function prop(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Prop::class, 'prop_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Prop\Models;
|
|
||||||
|
|
||||||
use App\Domains\Prop\Support\PropDataType;
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
#[Fillable([
|
|
||||||
'propable_type',
|
|
||||||
'codigo',
|
|
||||||
'nombre',
|
|
||||||
'is_required',
|
|
||||||
'data_type',
|
|
||||||
])]
|
|
||||||
class Prop extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'is_required' => 'boolean',
|
|
||||||
'data_type' => PropDataType::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
*/
|
|
||||||
public function scopeForModel(Builder $query, Model|string $model): void
|
|
||||||
{
|
|
||||||
$query->where('propable_type', is_string($model) ? $model : $model::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<PropOption, $this>
|
|
||||||
*/
|
|
||||||
public function options(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(PropOption::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<ModelPropValue, $this>
|
|
||||||
*/
|
|
||||||
public function modelValues(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(ModelPropValue::class, 'prop_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Prop\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([
|
|
||||||
'prop_id',
|
|
||||||
'value',
|
|
||||||
'label',
|
|
||||||
'sort_order',
|
|
||||||
])]
|
|
||||||
class PropOption extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Prop, $this>
|
|
||||||
*/
|
|
||||||
public function prop(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Prop::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Prop\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Prop\Support\PropDataType;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class StorePropRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('props', 'codigo')],
|
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
|
||||||
'is_required' => ['sometimes', 'boolean'],
|
|
||||||
'data_type' => ['required', Rule::enum(PropDataType::class)],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Prop\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Prop\Models\Prop;
|
|
||||||
use App\Domains\Prop\Support\PropDataType;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class UpdatePropRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
/** @var Prop|null $prop */
|
|
||||||
$prop = $this->route('prop');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('props', 'codigo')->ignore($prop?->id)],
|
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
|
||||||
'is_required' => ['sometimes', 'boolean'],
|
|
||||||
'data_type' => ['required', Rule::enum(PropDataType::class)],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Prop\Resources;
|
|
||||||
|
|
||||||
use App\Domains\Prop\Concerns\Propable;
|
|
||||||
use App\Domains\Prop\Models\ModelPropValue;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @mixin ModelPropValue
|
|
||||||
*/
|
|
||||||
class PropValueResource extends JsonResource
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param iterable<ModelPropValue> $propValues
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function collapsed(iterable $propValues): array
|
|
||||||
{
|
|
||||||
return collect($propValues)
|
|
||||||
->mapWithKeys(fn (ModelPropValue $propValue) => [
|
|
||||||
$propValue->prop->codigo => $propValue->value,
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function collapsedFromPropable(object $model): array
|
|
||||||
{
|
|
||||||
if (! in_array(Propable::class, class_uses_recursive($model), true)) {
|
|
||||||
throw new InvalidArgumentException('The given model must use the Propable trait.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return static::collapsed(
|
|
||||||
$model->propValues()->with('prop')->get(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [$this->prop->codigo => $this->value];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Prop\Support;
|
|
||||||
|
|
||||||
enum PropDataType: string
|
|
||||||
{
|
|
||||||
case String = 'string';
|
|
||||||
case Integer = 'integer';
|
|
||||||
case Decimal = 'decimal';
|
|
||||||
case Boolean = 'boolean';
|
|
||||||
case Date = 'date';
|
|
||||||
case DateTime = 'datetime';
|
|
||||||
case Json = 'json';
|
|
||||||
case Select = 'select';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public static function values(): array
|
|
||||||
{
|
|
||||||
return array_map(
|
|
||||||
static fn (self $type): string => $type->value,
|
|
||||||
self::cases(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Prop\Support;
|
|
||||||
|
|
||||||
class PropRules
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function sync(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'props' => ['sometimes', 'array'],
|
|
||||||
'props.*' => ['nullable'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Prop\Support;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\Brand;
|
|
||||||
use App\Domains\Catalog\Models\Category;
|
|
||||||
use App\Domains\Catalog\Models\Product;
|
|
||||||
use App\Domains\Prop\Concerns\Propable;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
|
|
||||||
class PropableModels
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, class-string<Model>>
|
|
||||||
*/
|
|
||||||
public static function map(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'brand' => Brand::class,
|
|
||||||
'category' => Category::class,
|
|
||||||
'product' => Product::class,
|
|
||||||
'tenant' => Tenant::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return class-string<Model>
|
|
||||||
*/
|
|
||||||
public static function resolveOrFail(string $alias): string
|
|
||||||
{
|
|
||||||
$modelClass = Arr::get(static::map(), $alias);
|
|
||||||
|
|
||||||
if (! is_string($modelClass) || ! is_subclass_of($modelClass, Model::class) || ! in_array(Propable::class, class_uses_recursive($modelClass), true)) {
|
|
||||||
throw new NotFoundHttpException('Propable model not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $modelClass;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function aliasFor(string $modelClass): ?string
|
|
||||||
{
|
|
||||||
return array_search($modelClass, static::map(), true) ?: null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Domains\Prop\Controllers\PropController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
|
|
||||||
Route::apiResource('prop-models.props', PropController::class)
|
|
||||||
->parameters([
|
|
||||||
'prop-models' => 'propableType',
|
|
||||||
'props' => 'prop',
|
|
||||||
]);
|
|
||||||
129
app/Domains/Purchase/Controllers/PurchaseController.php
Normal file
129
app/Domains/Purchase/Controllers/PurchaseController.php
Normal 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()
|
||||||
|
->get()
|
||||||
|
)->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Domains/Purchase/Models/Purchase.php
Normal file
56
app/Domains/Purchase/Models/Purchase.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Domains/Purchase/Models/PurchaseItem.php
Normal file
54
app/Domains/Purchase/Models/PurchaseItem.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Domains/Purchase/Requests/StorePurchaseRequest.php
Normal file
29
app/Domains/Purchase/Requests/StorePurchaseRequest.php
Normal 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Domains/Purchase/Resources/PurchaseItemResource.php
Normal file
49
app/Domains/Purchase/Resources/PurchaseItemResource.php
Normal 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, '.', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Domains/Purchase/Resources/PurchaseResource.php
Normal file
49
app/Domains/Purchase/Resources/PurchaseResource.php
Normal 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, '.', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app/Domains/Purchase/routes/api.php
Normal file
8
app/Domains/Purchase/routes/api.php
Normal 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']);
|
||||||
|
});
|
||||||
@@ -21,7 +21,6 @@ class TenantController extends Controller
|
|||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$props = $validated['props'] ?? [];
|
$props = $validated['props'] ?? [];
|
||||||
unset($validated['props']);
|
|
||||||
|
|
||||||
$tenant = Tenant::createWithProps($validated, $props);
|
$tenant = Tenant::createWithProps($validated, $props);
|
||||||
|
|
||||||
@@ -37,9 +36,8 @@ class TenantController extends Controller
|
|||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$props = $validated['props'] ?? [];
|
$props = $validated['props'] ?? [];
|
||||||
unset($validated['props']);
|
|
||||||
|
|
||||||
$tenant = $tenant->updateWithProps($validated, $props);
|
$tenant->updateWithProps($validated, $props);
|
||||||
|
|
||||||
return TenantResource::make($tenant);
|
return TenantResource::make($tenant);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,60 +2,44 @@
|
|||||||
|
|
||||||
namespace App\Domains\Tenant\Controllers;
|
namespace App\Domains\Tenant\Controllers;
|
||||||
|
|
||||||
use App\Domains\Prop\Models\Prop;
|
use App\Domains\Tenant\Models\TenantProp;
|
||||||
use App\Domains\Prop\Resources\PropResource;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use App\Domains\Tenant\Requests\StoreTenantPropRequest;
|
use App\Domains\Tenant\Requests\StoreTenantPropRequest;
|
||||||
use App\Domains\Tenant\Requests\UpdateTenantPropRequest;
|
use App\Domains\Tenant\Requests\UpdateTenantPropRequest;
|
||||||
|
use App\Domains\Tenant\Resources\TenantPropResource;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
|
|
||||||
class TenantPropController extends Controller
|
class TenantPropController extends Controller
|
||||||
{
|
{
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
return PropResource::collection(
|
return TenantPropResource::collection(TenantProp::query()->latest()->get())->response();
|
||||||
app(Tenant::class)->props()->latest()->get()
|
|
||||||
)->response();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(StoreTenantPropRequest $request): JsonResponse
|
public function store(StoreTenantPropRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var Prop $prop */
|
$tenantProp = TenantProp::query()->create($request->validated());
|
||||||
$prop = Tenant::createProp($request->validated());
|
|
||||||
|
|
||||||
return PropResource::make($prop)->response()->setStatusCode(201);
|
return TenantPropResource::make($tenantProp)->response()->setStatusCode(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Prop $tenantProp): PropResource
|
public function show(TenantProp $tenantProp): TenantPropResource
|
||||||
{
|
{
|
||||||
return PropResource::make($this->resolveScopedProp($tenantProp));
|
return TenantPropResource::make($tenantProp);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(UpdateTenantPropRequest $request, Prop $tenantProp): PropResource
|
public function update(UpdateTenantPropRequest $request, TenantProp $tenantProp): TenantPropResource
|
||||||
{
|
{
|
||||||
$tenantProp = $this->resolveScopedProp($tenantProp);
|
|
||||||
$tenantProp->update($request->validated());
|
$tenantProp->update($request->validated());
|
||||||
|
|
||||||
return PropResource::make($tenantProp);
|
return TenantPropResource::make($tenantProp);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Prop $tenantProp): Response
|
public function destroy(TenantProp $tenantProp): Response
|
||||||
{
|
{
|
||||||
$tenantProp = $this->resolveScopedProp($tenantProp);
|
|
||||||
$tenantProp->delete();
|
$tenantProp->delete();
|
||||||
|
|
||||||
return response()->noContent();
|
return response()->noContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function resolveScopedProp(Prop $prop): Prop
|
|
||||||
{
|
|
||||||
if ($prop->propable_type !== Tenant::class) {
|
|
||||||
throw new NotFoundHttpException('Tenant prop not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $prop;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Domains\Tenant\Models;
|
namespace App\Domains\Tenant\Models;
|
||||||
|
|
||||||
use App\Domains\Prop\Concerns\Propable;
|
|
||||||
use App\Domains\Catalog\Models\Product;
|
use App\Domains\Catalog\Models\Product;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'codigo',
|
'codigo',
|
||||||
@@ -17,7 +18,27 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
class Tenant extends Model
|
class Tenant extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
use Propable;
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
* @param array<string, mixed> $props
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public static function createWithProps(array $attributes, array $props = []): static
|
||||||
|
{
|
||||||
|
unset($attributes['props']);
|
||||||
|
|
||||||
|
/** @var static $tenant */
|
||||||
|
$tenant = DB::transaction(function () use ($attributes, $props): self {
|
||||||
|
/** @var static $createdTenant */
|
||||||
|
$createdTenant = static::query()->create($attributes);
|
||||||
|
$createdTenant->syncPropValues($props);
|
||||||
|
|
||||||
|
return $createdTenant;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return HasMany<Product, $this>
|
* @return HasMany<Product, $this>
|
||||||
@@ -26,4 +47,78 @@ class Tenant extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(Product::class, 'tenant_codigo', 'codigo');
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
app/Domains/Tenant/Models/TenantProp.php
Normal file
42
app/Domains/Tenant/Models/TenantProp.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Domains/Tenant/Models/TenantPropValue.php
Normal file
36
app/Domains/Tenant/Models/TenantPropValue.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Tenant\Requests;
|
namespace App\Domains\Tenant\Requests;
|
||||||
|
|
||||||
use App\Domains\Prop\Support\PropDataType;
|
use App\Domains\Tenant\Support\TenantPropDataType;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@@ -19,10 +19,11 @@ class StoreTenantPropRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('props', 'codigo')],
|
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenant_props', 'codigo')],
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
|
'descripcion' => ['nullable', 'string'],
|
||||||
'is_required' => ['sometimes', 'boolean'],
|
'is_required' => ['sometimes', 'boolean'],
|
||||||
'data_type' => ['required', Rule::enum(PropDataType::class)],
|
'data_type' => ['required', Rule::enum(TenantPropDataType::class)],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Tenant\Requests;
|
namespace App\Domains\Tenant\Requests;
|
||||||
|
|
||||||
use App\Domains\Prop\Support\PropRules;
|
use App\Domains\Tenant\Support\TenantPropRules;
|
||||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
@@ -50,7 +50,7 @@ class StoreTenantRequest extends FormRequest
|
|||||||
'max:255',
|
'max:255',
|
||||||
Rule::unique('tenants', 'dominio'),
|
Rule::unique('tenants', 'dominio'),
|
||||||
],
|
],
|
||||||
...PropRules::sync(),
|
...TenantPropRules::sync(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Domains\Tenant\Requests;
|
namespace App\Domains\Tenant\Requests;
|
||||||
|
|
||||||
use App\Domains\Prop\Models\Prop;
|
use App\Domains\Tenant\Models\TenantProp;
|
||||||
use App\Domains\Prop\Support\PropDataType;
|
use App\Domains\Tenant\Support\TenantPropDataType;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@@ -19,14 +19,15 @@ class UpdateTenantPropRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
/** @var Prop|null $tenantProp */
|
/** @var TenantProp|null $tenantProp */
|
||||||
$tenantProp = $this->route('tenantProp');
|
$tenantProp = $this->route('tenantProp');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('props', 'codigo')->ignore($tenantProp?->id)],
|
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenant_props', 'codigo')->ignore($tenantProp?->id)],
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
|
'descripcion' => ['nullable', 'string'],
|
||||||
'is_required' => ['sometimes', 'boolean'],
|
'is_required' => ['sometimes', 'boolean'],
|
||||||
'data_type' => ['required', Rule::enum(PropDataType::class)],
|
'data_type' => ['required', Rule::enum(TenantPropDataType::class)],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Domains\Tenant\Requests;
|
namespace App\Domains\Tenant\Requests;
|
||||||
|
|
||||||
use App\Domains\Prop\Support\PropRules;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||||
|
use App\Domains\Tenant\Support\TenantPropRules;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
@@ -59,7 +59,7 @@ class UpdateTenantRequest extends FormRequest
|
|||||||
'max:255',
|
'max:255',
|
||||||
Rule::unique('tenants', 'dominio')->ignore($tenant?->id),
|
Rule::unique('tenants', 'dominio')->ignore($tenant?->id),
|
||||||
],
|
],
|
||||||
...PropRules::sync(),
|
...TenantPropRules::sync(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Domains\Prop\Resources;
|
namespace App\Domains\Tenant\Resources;
|
||||||
|
|
||||||
use App\Domains\Prop\Models\Prop;
|
|
||||||
use App\Domains\Prop\Support\PropableModels;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @mixin Prop
|
* @mixin \App\Domains\Tenant\Models\TenantProp
|
||||||
*/
|
*/
|
||||||
class PropResource extends JsonResource
|
class TenantPropResource extends JsonResource
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
@@ -19,11 +17,11 @@ class PropResource extends JsonResource
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'propable_type' => PropableModels::aliasFor($this->propable_type) ?? $this->propable_type,
|
|
||||||
'codigo' => $this->codigo,
|
'codigo' => $this->codigo,
|
||||||
'nombre' => $this->nombre,
|
'nombre' => $this->nombre,
|
||||||
|
'descripcion' => $this->descripcion,
|
||||||
'is_required' => $this->is_required,
|
'is_required' => $this->is_required,
|
||||||
'data_type' => $this->data_type?->value,
|
'data_type' => $this->data_type?->value ?? $this->data_type,
|
||||||
'created_at' => $this->created_at,
|
'created_at' => $this->created_at,
|
||||||
'updated_at' => $this->updated_at,
|
'updated_at' => $this->updated_at,
|
||||||
];
|
];
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domains\Tenant\Resources;
|
namespace App\Domains\Tenant\Resources;
|
||||||
|
|
||||||
use App\Domains\Prop\Resources\PropValueResource;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@@ -11,6 +10,29 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
|||||||
*/
|
*/
|
||||||
class TenantResource extends 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>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -21,7 +43,7 @@ class TenantResource extends JsonResource
|
|||||||
'codigo' => $this->codigo,
|
'codigo' => $this->codigo,
|
||||||
'nombre' => $this->nombre,
|
'nombre' => $this->nombre,
|
||||||
'dominio' => $this->dominio,
|
'dominio' => $this->dominio,
|
||||||
'props' => PropValueResource::collapsedFromPropable($this->resource)
|
'props' => $this->collapseProps(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
app/Domains/Tenant/Support/TenantPropDataType.php
Normal file
18
app/Domains/Tenant/Support/TenantPropDataType.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Domains/Tenant/Support/TenantPropRules.php
Normal file
51
app/Domains/Tenant/Support/TenantPropRules.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?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),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Domains\Prop\Support\PropDataType;
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
if (! Schema::hasTable('props')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Schema::hasColumn('props', 'data_type_id')) {
|
|
||||||
Schema::table('props', function (Blueprint $table) {
|
|
||||||
$table->string('data_type')->nullable()->after('is_required');
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Schema::hasTable('props_data_types')) {
|
|
||||||
$typeMap = DB::table('props_data_types')
|
|
||||||
->pluck('code', 'id')
|
|
||||||
->map(static fn (mixed $code): string => in_array($code, PropDataType::values(), true) ? (string) $code : PropDataType::String->value)
|
|
||||||
->all();
|
|
||||||
|
|
||||||
DB::table('props')
|
|
||||||
->select(['id', 'data_type_id'])
|
|
||||||
->orderBy('id')
|
|
||||||
->get()
|
|
||||||
->each(function (object $prop) use ($typeMap): void {
|
|
||||||
DB::table('props')
|
|
||||||
->where('id', $prop->id)
|
|
||||||
->update([
|
|
||||||
'data_type' => $typeMap[$prop->data_type_id] ?? PropDataType::String->value,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
DB::table('props')->update([
|
|
||||||
'data_type' => PropDataType::String->value,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Schema::table('props', function (Blueprint $table) {
|
|
||||||
$table->dropForeign(['data_type_id']);
|
|
||||||
$table->dropColumn('data_type_id');
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::table('props', function (Blueprint $table) {
|
|
||||||
$table->string('data_type')->nullable(false)->change();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Schema::hasTable('props_data_types')) {
|
|
||||||
Schema::drop('props_data_types');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
if (! Schema::hasTable('props_data_types')) {
|
|
||||||
Schema::create('props_data_types', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->string('name');
|
|
||||||
$table->string('code')->unique();
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
|
|
||||||
DB::table('props_data_types')->insert([
|
|
||||||
[
|
|
||||||
'id' => 1,
|
|
||||||
'name' => 'String',
|
|
||||||
'code' => PropDataType::String->value,
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Schema::hasTable('props') && Schema::hasColumn('props', 'data_type') && ! Schema::hasColumn('props', 'data_type_id')) {
|
|
||||||
Schema::table('props', function (Blueprint $table) {
|
|
||||||
$table->foreignId('data_type_id')->nullable()->after('is_required')->constrained('props_data_types')->cascadeOnUpdate()->restrictOnDelete();
|
|
||||||
});
|
|
||||||
|
|
||||||
$stringTypeId = DB::table('props_data_types')
|
|
||||||
->where('code', PropDataType::String->value)
|
|
||||||
->value('id');
|
|
||||||
|
|
||||||
DB::table('props')->update([
|
|
||||||
'data_type_id' => $stringTypeId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Schema::table('props', function (Blueprint $table) {
|
|
||||||
$table->foreignId('data_type_id')->nullable(false)->change();
|
|
||||||
$table->dropColumn('data_type');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?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('categorias', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_code')->nullable();
|
||||||
|
$table->unsignedBigInteger('categoria_id')->nullable();
|
||||||
|
$table->string('nombre');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('tenant_code')
|
||||||
|
->references('codigo')
|
||||||
|
->on('tenants')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->restrictOnDelete();
|
||||||
|
|
||||||
|
$table->foreign('categoria_id')
|
||||||
|
->references('id')
|
||||||
|
->on('categorias')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->restrictOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('categorias');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?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('brands', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_codigo');
|
||||||
|
$table->string('nombre');
|
||||||
|
$table->text('descripcion')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('tenant_codigo')
|
||||||
|
->references('codigo')
|
||||||
|
->on('tenants')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->restrictOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('brands');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?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_variantes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('producto_id');
|
||||||
|
$table->string('slug')->nullable();
|
||||||
|
$table->string('nombre')->nullable();
|
||||||
|
$table->unsignedInteger('stock')->default(0);
|
||||||
|
$table->text('descripcion')->nullable();
|
||||||
|
$table->decimal('precio', 10, 2);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('producto_id')
|
||||||
|
->references('id')
|
||||||
|
->on('productos')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->cascadeOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('productos_variantes');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -11,16 +11,14 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::create('props', function (Blueprint $table) {
|
Schema::create('tenant_props', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->string('propable_type');
|
|
||||||
$table->string('codigo')->unique();
|
$table->string('codigo')->unique();
|
||||||
$table->string('nombre');
|
$table->string('nombre');
|
||||||
|
$table->text('descripcion')->nullable();
|
||||||
$table->boolean('is_required')->default(false);
|
$table->boolean('is_required')->default(false);
|
||||||
$table->string('data_type');
|
$table->string('data_type');
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
||||||
$table->index('propable_type');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +27,6 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
Schema::dropIfExists('props');
|
Schema::dropIfExists('tenant_props');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?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_prop_values', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_codigo');
|
||||||
|
$table->string('tenant_prop_codigo');
|
||||||
|
$table->text('value')->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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('tenant_prop_values');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?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_attributes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_codigo');
|
||||||
|
$table->string('codigo');
|
||||||
|
$table->string('nombre');
|
||||||
|
$table->boolean('is_required')->default(false);
|
||||||
|
$table->json('metadata_schema')->nullable();
|
||||||
|
$table->string('type');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('tenant_codigo')
|
||||||
|
->references('codigo')
|
||||||
|
->on('tenants')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->cascadeOnDelete();
|
||||||
|
|
||||||
|
$table->unique(['tenant_codigo', 'codigo']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('productos_attributes');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -11,15 +11,13 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::create('prop_options', function (Blueprint $table) {
|
Schema::create('attribute_options', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->foreignId('prop_id')->constrained('props')->cascadeOnUpdate()->cascadeOnDelete();
|
$table->foreignId('attribute_id')->constrained('productos_attributes')->cascadeOnUpdate()->cascadeOnDelete();
|
||||||
$table->string('value');
|
|
||||||
$table->string('label');
|
$table->string('label');
|
||||||
$table->unsignedInteger('sort_order')->default(0);
|
$table->unsignedInteger('sort_order')->default(0);
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
||||||
$table->unique(['prop_id', 'value']);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +26,6 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
Schema::dropIfExists('prop_options');
|
Schema::dropIfExists('attribute_options');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?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_variantes_definiciones', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('producto_variante_id');
|
||||||
|
$table->foreignId('attribute_id')->constrained('productos_attributes')->cascadeOnUpdate()->cascadeOnDelete();
|
||||||
|
$table->text('value')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('producto_variante_id')
|
||||||
|
->references('id')
|
||||||
|
->on('productos_variantes')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->cascadeOnDelete();
|
||||||
|
|
||||||
|
$table->unique(['producto_variante_id', 'attribute_id'], 'prod_var_def_variant_attr_unique');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('productos_variantes_definiciones');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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('carritos', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_codigo');
|
||||||
|
$table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnUpdate()->cascadeOnDelete();
|
||||||
|
$table->string('guest_token')->nullable();
|
||||||
|
$table->string('status')->default('active');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('tenant_codigo')
|
||||||
|
->references('codigo')
|
||||||
|
->on('tenants')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->cascadeOnDelete();
|
||||||
|
|
||||||
|
$table->unique(['tenant_codigo', 'user_id']);
|
||||||
|
$table->unique(['tenant_codigo', 'guest_token']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('carritos');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?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('carrito_items', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('cart_id')->constrained('carritos')->cascadeOnUpdate()->cascadeOnDelete();
|
||||||
|
$table->unsignedBigInteger('producto_variante_id');
|
||||||
|
$table->unsignedInteger('cantidad');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('producto_variante_id')
|
||||||
|
->references('id')
|
||||||
|
->on('productos_variantes')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->cascadeOnDelete();
|
||||||
|
|
||||||
|
$table->unique(['cart_id', 'producto_variante_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('carrito_items');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -11,15 +11,12 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::create('model_prop_values', function (Blueprint $table) {
|
Schema::create('carritos', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->unsignedBigInteger('valuable_id');
|
$table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnUpdate()->nullOnDelete();
|
||||||
$table->foreignId('prop_id')->constrained('props')->cascadeOnUpdate()->cascadeOnDelete();
|
$table->string('guest_token')->nullable()->index();
|
||||||
$table->text('value')->nullable();
|
$table->enum('status', ['active', 'converted', 'abandoned'])->default('active');
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
||||||
$table->unique(['prop_id', 'valuable_id']);
|
|
||||||
$table->index('valuable_id');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +25,6 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
Schema::dropIfExists('model_prop_values');
|
Schema::dropIfExists('carritos');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?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('carrito_items', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('cart_id')->constrained('carritos')->cascadeOnUpdate()->cascadeOnDelete();
|
||||||
|
$table->unsignedBigInteger('producto_variante_id');
|
||||||
|
$table->unsignedInteger('cantidad');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('producto_variante_id')
|
||||||
|
->references('id')
|
||||||
|
->on('productos_variantes')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->cascadeOnDelete();
|
||||||
|
|
||||||
|
$table->unique(['cart_id', 'producto_variante_id'], 'cart_item_cart_variant_unique');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('carrito_items');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?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('compras', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_codigo');
|
||||||
|
$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()
|
||||||
|
->restrictOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('compras');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -8,5 +8,6 @@ Route::get('/user', function (Request $request) {
|
|||||||
})->middleware('auth:sanctum');
|
})->middleware('auth:sanctum');
|
||||||
|
|
||||||
require __DIR__.'/../app/Domains/Catalog/routes/api.php';
|
require __DIR__.'/../app/Domains/Catalog/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Prop/routes/api.php';
|
require __DIR__.'/../app/Domains/Cart/routes/api.php';
|
||||||
|
require __DIR__.'/../app/Domains/Purchase/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Tenant/routes/api.php';
|
require __DIR__.'/../app/Domains/Tenant/routes/api.php';
|
||||||
|
|||||||
303
tests/Feature/Cart/CartControllerTest.php
Normal file
303
tests/Feature/Cart/CartControllerTest.php
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Cart;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\Product;
|
||||||
|
use App\Domains\Catalog\Models\ProductAttribute;
|
||||||
|
use App\Domains\Catalog\Models\ProductVariant;
|
||||||
|
use App\Domains\Catalog\Models\ProductVariantDefinition;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CartControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
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->getJson('/api/tenants/acme/cart')
|
||||||
|
->assertOk()
|
||||||
|
->assertJson([
|
||||||
|
'id' => null,
|
||||||
|
'tenant_codigo' => 'acme',
|
||||||
|
'status' => 'active',
|
||||||
|
'items' => [],
|
||||||
|
'subtotal' => '0.00',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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([
|
||||||
|
'tenant_codigo' => 'acme',
|
||||||
|
'codigo' => 'color',
|
||||||
|
'nombre' => 'Color',
|
||||||
|
'type' => 'text',
|
||||||
|
]);
|
||||||
|
|
||||||
|
ProductVariantDefinition::query()->create([
|
||||||
|
'producto_variante_id' => $variant->id,
|
||||||
|
'attribute_id' => $attribute->id,
|
||||||
|
'value' => 'Red',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'product_variant_id' => $variant->id,
|
||||||
|
'cantidad' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertCookie('guest_token')
|
||||||
|
->assertJsonPath('tenant_codigo', 'acme')
|
||||||
|
->assertJsonPath('items.0.cantidad', 2)
|
||||||
|
->assertJsonPath('items.0.precio_unitario', '49.90')
|
||||||
|
->assertJsonPath('items.0.subtotal', '99.80')
|
||||||
|
->assertJsonPath('items.0.product.id', $variant->product->id)
|
||||||
|
->assertJsonPath('items.0.variant.id', $variant->id)
|
||||||
|
->assertJsonPath('items.0.variant.definitions.0.attribute.codigo', 'color')
|
||||||
|
->assertJsonPath('subtotal', '99.80');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('carritos', [
|
||||||
|
'tenant_codigo' => 'acme',
|
||||||
|
'guest_token' => $response->getCookie('guest_token')?->getValue(),
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('carrito_items', [
|
||||||
|
'producto_variante_id' => $variant->id,
|
||||||
|
'cantidad' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('productos_variantes', [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'stock' => 8,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_merges_quantities_when_the_same_guest_adds_the_same_variant_twice(): void
|
||||||
|
{
|
||||||
|
$variant = $this->createVariantForTenant('acme', 12, '25.00');
|
||||||
|
|
||||||
|
$firstResponse = $this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'product_variant_id' => $variant->id,
|
||||||
|
'cantidad' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$guestToken = $firstResponse->getCookie('guest_token')?->getValue();
|
||||||
|
|
||||||
|
$this->withCookie('guest_token', $guestToken)
|
||||||
|
->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'product_variant_id' => $variant->id,
|
||||||
|
'cantidad' => 3,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('items.0.cantidad', 5)
|
||||||
|
->assertJsonPath('items.0.subtotal', '125.00')
|
||||||
|
->assertJsonPath('subtotal', '125.00');
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('carritos', 1);
|
||||||
|
$this->assertDatabaseCount('carrito_items', 1);
|
||||||
|
$this->assertDatabaseHas('carrito_items', [
|
||||||
|
'producto_variante_id' => $variant->id,
|
||||||
|
'cantidad' => 5,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('productos_variantes', [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'stock' => 7,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_updates_item_quantity_and_adjusts_stock(): void
|
||||||
|
{
|
||||||
|
$variant = $this->createVariantForTenant('acme', 10, '15.00');
|
||||||
|
|
||||||
|
$createResponse = $this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'product_variant_id' => $variant->id,
|
||||||
|
'cantidad' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$guestToken = $createResponse->getCookie('guest_token')?->getValue();
|
||||||
|
|
||||||
|
$this->withCookie('guest_token', $guestToken)
|
||||||
|
->patchJson("/api/tenants/acme/cart/items/{$variant->id}", [
|
||||||
|
'cantidad' => 5,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('items.0.cantidad', 5)
|
||||||
|
->assertJsonPath('items.0.subtotal', '75.00')
|
||||||
|
->assertJsonPath('subtotal', '75.00');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('carrito_items', [
|
||||||
|
'producto_variante_id' => $variant->id,
|
||||||
|
'cantidad' => 5,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('productos_variantes', [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'stock' => 5,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_removes_an_item_and_restores_stock(): void
|
||||||
|
{
|
||||||
|
$variant = $this->createVariantForTenant('acme', 10, '15.00');
|
||||||
|
|
||||||
|
$createResponse = $this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'product_variant_id' => $variant->id,
|
||||||
|
'cantidad' => 4,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$guestToken = $createResponse->getCookie('guest_token')?->getValue();
|
||||||
|
|
||||||
|
$this->withCookie('guest_token', $guestToken)
|
||||||
|
->deleteJson("/api/tenants/acme/cart/items/{$variant->id}")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('items', [])
|
||||||
|
->assertJsonPath('subtotal', '0.00');
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('carrito_items', 0);
|
||||||
|
$this->assertDatabaseHas('productos_variantes', [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'stock' => 10,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_authenticated_users_reuse_the_same_cart_per_tenant_and_get_a_new_one_for_another_tenant(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$acmeVariantA = $this->createVariantForTenant('acme', 10, '10.00');
|
||||||
|
$acmeVariantB = $this->createVariantForTenant('acme', 8, '20.00', 'hoodie');
|
||||||
|
$globexVariant = $this->createVariantForTenant('globex', 6, '30.00');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'product_variant_id' => $acmeVariantA->id,
|
||||||
|
'cantidad' => 1,
|
||||||
|
])
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'product_variant_id' => $acmeVariantB->id,
|
||||||
|
'cantidad' => 2,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('subtotal', '50.00');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->postJson('/api/tenants/globex/cart/items', [
|
||||||
|
'product_variant_id' => $globexVariant->id,
|
||||||
|
'cantidad' => 1,
|
||||||
|
])
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('carritos', 2);
|
||||||
|
$this->assertDatabaseHas('carritos', [
|
||||||
|
'tenant_codigo' => 'acme',
|
||||||
|
'user_id' => $user->id,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('carritos', [
|
||||||
|
'tenant_codigo' => 'globex',
|
||||||
|
'user_id' => $user->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_variants_from_another_tenant(): void
|
||||||
|
{
|
||||||
|
$this->createVariantForTenant('acme', 10, '10.00');
|
||||||
|
$otherVariant = $this->createVariantForTenant('globex', 10, '20.00');
|
||||||
|
|
||||||
|
$this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'product_variant_id' => $otherVariant->id,
|
||||||
|
'cantidad' => 1,
|
||||||
|
])->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_not_found_when_the_cart_item_does_not_exist_for_update_or_delete(): void
|
||||||
|
{
|
||||||
|
$variant = $this->createVariantForTenant('acme', 10, '10.00');
|
||||||
|
|
||||||
|
$this->patchJson("/api/tenants/acme/cart/items/{$variant->id}", [
|
||||||
|
'cantidad' => 2,
|
||||||
|
])->assertNotFound();
|
||||||
|
|
||||||
|
$this->deleteJson("/api/tenants/acme/cart/items/{$variant->id}")
|
||||||
|
->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_validates_quantity_and_stock_constraints(): void
|
||||||
|
{
|
||||||
|
$variant = $this->createVariantForTenant('acme', 2, '10.00');
|
||||||
|
|
||||||
|
$this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'product_variant_id' => $variant->id,
|
||||||
|
'cantidad' => 0,
|
||||||
|
])->assertUnprocessable()->assertJsonValidationErrors(['cantidad']);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'product_variant_id' => $variant->id,
|
||||||
|
'cantidad' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$guestToken = $response->getCookie('guest_token')?->getValue();
|
||||||
|
|
||||||
|
$this->withCookie('guest_token', $guestToken)
|
||||||
|
->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'product_variant_id' => $variant->id,
|
||||||
|
'cantidad' => 1,
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['cantidad']);
|
||||||
|
|
||||||
|
$this->withCookie('guest_token', $guestToken)
|
||||||
|
->patchJson("/api/tenants/acme/cart/items/{$variant->id}", [
|
||||||
|
'cantidad' => 3,
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['cantidad']);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function createVariantForTenant(
|
||||||
|
string $tenantCode,
|
||||||
|
int $stock,
|
||||||
|
string $price,
|
||||||
|
string $slugPrefix = 'shirt',
|
||||||
|
): ProductVariant {
|
||||||
|
Tenant::query()->firstOrCreate(
|
||||||
|
['codigo' => $tenantCode],
|
||||||
|
['nombre' => ucfirst($tenantCode), 'dominio' => "{$tenantCode}.com"],
|
||||||
|
);
|
||||||
|
|
||||||
|
$category = \App\Domains\Catalog\Models\Category::query()->create([
|
||||||
|
'tenant_code' => $tenantCode,
|
||||||
|
'nombre' => "{$slugPrefix} category {$tenantCode}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
$product = Product::query()->create([
|
||||||
|
'tenant_codigo' => $tenantCode,
|
||||||
|
'categoria_id' => $category->id,
|
||||||
|
'slug' => "{$slugPrefix}-{$tenantCode}-".Product::query()->count(),
|
||||||
|
'nombre' => ucfirst($slugPrefix)." {$tenantCode}",
|
||||||
|
'descripcion' => 'Test product',
|
||||||
|
'precio' => $price,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ProductVariant::query()->create([
|
||||||
|
'producto_id' => $product->id,
|
||||||
|
'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(),
|
||||||
|
'nombre' => ucfirst($slugPrefix).' Variant',
|
||||||
|
'stock' => $stock,
|
||||||
|
'descripcion' => 'Test variant',
|
||||||
|
'precio' => $price,
|
||||||
|
])->load('product');
|
||||||
|
}
|
||||||
|
}
|
||||||
84
tests/Feature/Catalog/ProductAttributeControllerTest.php
Normal file
84
tests/Feature/Catalog/ProductAttributeControllerTest.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Prop;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class PropControllerTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_it_creates_a_prop_using_enum_data_type(): void
|
|
||||||
{
|
|
||||||
$response = $this->postJson('/api/prop-models/tenant/props', [
|
|
||||||
'codigo' => 'color_primario',
|
|
||||||
'nombre' => 'Color primario',
|
|
||||||
'is_required' => false,
|
|
||||||
'data_type' => 'string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertCreated()
|
|
||||||
->assertJsonPath('propable_type', 'tenant')
|
|
||||||
->assertJsonPath('data_type', 'string');
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('props', [
|
|
||||||
'codigo' => 'color_primario',
|
|
||||||
'data_type' => 'string',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_unknown_enum_data_type(): void
|
|
||||||
{
|
|
||||||
$response = $this->postJson('/api/prop-models/tenant/props', [
|
|
||||||
'codigo' => 'color_primario',
|
|
||||||
'nombre' => 'Color primario',
|
|
||||||
'is_required' => false,
|
|
||||||
'data_type' => 'unsupported-type',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertUnprocessable()
|
|
||||||
->assertJsonValidationErrors(['data_type']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace Tests\Feature\Tenant;
|
namespace Tests\Feature\Tenant;
|
||||||
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Tenant\Models\TenantProp;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -18,9 +19,10 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
'dominio' => 'acme.com',
|
'dominio' => 'acme.com',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Tenant::createProp([
|
TenantProp::create([
|
||||||
'codigo' => 'primary_color',
|
'codigo' => 'primary_color',
|
||||||
'nombre' => 'Primary Color',
|
'nombre' => 'Primary Color',
|
||||||
|
'descripcion' => 'Brand color',
|
||||||
'is_required' => false,
|
'is_required' => false,
|
||||||
'data_type' => 'string',
|
'data_type' => 'string',
|
||||||
]);
|
]);
|
||||||
@@ -63,15 +65,33 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
|
|
||||||
public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void
|
public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void
|
||||||
{
|
{
|
||||||
|
TenantProp::create([
|
||||||
|
'codigo' => 'primary_color',
|
||||||
|
'nombre' => 'Primary Color',
|
||||||
|
'descripcion' => 'Brand color',
|
||||||
|
'is_required' => false,
|
||||||
|
'data_type' => 'string',
|
||||||
|
]);
|
||||||
|
|
||||||
$firstResponse = $this->postJson('/api/tenants', [
|
$firstResponse = $this->postJson('/api/tenants', [
|
||||||
'codigo' => 'acme',
|
'codigo' => 'acme',
|
||||||
'nombre' => 'Acme',
|
'nombre' => 'Acme',
|
||||||
'dominio' => 'https://ACME.com/path',
|
'dominio' => 'https://ACME.com/path',
|
||||||
|
'props' => [
|
||||||
|
'primary_color' => 'blue',
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$firstResponse
|
$firstResponse
|
||||||
->assertCreated()
|
->assertCreated()
|
||||||
->assertJsonPath('dominio', 'acme.com');
|
->assertJsonPath('dominio', 'acme.com')
|
||||||
|
->assertJsonPath('props.primary_color', 'blue');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tenant_prop_values', [
|
||||||
|
'tenant_codigo' => 'acme',
|
||||||
|
'tenant_prop_codigo' => 'primary_color',
|
||||||
|
'value' => 'blue',
|
||||||
|
]);
|
||||||
|
|
||||||
$secondResponse = $this->postJson('/api/tenants', [
|
$secondResponse = $this->postJson('/api/tenants', [
|
||||||
'codigo' => 'globex',
|
'codigo' => 'globex',
|
||||||
@@ -86,6 +106,14 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
|
|
||||||
public function test_it_allows_keeping_the_same_domain_on_update_but_rejects_collisions(): void
|
public function test_it_allows_keeping_the_same_domain_on_update_but_rejects_collisions(): void
|
||||||
{
|
{
|
||||||
|
TenantProp::create([
|
||||||
|
'codigo' => 'primary_color',
|
||||||
|
'nombre' => 'Primary Color',
|
||||||
|
'descripcion' => 'Brand color',
|
||||||
|
'is_required' => false,
|
||||||
|
'data_type' => 'string',
|
||||||
|
]);
|
||||||
|
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::create([
|
||||||
'codigo' => 'acme',
|
'codigo' => 'acme',
|
||||||
'nombre' => 'Acme',
|
'nombre' => 'Acme',
|
||||||
@@ -102,12 +130,16 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
'codigo' => 'acme',
|
'codigo' => 'acme',
|
||||||
'nombre' => 'Acme Updated',
|
'nombre' => 'Acme Updated',
|
||||||
'dominio' => 'https://ACME.com:443/admin',
|
'dominio' => 'https://ACME.com:443/admin',
|
||||||
|
'props' => [
|
||||||
|
'primary_color' => 'green',
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$successfulResponse
|
$successfulResponse
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('nombre', 'Acme Updated')
|
->assertJsonPath('nombre', 'Acme Updated')
|
||||||
->assertJsonPath('dominio', 'acme.com');
|
->assertJsonPath('dominio', 'acme.com')
|
||||||
|
->assertJsonPath('props.primary_color', 'green');
|
||||||
|
|
||||||
$failingResponse = $this->putJson("/api/tenants/{$otherTenant->id}", [
|
$failingResponse = $this->putJson("/api/tenants/{$otherTenant->id}", [
|
||||||
'codigo' => 'globex',
|
'codigo' => 'globex',
|
||||||
|
|||||||
45
tests/Feature/Tenant/TenantPropControllerTest.php
Normal file
45
tests/Feature/Tenant/TenantPropControllerTest.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Tenant;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TenantPropControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_creates_a_tenant_prop(): void
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/tenant-props', [
|
||||||
|
'codigo' => 'primary_color',
|
||||||
|
'nombre' => 'Primary Color',
|
||||||
|
'descripcion' => 'Brand color',
|
||||||
|
'is_required' => false,
|
||||||
|
'data_type' => 'string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('codigo', 'primary_color')
|
||||||
|
->assertJsonPath('data_type', 'string');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tenant_props', [
|
||||||
|
'codigo' => 'primary_color',
|
||||||
|
'data_type' => 'string',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_unknown_data_type(): void
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/tenant-props', [
|
||||||
|
'codigo' => 'primary_color',
|
||||||
|
'nombre' => 'Primary Color',
|
||||||
|
'data_type' => 'unsupported',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['data_type']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user