diff --git a/app/Domains/Attachable/documentacion/README.md b/app/Domains/Attachable/documentacion/README.md new file mode 100644 index 0000000..fec8cbd --- /dev/null +++ b/app/Domains/Attachable/documentacion/README.md @@ -0,0 +1,29 @@ +# Dominio Attachable + +## Propósito + +Centraliza el almacenamiento y la metadata de archivos adjuntos. Acepta archivos subidos o contenido Base64, los persiste en S3 y registra su tipo, MIME, extensión, tamaño, nombre original y clave única. + +## Componentes principales + +- `Models/Attachment.php`: representa un adjunto y genera URL temporales de acceso. +- `Services/AttachmentService.php`: almacena, copia y elimina archivos, compensando en S3 si falla la escritura en base de datos. +- `Enums/AttachmentType.php`: clasifica imágenes, videos, PDF, audio, documentos y otros archivos. +- `Exceptions/AttachmentStorageException.php`: expresa fallos propios del almacenamiento. + +## Flujo principal + +1. El consumidor entrega un `UploadedFile` o una cadena Base64 y un directorio. +2. El servicio valida el contenido, detecta MIME/extensión y genera una clave UUID. +3. El archivo se guarda en el disco `s3`. +4. Se crea el registro `Attachment`; ante error se elimina el objeto que había sido subido. + +## API y dependencias + +No expone rutas HTTP propias. Lo consumen otros dominios, especialmente `Catalog` y `Tenant`. Depende de Laravel Storage, Symfony Mime y del modelo `Attachment`. + +## Consideraciones + +- El directorio no puede quedar vacío después de normalizarlo. +- La eliminación se considera fallida si S3 no confirma el borrado. +- Las URL generadas son temporales; el vencimiento predeterminado es de 10 minutos. diff --git a/app/Domains/Auth/documentacion/README.md b/app/Domains/Auth/documentacion/README.md new file mode 100644 index 0000000..40ea937 --- /dev/null +++ b/app/Domains/Auth/documentacion/README.md @@ -0,0 +1,36 @@ +# Dominio Auth + +## Propósito + +Gestiona identidad y acceso de usuarios de la tienda y del panel administrativo: registro, inicio y cierre de sesión, perfil, autenticación con Google y recuperación de contraseña. + +## Modelo y servicios + +- `User`: usuario autenticable, asociado a tenant, rol, intentos de acceso y categorías habilitadas para escaneo. +- `LoginAttempt` y `ResetPasswordAttempt`: trazabilidad de accesos y recuperación de contraseña. +- `PasswordLoginService`: autentica tienda y AdminApp, incluyendo bloqueo por intentos. +- `RegisterUserService` y `ProfileService`: alta y edición del usuario. +- `ResetPasswordAttemptService`: crea, valida y consume códigos de recuperación. +- `GoogleAuthService`: redirección, callback e intercambio de código para Google OAuth. +- `AdminAppContextService`: carga el contexto requerido por un usuario administrativo. + +## Endpoints públicos + +- `POST /register`, `POST /login` y `POST /logout`. +- `GET /me` y `PUT /me`, protegidos por `auth:sanctum`. +- Creación, validación y aplicación de intentos de recuperación bajo `/password`. +- `POST /auth/google/exchange` para canjear el código de autenticación. +- `POST /v1/adminapp/login` y consulta del usuario administrativo dentro del grupo autenticado de AdminApp. + +## Validación y respuestas + +Los `FormRequest` validan cada operación. `UserResource` y `AdminAppMeResource` definen las representaciones de salida. Los endpoints sensibles aplican `auth:sanctum` y límites de frecuencia. + +## Dependencias y eventos + +Se relaciona con `Tenant` y `Authorization`; el registro y la recuperación disparan flujos atendidos por `Notification`. El carrito invitado puede integrarse al usuario autenticado mediante el dominio `Cart`. + +## Consideraciones + +- La resolución del tenant forma parte de la autenticación y no debe omitirse. +- Los cambios en reglas de login deben conservar los límites de intentos y el manejo de `AccountLockedException`. diff --git a/app/Domains/Authorization/documentacion/README.md b/app/Domains/Authorization/documentacion/README.md new file mode 100644 index 0000000..9a3845c --- /dev/null +++ b/app/Domains/Authorization/documentacion/README.md @@ -0,0 +1,26 @@ +# Dominio Authorization + +## Propósito + +Define el esquema de roles y permisos usado para autorizar funcionalidades de la aplicación. + +## Componentes principales + +- `Enums/RoleCode.php`: códigos de roles conocidos por el sistema. +- `Models/Role.php`: rol con relaciones hacia permisos, usuarios y menús. +- `Models/Permission.php`: permiso asignable a uno o más roles. +- `Models/RolePermission.php`: entidad de asociación entre rol y permiso. + +## API + +No expone controladores ni rutas propias. Su información se consume desde autenticación, menús, políticas y middleware de autorización. + +## Relaciones relevantes + +- `Role` tiene muchos usuarios del dominio `Auth`. +- Roles y permisos mantienen una relación muchos-a-muchos. +- Los roles determinan los menús disponibles mediante el dominio `Menu`. + +## Consideraciones + +Los códigos definidos en `RoleCode` funcionan como contrato entre datos persistidos y lógica de aplicación. Al agregar un rol o permiso se deben revisar seeds, asociaciones y consumidores. diff --git a/app/Domains/Bootstrap/documentacion/README.md b/app/Domains/Bootstrap/documentacion/README.md new file mode 100644 index 0000000..2d9c6a4 --- /dev/null +++ b/app/Domains/Bootstrap/documentacion/README.md @@ -0,0 +1,28 @@ +# Dominio Bootstrap + +## Propósito + +Entrega la configuración inicial que necesitan la tienda y el panel administrativo antes de renderizar su interfaz. + +## Flujos + +- `TenantBootstrapService` resuelve un tenant desde el dominio solicitado y carga su información pública. +- `AdminAppBootstrapService` prepara el contexto inicial del panel administrativo para el tenant autenticado. +- Los controladores invocables transforman el resultado mediante `TenantResource` o `AdminAppBootstrapResource`. + +## Endpoints + +- `GET /tenants/bootstrap/{dominio}`: bootstrap público de la tienda. +- Endpoint de bootstrap bajo `/v1/adminapp`, protegido por `auth:sanctum` y `adminapp.tenant`. + +## Validación + +`TenantBootstrapRequest` valida el dominio recibido. `AdminAppBootstrapRequest` reutiliza ese contrato para el panel. + +## Dependencias + +Depende principalmente de `Tenant` para resolver y cargar la tienda, y de los dominios que aportan datos al contexto administrativo. + +## Consideraciones + +Este dominio es un agregador de lectura. Debe mantenerse liviano y delegar la obtención de cada dato al dominio propietario. diff --git a/app/Domains/Cart/Controllers/CartController.php b/app/Domains/Cart/Controllers/CartController.php index 88524ba..368a77c 100644 --- a/app/Domains/Cart/Controllers/CartController.php +++ b/app/Domains/Cart/Controllers/CartController.php @@ -54,16 +54,26 @@ class CartController extends Controller Tenant $tenant, CartItem $cartItem, ): CartResource { + $updatesVariant = $request->exists('variant_id'); + return CartResource::make( - $this->cartService->updateItemQuantity( + $this->cartService->updateItem( $tenant, $request, $cartItem->getKey(), (int) $request->validated('cantidad'), + $updatesVariant + ? ($request->validated('variant_id') !== null + ? (int) $request->validated('variant_id') + : null) + : $cartItem->variant_id, + $updatesVariant, ) )->additional([ - 'code' => 'cart.quantity_updated', - 'message' => __('api.cart.quantity_updated'), + 'code' => $updatesVariant ? 'cart.item_updated' : 'cart.quantity_updated', + 'message' => $updatesVariant + ? __('api.cart.item_updated') + : __('api.cart.quantity_updated'), ]); } diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index f4f1aca..cfddda5 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -7,6 +7,7 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogInventoryService; +use App\Domains\Purchase\Services\UserPurchaseLimitService; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -86,6 +87,10 @@ class Cart extends Model return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem { self::query()->whereKey($this->getKey())->lockForUpdate()->firstOrFail(); $selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true); + $cartQuantity = (int) $this->items() + ->where('catalog_item_id', $catalogItemId) + ->sum('cantidad'); + $this->assertUserPurchaseLimit($selectedItem, $cartQuantity + $quantity); $inventoryService = app(CatalogInventoryService::class); $availableQuantity = $inventoryService->availableQuantity($selectedItem); @@ -119,29 +124,99 @@ class Cart extends Model }); } - public function updateItem(int $cartItemId, int $quantity): CartItem - { + public function updateItem( + int $cartItemId, + int $quantity, + ?int $variantId = null, + bool $updateVariant = false, + ): CartItem { if ($quantity <= 0) { throw ValidationException::withMessages([ 'cantidad' => __('api.cart.positive_quantity'), ]); } - return DB::transaction(function () use ($cartItemId, $quantity): CartItem { + return DB::transaction(function () use ( + $cartItemId, + $quantity, + $variantId, + $updateVariant, + ): CartItem { /** @var CartItem $item */ $item = $this->items() ->where('id', $cartItemId) ->lockForUpdate() ->firstOrFail(); - $selectedItem = $this->resolveScopedItem( + $currentSelection = $this->resolveScopedItem( $item->catalog_item_id, $item->variant_id, true, ); $inventoryService = app(CatalogInventoryService::class); + + if ($updateVariant && $variantId !== $item->variant_id) { + $nextSelection = $this->resolveScopedItem( + $item->catalog_item_id, + $variantId, + true, + ); + $otherVariantsQuantity = (int) $this->items() + ->where('catalog_item_id', $item->catalog_item_id) + ->whereKeyNot($item->getKey()) + ->sum('cantidad'); + $this->assertUserPurchaseLimit( + $nextSelection, + $otherVariantsQuantity + $quantity, + ); + + $inventoryService->release($currentSelection, $item->cantidad); + $availableQuantity = $inventoryService->availableQuantity($nextSelection); + + if ($availableQuantity !== null && $availableQuantity < $quantity) { + throw ValidationException::withMessages([ + 'variant_id' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]), + ]); + } + + $targetItem = $this->items() + ->where('catalog_item_id', $item->catalog_item_id) + ->where('variant_id', $variantId) + ->whereKeyNot($item->getKey()) + ->lockForUpdate() + ->first(); + + $inventoryService->reserve($nextSelection, $quantity); + + if ($targetItem !== null) { + $targetItem->cantidad += $quantity; + $targetItem->save(); + $item->delete(); + + return $targetItem->fresh(); + } + + $item->variant_id = $variantId; + $item->cantidad = $quantity; + $item->save(); + + return $item->fresh(); + } + $delta = $quantity - $item->cantidad; - $availableQuantity = $inventoryService->availableQuantity($selectedItem); + + if ($delta > 0) { + $otherVariantsQuantity = (int) $this->items() + ->where('catalog_item_id', $item->catalog_item_id) + ->whereKeyNot($item->getKey()) + ->sum('cantidad'); + $this->assertUserPurchaseLimit( + $currentSelection, + $otherVariantsQuantity + $quantity, + ); + } + + $availableQuantity = $inventoryService->availableQuantity($currentSelection); if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) { $maxAvailable = $availableQuantity + $item->cantidad; @@ -154,11 +229,11 @@ class Cart extends Model $item->save(); if ($delta > 0) { - $inventoryService->reserve($selectedItem, $delta); + $inventoryService->reserve($currentSelection, $delta); } if ($delta < 0) { - $inventoryService->release($selectedItem, abs($delta)); + $inventoryService->release($currentSelection, abs($delta)); } return $item->fresh(); @@ -256,6 +331,26 @@ class Cart extends Model return $variant; } + private function assertUserPurchaseLimit( + CatalogItem|Variant $selectedItem, + int $cartQuantity, + ): void { + if ($this->user_id === null) { + return; + } + + $catalogItem = $selectedItem instanceof Variant + ? $selectedItem->catalogItem + : $selectedItem; + + app(UserPurchaseLimitService::class)->assertCanPurchase( + $catalogItem, + $this->user_id, + $cartQuantity, + field: 'cantidad', + ); + } + protected function resolveInventory(int $inventoryId, bool $lockForUpdate): Inventory { $query = Inventory::query()->whereKey($inventoryId); diff --git a/app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php b/app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php index 73e55a9..5518642 100644 --- a/app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php +++ b/app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php @@ -19,7 +19,7 @@ class UpdateCartItemQuantityRequest extends FormRequest return [ 'cantidad' => ['required', 'integer', 'min:1'], 'catalog_item_id' => ['prohibited'], - 'variant_id' => ['prohibited'], + 'variant_id' => ['sometimes', 'nullable', 'integer'], ]; } } diff --git a/app/Domains/Cart/Resources/CartItemResource.php b/app/Domains/Cart/Resources/CartItemResource.php index 67e8316..dbe1709 100644 --- a/app/Domains/Cart/Resources/CartItemResource.php +++ b/app/Domains/Cart/Resources/CartItemResource.php @@ -3,6 +3,8 @@ namespace App\Domains\Cart\Resources; use App\Domains\Cart\Models\CartItem; +use App\Domains\Catalog\Enums\InventoryPolicy; +use App\Domains\Catalog\Models\Variant; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -36,6 +38,16 @@ class CartItemResource extends JsonResource 'product' => $selectedItem === null ? null : [ 'nombre' => $selectedItem->getName(), 'imagen' => $imageUrl, + 'variants' => $this->catalogItem->visibleVariants($this->variant_id) + ->map(fn (Variant $variant): array => [ + 'id' => $variant->id, + 'precio' => $this->formatMoney($variant->getPrice()), + 'stock_tecnico' => $this->catalogItem->inventory_policy === InventoryPolicy::Unlimited + ? null + : $variant->inventory->availableStock(), + 'values' => $variant->selectionOptions($this->catalogItem->itemAttributes), + ]) + ->values(), ], ]; } diff --git a/app/Domains/Cart/Services/CartService.php b/app/Domains/Cart/Services/CartService.php index affbb55..266e311 100644 --- a/app/Domains/Cart/Services/CartService.php +++ b/app/Domains/Cart/Services/CartService.php @@ -51,11 +51,17 @@ class CartService ]; } - public function updateItemQuantity(Tenant $tenant, Request $request, int $cartItemId, int $quantity): Cart - { + public function updateItem( + Tenant $tenant, + Request $request, + int $cartItemId, + int $quantity, + ?int $variantId, + bool $updateVariant, + ): Cart { $identity = $this->requireIdentity($request); $cart = $this->findCartOrFail($tenant, $identity); - $cart->updateItem($cartItemId, $quantity); + $cart->updateItem($cartItemId, $quantity, $variantId, $updateVariant); return $this->loadCart($cart); } @@ -101,9 +107,16 @@ class CartService return $cart->fresh()->load([ 'items.catalogItem.attachments', 'items.catalogItem.inventory', + 'items.catalogItem.itemAttributes.attribute', + 'items.catalogItem.variants.inventory', + 'items.catalogItem.variants.definitions.itemAttribute.attribute.options', + 'items.catalogItem.variants.eventDates', + 'items.catalogItem.variants.eventDate', 'items.variant.attachments', 'items.variant.inventory', - 'items.variant.definitions.itemAttribute.attribute', + 'items.variant.definitions.itemAttribute.attribute.options', + 'items.variant.eventDates', + 'items.variant.eventDate', ]); } diff --git a/app/Domains/Cart/documentacion/README.md b/app/Domains/Cart/documentacion/README.md new file mode 100644 index 0000000..f80a34d --- /dev/null +++ b/app/Domains/Cart/documentacion/README.md @@ -0,0 +1,32 @@ +# Dominio Cart + +## Propósito + +Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios autenticados. + +## Modelo + +- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total y permite agregar, actualizar o quitar ítems. +- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; expone la selección efectiva. + +## Servicios + +- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado. +- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica. + +## Endpoints + +Bajo `/tenants/{tenant:codigo}`: + +- `GET /cart`. +- `POST /cart/items`. +- `PATCH /cart/items/{cartItem}`. +- `DELETE /cart/items/{cartItem}`. + +## Contratos + +`AddCartItemRequest` y `UpdateCartItemQuantityRequest` validan selección y cantidad. `CartResource` y `CartItemResource` estabilizan la respuesta pública. + +## Dependencias y reglas + +Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y de `Auth` cuando existe usuario. Toda operación debe comprobar que carrito e ítem pertenecen al tenant actual. diff --git a/app/Domains/Catalog/Models/Attribute.php b/app/Domains/Catalog/Models/Attribute.php index 7bb7ff1..58e1f99 100644 --- a/app/Domains/Catalog/Models/Attribute.php +++ b/app/Domains/Catalog/Models/Attribute.php @@ -2,6 +2,7 @@ namespace App\Domains\Catalog\Models; +use App\Domains\Event\Models\EventDate; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Fillable; @@ -51,4 +52,14 @@ class Attribute extends Model { return $this->hasMany(AttributeOption::class, 'attribute_id')->orderBy('sort_order'); } + + /** + * @return HasMany + */ + public function eventDates(): HasMany + { + return $this->hasMany(EventDate::class, 'tenant_code', 'tenant_codigo') + ->orderBy('date') + ->orderBy('time_start'); + } } diff --git a/app/Domains/Catalog/Models/AttributeOption.php b/app/Domains/Catalog/Models/AttributeOption.php index fe9811c..233e4bc 100644 --- a/app/Domains/Catalog/Models/AttributeOption.php +++ b/app/Domains/Catalog/Models/AttributeOption.php @@ -2,6 +2,7 @@ namespace App\Domains\Catalog\Models; +use App\Domains\Ticket\Models\ValidityTime; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -9,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; #[Fillable([ 'attribute_id', + 'validity_time_id', 'value', 'label', 'sort_order', @@ -26,6 +28,7 @@ class AttributeOption extends Model protected function casts(): array { return [ + 'validity_time_id' => 'integer', 'sort_order' => 'integer', 'metadata' => 'array', ]; @@ -38,4 +41,10 @@ class AttributeOption extends Model { return $this->belongsTo(Attribute::class, 'attribute_id'); } + + /** @return BelongsTo */ + public function validityTime(): BelongsTo + { + return $this->belongsTo(ValidityTime::class); + } } diff --git a/app/Domains/Catalog/Models/CatalogItem.php b/app/Domains/Catalog/Models/CatalogItem.php index 2fd62f0..5e9616e 100644 --- a/app/Domains/Catalog/Models/CatalogItem.php +++ b/app/Domains/Catalog/Models/CatalogItem.php @@ -4,24 +4,23 @@ namespace App\Domains\Catalog\Models; use App\Domains\Attachable\Models\Attachment; use App\Domains\Catalog\Enums\CatalogItemType; -use App\Domains\Catalog\Enums\EventProductType; use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Services\CatalogInventoryService; -use App\Domains\Event\Models\Event; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\TicketGenerationPolicy; use App\Domains\Ticket\Models\Ticket; -use Carbon\CarbonInterface; +use App\Domains\Ticket\Models\ValidityTime; 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\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Collection; #[Fillable([ 'tenant_code', - 'event_id', - 'event_product_type', 'category_id', 'brand_id', 'inventory_id', @@ -31,9 +30,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'descripcion', 'precio', 'inventory_policy', + 'max_units_per_user', 'has_tickets', - 'maximum_use_date', - 'minimum_use_date', + 'ticket_generation_policy', + 'validity_time_id', ])] class CatalogItem extends Model { @@ -47,6 +47,7 @@ class CatalogItem extends Model 'type' => CatalogItemType::Standard->value, 'inventory_policy' => InventoryPolicy::Tracked->value, 'has_tickets' => false, + 'ticket_generation_policy' => TicketGenerationPolicy::PerEventDate->value, ]; protected function casts(): array @@ -55,14 +56,13 @@ class CatalogItem extends Model 'category_id' => 'integer', 'brand_id' => 'integer', 'inventory_id' => 'integer', - 'event_id' => 'integer', - 'event_product_type' => EventProductType::class, 'type' => CatalogItemType::class, 'precio' => 'decimal:2', 'inventory_policy' => InventoryPolicy::class, + 'max_units_per_user' => 'integer', 'has_tickets' => 'boolean', - 'maximum_use_date' => 'datetime', - 'minimum_use_date' => 'datetime', + 'ticket_generation_policy' => TicketGenerationPolicy::class, + 'validity_time_id' => 'integer', ]; } @@ -72,12 +72,6 @@ class CatalogItem extends Model return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); } - /** @return BelongsTo */ - public function event(): BelongsTo - { - return $this->belongsTo(Event::class); - } - /** @return BelongsTo */ public function category(): BelongsTo { @@ -120,6 +114,12 @@ class CatalogItem extends Model return $this->hasMany(Ticket::class, 'source_catalog_item_id'); } + /** @return BelongsTo */ + public function validityTime(): BelongsTo + { + return $this->belongsTo(ValidityTime::class); + } + /** @return BelongsToMany */ public function attributes(): BelongsToMany { @@ -173,6 +173,31 @@ class CatalogItem extends Model return ($this->availableStock() ?? 0) > 0; } + /** @param Builder $query */ + public function scopeWhereVariantsAvailable(Builder $query): Builder + { + return $query->where(function (Builder $query): void { + $query + ->whereDoesntHave('variants') + ->orWhere('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value) + ->orWhereHas( + 'variants.inventory', + fn (Builder $inventoryQuery): Builder => $inventoryQuery + ->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') + ); + }); + } + + /** @return Collection */ + public function visibleVariants(?int $includedVariantId = null): Collection + { + return $this->variants + ->filter(fn (Variant $variant): bool => ($includedVariantId !== null && $variant->id === $includedVariantId) + || $this->inventory_policy === InventoryPolicy::Unlimited + || ($variant->inventory?->availableStock() ?? 0) > 0) + ->values(); + } + public function getPrice(): float { return (float) $this->precio; @@ -183,14 +208,9 @@ class CatalogItem extends Model return $this->nombre; } - public function getMinimumUseDate(): ?CarbonInterface + public function getDescription(): ?string { - return $this->minimum_use_date; - } - - public function getMaximumUseDate(): ?CarbonInterface - { - return $this->maximum_use_date; + return $this->descripcion; } public function isBundle(): bool diff --git a/app/Domains/Catalog/Models/ItemAttribute.php b/app/Domains/Catalog/Models/ItemAttribute.php index 89e4b31..72fc815 100644 --- a/app/Domains/Catalog/Models/ItemAttribute.php +++ b/app/Domains/Catalog/Models/ItemAttribute.php @@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ 'catalog_item_id', 'attribute_id', + 'allow_multi_select', + 'sort_order', ])] class ItemAttribute extends Model { @@ -18,6 +20,16 @@ class ItemAttribute extends Model protected $table = 'item_attributes'; + protected function casts(): array + { + return [ + 'catalog_item_id' => 'integer', + 'attribute_id' => 'integer', + 'allow_multi_select' => 'boolean', + 'sort_order' => 'integer', + ]; + } + /** @return BelongsTo */ public function catalogItem(): BelongsTo { diff --git a/app/Domains/Catalog/Models/Variant.php b/app/Domains/Catalog/Models/Variant.php index fe3b13e..7f6051f 100644 --- a/app/Domains/Catalog/Models/Variant.php +++ b/app/Domains/Catalog/Models/Variant.php @@ -5,20 +5,20 @@ namespace App\Domains\Catalog\Models; use App\Domains\Attachable\Models\Attachment; use App\Domains\Event\Models\EventDate; use App\Domains\Ticket\Models\Ticket; -use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Collection; #[Fillable([ 'catalog_item_id', 'event_date_id', 'inventory_id', - 'minimum_use_date', - 'maximum_use_date', + 'descripcion', + 'precio', ])] class Variant extends Model { @@ -34,8 +34,7 @@ class Variant extends Model 'catalog_item_id' => 'integer', 'event_date_id' => 'integer', 'inventory_id' => 'integer', - 'minimum_use_date' => 'datetime', - 'maximum_use_date' => 'datetime', + 'precio' => 'decimal:2', ]; } @@ -51,6 +50,17 @@ class Variant extends Model return $this->belongsTo(EventDate::class); } + /** @return BelongsToMany */ + public function eventDates(): BelongsToMany + { + return $this->belongsToMany( + EventDate::class, + 'variant_event_dates', + 'variant_id', + 'event_date_id', + )->orderBy('date')->orderBy('time_start'); + } + /** @return HasMany */ public function sourceTickets(): HasMany { @@ -90,7 +100,12 @@ class Variant extends Model public function getPrice(): float { - return $this->catalogItem->getPrice(); + return (float) ($this->precio ?? $this->catalogItem->precio); + } + + public function getDescription(): ?string + { + return $this->descripcion ?? $this->catalogItem->descripcion; } public function getName(): string @@ -98,17 +113,132 @@ class Variant extends Model return $this->catalogItem->nombre; } - public function getMinimumUseDate(): ?CarbonInterface + /** @return Collection> */ + public function selectionValues(): Collection { - return $this->eventDate?->startsAt() - ?? $this->minimum_use_date - ?? $this->catalogItem->getMinimumUseDate(); + $values = $this->definitions + ->groupBy('item_attribute_id') + ->mapWithKeys(function (Collection $definitions): array { + $itemAttribute = $definitions->first()?->itemAttribute; + $attributeCode = $itemAttribute?->attribute?->codigo; + + if ($attributeCode === null) { + return []; + } + + $definitionValues = $definitions->pluck('value')->values(); + + return [ + $attributeCode => $itemAttribute->allow_multi_select + ? $definitionValues->all() + : $definitionValues->first(), + ]; + }); + + $eventDateIds = $this->selectedEventDates() + ->pluck('id') + ->map(fn ($id): string => (string) $id) + ->values(); + + if ($eventDateIds->count() === 1) { + $values->put('event_date', $eventDateIds->first()); + } elseif ($eventDateIds->isNotEmpty()) { + $values->put('event_date', $eventDateIds->all()); + } + + return $values; } - public function getMaximumUseDate(): ?CarbonInterface + /** + * @return Collection> + */ + public function selectionOptions(?Collection $itemAttributes = null): Collection { - return $this->eventDate?->endsAt() - ?? $this->maximum_use_date - ?? $this->catalogItem->getMaximumUseDate(); + $options = $this->definitions + ->groupBy('item_attribute_id') + ->mapWithKeys(function (Collection $definitions): array { + $itemAttribute = $definitions->first()?->itemAttribute; + $attribute = $itemAttribute?->attribute; + $attributeCode = $attribute?->codigo; + + if ($attributeCode === null) { + return []; + } + + $values = $definitions + ->pluck('value') + ->values() + ->map(function (string $value) use ($attribute): array { + $attributeOption = $attribute->options->firstWhere('value', $value); + + return [ + 'value' => $value, + 'label' => $attributeOption?->label ?? $value, + ]; + }); + + return [ + $attributeCode => $itemAttribute->allow_multi_select + ? $values->all() + : $values->first(), + ]; + }); + + $eventDateOptions = $this->selectedEventDates() + ->map(fn (EventDate $eventDate): array => [ + 'value' => (string) $eventDate->id, + 'label' => $eventDate->date->format('d/m/Y'), + ]) + ->values(); + + if ($eventDateOptions->count() === 1) { + $options->put('event_date', $eventDateOptions->first()); + } elseif ($eventDateOptions->isNotEmpty()) { + $options->put('event_date', $eventDateOptions->all()); + } + + if ($itemAttributes === null) { + $itemAttributes = $this->definitions + ->map(fn (VariantDefinition $definition) => $definition->itemAttribute) + ->filter() + ->unique('id') + ->values(); + + if ($this->catalogItem !== null) { + $itemAttributes = $itemAttributes + ->merge($this->catalogItem->itemAttributes) + ->unique('id') + ->values(); + } + } + + $ordering = $itemAttributes->mapWithKeys(function (ItemAttribute $itemAttribute): array { + $attribute = $itemAttribute->attribute; + + return $attribute === null + ? [] + : [$attribute->codigo => [$itemAttribute->sort_order, mb_strtolower($attribute->nombre)]]; + }); + + return $options->sortKeysUsing(function (string $left, string $right) use ($ordering): int { + [$leftOrder, $leftLabel] = $ordering->get($left, [0, mb_strtolower($left)]); + [$rightOrder, $rightLabel] = $ordering->get($right, [0, mb_strtolower($right)]); + + return $leftOrder <=> $rightOrder + ?: $leftLabel <=> $rightLabel + ?: $left <=> $right; + }); + } + + /** @return Collection */ + public function selectedEventDates(): Collection + { + $eventDates = $this->eventDates; + + if ($eventDates->isEmpty() && $this->event_date_id !== null && $this->eventDate !== null) { + return collect([$this->eventDate]); + } + + return $eventDates; } } diff --git a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php index 4ccb4c0..09c8762 100644 --- a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php +++ b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php @@ -3,9 +3,9 @@ namespace App\Domains\Catalog\Requests; use App\Domains\Catalog\Enums\CatalogItemType; -use App\Domains\Catalog\Enums\EventProductType; use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Shared\Rules\ImageOrBase64Rule; +use App\Domains\Ticket\Enums\TicketGenerationPolicy; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -26,20 +26,6 @@ class StoreCatalogItemRequest extends FormRequest return [ 'tenant_code' => ['prohibited'], 'type' => ['sometimes', Rule::enum(CatalogItemType::class)], - 'event_id' => [ - 'sometimes', - 'nullable', - 'required_with:event_product_type', - Rule::exists('events', 'id')->where( - fn ($query) => $query->where('tenant_code', $tenantCode) - ), - ], - 'event_product_type' => [ - 'sometimes', - 'nullable', - 'required_with:event_id', - Rule::enum(EventProductType::class), - ], 'category_id' => [ 'sometimes', 'nullable', @@ -68,9 +54,10 @@ class StoreCatalogItemRequest extends FormRequest 'descripcion' => ['sometimes', 'nullable', 'string'], 'precio' => ['required', 'numeric', 'min:0'], 'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)], + 'max_units_per_user' => ['sometimes', 'nullable', 'integer', 'min:1'], 'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'], - 'minimum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date'], - 'maximum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date', 'after_or_equal:minimum_use_date'], + 'ticket_generation_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(TicketGenerationPolicy::class)], + 'validity_time_id' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'integer', Rule::exists('validity_times', 'id')], 'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'], 'inventory_id' => ['prohibited'], 'reserved_stock' => ['prohibited'], @@ -84,30 +71,44 @@ class StoreCatalogItemRequest extends FormRequest fn ($query) => $query->where('tenant_codigo', $tenantCode) ), ], + 'multi_select_attribute_codes' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'], + 'multi_select_attribute_codes.*' => [ + 'required', + 'string', + 'distinct', + Rule::exists('attribute', 'codigo')->where( + fn ($query) => $query->where('tenant_codigo', $tenantCode) + ), + ], 'images' => ['sometimes', 'array'], 'images.*' => ['required', new ImageOrBase64Rule], 'variants' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'], 'variants.*.real_stock' => ['sometimes', 'integer', 'min:0'], + 'variants.*.descripcion' => ['sometimes', 'nullable', 'string'], + 'variants.*.precio' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:99999999.99'], 'variants.*.event_date_id' => [ 'sometimes', 'nullable', 'integer', Rule::exists('event_dates', 'id')->where( - fn ($query) => $query->where('event_id', $this->input('event_id')) + fn ($query) => $query->where('tenant_code', $tenantCode) + ), + ], + 'variants.*.event_date_ids' => ['sometimes', 'array', 'min:1'], + 'variants.*.event_date_ids.*' => [ + 'required', + 'integer', + 'distinct', + Rule::exists('event_dates', 'id')->where( + fn ($query) => $query->where('tenant_code', $tenantCode) ), ], 'variants.*.inventory_id' => ['prohibited'], 'variants.*.reserved_stock' => ['prohibited'], 'variants.*.sold_units' => ['prohibited'], - 'variants.*.minimum_use_date' => ['sometimes', 'nullable', 'date'], - 'variants.*.maximum_use_date' => [ - 'sometimes', - 'nullable', - 'date', - 'after_or_equal:variants.*.minimum_use_date', - ], 'variants.*.values' => ['sometimes', 'array'], - 'variants.*.values.*' => ['nullable', 'string'], + 'variants.*.values.*' => ['nullable'], + 'variants.*.values.*.*' => ['required', 'string'], 'variants.*.images' => ['sometimes', 'array'], 'variants.*.images.*' => ['required', new ImageOrBase64Rule], 'components' => [ diff --git a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php index 2c44aaf..c303174 100644 --- a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php @@ -7,6 +7,7 @@ use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Models\Variant; +use App\Domains\Ticket\Resources\ValidityTimeResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -30,20 +31,23 @@ class CatalogFeaturedItemResource extends JsonResource 'nombre' => $catalogItem->nombre, 'descripcion' => $catalogItem->descripcion, 'precio' => $catalogItem->precio, + 'ticket_generation_policy' => $catalogItem->ticket_generation_policy->value, + 'validity_time_id' => $catalogItem->validity_time_id, + 'validity_time' => ValidityTimeResource::make($catalogItem->validityTime), 'stock_tecnico' => $catalogItem->availableStock(), - 'variants' => $catalogItem->variants + 'variants' => $catalogItem->visibleVariants() ->map(fn (Variant $variant): array => [ 'id' => $variant->id, 'event_date_id' => $variant->event_date_id, 'event_date' => $variant->eventDate?->date?->format('Y-m-d'), + 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), + 'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(), + 'descripcion' => $variant->getDescription(), + 'precio' => number_format($variant->getPrice(), 2, '.', ''), 'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited ? null : $variant->inventory->availableStock(), - 'values' => $variant->definitions - ->mapWithKeys(fn ($definition) => [ - $definition->itemAttribute?->attribute?->codigo => $definition->value, - ]) - ->filter(fn ($value, $key): bool => $key !== null), + 'values' => $variant->selectionOptions($catalogItem->itemAttributes), ]) ->values(), ]; @@ -62,6 +66,9 @@ class CatalogFeaturedItemResource extends JsonResource 'type' => $catalogItem->type->value, 'nombre' => $catalogItem->nombre, 'precio' => $catalogItem->precio, + 'ticket_generation_policy' => $catalogItem->ticket_generation_policy->value, + 'validity_time_id' => $catalogItem->validity_time_id, + 'validity_time' => ValidityTimeResource::make($catalogItem->validityTime), 'image' => $attachment?->getTemporaryUrl(1440), ]; } diff --git a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php index 00fefe7..6fc11c1 100644 --- a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php @@ -6,6 +6,8 @@ use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\Variant; +use App\Domains\Shared\Enums\FieldType; +use App\Domains\Ticket\Resources\ValidityTimeResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Collection; @@ -22,8 +24,6 @@ class CatalogItemDetailResource extends JsonResource return [ 'id' => $this->id, 'type' => $this->type->value, - 'event_id' => $this->event_id, - 'event_product_type' => $this->event_product_type?->value, 'category_id' => $this->category_id, 'brand_id' => $this->brand_id, 'slug' => $this->slug, @@ -33,12 +33,14 @@ class CatalogItemDetailResource extends JsonResource 'category' => $this->category?->nombre, 'brand' => $this->brand?->nombre, 'inventory_policy' => $this->inventory_policy?->value, + 'max_units_per_user' => $this->max_units_per_user, 'has_tickets' => $this->has_tickets, - 'minimum_use_date' => $this->minimum_use_date, - 'maximum_use_date' => $this->maximum_use_date, - 'attributes' => $this->itemAttributes - ->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute)) - ->values(), + 'ticket_generation_policy' => $this->ticket_generation_policy->value, + 'validity_time_id' => $this->validity_time_id, + 'validity_time' => $this->validityTime === null + ? null + : ValidityTimeResource::make($this->validityTime), + 'attributes' => $this->attributesData(), 'stock_tecnico' => $this->when( $selectedVariant === null, fn () => $this->availableStock(), @@ -47,7 +49,7 @@ class CatalogItemDetailResource extends JsonResource $selectedVariant === null, fn () => $this->imageUrls($this->attachments), ), - 'variants' => $this->variants + 'variants' => $this->visibleVariants() ->map(fn (Variant $variant): array => $this->variantData($variant)) ->values(), 'selected_variant' => $this->when( @@ -80,6 +82,44 @@ class CatalogItemDetailResource extends JsonResource private function attributeData(ItemAttribute $itemAttribute): array { $attribute = $itemAttribute->attribute; + + return [ + 'id' => $attribute->id, + 'codigo' => $attribute->codigo, + 'nombre' => $attribute->nombre, + 'sort_order' => $itemAttribute->sort_order, + 'is_required' => $attribute->is_required, + 'allow_multi_select' => $itemAttribute->allow_multi_select, + 'metadata_schema' => $attribute->metadata_schema, + 'type' => $attribute->type->value, + 'options' => $attribute->type === FieldType::EventDate + ? $this->eventDateOptions($itemAttribute) + : $this->catalogAttributeOptions($itemAttribute), + ]; + } + + /** @return Collection> */ + private function eventDateOptions(ItemAttribute $itemAttribute): Collection + { + return $itemAttribute->attribute->eventDates + ->map(fn ($eventDate, int $index): array => [ + 'id' => $eventDate->id, + 'value' => (string) $eventDate->id, + 'label' => $eventDate->date->format('d/m/Y'), + 'sort_order' => $index, + 'validity_time_id' => $eventDate->validity_time_id, + 'validity_time' => ValidityTimeResource::make($eventDate->validityTime), + 'metadata' => [ + 'date' => $eventDate->date->format('Y-m-d'), + 'time_start' => $eventDate->time_start, + 'time_end' => $eventDate->time_end, + ], + ]); + } + + /** @return Collection> */ + private function catalogAttributeOptions(ItemAttribute $itemAttribute): Collection + { $availableValues = $this->variants ->flatMap->definitions ->where('item_attribute_id', $itemAttribute->id) @@ -87,47 +127,50 @@ class CatalogItemDetailResource extends JsonResource ->filter() ->unique(); - return [ - 'id' => $attribute->id, - 'codigo' => $attribute->codigo, - 'nombre' => $attribute->nombre, - 'is_required' => $attribute->is_required, - 'metadata_schema' => $attribute->metadata_schema, - 'type' => $attribute->type->value, - 'options' => $attribute->options - ->whereIn('value', $availableValues) - ->map(fn ($option): array => [ - 'id' => $option->id, - 'value' => $option->value, - 'label' => $option->label, - 'sort_order' => $option->sort_order, - 'metadata' => $option->metadata, - ]) - ->values(), - ]; + return $itemAttribute->attribute->options + ->whereIn('value', $availableValues) + ->map(fn ($option): array => [ + 'id' => $option->id, + 'value' => $option->value, + 'label' => $option->label, + 'sort_order' => $option->sort_order, + 'validity_time_id' => $option->validity_time_id, + 'validity_time' => $option->validityTime === null + ? null + : ValidityTimeResource::make($option->validityTime), + 'metadata' => $option->metadata, + ]) + ->values(); + } + + /** @return Collection> */ + private function attributesData(): Collection + { + return $this->itemAttributes + ->sort(function (ItemAttribute $left, ItemAttribute $right): int { + return $left->sort_order <=> $right->sort_order + ?: mb_strtolower($left->attribute->nombre) <=> mb_strtolower($right->attribute->nombre); + }) + ->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute)) + ->values(); } /** @return array */ private function variantData(Variant $variant): array { + $values = $variant->selectionOptions($this->itemAttributes); + $eventDates = $variant->selectedEventDates(); + return [ 'id' => $variant->id, 'event_date_id' => $variant->event_date_id, 'event_date' => $variant->eventDate?->date?->format('Y-m-d'), + 'event_date_ids' => $eventDates->pluck('id')->values(), + 'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(), + 'descripcion' => $variant->getDescription(), + 'precio' => number_format($variant->getPrice(), 2, '.', ''), 'stock_tecnico' => $this->variantStock($variant), - 'minimum_use_date' => $variant->minimum_use_date, - 'maximum_use_date' => $variant->maximum_use_date, - 'effective_minimum_use_date' => $variant->eventDate?->startsAt() - ?? $variant->minimum_use_date - ?? $this->minimum_use_date, - 'effective_maximum_use_date' => $variant->eventDate?->endsAt() - ?? $variant->maximum_use_date - ?? $this->maximum_use_date, - 'values' => $variant->definitions - ->mapWithKeys(fn ($definition) => [ - $definition->itemAttribute?->attribute?->codigo => $definition->value, - ]) - ->filter(fn ($value, $key): bool => $key !== null), + 'values' => $values, ]; } diff --git a/app/Domains/Catalog/Resources/CatalogItemResource.php b/app/Domains/Catalog/Resources/CatalogItemResource.php index 363a73c..e25495a 100644 --- a/app/Domains/Catalog/Resources/CatalogItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemResource.php @@ -3,6 +3,7 @@ namespace App\Domains\Catalog\Resources; use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Ticket\Resources\ValidityTimeResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -15,8 +16,6 @@ class CatalogItemResource extends JsonResource return [ 'id' => $this->id, 'type' => $this->type->value, - 'event_id' => $this->event_id, - 'event_product_type' => $this->event_product_type?->value, 'category_id' => $this->category_id, 'brand_id' => $this->brand_id, 'slug' => $this->slug, @@ -24,9 +23,14 @@ class CatalogItemResource extends JsonResource 'descripcion' => $this->descripcion, 'precio' => $this->precio, 'inventory_policy' => $this->inventory_policy?->value, + 'max_units_per_user' => $this->max_units_per_user, 'has_tickets' => $this->has_tickets, - 'minimum_use_date' => $this->minimum_use_date, - 'maximum_use_date' => $this->maximum_use_date, + 'ticket_generation_policy' => $this->ticket_generation_policy->value, + 'validity_time_id' => $this->validity_time_id, + 'validity_time' => $this->whenLoaded( + 'validityTime', + fn () => ValidityTimeResource::make($this->validityTime), + ), 'real_stock' => $this->whenLoaded('inventory', fn () => $this->inventory?->real_stock), 'images' => $this->whenLoaded('attachments', fn () => $this->attachments ->map(fn ($attachment) => $attachment->getTemporaryUrl(1440)) @@ -36,20 +40,12 @@ class CatalogItemResource extends JsonResource 'id' => $variant->id, 'event_date_id' => $variant->event_date_id, 'event_date' => $variant->eventDate?->date?->format('Y-m-d'), + 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), + 'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(), + 'descripcion' => $variant->getDescription(), + 'precio' => number_format($variant->getPrice(), 2, '.', ''), 'real_stock' => $variant->inventory?->real_stock, - 'minimum_use_date' => $variant->minimum_use_date, - 'maximum_use_date' => $variant->maximum_use_date, - 'effective_minimum_use_date' => $variant->eventDate?->startsAt() - ?? $variant->minimum_use_date - ?? $this->minimum_use_date, - 'effective_maximum_use_date' => $variant->eventDate?->endsAt() - ?? $variant->maximum_use_date - ?? $this->maximum_use_date, - 'values' => $variant->definitions - ->mapWithKeys(fn ($definition) => [ - $definition->itemAttribute?->attribute?->codigo => $definition->value, - ]) - ->filter(fn ($value, $key) => $key !== null), + 'values' => $variant->selectionOptions($this->itemAttributes), 'images' => $variant->attachments ->map(fn ($attachment) => $attachment->getTemporaryUrl(1440)) ->values(), diff --git a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php index 3c83e7a..ba12b94 100644 --- a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php @@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Resources; use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; +use App\Domains\Ticket\Resources\ValidityTimeResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -25,21 +26,24 @@ class CatalogSearchItemResource extends JsonResource 'nombre' => $this->nombre, 'descripcion' => $this->descripcion, 'precio' => $this->precio, + 'ticket_generation_policy' => $this->ticket_generation_policy->value, + 'validity_time_id' => $this->validity_time_id, + 'validity_time' => ValidityTimeResource::make($this->validityTime), 'image' => $attachment?->getTemporaryUrl(1440), 'stock_tecnico' => $this->availableStock(), - 'variants' => $this->variants + 'variants' => $this->visibleVariants() ->map(fn (Variant $variant): array => [ 'id' => $variant->id, 'event_date_id' => $variant->event_date_id, 'event_date' => $variant->eventDate?->date?->format('Y-m-d'), + 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), + 'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(), + 'descripcion' => $variant->getDescription(), + 'precio' => number_format($variant->getPrice(), 2, '.', ''), 'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited ? null : $variant->inventory?->availableStock(), - 'values' => $variant->definitions - ->mapWithKeys(fn ($definition) => [ - $definition->itemAttribute?->attribute?->codigo => $definition->value, - ]) - ->filter(fn ($value, $key): bool => $key !== null), + 'values' => $variant->selectionOptions($this->itemAttributes), ]) ->values(), ]; diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php index 3896ca8..f2edfe3 100644 --- a/app/Domains/Catalog/Services/CatalogService.php +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -5,14 +5,12 @@ namespace App\Domains\Catalog\Services; use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Services\AttachmentService; use App\Domains\Catalog\Enums\CatalogItemType; -use App\Domains\Catalog\Enums\EventProductType; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\Variant; -use App\Domains\Event\Models\Event; use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Builder; @@ -38,16 +36,35 @@ class CatalogService $variants = $data['variants'] ?? []; $images = $data['images'] ?? []; $attributeCodes = $data['attribute_codes'] ?? []; + $multiSelectAttributeCodes = $data['multi_select_attribute_codes'] ?? []; $components = $data['components'] ?? []; $hasDirectStock = array_key_exists('real_stock', $data); $realStock = (int) ($data['real_stock'] ?? 0); $hasEventDateVariants = $variants !== [] && collect($variants)->every( fn (array $variant): bool => ! empty($variant['event_date_id']) + || ! empty($variant['event_date_ids']) ); + + if ($hasEventDateVariants && ! in_array('event_date', $attributeCodes, true)) { + throw ValidationException::withMessages([ + 'attribute_codes' => [ + __('api.catalog.event_date_attribute_required'), + ], + ]); + } + $hasVariants = $attributeCodes !== [] || $hasEventDateVariants; - $this->validateEventData($data); + if (array_diff($multiSelectAttributeCodes, $attributeCodes) !== []) { + throw ValidationException::withMessages([ + 'multi_select_attribute_codes' => [ + __('api.catalog.multi_select_attribute_not_on_item'), + ], + ]); + } + + $this->validateUniqueVariantCombinations($variants, $attributeCodes); if ($type === CatalogItemType::Bundle) { $this->validateBundleData($data, $components); @@ -70,6 +87,7 @@ class CatalogService $data['variants'], $data['images'], $data['attribute_codes'], + $data['multi_select_attribute_codes'], $data['components'], $data['real_stock'], $data['reserved_stock'], @@ -83,8 +101,6 @@ class CatalogService $data['inventory_id'] = null; $data['inventory_policy'] = null; $data['has_tickets'] = false; - $data['minimum_use_date'] = null; - $data['maximum_use_date'] = null; } elseif ($hasVariants) { $data['inventory_id'] = null; } else { @@ -93,7 +109,7 @@ class CatalogService $catalogItem = CatalogItem::query()->create($data); $itemAttributes = $type === CatalogItemType::Standard - ? $this->createItemAttributes($catalogItem, $attributeCodes) + ? $this->createItemAttributes($catalogItem, $attributeCodes, $multiSelectAttributeCodes) : []; if ($type === CatalogItemType::Bundle) { @@ -129,12 +145,13 @@ class CatalogService 'inventory', 'category', 'brand', - 'event', + 'validityTime', 'itemAttributes.attribute', 'variants.inventory', 'variants.attachments', 'variants.eventDate', - 'variants.definitions.itemAttribute.attribute', + 'variants.eventDates', + 'variants.definitions.itemAttribute.attribute.options', 'bundleComponents.catalogItem', 'bundleComponents.variant.catalogItem', ]); @@ -148,22 +165,25 @@ class CatalogService 'inventory', 'category', 'brand', - 'event', - 'itemAttributes.attribute.options', + 'validityTime', + 'itemAttributes.attribute.options.validityTime', + 'itemAttributes.attribute.eventDates.validityTime', 'variants' => fn ($query) => $query->orderBy('id'), 'variants.inventory', 'variants.attachments', 'variants.eventDate', + 'variants.eventDates', 'variants.definitions' => fn ($query) => $query->orderBy('id'), - 'variants.definitions.itemAttribute.attribute', + 'variants.definitions.itemAttribute.attribute.options', 'bundleComponents.catalogItem.inventory', 'bundleComponents.variant.inventory', 'bundleComponents.variant.definitions.itemAttribute.attribute', ]); + $visibleVariants = $catalogItem->visibleVariants(); $selectedVariant = $variantId === null - ? $catalogItem->variants->first() - : $catalogItem->variants->firstWhere('id', $variantId); + ? $visibleVariants->first() + : $visibleVariants->firstWhere('id', $variantId); if ($variantId !== null && $selectedVariant === null) { throw new NotFoundHttpException('Variant not found for catalog item.'); @@ -187,6 +207,7 @@ class CatalogService $paginator = CatalogItem::query() ->where('tenant_code', $tenant->codigo) + ->whereVariantsAvailable() ->where(function (Builder $query) use ($containsPattern): void { $query ->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern]) @@ -205,10 +226,13 @@ class CatalogService ->with([ 'attachments', 'inventory', + 'validityTime', + 'itemAttributes.attribute', 'variants.inventory', 'variants.attachments', 'variants.eventDate', - 'variants.definitions.itemAttribute.attribute', + 'variants.eventDates', + 'variants.definitions.itemAttribute.attribute.options', 'bundleComponents.catalogItem', 'bundleComponents.variant.catalogItem', ]) @@ -234,13 +258,17 @@ class CatalogService return CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->where('category_id', $category->id) + ->whereVariantsAvailable() ->with([ 'attachments', 'inventory', + 'validityTime', + 'itemAttributes.attribute', 'variants.inventory', 'variants.attachments', 'variants.eventDate', - 'variants.definitions.itemAttribute.attribute', + 'variants.eventDates', + 'variants.definitions.itemAttribute.attribute.options', 'bundleComponents.catalogItem', 'bundleComponents.variant.catalogItem', ]) @@ -280,6 +308,39 @@ class CatalogService }); } + public function deleteVariant(Variant $variant): void + { + DB::transaction(function () use ($variant): void { + $variant = Variant::query() + ->with('attachments') + ->lockForUpdate() + ->findOrFail($variant->getKey()); + $catalogItem = CatalogItem::query() + ->lockForUpdate() + ->findOrFail($variant->catalog_item_id); + $attachments = $variant->attachments; + $inventoryId = $variant->inventory_id; + + $variant->attachments()->detach(); + $variant->delete(); + Inventory::query()->whereKey($inventoryId)->delete(); + + $minimumPrice = $catalogItem->variants()->min('precio'); + + if ($minimumPrice === null) { + $this->delete($catalogItem); + } else { + $catalogItem->update(['precio' => $minimumPrice]); + } + + foreach ($attachments as $attachment) { + if (! DB::table('catalog_items_attachments')->where('attachment_id', $attachment->id)->exists()) { + $this->attachmentService->delete($attachment); + } + } + }); + } + private function createInventory(int $realStock): Inventory { return Inventory::query()->create([ @@ -381,8 +442,8 @@ class CatalogService 'attribute_codes', 'variants', 'has_tickets', - 'minimum_use_date', - 'maximum_use_date', + 'ticket_generation_policy', + 'validity_time_id', ] as $field) { if (array_key_exists($field, $data)) { throw ValidationException::withMessages([ @@ -426,11 +487,13 @@ class CatalogService /** * @param array $attributeCodes + * @param array $multiSelectAttributeCodes * @return array */ private function createItemAttributes( CatalogItem $catalogItem, array $attributeCodes, + array $multiSelectAttributeCodes = [], ): array { $itemAttributes = []; $attributeCodes = array_values(array_unique($attributeCodes)); @@ -453,6 +516,7 @@ class CatalogService $itemAttribute = $catalogItem->itemAttributes()->create([ 'attribute_id' => $attribute->id, + 'allow_multi_select' => in_array($attributeCode, $multiSelectAttributeCodes, true), ]); $itemAttributes[$attributeCode] = $itemAttribute; @@ -486,35 +550,53 @@ class CatalogService } $inventory = $this->createInventory((int) ($data['real_stock'] ?? 0)); - $eventDateId = $data['event_date_id'] ?? null; - - if ( - $eventDateId !== null - && ( - $catalogItem->event_id === null - || ! EventDate::query() - ->whereKey($eventDateId) - ->where('event_id', $catalogItem->event_id) - ->exists() + $eventDateIds = collect($data['event_date_ids'] ?? []) + ->when( + isset($data['event_date_id']), + fn ($ids) => $ids->push($data['event_date_id']), ) - ) { + ->filter(fn ($id): bool => $id !== null) + ->map(fn ($id): int => (int) $id) + ->unique() + ->sort() + ->values(); + + $eventDateItemAttribute = $itemAttributes['event_date'] ?? null; + if ($eventDateItemAttribute !== null && ( + $eventDateIds->isEmpty() + || (! $eventDateItemAttribute->allow_multi_select && $eventDateIds->count() !== 1) + )) { throw ValidationException::withMessages([ - "variants.{$index}.event_date_id" => [ - 'The event date must belong to the catalog item event.', + "variants.{$index}.event_date_ids" => [ + $eventDateItemAttribute->allow_multi_select + ? __('api.catalog.event_date_selection_required') + : __('api.catalog.single_event_date_required'), + ], + ]); + } + + $validEventDateCount = EventDate::query() + ->whereKey($eventDateIds) + ->where('tenant_code', $catalogItem->tenant_code) + ->count(); + + if ($validEventDateCount !== $eventDateIds->count()) { + throw ValidationException::withMessages([ + "variants.{$index}.event_date_ids" => [ + __('api.catalog.event_date_wrong_tenant'), ], ]); } $variant = $catalogItem->variants()->create([ 'inventory_id' => $inventory->id, - 'event_date_id' => $eventDateId, - 'minimum_use_date' => $data['minimum_use_date'] ?? null, - 'maximum_use_date' => $data['maximum_use_date'] ?? null, + 'event_date_id' => $eventDateIds->count() === 1 ? $eventDateIds->first() : null, + 'descripcion' => $data['descripcion'] ?? null, + 'precio' => $data['precio'] ?? null, ]); + $variant->eventDates()->sync($eventDateIds->all()); $variant->setRelation('catalogItem', $catalogItem); - $this->validateVariantUseDates($variant, $index); - foreach ($data['values'] ?? [] as $attributeCode => $value) { $itemAttribute = $itemAttributes[$attributeCode] ?? null; @@ -526,63 +608,130 @@ class CatalogService ]); } - $variant->definitions()->create([ - 'item_attribute_id' => $itemAttribute->id, - 'value' => $value, - ]); + foreach ($this->validatedVariantValues( + $itemAttribute, + $value, + "variants.{$index}.values.{$attributeCode}", + ) as $validatedValue) { + $variant->definitions()->create([ + 'item_attribute_id' => $itemAttribute->id, + 'value' => $validatedValue, + ]); + } } return $variant; } - /** @param array $data */ - private function validateEventData(array $data): void + /** + * @param array> $variants + * @param array $attributeCodes + */ + private function validateUniqueVariantCombinations(array $variants, array $attributeCodes): void { - $eventId = $data['event_id'] ?? null; - $eventProductType = $data['event_product_type'] ?? null; + $seen = []; + $attributeCodes = array_values(array_unique($attributeCodes)); + sort($attributeCodes); - if (($eventId === null) !== ($eventProductType === null)) { - throw ValidationException::withMessages([ - 'event_id' => ['Event and event product type must be provided together.'], - ]); - } + foreach (array_values($variants) as $index => $variant) { + $eventDateIds = collect($variant['event_date_ids'] ?? []) + ->when( + isset($variant['event_date_id']), + fn ($ids) => $ids->push($variant['event_date_id']), + ) + ->map(fn ($id): int => (int) $id) + ->unique() + ->sort() + ->values() + ->implode(','); + $combination = [$eventDateIds]; - if ($eventId === null) { - return; - } + foreach ($attributeCodes as $attributeCode) { + $values = $variant['values'][$attributeCode] ?? ''; + $normalizedValues = collect(is_array($values) ? $values : [$values]) + ->map(fn ($value): string => $this->normalizeVariantValue((string) $value)) + ->unique() + ->sort() + ->values() + ->implode(','); + $combination[] = $normalizedValues; + } - if (! Event::query() - ->whereKey($eventId) - ->where('tenant_code', $data['tenant_code'] ?? null) - ->exists()) { - throw ValidationException::withMessages([ - 'event_id' => ['The event must belong to the catalog item tenant.'], - ]); - } + $key = implode('|', $combination); + if (isset($seen[$key])) { + throw ValidationException::withMessages([ + "variants.{$index}" => [__('api.catalog.duplicate_variant_combination')], + ]); + } - if (! in_array($eventProductType, EventProductType::values(), true)) { - throw ValidationException::withMessages([ - 'event_product_type' => ['The event product type is invalid.'], - ]); + $seen[$key] = true; } } - private function validateVariantUseDates(Variant $variant, int $index): void - { - $minimumUseDate = $variant->getMinimumUseDate(); - $maximumUseDate = $variant->getMaximumUseDate(); + /** @return array */ + private function validatedVariantValues( + ItemAttribute $itemAttribute, + mixed $value, + string $validationKey, + ): array { + $values = is_array($value) ? array_values($value) : [$value]; - if ( - $minimumUseDate !== null - && $maximumUseDate !== null - && $maximumUseDate->lessThan($minimumUseDate) - ) { + if ($values === [] || (! $itemAttribute->allow_multi_select && count($values) !== 1)) { throw ValidationException::withMessages([ - "variants.{$index}.maximum_use_date" => [ - __('api.catalog.invalid_effective_date_range'), + $validationKey => [ + $itemAttribute->allow_multi_select + ? __('api.catalog.multi_value_required') + : __('api.catalog.single_value_required'), ], ]); } + + if (collect($values)->contains(fn ($item): bool => ! is_string($item) || trim($item) === '')) { + throw ValidationException::withMessages([ + $validationKey => [__('api.catalog.selected_values_non_empty')], + ]); + } + + $normalizedValues = collect($values) + ->map(fn (string $item): string => $this->normalizeVariantValue($item)); + + if ($normalizedValues->unique()->count() !== count($values)) { + throw ValidationException::withMessages([ + $validationKey => [__('api.catalog.selected_values_distinct')], + ]); + } + + $attribute = $itemAttribute->attribute; + if ($attribute->type->supportsOptions() && ! $attribute->type->usesDynamicOptions()) { + $optionsByNormalizedValue = $attribute->options + ->keyBy(fn ($option): string => $this->normalizeVariantValue($option->value)); + + $resolvedOptions = $normalizedValues->map(fn (string $normalizedValue) => $optionsByNormalizedValue->get($normalizedValue)); + if ($resolvedOptions->contains(null)) { + throw ValidationException::withMessages([ + $validationKey => [__('api.catalog.invalid_attribute_options')], + ]); + } + + $validityTimeIds = $resolvedOptions + ->pluck('validity_time_id') + ->filter() + ->unique(); + if ($validityTimeIds->count() > 1) { + throw ValidationException::withMessages([ + $validationKey => [__('api.catalog.incompatible_validity_windows')], + ]); + } + + return $resolvedOptions->pluck('value')->all(); + } + + return collect($values)->map(fn (string $item): string => trim($item))->all(); + } + + private function normalizeVariantValue(string $value): string + { + return Str::ascii(mb_strtolower(trim($value))); } /** diff --git a/app/Domains/Catalog/Services/FeaturedGroupService.php b/app/Domains/Catalog/Services/FeaturedGroupService.php index 3d68dcb..a7fe410 100644 --- a/app/Domains/Catalog/Services/FeaturedGroupService.php +++ b/app/Domains/Catalog/Services/FeaturedGroupService.php @@ -37,13 +37,17 @@ class FeaturedGroupService { $query = CatalogItem::query() ->where('catalog_items.tenant_code', $featuredGroup->tenant_code) + ->whereVariantsAvailable() ->with([ 'inventory', 'attachments', + 'validityTime', + 'itemAttributes.attribute', 'variants.inventory', 'variants.attachments', 'variants.eventDate', - 'variants.definitions.itemAttribute.attribute', + 'variants.eventDates', + 'variants.definitions.itemAttribute.attribute.options', 'bundleComponents.catalogItem', 'bundleComponents.variant.catalogItem', ]); diff --git a/app/Domains/Catalog/documentacion/README.md b/app/Domains/Catalog/documentacion/README.md new file mode 100644 index 0000000..9ecf3aa --- /dev/null +++ b/app/Domains/Catalog/documentacion/README.md @@ -0,0 +1,33 @@ +# Dominio Catalog + +## Propósito + +Modela y publica la oferta comercial del tenant: productos, variantes, categorías, marcas, atributos, inventario, bundles y grupos destacados. + +## Modelo + +- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados. +- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables. +- `Inventory` administra stock disponible, reservado y comprado. +- `Category` soporta jerarquía y categorías globales o propias del tenant. +- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas. +- `BundleComponent` representa los componentes de un paquete. + +## Servicios + +- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación. +- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario. +- `FeaturedGroupService`: pagina los ítems destacados para la tienda. +- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets. + +## Endpoints de tienda + +Bajo `/tenants/{tenant:codigo}` se publican catálogo, búsqueda, categoría, detalle, alta de ítems y paginación de grupos destacados. + +## Endpoints administrativos + +Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenant`, se listan, crean y actualizan grupos destacados. + +## Dependencias y reglas + +Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad. diff --git a/app/Domains/Event/Controllers/AdminApp/EventController.php b/app/Domains/Event/Controllers/AdminApp/EventController.php index a31c2c9..b6036d9 100644 --- a/app/Domains/Event/Controllers/AdminApp/EventController.php +++ b/app/Domains/Event/Controllers/AdminApp/EventController.php @@ -15,14 +15,14 @@ class EventController extends Controller public function show(Request $request): EventResource { return EventResource::make( - $this->eventService->activeForTenant($request->user()->tenant()->firstOrFail()) + $this->eventService->forTenant($request->user()->tenant()->firstOrFail()) ); } public function update(UpdateEventRequest $request): EventResource { return EventResource::make( - $this->eventService->updateActiveForTenant( + $this->eventService->updateForTenant( $request->user()->tenant()->firstOrFail(), $request->validated() ) diff --git a/app/Domains/Event/Models/Event.php b/app/Domains/Event/Models/Event.php deleted file mode 100644 index 15582f7..0000000 --- a/app/Domains/Event/Models/Event.php +++ /dev/null @@ -1,48 +0,0 @@ - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); - } - - /** @return HasMany */ - public function dates(): HasMany - { - return $this->hasMany(EventDate::class)->orderBy('date')->orderBy('time_start'); - } - - /** @return HasMany */ - public function catalogItems(): HasMany - { - return $this->hasMany(CatalogItem::class); - } - - /** @return HasMany */ - public function purchases(): HasMany - { - return $this->hasMany(Purchase::class); - } -} diff --git a/app/Domains/Event/Models/EventDate.php b/app/Domains/Event/Models/EventDate.php index 48714e6..665bacd 100644 --- a/app/Domains/Event/Models/EventDate.php +++ b/app/Domains/Event/Models/EventDate.php @@ -3,16 +3,21 @@ namespace App\Domains\Event\Models; use App\Domains\Catalog\Models\Variant; +use App\Domains\Event\Services\EventDateTextFormatter; +use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\ValidityTimeType; +use App\Domains\Ticket\Models\ValidityTime; use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Carbon; #[Fillable([ - 'event_id', + 'tenant_code', 'date', 'time_start', 'time_end', @@ -23,18 +28,44 @@ class EventDate extends Model public $timestamps = false; + protected static function booted(): void + { + static::creating(fn (self $eventDate) => $eventDate->syncValidityTime()); + static::created(fn (self $eventDate) => $eventDate->syncTenantDateText()); + static::updated(function (self $eventDate): void { + if ($eventDate->wasChanged(['date', 'time_start', 'time_end'])) { + $eventDate->syncValidityTime(); + } + + $eventDate->syncTenantDateText(); + }); + static::deleted(function (self $eventDate): void { + $eventDate->syncTenantDateText(); + ValidityTime::query() + ->whereKey($eventDate->validity_time_id) + ->whereDoesntHave('ticketValidityGroups') + ->delete(); + }); + } + protected function casts(): array { return [ - 'event_id' => 'integer', 'date' => 'date:Y-m-d', + 'validity_time_id' => 'integer', ]; } - /** @return BelongsTo */ - public function event(): BelongsTo + /** @return BelongsTo */ + public function tenant(): BelongsTo { - return $this->belongsTo(Event::class); + return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); + } + + /** @return BelongsTo */ + public function validityTime(): BelongsTo + { + return $this->belongsTo(ValidityTime::class); } /** @return HasMany */ @@ -43,6 +74,17 @@ class EventDate extends Model return $this->hasMany(Variant::class); } + /** @return BelongsToMany */ + public function selectedByVariants(): BelongsToMany + { + return $this->belongsToMany( + Variant::class, + 'variant_event_dates', + 'event_date_id', + 'variant_id', + ); + } + public function startsAt(): CarbonInterface { return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_start); @@ -52,4 +94,48 @@ class EventDate extends Model { return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end); } + + private function syncTenantDateText(): void + { + $tenant = $this->tenant()->first(); + + if (! $tenant) { + return; + } + + $tenant->update([ + 'event_date_text' => app(EventDateTextFormatter::class)->format( + $tenant->eventDates()->pluck('date') + ), + ]); + } + + private function syncValidityTime(): void + { + $startsAt = $this->startsAt(); + $expiresAt = $this->endsAt(); + + if ($expiresAt->lessThanOrEqualTo($startsAt)) { + $expiresAt = $expiresAt->addDay(); + } + + $attributes = [ + 'type' => ValidityTimeType::FixedWindow, + 'start_time' => null, + 'end_time' => null, + 'fixed_starts_at' => $startsAt, + 'fixed_expires_at' => $expiresAt, + ]; + + if ($this->validity_time_id === null) { + $validityTime = ValidityTime::query()->create($attributes); + $this->validity_time_id = $validityTime->getKey(); + $this->setRelation('validityTime', $validityTime); + + return; + } + + $this->validityTime()->update($attributes); + $this->unsetRelation('validityTime'); + } } diff --git a/app/Domains/Event/Resources/EventResource.php b/app/Domains/Event/Resources/EventResource.php index ef566a3..1dd4735 100644 --- a/app/Domains/Event/Resources/EventResource.php +++ b/app/Domains/Event/Resources/EventResource.php @@ -2,29 +2,32 @@ namespace App\Domains\Event\Resources; -use App\Domains\Event\Models\Event; +use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Resources\ValidityTimeResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; -/** @mixin Event */ +/** @mixin Tenant */ class EventResource extends JsonResource { /** @return array */ public function toArray(Request $request): array { - $socialMedia = $this->tenant->socialMedia->keyBy('code'); + $socialMedia = $this->socialMedia->keyBy('code'); return [ 'id' => $this->id, - 'title' => $this->name, - 'location' => $this->address, - 'dates' => $this->dates->map(fn ($eventDate): array => [ + 'title' => $this->event_title, + 'location' => $this->event_location, + 'dates' => $this->eventDates->map(fn ($eventDate): array => [ 'id' => $eventDate->id, + 'validity_time_id' => $eventDate->validity_time_id, + 'validity_time' => ValidityTimeResource::make($eventDate->validityTime), 'date' => $eventDate->date->format('Y-m-d'), 'start_time' => substr($eventDate->time_start, 0, 5), 'end_time' => substr($eventDate->time_end, 0, 5), ])->values(), - 'social_media' => $this->tenant->socialMedia->map(fn ($item): array => [ + 'social_media' => $this->socialMedia->map(fn ($item): array => [ 'code' => $item->code, 'url' => $item->pivot->url, 'orden' => $item->pivot->orden, diff --git a/app/Domains/Event/Services/EventDateTextFormatter.php b/app/Domains/Event/Services/EventDateTextFormatter.php new file mode 100644 index 0000000..2731390 --- /dev/null +++ b/app/Domains/Event/Services/EventDateTextFormatter.php @@ -0,0 +1,73 @@ + */ + private const MONTHS = [ + 1 => 'Enero', + 2 => 'Febrero', + 3 => 'Marzo', + 4 => 'Abril', + 5 => 'Mayo', + 6 => 'Junio', + 7 => 'Julio', + 8 => 'Agosto', + 9 => 'Septiembre', + 10 => 'Octubre', + 11 => 'Noviembre', + 12 => 'Diciembre', + ]; + + /** @param iterable $dates */ + public function format(iterable $dates): ?string + { + $normalizedDates = collect($dates) + ->map(fn (string $date): DateTimeImmutable => new DateTimeImmutable($date)) + ->unique(fn (DateTimeImmutable $date): string => $date->format('Y-m-d')) + ->sortBy(fn (DateTimeImmutable $date): string => $date->format('Y-m-d')) + ->values(); + + if ($normalizedDates->isEmpty()) { + return null; + } + + $years = $normalizedDates + ->groupBy(fn (DateTimeImmutable $date): string => $date->format('Y')) + ->map(function ($yearDates, string $year): string { + $months = $yearDates + ->groupBy(fn (DateTimeImmutable $date): string => $date->format('n')) + ->map(function ($monthDates, string $month): string { + $days = $monthDates + ->map(fn (DateTimeImmutable $date): string => (string) ((int) $date->format('j'))) + ->values() + ->all(); + + return $this->join($days).' de '.self::MONTHS[(int) $month]; + }) + ->values() + ->all(); + + return $this->join($months).' '.$year; + }) + ->values() + ->all(); + + return $this->join($years); + } + + /** @param array $parts */ + private function join(array $parts): string + { + if (count($parts) <= 1) { + return $parts[0] ?? ''; + } + + $last = array_pop($parts); + + return implode(', ', $parts).' y '.$last; + } +} diff --git a/app/Domains/Event/Services/EventService.php b/app/Domains/Event/Services/EventService.php index e8d610f..308814e 100644 --- a/app/Domains/Event/Services/EventService.php +++ b/app/Domains/Event/Services/EventService.php @@ -2,7 +2,6 @@ namespace App\Domains\Event\Services; -use App\Domains\Event\Models\Event; use App\Domains\Tenant\Models\Tenant; use Illuminate\Support\Facades\DB; @@ -14,53 +13,36 @@ class EventService 'facebook_url' => 'facebook', ]; - public function activeForTenant(Tenant $tenant): Event + public function forTenant(Tenant $tenant): Tenant { - return $tenant->events() - ->whereKey($tenant->active_event_id) - ->with(['dates', 'tenant.socialMedia']) - ->firstOrFail(); + return $tenant->load(['eventDates.validityTime', 'socialMedia']); } /** @param array $data */ - public function updateActiveForTenant(Tenant $tenant, array $data): Event + public function updateForTenant(Tenant $tenant, array $data): Tenant { - return DB::transaction(function () use ($tenant, $data): Event { + return DB::transaction(function () use ($tenant, $data): Tenant { $tenant = Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail(); - $event = $tenant->active_event_id === null - ? $tenant->events()->create([ - 'name' => $data['title'], - 'address' => $data['location'], - ]) - : $tenant->events() - ->whereKey($tenant->active_event_id) - ->lockForUpdate() - ->firstOrFail(); - - $event->update([ - 'name' => $data['title'], - 'address' => $data['location'], + $tenant->update([ + 'event_title' => $data['title'], + 'event_location' => $data['location'], ]); - if ($tenant->active_event_id === null) { - $tenant->update(['active_event_id' => $event->id]); - } - - $this->syncDates($event, $data['dates']); + $this->syncDates($tenant, $data['dates']); if (array_key_exists('social_media', $data)) { $this->syncSocialMedia($tenant, $data['social_media']); } else { $this->syncLegacyContact($tenant, $data['contact']); } - return $event->load(['dates', 'tenant.socialMedia']); + return $tenant->load(['eventDates.validityTime', 'socialMedia']); }); } /** @param array $dates */ - private function syncDates(Event $event, array $dates): void + private function syncDates(Tenant $tenant, array $dates): void { - $existingDates = $event->dates()->get()->values(); + $existingDates = $tenant->eventDates()->get()->values(); foreach (array_values($dates) as $index => $date) { $attributes = [ @@ -74,12 +56,12 @@ class EventService if ($existingDate) { $existingDate->update($attributes); } else { - $event->dates()->create($attributes); + $tenant->eventDates()->create($attributes); } } $existingDates->slice(count($dates))->each->delete(); - $event->unsetRelation('dates'); + $tenant->unsetRelation('eventDates'); } /** @param array $contact */ diff --git a/app/Domains/Event/documentacion/README.md b/app/Domains/Event/documentacion/README.md new file mode 100644 index 0000000..6959a0b --- /dev/null +++ b/app/Domains/Event/documentacion/README.md @@ -0,0 +1,28 @@ +# Dominio Event + +## Propósito + +Administra la configuración temporal de un tenant orientado a eventos y sus fechas disponibles. + +## Componentes + +- `Models/EventDate.php`: fecha del evento con inicio, fin, tenant y variantes asociadas. +- `Services/EventService.php`: obtiene y actualiza la configuración de evento del tenant. +- `Controllers/AdminApp/EventController.php`: consulta y modificación desde AdminApp. +- `UpdateEventRequest`: valida datos y reglas cruzadas de fechas. +- `EventResource`: serializa la configuración de salida. + +## Endpoints + +Bajo `/v1/adminapp/tenant/event`, protegidos por `auth:sanctum` y `adminapp.tenant`: + +- `GET`: obtiene la configuración. +- `PUT`: actualiza la configuración. + +## Dependencias + +Depende de `Tenant`. Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar tickets. + +## Consideraciones + +El archivo `routes/api.php` no publica operaciones adicionales. Al modificar fechas debe mantenerse la validación de orden y coherencia temporal de `UpdateEventRequest`. diff --git a/app/Domains/FiestaFutbolInfantil/Controllers/AccommodationController.php b/app/Domains/FiestaFutbolInfantil/Controllers/AccommodationController.php new file mode 100644 index 0000000..804c320 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Controllers/AccommodationController.php @@ -0,0 +1,44 @@ +accommodationService->current($request->user()->tenant()->firstOrFail()) + ); + } + + public function store(UpsertAccommodationVariantsRequest $request): AccommodationResource + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return AccommodationResource::make( + $this->accommodationService->upsertMany( + $tenant, + $request->validated('variants'), + ) + ); + } + + public function destroy(Request $request, int $accommodation): Response + { + $this->accommodationService->delete( + $request->user()->tenant()->firstOrFail(), + $accommodation, + ); + + return response()->noContent(); + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Controllers/EntryController.php b/app/Domains/FiestaFutbolInfantil/Controllers/EntryController.php new file mode 100644 index 0000000..9203341 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Controllers/EntryController.php @@ -0,0 +1,48 @@ +entryService->all($request->user()->tenant()->firstOrFail()) + ); + } + + public function store(UpsertEntriesRequest $request): JsonResponse + { + $tenant = $request->user()->tenant()->firstOrFail(); + + $entries = $this->entryService->upsertMany( + $tenant, + $request->validated('entries'), + ); + + return EntryResource::collection($entries) + ->response() + ->setStatusCode(200); + } + + public function destroy(Request $request, int $entry): Response + { + $this->entryService->delete( + $request->user()->tenant()->firstOrFail(), + $entry, + ); + + return response()->noContent(); + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php b/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php new file mode 100644 index 0000000..109a836 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php @@ -0,0 +1,44 @@ +foodService->current($request->user()->tenant()->firstOrFail()) + ); + } + + public function store(UpsertFoodVariantsRequest $request): FoodResource + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return FoodResource::make( + $this->foodService->upsertMany( + $tenant, + $request->validated('variants'), + ) + ); + } + + public function destroy(Request $request, int $food): Response + { + $this->foodService->delete( + $request->user()->tenant()->firstOrFail(), + $food, + ); + + return response()->noContent(); + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Controllers/MerchandiseController.php b/app/Domains/FiestaFutbolInfantil/Controllers/MerchandiseController.php new file mode 100644 index 0000000..cbce5a5 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Controllers/MerchandiseController.php @@ -0,0 +1,47 @@ +merchandiseService->all($request->user()->tenant()->firstOrFail()) + ); + } + + public function store(UpsertMerchandiseRequest $request): JsonResponse + { + $tenant = $request->user()->tenant()->firstOrFail(); + $items = $this->merchandiseService->upsertMany( + $tenant, + $request->validated('items'), + ); + + return MerchandiseResource::collection($items) + ->response() + ->setStatusCode(200); + } + + public function destroy(Request $request, int $merchandise): Response + { + $this->merchandiseService->delete( + $request->user()->tenant()->firstOrFail(), + $merchandise, + ); + + return response()->noContent(); + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Requests/UpsertAccommodationVariantsRequest.php b/app/Domains/FiestaFutbolInfantil/Requests/UpsertAccommodationVariantsRequest.php new file mode 100644 index 0000000..61fac42 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Requests/UpsertAccommodationVariantsRequest.php @@ -0,0 +1,27 @@ + */ + public function rules(): array + { + return [ + 'variants' => ['required', 'array', 'min:1', 'max:500'], + 'variants.*' => ['required', 'array:id,title,description,stock,price'], + 'variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'], + 'variants.*.title' => ['required', 'string', 'max:255'], + 'variants.*.description' => ['sometimes', 'nullable', 'string'], + 'variants.*.stock' => ['required', 'integer', 'min:0'], + 'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], + ]; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Requests/UpsertEntriesRequest.php b/app/Domains/FiestaFutbolInfantil/Requests/UpsertEntriesRequest.php new file mode 100644 index 0000000..6a993e9 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Requests/UpsertEntriesRequest.php @@ -0,0 +1,80 @@ + */ + public function rules(): array + { + $tenantCode = $this->user()?->tenant_codigo; + + return [ + 'entries' => ['required', 'array', 'min:1', 'max:100'], + 'entries.*' => ['required', 'array:id,title,description,event_date_ids,stock,price'], + 'entries.*.id' => [ + 'sometimes', + 'nullable', + 'integer', + 'distinct', + Rule::exists('catalog_items', 'id')->where( + fn ($query) => $query + ->where('tenant_code', $tenantCode) + ->whereIn('category_id', fn ($categoryQuery) => $categoryQuery + ->select('id') + ->from('categorias') + ->where('tenant_code', $tenantCode) + ->where('nombre', 'Entradas')) + ), + ], + 'entries.*.title' => ['required', 'string', 'max:255'], + 'entries.*.description' => ['sometimes', 'nullable', 'string'], + 'entries.*.event_date_ids' => ['required', 'array', 'min:1'], + 'entries.*.event_date_ids.*' => [ + 'required', + 'integer', + Rule::exists('event_dates', 'id')->where( + fn ($query) => $query->where('tenant_code', $tenantCode) + ), + ], + 'entries.*.stock' => ['required', 'integer', 'min:0'], + 'entries.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], + ]; + } + + /** @return array */ + public function after(): array + { + return [ + function (Validator $validator): void { + foreach ($this->input('entries', []) as $index => $entry) { + if (! is_array($entry)) { + continue; + } + + $dateIds = $entry['event_date_ids'] ?? []; + + if (! is_array($dateIds)) { + continue; + } + + if (count($dateIds) !== count(array_unique($dateIds))) { + $validator->errors()->add( + "entries.{$index}.event_date_ids", + 'Las fechas de una entrada no pueden repetirse.', + ); + } + } + }, + ]; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Requests/UpsertFoodVariantsRequest.php b/app/Domains/FiestaFutbolInfantil/Requests/UpsertFoodVariantsRequest.php new file mode 100644 index 0000000..8e6b918 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Requests/UpsertFoodVariantsRequest.php @@ -0,0 +1,70 @@ + */ + public function rules(): array + { + $tenantCode = $this->user()?->tenant_codigo; + + return [ + 'variants' => ['required', 'array', 'min:1', 'max:500'], + 'variants.*' => ['required', 'array:id,event_date_id,schedule,service,description,stock,price'], + 'variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'], + 'variants.*.event_date_id' => [ + 'required', + 'integer', + Rule::exists('event_dates', 'id')->where( + fn ($query) => $query->where('tenant_code', $tenantCode) + ), + ], + 'variants.*.schedule' => ['required', 'string', 'max:255'], + 'variants.*.service' => ['required', 'string', 'max:255'], + 'variants.*.description' => ['sometimes', 'nullable', 'string'], + 'variants.*.stock' => ['required', 'integer', 'min:0'], + 'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], + ]; + } + + /** @return array */ + public function after(): array + { + return [ + function (Validator $validator): void { + $seen = []; + + foreach ($this->input('variants', []) as $index => $variant) { + if (! is_array($variant)) { + continue; + } + + $key = implode('|', [ + $variant['event_date_id'] ?? '', + mb_strtolower(trim((string) ($variant['schedule'] ?? ''))), + mb_strtolower(trim((string) ($variant['service'] ?? ''))), + ]); + + if (isset($seen[$key])) { + $validator->errors()->add( + "variants.{$index}", + 'La combinación de fecha, horario y servicio no puede repetirse.', + ); + } + + $seen[$key] = true; + } + }, + ]; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Requests/UpsertMerchandiseRequest.php b/app/Domains/FiestaFutbolInfantil/Requests/UpsertMerchandiseRequest.php new file mode 100644 index 0000000..2283b55 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Requests/UpsertMerchandiseRequest.php @@ -0,0 +1,50 @@ + */ + public function rules(): array + { + $tenantCode = $this->user()?->tenant_codigo; + + return [ + 'items' => ['required', 'array', 'min:1', 'max:100'], + 'items.*' => ['required', 'array:id,title,description,max_units_per_user,variants'], + 'items.*.id' => [ + 'sometimes', + 'nullable', + 'integer', + 'distinct', + Rule::exists('catalog_items', 'id')->where( + fn ($query) => $query + ->where('tenant_code', $tenantCode) + ->whereIn('category_id', fn ($categoryQuery) => $categoryQuery + ->select('id') + ->from('categorias') + ->where('tenant_code', $tenantCode) + ->where('nombre', 'Merchandising')) + ), + ], + 'items.*.title' => ['required', 'string', 'max:255'], + 'items.*.description' => ['sometimes', 'nullable', 'string'], + 'items.*.max_units_per_user' => ['required', 'integer', 'min:1'], + 'items.*.variants' => ['required', 'array', 'min:1', 'max:500'], + 'items.*.variants.*' => ['required', 'array:id,color,size,stock,price'], + 'items.*.variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'], + 'items.*.variants.*.color' => ['required', 'string', 'max:255'], + 'items.*.variants.*.size' => ['required', 'string', 'max:255'], + 'items.*.variants.*.stock' => ['required', 'integer', 'min:0'], + 'items.*.variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], + ]; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Resources/AccommodationResource.php b/app/Domains/FiestaFutbolInfantil/Resources/AccommodationResource.php new file mode 100644 index 0000000..e674ff4 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Resources/AccommodationResource.php @@ -0,0 +1,46 @@ + */ + public function toArray(Request $request): array + { + if ($this->resource === null) { + return [ + 'id' => null, + 'name' => 'Alojamiento', + 'variants' => [], + ]; + } + + $typeAttribute = $this->itemAttributes + ->first(fn ($itemAttribute) => $itemAttribute->attribute?->codigo === 'tipo_alojamiento'); + $options = $typeAttribute?->attribute?->options?->keyBy('value') ?? collect(); + + return [ + 'id' => $this->id, + 'name' => $this->nombre, + 'variants' => $this->variants->map(function ($variant) use ($typeAttribute, $options): array { + $value = $variant->definitions + ->firstWhere('item_attribute_id', $typeAttribute?->id) + ?->value; + + return [ + 'id' => $variant->id, + 'title' => $options->get($value)?->label ?? $value, + 'value' => $value, + 'description' => $variant->descripcion, + 'stock' => $variant->inventory->real_stock, + 'price' => number_format($variant->getPrice(), 2, '.', ''), + ]; + })->values(), + ]; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php new file mode 100644 index 0000000..41e414f --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php @@ -0,0 +1,26 @@ + */ + public function toArray(Request $request): array + { + $variant = $this->variants->sole(); + + return [ + 'id' => $this->id, + 'title' => $this->nombre, + 'description' => $this->descripcion, + 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), + 'stock' => $variant->inventory->real_stock, + 'price' => $this->precio, + ]; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php b/app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php new file mode 100644 index 0000000..ad1f800 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php @@ -0,0 +1,43 @@ + */ + public function toArray(Request $request): array + { + if ($this->resource === null) { + return [ + 'id' => null, + 'name' => 'Comida', + 'variants' => [], + ]; + } + + return [ + 'id' => $this->id, + 'name' => $this->nombre, + 'variants' => $this->variants->map(function ($variant): array { + $values = $variant->selectionValues(); + $eventDate = $variant->selectedEventDates()->first(); + + return [ + 'id' => $variant->id, + 'event_date_id' => $eventDate?->id, + 'event_date' => $eventDate?->date?->format('Y-m-d'), + 'schedule' => $values->get('horario'), + 'service' => $values->get('servicio'), + 'description' => $variant->descripcion, + 'stock' => $variant->inventory->real_stock, + 'price' => number_format($variant->getPrice(), 2, '.', ''), + ]; + })->values(), + ]; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Resources/MerchandiseResource.php b/app/Domains/FiestaFutbolInfantil/Resources/MerchandiseResource.php new file mode 100644 index 0000000..68f1c9e --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Resources/MerchandiseResource.php @@ -0,0 +1,53 @@ + */ + public function toArray(Request $request): array + { + $itemAttributes = $this->itemAttributes->keyBy( + fn ($itemAttribute) => $itemAttribute->attribute?->codigo + ); + $colorAttribute = $itemAttributes->get('color'); + $sizeAttribute = $itemAttributes->get('talle'); + $colorOptions = $colorAttribute?->attribute?->options?->keyBy('value') ?? collect(); + $sizeOptions = $sizeAttribute?->attribute?->options?->keyBy('value') ?? collect(); + + return [ + 'id' => $this->id, + 'title' => $this->nombre, + 'description' => $this->descripcion, + 'max_units_per_user' => $this->max_units_per_user, + 'variants' => $this->variants->map(function ($variant) use ( + $colorAttribute, + $sizeAttribute, + $colorOptions, + $sizeOptions, + ): array { + $colorValue = $variant->definitions + ->firstWhere('item_attribute_id', $colorAttribute?->id) + ?->value; + $sizeValue = $variant->definitions + ->firstWhere('item_attribute_id', $sizeAttribute?->id) + ?->value; + + return [ + 'id' => $variant->id, + 'color' => $colorOptions->get($colorValue)?->label ?? $colorValue, + 'color_value' => $colorValue, + 'size' => $sizeOptions->get($sizeValue)?->label ?? $sizeValue, + 'size_value' => $sizeValue, + 'stock' => $variant->inventory->real_stock, + 'price' => number_format($variant->getPrice(), 2, '.', ''), + ]; + })->values(), + ]; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Services/AccommodationService.php b/app/Domains/FiestaFutbolInfantil/Services/AccommodationService.php new file mode 100644 index 0000000..7d68d98 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Services/AccommodationService.php @@ -0,0 +1,298 @@ +where('tenant_code', $tenant->codigo) + ->where('slug', 'alojamiento') + ->with([ + 'itemAttributes.attribute.options', + 'variants.catalogItem', + 'variants.inventory', + 'variants.definitions', + ]) + ->first(); + } + + /** + * @param array> $variants + */ + public function upsertMany(Tenant $tenant, array $variants): CatalogItem + { + return DB::transaction(function () use ($tenant, $variants): CatalogItem { + $attribute = $this->attribute($tenant); + $accommodation = $this->accommodation($tenant, $variants); + $itemAttribute = $accommodation->itemAttributes()->firstOrCreate( + ['attribute_id' => $attribute->id], + ['allow_multi_select' => false], + ); + $existingVariants = $accommodation->variants() + ->with(['inventory', 'definitions']) + ->lockForUpdate() + ->get(); + $resolvedVariants = $this->resolveVariants($variants); + + $this->validateValues($resolvedVariants, $existingVariants, $itemAttribute); + + foreach ($resolvedVariants as $index => $data) { + $variant = isset($data['id']) + ? $existingVariants->firstWhere('id', (int) $data['id']) + : null; + + if (isset($data['id']) && $variant === null) { + throw ValidationException::withMessages([ + "variants.{$index}.id" => ['La variante no pertenece al producto Alojamiento.'], + ]); + } + + if ($variant === null) { + $this->createVariant($attribute, $accommodation, $itemAttribute, $data); + } else { + $this->updateVariant($attribute, $variant, $itemAttribute, $data, $index); + } + } + + $minimumPrice = $accommodation->variants()->min('precio'); + if ($minimumPrice !== null) { + $accommodation->update(['precio' => $minimumPrice]); + } + + return $accommodation->fresh()->load([ + 'itemAttributes.attribute.options', + 'variants.catalogItem', + 'variants.inventory', + 'variants.definitions', + ]); + }); + } + + public function delete(Tenant $tenant, int $accommodationId): void + { + $variant = Variant::query() + ->whereKey($accommodationId) + ->whereHas('catalogItem', fn ($query) => $query + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'alojamiento')) + ->firstOrFail(); + + $this->catalogService->deleteVariant($variant); + } + + private function attribute(Tenant $tenant): Attribute + { + $attribute = Attribute::query() + ->where('tenant_codigo', $tenant->codigo) + ->where('codigo', self::ATTRIBUTE_CODE) + ->with('options') + ->lockForUpdate() + ->first(); + + if ($attribute === null) { + throw ValidationException::withMessages([ + 'variants' => ['Falta el atributo requerido tipo_alojamiento.'], + ]); + } + + return $attribute; + } + + /** @param array> $variants */ + private function accommodation(Tenant $tenant, array $variants): CatalogItem + { + $category = Category::query()->firstOrCreate([ + 'tenant_code' => $tenant->codigo, + 'nombre' => 'Alojamientos', + ]); + $accommodation = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'alojamiento') + ->lockForUpdate() + ->first(); + + if ($accommodation !== null) { + $accommodation->update([ + 'category_id' => $category->id, + 'inventory_policy' => InventoryPolicy::Tracked->value, + 'has_tickets' => true, + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, + ]); + + return $accommodation; + } + + return CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'alojamiento', + 'nombre' => 'Alojamiento', + 'descripcion' => 'Alojamiento', + 'category_id' => $category->id, + 'precio' => collect($variants)->min('price') ?? 0, + 'inventory_policy' => InventoryPolicy::Tracked->value, + 'has_tickets' => true, + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, + 'inventory_id' => null, + ]); + } + + /** + * @param array> $variants + * @return array> + */ + private function resolveVariants(array $variants): array + { + return collect($variants)->map(fn (array $variant): array => [ + ...$variant, + 'title' => trim($variant['title']), + 'value' => $this->valueCode($variant['title']), + 'description' => $variant['description'] ?? null, + 'stock' => (int) $variant['stock'], + ])->all(); + } + + /** + * @param array> $incoming + * @param Collection $existing + */ + private function validateValues(array $incoming, Collection $existing, ItemAttribute $itemAttribute): void + { + $incomingIds = collect($incoming)->pluck('id')->filter()->map(fn ($id): int => (int) $id); + $seen = []; + + foreach ($existing->whereNotIn('id', $incomingIds) as $variant) { + $value = $variant->definitions->firstWhere('item_attribute_id', $itemAttribute->id)?->value; + if ($value !== null) { + $seen[mb_strtolower(trim($value))] = true; + } + } + + foreach ($incoming as $index => $variant) { + $value = $variant['value']; + + if (isset($seen[$value])) { + throw ValidationException::withMessages([ + "variants.{$index}.title" => ['Ya existe un tipo de alojamiento con ese título.'], + ]); + } + + $seen[$value] = true; + } + } + + /** @param array $data */ + private function createVariant( + Attribute $attribute, + CatalogItem $accommodation, + ItemAttribute $itemAttribute, + array $data, + ): void { + $this->createOption($attribute, $data['value'], $data['title']); + + $inventory = Inventory::query()->create(['real_stock' => $data['stock']]); + $variant = $accommodation->variants()->create([ + 'inventory_id' => $inventory->id, + 'descripcion' => $data['description'], + 'precio' => $data['price'], + ]); + $variant->definitions()->create([ + 'item_attribute_id' => $itemAttribute->id, + 'value' => $data['value'], + ]); + } + + /** @param array $data */ + private function updateVariant( + Attribute $attribute, + Variant $variant, + ItemAttribute $itemAttribute, + array $data, + int $index, + ): void { + $inventory = Inventory::query() + ->whereKey($variant->inventory_id) + ->lockForUpdate() + ->firstOrFail(); + + if ($data['stock'] < $inventory->reserved_stock) { + throw ValidationException::withMessages([ + "variants.{$index}.stock" => [ + 'El stock no puede ser menor que la cantidad actualmente reservada.', + ], + ]); + } + + $definition = $variant->definitions + ->firstWhere('item_attribute_id', $itemAttribute->id); + $option = $definition === null + ? null + : $attribute->options->firstWhere('value', $definition->value); + + if ($option === null) { + $this->createOption($attribute, $data['value'], $data['title']); + } else { + $option->update([ + 'value' => $data['value'], + 'label' => $data['title'], + ]); + } + + $variant->update([ + 'descripcion' => $data['description'], + 'precio' => $data['price'], + ]); + $inventory->update(['real_stock' => $data['stock']]); + $variant->definitions()->updateOrCreate( + ['item_attribute_id' => $itemAttribute->id], + ['value' => $data['value']], + ); + } + + private function createOption(Attribute $attribute, string $value, string $label): AttributeOption + { + $existing = $attribute->options->first( + fn (AttributeOption $option): bool => mb_strtolower($option->value) === $value + ); + + if ($existing !== null) { + $existing->update(['label' => $label]); + + return $existing; + } + + $option = $attribute->options()->create([ + 'value' => $value, + 'label' => $label, + 'sort_order' => ((int) $attribute->options->max('sort_order')) + 1, + ]); + $attribute->options->push($option); + + return $option; + } + + private function valueCode(string $title): string + { + return mb_strtolower((string) preg_replace('/\s+/u', '_', trim($title))); + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php new file mode 100644 index 0000000..e1d4af1 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php @@ -0,0 +1,177 @@ + */ + public function all(Tenant $tenant): Collection + { + return CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas')) + ->with([ + 'variants.inventory', + 'variants.eventDate', + 'variants.eventDates', + ]) + ->orderBy('id') + ->get(); + } + + /** + * @param array> $entries + * @return Collection + */ + public function upsertMany(Tenant $tenant, array $entries): Collection + { + return DB::transaction(function () use ($tenant, $entries): Collection { + $reservedSlugs = []; + $category = Category::query()->firstOrCreate([ + 'tenant_code' => $tenant->codigo, + 'nombre' => 'Entradas', + ]); + + return collect($entries)->map(function (array $entry, int $index) use ($tenant, $category, &$reservedSlugs): CatalogItem { + if (isset($entry['id'])) { + return $this->update($tenant, $category, $entry, $index); + } + + $slug = $this->uniqueSlug($tenant, $entry['title'], $reservedSlugs); + $reservedSlugs[] = $slug; + + return $this->catalogService->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => $slug, + 'nombre' => $entry['title'], + 'descripcion' => $entry['description'] ?? null, + 'category_id' => $category->id, + 'precio' => $entry['price'], + 'has_tickets' => true, + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, + 'inventory_policy' => InventoryPolicy::Tracked->value, + 'attribute_codes' => ['event_date'], + 'multi_select_attribute_codes' => ['event_date'], + 'variants' => [[ + 'real_stock' => $entry['stock'], + 'event_date_ids' => array_values($entry['event_date_ids']), + ]], + ]); + })->values(); + }); + } + + public function delete(Tenant $tenant, int $entryId): void + { + $entry = CatalogItem::query() + ->whereKey($entryId) + ->where('tenant_code', $tenant->codigo) + ->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas')) + ->firstOrFail(); + + $this->catalogService->delete($entry); + } + + /** @param array $entry */ + private function update(Tenant $tenant, Category $category, array $entry, int $index): CatalogItem + { + $catalogItem = CatalogItem::query() + ->whereKey($entry['id']) + ->where('tenant_code', $tenant->codigo) + ->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas')) + ->lockForUpdate() + ->firstOrFail(); + + $variants = Variant::query() + ->where('catalog_item_id', $catalogItem->id) + ->lockForUpdate() + ->get(); + + if ($variants->count() !== 1) { + throw ValidationException::withMessages([ + "entries.{$index}.id" => [ + 'La entrada no posee una única variante editable.', + ], + ]); + } + + $variant = $variants->first(); + $inventory = Inventory::query() + ->whereKey($variant->inventory_id) + ->lockForUpdate() + ->firstOrFail(); + + if ((int) $entry['stock'] < $inventory->reserved_stock) { + throw ValidationException::withMessages([ + "entries.{$index}.stock" => [ + 'El stock no puede ser menor que la cantidad actualmente reservada.', + ], + ]); + } + + $eventDateIds = collect($entry['event_date_ids']) + ->map(fn ($id): int => (int) $id) + ->unique() + ->values(); + + $catalogItem->update([ + 'nombre' => $entry['title'], + 'descripcion' => $entry['description'] ?? null, + 'category_id' => $category->id, + 'precio' => $entry['price'], + 'has_tickets' => true, + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, + 'inventory_policy' => InventoryPolicy::Tracked->value, + ]); + $variant->update([ + 'event_date_id' => $eventDateIds->count() === 1 ? $eventDateIds->first() : null, + ]); + $variant->eventDates()->sync($eventDateIds->all()); + $catalogItem->itemAttributes() + ->whereHas('attribute', fn ($query) => $query->where('codigo', 'event_date')) + ->update(['allow_multi_select' => true]); + $inventory->update(['real_stock' => $entry['stock']]); + + return $catalogItem->load([ + 'variants.inventory', + 'variants.eventDate', + 'variants.eventDates', + ]); + } + + /** @param array $reservedSlugs */ + private function uniqueSlug(Tenant $tenant, string $title, array $reservedSlugs): string + { + $baseSlug = Str::slug($title) ?: 'entrada'; + $slug = $baseSlug; + $suffix = 2; + + while ( + in_array($slug, $reservedSlugs, true) + || CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', $slug) + ->exists() + ) { + $slug = "{$baseSlug}-{$suffix}"; + $suffix++; + } + + return $slug; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Services/FoodService.php b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php new file mode 100644 index 0000000..09430ef --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php @@ -0,0 +1,331 @@ + 1, + 'horario' => 2, + 'servicio' => 3, + ]; + + public function __construct(private readonly CatalogService $catalogService) {} + + public function current(Tenant $tenant): ?CatalogItem + { + return CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'comida') + ->with([ + 'variants.catalogItem', + 'variants.inventory', + 'variants.eventDate', + 'variants.eventDates', + 'variants.definitions.itemAttribute.attribute', + ]) + ->first(); + } + + /** + * @param array> $variants + */ + public function upsertMany(Tenant $tenant, array $variants): CatalogItem + { + return DB::transaction(function () use ($tenant, $variants): CatalogItem { + $attributes = $this->attributes($tenant); + $food = $this->food($tenant, $variants); + $itemAttributes = $this->itemAttributes($food, $attributes); + + $food->variants()->whereNull('precio')->update(['precio' => $food->precio]); + $existingVariants = $food->variants() + ->with(['inventory', 'eventDate', 'eventDates', 'definitions.itemAttribute.attribute']) + ->lockForUpdate() + ->get(); + $resolvedVariants = $this->resolveVariants($variants, $attributes); + + $this->validateCombinations($resolvedVariants, $existingVariants); + + foreach ($resolvedVariants as $index => $data) { + $variant = isset($data['id']) + ? $existingVariants->firstWhere('id', (int) $data['id']) + : null; + + if (isset($data['id']) && $variant === null) { + throw ValidationException::withMessages([ + "variants.{$index}.id" => ['La variante no pertenece al producto Comida.'], + ]); + } + + if ($variant === null) { + $this->createVariant($food, $itemAttributes, $data); + } else { + $this->updateVariant($variant, $itemAttributes, $data, $index); + } + } + + $minimumPrice = $food->variants()->min('precio'); + if ($minimumPrice !== null) { + $food->update(['precio' => $minimumPrice]); + } + + return $food->fresh()->load([ + 'variants.catalogItem', + 'variants.inventory', + 'variants.eventDate', + 'variants.eventDates', + 'variants.definitions.itemAttribute.attribute', + ]); + }); + } + + public function delete(Tenant $tenant, int $foodId): void + { + $variant = Variant::query() + ->whereKey($foodId) + ->whereHas('catalogItem', fn ($query) => $query + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'comida')) + ->firstOrFail(); + + $this->catalogService->deleteVariant($variant); + } + + /** @return Collection */ + private function attributes(Tenant $tenant): Collection + { + $attributes = Attribute::query() + ->where('tenant_codigo', $tenant->codigo) + ->whereIn('codigo', self::ATTRIBUTE_CODES) + ->with('options') + ->get() + ->keyBy('codigo'); + + $missingCodes = collect(self::ATTRIBUTE_CODES)->diff($attributes->keys()); + if ($missingCodes->isNotEmpty()) { + throw ValidationException::withMessages([ + 'variants' => [ + 'Faltan atributos requeridos para Comida: '.$missingCodes->implode(', ').'.', + ], + ]); + } + + return $attributes; + } + + /** @param array> $variants */ + private function food(Tenant $tenant, array $variants): CatalogItem + { + $category = Category::query()->firstOrCreate([ + 'tenant_code' => $tenant->codigo, + 'nombre' => 'Comidas', + ]); + $food = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'comida') + ->lockForUpdate() + ->first(); + + if ($food !== null) { + $food->update([ + 'category_id' => $category->id, + 'inventory_policy' => InventoryPolicy::Tracked->value, + 'has_tickets' => true, + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, + ]); + + return $food; + } + + return CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'comida', + 'nombre' => 'Comida', + 'descripcion' => 'Comida', + 'category_id' => $category->id, + 'precio' => collect($variants)->min('price') ?? 0, + 'inventory_policy' => InventoryPolicy::Tracked->value, + 'has_tickets' => true, + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, + 'inventory_id' => null, + ]); + } + + /** + * @param Collection $attributes + * @return Collection + */ + private function itemAttributes(CatalogItem $food, Collection $attributes): Collection + { + return $attributes->mapWithKeys(function (Attribute $attribute, string $code) use ($food): array { + $itemAttribute = $food->itemAttributes()->updateOrCreate( + ['attribute_id' => $attribute->id], + [ + 'allow_multi_select' => false, + 'sort_order' => self::ATTRIBUTE_SORT_ORDERS[$code], + ], + ); + + return [$code => $itemAttribute]; + }); + } + + /** + * @param array> $variants + * @param Collection $attributes + * @return array> + */ + private function resolveVariants(array $variants, Collection $attributes): array + { + return collect($variants)->map(function (array $variant, int $index) use ($attributes): array { + $schedule = $this->option($attributes['horario'], $variant['schedule'], "variants.{$index}.schedule"); + $service = $this->option($attributes['servicio'], $variant['service'], "variants.{$index}.service"); + + return [ + ...$variant, + 'event_date_id' => (int) $variant['event_date_id'], + 'schedule' => $schedule->value, + 'service' => $service->value, + 'description' => (string) ($variant['description'] ?? ''), + 'stock' => (int) $variant['stock'], + ]; + })->all(); + } + + private function option(Attribute $attribute, string $value, string $validationKey): AttributeOption + { + $option = $attribute->options->first( + fn (AttributeOption $option): bool => mb_strtolower(trim($option->value)) === mb_strtolower(trim($value)) + ); + + if ($option === null) { + throw ValidationException::withMessages([ + $validationKey => ["El valor seleccionado no es válido para {$attribute->nombre}."], + ]); + } + + return $option; + } + + /** + * @param array> $incoming + * @param Collection $existing + */ + private function validateCombinations(array $incoming, Collection $existing): void + { + $incomingIds = collect($incoming)->pluck('id')->filter()->map(fn ($id): int => (int) $id); + $seen = []; + + foreach ($existing->whereNotIn('id', $incomingIds) as $variant) { + $values = $variant->selectionValues(); + $seen[$this->combinationKey( + (int) $variant->selectedEventDates()->first()?->id, + (string) $values->get('horario'), + (string) $values->get('servicio'), + )] = true; + } + + foreach ($incoming as $index => $variant) { + $key = $this->combinationKey( + $variant['event_date_id'], + $variant['schedule'], + $variant['service'], + ); + + if (isset($seen[$key])) { + throw ValidationException::withMessages([ + "variants.{$index}" => ['La combinación de fecha, horario y servicio ya existe.'], + ]); + } + + $seen[$key] = true; + } + } + + /** @param Collection $itemAttributes */ + private function createVariant(CatalogItem $food, Collection $itemAttributes, array $data): void + { + $inventory = Inventory::query()->create(['real_stock' => $data['stock']]); + $variant = $food->variants()->create([ + 'event_date_id' => $data['event_date_id'], + 'inventory_id' => $inventory->id, + 'descripcion' => $data['description'], + 'precio' => $data['price'], + ]); + $variant->eventDates()->sync([$data['event_date_id']]); + $this->syncDefinitions($variant, $itemAttributes, $data); + } + + /** @param Collection $itemAttributes */ + private function updateVariant( + Variant $variant, + Collection $itemAttributes, + array $data, + int $index, + ): void { + $inventory = Inventory::query() + ->whereKey($variant->inventory_id) + ->lockForUpdate() + ->firstOrFail(); + + if ($data['stock'] < $inventory->reserved_stock) { + throw ValidationException::withMessages([ + "variants.{$index}.stock" => [ + 'El stock no puede ser menor que la cantidad actualmente reservada.', + ], + ]); + } + + $variant->update([ + 'event_date_id' => $data['event_date_id'], + 'descripcion' => $data['description'], + 'precio' => $data['price'], + ]); + $variant->eventDates()->sync([$data['event_date_id']]); + $inventory->update(['real_stock' => $data['stock']]); + $this->syncDefinitions($variant, $itemAttributes, $data); + } + + /** @param Collection $itemAttributes */ + private function syncDefinitions(Variant $variant, Collection $itemAttributes, array $data): void + { + $definitionAttributes = $itemAttributes->toBase()->only(['horario', 'servicio']); + $variant->definitions()->whereIn('item_attribute_id', $definitionAttributes->pluck('id'))->delete(); + $variant->definitions()->createMany([ + [ + 'item_attribute_id' => $definitionAttributes['horario']->id, + 'value' => $data['schedule'], + ], + [ + 'item_attribute_id' => $definitionAttributes['servicio']->id, + 'value' => $data['service'], + ], + ]); + } + + private function combinationKey(int $eventDateId, string $schedule, string $service): string + { + return implode('|', [ + $eventDateId, + mb_strtolower(trim($schedule)), + mb_strtolower(trim($service)), + ]); + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Services/MerchandiseService.php b/app/Domains/FiestaFutbolInfantil/Services/MerchandiseService.php new file mode 100644 index 0000000..bbbb152 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Services/MerchandiseService.php @@ -0,0 +1,443 @@ + */ + public function all(Tenant $tenant): Collection + { + return CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->whereHas('category', fn ($query) => $query->where('nombre', 'Merchandising')) + ->with([ + 'itemAttributes.attribute.options', + 'variants.catalogItem', + 'variants.inventory', + 'variants.definitions', + ]) + ->orderBy('id') + ->get(); + } + + /** + * @param array> $items + * @return Collection + */ + public function upsertMany(Tenant $tenant, array $items): Collection + { + return DB::transaction(function () use ($tenant, $items): Collection { + $attributes = $this->attributes($tenant); + $category = Category::query()->firstOrCreate([ + 'tenant_code' => $tenant->codigo, + 'nombre' => 'Merchandising', + ]); + $reservedSlugs = []; + + return collect($items)->map(function (array $data, int $index) use ( + $tenant, + $attributes, + $category, + &$reservedSlugs, + ): CatalogItem { + $item = isset($data['id']) + ? $this->existingItem($tenant, $category, (int) $data['id'], $index) + : $this->createItem($tenant, $category, $data, $reservedSlugs); + + if (! isset($data['id'])) { + $reservedSlugs[] = $item->slug; + } + + $item->update([ + 'nombre' => trim($data['title']), + 'descripcion' => $data['description'] ?? null, + 'category_id' => $category->id, + 'max_units_per_user' => (int) $data['max_units_per_user'], + 'inventory_policy' => InventoryPolicy::Tracked->value, + 'has_tickets' => true, + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, + ]); + + $itemAttributes = $this->itemAttributes($item, $attributes); + $existingVariants = $item->variants() + ->with(['inventory', 'definitions']) + ->lockForUpdate() + ->get(); + $variants = $this->resolveVariants($data['variants'], $attributes, $index); + + $this->validateCombinations($variants, $existingVariants, $itemAttributes, $index); + + foreach ($variants as $variantIndex => $variantData) { + $variant = isset($variantData['id']) + ? $existingVariants->firstWhere('id', (int) $variantData['id']) + : null; + + if (isset($variantData['id']) && $variant === null) { + throw ValidationException::withMessages([ + "items.{$index}.variants.{$variantIndex}.id" => [ + 'La variante no pertenece al artículo de merchandising.', + ], + ]); + } + + if ($variant === null) { + $this->createVariant($item, $itemAttributes, $variantData); + } else { + $this->updateVariant($variant, $itemAttributes, $variantData, $index, $variantIndex); + } + } + + $minimumPrice = $item->variants()->min('precio'); + if ($minimumPrice !== null) { + $item->update(['precio' => $minimumPrice]); + } + + return $item->fresh()->load([ + 'itemAttributes.attribute.options', + 'variants.catalogItem', + 'variants.inventory', + 'variants.definitions', + ]); + })->values(); + }); + } + + public function delete(Tenant $tenant, int $merchandiseId): void + { + $variant = Variant::query() + ->whereKey($merchandiseId) + ->whereHas('catalogItem', fn ($query) => $query + ->where('tenant_code', $tenant->codigo) + ->whereHas('category', fn ($categoryQuery) => $categoryQuery + ->where('nombre', 'Merchandising'))) + ->firstOrFail(); + + $this->catalogService->deleteVariant($variant); + } + + /** @return Collection */ + private function attributes(Tenant $tenant): Collection + { + $attributes = Attribute::query() + ->where('tenant_codigo', $tenant->codigo) + ->whereIn('codigo', self::ATTRIBUTE_CODES) + ->with('options') + ->lockForUpdate() + ->get() + ->keyBy('codigo'); + + $missingCodes = collect(self::ATTRIBUTE_CODES)->diff($attributes->keys()); + if ($missingCodes->isNotEmpty()) { + throw ValidationException::withMessages([ + 'items' => [ + 'Faltan atributos requeridos para merchandising: '.$missingCodes->implode(', ').'.', + ], + ]); + } + + return $attributes; + } + + private function existingItem( + Tenant $tenant, + Category $category, + int $itemId, + int $index, + ): CatalogItem { + $item = CatalogItem::query() + ->whereKey($itemId) + ->where('tenant_code', $tenant->codigo) + ->where('category_id', $category->id) + ->lockForUpdate() + ->first(); + + if ($item === null) { + throw ValidationException::withMessages([ + "items.{$index}.id" => ['El artículo no pertenece al merchandising del tenant.'], + ]); + } + + return $item; + } + + /** + * @param array $data + * @param array $reservedSlugs + */ + private function createItem( + Tenant $tenant, + Category $category, + array $data, + array $reservedSlugs, + ): CatalogItem { + return CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => $this->uniqueSlug($tenant, $data['title'], $reservedSlugs), + 'nombre' => trim($data['title']), + 'descripcion' => $data['description'] ?? null, + 'category_id' => $category->id, + 'precio' => collect($data['variants'])->min('price') ?? 0, + 'max_units_per_user' => (int) $data['max_units_per_user'], + 'inventory_policy' => InventoryPolicy::Tracked->value, + 'has_tickets' => true, + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, + 'inventory_id' => null, + ]); + } + + /** + * @param Collection $attributes + * @return Collection + */ + private function itemAttributes(CatalogItem $item, Collection $attributes): Collection + { + return $attributes->mapWithKeys(function (Attribute $attribute, string $code) use ($item): array { + $itemAttribute = $item->itemAttributes()->firstOrCreate( + ['attribute_id' => $attribute->id], + ['allow_multi_select' => false], + ); + + if ($itemAttribute->allow_multi_select) { + $itemAttribute->update(['allow_multi_select' => false]); + } + + return [$code => $itemAttribute]; + }); + } + + /** + * @param array> $variants + * @param Collection $attributes + * @return array> + */ + private function resolveVariants(array $variants, Collection $attributes, int $itemIndex): array + { + return collect($variants)->map(function (array $variant, int $variantIndex) use ( + $attributes, + $itemIndex, + ): array { + $color = $this->resolveColor($attributes['color'], $variant['color']); + $size = $this->existingOption( + $attributes['talle'], + $variant['size'], + "items.{$itemIndex}.variants.{$variantIndex}.size", + ); + + return [ + ...$variant, + 'color' => $color->value, + 'size' => $size->value, + 'stock' => (int) $variant['stock'], + ]; + })->all(); + } + + private function resolveColor(Attribute $attribute, string $color): AttributeOption + { + $option = $this->findOption($attribute, $color); + if ($option !== null) { + return $option; + } + + $label = trim($color); + $option = $attribute->options()->create([ + 'value' => $this->valueCode($label), + 'label' => $label, + 'sort_order' => ((int) $attribute->options->max('sort_order')) + 1, + ]); + $attribute->options->push($option); + + return $option; + } + + private function existingOption( + Attribute $attribute, + string $value, + string $validationKey, + ): AttributeOption { + $option = $this->findOption($attribute, $value); + + if ($option === null) { + throw ValidationException::withMessages([ + $validationKey => ["El valor seleccionado no es válido para {$attribute->nombre}."], + ]); + } + + return $option; + } + + private function findOption(Attribute $attribute, string $value): ?AttributeOption + { + $key = $this->optionKey($value); + + return $attribute->options->first( + fn (AttributeOption $option): bool => $this->optionKey($option->value) === $key + || $this->optionKey($option->label) === $key + ); + } + + /** + * @param array> $incoming + * @param Collection $existing + * @param Collection $itemAttributes + */ + private function validateCombinations( + array $incoming, + Collection $existing, + Collection $itemAttributes, + int $itemIndex, + ): void { + $incomingIds = collect($incoming)->pluck('id')->filter()->map(fn ($id): int => (int) $id); + $seen = []; + + foreach ($existing->whereNotIn('id', $incomingIds) as $variant) { + $values = $variant->definitions->keyBy('item_attribute_id'); + $color = $values->get($itemAttributes['color']->id)?->value; + $size = $values->get($itemAttributes['talle']->id)?->value; + + if ($color !== null && $size !== null) { + $seen[$this->combinationKey($color, $size)] = true; + } + } + + foreach ($incoming as $variantIndex => $variant) { + $key = $this->combinationKey($variant['color'], $variant['size']); + + if (isset($seen[$key])) { + throw ValidationException::withMessages([ + "items.{$itemIndex}.variants.{$variantIndex}" => [ + 'La combinación de color y talle ya existe para el artículo.', + ], + ]); + } + + $seen[$key] = true; + } + } + + /** + * @param Collection $itemAttributes + * @param array $data + */ + private function createVariant( + CatalogItem $item, + Collection $itemAttributes, + array $data, + ): void { + $inventory = Inventory::query()->create(['real_stock' => $data['stock']]); + $variant = $item->variants()->create([ + 'inventory_id' => $inventory->id, + 'precio' => $data['price'], + ]); + $this->syncDefinitions($variant, $itemAttributes, $data); + } + + /** + * @param Collection $itemAttributes + * @param array $data + */ + private function updateVariant( + Variant $variant, + Collection $itemAttributes, + array $data, + int $itemIndex, + int $variantIndex, + ): void { + $inventory = Inventory::query() + ->whereKey($variant->inventory_id) + ->lockForUpdate() + ->firstOrFail(); + + if ($data['stock'] < $inventory->reserved_stock) { + throw ValidationException::withMessages([ + "items.{$itemIndex}.variants.{$variantIndex}.stock" => [ + 'El stock no puede ser menor que la cantidad actualmente reservada.', + ], + ]); + } + + $variant->update(['precio' => $data['price']]); + $inventory->update(['real_stock' => $data['stock']]); + $this->syncDefinitions($variant, $itemAttributes, $data); + } + + /** + * @param Collection $itemAttributes + * @param array $data + */ + private function syncDefinitions( + Variant $variant, + Collection $itemAttributes, + array $data, + ): void { + $variant->definitions() + ->whereIn('item_attribute_id', $itemAttributes->pluck('id')) + ->delete(); + $variant->definitions()->createMany([ + [ + 'item_attribute_id' => $itemAttributes['color']->id, + 'value' => $data['color'], + ], + [ + 'item_attribute_id' => $itemAttributes['talle']->id, + 'value' => $data['size'], + ], + ]); + } + + /** @param array $reservedSlugs */ + private function uniqueSlug(Tenant $tenant, string $title, array $reservedSlugs): string + { + $baseSlug = Str::slug($title) ?: 'merchandising'; + $slug = $baseSlug; + $suffix = 2; + + while ( + in_array($slug, $reservedSlugs, true) + || CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', $slug) + ->exists() + ) { + $slug = "{$baseSlug}-{$suffix}"; + $suffix++; + } + + return $slug; + } + + private function combinationKey(string $color, string $size): string + { + return $this->optionKey($color).'|'.$this->optionKey($size); + } + + private function optionKey(string $value): string + { + return Str::ascii(mb_strtolower((string) preg_replace('/[_\s]+/u', ' ', trim($value)))); + } + + private function valueCode(string $value): string + { + return mb_strtolower((string) preg_replace('/\s+/u', '_', trim($value))); + } +} diff --git a/app/Domains/FiestaFutbolInfantil/routes/api.php b/app/Domains/FiestaFutbolInfantil/routes/api.php new file mode 100644 index 0000000..86065e8 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/routes/api.php @@ -0,0 +1,48 @@ +middleware(['auth:sanctum', 'adminapp.tenant']) + ->group(function (): void { + Route::get('entries', [EntryController::class, 'index']) + ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas') + ->name('adminapp.fiesta-futbol-infantil.entries.index'); + Route::post('entries', [EntryController::class, 'store']) + ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas') + ->name('adminapp.fiesta-futbol-infantil.entries.store'); + Route::delete('entries/{entry}', [EntryController::class, 'destroy']) + ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas') + ->name('adminapp.fiesta-futbol-infantil.entries.destroy'); + Route::get('foods', [FoodController::class, 'index']) + ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') + ->name('adminapp.fiesta-futbol-infantil.foods.index'); + Route::post('foods', [FoodController::class, 'store']) + ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') + ->name('adminapp.fiesta-futbol-infantil.foods.store'); + Route::delete('foods/{food}', [FoodController::class, 'destroy']) + ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') + ->name('adminapp.fiesta-futbol-infantil.foods.destroy'); + Route::get('accommodations', [AccommodationController::class, 'index']) + ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos') + ->name('adminapp.fiesta-futbol-infantil.accommodations.index'); + Route::post('accommodations', [AccommodationController::class, 'store']) + ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos') + ->name('adminapp.fiesta-futbol-infantil.accommodations.store'); + Route::delete('accommodations/{accommodation}', [AccommodationController::class, 'destroy']) + ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos') + ->name('adminapp.fiesta-futbol-infantil.accommodations.destroy'); + Route::get('merchandise', [MerchandiseController::class, 'index']) + ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising') + ->name('adminapp.fiesta-futbol-infantil.merchandise.index'); + Route::post('merchandise', [MerchandiseController::class, 'store']) + ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising') + ->name('adminapp.fiesta-futbol-infantil.merchandise.store'); + Route::delete('merchandise/{merchandise}', [MerchandiseController::class, 'destroy']) + ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising') + ->name('adminapp.fiesta-futbol-infantil.merchandise.destroy'); + }); diff --git a/app/Domains/Forms/Controllers/AdminApp/FoodFormController.php b/app/Domains/Forms/Controllers/AdminApp/FoodFormController.php new file mode 100644 index 0000000..cb2380f --- /dev/null +++ b/app/Domains/Forms/Controllers/AdminApp/FoodFormController.php @@ -0,0 +1,22 @@ +foodFormService->get( + $request->user('sanctum')->tenant()->firstOrFail() + ) + ); + } +} diff --git a/app/Domains/Forms/Controllers/AdminApp/MerchandiseFormController.php b/app/Domains/Forms/Controllers/AdminApp/MerchandiseFormController.php new file mode 100644 index 0000000..6b38675 --- /dev/null +++ b/app/Domains/Forms/Controllers/AdminApp/MerchandiseFormController.php @@ -0,0 +1,22 @@ +merchandiseFormService->get( + $request->user('sanctum')->tenant()->firstOrFail() + ) + ); + } +} diff --git a/app/Domains/Forms/Resources/FoodFormResource.php b/app/Domains/Forms/Resources/FoodFormResource.php new file mode 100644 index 0000000..611b2b9 --- /dev/null +++ b/app/Domains/Forms/Resources/FoodFormResource.php @@ -0,0 +1,42 @@ + */ + public function toArray(Request $request): array + { + return [ + 'event_dates' => $this->resource['event_dates']->map( + fn (EventDate $eventDate): array => [ + 'id' => $eventDate->id, + 'validity_time_id' => $eventDate->validity_time_id, + 'validity_time' => ValidityTimeResource::make($eventDate->validityTime), + 'date' => $eventDate->date->format('Y-m-d'), + ] + )->values(), + 'schedules' => $this->options($this->resource['schedules']), + 'services' => $this->options($this->resource['services']), + ]; + } + + /** + * @param Collection $options + * @return Collection + */ + private function options(Collection $options): Collection + { + return $options->map(fn (AttributeOption $option): array => [ + 'value' => $option->value, + 'label' => $option->label, + ])->values(); + } +} diff --git a/app/Domains/Forms/Resources/MerchandiseFormResource.php b/app/Domains/Forms/Resources/MerchandiseFormResource.php new file mode 100644 index 0000000..c8fa453 --- /dev/null +++ b/app/Domains/Forms/Resources/MerchandiseFormResource.php @@ -0,0 +1,32 @@ + */ + public function toArray(Request $request): array + { + return [ + 'colors' => $this->options($this->resource['colors']), + 'sizes' => $this->options($this->resource['sizes']), + ]; + } + + /** + * @param Collection $options + * @return Collection + */ + private function options(Collection $options): Collection + { + return $options->map(fn (AttributeOption $option): array => [ + 'value' => $option->value, + 'label' => $option->label, + ])->values(); + } +} diff --git a/app/Domains/Forms/Services/FoodFormService.php b/app/Domains/Forms/Services/FoodFormService.php new file mode 100644 index 0000000..72f2b07 --- /dev/null +++ b/app/Domains/Forms/Services/FoodFormService.php @@ -0,0 +1,35 @@ +, + * schedules: Collection, + * services: Collection + * } + */ + public function get(Tenant $tenant): array + { + $attributes = Attribute::query() + ->where('tenant_codigo', $tenant->codigo) + ->whereIn('codigo', ['horario', 'servicio']) + ->with('options') + ->get() + ->keyBy('codigo'); + + return [ + 'event_dates' => $tenant->eventDates()->with('validityTime')->get(), + 'schedules' => $attributes->get('horario')?->options ?? new Collection, + 'services' => $attributes->get('servicio')?->options ?? new Collection, + ]; + } +} diff --git a/app/Domains/Forms/Services/MerchandiseFormService.php b/app/Domains/Forms/Services/MerchandiseFormService.php new file mode 100644 index 0000000..c8aea73 --- /dev/null +++ b/app/Domains/Forms/Services/MerchandiseFormService.php @@ -0,0 +1,32 @@ +, + * sizes: Collection + * } + */ + public function get(Tenant $tenant): array + { + $attributes = Attribute::query() + ->where('tenant_codigo', $tenant->codigo) + ->whereIn('codigo', ['color', 'talle']) + ->with('options') + ->get() + ->keyBy('codigo'); + + return [ + 'colors' => $attributes->get('color')?->options ?? new Collection, + 'sizes' => $attributes->get('talle')?->options ?? new Collection, + ]; + } +} diff --git a/app/Domains/Forms/documentacion/README.md b/app/Domains/Forms/documentacion/README.md new file mode 100644 index 0000000..8f3a84b --- /dev/null +++ b/app/Domains/Forms/documentacion/README.md @@ -0,0 +1,26 @@ +# Dominio Forms + +## Propósito + +Provee catálogos y opciones auxiliares para construir formularios del panel administrativo. Es un dominio de lectura que compone datos pertenecientes a otros dominios. + +## Formularios disponibles + +- `EventFormService`: devuelve redes sociales disponibles y las URL configuradas para el tenant. +- `SaleFormService`: expone los estados admitidos para compras con sus etiquetas de presentación. +- `StaffFormService`: lista categorías raíz que pueden asignarse al personal del tenant. + +Cada servicio tiene un controlador invocable y un `JsonResource` específico. `SocialMediaOptionResource` representa las opciones de redes sociales. + +## Endpoints + +Bajo `/v1/adminapp/forms`, con `auth:sanctum` y `adminapp.tenant`: + +- `GET /event`. +- `GET /sale`. +- `GET /staff`. +- `GET /fiesta-futbol-infantil/merchandise`: opciones de color y talle del tenant para merchandising. + +## Dependencias + +Compone datos de `Tenant`, `Purchase` y `Catalog`. No debe duplicar reglas de negocio: las listas y estados canónicos siguen perteneciendo a sus dominios de origen. diff --git a/app/Domains/Forms/routes/adminapp.php b/app/Domains/Forms/routes/adminapp.php index 14e62ed..cb8683f 100644 --- a/app/Domains/Forms/routes/adminapp.php +++ b/app/Domains/Forms/routes/adminapp.php @@ -1,6 +1,8 @@ checkoutService->confirmPurchase($compra); - $compra->markAsPaid(); + $this->checkoutService->confirmPaidPurchase($compra); }); Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}"); diff --git a/app/Domains/Integration/documentacion/README.md b/app/Domains/Integration/documentacion/README.md new file mode 100644 index 0000000..0975bb3 --- /dev/null +++ b/app/Domains/Integration/documentacion/README.md @@ -0,0 +1,29 @@ +# Dominio Integration + +## Propósito + +Gestiona integraciones externas disponibles y su configuración por tenant. Incluye correo y pagos mediante Telepagos. + +## Modelo y seguridad + +- `Integration`: definición global de una integración. +- `TenantIntegration`: configuración y credenciales de una integración para un tenant. +- `EncryptedIntegrationData`: cast que protege los datos sensibles persistidos. +- `TenantIntegrationService`: consulta y configura integraciones del tenant. + +## Servicios externos + +- `BaseIntegrationService`: base para resolver configuración, URL y cliente del tenant. +- `MailService`: envío de correo usando la integración configurada. +- `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros. +- `TelepagosWebhookService`: procesa notificaciones recibidas desde Telepagos. + +## Endpoints + +- CRUD global bajo `/integrations`. +- Consulta y configuración por tenant bajo `/{tenant_code}/integrations`. +- `POST /webhooks/telepagos/{tenant_codigo}` para notificaciones del proveedor. + +## Dependencias y reglas + +Se integra con `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. Las credenciales no deben exponerse en respuestas ni logs; los webhooks deben validar su contrato antes de alterar una compra. diff --git a/app/Domains/Logging/documentacion/README.md b/app/Domains/Logging/documentacion/README.md new file mode 100644 index 0000000..31c028d --- /dev/null +++ b/app/Domains/Logging/documentacion/README.md @@ -0,0 +1,25 @@ +# Dominio Logging + +## Propósito + +Registra cambios relevantes de valores en modelos de negocio, indicando tenant, atributo, valor anterior/nuevo, fecha y actor. + +## Componentes + +- `Models/ValueChange.php`: entrada persistida del historial, relacionada polimórficamente con el objeto modificado. +- `Models/Concerns/LogsValueChanges.php`: trait reutilizable que escucha actualizaciones del modelo. +- `Enums/ValueChangeActorType.php`: distingue cambios realizados por usuario o por el sistema. + +## Uso + +Un modelo consumidor debe: + +1. Usar el trait `LogsValueChanges`. +2. Declarar la propiedad `loggedAttributes` con los atributos auditables. +3. Implementar `valueChangeTenantCode()`. + +El trait solo registra atributos configurados que efectivamente cambiaron. Si existe un usuario autenticado lo asocia al cambio; en caso contrario marca al sistema como actor. + +## API y dependencias + +No expone rutas HTTP. `Purchase` lo utiliza para auditar cambios de estado y `Sale` consulta esas modificaciones para reportes. diff --git a/app/Domains/MailTest/documentacion/README.md b/app/Domains/MailTest/documentacion/README.md new file mode 100644 index 0000000..ac07f63 --- /dev/null +++ b/app/Domains/MailTest/documentacion/README.md @@ -0,0 +1,24 @@ +# Dominio MailTest + +## Propósito + +Ofrece una operación técnica para verificar la configuración de correo de un tenant sin ejecutar un flujo funcional real. + +## Componentes + +- `MailTestController`: endpoint invocable de envío. +- `SendTestMailRequest`: valida destinatario y contenido requerido. +- `MailTestService`: coordina el envío de prueba. +- `TestMail`: mailable utilizado para construir el mensaje. + +## Endpoint + +- `POST /{tenant_code}/mail-test/send`. + +## Dependencias + +Usa la configuración de correo del dominio `Integration` y resuelve el tenant indicado. + +## Consideraciones + +Es una herramienta de diagnóstico. Debe restringirse o deshabilitarse en entornos donde no corresponda exponer envíos de prueba, y nunca debe registrar credenciales. diff --git a/app/Domains/Menu/documentacion/README.md b/app/Domains/Menu/documentacion/README.md new file mode 100644 index 0000000..a15c15f --- /dev/null +++ b/app/Domains/Menu/documentacion/README.md @@ -0,0 +1,24 @@ +# Dominio Menu + +## Propósito + +Define menús disponibles y permite configurar su contenido para cada tenant y rol. + +## Modelo + +- `Menu`: definición global de una entrada de menú y su tipo de contenido. +- `TenantMenu`: configuración específica por tenant, incluyendo contenido estático cuando corresponde. +- `MenuRole`: asociación entre menú y rol autorizado. + +## Servicios + +`TenantMenuService::configure()` crea o actualiza atómicamente la configuración de un menú para un tenant. Solo conserva `static_content` cuando el menú fue definido como contenido estático. + +## Endpoints + +- Recurso REST `/menues` mediante `MenuController`. +- `POST /{tenant_code}/menues/{menu_code}` para configurar un menú del tenant. + +## Dependencias y reglas + +Depende de `Tenant` y `Authorization`. Los códigos de menú y tenant forman la identidad lógica de la configuración; el contenido enviado debe respetar el tipo definido por `Menu`. diff --git a/app/Domains/Notification/documentacion/README.md b/app/Domains/Notification/documentacion/README.md new file mode 100644 index 0000000..6d7dd16 --- /dev/null +++ b/app/Domains/Notification/documentacion/README.md @@ -0,0 +1,26 @@ +# Dominio Notification + +## Propósito + +Orquesta notificaciones de negocio por correo a partir de eventos de otros dominios. + +## Eventos atendidos + +- `UserRegistered`: dispara el correo de bienvenida. +- `PasswordResetRequested`: envía el código de recuperación si el intento sigue pendiente. +- `PurchasePaid`: envía la confirmación de pago. +- `TicketsAvailable`: informa y entrega la disponibilidad de tickets. + +## Componentes + +Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail`, `SendPurchasePaidEmail` y `SendTicketsAvailableEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`. + +## API y dependencias + +No expone rutas HTTP. Consume datos de `Auth`, `Tenant`, `Purchase` y `Ticket`, y delega la entrega al dominio `Integration`. + +## Consideraciones + +- Los listeners reciben identificadores y vuelven a cargar los modelos, evitando transportar entidades obsoletas. +- La recuperación no se envía si el intento dejó de estar pendiente. +- Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola. diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index 698f4df..a5dcb11 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -4,7 +4,6 @@ namespace App\Domains\Purchase\Models; use App\Domains\Auth\Models\User; use App\Domains\Cart\Models\Cart; -use App\Domains\Event\Models\Event; use App\Domains\Logging\Models\Concerns\LogsValueChanges; use App\Domains\Purchase\Events\PurchasePaid; use App\Domains\Tenant\Models\Tenant; @@ -20,7 +19,6 @@ use Illuminate\Support\Facades\DB; #[Fillable([ 'cart_id', 'tenant_codigo', - 'event_id', 'user_id', 'status', 'payment_method', @@ -72,7 +70,6 @@ class Purchase extends Model { return [ 'cart_id' => 'integer', - 'event_id' => 'integer', 'user_id' => 'integer', 'expires_at' => 'datetime', 'total' => 'decimal:2', @@ -87,12 +84,6 @@ class Purchase extends Model return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); } - /** @return BelongsTo */ - public function event(): BelongsTo - { - return $this->belongsTo(Event::class); - } - /** * @return BelongsTo */ diff --git a/app/Domains/Purchase/Resources/PurchaseItemResource.php b/app/Domains/Purchase/Resources/PurchaseItemResource.php index 5712cbf..0b09562 100644 --- a/app/Domains/Purchase/Resources/PurchaseItemResource.php +++ b/app/Domains/Purchase/Resources/PurchaseItemResource.php @@ -64,7 +64,7 @@ class PurchaseItemResource extends JsonResource ], 'item_details' => $selectedItem === null ? null : [ 'nombre' => $selectedItem->getName(), - 'descripcion' => $catalogItem?->descripcion, + 'descripcion' => $selectedItem->getDescription(), 'imagen' => $imageUrl, 'attributes' => $variant === null ? [] : $this->resolveAttributes($variant), ], @@ -98,14 +98,36 @@ class PurchaseItemResource extends JsonResource return []; } - return $variant->definitions - ->map(fn ($definition): array => [ - 'name' => (string) ($definition->itemAttribute?->attribute?->nombre ?? ''), - 'value' => $definition->value, - ]) + $attributes = $variant->definitions + ->groupBy('item_attribute_id') + ->map(function ($definitions): array { + $itemAttribute = $definitions->first()?->itemAttribute; + $values = $definitions->pluck('value')->values(); + + return [ + 'name' => (string) ($itemAttribute?->attribute?->nombre ?? ''), + 'value' => $itemAttribute?->allow_multi_select + ? $values->all() + : $values->first(), + ]; + }) ->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null) - ->values() - ->all(); + ->values(); + + $eventDates = $variant->relationLoaded('eventDates') + ? $variant->selectedEventDates() + : collect(); + if ($eventDates->isNotEmpty()) { + $attributes->prepend([ + 'name' => 'Fecha', + 'value' => $eventDates + ->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d')) + ->values() + ->all(), + ]); + } + + return $attributes->all(); } private function formatMoney(float|int|string|null $amount): string diff --git a/app/Domains/Purchase/Resources/PurchaseResource.php b/app/Domains/Purchase/Resources/PurchaseResource.php index 161c7cf..b383adc 100644 --- a/app/Domains/Purchase/Resources/PurchaseResource.php +++ b/app/Domains/Purchase/Resources/PurchaseResource.php @@ -42,7 +42,6 @@ class PurchaseResource extends JsonResource 'id' => $this->id, 'cart_id' => $this->cart_id, 'tenant_codigo' => $this->tenant_codigo, - 'event_id' => $this->event_id, 'user_id' => $this->user_id, 'created_at' => $this->created_at, 'status' => $this->status, diff --git a/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php b/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php new file mode 100644 index 0000000..c6df893 --- /dev/null +++ b/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php @@ -0,0 +1,90 @@ +whereKey($catalogItemId) + ->where('tenant_code', $tenant->codigo) + ->lockForUpdate() + ->first(); + + if ($catalogItem === null) { + throw new NotFoundHttpException('Catalog item not found for tenant.'); + } + + if ($catalogItem->isBundle()) { + if ($variantId !== null) { + throw ValidationException::withMessages([ + 'direct_item.variant_id' => __('api.cart.bundle_variant_forbidden'), + ]); + } + + if (! $catalogItem->bundleComponents()->exists()) { + throw ValidationException::withMessages([ + 'direct_item.catalog_item_id' => __('api.cart.empty_bundle'), + ]); + } + + return $catalogItem; + } + + if ($variantId === null) { + if ($catalogItem->inventory_id === null) { + throw ValidationException::withMessages([ + 'direct_item.variant_id' => __('api.cart.variant_required'), + ]); + } + + $catalogItem->setRelation( + 'inventory', + Inventory::query()->whereKey($catalogItem->inventory_id)->lockForUpdate()->firstOrFail(), + ); + + return $catalogItem; + } + + /** @var Variant|null $variant */ + $variant = Variant::query() + ->whereKey($variantId) + ->where('catalog_item_id', $catalogItem->id) + ->lockForUpdate() + ->first(); + + if ($variant === null) { + throw new NotFoundHttpException('Variant not found for catalog item.'); + } + + $variant->setRelation('catalogItem', $catalogItem); + $variant->setRelation( + 'inventory', + Inventory::query()->whereKey($variant->inventory_id)->lockForUpdate()->firstOrFail(), + ); + + return $variant; + } + + public function resolvePurchaseItem(Tenant $tenant, PurchaseItem $item): CatalogItem|Variant + { + return $this->resolve( + $tenant, + (int) $item->source_catalog_item_id, + $item->source_variant_id === null ? null : (int) $item->source_variant_id, + ); + } +} diff --git a/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php new file mode 100644 index 0000000..d5c2baa --- /dev/null +++ b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php @@ -0,0 +1,131 @@ +lockPurchase($purchase); + + if ($purchase->payment_method === null) { + throw ValidationException::withMessages([ + 'payment_method' => __('api.purchase.payment_method_required'), + ]); + } + + if ($this->isTerminal($purchase)) { + return $this->loadPurchase($purchase); + } + + $purchase->update([ + 'status' => Purchase::STATUS_PENDING_PAYMENT, + 'total' => $purchase->calculateCurrentTotalAmount(), + ]); + + return $this->loadPurchase($purchase); + }); + } + + public function submitForReview(Purchase $purchase): Purchase + { + return DB::transaction(function () use ($purchase): Purchase { + $purchase = $this->lockPurchase($purchase); + + if ($purchase->status === Purchase::STATUS_PAID) { + return $this->loadPurchase($purchase); + } + + if ( + $purchase->status !== Purchase::STATUS_PENDING_PAYMENT + || ($purchase->expires_at !== null && $purchase->expires_at->isPast()) + ) { + throw ValidationException::withMessages([ + 'purchase' => __('api.purchase.not_available_for_review'), + ]); + } + + $purchase->update(['expires_at' => null]); + + return $this->loadPurchase($purchase); + }); + } + + public function confirm(Purchase $purchase): void + { + DB::transaction(function () use ($purchase): void { + $purchase = $this->lockPurchase($purchase); + + if ($purchase->status === Purchase::STATUS_PAID) { + return; + } + + if (in_array($purchase->status, [ + Purchase::STATUS_CANCELLED, + Purchase::STATUS_REJECTED, + Purchase::STATUS_EXPIRED, + ], true)) { + throw ValidationException::withMessages([ + 'purchase' => __('api.purchase.cannot_confirm'), + ]); + } + + $items = $purchase->items() + ->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE) + ->lockForUpdate() + ->get(); + + foreach ($items as $item) { + $selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item); + + try { + $this->inventory->commit($selection, (int) $item->cantidad); + } catch (\InvalidArgumentException) { + throw ValidationException::withMessages([ + 'items' => __('api.purchase.inconsistent_reservation'), + ]); + } + + $item->update([ + 'reservation_status' => PurchaseItem::RESERVATION_COMMITTED, + ]); + } + + $this->sourceCart->finalize($purchase); + }); + } + + private function isTerminal(Purchase $purchase): bool + { + return in_array($purchase->status, [ + Purchase::STATUS_PAID, + Purchase::STATUS_CANCELLED, + Purchase::STATUS_REJECTED, + Purchase::STATUS_EXPIRED, + ], true); + } + + private function lockPurchase(Purchase $purchase): Purchase + { + /** @var Purchase */ + return Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); + } + + private function loadPurchase(Purchase $purchase): Purchase + { + return $purchase->load(['items.imageAttachment']); + } +} diff --git a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php new file mode 100644 index 0000000..9491b38 --- /dev/null +++ b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php @@ -0,0 +1,175 @@ + $customerData */ + public function updateCustomer(Purchase $purchase, array $customerData): Purchase + { + return DB::transaction(function () use ($purchase, $customerData): Purchase { + $purchase = $this->lockPurchase($purchase); + $this->assertEditable($purchase); + + $purchase->update($customerData); + + return $this->loadPurchase($purchase); + }); + } + + public function updateItemQuantity( + Purchase $purchase, + PurchaseItem $purchaseItem, + int $quantity, + ): Purchase { + return DB::transaction(function () use ($purchase, $purchaseItem, $quantity): Purchase { + $purchase = $this->lockPurchase($purchase); + + if ($purchase->status !== Purchase::STATUS_CREATED || $this->hasExpired($purchase)) { + throw ValidationException::withMessages([ + 'purchase' => __('api.purchase.not_editable'), + ]); + } + + $purchaseItem = $this->lockPurchaseItem($purchase, $purchaseItem); + $difference = $quantity - (int) $purchaseItem->cantidad; + + if ($difference !== 0) { + $this->adjustReservation($purchase, $purchaseItem, $quantity, $difference); + + $purchaseItem->update([ + 'cantidad' => $quantity, + 'total' => (float) $purchaseItem->precio_unitario * $quantity, + ]); + $this->sourceCart->syncItemQuantity($purchase, $purchaseItem, $quantity); + } + + $purchase->update([ + 'total' => $purchase->calculateCurrentTotalAmount(), + ]); + + return $this->loadPurchase($purchase); + }); + } + + public function prepareItemEditing(Purchase $purchase): Purchase + { + return DB::transaction(function () use ($purchase): Purchase { + $purchase = $this->lockPurchase($purchase); + $this->assertEditable($purchase); + + $purchase->telepagosQr()->delete(); + $purchase->update([ + 'status' => Purchase::STATUS_CREATED, + 'payment_method' => null, + 'transfer_payer_dni' => null, + 'expires_at' => now()->addMinutes( + max(1, (int) config('purchase.checkout_expiration_minutes', 30)), + ), + ]); + + return $this->loadPurchase($purchase); + }); + } + + private function adjustReservation( + Purchase $purchase, + PurchaseItem $purchaseItem, + int $quantity, + int $difference, + ): void { + $selection = $this->selections->resolvePurchaseItem($purchase->tenant, $purchaseItem); + + try { + if ($difference > 0) { + $otherItemQuantity = (int) $purchase->items() + ->where('source_catalog_item_id', $purchaseItem->source_catalog_item_id) + ->whereKeyNot($purchaseItem->getKey()) + ->sum('cantidad'); + $catalogItem = $selection instanceof Variant ? $selection->catalogItem : $selection; + + $this->purchaseLimits->assertCanPurchase( + $catalogItem, + (int) $purchase->user_id, + $otherItemQuantity + $quantity, + $purchase->getKey(), + ); + $this->inventory->reserve($selection, $difference); + } else { + $this->inventory->release($selection, abs($difference)); + } + } catch (\InvalidArgumentException) { + throw ValidationException::withMessages([ + 'quantity' => __('api.purchase.insufficient_stock'), + ]); + } + } + + private function lockPurchaseItem(Purchase $purchase, PurchaseItem $item): PurchaseItem + { + /** @var PurchaseItem|null $lockedItem */ + $lockedItem = $purchase->items() + ->whereKey($item->getKey()) + ->lockForUpdate() + ->first(); + + if ($lockedItem === null) { + throw new NotFoundHttpException('Purchase item not found.'); + } + + if ($lockedItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) { + throw ValidationException::withMessages([ + 'item' => __('api.purchase.item_not_editable'), + ]); + } + + return $lockedItem; + } + + private function assertEditable(Purchase $purchase): void + { + if ( + ! in_array($purchase->status, [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ], true) + || $this->hasExpired($purchase) + ) { + throw ValidationException::withMessages([ + 'purchase' => __('api.purchase.not_editable'), + ]); + } + } + + private function hasExpired(Purchase $purchase): bool + { + return $purchase->expires_at !== null && $purchase->expires_at->isPast(); + } + + private function lockPurchase(Purchase $purchase): Purchase + { + /** @var Purchase */ + return Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); + } + + private function loadPurchase(Purchase $purchase): Purchase + { + return $purchase->load(['items.imageAttachment']); + } +} diff --git a/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php new file mode 100644 index 0000000..e1711a2 --- /dev/null +++ b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php @@ -0,0 +1,85 @@ + $cartItems + * @return array> + */ + public function fromCartItems(Collection $cartItems): array + { + return $cartItems + ->map(function (CartItem $item): array { + $selectedItem = $item->selectedItem(); + $quantity = (int) $item->cantidad; + $unitPrice = $selectedItem?->getPrice() ?? 0; + + return [ + 'source_catalog_item_id' => $item->catalog_item_id, + 'source_variant_id' => $item->variant_id, + 'image_attachment_id' => $this->firstImageAttachment($item)?->id, + 'nombre' => $item->catalogItem->nombre, + 'descripcion' => $selectedItem?->getDescription(), + 'slug' => $item->catalogItem->slug, + 'item_nombre' => $selectedItem->getName(), + 'variant_attributes' => $item->variant === null + ? [] + : $this->snapshotAttributes($item->variant), + 'cantidad' => $quantity, + 'precio_unitario' => $unitPrice, + 'discount_total' => null, + 'tax_total' => null, + 'total' => $unitPrice * $quantity, + 'reservation_status' => PurchaseItem::RESERVATION_ACTIVE, + ]; + }) + ->all(); + } + + private function firstImageAttachment(CartItem $item): ?Attachment + { + return $item->variant?->attachments->first() + ?? $item->catalogItem?->attachments->first(); + } + + /** @return array */ + private function snapshotAttributes(Variant $variant): array + { + $attributes = $variant->definitions + ->groupBy('item_attribute_id') + ->map(function ($definitions): array { + $itemAttribute = $definitions->first()?->itemAttribute; + $values = $definitions->pluck('value')->values(); + + return [ + 'name' => (string) ($itemAttribute?->attribute?->nombre ?? ''), + 'value' => $itemAttribute?->allow_multi_select + ? $values->all() + : $values->first(), + ]; + }) + ->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null) + ->values(); + + $eventDates = $variant->selectedEventDates(); + if ($eventDates->isNotEmpty()) { + $attributes->prepend([ + 'name' => 'Fecha', + 'value' => $eventDates + ->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d')) + ->values() + ->all(), + ]); + } + + return $attributes->all(); + } +} diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php new file mode 100644 index 0000000..aa0ce9f --- /dev/null +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -0,0 +1,134 @@ +release($purchase, Purchase::STATUS_CANCELLED, restoreCart: true); + } + + public function cancelWithoutRestoringCart(Purchase $purchase): Purchase + { + return $this->release($purchase, Purchase::STATUS_CANCELLED, restoreCart: false); + } + + public function expire(Purchase $purchase): Purchase + { + return $this->release($purchase, Purchase::STATUS_EXPIRED, restoreCart: true); + } + + public function expireOverdue(): int + { + $expiredCount = 0; + + Purchase::query() + ->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT]) + ->whereNotNull('expires_at') + ->where('expires_at', '<=', now()) + ->orderBy('id') + ->eachById(function (Purchase $purchase) use (&$expiredCount): void { + $purchase = $this->expire($purchase); + + if ($purchase->status === Purchase::STATUS_EXPIRED) { + $expiredCount++; + } + }); + + return $expiredCount; + } + + private function release(Purchase $purchase, string $targetStatus, bool $restoreCart): Purchase + { + return DB::transaction(function () use ($purchase, $targetStatus, $restoreCart): Purchase { + $purchase = $this->lockPurchase($purchase); + + if ($purchase->status === Purchase::STATUS_PAID) { + if ($targetStatus === Purchase::STATUS_EXPIRED) { + return $this->loadPurchase($purchase); + } + + throw ValidationException::withMessages([ + 'purchase' => __('api.purchase.paid_cannot_cancel'), + ]); + } + + if ($this->isAlreadyReleased($purchase)) { + return $this->loadPurchase($purchase); + } + + if ( + $targetStatus === Purchase::STATUS_EXPIRED + && ($purchase->expires_at === null || $purchase->expires_at->isFuture()) + ) { + return $this->loadPurchase($purchase); + } + + $items = $purchase->items() + ->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE) + ->lockForUpdate() + ->get(); + $reservationReturnedToCart = $restoreCart && $this->sourceCart->restore($purchase); + + foreach ($items as $item) { + if (! $reservationReturnedToCart) { + $this->releaseInventory($purchase, $item); + } + + $item->update([ + 'reservation_status' => PurchaseItem::RESERVATION_RELEASED, + ]); + } + + $purchase->update(['status' => $targetStatus]); + + return $this->loadPurchase($purchase); + }); + } + + private function releaseInventory(Purchase $purchase, PurchaseItem $item): void + { + $selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item); + + try { + $this->inventory->release($selection, (int) $item->cantidad); + } catch (\InvalidArgumentException) { + throw ValidationException::withMessages([ + 'items' => __('api.purchase.inconsistent_reservation'), + ]); + } + } + + private function isAlreadyReleased(Purchase $purchase): bool + { + return in_array($purchase->status, [ + Purchase::STATUS_CANCELLED, + Purchase::STATUS_REJECTED, + Purchase::STATUS_EXPIRED, + ], true); + } + + private function lockPurchase(Purchase $purchase): Purchase + { + /** @var Purchase */ + return Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); + } + + private function loadPurchase(Purchase $purchase): Purchase + { + return $purchase->load(['items.imageAttachment']); + } +} diff --git a/app/Domains/Purchase/Services/Checkout/SourceCartService.php b/app/Domains/Purchase/Services/Checkout/SourceCartService.php new file mode 100644 index 0000000..45624cd --- /dev/null +++ b/app/Domains/Purchase/Services/Checkout/SourceCartService.php @@ -0,0 +1,125 @@ +findSourceCart($purchase); + + if ($sourceCart === null) { + return false; + } + + /** @var Cart|null $activeCart */ + $activeCart = Cart::query() + ->where('tenant_codigo', $purchase->tenant_codigo) + ->where('user_id', $purchase->user_id) + ->where('status', 'active') + ->where('id', '!=', $sourceCart->getKey()) + ->lockForUpdate() + ->first(); + + if ($activeCart !== null) { + $this->mergeIntoActiveCart($sourceCart, $activeCart); + + $sourceCart->update([ + 'status' => 'converted', + 'guest_token' => null, + ]); + + if (! $sourceCart->trashed()) { + $sourceCart->delete(); + } + + return true; + } + + if ($sourceCart->trashed()) { + $sourceCart->restore(); + } + + $sourceCart->update([ + 'status' => 'active', + 'user_id' => $purchase->user_id, + 'guest_token' => null, + ]); + + return true; + } + + public function syncItemQuantity( + Purchase $purchase, + PurchaseItem $purchaseItem, + int $quantity, + ): void { + $sourceCart = $this->findSourceCart($purchase); + + if ($sourceCart === null) { + return; + } + + $sourceCart->items() + ->where('catalog_item_id', $purchaseItem->source_catalog_item_id) + ->where('variant_id', $purchaseItem->source_variant_id) + ->update(['cantidad' => $quantity]); + } + + public function finalize(Purchase $purchase): void + { + $sourceCart = $this->findSourceCart($purchase); + + if ($sourceCart === null || $sourceCart->trashed()) { + return; + } + + $sourceCart->update([ + 'status' => 'converted', + 'guest_token' => null, + ]); + $sourceCart->delete(); + } + + private function findSourceCart(Purchase $purchase): ?Cart + { + if ($purchase->cart_id === null) { + return null; + } + + /** @var Cart|null */ + return Cart::withTrashed() + ->whereKey($purchase->cart_id) + ->lockForUpdate() + ->first(); + } + + private function mergeIntoActiveCart(Cart $sourceCart, Cart $activeCart): void + { + $sourceItems = $sourceCart->items()->lockForUpdate()->get(); + + foreach ($sourceItems as $sourceItem) { + /** @var CartItem|null $activeItem */ + $activeItem = $activeCart->items() + ->where('catalog_item_id', $sourceItem->catalog_item_id) + ->where('variant_id', $sourceItem->variant_id) + ->lockForUpdate() + ->first(); + + if ($activeItem === null) { + $activeCart->items()->create([ + 'catalog_item_id' => $sourceItem->catalog_item_id, + 'variant_id' => $sourceItem->variant_id, + 'cantidad' => $sourceItem->cantidad, + ]); + } else { + $activeItem->increment('cantidad', (int) $sourceItem->cantidad); + } + } + } +} diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php new file mode 100644 index 0000000..b5ade20 --- /dev/null +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -0,0 +1,290 @@ + $purchaseData */ + public function start(Tenant $tenant, int $userId, array $purchaseData): Purchase + { + return DB::transaction(function () use ($tenant, $userId, $purchaseData): Purchase { + /** @var Tenant $tenant */ + $tenant = Tenant::query() + ->lockForUpdate() + ->findOrFail($tenant->getKey()); + + $directItem = $purchaseData['direct_item'] ?? null; + $cartId = isset($purchaseData['cart_id']) ? (int) $purchaseData['cart_id'] : null; + unset($purchaseData['direct_item'], $purchaseData['cart_id']); + + if (is_array($directItem)) { + return $this->startDirect($tenant, $userId, $purchaseData, $directItem); + } + + if ($cartId === null) { + throw ValidationException::withMessages([ + 'cart_id' => __('api.purchase.source_required'), + ]); + } + + return $this->startFromCart($tenant, $userId, $purchaseData, $cartId); + }); + } + + /** + * @param array $purchaseData + * @param array $directItem + */ + private function startDirect( + Tenant $tenant, + int $userId, + array $purchaseData, + array $directItem, + ): Purchase { + $catalogItemId = (int) $directItem['catalog_item_id']; + $variantId = isset($directItem['variant_id']) ? (int) $directItem['variant_id'] : null; + $quantity = (int) $directItem['cantidad']; + $selection = $this->selections->resolve($tenant, $catalogItemId, $variantId); + $catalogItem = $selection instanceof Variant ? $selection->catalogItem : $selection; + + $this->purchaseLimits->assertCanPurchase( + $catalogItem, + $userId, + $quantity, + field: 'direct_item.cantidad', + ); + + $availableQuantity = $this->inventory->availableQuantity($selection); + + if ($availableQuantity !== null && $availableQuantity < $quantity) { + throw ValidationException::withMessages([ + 'direct_item.cantidad' => __('api.purchase.direct_item_max_stock', ['max' => $availableQuantity]), + ]); + } + + try { + $this->inventory->reserve($selection, $quantity); + } catch (\InvalidArgumentException) { + throw ValidationException::withMessages([ + 'direct_item.cantidad' => __('api.purchase.insufficient_stock'), + ]); + } + + $purchase = $this->createPurchase( + $tenant, + $userId, + $purchaseData, + $selection->getPrice() * $quantity, + null, + ); + $directCartItem = $this->makeDirectCartItem( + $selection, + $catalogItemId, + $variantId, + $quantity, + ); + $purchase->items()->createMany( + $this->snapshots->fromCartItems(collect([$directCartItem])), + ); + + return $this->loadPurchase($purchase); + } + + /** @param array $purchaseData */ + private function startFromCart( + Tenant $tenant, + int $userId, + array $purchaseData, + int $cartId, + ): Purchase { + $cart = $this->resolveCart($tenant, $userId, $cartId); + $cartItems = $cart->items()->lockForUpdate()->get(); + + if ($cartItems->isEmpty()) { + throw ValidationException::withMessages([ + 'cart_id' => __('api.purchase.empty_cart'), + ]); + } + + $this->loadCartItems($cartItems); + $this->verifyTenantItems($tenant, $cartItems); + $this->assertCartPurchaseLimits($tenant, $userId, $cartItems); + $cart->setRelation('items', $cartItems); + + $purchase = $this->createPurchase( + $tenant, + $userId, + $purchaseData, + $cart->getTotalAmount(), + $cart->getKey(), + ); + $purchase->items()->createMany($this->snapshots->fromCartItems($cartItems)); + + // The purchase owns the reservation until checkout finishes. The cart is + // retained so it can be restored if the purchase is cancelled or expires. + $cart->update([ + 'status' => 'checkout', + 'guest_token' => null, + ]); + + return $this->loadPurchase($purchase); + } + + private function resolveCart(Tenant $tenant, int $userId, int $cartId): Cart + { + /** @var Cart|null $cart */ + $cart = Cart::query()->lockForUpdate()->find($cartId); + + if ($cart === null || $cart->tenant_codigo !== $tenant->codigo || $cart->user_id !== $userId) { + throw new NotFoundHttpException('Cart not found for tenant.'); + } + + if ($cart->status !== 'active') { + throw ValidationException::withMessages([ + 'cart_id' => __('api.purchase.inactive_cart'), + ]); + } + + return $cart; + } + + /** @param Collection $cartItems */ + private function verifyTenantItems(Tenant $tenant, Collection $cartItems): void + { + foreach ($cartItems as $item) { + if ($item->selectedItem() === null) { + throw ValidationException::withMessages([ + 'cart_id' => __('api.purchase.catalog_item_missing'), + ]); + } + + if ($item->catalogItem?->tenant_code !== $tenant->codigo) { + throw ValidationException::withMessages([ + 'cart_id' => __('api.purchase.catalog_item_wrong_tenant'), + ]); + } + } + } + + /** @param Collection $cartItems */ + private function assertCartPurchaseLimits( + Tenant $tenant, + int $userId, + Collection $cartItems, + ): void { + $quantities = $cartItems + ->groupBy('catalog_item_id') + ->map(fn (Collection $items): int => (int) $items->sum('cantidad')) + ->sortKeys(); + + $catalogItems = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->whereKey($quantities->keys()) + ->orderBy('id') + ->lockForUpdate() + ->get() + ->keyBy('id'); + + foreach ($quantities as $catalogItemId => $quantity) { + /** @var CatalogItem $catalogItem */ + $catalogItem = $catalogItems->get($catalogItemId); + $this->purchaseLimits->assertCanPurchase( + $catalogItem, + $userId, + $quantity, + field: 'cart_id', + ); + } + } + + /** @param array $purchaseData */ + private function createPurchase( + Tenant $tenant, + int $userId, + array $purchaseData, + float $total, + ?int $cartId, + ): Purchase { + return Purchase::query()->create([ + ...$purchaseData, + 'cart_id' => $cartId, + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $userId, + 'status' => Purchase::STATUS_CREATED, + 'payment_method' => null, + 'expires_at' => now()->addMinutes( + max(1, (int) config('purchase.checkout_expiration_minutes', 30)), + ), + 'total' => $total, + ]); + } + + private function makeDirectCartItem( + CatalogItem|Variant $selection, + int $catalogItemId, + ?int $variantId, + int $quantity, + ): CartItem { + $catalogItem = $selection instanceof Variant ? $selection->catalogItem : $selection; + $catalogItem->loadMissing(['inventory', 'attachments']); + + if ($selection instanceof Variant) { + $selection->loadMissing([ + 'inventory', + 'attachments', + 'catalogItem', + 'definitions.itemAttribute.attribute', + ]); + } + + $item = new CartItem([ + 'catalog_item_id' => $catalogItemId, + 'variant_id' => $variantId, + 'cantidad' => $quantity, + ]); + $item->setRelation('catalogItem', $catalogItem); + $item->setRelation('variant', $selection instanceof Variant ? $selection : null); + + return $item; + } + + /** @param Collection $cartItems */ + private function loadCartItems(Collection $cartItems): void + { + $cartItems->load([ + 'catalogItem.inventory', + 'catalogItem.attachments', + 'variant.inventory', + 'variant.attachments', + 'variant.catalogItem', + 'variant.definitions.itemAttribute.attribute', + 'variant.eventDates', + 'variant.eventDate', + ]); + } + + private function loadPurchase(Purchase $purchase): Purchase + { + return $purchase->load(['items.imageAttachment']); + } +} diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index 3da31e6..b575acd 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -2,153 +2,49 @@ namespace App\Domains\Purchase\Services; -use App\Domains\Attachable\Models\Attachment; -use App\Domains\Cart\Models\Cart; -use App\Domains\Cart\Models\CartItem; -use App\Domains\Catalog\Models\CatalogItem; -use App\Domains\Catalog\Models\Inventory; -use App\Domains\Catalog\Models\Variant; -use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; +use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService; +use App\Domains\Purchase\Services\Checkout\EditCheckoutService; +use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService; +use App\Domains\Purchase\Services\Checkout\StartCheckoutService; use App\Domains\Tenant\Models\Tenant; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; -use Illuminate\Validation\ValidationException; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +/** + * Stable checkout API used by controllers, commands and integrations. + * + * Workflow details live in focused services under Services/Checkout. + */ class CheckoutService { public function __construct( - private readonly CatalogInventoryService $catalogInventoryService, + private readonly StartCheckoutService $starter, + private readonly EditCheckoutService $editor, + private readonly CompleteCheckoutService $completer, + private readonly ReleaseCheckoutService $releaser, ) {} + /** @param array $purchaseData */ public function startCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase { - return DB::transaction(function () use ($tenant, $userId, $purchaseData): Purchase { - /** @var Tenant $tenant */ - $tenant = Tenant::query() - ->lockForUpdate() - ->findOrFail($tenant->getKey()); - - $directItem = $purchaseData['direct_item'] ?? null; - $cartId = isset($purchaseData['cart_id']) ? (int) $purchaseData['cart_id'] : null; - unset($purchaseData['direct_item'], $purchaseData['cart_id']); - - if (is_array($directItem)) { - return $this->startDirectCheckout( - $tenant, - $userId, - $purchaseData, - $directItem, - ); - } - - if ($cartId === null) { - throw ValidationException::withMessages([ - 'cart_id' => __('api.purchase.source_required'), - ]); - } - - return $this->startCartCheckout( - $tenant, - $userId, - $purchaseData, - $cartId, - ); - }); + return $this->starter->start($tenant, $userId, $purchaseData); } public function completePurchase(Purchase $purchase): Purchase { - return DB::transaction(function () use ($purchase): Purchase { - /** @var Purchase $purchase */ - $purchase = Purchase::query() - ->lockForUpdate() - ->findOrFail($purchase->getKey()); - - if ($purchase->payment_method === null) { - throw ValidationException::withMessages([ - 'payment_method' => __('api.purchase.payment_method_required'), - ]); - } - - if (in_array($purchase->status, [ - Purchase::STATUS_PAID, - Purchase::STATUS_CANCELLED, - Purchase::STATUS_REJECTED, - Purchase::STATUS_EXPIRED, - ], true)) { - return $this->loadPurchase($purchase); - } - - $purchase->update([ - 'status' => Purchase::STATUS_PENDING_PAYMENT, - 'total' => $purchase->calculateCurrentTotalAmount(), - ]); - - return $this->loadPurchase($purchase); - }); + return $this->completer->complete($purchase); } public function submitForReview(Purchase $purchase): Purchase { - return DB::transaction(function () use ($purchase): Purchase { - /** @var Purchase $purchase */ - $purchase = Purchase::query() - ->lockForUpdate() - ->findOrFail($purchase->getKey()); - - if (in_array($purchase->status, [ - Purchase::STATUS_PAID, - ], true)) { - return $this->loadPurchase($purchase); - } - - if ( - $purchase->status !== Purchase::STATUS_PENDING_PAYMENT - || ($purchase->expires_at !== null && $purchase->expires_at->isPast()) - ) { - throw ValidationException::withMessages([ - 'purchase' => __('api.purchase.not_available_for_review'), - ]); - } - - $purchase->update([ - 'expires_at' => null, - ]); - - return $this->loadPurchase($purchase); - }); + return $this->completer->submitForReview($purchase); } - /** - * @param array $customerData - */ + /** @param array $customerData */ public function updateCustomerData(Purchase $purchase, array $customerData): Purchase { - return DB::transaction(function () use ($purchase, $customerData): Purchase { - /** @var Purchase $purchase */ - $purchase = Purchase::query() - ->lockForUpdate() - ->findOrFail($purchase->getKey()); - - if ( - ! in_array($purchase->status, [ - Purchase::STATUS_CREATED, - Purchase::STATUS_PENDING_PAYMENT, - ], true) - || ($purchase->expires_at !== null && $purchase->expires_at->isPast()) - ) { - throw ValidationException::withMessages([ - 'purchase' => __('api.purchase.not_editable'), - ]); - } - - $purchase->update($customerData); - - return $this->loadPurchase($purchase); - }); + return $this->editor->updateCustomer($purchase, $customerData); } public function updateItemQuantity( @@ -156,696 +52,46 @@ class CheckoutService PurchaseItem $purchaseItem, int $quantity, ): Purchase { - return DB::transaction(function () use ($purchase, $purchaseItem, $quantity): Purchase { - /** @var Purchase $purchase */ - $purchase = Purchase::query() - ->lockForUpdate() - ->findOrFail($purchase->getKey()); - - if ( - $purchase->status !== Purchase::STATUS_CREATED - || ($purchase->expires_at !== null && $purchase->expires_at->isPast()) - ) { - throw ValidationException::withMessages([ - 'purchase' => __('api.purchase.not_editable'), - ]); - } - - /** @var PurchaseItem|null $purchaseItem */ - $purchaseItem = $purchase->items() - ->whereKey($purchaseItem->getKey()) - ->lockForUpdate() - ->first(); - - if ($purchaseItem === null) { - throw new NotFoundHttpException('Purchase item not found.'); - } - - if ($purchaseItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) { - throw ValidationException::withMessages([ - 'item' => __('api.purchase.item_not_editable'), - ]); - } - - $currentQuantity = (int) $purchaseItem->cantidad; - $difference = $quantity - $currentQuantity; - - if ($difference !== 0) { - $selection = $this->resolvePurchaseItemSelection($purchase->tenant, $purchaseItem); - - try { - if ($difference > 0) { - $this->catalogInventoryService->reserve($selection, $difference); - } else { - $this->catalogInventoryService->release($selection, abs($difference)); - } - } catch (\InvalidArgumentException $exception) { - throw ValidationException::withMessages([ - 'quantity' => __('api.purchase.insufficient_stock'), - ]); - } - - $purchaseItem->update([ - 'cantidad' => $quantity, - 'total' => (float) $purchaseItem->precio_unitario * $quantity, - ]); - $this->syncSourceCartItemQuantity($purchase, $purchaseItem, $quantity); - } - - $purchase->update([ - 'total' => $purchase->calculateCurrentTotalAmount(), - ]); - - return $this->loadPurchase($purchase); - }); + return $this->editor->updateItemQuantity($purchase, $purchaseItem, $quantity); } public function prepareItemEditing(Purchase $purchase): Purchase { - return DB::transaction(function () use ($purchase): Purchase { - /** @var Purchase $purchase */ - $purchase = Purchase::query() - ->lockForUpdate() - ->findOrFail($purchase->getKey()); - - if ( - ! in_array($purchase->status, [ - Purchase::STATUS_CREATED, - Purchase::STATUS_PENDING_PAYMENT, - ], true) - || ($purchase->expires_at !== null && $purchase->expires_at->isPast()) - ) { - throw ValidationException::withMessages([ - 'purchase' => __('api.purchase.not_editable'), - ]); - } - - $purchase->telepagosQr()->delete(); - $purchase->update([ - 'status' => Purchase::STATUS_CREATED, - 'payment_method' => null, - 'transfer_payer_dni' => null, - 'expires_at' => now()->addMinutes( - max(1, (int) config('purchase.checkout_expiration_minutes', 30)), - ), - ]); - - return $this->loadPurchase($purchase); - }); + return $this->editor->prepareItemEditing($purchase); } public function confirmPurchase(Purchase $purchase): void { - DB::transaction(function () use ($purchase): void { - /** @var Purchase $purchase */ - $purchase = Purchase::query() - ->lockForUpdate() - ->findOrFail($purchase->getKey()); + $this->completer->confirm($purchase); + } - if ($purchase->status === Purchase::STATUS_PAID) { - return; - } + public function confirmPaidPurchase(Purchase $purchase): Purchase + { + return DB::transaction(function () use ($purchase): Purchase { + $this->completer->confirm($purchase); + $purchase->markAsPaid(); - if (in_array($purchase->status, [ - Purchase::STATUS_CANCELLED, - Purchase::STATUS_REJECTED, - Purchase::STATUS_EXPIRED, - ], true)) { - throw ValidationException::withMessages([ - 'purchase' => __('api.purchase.cannot_confirm'), - ]); - } - - $items = $purchase->items() - ->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE) - ->lockForUpdate() - ->get(); - - foreach ($items as $item) { - $selection = $this->resolvePurchaseItemSelection($purchase->tenant, $item); - - try { - $this->catalogInventoryService->commit($selection, (int) $item->cantidad); - } catch (\InvalidArgumentException $exception) { - throw ValidationException::withMessages([ - 'items' => __('api.purchase.inconsistent_reservation'), - ]); - } - - $item->update([ - 'reservation_status' => PurchaseItem::RESERVATION_COMMITTED, - ]); - } - - $this->finalizeSourceCart($purchase); + return $purchase->refresh()->load(['items.imageAttachment']); }); } public function cancelPurchase(Purchase $purchase): Purchase { - return $this->releasePurchase($purchase, Purchase::STATUS_CANCELLED); + return $this->releaser->cancel($purchase); + } + + public function cancelPurchaseWithoutRestoringCart(Purchase $purchase): Purchase + { + return $this->releaser->cancelWithoutRestoringCart($purchase); } public function expirePurchase(Purchase $purchase): Purchase { - return $this->releasePurchase($purchase, Purchase::STATUS_EXPIRED); + return $this->releaser->expire($purchase); } public function expireOverduePurchases(): int { - $expiredCount = 0; - - Purchase::query() - ->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT]) - ->whereNotNull('expires_at') - ->where('expires_at', '<=', now()) - ->orderBy('id') - ->eachById(function (Purchase $purchase) use (&$expiredCount): void { - $purchase = $this->expirePurchase($purchase); - - if ($purchase->status === Purchase::STATUS_EXPIRED) { - $expiredCount++; - } - }); - - return $expiredCount; - } - - private function releasePurchase(Purchase $purchase, string $targetStatus): Purchase - { - return DB::transaction(function () use ($purchase, $targetStatus): Purchase { - /** @var Purchase $purchase */ - $purchase = Purchase::query() - ->lockForUpdate() - ->findOrFail($purchase->getKey()); - - if ($purchase->status === Purchase::STATUS_PAID) { - if ($targetStatus === Purchase::STATUS_EXPIRED) { - return $this->loadPurchase($purchase); - } - - throw ValidationException::withMessages([ - 'purchase' => __('api.purchase.paid_cannot_cancel'), - ]); - } - - if (in_array($purchase->status, [ - Purchase::STATUS_CANCELLED, - Purchase::STATUS_REJECTED, - Purchase::STATUS_EXPIRED, - ], true)) { - return $this->loadPurchase($purchase); - } - - if ( - $targetStatus === Purchase::STATUS_EXPIRED - && ($purchase->expires_at === null || $purchase->expires_at->isFuture()) - ) { - return $this->loadPurchase($purchase); - } - - $items = $purchase->items() - ->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE) - ->lockForUpdate() - ->get(); - $reservationReturnedToCart = $this->restoreSourceCart($purchase); - - foreach ($items as $item) { - if (! $reservationReturnedToCart) { - $selection = $this->resolvePurchaseItemSelection($purchase->tenant, $item); - - try { - $this->catalogInventoryService->release($selection, (int) $item->cantidad); - } catch (\InvalidArgumentException $exception) { - throw ValidationException::withMessages([ - 'items' => __('api.purchase.inconsistent_reservation'), - ]); - } - } - - $item->update([ - 'reservation_status' => PurchaseItem::RESERVATION_RELEASED, - ]); - } - - $purchase->update([ - 'status' => $targetStatus, - ]); - - return $this->loadPurchase($purchase); - }); - } - - /** - * @param array $purchaseData - * @param array $directItem - */ - private function startDirectCheckout( - Tenant $tenant, - int $userId, - array $purchaseData, - array $directItem, - ): Purchase { - $catalogItemId = (int) $directItem['catalog_item_id']; - $variantId = isset($directItem['variant_id']) ? (int) $directItem['variant_id'] : null; - $quantity = (int) $directItem['cantidad']; - $selection = $this->resolveSelection($tenant, $catalogItemId, $variantId); - $availableQuantity = $this->catalogInventoryService->availableQuantity($selection); - - if ($availableQuantity !== null && $availableQuantity < $quantity) { - throw ValidationException::withMessages([ - 'direct_item.cantidad' => __('api.purchase.direct_item_max_stock', ['max' => $availableQuantity]), - ]); - } - - try { - $this->catalogInventoryService->reserve($selection, $quantity); - } catch (\InvalidArgumentException $exception) { - throw ValidationException::withMessages([ - 'direct_item.cantidad' => __('api.purchase.insufficient_stock'), - ]); - } - - $purchase = $this->createPurchase( - $tenant, - $userId, - $purchaseData, - $selection->getPrice() * $quantity, - null, - ); - $cartItem = $this->makeDirectCartItem($selection, $catalogItemId, $variantId, $quantity); - $purchase->items()->createMany( - $this->buildPurchaseItemsPayload(collect([$cartItem])), - ); - - return $this->loadPurchase($purchase); - } - - /** - * @param array $purchaseData - */ - private function startCartCheckout( - Tenant $tenant, - int $userId, - array $purchaseData, - int $cartId, - ): Purchase { - $cart = $this->resolveCheckoutCart($tenant, $userId, $cartId); - $cartItems = $cart->items()->lockForUpdate()->get(); - - if ($cartItems->isEmpty()) { - throw ValidationException::withMessages([ - 'cart_id' => __('api.purchase.empty_cart'), - ]); - } - - $this->loadCartItems($cartItems); - $this->verifyTenantItems($tenant, $cartItems); - $cart->setRelation('items', $cartItems); - - $purchase = $this->createPurchase( - $tenant, - $userId, - $purchaseData, - $cart->getTotalAmount(), - $cart->getKey(), - ); - $purchase->items()->createMany( - $this->buildPurchaseItemsPayload($cartItems), - ); - - // PurchaseItem owns the reservation during checkout. The source cart is - // kept with its owner so it can be restored if the purchase is cancelled - // or expires. Only active carts participate in the identity constraint. - $cart->update([ - 'status' => 'checkout', - 'guest_token' => null, - ]); - - return $this->loadPurchase($purchase); - } - - private function restoreSourceCart(Purchase $purchase): bool - { - if ($purchase->cart_id === null) { - return false; - } - - /** @var Cart|null $sourceCart */ - $sourceCart = Cart::withTrashed() - ->whereKey($purchase->cart_id) - ->lockForUpdate() - ->first(); - - if ($sourceCart === null) { - return false; - } - - /** @var Cart|null $activeCart */ - $activeCart = Cart::query() - ->where('tenant_codigo', $purchase->tenant_codigo) - ->where('user_id', $purchase->user_id) - ->where('status', 'active') - ->where('id', '!=', $sourceCart->getKey()) - ->lockForUpdate() - ->first(); - - if ($activeCart !== null) { - $sourceItems = $sourceCart->items()->lockForUpdate()->get(); - - foreach ($sourceItems as $sourceItem) { - /** @var CartItem|null $activeItem */ - $activeItem = $activeCart->items() - ->where('catalog_item_id', $sourceItem->catalog_item_id) - ->where('variant_id', $sourceItem->variant_id) - ->lockForUpdate() - ->first(); - - if ($activeItem === null) { - $activeCart->items()->create([ - 'catalog_item_id' => $sourceItem->catalog_item_id, - 'variant_id' => $sourceItem->variant_id, - 'cantidad' => $sourceItem->cantidad, - ]); - } else { - $activeItem->increment('cantidad', (int) $sourceItem->cantidad); - } - } - - $sourceCart->update([ - 'status' => 'converted', - 'guest_token' => null, - ]); - - if (! $sourceCart->trashed()) { - $sourceCart->delete(); - } - - return true; - } - - if ($sourceCart->trashed()) { - $sourceCart->restore(); - } - - $sourceCart->update([ - 'status' => 'active', - 'user_id' => $purchase->user_id, - 'guest_token' => null, - ]); - - return true; - } - - private function syncSourceCartItemQuantity( - Purchase $purchase, - PurchaseItem $purchaseItem, - int $quantity, - ): void { - if ($purchase->cart_id === null) { - return; - } - - $sourceCart = Cart::withTrashed() - ->whereKey($purchase->cart_id) - ->lockForUpdate() - ->first(); - - if ($sourceCart === null) { - return; - } - - $sourceCart->items() - ->where('catalog_item_id', $purchaseItem->source_catalog_item_id) - ->where('variant_id', $purchaseItem->source_variant_id) - ->update([ - 'cantidad' => $quantity, - ]); - } - - private function finalizeSourceCart(Purchase $purchase): void - { - if ($purchase->cart_id === null) { - return; - } - - /** @var Cart|null $sourceCart */ - $sourceCart = Cart::withTrashed() - ->whereKey($purchase->cart_id) - ->lockForUpdate() - ->first(); - - if ($sourceCart === null || $sourceCart->trashed()) { - return; - } - - $sourceCart->update([ - 'status' => 'converted', - 'guest_token' => null, - ]); - $sourceCart->delete(); - } - - /** - * @param array $purchaseData - */ - private function createPurchase( - Tenant $tenant, - int $userId, - array $purchaseData, - float $total, - ?int $cartId, - ): Purchase { - return Purchase::query()->create([ - ...$purchaseData, - 'cart_id' => $cartId, - 'tenant_codigo' => $tenant->codigo, - 'event_id' => $tenant->active_event_id, - 'user_id' => $userId, - 'status' => Purchase::STATUS_CREATED, - 'payment_method' => null, - 'expires_at' => now()->addMinutes( - max(1, (int) config('purchase.checkout_expiration_minutes', 30)), - ), - 'total' => $total, - ]); - } - - protected function verifyTenantItems(Tenant $tenant, Collection $cartItems): void - { - foreach ($cartItems as $item) { - if ($item->selectedItem() === null) { - throw ValidationException::withMessages([ - 'cart_id' => __('api.purchase.catalog_item_missing'), - ]); - } - - if ($item->catalogItem?->tenant_code !== $tenant->codigo) { - throw ValidationException::withMessages([ - 'cart_id' => __('api.purchase.catalog_item_wrong_tenant'), - ]); - } - } - } - - protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart - { - /** @var Cart|null $cart */ - $cart = Cart::query() - ->lockForUpdate() - ->find($cartId); - - if ($cart === null || $cart->tenant_codigo !== $tenant->codigo || $cart->user_id !== $userId) { - throw new NotFoundHttpException('Cart not found for tenant.'); - } - - if ($cart->status !== 'active') { - throw ValidationException::withMessages([ - 'cart_id' => __('api.purchase.inactive_cart'), - ]); - } - - return $cart; - } - - /** - * @param Collection $cartItems - * @return array> - */ - protected function buildPurchaseItemsPayload(Collection $cartItems): array - { - return $cartItems - ->map(function (CartItem $item): array { - $selectedItem = $item->selectedItem(); - $quantity = (int) $item->cantidad; - $unitPrice = $selectedItem?->getPrice() ?? 0; - $imageAttachment = $this->firstImageAttachment($item); - - return [ - 'source_catalog_item_id' => $item->catalog_item_id, - 'source_variant_id' => $item->variant_id, - 'image_attachment_id' => $imageAttachment?->id, - 'nombre' => $item->catalogItem->nombre, - 'descripcion' => $item->catalogItem->descripcion, - 'slug' => $item->catalogItem->slug, - 'item_nombre' => $selectedItem->getName(), - 'variant_attributes' => $item->variant === null - ? [] - : $this->snapshotAttributes($item->variant), - 'cantidad' => $quantity, - 'precio_unitario' => $unitPrice, - 'discount_total' => null, - 'tax_total' => null, - 'total' => $unitPrice * $quantity, - 'reservation_status' => PurchaseItem::RESERVATION_ACTIVE, - ]; - }) - ->all(); - } - - private function resolveSelection( - Tenant $tenant, - int $catalogItemId, - ?int $variantId, - ): CatalogItem|Variant { - /** @var CatalogItem|null $catalogItem */ - $catalogItem = CatalogItem::query() - ->whereKey($catalogItemId) - ->where('tenant_code', $tenant->codigo) - ->lockForUpdate() - ->first(); - - if ($catalogItem === null) { - throw new NotFoundHttpException('Catalog item not found for tenant.'); - } - - if ($catalogItem->isBundle()) { - if ($variantId !== null) { - throw ValidationException::withMessages([ - 'direct_item.variant_id' => __('api.cart.bundle_variant_forbidden'), - ]); - } - - if (! $catalogItem->bundleComponents()->exists()) { - throw ValidationException::withMessages([ - 'direct_item.catalog_item_id' => __('api.cart.empty_bundle'), - ]); - } - - return $catalogItem; - } - - if ($variantId === null) { - if ($catalogItem->inventory_id === null) { - throw ValidationException::withMessages([ - 'direct_item.variant_id' => __('api.cart.variant_required'), - ]); - } - - $catalogItem->setRelation( - 'inventory', - Inventory::query()->whereKey($catalogItem->inventory_id)->lockForUpdate()->firstOrFail(), - ); - - return $catalogItem; - } - - /** @var Variant|null $variant */ - $variant = Variant::query() - ->whereKey($variantId) - ->where('catalog_item_id', $catalogItem->id) - ->lockForUpdate() - ->first(); - - if ($variant === null) { - throw new NotFoundHttpException('Variant not found for catalog item.'); - } - - $variant->setRelation('catalogItem', $catalogItem); - $variant->setRelation( - 'inventory', - Inventory::query()->whereKey($variant->inventory_id)->lockForUpdate()->firstOrFail(), - ); - - return $variant; - } - - private function resolvePurchaseItemSelection(Tenant $tenant, PurchaseItem $item): CatalogItem|Variant - { - return $this->resolveSelection( - $tenant, - (int) $item->source_catalog_item_id, - $item->source_variant_id === null ? null : (int) $item->source_variant_id, - ); - } - - private function makeDirectCartItem( - CatalogItem|Variant $selection, - int $catalogItemId, - ?int $variantId, - int $quantity, - ): CartItem { - $catalogItem = $selection instanceof Variant - ? $selection->catalogItem - : $selection; - $catalogItem->loadMissing(['inventory', 'attachments']); - - if ($selection instanceof Variant) { - $selection->loadMissing([ - 'inventory', - 'attachments', - 'catalogItem', - 'definitions.itemAttribute.attribute', - ]); - } - - $item = new CartItem([ - 'catalog_item_id' => $catalogItemId, - 'variant_id' => $variantId, - 'cantidad' => $quantity, - ]); - $item->setRelation('catalogItem', $catalogItem); - $item->setRelation('variant', $selection instanceof Variant ? $selection : null); - - return $item; - } - - /** @param Collection $cartItems */ - private function loadCartItems(Collection $cartItems): void - { - $cartItems->load([ - 'catalogItem.inventory', - 'catalogItem.attachments', - 'variant.inventory', - 'variant.attachments', - 'variant.catalogItem', - 'variant.definitions.itemAttribute.attribute', - ]); - } - - private function loadPurchase(Purchase $purchase): Purchase - { - return $purchase->load([ - 'items.imageAttachment', - ]); - } - - private function firstImageAttachment(CartItem $item): ?Attachment - { - return $item->variant?->attachments->first() - ?? $item->catalogItem?->attachments->first(); - } - - /** @return array */ - private function snapshotAttributes(Variant $variant): array - { - return $variant->definitions - ->map(fn ($definition): array => [ - 'name' => (string) ($definition->itemAttribute?->attribute?->nombre ?? ''), - 'value' => $definition->value, - ]) - ->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null) - ->values() - ->all(); + return $this->releaser->expireOverdue(); } } diff --git a/app/Domains/Purchase/Services/UserPurchaseLimitService.php b/app/Domains/Purchase/Services/UserPurchaseLimitService.php new file mode 100644 index 0000000..8b3cc58 --- /dev/null +++ b/app/Domains/Purchase/Services/UserPurchaseLimitService.php @@ -0,0 +1,62 @@ +whereKey($catalogItem->getKey()) + ->lockForUpdate() + ->firstOrFail(); + $limit = $catalogItem->max_units_per_user; + + if ($limit === null) { + return; + } + + $purchasedQuantity = (int) PurchaseItem::query() + ->where('source_catalog_item_id', $catalogItem->getKey()) + ->whereHas('purchase', function ($query) use ($userId, $excludedPurchaseId): void { + $query + ->where('user_id', $userId) + ->whereIn('status', [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + Purchase::STATUS_PAID, + ]) + ->when( + $excludedPurchaseId !== null, + fn ($query) => $query->whereKeyNot($excludedPurchaseId), + ); + }) + ->sum('cantidad'); + + if ($purchasedQuantity + $requestedQuantity > $limit) { + throw ValidationException::withMessages([ + $field => __('api.purchase_limit.exceeded', ['max' => $limit]), + ]); + } + }); + } +} diff --git a/app/Domains/Purchase/documentacion/README.md b/app/Domains/Purchase/documentacion/README.md new file mode 100644 index 0000000..35d53d0 --- /dev/null +++ b/app/Domains/Purchase/documentacion/README.md @@ -0,0 +1,33 @@ +# Dominio Purchase + +## Propósito + +Implementa el ciclo de compra y checkout: crea una compra desde el carrito, toma una instantánea de sus ítems, reserva inventario, permite ediciones, inicia el pago y confirma, cancela o vence la operación. + +## Modelo + +- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `paid`, `cancelled`, `rejected` y `expired`. +- `PurchaseItem`: snapshot del producto o variante, cantidad, precio y total al comprar. +- `TelepagosQr` y `TelepagosPayment`: datos del QR e intentos/resultados del proveedor. +- `PurchasePaid`: evento emitido una sola vez al pasar a pagada bajo bloqueo transaccional. + +## Servicios de checkout + +`CheckoutService` es la fachada estable. Delega en: + +- `StartCheckoutService`: inicia la compra desde el carrito. +- `EditCheckoutService`: modifica cliente o cantidades antes del cierre. +- `CompleteCheckoutService`: completa, envía a revisión o confirma el pago. +- `ReleaseCheckoutService`: cancela, vence y procesa vencimientos pendientes. +- `SourceCartService`: sincroniza, restaura o finaliza el carrito fuente. +- `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots. + +`UserPurchaseLimitService` controla límites de compra y `CheckoutService` conserva el punto de entrada para controladores e integraciones. + +## Endpoints + +Bajo `/tenants/{tenant:codigo}/compras`, con `auth:sanctum`: listado, inicio, detalle, edición de ítems, datos del cliente, intención de pago, finalización, revisión y cancelación. + +## Dependencias y reglas + +Depende de `Cart`, `Catalog`, `Tenant`, `Auth` e `Integration`; emite eventos consumidos por `Ticket` y `Notification`. Los cambios de estado e inventario deben ser transaccionales y usar los servicios del checkout, no actualizaciones directas del modelo. diff --git a/app/Domains/Sale/Controllers/AdminApp/SaleController.php b/app/Domains/Sale/Controllers/AdminApp/SaleController.php index 8e5c302..d02db6f 100644 --- a/app/Domains/Sale/Controllers/AdminApp/SaleController.php +++ b/app/Domains/Sale/Controllers/AdminApp/SaleController.php @@ -3,8 +3,10 @@ namespace App\Domains\Sale\Controllers\AdminApp; use App\Domains\Sale\Requests\AdminAppSaleIndexRequest; +use App\Domains\Sale\Resources\AdminApp\SaleDetailResource; use App\Domains\Sale\Resources\AdminApp\SaleModificationResource; use App\Domains\Sale\Resources\AdminApp\SaleResource; +use App\Domains\Sale\Resources\AdminApp\SaleTicketResource; use App\Domains\Sale\Services\AdminAppSalePdfService; use App\Domains\Sale\Services\AdminAppSaleService; use App\Http\Controllers\Controller; @@ -30,6 +32,36 @@ class SaleController extends Controller ]); } + public function show(Request $request, int $sale): SaleDetailResource + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return new SaleDetailResource($this->saleService->detail($tenant, $sale)); + } + + public function tickets(Request $request, int $sale): AnonymousResourceCollection + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return SaleTicketResource::collection( + $this->saleService->tickets($tenant, $sale) + ); + } + + public function confirm(Request $request, int $sale): SaleResource + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return new SaleResource($this->saleService->confirm($tenant, $sale)); + } + + public function cancel(Request $request, int $sale): SaleResource + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return new SaleResource($this->saleService->cancel($tenant, $sale)); + } + public function modifications(Request $request): AnonymousResourceCollection { return SaleModificationResource::collection( diff --git a/app/Domains/Sale/Resources/AdminApp/SaleDetailResource.php b/app/Domains/Sale/Resources/AdminApp/SaleDetailResource.php new file mode 100644 index 0000000..d389d1a --- /dev/null +++ b/app/Domains/Sale/Resources/AdminApp/SaleDetailResource.php @@ -0,0 +1,50 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'items' => $this->items->map(fn (PurchaseItem $item): array => [ + 'id' => $item->id, + 'product' => $item->item_nombre, + 'event_dates' => $this->eventDates($item), + 'quantity' => (int) $item->cantidad, + 'unit_price' => $this->formatMoney($item->precio_unitario), + 'total' => $this->formatMoney($item->total), + ])->values(), + 'total' => $this->formatMoney($this->total), + ]; + } + + /** @return list */ + private function eventDates(PurchaseItem $item): array + { + return collect($item->variant_attributes ?? []) + ->filter(fn (mixed $attribute): bool => is_array($attribute) + && mb_strtolower(trim((string) ($attribute['name'] ?? ''))) === 'fecha') + ->flatMap(function (array $attribute): array { + $value = $attribute['value'] ?? []; + + return is_array($value) ? $value : [$value]; + }) + ->filter(fn (mixed $date): bool => is_string($date) && $date !== '') + ->values() + ->all(); + } + + private function formatMoney(float|int|string|null $amount): string + { + return number_format((float) ($amount ?? 0), 2, '.', ''); + } +} diff --git a/app/Domains/Sale/Resources/AdminApp/SaleTicketResource.php b/app/Domains/Sale/Resources/AdminApp/SaleTicketResource.php new file mode 100644 index 0000000..5070b1a --- /dev/null +++ b/app/Domains/Sale/Resources/AdminApp/SaleTicketResource.php @@ -0,0 +1,22 @@ + */ + public function toArray(Request $request): array + { + return [ + 'product' => $this->name, + 'id' => $this->id, + 'expires_at' => $this->getEffectiveExpiresAt(), + 'status' => $this->status, + ]; + } +} diff --git a/app/Domains/Sale/Services/AdminAppSaleService.php b/app/Domains/Sale/Services/AdminAppSaleService.php index fda53e6..d52d83a 100644 --- a/app/Domains/Sale/Services/AdminAppSaleService.php +++ b/app/Domains/Sale/Services/AdminAppSaleService.php @@ -4,13 +4,19 @@ namespace App\Domains\Sale\Services; use App\Domains\Logging\Models\ValueChange; use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Models\Ticket; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; class AdminAppSaleService { + public function __construct( + protected CheckoutService $checkoutService, + ) {} + public function confirmedSalesTotal(Tenant $tenant): string { $total = Purchase::query() @@ -39,6 +45,42 @@ class AdminAppSaleService ->withQueryString(); } + public function detail(Tenant $tenant, int $saleId): Purchase + { + return Purchase::query() + ->where('tenant_codigo', $tenant->codigo) + ->with('items') + ->findOrFail($saleId); + } + + /** @return Collection */ + public function tickets(Tenant $tenant, int $saleId): Collection + { + return $this->findForTenant($tenant, $saleId) + ->tickets() + ->with('validityGroups.validityTimes') + ->orderBy('id') + ->get(); + } + + public function confirm(Tenant $tenant, int $saleId): Purchase + { + $sale = $this->findForTenant($tenant, $saleId); + + return $this->saleForResponse( + $this->checkoutService->confirmPaidPurchase($sale) + ); + } + + public function cancel(Tenant $tenant, int $saleId): Purchase + { + $sale = $this->findForTenant($tenant, $saleId); + + return $this->saleForResponse( + $this->checkoutService->cancelPurchaseWithoutRestoringCart($sale) + ); + } + /** * @param array $filters * @return Collection @@ -117,4 +159,18 @@ class AdminAppSaleService ->orderByDesc('changed_at') ->orderByDesc('id'); } + + protected function findForTenant(Tenant $tenant, int $saleId): Purchase + { + return Purchase::query() + ->where('tenant_codigo', $tenant->codigo) + ->findOrFail($saleId); + } + + protected function saleForResponse(Purchase $sale): Purchase + { + return $sale->refresh() + ->loadSum('items as quantity', 'cantidad') + ->loadCount('tickets'); + } } diff --git a/app/Domains/Sale/documentacion/README.md b/app/Domains/Sale/documentacion/README.md new file mode 100644 index 0000000..3f6c49b --- /dev/null +++ b/app/Domains/Sale/documentacion/README.md @@ -0,0 +1,28 @@ +# Dominio Sale + +## Propósito + +Provee consultas administrativas y exportaciones de ventas confirmadas, además del historial de modificaciones auditadas. + +## Componentes + +- `AdminAppSaleService`: pagina ventas, calcula totales y obtiene colecciones para exportación; también consulta modificaciones. +- `AdminAppSalePdfService`: genera descargas PDF de ventas y de cambios. +- `AdminAppSaleIndexRequest`: valida filtros del listado y la exportación. +- `SaleResource` y `SaleModificationResource`: representan ventas e historial para AdminApp. +- `SaleController`: entrada HTTP del panel. + +## Endpoints + +Bajo `/v1/adminapp/tenant`, protegidos por `auth:sanctum` y `adminapp.tenant`: + +- `GET /sales` y `GET /sales/pdf`. +- `GET /sales/modifications` y `GET /sales/modifications/pdf`. + +## Dependencias + +Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es dueño del estado de una compra; cualquier mutación debe ejecutarse en `Purchase`. + +## Consideraciones + +La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla y PDF. diff --git a/app/Domains/Sale/routes/adminapp.php b/app/Domains/Sale/routes/adminapp.php index 3eea7bd..b803f1e 100644 --- a/app/Domains/Sale/routes/adminapp.php +++ b/app/Domains/Sale/routes/adminapp.php @@ -10,4 +10,8 @@ Route::prefix('v1/adminapp/tenant') Route::get('sales/pdf', [SaleController::class, 'downloadPdf']); Route::get('sales/modifications', [SaleController::class, 'modifications']); Route::get('sales/modifications/pdf', [SaleController::class, 'downloadModificationsPdf']); + Route::post('sales/{sale}/confirm', [SaleController::class, 'confirm'])->whereNumber('sale'); + Route::post('sales/{sale}/cancel', [SaleController::class, 'cancel'])->whereNumber('sale'); + Route::get('sales/{sale}/tickets', [SaleController::class, 'tickets'])->whereNumber('sale'); + Route::get('sales/{sale}', [SaleController::class, 'show'])->whereNumber('sale'); }); diff --git a/app/Domains/Shared/Enums/FieldType.php b/app/Domains/Shared/Enums/FieldType.php index abf480e..eb11b2f 100644 --- a/app/Domains/Shared/Enums/FieldType.php +++ b/app/Domains/Shared/Enums/FieldType.php @@ -11,10 +11,16 @@ enum FieldType: string case Multiselect = 'multiselect'; case Color = 'color'; case Image = 'image'; + case EventDate = 'event_date'; public function supportsOptions(): bool { - return in_array($this, [self::Select, self::Multiselect], true); + return in_array($this, [self::Select, self::Multiselect, self::EventDate], true); + } + + public function usesDynamicOptions(): bool + { + return $this === self::EventDate; } /** diff --git a/app/Domains/Shared/documentacion/README.md b/app/Domains/Shared/documentacion/README.md new file mode 100644 index 0000000..546bea3 --- /dev/null +++ b/app/Domains/Shared/documentacion/README.md @@ -0,0 +1,18 @@ +# Dominio Shared + +## Propósito + +Contiene contratos técnicos reutilizables que no pertenecen a un único dominio funcional. + +## Componentes + +- `Enums/FieldType.php`: tipos de campos dinámicos y helpers para determinar si admiten opciones estáticas o dinámicas. +- `Rules/ImageOrBase64Rule.php`: regla de validación para aceptar una imagen subida o codificada en Base64. + +## API + +No posee modelos persistentes, controladores ni rutas. Sus elementos se importan desde requests y servicios de otros dominios. + +## Criterio de pertenencia + +Solo deben incorporarse aquí conceptos verdaderamente transversales. Una regla o enum con significado de negocio específico debe permanecer en su dominio propietario. diff --git a/app/Domains/Staff/documentacion/README.md b/app/Domains/Staff/documentacion/README.md new file mode 100644 index 0000000..f72d4a6 --- /dev/null +++ b/app/Domains/Staff/documentacion/README.md @@ -0,0 +1,20 @@ +# Dominio Staff + +## Propósito + +Administra usuarios de personal de un tenant y las categorías que tienen habilitadas para operar o escanear. + +## Componentes + +- `AdminAppStaffController`: listado, alta, modificación y baja. +- `StaffService`: aplica el alcance por tenant, busca personal y sincroniza sus datos/asignaciones. +- `StoreStaffRequest` y `UpdateStaffRequest`: validan cada operación. +- `StaffResource`: representación de salida para AdminApp. + +## Endpoints + +Recurso REST `/v1/adminapp/tenant/staff`, excepto detalle individual, protegido por `auth:sanctum` y `adminapp.tenant`. + +## Dependencias y reglas + +Usa `Auth/User` como entidad de personal, `Authorization` para su rol, `Catalog/Category` para asignaciones y `Tenant` para aislamiento. Toda búsqueda, edición o borrado debe comprobar que el usuario pertenece al tenant autenticado. diff --git a/app/Domains/StorageTest/documentacion/README.md b/app/Domains/StorageTest/documentacion/README.md new file mode 100644 index 0000000..09d7138 --- /dev/null +++ b/app/Domains/StorageTest/documentacion/README.md @@ -0,0 +1,23 @@ +# Dominio StorageTest + +## Propósito + +Expone operaciones técnicas para comprobar la escritura en S3 y la generación de URL temporales. + +## Componentes + +- `S3TestController`: recibe solicitudes de carga y URL temporal. +- `S3TestService`: almacena un archivo de prueba y genera el enlace firmado. +- `StoreS3TestFileRequest`: valida la carga. +- `GenerateS3TemporaryUrlRequest`: valida ruta y tiempo de expiración. + +## Endpoints + +Bajo `/storage-test/s3`: + +- `POST /upload`. +- `GET /temporary-url`. + +## Consideraciones + +Es infraestructura de diagnóstico, no una API funcional de archivos. Debe restringirse por entorno o autorización. Para adjuntos de negocio se debe usar el dominio `Attachable`. diff --git a/app/Domains/Tenant/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php index cd0c4d8..0013a9c 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -7,7 +7,7 @@ use App\Domains\Catalog\Enums\GroupLayout; use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; -use App\Domains\Event\Models\Event; +use App\Domains\Event\Models\EventDate; use App\Domains\Menu\Models\Menu; use App\Domains\Menu\Models\TenantMenu; use Illuminate\Database\Eloquent\Attributes\Fillable; @@ -33,7 +33,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'search_product_layout', 'search_group_layout', 'search_items_per_page', - 'active_event_id', + 'display_categories', + 'display_seach_bar', + 'event_title', + 'event_location', + 'event_date_text', ])] class Tenant extends Model { @@ -43,6 +47,8 @@ class Tenant extends Model 'search_product_layout' => ProductLayout::ColumnWithImage->value, 'search_group_layout' => GroupLayout::Paginated->value, 'search_items_per_page' => 12, + 'display_categories' => true, + 'display_seach_bar' => true, ]; public function getRouteKeyName(): string @@ -61,7 +67,8 @@ class Tenant extends Model 'search_product_layout' => ProductLayout::class, 'search_group_layout' => GroupLayout::class, 'search_items_per_page' => 'integer', - 'active_event_id' => 'integer', + 'display_categories' => 'boolean', + 'display_seach_bar' => 'boolean', ]; } @@ -94,16 +101,12 @@ class Tenant extends Model return $this->hasMany(CatalogItem::class, 'tenant_code', 'codigo'); } - /** @return HasMany */ - public function events(): HasMany + /** @return HasMany */ + public function eventDates(): HasMany { - return $this->hasMany(Event::class, 'tenant_code', 'codigo'); - } - - /** @return BelongsTo */ - public function activeEvent(): BelongsTo - { - return $this->belongsTo(Event::class, 'active_event_id'); + return $this->hasMany(EventDate::class, 'tenant_code', 'codigo') + ->orderBy('date') + ->orderBy('time_start'); } /** diff --git a/app/Domains/Tenant/Models/WebsiteExtra.php b/app/Domains/Tenant/Models/WebsiteExtra.php index 3656fba..2698dc2 100644 --- a/app/Domains/Tenant/Models/WebsiteExtra.php +++ b/app/Domains/Tenant/Models/WebsiteExtra.php @@ -19,6 +19,10 @@ class WebsiteExtra extends Model protected $table = 'websites_extras'; + protected $attributes = [ + 'is_enabled' => true, + ]; + private mixed $resolvedConfig = null; private bool $hasResolvedConfig = false; diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php index 7485980..88d398d 100644 --- a/app/Domains/Tenant/Requests/StoreTenantRequest.php +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -75,6 +75,8 @@ class StoreTenantRequest extends FormRequest 'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)], 'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)], 'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'], + 'display_categories' => ['sometimes', 'boolean'], + 'display_seach_bar' => ['sometimes', 'boolean'], 'website_type_code' => [ 'required_with:extras', 'sometimes', diff --git a/app/Domains/Tenant/Requests/UpdateTenantRequest.php b/app/Domains/Tenant/Requests/UpdateTenantRequest.php index c860586..dec65d9 100644 --- a/app/Domains/Tenant/Requests/UpdateTenantRequest.php +++ b/app/Domains/Tenant/Requests/UpdateTenantRequest.php @@ -85,6 +85,8 @@ class UpdateTenantRequest extends FormRequest 'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)], 'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)], 'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'], + 'display_categories' => ['sometimes', 'boolean'], + 'display_seach_bar' => ['sometimes', 'boolean'], ]; } } diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index 354b398..e681a2d 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -9,6 +9,7 @@ use App\Domains\Tenant\Models\Tenant; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Collection; +use Illuminate\Support\Str; /** * @mixin Tenant @@ -32,14 +33,13 @@ class TenantResource extends JsonResource 'header_bg_color' => $this->header_bg_color, 'footer_bg_color' => $this->footer_bg_color, 'website_type_code' => $this->website_type_code, - 'active_event_id' => $this->active_event_id, - 'active_event' => $this->whenLoaded('activeEvent', fn () => $this->activeEvent === null + 'event_date_text' => $this->event_date_text, + 'event' => $this->whenLoaded('eventDates', fn () => $this->event_title === null ? null : [ - 'id' => $this->activeEvent->id, - 'name' => $this->activeEvent->name, - 'address' => $this->activeEvent->address, - 'dates' => $this->activeEvent->dates->map(fn ($eventDate): array => [ + 'title' => $this->event_title, + 'location' => $this->event_location, + 'dates' => $this->eventDates->map(fn ($eventDate): array => [ 'id' => $eventDate->id, 'date' => $eventDate->date->format('Y-m-d'), 'time_start' => $eventDate->time_start, @@ -62,6 +62,8 @@ class TenantResource extends JsonResource 'search_product_layout' => $this->search_product_layout->value, 'search_group_layout' => $this->search_group_layout->value, 'search_items_per_page' => $this->search_items_per_page, + 'display_categories' => $this->display_categories, + 'display_seach_bar' => $this->display_seach_bar, 'social_media' => $this->whenLoaded( 'socialMedia', fn () => $this->socialMedia @@ -158,6 +160,10 @@ class TenantResource extends JsonResource $formatted['submenues'] = $childrenByParent ->get($menu->code, collect()) + ->sortBy( + fn (Menu $submenu) => Str::lower(Str::ascii($submenu->label)), + SORT_NATURAL + ) ->map($formatMenu) ->values(); @@ -167,6 +173,10 @@ class TenantResource extends JsonResource return $menus ->filter(fn (Menu $menu) => $menu->parent_menu_code === null || ! $menuCodes->has($menu->parent_menu_code)) + ->sortBy( + fn (Menu $menu) => Str::lower(Str::ascii($menu->label)), + SORT_NATURAL + ) ->map($formatMenu) ->values(); } diff --git a/app/Domains/Tenant/Services/TenantInformationService.php b/app/Domains/Tenant/Services/TenantInformationService.php index 0f47a1d..32d7844 100644 --- a/app/Domains/Tenant/Services/TenantInformationService.php +++ b/app/Domains/Tenant/Services/TenantInformationService.php @@ -15,7 +15,7 @@ class TenantInformationService 'footerLogo', 'socialMedia', 'websiteExtras.websiteTypeExtra', - 'activeEvent.dates', + 'eventDates', ]; /** diff --git a/app/Domains/Tenant/documentacion/README.md b/app/Domains/Tenant/documentacion/README.md new file mode 100644 index 0000000..aca3c97 --- /dev/null +++ b/app/Domains/Tenant/documentacion/README.md @@ -0,0 +1,30 @@ +# Dominio Tenant + +## Propósito + +Es la raíz del modelo multi-tenant. Gestiona organizaciones/sitios, tipos de web, redes sociales, logos y extras configurables por sitio. + +## Modelo + +- `Tenant`: entidad principal, resuelta en rutas por `codigo`; relaciona catálogo, fechas, redes, menús y configuración visual. +- `WebsiteType`: plantilla o tipo de sitio disponible. +- `WebsiteTypeExtra`: definición de un extra y su configuración admitida. +- `WebsiteExtra`: valor resuelto y estado del extra para un tenant. +- `SocialMedia`: catálogo de redes sociales asociables. + +## Servicios + +- `TenantService`: crea y actualiza tenants, incluyendo sus recursos asociados. +- `TenantInformationService`: carga un tenant y las relaciones requeridas por cada contexto. +- `WebsiteTypeService`: crea o actualiza tipos de sitio. +- `WebsiteExtraService`: construye reglas dinámicas, crea, actualiza y habilita/deshabilita extras. +- `TenantDomainNormalizer`: normaliza dominios antes de resolver el tenant. + +## Endpoints + +- Recurso REST público/administrativo `/tenants`. +- Bajo `/v1/adminapp/tenant/website-extras`, con autenticación y contexto de tenant: consulta general, detalle, actualización y activación/desactivación. + +## Dependencias y reglas + +Usa `Attachable` para logos y archivos. Es referenciado por casi todos los dominios para aislamiento. El `codigo` es clave de ruta y clave foránea heredada; no debe sustituirse por `id` sin una migración integral. diff --git a/app/Domains/Ticket/Controllers/TicketController.php b/app/Domains/Ticket/Controllers/TicketController.php index 7c8fc2a..85b7456 100644 --- a/app/Domains/Ticket/Controllers/TicketController.php +++ b/app/Domains/Ticket/Controllers/TicketController.php @@ -22,7 +22,7 @@ class TicketController extends Controller $tickets = Ticket::query() ->where('tenant_code', $tenant->codigo) ->where('user_id', $request->user()->getKey()) - ->with('sourceVariant.eventDate', 'sourceVariant.catalogItem') + ->with('validityGroups.validityTimes', 'sourceVariant.eventDate', 'sourceVariant.catalogItem') ->orderByDesc('id') ->get(); @@ -36,7 +36,7 @@ class TicketController extends Controller ->where('tenant_code', $tenant->codigo) ->where('user_id', $request->user()->getKey()) ->whereIn('id', $ticketIds) - ->with('sourceVariant.eventDate', 'sourceVariant.catalogItem') + ->with('validityGroups.validityTimes', 'sourceVariant.eventDate', 'sourceVariant.catalogItem') ->orderByDesc('id') ->get(); diff --git a/app/Domains/Ticket/Enums/TicketGenerationPolicy.php b/app/Domains/Ticket/Enums/TicketGenerationPolicy.php new file mode 100644 index 0000000..c26a177 --- /dev/null +++ b/app/Domains/Ticket/Enums/TicketGenerationPolicy.php @@ -0,0 +1,15 @@ + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Domains/Catalog/Enums/EventProductType.php b/app/Domains/Ticket/Enums/ValidityTimeType.php similarity index 51% rename from app/Domains/Catalog/Enums/EventProductType.php rename to app/Domains/Ticket/Enums/ValidityTimeType.php index 56157ad..b17089c 100644 --- a/app/Domains/Catalog/Enums/EventProductType.php +++ b/app/Domains/Ticket/Enums/ValidityTimeType.php @@ -1,11 +1,11 @@ */ public static function values(): array diff --git a/app/Domains/Ticket/Exceptions/TicketGenerationException.php b/app/Domains/Ticket/Exceptions/TicketGenerationException.php index e9fa303..bc543cc 100644 --- a/app/Domains/Ticket/Exceptions/TicketGenerationException.php +++ b/app/Domains/Ticket/Exceptions/TicketGenerationException.php @@ -24,9 +24,9 @@ class TicketGenerationException extends RuntimeException return new self(__('api.ticket.disabled', ['product' => $catalogItem->id])); } - public static function maximumUseDateReached(CatalogItem $catalogItem): self + public static function ambiguousValidityTime(CatalogItem $catalogItem): self { - return new self(__('api.ticket.expired', ['product' => $catalogItem->id])); + return new self("Catalog item {$catalogItem->id} resolves more than one validity time."); } public static function variantNotFound( diff --git a/app/Domains/Ticket/Models/Ticket.php b/app/Domains/Ticket/Models/Ticket.php index 7d937bc..7c278cd 100644 --- a/app/Domains/Ticket/Models/Ticket.php +++ b/app/Domains/Ticket/Models/Ticket.php @@ -9,9 +9,12 @@ use App\Domains\Purchase\Models\Purchase; use App\Domains\Tenant\Models\Tenant; use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Attributes\Fillable; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; 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\Collection; #[Fillable([ 'tenant_code', @@ -21,8 +24,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; 'source_purchase_id', 'source_catalog_item_id', 'source_variant_id', - 'starts_at', - 'expires_at', 'used_at', 'scanner_user_id', 'user_id', @@ -31,12 +32,19 @@ class Ticket extends Model { use HasFactory; + public const STATUS_ACTIVE = 'active'; + + public const STATUS_EXPIRED = 'expired'; + + public const STATUS_USED = 'used'; + public $timestamps = false; protected $appends = [ 'is_valid', 'is_expired', 'is_used', + 'status', ]; protected function casts(): array @@ -45,8 +53,6 @@ class Ticket extends Model 'source_catalog_item_id' => 'integer', 'source_variant_id' => 'integer', 'source_purchase_id' => 'integer', - 'starts_at' => 'datetime', - 'expires_at' => 'datetime', 'used_at' => 'datetime', 'scanner_user_id' => 'integer', 'user_id' => 'integer', @@ -89,15 +95,24 @@ class Ticket extends Model return $this->belongsTo(Variant::class, 'source_variant_id'); } + /** @return HasMany */ + public function validityGroups(): HasMany + { + return $this->hasMany(TicketValidityGroup::class); + } + public function isValid(): bool { - $now = now(); - $startsAt = $this->getEffectiveStartsAt(); - $expiresAt = $this->getEffectiveExpiresAt(); + if ($this->used_at !== null) { + return false; + } - return $this->used_at === null - && ($startsAt === null || $startsAt->lessThanOrEqualTo($now)) - && ($expiresAt === null || $expiresAt->greaterThan($now)); + $validityGroups = $this->resolvedValidityGroups(); + + return $validityGroups->isEmpty() + || $validityGroups->contains( + fn (TicketValidityGroup $group): bool => $group->isValid() + ); } public function getIsValidAttribute(): bool @@ -107,11 +122,13 @@ class Ticket extends Model public function getIsExpiredAttribute(): bool { - $expiresAt = $this->getEffectiveExpiresAt(); + $validityGroups = $this->resolvedValidityGroups(); return $this->used_at === null - && $expiresAt !== null - && $expiresAt->lessThanOrEqualTo(now()); + && $validityGroups->isNotEmpty() + && $validityGroups->every( + fn (TicketValidityGroup $group): bool => $group->isExpired() + ); } public function getIsUsedAttribute(): bool @@ -119,15 +136,65 @@ class Ticket extends Model return $this->used_at !== null; } + public function getStatusAttribute(): string + { + if ($this->is_used) { + return self::STATUS_USED; + } + + if ($this->is_expired) { + return self::STATUS_EXPIRED; + } + + return self::STATUS_ACTIVE; + } + public function getEffectiveStartsAt(): ?CarbonInterface { - return $this->sourceVariant?->getMinimumUseDate() - ?? $this->starts_at; + return $this->resolvedValidityGroups() + ->map(fn (TicketValidityGroup $group): ?CarbonInterface => $group->effectiveStartsAt()) + ->filter() + ->sortBy(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp()) + ->first(); } public function getEffectiveExpiresAt(): ?CarbonInterface { - return $this->sourceVariant?->getMaximumUseDate() - ?? $this->expires_at; + return $this->resolvedValidityGroups() + ->map(fn (TicketValidityGroup $group): ?CarbonInterface => $group->effectiveExpiresAt()) + ->filter() + ->sortByDesc(fn (CarbonInterface $expiresAt): int => $expiresAt->getTimestamp()) + ->first(); + } + + /** @return Collection */ + public function allValidityTimes(): Collection + { + return $this->resolvedValidityGroups() + ->flatMap(fn (TicketValidityGroup $group): EloquentCollection => $group->resolvedValidityTimes()) + ->unique( + fn (ValidityTime $validityTime): int => $validityTime->getKey() + ?? spl_object_id($validityTime) + ) + ->values(); + } + + /** @return EloquentCollection */ + public function resolvedValidityGroups(): EloquentCollection + { + if ($this->relationLoaded('validityGroups')) { + return $this->getRelation('validityGroups'); + } + + if (! $this->exists) { + return new EloquentCollection; + } + + $validityGroups = $this->validityGroups() + ->with('validityTimes') + ->get(); + $this->setRelation('validityGroups', $validityGroups); + + return $validityGroups; } } diff --git a/app/Domains/Ticket/Models/TicketValidityGroup.php b/app/Domains/Ticket/Models/TicketValidityGroup.php new file mode 100644 index 0000000..8e14d4f --- /dev/null +++ b/app/Domains/Ticket/Models/TicketValidityGroup.php @@ -0,0 +1,117 @@ + */ + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } + + /** @return BelongsToMany */ + public function validityTimes(): BelongsToMany + { + return $this->belongsToMany( + ValidityTime::class, + 'ticket_validity_group_times', + 'ticket_validity_group_id', + 'validity_time_id', + ); + } + + public function isValid(?CarbonInterface $at = null): bool + { + $at ??= now(); + $validityTimes = $this->resolvedValidityTimes(); + + return $validityTimes->isNotEmpty() + && $validityTimes->every( + fn (ValidityTime $validityTime): bool => $validityTime->isValid($at) + ); + } + + public function isExpired(?CarbonInterface $at = null): bool + { + $at ??= now(); + $expiresAt = $this->effectiveExpiresAt($at); + + return $expiresAt !== null && $expiresAt->lessThanOrEqualTo($at); + } + + public function effectiveStartsAt(?CarbonInterface $at = null): ?CarbonInterface + { + $at ??= now(); + $anchor = $this->dateAnchor() ?? $at; + + return $this->resolvedValidityTimes() + ->map(fn (ValidityTime $validityTime): ?CarbonInterface => $validityTime->startsAt($anchor)) + ->filter() + ->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp()) + ->first(); + } + + public function effectiveExpiresAt(?CarbonInterface $at = null): ?CarbonInterface + { + $at ??= now(); + $anchor = $this->dateAnchor() ?? $at; + + return $this->resolvedValidityTimes() + ->map(function (ValidityTime $validityTime) use ($anchor): ?CarbonInterface { + $startsAt = $validityTime->startsAt($anchor); + $expiresAt = $validityTime->expiresAt($anchor); + + if ( + $startsAt !== null + && $expiresAt !== null + && $expiresAt->lessThanOrEqualTo($startsAt) + ) { + return $expiresAt->addDay(); + } + + return $expiresAt; + }) + ->filter() + ->sortBy(fn (CarbonInterface $expiresAt): int => $expiresAt->getTimestamp()) + ->first(); + } + + /** @return EloquentCollection */ + public function resolvedValidityTimes(): EloquentCollection + { + if ($this->relationLoaded('validityTimes')) { + return $this->getRelation('validityTimes'); + } + + if (! $this->exists) { + return new EloquentCollection; + } + + $validityTimes = $this->validityTimes()->get(); + $this->setRelation('validityTimes', $validityTimes); + + return $validityTimes; + } + + private function dateAnchor(): ?CarbonInterface + { + return $this->resolvedValidityTimes() + ->filter(fn (ValidityTime $validityTime): bool => $validityTime->type === ValidityTimeType::FixedWindow) + ->map(fn (ValidityTime $validityTime): ?CarbonInterface => $validityTime->fixed_starts_at) + ->filter() + ->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp()) + ->first(); + } +} diff --git a/app/Domains/Ticket/Models/ValidityTime.php b/app/Domains/Ticket/Models/ValidityTime.php new file mode 100644 index 0000000..5210091 --- /dev/null +++ b/app/Domains/Ticket/Models/ValidityTime.php @@ -0,0 +1,112 @@ + ValidityTimeType::class, + 'fixed_starts_at' => 'datetime', + 'fixed_expires_at' => 'datetime', + ]; + } + + /** @return HasMany */ + public function catalogItems(): HasMany + { + return $this->hasMany(CatalogItem::class); + } + + /** @return HasMany */ + public function attributeOptions(): HasMany + { + return $this->hasMany(AttributeOption::class); + } + + /** @return HasOne */ + public function eventDate(): HasOne + { + return $this->hasOne(EventDate::class); + } + + /** @return BelongsToMany */ + public function ticketValidityGroups(): BelongsToMany + { + return $this->belongsToMany( + TicketValidityGroup::class, + 'ticket_validity_group_times', + 'validity_time_id', + 'ticket_validity_group_id', + ); + } + + public function startsAt( + ?CarbonInterface $at = null, + ): ?CarbonInterface { + if ($this->type === ValidityTimeType::FixedWindow) { + return $this->fixed_starts_at; + } + + return $this->atCurrentDate($this->start_time, $at); + } + + public function expiresAt( + ?CarbonInterface $at = null, + ): ?CarbonInterface { + if ($this->type === ValidityTimeType::FixedWindow) { + return $this->fixed_expires_at; + } + + return $this->atCurrentDate($this->end_time, $at); + } + + public function isValid( + ?CarbonInterface $at = null, + ): bool { + $at ??= now(); + $startsAt = $this->startsAt($at); + $expiresAt = $this->expiresAt($at); + + return ($startsAt === null || $startsAt->lessThanOrEqualTo($at)) + && ($expiresAt === null || $expiresAt->greaterThan($at)); + } + + private function atCurrentDate( + ?string $time, + ?CarbonInterface $at, + ): ?CarbonInterface { + if ($time === null) { + return null; + } + + $at ??= now(); + $localDate = CarbonImmutable::instance($at) + ->format('Y-m-d'); + + return CarbonImmutable::parse($localDate.' '.$time); + } +} diff --git a/app/Domains/Ticket/Resources/TicketResource.php b/app/Domains/Ticket/Resources/TicketResource.php index a8d7714..8d6c074 100644 --- a/app/Domains/Ticket/Resources/TicketResource.php +++ b/app/Domains/Ticket/Resources/TicketResource.php @@ -20,6 +20,10 @@ class TicketResource extends JsonResource 'description' => $this->description, 'source_catalog_item_id' => $this->source_catalog_item_id, 'source_variant_id' => $this->source_variant_id, + 'validity_times' => ValidityTimeResource::collection($this->allValidityTimes()), + 'validity_groups' => TicketValidityGroupResource::collection( + $this->resolvedValidityGroups() + ), 'starts_at' => $this->getEffectiveStartsAt(), 'expires_at' => $this->getEffectiveExpiresAt(), 'used_at' => $this->used_at, diff --git a/app/Domains/Ticket/Resources/TicketValidityGroupResource.php b/app/Domains/Ticket/Resources/TicketValidityGroupResource.php new file mode 100644 index 0000000..c56383c --- /dev/null +++ b/app/Domains/Ticket/Resources/TicketValidityGroupResource.php @@ -0,0 +1,24 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'validity_times' => ValidityTimeResource::collection($this->resolvedValidityTimes()), + 'starts_at' => $this->effectiveStartsAt(), + 'expires_at' => $this->effectiveExpiresAt(), + 'is_valid' => $this->isValid(), + 'is_expired' => $this->isExpired(), + ]; + } +} diff --git a/app/Domains/Ticket/Resources/ValidityTimeResource.php b/app/Domains/Ticket/Resources/ValidityTimeResource.php new file mode 100644 index 0000000..912182c --- /dev/null +++ b/app/Domains/Ticket/Resources/ValidityTimeResource.php @@ -0,0 +1,34 @@ + */ + public function toArray(Request $request): array + { + $fields = match ($this->type) { + ValidityTimeType::TimeWindow => [ + 'start_time' => $this->start_time, + 'end_time' => $this->end_time, + ], + ValidityTimeType::FixedWindow => [ + 'fixed_starts_at' => $this->fixed_starts_at, + 'fixed_expires_at' => $this->fixed_expires_at, + ], + }; + + return [ + 'id' => $this->id, + 'type' => $this->type->value, + 'is_valid' => $this->isValid(), + ...array_filter($fields, fn (mixed $value): bool => $value !== null), + ]; + } +} diff --git a/app/Domains/Ticket/Services/TicketGeneratorService.php b/app/Domains/Ticket/Services/TicketGeneratorService.php index 652c89d..a4bc4ca 100644 --- a/app/Domains/Ticket/Services/TicketGeneratorService.php +++ b/app/Domains/Ticket/Services/TicketGeneratorService.php @@ -5,8 +5,13 @@ namespace App\Domains\Ticket\Services; use App\Domains\Auth\Models\User; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; +use App\Domains\Event\Models\EventDate; +use App\Domains\Ticket\Enums\TicketGenerationPolicy; use App\Domains\Ticket\Exceptions\TicketGenerationException; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Models\TicketValidityGroup; +use App\Domains\Ticket\Models\ValidityTime; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -39,27 +44,51 @@ class TicketGeneratorService $user, ): Ticket { $item = $target['catalog_item']; - $selectedItem = $target['variant'] ?? $item; + $variant = $target['variant']; + $eventDate = $target['event_date']; + $validityGroups = $this->buildTicketValidityGroups( + $item, + $variant, + $eventDate, + $this->resolveValidityTime($item, $variant), + ); - return Ticket::query()->create([ + $ticket = Ticket::query()->create([ 'tenant_code' => $item->tenant_code, 'ticket' => (string) Str::uuid(), - 'name' => $item->nombre, + 'name' => $this->ticketName($item, $variant, $eventDate), 'description' => (string) ($item->descripcion ?? ''), 'source_purchase_id' => $sourcePurchaseId, 'source_catalog_item_id' => $item->getKey(), - 'source_variant_id' => $target['variant']?->getKey(), - 'starts_at' => $selectedItem->getMinimumUseDate(), - 'expires_at' => $selectedItem->getMaximumUseDate(), + 'source_variant_id' => $variant?->getKey(), 'used_at' => null, 'user_id' => $user->getKey(), ]); + + $groups = $validityGroups->map(function (Collection $validityTimes) use ($ticket): TicketValidityGroup { + $group = $ticket->validityGroups()->create(); + $group->validityTimes()->attach( + $validityTimes + ->map(fn (ValidityTime $validityTime): int => $validityTime->getKey()) + ->all() + ); + $group->setRelation( + 'validityTimes', + new EloquentCollection($validityTimes->all()), + ); + + return $group; + }); + + $ticket->setRelation('validityGroups', new EloquentCollection($groups->all())); + + return $ticket; }); }); } /** - * @return Collection + * @return Collection */ private function resolveTargets( CatalogItem $catalogItem, @@ -70,10 +99,7 @@ class TicketGeneratorService $variant = $this->resolveVariant($catalogItem, $sourceVariantId); $this->validateTarget($catalogItem, $variant); - return Collection::times($quantity, fn (): array => [ - 'catalog_item' => $catalogItem, - 'variant' => $variant, - ]); + return $this->targetsForVariant($catalogItem, $variant, $quantity); } $catalogItem->loadMissing([ @@ -91,17 +117,59 @@ class TicketGeneratorService $variant = $component->variant; $this->validateTarget($componentItem, $variant); - return Collection::times( + return $this->targetsForVariant( + $componentItem, + $variant, $quantity * $component->quantity, - fn (): array => [ - 'catalog_item' => $componentItem, - 'variant' => $variant, - ], ); }) ->values(); } + /** + * @return Collection + */ + private function targetsForVariant( + CatalogItem $catalogItem, + ?Variant $variant, + int $quantity, + ): Collection { + if ($variant === null) { + return Collection::times($quantity, fn (): array => [ + 'catalog_item' => $catalogItem, + 'variant' => null, + 'event_date' => null, + ]); + } + + $variant->loadMissing(['eventDates.validityTime', 'eventDate.validityTime']); + $selectedEventDates = $variant->selectedEventDates(); + + if ($catalogItem->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit) { + $eventDate = $selectedEventDates->count() === 1 + ? $selectedEventDates->first() + : null; + + return Collection::times($quantity, fn (): array => [ + 'catalog_item' => $catalogItem, + 'variant' => $variant, + 'event_date' => $eventDate, + ]); + } + + $eventDates = $selectedEventDates->isEmpty() + ? collect([null]) + : $selectedEventDates; + + return Collection::times($quantity) + ->flatMap(fn () => $eventDates->map(fn (?EventDate $eventDate): array => [ + 'catalog_item' => $catalogItem, + 'variant' => $variant, + 'event_date' => $eventDate, + ])) + ->values(); + } + private function resolveVariant( CatalogItem $catalogItem, ?int $sourceVariantId, @@ -134,12 +202,111 @@ class TicketGeneratorService throw TicketGenerationException::ticketsDisabled($catalogItem); } - // TODO: Reactivar esta validación cuando los pagos con productos vencidos - // deban rechazarse nuevamente. Se deja deshabilitada temporalmente. - // $selectedItem = $variant ?? $catalogItem; - // - // if ($selectedItem->getMaximumUseDate()?->lessThanOrEqualTo(now())) { - // throw TicketGenerationException::maximumUseDateReached($catalogItem); - // } + } + + private function resolveValidityTime( + CatalogItem $catalogItem, + ?Variant $variant, + ): ?ValidityTime { + if ($catalogItem->validity_time_id !== null) { + return $catalogItem->validityTime; + } + + if ($variant === null) { + return null; + } + + $variant->loadMissing('definitions.itemAttribute.attribute.options.validityTime'); + + $validityTimes = $variant->definitions + ->map(function ($definition): ?ValidityTime { + $option = $definition->itemAttribute?->attribute?->options + ->firstWhere('value', $definition->value); + + return $option?->validityTime; + }) + ->filter() + ->unique(fn (ValidityTime $validityTime): int => $validityTime->getKey()) + ->values(); + + if ($validityTimes->count() > 1) { + throw TicketGenerationException::ambiguousValidityTime($catalogItem); + } + + return $validityTimes->first(); + } + + private function ticketName( + CatalogItem $catalogItem, + ?Variant $variant, + ?EventDate $eventDate, + ): string { + if ($variant === null) { + return $catalogItem->nombre; + } + + $options = $variant->selectionOptions(); + + if ($eventDate !== null) { + $options->put('event_date', [ + 'value' => (string) $eventDate->getKey(), + 'label' => $eventDate->date->format('d/m/Y'), + ]); + } + + $properties = $options + ->flatMap(function (array $option): array { + if (array_is_list($option)) { + return collect($option) + ->pluck('label') + ->filter(fn ($label): bool => is_string($label) && $label !== '') + ->all(); + } + + $label = $option['label'] ?? null; + + return is_string($label) && $label !== '' ? [$label] : []; + }) + ->values(); + + if ($properties->isEmpty()) { + return $catalogItem->nombre; + } + + return $catalogItem->nombre.' ('.$properties->implode(', ').')'; + } + + /** @return Collection> */ + private function buildTicketValidityGroups( + CatalogItem $catalogItem, + ?Variant $variant, + ?EventDate $eventDate, + ?ValidityTime $validityTime, + ): Collection { + $eventDates = collect([$eventDate]); + + if ( + $catalogItem->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit + && $variant !== null + ) { + $variant->loadMissing(['eventDates.validityTime', 'eventDate.validityTime']); + $selectedEventDates = $variant->selectedEventDates(); + + if ($selectedEventDates->isNotEmpty()) { + $eventDates = $selectedEventDates; + } + } + + return $eventDates + ->map(function (?EventDate $date) use ($validityTime): Collection { + $date?->loadMissing('validityTime'); + + return collect([$date?->validityTime, $validityTime]) + ->filter() + ->unique(fn (ValidityTime $time): int => $time->getKey()) + ->values(); + }) + ->filter(fn (Collection $group): bool => $group->isNotEmpty()) + ->values(); } } diff --git a/app/Domains/Ticket/documentacion/README.md b/app/Domains/Ticket/documentacion/README.md new file mode 100644 index 0000000..60b1e27 --- /dev/null +++ b/app/Domains/Ticket/documentacion/README.md @@ -0,0 +1,33 @@ +# Dominio Ticket + +## Propósito + +Genera, valida, consulta y exporta entradas asociadas a compras pagadas de productos o variantes ticketables. + +## Modelo + +- `Ticket`: pertenece a tenant y usuario, conserva referencias a compra, producto, variante, validez y usuario escáner. +- `ValidityTime`: define ventanas absolutas o relativas de vigencia para productos, opciones y tickets. +- `ValidityTimeType`: enum de estrategias de vigencia. + +El modelo calcula si un ticket está vigente, vencido o usado, y resuelve sus fechas efectivas de inicio y fin. + +## Flujo de generación + +1. `Purchase` emite `PurchasePaid` al confirmarse el pago. +2. `GenerateTicketsForPaidPurchase` atiende el evento. +3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia. +4. El flujo puede emitir disponibilidad para que `Notification` informe al comprador. + +## Endpoints + +Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`: + +- `GET /tickets`. +- `POST /tickets/pdf`. + +`TicketPdfService` genera la descarga y `TicketResource`/`ValidityTimeResource` definen las respuestas. + +## Dependencias y reglas + +Depende de `Purchase`, `Catalog`, `Tenant` y `Auth`. La generación debe ser idempotente ante reintentos del evento. `TicketNotAvailableException` y `TicketGenerationException` separan indisponibilidad de errores de generación. diff --git a/app/Http/Middleware/EnsureTenantHasMenu.php b/app/Http/Middleware/EnsureTenantHasMenu.php new file mode 100644 index 0000000..659b065 --- /dev/null +++ b/app/Http/Middleware/EnsureTenantHasMenu.php @@ -0,0 +1,22 @@ +user()?->tenant()->first(); + $hasMenu = $tenant?->menues() + ->where('menues.code', $menuCode) + ->exists() ?? false; + + abort_unless($hasMenu, 404); + + return $next($request); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 01e3e9d..1760f1d 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use App\Domains\Auth\Exceptions\AccountLockedException; use App\Domains\Ticket\Exceptions\TicketNotAvailableException; use App\Http\Middleware\EnsureAdminAppTenant; +use App\Http\Middleware\EnsureTenantHasMenu; use App\Http\Middleware\SetApiLocale; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\AuthenticationException; @@ -24,6 +25,7 @@ return Application::configure(basePath: dirname(__DIR__)) ->withMiddleware(function (Middleware $middleware): void { $middleware->alias([ 'adminapp.tenant' => EnsureAdminAppTenant::class, + 'tenant.menu' => EnsureTenantHasMenu::class, ]); $middleware->encryptCookies(except: [ 'guest_token', diff --git a/database/migrations/2026_08_03_000000_create_events_and_link_catalog.php b/database/migrations/2026_08_03_000000_create_events_and_link_catalog.php index e8f84c3..e9cdd8c 100644 --- a/database/migrations/2026_08_03_000000_create_events_and_link_catalog.php +++ b/database/migrations/2026_08_03_000000_create_events_and_link_catalog.php @@ -1,6 +1,5 @@ after('tenant_code') ->constrained('events') ->nullOnDelete(); - $table->enum('event_product_type', EventProductType::values()) - ->nullable() - ->after('event_id'); }); Schema::table('variantes', function (Blueprint $table): void { @@ -71,7 +67,6 @@ return new class extends Migration Schema::table('catalog_items', function (Blueprint $table): void { $table->dropConstrainedForeignId('event_id'); - $table->dropColumn('event_product_type'); }); Schema::dropIfExists('event_dates'); diff --git a/database/migrations/2026_08_06_000100_create_validity_times_table.php b/database/migrations/2026_08_06_000100_create_validity_times_table.php new file mode 100644 index 0000000..027ab97 --- /dev/null +++ b/database/migrations/2026_08_06_000100_create_validity_times_table.php @@ -0,0 +1,26 @@ +id(); + $table->string('type'); + $table->time('start_time')->nullable(); + $table->time('end_time')->nullable(); + $table->dateTime('fixed_starts_at')->nullable(); + $table->dateTime('fixed_expires_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('validity_times'); + } +}; diff --git a/database/migrations/2026_08_06_000200_add_validity_time_to_catalog_tables.php b/database/migrations/2026_08_06_000200_add_validity_time_to_catalog_tables.php new file mode 100644 index 0000000..36833c7 --- /dev/null +++ b/database/migrations/2026_08_06_000200_add_validity_time_to_catalog_tables.php @@ -0,0 +1,40 @@ +foreignId('validity_time_id') + ->nullable() + ->after('has_tickets') + ->constrained('validity_times') + ->cascadeOnUpdate() + ->nullOnDelete(); + }); + + Schema::table('attribute_options', function (Blueprint $table): void { + $table->foreignId('validity_time_id') + ->nullable() + ->after('attribute_id') + ->constrained('validity_times') + ->cascadeOnUpdate() + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('attribute_options', function (Blueprint $table): void { + $table->dropConstrainedForeignId('validity_time_id'); + }); + + Schema::table('catalog_items', function (Blueprint $table): void { + $table->dropConstrainedForeignId('validity_time_id'); + }); + } +}; diff --git a/database/migrations/2026_08_06_000300_add_validity_time_to_tickets_table.php b/database/migrations/2026_08_06_000300_add_validity_time_to_tickets_table.php new file mode 100644 index 0000000..e605319 --- /dev/null +++ b/database/migrations/2026_08_06_000300_add_validity_time_to_tickets_table.php @@ -0,0 +1,27 @@ +foreignId('validity_time_id') + ->nullable() + ->after('source_variant_id') + ->constrained('validity_times') + ->cascadeOnUpdate() + ->restrictOnDelete(); + }); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table): void { + $table->dropConstrainedForeignId('validity_time_id'); + }); + } +}; diff --git a/database/migrations/2026_08_06_000400_remove_legacy_ticket_validity_columns.php b/database/migrations/2026_08_06_000400_remove_legacy_ticket_validity_columns.php new file mode 100644 index 0000000..af98b19 --- /dev/null +++ b/database/migrations/2026_08_06_000400_remove_legacy_ticket_validity_columns.php @@ -0,0 +1,92 @@ +preserveFixedWindows( + 'catalog_items', + 'minimum_use_date', + 'maximum_use_date', + ); + $this->preserveFixedWindows('tickets', 'starts_at', 'expires_at'); + + Schema::table('tickets', function (Blueprint $table): void { + $table->dropColumn(['starts_at', 'expires_at']); + }); + + Schema::table('catalog_items', function (Blueprint $table): void { + $table->dropColumn(['minimum_use_date', 'maximum_use_date']); + }); + + Schema::table('variantes', function (Blueprint $table): void { + $table->dropColumn(['minimum_use_date', 'maximum_use_date']); + }); + } + + public function down(): void + { + Schema::table('variantes', function (Blueprint $table): void { + $table->dateTime('minimum_use_date')->nullable(); + $table->dateTime('maximum_use_date')->nullable(); + }); + + Schema::table('catalog_items', function (Blueprint $table): void { + $table->dateTime('minimum_use_date')->nullable(); + $table->dateTime('maximum_use_date')->nullable(); + }); + + Schema::table('tickets', function (Blueprint $table): void { + $table->dateTime('starts_at')->nullable(); + $table->dateTime('expires_at')->nullable(); + }); + } + + private function preserveFixedWindows( + string $table, + string $startsAtColumn, + string $expiresAtColumn, + ): void { + DB::table($table) + ->whereNull('validity_time_id') + ->where(function ($query) use ($startsAtColumn, $expiresAtColumn): void { + $query->whereNotNull($startsAtColumn) + ->orWhereNotNull($expiresAtColumn); + }) + ->select([$startsAtColumn, $expiresAtColumn]) + ->distinct() + ->get() + ->each(function ($window) use ($table, $startsAtColumn, $expiresAtColumn): void { + $startsAt = $window->{$startsAtColumn}; + $expiresAt = $window->{$expiresAtColumn}; + $validityTimeId = DB::table('validity_times')->insertGetId([ + 'type' => 'fixed_window', + 'start_time' => null, + 'end_time' => null, + 'fixed_starts_at' => $startsAt, + 'fixed_expires_at' => $expiresAt, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table($table) + ->whereNull('validity_time_id') + ->when( + $startsAt === null, + fn ($query) => $query->whereNull($startsAtColumn), + fn ($query) => $query->where($startsAtColumn, $startsAt), + ) + ->when( + $expiresAt === null, + fn ($query) => $query->whereNull($expiresAtColumn), + fn ($query) => $query->where($expiresAtColumn, $expiresAt), + ) + ->update(['validity_time_id' => $validityTimeId]); + }); + } +}; diff --git a/database/migrations/2026_08_06_000500_add_max_units_per_user_to_catalog_items.php b/database/migrations/2026_08_06_000500_add_max_units_per_user_to_catalog_items.php new file mode 100644 index 0000000..119964a --- /dev/null +++ b/database/migrations/2026_08_06_000500_add_max_units_per_user_to_catalog_items.php @@ -0,0 +1,22 @@ +unsignedInteger('max_units_per_user')->nullable(); + }); + } + + public function down(): void + { + Schema::table('catalog_items', function (Blueprint $table): void { + $table->dropColumn('max_units_per_user'); + }); + } +}; diff --git a/database/migrations/2026_08_07_000000_move_event_onto_tenant.php b/database/migrations/2026_08_07_000000_move_event_onto_tenant.php new file mode 100644 index 0000000..b8f439e --- /dev/null +++ b/database/migrations/2026_08_07_000000_move_event_onto_tenant.php @@ -0,0 +1,125 @@ +string('event_title')->nullable()->after('nombre'); + $table->string('event_location')->nullable()->after('event_title'); + }); + + Schema::table('event_dates', function (Blueprint $table): void { + $table->string('tenant_code')->nullable()->after('id'); + }); + + DB::table('tenants') + ->whereNotNull('active_event_id') + ->orderBy('id') + ->each(function (object $tenant): void { + $event = DB::table('events')->where('id', $tenant->active_event_id)->first(); + + if ($event === null) { + return; + } + + DB::table('tenants')->where('id', $tenant->id)->update([ + 'event_title' => $event->name, + 'event_location' => $event->address, + ]); + }); + + DB::table('events')->orderBy('id')->each(function (object $event): void { + DB::table('event_dates') + ->where('event_id', $event->id) + ->update(['tenant_code' => $event->tenant_code]); + }); + + Schema::table('tenants', function (Blueprint $table): void { + $table->dropConstrainedForeignId('active_event_id'); + }); + Schema::table('catalog_items', function (Blueprint $table): void { + $table->dropConstrainedForeignId('event_id'); + }); + Schema::table('compras', function (Blueprint $table): void { + $table->dropConstrainedForeignId('event_id'); + }); + Schema::table('event_dates', function (Blueprint $table): void { + $table->dropConstrainedForeignId('event_id'); + $table->string('tenant_code')->nullable(false)->change(); + $table->foreign('tenant_code') + ->references('codigo') + ->on('tenants') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + }); + + Schema::dropIfExists('events'); + } + + public function down(): void + { + Schema::create('events', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->string('name'); + $table->string('address'); + $table->foreign('tenant_code') + ->references('codigo') + ->on('tenants') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + }); + + Schema::table('tenants', function (Blueprint $table): void { + $table->foreignId('active_event_id')->nullable(); + }); + Schema::table('catalog_items', function (Blueprint $table): void { + $table->foreignId('event_id')->nullable(); + }); + Schema::table('compras', function (Blueprint $table): void { + $table->foreignId('event_id')->nullable(); + }); + Schema::table('event_dates', function (Blueprint $table): void { + $table->foreignId('event_id')->nullable(); + }); + + DB::table('tenants')->orderBy('id')->each(function (object $tenant): void { + if ($tenant->event_title === null && $tenant->event_location === null) { + return; + } + + $eventId = DB::table('events')->insertGetId([ + 'tenant_code' => $tenant->codigo, + 'name' => $tenant->event_title ?? $tenant->nombre, + 'address' => $tenant->event_location ?? '', + ]); + + DB::table('tenants')->where('id', $tenant->id)->update(['active_event_id' => $eventId]); + DB::table('catalog_items')->where('tenant_code', $tenant->codigo)->update(['event_id' => $eventId]); + DB::table('compras')->where('tenant_codigo', $tenant->codigo)->update(['event_id' => $eventId]); + DB::table('event_dates')->where('tenant_code', $tenant->codigo)->update(['event_id' => $eventId]); + }); + + Schema::table('tenants', function (Blueprint $table): void { + $table->foreign('active_event_id')->references('id')->on('events')->nullOnDelete(); + $table->dropColumn(['event_title', 'event_location']); + }); + Schema::table('catalog_items', function (Blueprint $table): void { + $table->foreign('event_id')->references('id')->on('events')->nullOnDelete(); + }); + Schema::table('compras', function (Blueprint $table): void { + $table->foreign('event_id')->references('id')->on('events')->nullOnDelete(); + }); + Schema::table('event_dates', function (Blueprint $table): void { + $table->dropForeign(['tenant_code']); + $table->dropColumn('tenant_code'); + $table->foreign('event_id')->references('id')->on('events')->cascadeOnDelete(); + }); + } +}; diff --git a/database/migrations/2026_08_07_000100_allow_event_date_attribute_combinations.php b/database/migrations/2026_08_07_000100_allow_event_date_attribute_combinations.php new file mode 100644 index 0000000..8410619 --- /dev/null +++ b/database/migrations/2026_08_07_000100_allow_event_date_attribute_combinations.php @@ -0,0 +1,30 @@ +index(['catalog_item_id', 'event_date_id']); + }); + + Schema::table('variantes', function (Blueprint $table): void { + $table->dropUnique(['catalog_item_id', 'event_date_id']); + }); + } + + public function down(): void + { + Schema::table('variantes', function (Blueprint $table): void { + $table->unique(['catalog_item_id', 'event_date_id']); + }); + + Schema::table('variantes', function (Blueprint $table): void { + $table->dropIndex(['catalog_item_id', 'event_date_id']); + }); + } +}; diff --git a/database/migrations/2026_08_07_000200_add_event_date_catalog_attributes.php b/database/migrations/2026_08_07_000200_add_event_date_catalog_attributes.php new file mode 100644 index 0000000..d51c328 --- /dev/null +++ b/database/migrations/2026_08_07_000200_add_event_date_catalog_attributes.php @@ -0,0 +1,75 @@ +whereExists(fn ($query) => $query + ->selectRaw('1') + ->from('event_dates') + ->whereColumn('event_dates.tenant_code', 'tenants.codigo')) + ->orderBy('id') + ->each(function (object $tenant) use ($now): void { + DB::table('attribute')->updateOrInsert( + [ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'event_date', + ], + [ + 'nombre' => 'Fecha', + 'is_required' => true, + 'metadata_schema' => null, + 'type' => 'event_date', + 'updated_at' => $now, + 'created_at' => $now, + ], + ); + }); + + DB::table('catalog_items') + ->whereExists(fn ($query) => $query + ->selectRaw('1') + ->from('variantes') + ->whereColumn('variantes.catalog_item_id', 'catalog_items.id') + ->whereNotNull('variantes.event_date_id')) + ->orderBy('id') + ->each(function (object $catalogItem) use ($now): void { + $attributeId = DB::table('attribute') + ->where('tenant_codigo', $catalogItem->tenant_code) + ->where('codigo', 'event_date') + ->value('id'); + + if ($attributeId === null) { + return; + } + + DB::table('item_attributes')->updateOrInsert( + [ + 'catalog_item_id' => $catalogItem->id, + 'attribute_id' => $attributeId, + ], + [ + 'updated_at' => $now, + 'created_at' => $now, + ], + ); + }); + } + + public function down(): void + { + $attributeIds = DB::table('attribute') + ->where('codigo', 'event_date') + ->where('type', 'event_date') + ->pluck('id'); + + DB::table('item_attributes')->whereIn('attribute_id', $attributeIds)->delete(); + DB::table('attribute')->whereIn('id', $attributeIds)->delete(); + } +}; diff --git a/database/migrations/2026_08_07_000300_add_multi_value_event_dates_to_variants.php b/database/migrations/2026_08_07_000300_add_multi_value_event_dates_to_variants.php new file mode 100644 index 0000000..e60d409 --- /dev/null +++ b/database/migrations/2026_08_07_000300_add_multi_value_event_dates_to_variants.php @@ -0,0 +1,42 @@ +boolean('allow_multi_select')->default(false)->after('attribute_id'); + }); + + Schema::create('variant_event_dates', function (Blueprint $table): void { + $table->foreignId('variant_id')->constrained('variantes')->cascadeOnDelete(); + $table->foreignId('event_date_id')->constrained('event_dates')->cascadeOnDelete(); + $table->primary(['variant_id', 'event_date_id']); + $table->index(['event_date_id', 'variant_id']); + }); + + DB::table('variantes') + ->whereNotNull('event_date_id') + ->orderBy('id') + ->each(function (object $variant): void { + DB::table('variant_event_dates')->insertOrIgnore([ + 'variant_id' => $variant->id, + 'event_date_id' => $variant->event_date_id, + ]); + }); + } + + public function down(): void + { + Schema::dropIfExists('variant_event_dates'); + + Schema::table('item_attributes', function (Blueprint $table): void { + $table->dropColumn('allow_multi_select'); + }); + } +}; diff --git a/database/migrations/2026_08_07_000400_allow_multiple_variant_values_per_attribute.php b/database/migrations/2026_08_07_000400_allow_multiple_variant_values_per_attribute.php new file mode 100644 index 0000000..0ff277d --- /dev/null +++ b/database/migrations/2026_08_07_000400_allow_multiple_variant_values_per_attribute.php @@ -0,0 +1,22 @@ +dropUnique('variant_values_variant_id_item_attribute_id_unique'); + }); + } + + public function down(): void + { + Schema::table('variant_values', function (Blueprint $table): void { + $table->unique(['variant_id', 'item_attribute_id']); + }); + } +}; diff --git a/database/migrations/2026_08_07_000500_add_commercial_overrides_to_variants.php b/database/migrations/2026_08_07_000500_add_commercial_overrides_to_variants.php new file mode 100644 index 0000000..86035a3 --- /dev/null +++ b/database/migrations/2026_08_07_000500_add_commercial_overrides_to_variants.php @@ -0,0 +1,34 @@ +text('descripcion')->nullable()->after('inventory_id'); + $table->decimal('precio', 10, 2)->nullable()->after('descripcion'); + }); + + DB::table('variantes')->orderBy('id')->each(function (object $variant): void { + $price = DB::table('catalog_items') + ->where('id', $variant->catalog_item_id) + ->value('precio'); + + DB::table('variantes')->where('id', $variant->id)->update([ + 'precio' => $price, + ]); + }); + } + + public function down(): void + { + Schema::table('variantes', function (Blueprint $table): void { + $table->dropColumn(['descripcion', 'precio']); + }); + } +}; diff --git a/database/migrations/2026_08_10_000000_add_event_date_text_to_tenants.php b/database/migrations/2026_08_10_000000_add_event_date_text_to_tenants.php new file mode 100644 index 0000000..99a00be --- /dev/null +++ b/database/migrations/2026_08_10_000000_add_event_date_text_to_tenants.php @@ -0,0 +1,37 @@ +text('event_date_text')->nullable()->after('event_location'); + }); + + $formatter = new EventDateTextFormatter; + + DB::table('tenants')->orderBy('id')->each(function (object $tenant) use ($formatter): void { + $dates = DB::table('event_dates') + ->where('tenant_code', $tenant->codigo) + ->orderBy('date') + ->pluck('date'); + + DB::table('tenants')->where('id', $tenant->id)->update([ + 'event_date_text' => $formatter->format($dates), + ]); + }); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table): void { + $table->dropColumn('event_date_text'); + }); + } +}; diff --git a/database/migrations/2026_08_10_000100_add_sort_order_to_item_attributes.php b/database/migrations/2026_08_10_000100_add_sort_order_to_item_attributes.php new file mode 100644 index 0000000..c0e243b --- /dev/null +++ b/database/migrations/2026_08_10_000100_add_sort_order_to_item_attributes.php @@ -0,0 +1,43 @@ +unsignedInteger('sort_order')->default(0)->after('allow_multi_select'); + }); + + $foodItemAttributes = DB::table('item_attributes') + ->join('catalog_items', 'catalog_items.id', '=', 'item_attributes.catalog_item_id') + ->join('attribute', 'attribute.id', '=', 'item_attributes.attribute_id') + ->where('catalog_items.slug', 'comida') + ->whereIn('attribute.codigo', ['event_date', 'horario', 'servicio']) + ->select('item_attributes.id', 'attribute.codigo') + ->get(); + + $sortOrders = [ + 'event_date' => 1, + 'horario' => 2, + 'servicio' => 3, + ]; + + foreach ($foodItemAttributes as $itemAttribute) { + DB::table('item_attributes') + ->where('id', $itemAttribute->id) + ->update(['sort_order' => $sortOrders[$itemAttribute->codigo]]); + } + } + + public function down(): void + { + Schema::table('item_attributes', function (Blueprint $table): void { + $table->dropColumn('sort_order'); + }); + } +}; diff --git a/database/migrations/2026_08_10_000200_enable_tickets_for_fiesta_futbol_infantil_catalog.php b/database/migrations/2026_08_10_000200_enable_tickets_for_fiesta_futbol_infantil_catalog.php new file mode 100644 index 0000000..f91fba4 --- /dev/null +++ b/database/migrations/2026_08_10_000200_enable_tickets_for_fiesta_futbol_infantil_catalog.php @@ -0,0 +1,27 @@ +where('tenant_code', 'fiesta_futbol_infantil') + ->update(['has_tickets' => true]); + } + + public function down(): void + { + $entryCategoryIds = DB::table('categorias') + ->select('id') + ->where('tenant_code', 'fiesta_futbol_infantil') + ->where('nombre', 'Entradas'); + + DB::table('catalog_items') + ->where('tenant_code', 'fiesta_futbol_infantil') + ->whereNotIn('category_id', $entryCategoryIds) + ->update(['has_tickets' => false]); + } +}; diff --git a/database/migrations/2026_08_10_000300_remove_event_product_type_from_catalog_items.php b/database/migrations/2026_08_10_000300_remove_event_product_type_from_catalog_items.php new file mode 100644 index 0000000..6df1f59 --- /dev/null +++ b/database/migrations/2026_08_10_000300_remove_event_product_type_from_catalog_items.php @@ -0,0 +1,32 @@ +dropColumn('event_product_type'); + }); + } + + public function down(): void + { + if (Schema::hasColumn('catalog_items', 'event_product_type')) { + return; + } + + Schema::table('catalog_items', function (Blueprint $table): void { + $table->enum('event_product_type', ['entrada', 'producto']) + ->nullable() + ->after('tenant_code'); + }); + } +}; diff --git a/database/migrations/2026_08_11_000000_add_header_display_options_to_tenants_table.php b/database/migrations/2026_08_11_000000_add_header_display_options_to_tenants_table.php new file mode 100644 index 0000000..4e49f56 --- /dev/null +++ b/database/migrations/2026_08_11_000000_add_header_display_options_to_tenants_table.php @@ -0,0 +1,23 @@ +boolean('display_categories')->default(true)->after('search_items_per_page'); + $table->boolean('display_seach_bar')->default(true)->after('display_categories'); + }); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table): void { + $table->dropColumn(['display_categories', 'display_seach_bar']); + }); + } +}; diff --git a/database/migrations/2026_08_11_000100_configure_header_display_options_for_seeded_tenants.php b/database/migrations/2026_08_11_000100_configure_header_display_options_for_seeded_tenants.php new file mode 100644 index 0000000..98d112a --- /dev/null +++ b/database/migrations/2026_08_11_000100_configure_header_display_options_for_seeded_tenants.php @@ -0,0 +1,26 @@ +where('codigo', 'fiesta_futbol_infantil') + ->update([ + 'display_categories' => false, + 'display_seach_bar' => false, + ]); + + DB::table('tenants') + ->where('codigo', 'sonder') + ->update([ + 'display_categories' => true, + 'display_seach_bar' => true, + ]); + } + + public function down(): void {} +}; diff --git a/database/migrations/2026_08_11_120000_default_website_extras_is_enabled_to_true.php b/database/migrations/2026_08_11_120000_default_website_extras_is_enabled_to_true.php new file mode 100644 index 0000000..b0a051a --- /dev/null +++ b/database/migrations/2026_08_11_120000_default_website_extras_is_enabled_to_true.php @@ -0,0 +1,27 @@ +whereNull('is_enabled') + ->update(['is_enabled' => true]); + + Schema::table('websites_extras', function (Blueprint $table): void { + $table->boolean('is_enabled')->default(true)->nullable(false)->change(); + }); + } + + public function down(): void + { + Schema::table('websites_extras', function (Blueprint $table): void { + $table->boolean('is_enabled')->nullable()->default(null)->change(); + }); + } +}; diff --git a/database/migrations/2026_08_11_130000_add_ticket_generation_policy_to_catalog_items.php b/database/migrations/2026_08_11_130000_add_ticket_generation_policy_to_catalog_items.php new file mode 100644 index 0000000..5a5d18f --- /dev/null +++ b/database/migrations/2026_08_11_130000_add_ticket_generation_policy_to_catalog_items.php @@ -0,0 +1,32 @@ +enum('ticket_generation_policy', TicketGenerationPolicy::values()) + ->default(TicketGenerationPolicy::PerEventDate->value) + ->after('has_tickets'); + }); + + DB::table('catalog_items') + ->where('tenant_code', 'fiesta_futbol_infantil') + ->update([ + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, + ]); + } + + public function down(): void + { + Schema::table('catalog_items', function (Blueprint $table): void { + $table->dropColumn('ticket_generation_policy'); + }); + } +}; diff --git a/database/migrations/2026_08_11_140000_allow_multiple_validity_times_per_ticket.php b/database/migrations/2026_08_11_140000_allow_multiple_validity_times_per_ticket.php new file mode 100644 index 0000000..a7d973f --- /dev/null +++ b/database/migrations/2026_08_11_140000_allow_multiple_validity_times_per_ticket.php @@ -0,0 +1,63 @@ +foreignId('ticket_id') + ->constrained('tickets') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->foreignId('validity_time_id') + ->constrained('validity_times') + ->cascadeOnUpdate() + ->restrictOnDelete(); + + $table->primary(['ticket_id', 'validity_time_id']); + }); + + DB::table('ticket_validity_times')->insertUsing( + ['ticket_id', 'validity_time_id'], + DB::table('tickets') + ->select(['id', 'validity_time_id']) + ->whereNotNull('validity_time_id'), + ); + + Schema::table('tickets', function (Blueprint $table): void { + $table->dropConstrainedForeignId('validity_time_id'); + }); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table): void { + $table->foreignId('validity_time_id') + ->nullable() + ->after('source_variant_id') + ->constrained('validity_times') + ->cascadeOnUpdate() + ->restrictOnDelete(); + }); + + DB::table('ticket_validity_times') + ->orderBy('ticket_id') + ->orderBy('validity_time_id') + ->get() + ->groupBy('ticket_id') + ->each(function ($validityTimes, int|string $ticketId): void { + DB::table('tickets') + ->where('id', $ticketId) + ->update([ + 'validity_time_id' => $validityTimes->first()->validity_time_id, + ]); + }); + + Schema::dropIfExists('ticket_validity_times'); + } +}; diff --git a/database/migrations/2026_08_11_150000_add_validity_time_to_event_dates.php b/database/migrations/2026_08_11_150000_add_validity_time_to_event_dates.php new file mode 100644 index 0000000..724fc77 --- /dev/null +++ b/database/migrations/2026_08_11_150000_add_validity_time_to_event_dates.php @@ -0,0 +1,68 @@ +unsignedBigInteger('validity_time_id') + ->nullable() + ->unique() + ->after('id'); + }); + + DB::table('event_dates') + ->orderBy('id') + ->each(function (object $eventDate): void { + $startsAt = $eventDate->date.' '.$eventDate->time_start; + $expiresAt = $eventDate->date.' '.$eventDate->time_end; + + if (strtotime($expiresAt) <= strtotime($startsAt)) { + $expiresAt = date('Y-m-d H:i:s', strtotime($expiresAt.' +1 day')); + } + + $validityTimeId = DB::table('validity_times')->insertGetId([ + 'type' => 'fixed_window', + 'start_time' => null, + 'end_time' => null, + 'fixed_starts_at' => $startsAt, + 'fixed_expires_at' => $expiresAt, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('event_dates') + ->where('id', $eventDate->id) + ->update(['validity_time_id' => $validityTimeId]); + }); + + Schema::table('event_dates', function (Blueprint $table): void { + $table->unsignedBigInteger('validity_time_id')->nullable(false)->change(); + $table->foreign('validity_time_id') + ->references('id') + ->on('validity_times') + ->restrictOnDelete(); + }); + } + + public function down(): void + { + $validityTimeIds = DB::table('event_dates') + ->pluck('validity_time_id') + ->filter() + ->all(); + + Schema::table('event_dates', function (Blueprint $table): void { + $table->dropForeign(['validity_time_id']); + $table->dropUnique(['validity_time_id']); + $table->dropColumn('validity_time_id'); + }); + + DB::table('validity_times')->whereIn('id', $validityTimeIds)->delete(); + } +}; diff --git a/database/migrations/2026_08_11_160000_group_ticket_validity_times.php b/database/migrations/2026_08_11_160000_group_ticket_validity_times.php new file mode 100644 index 0000000..393563c --- /dev/null +++ b/database/migrations/2026_08_11_160000_group_ticket_validity_times.php @@ -0,0 +1,88 @@ +id(); + $table->foreignId('ticket_id') + ->constrained('tickets') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + }); + + Schema::create('ticket_validity_group_times', function (Blueprint $table): void { + $table->foreignId('ticket_validity_group_id') + ->constrained('ticket_validity_groups') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->foreignId('validity_time_id') + ->constrained('validity_times') + ->cascadeOnUpdate() + ->restrictOnDelete(); + + $table->primary(['ticket_validity_group_id', 'validity_time_id']); + }); + + // Every former pivot row was an OR alternative, so each one becomes + // an independent group to preserve existing ticket behavior. + DB::table('ticket_validity_times') + ->orderBy('ticket_id') + ->orderBy('validity_time_id') + ->each(function (object $association): void { + $groupId = DB::table('ticket_validity_groups')->insertGetId([ + 'ticket_id' => $association->ticket_id, + ]); + + DB::table('ticket_validity_group_times')->insert([ + 'ticket_validity_group_id' => $groupId, + 'validity_time_id' => $association->validity_time_id, + ]); + }); + + Schema::dropIfExists('ticket_validity_times'); + } + + public function down(): void + { + Schema::create('ticket_validity_times', function (Blueprint $table): void { + $table->foreignId('ticket_id') + ->constrained('tickets') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->foreignId('validity_time_id') + ->constrained('validity_times') + ->cascadeOnUpdate() + ->restrictOnDelete(); + + $table->primary(['ticket_id', 'validity_time_id']); + }); + + DB::table('ticket_validity_group_times') + ->join( + 'ticket_validity_groups', + 'ticket_validity_groups.id', + '=', + 'ticket_validity_group_times.ticket_validity_group_id', + ) + ->select([ + 'ticket_validity_groups.ticket_id', + 'ticket_validity_group_times.validity_time_id', + ]) + ->distinct() + ->orderBy('ticket_validity_groups.ticket_id') + ->each(fn (object $association) => DB::table('ticket_validity_times')->insert([ + 'ticket_id' => $association->ticket_id, + 'validity_time_id' => $association->validity_time_id, + ])); + + Schema::dropIfExists('ticket_validity_group_times'); + Schema::dropIfExists('ticket_validity_groups'); + } +}; diff --git a/database/seeders/AttributeSeeder.php b/database/seeders/AttributeSeeder.php index f4b4cc0..001d26a 100644 --- a/database/seeders/AttributeSeeder.php +++ b/database/seeders/AttributeSeeder.php @@ -5,6 +5,8 @@ namespace Database\Seeders; use App\Domains\Catalog\Models\Attribute; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\ValidityTimeType; +use App\Domains\Ticket\Models\ValidityTime; use Illuminate\Database\Seeder; class AttributeSeeder extends Seeder @@ -17,12 +19,8 @@ class AttributeSeeder extends Seeder $tenants = Tenant::all(); foreach ($tenants as $tenant) { - // Event dates replace catalog attributes for Fiesta Futbol Infantil. if ($tenant->codigo === 'fiesta_futbol_infantil') { - Attribute::query() - ->where('tenant_codigo', $tenant->codigo) - ->whereIn('codigo', ['color', 'talle', 'talle_numerico', 'fecha']) - ->delete(); + $this->seedFiestaFutbolInfantilAttributes($tenant); continue; } @@ -91,23 +89,130 @@ class AttributeSeeder extends Seeder ], ]); - // Seed Fecha attribute + // Las opciones de fecha se resuelven dinámicamente desde event_dates. $this->seedAttribute($tenant, [ - 'codigo' => 'fecha', + 'codigo' => 'event_date', 'nombre' => 'Fecha', - 'type' => FieldType::Select->value, + 'type' => FieldType::EventDate->value, 'is_required' => true, - 'options' => [ - ['value' => '2026-10-09', 'label' => '09/10/2026', 'sort_order' => 1], - ['value' => '2026-10-10', 'label' => '10/10/2026', 'sort_order' => 2], - ['value' => '2026-10-11', 'label' => '11/10/2026', 'sort_order' => 3], - ['value' => '2026-10-12', 'label' => '12/10/2026', 'sort_order' => 4], - ], ]); } } + private function seedFiestaFutbolInfantilAttributes(Tenant $tenant): void + { + Attribute::query() + ->where('tenant_codigo', $tenant->codigo) + ->whereNotIn('codigo', [ + 'event_date', + 'servicio', + 'color', + 'horario', + 'talle', + 'tipo_alojamiento', + ]) + ->delete(); + + $this->seedAttribute($tenant, [ + 'codigo' => 'event_date', + 'nombre' => 'Fecha', + 'type' => FieldType::EventDate->value, + 'is_required' => true, + ]); + + $breakfastValidityTime = $this->timeWindow('07:00:00', '12:00:00'); + $lunchValidityTime = $this->timeWindow('12:00:00', '15:00:00'); + $dinnerValidityTime = $this->timeWindow('20:00:00', '24:00:00'); + + $this->seedAttribute($tenant, [ + 'codigo' => 'tipo_alojamiento', + 'nombre' => 'TipoAlojamiento', + 'type' => FieldType::Select->value, + 'is_required' => true, + 'options' => [ + ['value' => 'Carpa', 'label' => 'Carpa', 'sort_order' => 1], + ['value' => 'Motorhome', 'label' => 'Motorhome', 'sort_order' => 2], + ], + ]); + + $this->seedAttribute($tenant, [ + 'codigo' => 'servicio', + 'nombre' => 'Servicio', + 'type' => FieldType::Select->value, + 'is_required' => true, + 'options' => [ + ['value' => 'Comedor', 'label' => 'Comedor', 'sort_order' => 1], + ['value' => 'Vianda', 'label' => 'Vianda', 'sort_order' => 2], + ], + ]); + + $this->seedAttribute($tenant, [ + 'codigo' => 'color', + 'nombre' => 'Color', + 'type' => FieldType::Select->value, + 'is_required' => true, + 'metadata_schema' => [ + 'hex' => ['type' => 'string'], + ], + 'options' => [ + ['value' => 'Verde', 'label' => 'Verde', 'sort_order' => 1, 'metadata' => ['hex' => '#00973F']], + ['value' => 'Blanco', 'label' => 'Blanco', 'sort_order' => 2, 'metadata' => ['hex' => '#FFFFFF']], + ], + ]); + + $this->seedAttribute($tenant, [ + 'codigo' => 'horario', + 'nombre' => 'Horario', + 'type' => FieldType::Select->value, + 'is_required' => true, + 'options' => [ + [ + 'value' => 'Desayuno', + 'label' => 'Desayuno', + 'sort_order' => 1, + 'validity_time_id' => $breakfastValidityTime->id, + ], + [ + 'value' => 'Almuerzo', + 'label' => 'Almuerzo', + 'sort_order' => 2, + 'validity_time_id' => $lunchValidityTime->id, + ], + [ + 'value' => 'Cena', + 'label' => 'Cena', + 'sort_order' => 3, + 'validity_time_id' => $dinnerValidityTime->id, + ], + ], + ]); + + $this->seedAttribute($tenant, [ + 'codigo' => 'talle', + 'nombre' => 'Talle', + 'type' => FieldType::Select->value, + 'is_required' => true, + 'options' => [ + ['value' => '14', 'label' => '14', 'sort_order' => 1], + ['value' => 'S', 'label' => 'S', 'sort_order' => 2], + ['value' => 'M', 'label' => 'M', 'sort_order' => 3], + ['value' => 'L', 'label' => 'L', 'sort_order' => 4], + ['value' => 'XL', 'label' => 'XL', 'sort_order' => 5], + ['value' => 'XXL', 'label' => 'XXL', 'sort_order' => 6], + ], + ]); + } + + private function timeWindow(string $startTime, string $endTime): ValidityTime + { + return ValidityTime::query()->firstOrCreate([ + 'type' => ValidityTimeType::TimeWindow, + 'start_time' => $startTime, + 'end_time' => $endTime, + ]); + } + /** * @param array $data */ diff --git a/database/seeders/FiestaFutbolInfantilProductSeeder.php b/database/seeders/FiestaFutbolInfantilProductSeeder.php index 5797c0b..5a518a7 100644 --- a/database/seeders/FiestaFutbolInfantilProductSeeder.php +++ b/database/seeders/FiestaFutbolInfantilProductSeeder.php @@ -2,8 +2,6 @@ namespace Database\Seeders; -use App\Domains\Catalog\Enums\CatalogItemType; -use App\Domains\Catalog\Enums\EventProductType; use App\Domains\Catalog\Enums\FeaturedGroupSource; use App\Domains\Catalog\Enums\GroupLayout; use App\Domains\Catalog\Enums\InventoryPolicy; @@ -12,8 +10,8 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Services\CatalogService; -use App\Domains\Event\Models\Event; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\TicketGenerationPolicy; use Illuminate\Database\Seeder; use RuntimeException; @@ -30,151 +28,104 @@ class FiestaFutbolInfantilProductSeeder extends Seeder } $this->deleteExistingCatalog($tenant); + FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->delete(); + Category::query()->where('tenant_code', $tenant->codigo)->update(['categoria_id' => null]); + Category::query()->where('tenant_code', $tenant->codigo)->delete(); - $event = Event::query()->updateOrCreate( - [ - 'tenant_code' => $tenant->codigo, - 'name' => 'Fiesta Nacional del Fútbol Infantil', - ], - ['address' => 'Sunchales, Santa Fe'], - ); - $event->dates()->delete(); + $categories = collect([ + 'entradas' => 'Entradas', + 'alojamientos' => 'Alojamientos', + 'comidas' => 'Comidas', + 'merchandising' => 'Merchandising', + ])->map(fn (string $name): Category => Category::query()->create([ + 'nombre' => $name, + 'tenant_code' => $tenant->codigo, + ])); + $tenant->update([ + 'event_title' => 'Fiesta Nacional del Fútbol Infantil', + 'event_location' => 'Sunchales, Santa Fe', + ]); + + $tenant->eventDates()->delete(); $eventDates = collect(['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12']) - ->mapWithKeys(function (string $date) use ($event): array { - $eventDate = $event->dates()->create([ - 'date' => $date, - 'time_start' => '00:00:00', - 'time_end' => '23:59:59', - ]); + ->map(fn (string $date) => $tenant->eventDates()->create([ + 'date' => $date, + 'time_start' => '00:00:00', + 'time_end' => '23:59:59', + ])); + $dateIds = $eventDates->pluck('id')->map(fn ($id): int => (int) $id)->values(); - return [$date => $eventDate]; - }); - - $tenant->active_event_id = $event->id; - $tenant->save(); - - $ticketCategory = Category::query()->firstOrCreate([ - 'nombre' => 'Entradas', - 'tenant_code' => $tenant->codigo, - ]); - $foodCategory = Category::query()->firstOrCreate([ - 'nombre' => 'Gastronomía', - 'tenant_code' => $tenant->codigo, - ]); - $mealCategory = Category::query()->updateOrCreate([ - 'nombre' => 'Comidas', - 'tenant_code' => $tenant->codigo, - ], [ - 'categoria_id' => $foodCategory->id, - ]); - $drinkCategory = Category::query()->updateOrCreate([ - 'nombre' => 'Bebidas', - 'tenant_code' => $tenant->codigo, - ], [ - 'categoria_id' => $foodCategory->id, - ]); - $parkingCategory = Category::query()->firstOrCreate([ - 'nombre' => 'Estacionamiento', - 'tenant_code' => $tenant->codigo, + $this->createProduct($tenant, [ + 'slug' => 'camiseta', + 'nombre' => 'Camiseta', + 'category_id' => $categories['merchandising']->id, + 'precio' => 18000, + 'attribute_codes' => ['color', 'talle'], + 'variants' => collect(['Verde', 'Blanco']) + ->crossJoin(['14', 'S', 'M', 'L', 'XL', 'XXL']) + ->map(fn (array $values, int $index): array => [ + 'real_stock' => [24, 3, 18, 0, 12, 2, 25, 0, 14, 1, 19, 8][$index], + 'values' => ['color' => $values[0], 'talle' => $values[1]], + ])->all(), ]); - $dates = $eventDates->keys()->all(); - $minimumUseDate = $dates[0].' 00:00:00'; - $maximumUseDate = $dates[array_key_last($dates)].' 23:59:59'; - - $generalAdmission = $this->catalogService->create([ - 'tenant_code' => $tenant->codigo, - 'event_id' => $event->id, - 'event_product_type' => EventProductType::Entry->value, - 'category_id' => $ticketCategory->id, - 'slug' => 'entrada-general', - 'nombre' => 'Entrada General', - 'descripcion' => 'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.', - 'precio' => 10000, - 'inventory_policy' => InventoryPolicy::Unlimited->value, - 'has_tickets' => true, - 'minimum_use_date' => $minimumUseDate, - 'maximum_use_date' => $maximumUseDate, - 'variants' => array_map( - fn (string $date): array => [ - 'real_stock' => 0, - 'event_date_id' => $eventDates->get($date)->id, - ], - $dates, - ), + $this->createProduct($tenant, [ + 'slug' => 'alojamiento', + 'nombre' => 'Alojamiento', + 'category_id' => $categories['alojamientos']->id, + 'precio' => 35000, + 'attribute_codes' => ['tipo_alojamiento'], + 'variants' => collect(['Carpa', 'Motorhome'])->map(fn (string $type, int $index): array => [ + 'real_stock' => [0, 3][$index], + 'values' => ['tipo_alojamiento' => $type], + ])->all(), ]); - $items = [ - ['slug' => 'hamburguesa-papa-frita', 'nombre' => 'Hamburguesa con papa frita', 'precio' => 8000, 'category_id' => $mealCategory->id], - ['slug' => 'pancho', 'nombre' => 'Pancho', 'precio' => 4000, 'category_id' => $mealCategory->id], - ['slug' => 'coca-cola-500ml', 'nombre' => 'Coca Cola 500ml', 'precio' => 3000, 'category_id' => $drinkCategory->id], - ['slug' => 'agua-mineral-1l', 'nombre' => 'Agua Mineral 1L', 'precio' => 2500, 'category_id' => $drinkCategory->id], - ['slug' => 'estacionamiento-auto', 'nombre' => 'Estacionamiento Auto', 'precio' => 5000, 'category_id' => $parkingCategory->id], - ['slug' => 'estacionamiento-moto', 'nombre' => 'Estacionamiento Moto', 'precio' => 2000, 'category_id' => $parkingCategory->id], - ]; + $this->createProduct($tenant, [ + 'slug' => 'comida', + 'nombre' => 'Comida', + 'category_id' => $categories['comidas']->id, + 'precio' => 4000, + 'attribute_codes' => ['event_date', 'horario', 'servicio'], + 'variants' => $eventDates + ->crossJoin(['Desayuno', 'Almuerzo', 'Cena'], ['Comedor', 'Vianda']) + ->map(fn (array $values, int $index): array => [ + 'real_stock' => [80, 3, 65, 0, 42, 2, 70, 18, 0, 5, 55, 40, 90, 1, 35, 0, 60, 8, 75, 22, 0, 4, 50, 30][$index], + 'event_date_ids' => [(int) $values[0]->id], + 'descripcion' => sprintf( + '%s del %s - %s', + $values[1], + $values[0]->date->format('d/m/Y'), + $values[2], + ), + 'precio' => $this->foodPrice($values[1]), + 'values' => ['horario' => $values[1], 'servicio' => $values[2]], + ])->all(), + ]); - $createdItems = []; - foreach ($items as $item) { - $createdItems[$item['slug']] = $this->catalogService->create([ - 'tenant_code' => $tenant->codigo, - 'event_id' => $event->id, - 'event_product_type' => EventProductType::Product->value, - 'descripcion' => $item['descripcion'] ?? $item['nombre'], - 'inventory_policy' => InventoryPolicy::Unlimited->value, - 'real_stock' => 0, - 'minimum_use_date' => $minimumUseDate, - 'maximum_use_date' => $maximumUseDate, - ...$item, - ]); - } - - $this->catalogService->create([ - 'tenant_code' => $tenant->codigo, - 'event_id' => $event->id, - 'event_product_type' => EventProductType::Entry->value, - 'type' => CatalogItemType::Bundle->value, - 'slug' => 'entrada-general-todos-los-dias', - 'nombre' => 'Entrada General - Todos los días', - 'descripcion' => 'Incluye una entrada para cada día de la Fiesta Nacional del Fútbol Infantil.', + $this->createProduct($tenant, [ + 'slug' => 'abono', + 'nombre' => 'Abono', + 'category_id' => $categories['entradas']->id, 'precio' => 40000, - 'category_id' => $ticketCategory->id, - 'components' => $generalAdmission->variants - ->map(fn ($variant): array => [ - 'catalog_item_id' => $generalAdmission->id, - 'variant_id' => $variant->id, - 'quantity' => 1, - ]) - ->all(), + 'has_tickets' => true, + 'attribute_codes' => ['event_date'], + 'multi_select_attribute_codes' => ['event_date'], + 'variants' => [[ + 'real_stock' => 120, + 'event_date_ids' => $dateIds->all(), + ]], ]); - $this->catalogService->create([ + FeaturedGroup::query()->create([ 'tenant_code' => $tenant->codigo, - 'event_id' => $event->id, - 'event_product_type' => EventProductType::Product->value, - 'type' => CatalogItemType::Bundle->value, - 'slug' => 'combo-2-panchos-2-hamburguesas', - 'nombre' => 'Combo 2 Panchos + 2 Hamburguesas', - 'descripcion' => 'Incluye 2 panchos y 2 hamburguesas con papa frita.', - 'precio' => 24000, - 'category_id' => $mealCategory->id, - 'components' => [ - [ - 'catalog_item_id' => $createdItems['pancho']->id, - 'quantity' => 2, - ], - [ - 'catalog_item_id' => $createdItems['hamburguesa-papa-frita']->id, - 'quantity' => 2, - ], - ], - ]); - - $this->seedFeaturedGroups($tenant, [ - 'Entradas' => $ticketCategory, - 'Estacionamiento' => $parkingCategory, - 'Comidas' => $mealCategory, - 'Bebidas' => $drinkCategory, + 'source_type' => FeaturedGroupSource::All, + 'category_id' => null, + 'product_layout' => ProductLayout::Row, + 'group_layout' => GroupLayout::SimpleVertical, + 'group_name' => 'Productos', + 'group_order' => 0, ]); } @@ -182,51 +133,30 @@ class FiestaFutbolInfantilProductSeeder extends Seeder { CatalogItem::query() ->where('tenant_code', $tenant->codigo) - ->where('type', CatalogItemType::Bundle->value) - ->each(fn (CatalogItem $item) => $this->catalogService->delete($item)); - - CatalogItem::query() - ->where('tenant_code', $tenant->codigo) - ->where('type', CatalogItemType::Standard->value) + ->orderByRaw("CASE WHEN type = 'bundle' THEN 0 ELSE 1 END") ->each(fn (CatalogItem $item) => $this->catalogService->delete($item)); } - /** @param array $categories */ - private function seedFeaturedGroups(Tenant $tenant, array $categories): void + /** @param array $data */ + private function createProduct(Tenant $tenant, array $data): CatalogItem { - FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->delete(); + return $this->catalogService->create([ + 'tenant_code' => $tenant->codigo, + 'descripcion' => $data['nombre'], + 'inventory_policy' => InventoryPolicy::Tracked->value, + ...$data, + 'has_tickets' => true, + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, + ]); + } - $groups = [ - 'Entradas' => [ - 'product_layout' => ProductLayout::Row, - 'group_layout' => GroupLayout::SimpleVertical, - ], - 'Estacionamiento' => [ - 'product_layout' => ProductLayout::ColumnWithCart, - 'group_layout' => GroupLayout::Simple, - ], - 'Comidas' => [ - 'product_layout' => ProductLayout::ColumnWithCart, - 'group_layout' => GroupLayout::Simple, - ], - 'Bebidas' => [ - 'product_layout' => ProductLayout::ColumnWithCart, - 'group_layout' => GroupLayout::Simple, - ], - ]; - - $groupOrder = 0; - foreach ($groups as $groupName => $config) { - FeaturedGroup::query()->create([ - 'tenant_code' => $tenant->codigo, - 'source_type' => FeaturedGroupSource::Category, - 'category_id' => $categories[$groupName]->id, - 'product_layout' => $config['product_layout'], - 'group_layout' => $config['group_layout'], - 'group_name' => $groupName, - 'group_order' => $groupOrder++, - ]); - - } + private function foodPrice(string $schedule): int + { + return match ($schedule) { + 'Desayuno' => 4000, + 'Almuerzo' => 10000, + 'Cena' => 8000, + default => throw new RuntimeException("Horario de comida desconocido: {$schedule}"), + }; } } diff --git a/database/seeders/MenuSeeder.php b/database/seeders/MenuSeeder.php index 034d35f..3c09308 100644 --- a/database/seeders/MenuSeeder.php +++ b/database/seeders/MenuSeeder.php @@ -71,6 +71,30 @@ class MenuSeeder extends Seeder 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/staff', ], + [ + 'code' => 'adminapp.fiesta-futbol-infantil.entradas', + 'label' => 'Entradas', + 'parent_menu_code' => 'main.adminapp', + 'route' => '/admin/entradas', + ], + [ + 'code' => 'adminapp.fiesta-futbol-infantil.alojamientos', + 'label' => 'Alojamientos', + 'parent_menu_code' => 'main.adminapp', + 'route' => '/admin/alojamientos', + ], + [ + 'code' => 'adminapp.fiesta-futbol-infantil.merchandising', + 'label' => 'Merchandising', + 'parent_menu_code' => 'main.adminapp', + 'route' => '/admin/merchandising', + ], + [ + 'code' => 'adminapp.fiesta-futbol-infantil.comida', + 'label' => 'Comida', + 'parent_menu_code' => 'main.adminapp', + 'route' => '/admin/comidas', + ], [ 'code' => 'account', 'label' => 'Mi cuenta', @@ -222,6 +246,18 @@ class MenuSeeder extends Seeder 'sonder', 'fiesta_futbol_infantil', ]; + $fiestaCategoryMenuCodes = [ + 'adminapp.fiesta-futbol-infantil.entradas', + 'adminapp.fiesta-futbol-infantil.alojamientos', + 'adminapp.fiesta-futbol-infantil.merchandising', + 'adminapp.fiesta-futbol-infantil.comida', + ]; + $fiestaExcludedAdminMenuCodes = [ + 'adminapp.inicio', + 'adminapp.catalog', + 'adminapp.categories', + 'adminapp.combos', + ]; $frequentlyAskedQuestions = [ [ 'pregunta' => '¿Hay algún límite de compra?', @@ -279,6 +315,12 @@ class MenuSeeder extends Seeder $menuCodes = array_diff($menuCodes, $helpMenuCodes); } + if ($tenant->codigo !== 'fiesta_futbol_infantil') { + $menuCodes = array_diff($menuCodes, $fiestaCategoryMenuCodes); + } else { + $menuCodes = array_diff($menuCodes, $fiestaExcludedAdminMenuCodes); + } + // Usar sync para asociar los menues al tenant $tenant->menues()->sync($menuCodes); diff --git a/database/seeders/TenantSeeder.php b/database/seeders/TenantSeeder.php index 474fe0b..3721fc5 100644 --- a/database/seeders/TenantSeeder.php +++ b/database/seeders/TenantSeeder.php @@ -56,6 +56,8 @@ class TenantSeeder extends Seeder 'success_color' => '#198754', 'header_bg_color' => '#ffffff', 'footer_bg_color' => '#313131', + 'display_categories' => true, + 'display_seach_bar' => true, 'header_logo' => $this->uploadedImage('images/tennants/sonder/sonder_header.png', 'sonder_header.png'), 'footer_logo' => $this->uploadedImage('images/tennants/sonder/sonder_footer.png', 'sonder_footer.png'), 'social_media' => self::SOCIAL_MEDIA, @@ -107,6 +109,8 @@ class TenantSeeder extends Seeder 'success_color' => '#198754', 'header_bg_color' => '#ffffff', 'footer_bg_color' => '#015327', + 'display_categories' => false, + 'display_seach_bar' => false, 'header_logo' => $this->uploadedImage( 'images/tennants/fiesta_futbol_infantil/futbol_infantil_header.png', 'futbol_infantil_header.png', diff --git a/lang/en/api.php b/lang/en/api.php index 9cdf268..91cc55b 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -24,6 +24,7 @@ return [ 'cart' => [ 'item_added' => 'Product added to cart.', 'quantity_updated' => 'Product quantity updated.', + 'item_updated' => 'Product updated.', 'item_removed' => 'Product removed from cart.', 'positive_quantity' => 'The quantity must be greater than zero.', 'insufficient_stock' => 'There is not enough stock for the requested product. Maximum available: :max.', @@ -49,6 +50,9 @@ return [ 'not_available_for_payment' => 'The purchase is no longer available for payment.', 'not_available_for_review' => 'The purchase is no longer available for review.', ], + 'purchase_limit' => [ + 'exceeded' => 'You can purchase up to :max units of this product.', + ], 'ticket' => [ 'not_available' => 'One or more tickets are not available.', 'positive_quantity' => 'The number of tickets to generate must be greater than zero.', @@ -89,6 +93,18 @@ return [ 'variants_required' => 'An item with attribute_codes must have variants.', 'variants_forbidden' => 'An item without attribute_codes cannot have variants.', 'direct_inventory_forbidden' => 'An item with variants cannot have direct inventory.', + 'event_date_attribute_required' => 'The event_date attribute is required for event date variants.', + 'multi_select_attribute_not_on_item' => 'Multi-select attributes must also be present in attribute_codes.', + 'event_date_selection_required' => 'At least one event date must be selected.', + 'single_event_date_required' => 'Exactly one event date must be selected.', + 'event_date_wrong_tenant' => 'Every event date must belong to the catalog item tenant.', + 'duplicate_variant_combination' => 'The variant combination must be unique.', + 'multi_value_required' => 'At least one value must be selected.', + 'single_value_required' => 'Exactly one value must be selected.', + 'selected_values_non_empty' => 'Every selected value must be a non-empty string.', + 'selected_values_distinct' => 'Selected values must be distinct.', + 'invalid_attribute_options' => 'One or more selected values are not valid attribute options.', + 'incompatible_validity_windows' => 'Selected values cannot have different validity windows.', ], 'menu' => [ 'schema_required' => 'The schema is required for static menus.', diff --git a/lang/es/api.php b/lang/es/api.php index cbcfaef..129e49d 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -24,6 +24,7 @@ return [ 'cart' => [ 'item_added' => 'Producto agregado al carrito.', 'quantity_updated' => 'Cantidad de producto actualizada.', + 'item_updated' => 'Producto actualizado.', 'item_removed' => 'Producto eliminado del carrito.', 'positive_quantity' => 'La cantidad debe ser mayor a cero.', 'insufficient_stock' => 'Stock insuficiente para el producto solicitado. Máximo disponible: :max.', @@ -49,6 +50,9 @@ return [ 'not_available_for_payment' => 'La compra ya no está disponible para el pago.', 'not_available_for_review' => "La compra ya no est\u{00E1} disponible para revisi\u{00F3}n.", ], + 'purchase_limit' => [ + 'exceeded' => 'Podés comprar hasta :max unidades de este producto.', + ], 'ticket' => [ 'not_available' => 'Uno o más tickets no están disponibles.', 'positive_quantity' => 'La cantidad de tickets a generar debe ser mayor a cero.', @@ -89,6 +93,18 @@ return [ 'variants_required' => 'Un ítem con attribute_codes debe tener variantes.', 'variants_forbidden' => 'Un ítem sin attribute_codes no puede tener variantes.', 'direct_inventory_forbidden' => 'Un ítem con variantes no puede tener inventario directo.', + 'event_date_attribute_required' => 'El atributo event_date es obligatorio para las variantes con fecha de evento.', + 'multi_select_attribute_not_on_item' => 'Los atributos multiselección también deben estar incluidos en attribute_codes.', + 'event_date_selection_required' => 'Debe seleccionar al menos una fecha de evento.', + 'single_event_date_required' => 'Debe seleccionar exactamente una fecha de evento.', + 'event_date_wrong_tenant' => 'Todas las fechas del evento deben pertenecer al tenant del ítem de catálogo.', + 'duplicate_variant_combination' => 'La combinación de la variante debe ser única.', + 'multi_value_required' => 'Debe seleccionar al menos un valor.', + 'single_value_required' => 'Debe seleccionar exactamente un valor.', + 'selected_values_non_empty' => 'Cada valor seleccionado debe ser un texto no vacío.', + 'selected_values_distinct' => 'Los valores seleccionados no pueden repetirse.', + 'invalid_attribute_options' => 'Uno o más valores seleccionados no son opciones válidas del atributo.', + 'incompatible_validity_windows' => 'Los valores seleccionados no pueden tener ventanas de validez diferentes.', ], 'menu' => [ 'schema_required' => 'El schema es obligatorio para los menús estáticos.', diff --git a/public/images/tennants/fiesta_futbol_infantil/futbol_infantil_hero.jpg b/public/images/tennants/fiesta_futbol_infantil/futbol_infantil_hero.jpg deleted file mode 100644 index c636725..0000000 Binary files a/public/images/tennants/fiesta_futbol_infantil/futbol_infantil_hero.jpg and /dev/null differ diff --git a/public/images/tennants/fiesta_futbol_infantil/futbol_infantil_hero.png b/public/images/tennants/fiesta_futbol_infantil/futbol_infantil_hero.png new file mode 100644 index 0000000..6841d72 Binary files /dev/null and b/public/images/tennants/fiesta_futbol_infantil/futbol_infantil_hero.png differ diff --git a/routes/api.php b/routes/api.php index 9339e26..f7154be 100644 --- a/routes/api.php +++ b/routes/api.php @@ -15,3 +15,4 @@ require __DIR__.'/../app/Domains/Event/routes/api.php'; require __DIR__.'/../app/Domains/Bootstrap/routes/api.php'; require __DIR__.'/../app/Domains/Forms/routes/api.php'; require __DIR__.'/../app/Domains/Staff/routes/api.php'; +require __DIR__.'/../app/Domains/FiestaFutbolInfantil/routes/api.php'; diff --git a/tests/Feature/Cart/CartControllerTest.php b/tests/Feature/Cart/CartControllerTest.php index 950c106..460b164 100644 --- a/tests/Feature/Cart/CartControllerTest.php +++ b/tests/Feature/Cart/CartControllerTest.php @@ -4,10 +4,13 @@ namespace Tests\Feature\Cart; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; +use App\Domains\Auth\Models\User; use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; +use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Schema; @@ -99,6 +102,114 @@ class CartControllerTest extends TestCase ]); } + public function test_authenticated_cart_respects_previous_purchases_and_repeated_additions(): void + { + $tenant = $this->createTenant('acme'); + $user = User::factory()->create(); + $item = $this->createDirectItem($tenant, 20, '10.00'); + $item->update(['max_units_per_user' => 4]); + $this->createPurchaseItem($tenant, $user, $item, 1); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 2, + ]) + ->assertOk() + ->assertJsonPath('data.items.0.cantidad', 2); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 2, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('cantidad'); + + $this->assertDatabaseHas('carrito_items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 2, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $item->inventory_id, + 'reserved_stock' => 2, + ]); + } + + public function test_authenticated_cart_shares_the_purchase_limit_between_variants(): void + { + $tenant = $this->createTenant('acme'); + $user = User::factory()->create(); + [$item, $firstVariant] = $this->createVariantItem($tenant, 20, '10.00'); + $item->update(['max_units_per_user' => 3]); + $secondInventory = Inventory::query()->create(['real_stock' => 20]); + $secondVariant = $item->variants()->create(['inventory_id' => $secondInventory->id]); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'variant_id' => $firstVariant->id, + 'cantidad' => 2, + ]) + ->assertOk(); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'variant_id' => $secondVariant->id, + 'cantidad' => 2, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('cantidad'); + + $this->assertDatabaseCount('carrito_items', 1); + $this->assertDatabaseHas('inventories', [ + 'id' => $secondInventory->id, + 'reserved_stock' => 0, + ]); + } + + public function test_authenticated_cart_rejects_quantity_updates_above_the_purchase_limit(): void + { + $tenant = $this->createTenant('acme'); + $user = User::factory()->create(); + $item = $this->createDirectItem($tenant, 20, '10.00'); + $item->update(['max_units_per_user' => 3]); + + $response = $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 2, + ]) + ->assertOk(); + + $this->actingAs($user, 'sanctum') + ->patchJson('/api/tenants/acme/cart/items/'.$response->json('data.items.0.id'), [ + 'cantidad' => 4, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('cantidad'); + + $this->assertDatabaseHas('carrito_items', [ + 'id' => $response->json('data.items.0.id'), + 'cantidad' => 2, + ]); + } + + public function test_guest_cart_does_not_apply_a_user_purchase_limit(): void + { + $tenant = $this->createTenant('acme'); + $item = $this->createDirectItem($tenant, 20, '10.00'); + $item->update(['max_units_per_user' => 1]); + + $this->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 2, + ]) + ->assertOk() + ->assertJsonPath('data.items.0.cantidad', 2); + } + public function test_it_updates_and_removes_an_item_using_its_selected_inventory(): void { $tenant = $this->createTenant('acme'); @@ -145,6 +256,110 @@ class CartControllerTest extends TestCase ]); } + public function test_it_changes_an_item_variant_and_moves_the_stock_reservation(): void + { + $tenant = $this->createTenant('acme'); + [$item, $firstVariant] = $this->createVariantItem($tenant, 10, '15.00'); + $secondInventory = Inventory::query()->create(['real_stock' => 2]); + $secondVariant = $item->variants()->create([ + 'inventory_id' => $secondInventory->id, + 'precio' => '20.00', + ]); + $unavailableInventory = Inventory::query()->create(['real_stock' => 0]); + $unavailableVariant = $item->variants()->create([ + 'inventory_id' => $unavailableInventory->id, + 'precio' => '25.00', + ]); + $createResponse = $this->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'variant_id' => $firstVariant->id, + 'cantidad' => 2, + ]); + $guestToken = $createResponse->getCookie('guest_token', false)?->getValue(); + $cartItemId = $createResponse->json('data.items.0.id'); + + $this->call( + 'PATCH', + "/api/tenants/acme/cart/items/{$cartItemId}", + [], + ['guest_token' => $guestToken], + [], + ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], + json_encode(['cantidad' => 2, 'variant_id' => $secondVariant->id]), + ) + ->assertOk() + ->assertJsonPath('data.items.0.variant_id', $secondVariant->id) + ->assertJsonPath('data.items.0.precio_unitario', '20.00') + ->assertJsonCount(2, 'data.items.0.product.variants') + ->assertJsonPath('data.items.0.product.variants.0.id', $firstVariant->id) + ->assertJsonPath('data.items.0.product.variants.1.id', $secondVariant->id) + ->assertJsonPath('data.items.0.product.variants.1.stock_tecnico', 0) + ->assertJsonMissing(['id' => $unavailableVariant->id, 'stock_tecnico' => 0]); + + $this->assertDatabaseHas('inventories', [ + 'id' => $firstVariant->inventory_id, + 'reserved_stock' => 0, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $secondInventory->id, + 'reserved_stock' => 2, + ]); + } + + public function test_changing_to_a_variant_already_in_the_cart_merges_both_rows(): void + { + $tenant = $this->createTenant('acme'); + [$item, $firstVariant] = $this->createVariantItem($tenant, 10, '15.00'); + $secondInventory = Inventory::query()->create(['real_stock' => 10]); + $secondVariant = $item->variants()->create(['inventory_id' => $secondInventory->id]); + + $firstResponse = $this->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'variant_id' => $firstVariant->id, + 'cantidad' => 2, + ]); + $guestToken = $firstResponse->getCookie('guest_token', false)?->getValue(); + $firstCartItemId = $firstResponse->json('data.items.0.id'); + + $this->call( + 'POST', + '/api/tenants/acme/cart/items', + [], + ['guest_token' => $guestToken], + [], + ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], + json_encode([ + 'catalog_item_id' => $item->id, + 'variant_id' => $secondVariant->id, + 'cantidad' => 3, + ]), + )->assertOk(); + + $this->call( + 'PATCH', + "/api/tenants/acme/cart/items/{$firstCartItemId}", + [], + ['guest_token' => $guestToken], + [], + ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], + json_encode(['cantidad' => 2, 'variant_id' => $secondVariant->id]), + ) + ->assertOk() + ->assertJsonCount(1, 'data.items') + ->assertJsonPath('data.items.0.variant_id', $secondVariant->id) + ->assertJsonPath('data.items.0.cantidad', 5); + + $this->assertDatabaseCount('carrito_items', 1); + $this->assertDatabaseHas('inventories', [ + 'id' => $firstVariant->inventory_id, + 'reserved_stock' => 0, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $secondInventory->id, + 'reserved_stock' => 5, + ]); + } + public function test_it_requires_a_variant_when_the_item_has_variant_inventory(): void { $tenant = $this->createTenant('acme'); @@ -245,6 +460,33 @@ class CartControllerTest extends TestCase return [$item, $variant]; } + private function createPurchaseItem( + Tenant $tenant, + User $user, + CatalogItem $item, + int $quantity, + ): PurchaseItem { + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $user->id, + 'status' => Purchase::STATUS_PAID, + 'total' => (float) $item->precio * $quantity, + ]); + + return $purchase->items()->create([ + 'source_catalog_item_id' => $item->id, + 'source_variant_id' => null, + 'nombre' => $item->nombre, + 'slug' => $item->slug, + 'item_nombre' => $item->nombre, + 'variant_attributes' => [], + 'cantidad' => $quantity, + 'precio_unitario' => $item->precio, + 'total' => (float) $item->precio * $quantity, + 'reservation_status' => PurchaseItem::RESERVATION_COMMITTED, + ]); + } + private function createTenant(string $code): Tenant { $headerLogo = $this->createAttachment("{$code}-header"); diff --git a/tests/Feature/Catalog/CatalogControllerTest.php b/tests/Feature/Catalog/CatalogControllerTest.php index c560b54..9c12fed 100644 --- a/tests/Feature/Catalog/CatalogControllerTest.php +++ b/tests/Feature/Catalog/CatalogControllerTest.php @@ -48,8 +48,15 @@ class CatalogControllerTest extends TestCase 'real_stock' => 4, 'reserved_stock' => 1, ]); + $unavailableInventory = Inventory::query()->create([ + 'real_stock' => 2, + 'reserved_stock' => 2, + ]); $variantItem->variants()->create(['inventory_id' => $firstInventory->id]); $variantItem->variants()->create(['inventory_id' => $secondInventory->id]); + $unavailableVariant = $variantItem->variants()->create([ + 'inventory_id' => $unavailableInventory->id, + ]); $cart->featuredItems()->create(['catalog_item_id' => $variantItem->id]); $response = $this->getJson("/api/tenants/{$tenant->codigo}/catalog"); @@ -67,11 +74,45 @@ class CatalogControllerTest extends TestCase ->assertJsonCount(2, '0.items.0.variants') ->assertJsonPath('0.items.0.variants.0.stock_tecnico', 4) ->assertJsonPath('0.items.0.variants.1.stock_tecnico', 3) + ->assertJsonMissing(['id' => $unavailableVariant->id, 'stock_tecnico' => 0]) ->assertJsonPath('1.title', 'Row') ->assertJsonPath('1.items.data.0.stock_tecnico', 8) ->assertJsonCount(0, '1.items.data.0.variants'); } + public function test_it_excludes_items_when_all_of_their_variants_are_out_of_stock(): void + { + $tenant = $this->createTenant('catalog-available-variants'); + $group = $this->createGroup( + $tenant, + ProductLayout::ColumnWithCart, + 'Available variants', + groupLayout: GroupLayout::SimpleVertical, + ); + + $unavailableItem = $this->createItem($tenant, 'Unavailable'); + $unavailableInventory = Inventory::query()->create([ + 'real_stock' => 4, + 'reserved_stock' => 4, + ]); + $unavailableItem->variants()->create(['inventory_id' => $unavailableInventory->id]); + $group->featuredItems()->create(['catalog_item_id' => $unavailableItem->id]); + + $availableItem = $this->createItem($tenant, 'Available'); + $availableInventory = Inventory::query()->create([ + 'real_stock' => 4, + 'reserved_stock' => 3, + ]); + $availableItem->variants()->create(['inventory_id' => $availableInventory->id]); + $group->featuredItems()->create(['catalog_item_id' => $availableItem->id]); + + $this->getJson("/api/tenants/{$tenant->codigo}/catalog") + ->assertOk() + ->assertJsonCount(1, '0.items') + ->assertJsonPath('0.items.0.nombre', 'Available') + ->assertJsonMissing(['nombre' => 'Unavailable']); + } + public function test_column_with_image_uses_item_image_then_variant_image_then_null(): void { Storage::fake('s3'); diff --git a/tests/Feature/Catalog/CatalogItemControllerTest.php b/tests/Feature/Catalog/CatalogItemControllerTest.php index 76d5190..1b86f43 100644 --- a/tests/Feature/Catalog/CatalogItemControllerTest.php +++ b/tests/Feature/Catalog/CatalogItemControllerTest.php @@ -32,14 +32,12 @@ class CatalogItemControllerTest extends TestCase 'slug' => 'shirt', 'nombre' => 'Shirt', 'precio' => 100, - 'minimum_use_date' => '2026-08-01 09:00:00', - 'maximum_use_date' => '2026-08-31 18:00:00', + 'max_units_per_user' => 4, 'attribute_codes' => [$attribute->codigo], 'images' => [$image, $image], 'variants' => [ [ 'real_stock' => 5, - 'maximum_use_date' => '2026-08-15 18:00:00', 'values' => ['size' => 'M'], 'images' => [$image], ], @@ -49,27 +47,16 @@ class CatalogItemControllerTest extends TestCase $response ->assertCreated() ->assertJsonPath('data.nombre', 'Shirt') + ->assertJsonPath('data.max_units_per_user', 4) ->assertJsonCount(2, 'data.images') ->assertJsonCount(1, 'data.variants') - ->assertJsonCount(1, 'data.variants.0.images') - ->assertJsonPath('data.variants.0.minimum_use_date', null) - ->assertJsonPath( - 'data.variants.0.maximum_use_date', - fn (string $value): bool => str_starts_with($value, '2026-08-15T18:00:00'), - ) - ->assertJsonPath( - 'data.variants.0.effective_minimum_use_date', - fn (string $value): bool => str_starts_with($value, '2026-08-01T09:00:00'), - ) - ->assertJsonPath( - 'data.variants.0.effective_maximum_use_date', - fn (string $value): bool => str_starts_with($value, '2026-08-15T18:00:00'), - ); + ->assertJsonCount(1, 'data.variants.0.images'); $item = CatalogItem::query()->where('slug', 'shirt')->firstOrFail(); $variant = $item->variants()->firstOrFail(); $this->assertSame([0, 1], $item->attachments()->get()->pluck('pivot.orden')->all()); + $this->assertSame(4, $item->max_units_per_user); $this->assertSame([0], $variant->attachments()->get()->pluck('pivot.orden')->all()); $this->assertDatabaseHas('catalog_items_attachments', [ 'catalog_item_id' => $item->id, @@ -93,6 +80,21 @@ class CatalogItemControllerTest extends TestCase $this->assertDatabaseMissing('catalog_items', ['slug' => 'invalid-image']); } + public function test_it_rejects_a_non_positive_user_purchase_limit(): void + { + $tenant = $this->createTenant('purchase-limit-validation'); + + $this->postJson("/api/tenants/{$tenant->codigo}/catalog-items", [ + 'slug' => 'invalid-purchase-limit', + 'nombre' => 'Invalid purchase limit', + 'precio' => 100, + 'real_stock' => 10, + 'max_units_per_user' => 0, + ])->assertUnprocessable()->assertJsonValidationErrors('max_units_per_user'); + + $this->assertDatabaseMissing('catalog_items', ['slug' => 'invalid-purchase-limit']); + } + private function createTenant(string $code = 'catalog-controller'): Tenant { $headerLogo = $this->createAttachment("{$code}-header"); diff --git a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php index c392e7a..18e9c28 100644 --- a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php +++ b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php @@ -45,7 +45,7 @@ class CatalogItemDetailControllerTest extends TestCase $this->assertStringContainsString($itemImage->path, $response->json('data.images.0')); } - public function test_it_selects_the_first_variant_and_returns_its_images_by_default(): void + public function test_it_filters_unavailable_variants_and_selects_the_first_available_one(): void { Storage::fake('s3'); $tenant = $this->createTenant('detail-default'); @@ -66,14 +66,21 @@ class CatalogItemDetailControllerTest extends TestCase $response ->assertOk() - ->assertJsonPath('data.selected_variant.id', $firstVariant->id) - ->assertJsonPath('data.selected_variant.stock_tecnico', 0) + ->assertJsonCount(1, 'data.variants') + ->assertJsonPath('data.variants.0.id', $secondVariant->id) + ->assertJsonPath('data.selected_variant.id', $secondVariant->id) + ->assertJsonPath('data.selected_variant.stock_tecnico', 6) ->assertJsonCount(1, 'data.selected_variant.images'); $response ->assertJsonMissingPath('data.stock_tecnico') ->assertJsonMissingPath('data.images'); - $this->assertStringContainsString($firstImage->path, $response->json('data.selected_variant.images.0')); + $this->assertStringContainsString($secondImage->path, $response->json('data.selected_variant.images.0')); + $this->assertStringNotContainsString($firstImage->path, $response->json('data.selected_variant.images.0')); $this->assertStringNotContainsString($itemImage->path, $response->json('data.selected_variant.images.0')); + + $this->getJson( + "/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}?variant_id={$firstVariant->id}" + )->assertNotFound(); } public function test_it_selects_the_requested_variant_and_lists_variant_values_and_stock(): void @@ -117,17 +124,20 @@ class CatalogItemDetailControllerTest extends TestCase ->assertOk() ->assertJsonPath('data.variants.0.id', $firstVariant->id) ->assertJsonPath('data.variants.0.stock_tecnico', 4) - ->assertJsonPath('data.variants.0.values.size', 'S') + ->assertJsonPath('data.variants.0.values.size.value', 'S') + ->assertJsonPath('data.variants.0.values.size.label', 'Small') ->assertJsonPath('data.variants.1.id', $secondVariant->id) ->assertJsonPath('data.variants.1.stock_tecnico', 7) - ->assertJsonPath('data.variants.1.values.size', 'M') + ->assertJsonPath('data.variants.1.values.size.value', 'M') + ->assertJsonPath('data.variants.1.values.size.label', 'Medium') ->assertJsonPath('data.attributes.0.codigo', 'size') ->assertJsonPath('data.attributes.0.options.0.value', 'S') ->assertJsonPath('data.attributes.0.options.1.value', 'M') ->assertJsonCount(2, 'data.attributes.0.options') ->assertJsonPath('data.selected_variant.id', $secondVariant->id) ->assertJsonPath('data.selected_variant.stock_tecnico', 7) - ->assertJsonPath('data.selected_variant.values.size', 'M') + ->assertJsonPath('data.selected_variant.values.size.value', 'M') + ->assertJsonPath('data.selected_variant.values.size.label', 'Medium') ->assertJsonCount(1, 'data.selected_variant.images'); $response ->assertJsonMissingPath('data.stock_tecnico') @@ -170,6 +180,102 @@ class CatalogItemDetailControllerTest extends TestCase ->assertJsonPath('data.variants.0.stock_tecnico', null); } + public function test_it_exposes_event_dates_as_a_dynamic_variant_attribute(): void + { + $tenant = $this->createTenant('detail-event-date'); + $tenant->update([ + 'event_title' => 'Festival', + 'event_location' => 'Rosario', + ]); + $eventDate = $tenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '09:00', + 'time_end' => '18:00', + ]); + $unusedEventDate = $tenant->eventDates()->create([ + 'date' => '2026-10-10', + 'time_start' => '10:00', + 'time_end' => '19:00', + ]); + $item = $this->createItem($tenant, 'Entry'); + $attribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'event_date', + 'nombre' => 'Fecha', + 'is_required' => true, + 'type' => FieldType::EventDate, + ]); + $item->itemAttributes()->create(['attribute_id' => $attribute->id]); + $variant = $this->createVariant($item, 10, 0); + $variant->update(['event_date_id' => $eventDate->id]); + + $this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}") + ->assertOk() + ->assertJsonPath('data.attributes.0.id', $attribute->id) + ->assertJsonPath('data.attributes.0.codigo', 'event_date') + ->assertJsonPath('data.attributes.0.type', 'event_date') + ->assertJsonCount(2, 'data.attributes.0.options') + ->assertJsonPath('data.attributes.0.options.0.id', $eventDate->id) + ->assertJsonPath('data.attributes.0.options.0.value', (string) $eventDate->id) + ->assertJsonPath('data.attributes.0.options.0.label', '09/10/2026') + ->assertJsonPath('data.attributes.0.options.0.validity_time_id', $eventDate->validity_time_id) + ->assertJsonPath('data.attributes.0.options.0.validity_time.type', 'fixed_window') + ->assertJsonPath('data.attributes.0.options.1.id', $unusedEventDate->id) + ->assertJsonPath('data.attributes.0.options.1.value', (string) $unusedEventDate->id) + ->assertJsonPath('data.variants.0.values.event_date.value', (string) $eventDate->id) + ->assertJsonPath( + 'data.variants.0.values.event_date.label', + '09/10/2026', + ); + + $this->assertDatabaseCount('attribute_options', 0); + } + + public function test_it_orders_item_attributes_by_sort_order_and_then_attribute_label(): void + { + $tenant = $this->createTenant('detail-attribute-order'); + $item = $this->createItem($tenant, 'Ordered attributes'); + $variant = $this->createVariant($item, 10, 0); + + $attributes = collect([ + ['code' => 'zeta', 'label' => 'Zeta', 'sort_order' => 2], + ['code' => 'priority', 'label' => 'Priority', 'sort_order' => 1], + ['code' => 'alpha', 'label' => 'Alpha', 'sort_order' => 2], + ])->mapWithKeys(function (array $data) use ($tenant, $item): array { + $attribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => $data['code'], + 'nombre' => $data['label'], + 'type' => FieldType::String, + ]); + $itemAttribute = $item->itemAttributes()->create([ + 'attribute_id' => $attribute->id, + 'sort_order' => $data['sort_order'], + ]); + + return [$data['code'] => $itemAttribute]; + }); + + foreach (['zeta', 'priority', 'alpha'] as $code) { + $variant->definitions()->create([ + 'item_attribute_id' => $attributes[$code]->id, + 'value' => $code, + ]); + } + + $response = $this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}") + ->assertOk() + ->assertJsonPath('data.attributes.0.codigo', 'priority') + ->assertJsonPath('data.attributes.0.sort_order', 1) + ->assertJsonPath('data.attributes.1.codigo', 'alpha') + ->assertJsonPath('data.attributes.2.codigo', 'zeta'); + + $this->assertSame( + ['priority', 'alpha', 'zeta'], + array_keys($response->json('data.variants.0.values')), + ); + } + private function createItem( Tenant $tenant, string $name, diff --git a/tests/Feature/Catalog/CatalogSchemaTest.php b/tests/Feature/Catalog/CatalogSchemaTest.php index f561360..28a01d8 100644 --- a/tests/Feature/Catalog/CatalogSchemaTest.php +++ b/tests/Feature/Catalog/CatalogSchemaTest.php @@ -24,6 +24,7 @@ class CatalogSchemaTest extends TestCase $this->assertFalse(Schema::hasTable('bundle_items')); $this->assertTrue(Schema::hasTable('variantes')); $this->assertTrue(Schema::hasTable('item_attributes')); + $this->assertTrue(Schema::hasColumn('item_attributes', 'sort_order')); $this->assertTrue(Schema::hasTable('variant_values')); } @@ -31,9 +32,8 @@ class CatalogSchemaTest extends TestCase { $this->assertEqualsCanonicalizing([ 'id', + 'validity_time_id', 'tenant_code', - 'event_id', - 'event_product_type', 'category_id', 'brand_id', 'inventory_id', @@ -43,9 +43,10 @@ class CatalogSchemaTest extends TestCase 'descripcion', 'precio', 'inventory_policy', + 'max_units_per_user', 'has_tickets', - 'maximum_use_date', - 'minimum_use_date', + 'ticket_generation_policy', + 'validity_time_id', ], Schema::getColumnListing('catalog_items')); } @@ -183,26 +184,25 @@ class CatalogSchemaTest extends TestCase { $this->assertTrue(Schema::hasColumns('variantes', [ 'event_date_id', - 'minimum_use_date', - 'maximum_use_date', ])); } - public function test_events_and_event_dates_are_linked_to_the_catalog(): void + public function test_event_configuration_and_dates_belong_to_the_tenant(): void { + $this->assertFalse(Schema::hasTable('events')); + $this->assertTrue(Schema::hasColumns('tenants', [ + 'event_title', + 'event_location', + 'event_date_text', + ])); $this->assertEqualsCanonicalizing([ 'id', 'tenant_code', - 'name', - 'address', - ], Schema::getColumnListing('events')); - $this->assertEqualsCanonicalizing([ - 'id', - 'event_id', 'date', 'time_start', 'time_end', ], Schema::getColumnListing('event_dates')); - $this->assertTrue(Schema::hasColumn('tenants', 'active_event_id')); + $this->assertFalse(Schema::hasColumn('catalog_items', 'event_id')); + $this->assertFalse(Schema::hasColumn('compras', 'event_id')); } } diff --git a/tests/Feature/Catalog/CatalogServiceTest.php b/tests/Feature/Catalog/CatalogServiceTest.php index bc1c211..e671c98 100644 --- a/tests/Feature/Catalog/CatalogServiceTest.php +++ b/tests/Feature/Catalog/CatalogServiceTest.php @@ -13,7 +13,6 @@ use App\Domains\Catalog\Services\CatalogService; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Carbon; use Illuminate\Validation\ValidationException; use Tests\TestCase; @@ -94,71 +93,138 @@ class CatalogServiceTest extends TestCase } } - public function test_variant_use_dates_override_or_inherit_catalog_item_dates(): void + public function test_it_allows_the_same_event_date_with_different_attribute_values(): void { - $attribute = $this->createAttribute('day'); - $itemMinimum = Carbon::parse('2026-08-01 09:00:00'); - $itemMaximum = Carbon::parse('2026-08-31 18:00:00'); - $variantMaximum = Carbon::parse('2026-08-15 18:00:00'); + $sector = $this->createAttribute('sector'); + $eventDateAttribute = $this->createAttribute('event_date', FieldType::EventDate); + $eventDate = $this->tenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '09:00', + 'time_end' => '18:00', + ]); $item = $this->service->create([ 'tenant_code' => $this->tenant->codigo, - 'slug' => 'dated-variants', - 'nombre' => 'Dated variants', + 'slug' => 'entry-by-sector', + 'nombre' => 'Entry by sector', 'precio' => 100, - 'minimum_use_date' => $itemMinimum, - 'maximum_use_date' => $itemMaximum, - 'attribute_codes' => [$attribute->codigo], + 'attribute_codes' => [$eventDateAttribute->codigo, $sector->codigo], 'variants' => [ [ - 'real_stock' => 5, - 'maximum_use_date' => $variantMaximum, - 'values' => [$attribute->codigo => 'Saturday'], + 'event_date_id' => $eventDate->id, + 'values' => ['sector' => 'General'], + ], + [ + 'event_date_id' => $eventDate->id, + 'values' => ['sector' => 'VIP'], ], ], ]); - $variant = $item->variants->firstOrFail(); - - $this->assertNull($variant->minimum_use_date); - $this->assertTrue($variant->maximum_use_date->equalTo($variantMaximum)); - $this->assertTrue($variant->getMinimumUseDate()->equalTo($itemMinimum)); - $this->assertTrue($variant->getMaximumUseDate()->equalTo($variantMaximum)); + $this->assertCount(2, $item->variants); + $this->assertSame( + [$eventDate->id, $eventDate->id], + $item->variants->pluck('event_date_id')->all(), + ); + $this->assertSame( + ['event_date', 'sector'], + $item->itemAttributes->pluck('attribute.codigo')->all(), + ); + $this->assertSame( + FieldType::EventDate, + $item->itemAttributes->first()->attribute->type, + ); } - public function test_it_rejects_an_invalid_effective_variant_use_date_range(): void + public function test_it_creates_a_variant_with_multiple_event_dates(): void { - $attribute = $this->createAttribute('day'); + $eventDateAttribute = $this->createAttribute('event_date', FieldType::EventDate); + $firstDate = $this->tenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '00:00:00', + 'time_end' => '23:59:59', + ]); + $secondDate = $this->tenant->eventDates()->create([ + 'date' => '2026-10-10', + 'time_start' => '00:00:00', + 'time_end' => '23:59:59', + ]); + + $item = $this->service->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => 'multi-date-pass', + 'nombre' => 'Multi-date pass', + 'precio' => 100, + 'attribute_codes' => [$eventDateAttribute->codigo], + 'multi_select_attribute_codes' => ['event_date'], + 'variants' => [[ + 'real_stock' => 5, + 'event_date_ids' => [$secondDate->id, $firstDate->id], + ]], + ]); + + $variant = $item->variants->sole(); + $this->assertNull($variant->event_date_id); + $this->assertSame([$firstDate->id, $secondDate->id], $variant->eventDates->pluck('id')->all()); + $this->assertSame( + [(string) $firstDate->id, (string) $secondDate->id], + $variant->selectionValues()->get('event_date'), + ); + } + + public function test_it_creates_multiple_values_for_any_multi_select_attribute(): void + { + $color = $this->createAttribute('color', FieldType::Select); + $color->options()->createMany([ + ['value' => 'Green', 'label' => 'Green', 'sort_order' => 1], + ['value' => 'White', 'label' => 'White', 'sort_order' => 2], + ]); + + $item = $this->service->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => 'multi-color-shirt', + 'nombre' => 'Multi-color shirt', + 'precio' => 100, + 'attribute_codes' => ['color'], + 'multi_select_attribute_codes' => ['color'], + 'variants' => [[ + 'real_stock' => 5, + 'values' => ['color' => ['White', 'Green']], + ]], + ]); + + $variant = $item->variants->sole(); + $this->assertSame(['White', 'Green'], $variant->definitions->pluck('value')->all()); + $this->assertSame(['White', 'Green'], $variant->selectionValues()->get('color')); + } + + public function test_it_rejects_duplicate_variant_combinations(): void + { + $sector = $this->createAttribute('sector'); + $eventDateAttribute = $this->createAttribute('event_date', FieldType::EventDate); + $eventDate = $this->tenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '09:00', + 'time_end' => '18:00', + ]); try { $this->service->create([ 'tenant_code' => $this->tenant->codigo, - 'slug' => 'invalid-dated-variant', - 'nombre' => 'Invalid dated variant', + 'slug' => 'duplicate-entry', + 'nombre' => 'Duplicate entry', 'precio' => 100, - 'minimum_use_date' => '2026-08-10 09:00:00', - 'maximum_use_date' => '2026-08-31 18:00:00', - 'attribute_codes' => [$attribute->codigo], + 'attribute_codes' => [$eventDateAttribute->codigo, $sector->codigo], 'variants' => [ - [ - 'real_stock' => 5, - 'maximum_use_date' => '2026-08-09 18:00:00', - 'values' => [$attribute->codigo => 'Saturday'], - ], + ['event_date_id' => $eventDate->id, 'values' => ['sector' => 'VIP']], + ['event_date_id' => $eventDate->id, 'values' => ['sector' => ' vip ']], ], ]); $this->fail('A validation exception was not thrown.'); } catch (ValidationException $exception) { - $this->assertArrayHasKey( - 'variants.0.maximum_use_date', - $exception->errors(), - ); + $this->assertArrayHasKey('variants.1', $exception->errors()); } - - $this->assertDatabaseMissing('catalog_items', [ - 'slug' => 'invalid-dated-variant', - ]); } public function test_it_rejects_direct_inventory_together_with_variants(): void @@ -283,13 +349,15 @@ class CatalogServiceTest extends TestCase ]); } - private function createAttribute(string $code): Attribute - { + private function createAttribute( + string $code, + FieldType $type = FieldType::String, + ): Attribute { return Attribute::query()->create([ 'tenant_codigo' => $this->tenant->codigo, 'codigo' => $code, 'nombre' => ucfirst($code), - 'type' => FieldType::String, + 'type' => $type, ]); } } diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index b30dcb0..cdf9a98 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -2,11 +2,13 @@ namespace Tests\Feature\Event; +use App\Domains\Attachable\Enums\AttachmentType; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; -use App\Domains\Event\Models\Event; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; +use App\Domains\Ticket\Enums\ValidityTimeType; use Database\Seeders\AuthorizationSeeder; use Database\Seeders\SocialMediaSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -44,27 +46,37 @@ class AdminAppEventControllerTest extends TestCase ->assertJsonPath('data.title', 'Festival Acme') ->assertJsonPath('data.location', 'Predio Ferial, Rosario') ->assertJsonPath('data.dates.0.date', '2026-10-09') + ->assertJsonPath('data.dates.0.validity_time.type', 'fixed_window') ->assertJsonPath('data.dates.0.start_time', '09:00') ->assertJsonPath('data.dates.0.end_time', '18:30') ->assertJsonPath('data.contact.whatsapp_url', 'https://wa.me/5493415550101') ->assertJsonPath('data.contact.instagram_url', 'https://instagram.com/acme') ->assertJsonPath('data.contact.facebook_url', null); - $eventId = $response->json('data.id'); - - $this->assertDatabaseHas('events', [ - 'id' => $eventId, - 'tenant_code' => $tenant->codigo, - 'name' => 'Festival Acme', - 'address' => 'Predio Ferial, Rosario', + $this->assertSame($tenant->id, $response->json('data.id')); + $this->assertDatabaseHas('tenants', [ + 'id' => $tenant->id, + 'event_title' => 'Festival Acme', + 'event_location' => 'Predio Ferial, Rosario', + 'event_date_text' => '9 de Octubre 2026', ]); - $this->assertSame($eventId, $tenant->fresh()->active_event_id); $this->assertDatabaseHas('event_dates', [ - 'event_id' => $eventId, + 'tenant_code' => $tenant->codigo, 'date' => '2026-10-09', 'time_start' => '09:00:00', 'time_end' => '18:30:00', ]); + $eventDate = $tenant->eventDates()->with('validityTime')->sole(); + $this->assertSame($eventDate->validity_time_id, $response->json('data.dates.0.validity_time_id')); + $this->assertSame(ValidityTimeType::FixedWindow, $eventDate->validityTime->type); + $this->assertSame( + '2026-10-09 09:00:00', + $eventDate->validityTime->fixed_starts_at->format('Y-m-d H:i:s'), + ); + $this->assertSame( + '2026-10-09 18:30:00', + $eventDate->validityTime->fixed_expires_at->format('Y-m-d H:i:s'), + ); $this->assertDatabaseHas('tenant_social_media', [ 'tenant_code' => $tenant->codigo, 'social_media_code' => 'whatsapp', @@ -86,28 +98,33 @@ class AdminAppEventControllerTest extends TestCase ->assertJsonMissing(['title' => 'Other Event']); } - public function test_reading_a_tenant_without_an_active_event_returns_not_found(): void + public function test_reading_a_tenant_without_event_configuration_returns_empty_values(): void { $tenant = $this->createTenant('acme'); Sanctum::actingAs($this->createAdminAppUser($tenant)); - $this->getJson('/api/v1/adminapp/tenant/event')->assertNotFound(); + $this->getJson('/api/v1/adminapp/tenant/event') + ->assertOk() + ->assertJsonPath('data.title', null) + ->assertJsonCount(0, 'data.dates'); } public function test_updating_reuses_the_active_event_and_synchronizes_dates_and_contact(): void { $tenant = $this->createTenant('acme'); - $event = $this->createActiveEvent($tenant, 'Old Event'); - $firstDate = $event->dates()->create([ + $eventTenant = $this->createActiveEvent($tenant, 'Old Event'); + $firstDate = $eventTenant->eventDates()->create([ 'date' => '2026-10-01', 'time_start' => '08:00', 'time_end' => '12:00', ]); - $removedDate = $event->dates()->create([ + $removedDate = $eventTenant->eventDates()->create([ 'date' => '2026-10-02', 'time_start' => '08:00', 'time_end' => '12:00', ]); + $firstValidityTimeId = $firstDate->validity_time_id; + $removedValidityTimeId = $removedDate->validity_time_id; $tenant->socialMedia()->attach('facebook', [ 'url' => 'https://facebook.com/old', 'orden' => 2, @@ -127,15 +144,24 @@ class AdminAppEventControllerTest extends TestCase $this->putJson('/api/v1/adminapp/tenant/event', $payload) ->assertOk() - ->assertJsonPath('data.id', $event->id) + ->assertJsonPath('data.id', $tenant->id) ->assertJsonPath('data.dates.0.id', $firstDate->id) ->assertJsonPath('data.contact.facebook_url', null); $this->assertDatabaseHas('event_dates', [ 'id' => $firstDate->id, + 'validity_time_id' => $firstValidityTimeId, 'date' => '2026-11-15', ]); + $this->assertDatabaseHas('validity_times', [ + 'id' => $firstValidityTimeId, + 'type' => ValidityTimeType::FixedWindow->value, + 'fixed_starts_at' => '2026-11-15 10:00:00', + 'fixed_expires_at' => '2026-11-15 20:00:00', + ]); $this->assertDatabaseMissing('event_dates', ['id' => $removedDate->id]); + $this->assertDatabaseMissing('validity_times', ['id' => $removedValidityTimeId]); + $this->assertSame('15 de Noviembre 2026', $tenant->fresh()->event_date_text); $this->assertDatabaseMissing('tenant_social_media', [ 'tenant_code' => $tenant->codigo, 'social_media_code' => 'facebook', @@ -147,6 +173,27 @@ class AdminAppEventControllerTest extends TestCase ]); } + public function test_updating_event_dates_recalculates_the_tenant_date_text(): void + { + $tenant = $this->createTenant('acme'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + $payload = $this->eventPayload(); + $payload['dates'] = collect([9, 10, 11, 12]) + ->map(fn (int $day): array => [ + 'date' => sprintf('2026-10-%02d', $day), + 'start_time' => '09:00', + 'end_time' => '18:30', + ]) + ->all(); + + $this->putJson('/api/v1/adminapp/tenant/event', $payload)->assertOk(); + + $this->assertSame( + '9, 10, 11 y 12 de Octubre 2026', + $tenant->fresh()->event_date_text + ); + } + public function test_update_validates_event_dates_and_contact_urls(): void { $tenant = $this->createTenant('acme'); @@ -175,7 +222,7 @@ class AdminAppEventControllerTest extends TestCase 'contact.whatsapp_url', ]); - $this->assertDatabaseCount('events', 0); + $this->assertNull($tenant->fresh()->event_title); } public function test_social_media_accepts_any_registered_code_and_rejects_unknown_codes(): void @@ -241,11 +288,32 @@ class AdminAppEventControllerTest extends TestCase private function createTenant(string $code): Tenant { + $headerLogo = $this->createAttachment("{$code}-header"); + $footerLogo = $this->createAttachment("{$code}-footer"); + return Tenant::query()->create([ 'codigo' => $code, 'nombre' => ucfirst($code), 'dominio' => "{$code}.test", 'website_type_code' => 'onticket', + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + } + + private function createAttachment(string $name): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$name}.png", + 'filename' => "{$name}.png", + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', ]); } @@ -257,14 +325,13 @@ class AdminAppEventControllerTest extends TestCase ]); } - private function createActiveEvent(Tenant $tenant, string $name): Event + private function createActiveEvent(Tenant $tenant, string $name): Tenant { - $event = $tenant->events()->create([ - 'name' => $name, - 'address' => 'Rosario', + $tenant->update([ + 'event_title' => $name, + 'event_location' => 'Rosario', ]); - $tenant->update(['active_event_id' => $event->id]); - return $event; + return $tenant->fresh(); } } diff --git a/tests/Feature/FiestaFutbolInfantil/AccommodationControllerTest.php b/tests/Feature/FiestaFutbolInfantil/AccommodationControllerTest.php new file mode 100644 index 0000000..e9ac69c --- /dev/null +++ b/tests/Feature/FiestaFutbolInfantil/AccommodationControllerTest.php @@ -0,0 +1,266 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create([ + 'codigo' => 'onticket', + 'nombre' => 'OnTicket', + ]); + } + + public function test_authentication_is_required(): void + { + $this->postJson('/api/v1/adminapp/tenant/accommodations', ['variants' => []]) + ->assertUnauthorized(); + } + + public function test_it_creates_accommodation_variants_and_adds_type_options(): void + { + [$tenant, $attribute] = $this->configuredTenant(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/accommodations', [ + 'variants' => [ + $this->variantPayload('Casa Rodante Familiar', 'Parcela grande', 25, 45000), + $this->variantPayload('Carpa', null, 100, 35000), + ], + ]) + ->assertOk() + ->assertJsonPath('data.name', 'Alojamiento') + ->assertJsonCount(2, 'data.variants') + ->assertJsonPath('data.variants.0.title', 'Casa Rodante Familiar') + ->assertJsonPath('data.variants.0.value', 'casa_rodante_familiar') + ->assertJsonPath('data.variants.0.description', 'Parcela grande') + ->assertJsonPath('data.variants.0.stock', 25) + ->assertJsonPath('data.variants.0.price', '45000.00'); + + $this->getJson('/api/v1/adminapp/tenant/accommodations') + ->assertOk() + ->assertJsonPath('data.name', 'Alojamiento') + ->assertJsonCount(2, 'data.variants') + ->assertJsonPath('data.variants.0.title', 'Casa Rodante Familiar') + ->assertJsonPath('data.variants.1.title', 'Carpa'); + + $this->assertDatabaseHas('attribute_options', [ + 'attribute_id' => $attribute->id, + 'value' => 'casa_rodante_familiar', + 'label' => 'Casa Rodante Familiar', + ]); + $this->assertDatabaseHas('attribute_options', [ + 'attribute_id' => $attribute->id, + 'value' => 'carpa', + 'label' => 'Carpa', + ]); + $this->assertDatabaseCount('catalog_items', 1); + $this->assertTrue(CatalogItem::query()->sole()->has_tickets); + $this->assertDatabaseCount('variantes', 2); + $this->assertDatabaseCount('inventories', 2); + $this->assertDatabaseCount('variant_values', 2); + } + + public function test_it_updates_variants_with_an_id_and_creates_variants_without_one(): void + { + [$tenant, $attribute] = $this->configuredTenant(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $created = $this->postJson('/api/v1/adminapp/tenant/accommodations', [ + 'variants' => [ + $this->variantPayload('Casa Rodante', 'Descripción inicial', 20, 40000), + ], + ])->assertOk(); + $variantId = $created->json('data.variants.0.id'); + + $updated = $this->variantPayload('Casa Rodante Premium', 'Con electricidad', 15, 50000); + $updated['id'] = $variantId; + + $this->postJson('/api/v1/adminapp/tenant/accommodations', [ + 'variants' => [ + $updated, + $this->variantPayload('Motor Home', null, 10, 60000), + ], + ]) + ->assertOk() + ->assertJsonCount(2, 'data.variants') + ->assertJsonPath('data.variants.0.id', $variantId) + ->assertJsonPath('data.variants.0.title', 'Casa Rodante Premium') + ->assertJsonPath('data.variants.0.value', 'casa_rodante_premium') + ->assertJsonPath('data.variants.0.stock', 15) + ->assertJsonPath('data.variants.1.value', 'motor_home'); + + $this->assertDatabaseMissing('attribute_options', [ + 'attribute_id' => $attribute->id, + 'value' => 'casa_rodante', + ]); + $this->assertDatabaseHas('attribute_options', [ + 'attribute_id' => $attribute->id, + 'value' => 'casa_rodante_premium', + 'label' => 'Casa Rodante Premium', + ]); + $this->assertDatabaseHas('variant_values', [ + 'variant_id' => $variantId, + 'value' => 'casa_rodante_premium', + ]); + $this->assertDatabaseCount('variantes', 2); + } + + public function test_it_deletes_an_accommodation_and_its_empty_product(): void + { + [$tenant] = $this->configuredTenant(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $accommodationId = $this->postJson('/api/v1/adminapp/tenant/accommodations', [ + 'variants' => [ + $this->variantPayload('Carpa', null, 100, 35000), + ], + ])->assertOk()->json('data.variants.0.id'); + + $this->deleteJson("/api/v1/adminapp/tenant/accommodations/{$accommodationId}") + ->assertNoContent(); + + $this->assertDatabaseCount('catalog_items', 0); + $this->assertDatabaseCount('variantes', 0); + $this->assertDatabaseCount('inventories', 0); + } + + public function test_it_rejects_duplicate_normalized_titles(): void + { + [$tenant] = $this->configuredTenant(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/accommodations', [ + 'variants' => [ + $this->variantPayload('Casa Rodante', null, 10, 40000), + $this->variantPayload(' CASA RODANTE ', null, 10, 40000), + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('variants.1.title'); + + $this->assertDatabaseCount('catalog_items', 0); + $this->assertDatabaseCount('attribute_options', 0); + } + + public function test_the_tenant_must_have_the_accommodations_menu(): void + { + [$tenant] = $this->configuredTenant(withMenu: false); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/accommodations', [ + 'variants' => [ + $this->variantPayload('Carpa', null, 10, 35000), + ], + ])->assertNotFound(); + + $this->assertDatabaseCount('catalog_items', 0); + } + + public function test_a_different_tenant_can_use_the_endpoint_when_it_has_the_menu(): void + { + [$tenant] = $this->configuredTenant('another_tenant'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/accommodations', [ + 'variants' => [ + $this->variantPayload('Dormitorio Compartido', null, 30, 25000), + ], + ]) + ->assertOk() + ->assertJsonPath('data.variants.0.value', 'dormitorio_compartido'); + } + + /** @return array{Tenant, Attribute} */ + private function configuredTenant( + string $tenantCode = 'fiesta_futbol_infantil', + bool $withMenu = true, + ): array { + $headerLogo = Attachment::query()->create([ + 'path' => 'test/header.png', + 'filename' => 'header.png', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + $footerLogo = Attachment::query()->create([ + 'path' => 'test/footer.png', + 'filename' => 'footer.png', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + $tenant = Tenant::query()->create([ + 'codigo' => $tenantCode, + 'nombre' => 'Fiesta Fútbol Infantil', + 'dominio' => 'fiesta.test', + 'primary_color' => '#112233', + 'secondary_color' => '#445566', + 'danger_color' => '#cc0000', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#111111', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + 'website_type_code' => 'onticket', + ]); + $attribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'tipo_alojamiento', + 'nombre' => 'TipoAlojamiento', + 'type' => FieldType::Select, + 'is_required' => true, + ]); + + if ($withMenu) { + $menu = Menu::query()->firstOrCreate( + ['code' => 'adminapp.fiesta-futbol-infantil.alojamientos'], + ['label' => 'Alojamientos', 'route' => '/admin/alojamientos'], + ); + $tenant->menues()->syncWithoutDetaching([$menu->code]); + } + + return [$tenant, $attribute]; + } + + /** @return array */ + private function variantPayload( + string $title, + ?string $description, + int $stock, + float $price, + ): array { + return [ + 'title' => $title, + 'description' => $description, + 'stock' => $stock, + 'price' => $price, + ]; + } + + private function createAdminAppUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } +} diff --git a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php new file mode 100644 index 0000000..9a02329 --- /dev/null +++ b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php @@ -0,0 +1,329 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create([ + 'codigo' => 'onticket', + 'nombre' => 'OnTicket', + ]); + } + + public function test_authentication_is_required(): void + { + $this->postJson('/api/v1/adminapp/tenant/entries', ['entries' => []]) + ->assertUnauthorized(); + } + + public function test_it_creates_multiple_entries_with_dates_and_tracked_inventory(): void + { + $tenant = $this->createFiestaTenant(); + $this->createEventDateAttribute($tenant); + $firstDate = $tenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $secondDate = $tenant->eventDates()->create([ + 'date' => '2026-10-10', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/entries', [ + 'entries' => [ + [ + 'title' => 'Abono', + 'description' => 'Acceso para ambas jornadas', + 'event_date_ids' => [$firstDate->id, $secondDate->id], + 'stock' => 1000, + 'price' => 10000, + ], + [ + 'title' => 'Entrada diaria', + 'description' => null, + 'event_date_ids' => [$firstDate->id], + 'stock' => 250, + 'price' => 5000.50, + ], + ], + ]) + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.title', 'Abono') + ->assertJsonPath('data.0.event_date_ids', [$firstDate->id, $secondDate->id]) + ->assertJsonPath('data.0.stock', 1000) + ->assertJsonPath('data.0.price', '10000.00') + ->assertJsonPath('data.1.title', 'Entrada diaria') + ->assertJsonPath('data.1.price', '5000.50'); + + $this->getJson('/api/v1/adminapp/tenant/entries') + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.title', 'Abono') + ->assertJsonPath('data.0.event_date_ids', [$firstDate->id, $secondDate->id]) + ->assertJsonPath('data.1.title', 'Entrada diaria'); + + $this->assertDatabaseCount('catalog_items', 2); + $this->assertDatabaseCount('variantes', 2); + $this->assertDatabaseCount('inventories', 2); + $this->assertDatabaseCount('variant_event_dates', 3); + + $entryCategory = Category::query() + ->where('tenant_code', $tenant->codigo) + ->where('nombre', 'Entradas') + ->sole(); + + CatalogItem::query()->each(function (CatalogItem $entry) use ($tenant, $entryCategory): void { + $this->assertSame($tenant->codigo, $entry->tenant_code); + $this->assertSame($entryCategory->id, $entry->category_id); + $this->assertSame('tracked', $entry->inventory_policy->value); + $this->assertTrue($entry->has_tickets); + $this->assertNull($entry->inventory_id); + }); + } + + public function test_it_updates_entries_with_an_id_and_creates_entries_without_one(): void + { + $tenant = $this->createFiestaTenant(); + $this->createEventDateAttribute($tenant); + $firstDate = $tenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $secondDate = $tenant->eventDates()->create([ + 'date' => '2026-10-10', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $createdId = $this->postJson('/api/v1/adminapp/tenant/entries', [ + 'entries' => [[ + 'title' => 'Abono original', + 'description' => null, + 'event_date_ids' => [$firstDate->id], + 'stock' => 10, + 'price' => 100, + ]], + ])->assertOk()->json('data.0.id'); + + $this->postJson('/api/v1/adminapp/tenant/entries', [ + 'entries' => [ + [ + 'id' => $createdId, + 'title' => 'Abono actualizado', + 'description' => 'Ahora incluye ambas fechas', + 'event_date_ids' => [$firstDate->id, $secondDate->id], + 'stock' => 20, + 'price' => 250, + ], + [ + 'title' => 'Entrada nueva', + 'description' => null, + 'event_date_ids' => [$secondDate->id], + 'stock' => 30, + 'price' => 300, + ], + ], + ]) + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.id', $createdId) + ->assertJsonPath('data.0.title', 'Abono actualizado') + ->assertJsonPath('data.0.event_date_ids', [$firstDate->id, $secondDate->id]) + ->assertJsonPath('data.0.stock', 20) + ->assertJsonPath('data.1.title', 'Entrada nueva'); + + $this->assertDatabaseCount('catalog_items', 2); + $this->assertDatabaseHas('catalog_items', [ + 'id' => $createdId, + 'nombre' => 'Abono actualizado', + 'precio' => 250, + ]); + } + + public function test_it_deletes_an_entry_and_its_inventory(): void + { + $tenant = $this->createFiestaTenant(); + $this->createEventDateAttribute($tenant); + $eventDate = $tenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $entryId = $this->postJson('/api/v1/adminapp/tenant/entries', [ + 'entries' => [[ + 'title' => 'Entrada diaria', + 'description' => null, + 'event_date_ids' => [$eventDate->id], + 'stock' => 100, + 'price' => 5000, + ]], + ])->assertOk()->json('data.0.id'); + + $this->deleteJson("/api/v1/adminapp/tenant/entries/{$entryId}") + ->assertNoContent(); + + $this->assertDatabaseCount('catalog_items', 0); + $this->assertDatabaseCount('variantes', 0); + $this->assertDatabaseCount('inventories', 0); + } + + public function test_dates_must_belong_to_the_authenticated_tenant(): void + { + $tenant = $this->createFiestaTenant(); + $otherTenant = $this->createTenant('other'); + $this->createEventDateAttribute($tenant); + $foreignDate = $otherTenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/entries', [ + 'entries' => [[ + 'title' => 'Entrada inválida', + 'description' => null, + 'event_date_ids' => [$foreignDate->id], + 'stock' => 10, + 'price' => 100, + ]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['entries.0.event_date_ids.0']); + + $this->assertDatabaseCount('catalog_items', 0); + } + + public function test_entries_are_identified_by_the_entries_category(): void + { + $tenant = $this->createFiestaTenant(); + $this->createEventDateAttribute($tenant); + $eventDate = $tenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $merchandisingCategory = Category::query()->create([ + 'tenant_code' => $tenant->codigo, + 'nombre' => 'Merchandising', + ]); + $merchandise = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'category_id' => $merchandisingCategory->id, + 'slug' => 'camiseta', + 'nombre' => 'Camiseta', + 'precio' => 100, + ]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/tenant/entries') + ->assertOk() + ->assertJsonCount(0, 'data'); + + $this->postJson('/api/v1/adminapp/tenant/entries', [ + 'entries' => [[ + 'id' => $merchandise->id, + 'title' => 'Entrada inválida', + 'description' => null, + 'event_date_ids' => [$eventDate->id], + 'stock' => 10, + 'price' => 100, + ]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['entries.0.id']); + } + + public function test_the_endpoint_is_only_available_for_fiesta_futbol_infantil(): void + { + $tenant = $this->createTenant('other'); + $this->createEventDateAttribute($tenant); + $eventDate = $tenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/entries', [ + 'entries' => [[ + 'title' => 'Entrada', + 'description' => null, + 'event_date_ids' => [$eventDate->id], + 'stock' => 10, + 'price' => 100, + ]], + ])->assertNotFound(); + } + + private function createFiestaTenant(): Tenant + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $menu = Menu::query()->create([ + 'code' => 'adminapp.fiesta-futbol-infantil.entradas', + 'label' => 'Entradas', + 'route' => '/admin/entradas', + ]); + $tenant->menues()->attach($menu->code); + + return $tenant; + } + + private function createTenant(string $code): Tenant + { + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'website_type_code' => 'onticket', + ]); + } + + private function createEventDateAttribute(Tenant $tenant): void + { + Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'event_date', + 'nombre' => 'Fecha', + 'type' => FieldType::EventDate, + 'is_required' => true, + ]); + } + + private function createAdminAppUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } +} diff --git a/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php b/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php new file mode 100644 index 0000000..581172f --- /dev/null +++ b/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php @@ -0,0 +1,279 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create([ + 'codigo' => 'onticket', + 'nombre' => 'OnTicket', + ]); + } + + public function test_authentication_is_required(): void + { + $this->postJson('/api/v1/adminapp/tenant/foods', ['variants' => []]) + ->assertUnauthorized(); + } + + public function test_it_creates_one_food_item_with_multiple_variants(): void + { + [$tenant, $firstDate, $secondDate] = $this->configuredTenant(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/foods', [ + 'variants' => [ + $this->variantPayload($firstDate->id, 'Almuerzo', 'Comedor', 1700, 10000), + $this->variantPayload($secondDate->id, 'Cena', 'Vianda', 900, 8000), + ], + ]) + ->assertOk() + ->assertJsonPath('data.name', 'Comida') + ->assertJsonCount(2, 'data.variants') + ->assertJsonPath('data.variants.0.schedule', 'Almuerzo') + ->assertJsonPath('data.variants.0.service', 'Comedor') + ->assertJsonPath('data.variants.0.stock', 1700) + ->assertJsonPath('data.variants.0.price', '10000.00') + ->assertJsonPath('data.variants.1.price', '8000.00'); + + $this->getJson('/api/v1/adminapp/tenant/foods') + ->assertOk() + ->assertJsonPath('data.name', 'Comida') + ->assertJsonCount(2, 'data.variants') + ->assertJsonPath('data.variants.0.schedule', 'Almuerzo') + ->assertJsonPath('data.variants.1.service', 'Vianda'); + + $this->assertDatabaseCount('catalog_items', 1); + $this->assertDatabaseCount('variantes', 2); + $this->assertDatabaseCount('inventories', 2); + $this->assertDatabaseCount('variant_event_dates', 2); + $this->assertDatabaseCount('variant_values', 4); + $food = CatalogItem::query()->where('slug', 'comida')->sole(); + $this->assertDatabaseHas('item_attributes', [ + 'catalog_item_id' => $food->id, + 'attribute_id' => Attribute::query()->where('codigo', 'event_date')->sole()->id, + 'sort_order' => 1, + ]); + $this->assertDatabaseHas('item_attributes', [ + 'catalog_item_id' => $food->id, + 'attribute_id' => Attribute::query()->where('codigo', 'horario')->sole()->id, + 'sort_order' => 2, + ]); + $this->assertDatabaseHas('item_attributes', [ + 'catalog_item_id' => $food->id, + 'attribute_id' => Attribute::query()->where('codigo', 'servicio')->sole()->id, + 'sort_order' => 3, + ]); + $this->assertDatabaseHas('catalog_items', [ + 'tenant_code' => $tenant->codigo, + 'slug' => 'comida', + 'nombre' => 'Comida', + 'has_tickets' => true, + 'category_id' => Category::query() + ->where('tenant_code', $tenant->codigo) + ->where('nombre', 'Comidas') + ->sole() + ->id, + 'precio' => 8000, + ]); + } + + public function test_it_updates_variants_with_an_id_and_creates_variants_without_one(): void + { + [$tenant, $firstDate, $secondDate] = $this->configuredTenant(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $created = $this->postJson('/api/v1/adminapp/tenant/foods', [ + 'variants' => [ + $this->variantPayload($firstDate->id, 'Almuerzo', 'Comedor', 100, 10000), + ], + ])->assertOk(); + $variantId = $created->json('data.variants.0.id'); + + $updated = $this->variantPayload($secondDate->id, 'Cena', 'Vianda', 80, 8500); + $updated['id'] = $variantId; + $updated['description'] = 'Cena para llevar'; + + $this->postJson('/api/v1/adminapp/tenant/foods', [ + 'variants' => [ + $updated, + $this->variantPayload($firstDate->id, 'Almuerzo', 'Vianda', 50, 9000), + ], + ]) + ->assertOk() + ->assertJsonCount(2, 'data.variants') + ->assertJsonPath('data.variants.0.id', $variantId) + ->assertJsonPath('data.variants.0.event_date_id', $secondDate->id) + ->assertJsonPath('data.variants.0.schedule', 'Cena') + ->assertJsonPath('data.variants.0.service', 'Vianda') + ->assertJsonPath('data.variants.0.description', 'Cena para llevar') + ->assertJsonPath('data.variants.0.stock', 80) + ->assertJsonPath('data.variants.0.price', '8500.00'); + + $this->assertDatabaseCount('catalog_items', 1); + $this->assertDatabaseCount('variantes', 2); + $this->assertDatabaseHas('variantes', [ + 'id' => $variantId, + 'event_date_id' => $secondDate->id, + 'descripcion' => 'Cena para llevar', + 'precio' => 8500, + ]); + } + + public function test_it_deletes_food_records_and_removes_the_empty_product(): void + { + [$tenant, $firstDate, $secondDate] = $this->configuredTenant(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $created = $this->postJson('/api/v1/adminapp/tenant/foods', [ + 'variants' => [ + $this->variantPayload($firstDate->id, 'Almuerzo', 'Comedor', 100, 10000), + $this->variantPayload($secondDate->id, 'Cena', 'Vianda', 50, 8000), + ], + ])->assertOk(); + $firstId = $created->json('data.variants.0.id'); + $secondId = $created->json('data.variants.1.id'); + + $this->deleteJson("/api/v1/adminapp/tenant/foods/{$secondId}") + ->assertNoContent(); + + $this->assertDatabaseHas('catalog_items', [ + 'tenant_code' => $tenant->codigo, + 'slug' => 'comida', + 'precio' => 10000, + ]); + $this->assertDatabaseCount('variantes', 1); + $this->assertDatabaseCount('inventories', 1); + + $this->deleteJson("/api/v1/adminapp/tenant/foods/{$firstId}") + ->assertNoContent(); + + $this->assertDatabaseCount('catalog_items', 0); + $this->assertDatabaseCount('variantes', 0); + $this->assertDatabaseCount('inventories', 0); + } + + public function test_it_rejects_duplicate_combinations(): void + { + [$tenant, $firstDate] = $this->configuredTenant(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/foods', [ + 'variants' => [ + $this->variantPayload($firstDate->id, 'Almuerzo', 'Comedor', 100, 10000), + $this->variantPayload($firstDate->id, ' almuerzo ', ' comedor ', 100, 10000), + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['variants.1']); + + $this->assertDatabaseCount('catalog_items', 0); + } + + /** @return array{Tenant, mixed, mixed} */ + private function configuredTenant(): array + { + $tenant = Tenant::query()->create([ + 'codigo' => 'fiesta_futbol_infantil', + 'nombre' => 'Fiesta Fútbol Infantil', + 'dominio' => 'fiesta.test', + 'website_type_code' => 'onticket', + ]); + $eventDateAttribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'event_date', + 'nombre' => 'Fecha', + 'type' => FieldType::EventDate, + 'is_required' => true, + ]); + $scheduleAttribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'horario', + 'nombre' => 'Horario', + 'type' => FieldType::Select, + 'is_required' => true, + ]); + $scheduleAttribute->options()->createMany([ + ['value' => 'Almuerzo', 'label' => 'Almuerzo', 'sort_order' => 1], + ['value' => 'Cena', 'label' => 'Cena', 'sort_order' => 2], + ]); + $serviceAttribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'servicio', + 'nombre' => 'Servicio', + 'type' => FieldType::Select, + 'is_required' => true, + ]); + $serviceAttribute->options()->createMany([ + ['value' => 'Comedor', 'label' => 'Comedor', 'sort_order' => 1], + ['value' => 'Vianda', 'label' => 'Vianda', 'sort_order' => 2], + ]); + $firstDate = $tenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $secondDate = $tenant->eventDates()->create([ + 'date' => '2026-10-10', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $menu = Menu::query()->create([ + 'code' => 'adminapp.fiesta-futbol-infantil.comida', + 'label' => 'Comida', + 'route' => '/admin/comidas', + ]); + $tenant->menues()->attach($menu->code); + + $this->assertNotNull($eventDateAttribute); + + return [$tenant, $firstDate, $secondDate]; + } + + /** @return array */ + private function variantPayload( + int $eventDateId, + string $schedule, + string $service, + int $stock, + float $price, + ): array { + return [ + 'event_date_id' => $eventDateId, + 'schedule' => $schedule, + 'service' => $service, + 'description' => null, + 'stock' => $stock, + 'price' => $price, + ]; + } + + private function createAdminAppUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } +} diff --git a/tests/Feature/FiestaFutbolInfantil/MerchandiseControllerTest.php b/tests/Feature/FiestaFutbolInfantil/MerchandiseControllerTest.php new file mode 100644 index 0000000..bab3de6 --- /dev/null +++ b/tests/Feature/FiestaFutbolInfantil/MerchandiseControllerTest.php @@ -0,0 +1,344 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create([ + 'codigo' => 'onticket', + 'nombre' => 'OnTicket', + ]); + } + + public function test_authentication_is_required(): void + { + $this->postJson('/api/v1/adminapp/tenant/merchandise', ['items' => []]) + ->assertUnauthorized(); + } + + public function test_it_creates_multiple_items_with_color_and_size_variants(): void + { + [$tenant, $colorAttribute] = $this->configuredTenant(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/merchandise', [ + 'items' => [ + $this->itemPayload('Camiseta', 3, [ + $this->variantPayload('Verde', 'S', 1700, 100000), + $this->variantPayload('Verde', 'M', 1600, 95000), + $this->variantPayload('Azul Marino', 'L', 500, 110000), + ]), + $this->itemPayload('Buzo', 2, [ + $this->variantPayload('Blanco', 'XL', 300, 120000), + ]), + ], + ]) + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.title', 'Camiseta') + ->assertJsonPath('data.0.max_units_per_user', 3) + ->assertJsonCount(3, 'data.0.variants') + ->assertJsonPath('data.0.variants.0.color', 'Verde') + ->assertJsonPath('data.0.variants.0.color_value', 'Verde') + ->assertJsonPath('data.0.variants.0.size', 'S') + ->assertJsonPath('data.0.variants.0.stock', 1700) + ->assertJsonPath('data.0.variants.1.price', '95000.00') + ->assertJsonPath('data.0.variants.2.color', 'Azul Marino') + ->assertJsonPath('data.0.variants.2.color_value', 'azul_marino'); + + $this->getJson('/api/v1/adminapp/tenant/merchandise') + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.title', 'Camiseta') + ->assertJsonCount(3, 'data.0.variants') + ->assertJsonPath('data.1.title', 'Buzo') + ->assertJsonPath('data.1.variants.0.size', 'XL'); + + $this->assertDatabaseCount('catalog_items', 2); + $this->assertDatabaseCount('variantes', 4); + $this->assertDatabaseCount('inventories', 4); + $this->assertDatabaseCount('item_attributes', 4); + $this->assertDatabaseCount('variant_values', 8); + $this->assertDatabaseHas('attribute_options', [ + 'attribute_id' => $colorAttribute->id, + 'value' => 'azul_marino', + 'label' => 'Azul Marino', + ]); + $this->assertDatabaseHas('catalog_items', [ + 'nombre' => 'Camiseta', + 'precio' => 95000, + 'max_units_per_user' => 3, + 'inventory_policy' => 'tracked', + 'has_tickets' => true, + ]); + } + + public function test_it_updates_items_and_variants_with_ids_and_creates_those_without_ids(): void + { + [$tenant] = $this->configuredTenant(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $created = $this->postJson('/api/v1/adminapp/tenant/merchandise', [ + 'items' => [ + $this->itemPayload('Camiseta', 3, [ + $this->variantPayload('Verde', 'S', 100, 10000), + ]), + ], + ])->assertOk(); + $itemId = $created->json('data.0.id'); + $variantId = $created->json('data.0.variants.0.id'); + + $updatedVariant = $this->variantPayload('Blanco', 'M', 80, 12500); + $updatedVariant['id'] = $variantId; + $updatedItem = $this->itemPayload('Camiseta oficial', 2, [ + $updatedVariant, + $this->variantPayload('Verde', 'L', 60, 15000), + ]); + $updatedItem['id'] = $itemId; + + $this->postJson('/api/v1/adminapp/tenant/merchandise', [ + 'items' => [$updatedItem], + ]) + ->assertOk() + ->assertJsonPath('data.0.id', $itemId) + ->assertJsonPath('data.0.title', 'Camiseta oficial') + ->assertJsonPath('data.0.max_units_per_user', 2) + ->assertJsonPath('data.0.variants.0.id', $variantId) + ->assertJsonPath('data.0.variants.0.color', 'Blanco') + ->assertJsonPath('data.0.variants.0.size', 'M') + ->assertJsonPath('data.0.variants.0.stock', 80) + ->assertJsonCount(2, 'data.0.variants'); + + $this->assertDatabaseCount('catalog_items', 1); + $this->assertDatabaseCount('variantes', 2); + $this->assertDatabaseHas('catalog_items', [ + 'id' => $itemId, + 'nombre' => 'Camiseta oficial', + 'precio' => 12500, + 'max_units_per_user' => 2, + ]); + } + + public function test_it_deletes_merchandise_records_and_removes_the_empty_item(): void + { + [$tenant] = $this->configuredTenant(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $created = $this->postJson('/api/v1/adminapp/tenant/merchandise', [ + 'items' => [ + $this->itemPayload('Camiseta', 3, [ + $this->variantPayload('Verde', 'S', 100, 10000), + $this->variantPayload('Blanco', 'M', 50, 15000), + ]), + ], + ])->assertOk(); + $itemId = $created->json('data.0.id'); + $firstId = $created->json('data.0.variants.0.id'); + $secondId = $created->json('data.0.variants.1.id'); + + $this->deleteJson("/api/v1/adminapp/tenant/merchandise/{$firstId}") + ->assertNoContent(); + + $this->assertDatabaseHas('catalog_items', [ + 'id' => $itemId, + 'precio' => 15000, + ]); + $this->assertDatabaseCount('variantes', 1); + + $this->deleteJson("/api/v1/adminapp/tenant/merchandise/{$secondId}") + ->assertNoContent(); + + $this->assertDatabaseMissing('catalog_items', ['id' => $itemId]); + $this->assertDatabaseCount('variantes', 0); + $this->assertDatabaseCount('inventories', 0); + } + + public function test_it_rejects_duplicate_color_and_size_combinations(): void + { + [$tenant] = $this->configuredTenant(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/merchandise', [ + 'items' => [ + $this->itemPayload('Camiseta', 3, [ + $this->variantPayload('Verde', 'S', 100, 10000), + $this->variantPayload(' verde ', 's', 50, 12000), + ]), + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('items.0.variants.1'); + + $this->assertDatabaseCount('catalog_items', 0); + } + + public function test_it_rejects_sizes_that_are_not_attribute_options(): void + { + [$tenant] = $this->configuredTenant(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/merchandise', [ + 'items' => [ + $this->itemPayload('Gorra', 2, [ + $this->variantPayload('Verde', 'Único', 100, 10000), + ]), + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('items.0.variants.0.size'); + + $this->assertDatabaseCount('catalog_items', 0); + } + + public function test_a_variant_id_must_belong_to_its_merchandise_item(): void + { + [$tenant] = $this->configuredTenant(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $created = $this->postJson('/api/v1/adminapp/tenant/merchandise', [ + 'items' => [ + $this->itemPayload('Camiseta', 3, [ + $this->variantPayload('Verde', 'S', 100, 10000), + ]), + $this->itemPayload('Buzo', 2, [ + $this->variantPayload('Blanco', 'M', 50, 20000), + ]), + ], + ])->assertOk(); + + $firstItemId = $created->json('data.0.id'); + $secondItemVariantId = $created->json('data.1.variants.0.id'); + $foreignVariant = $this->variantPayload('Azul Marino', 'XL', 20, 30000); + $foreignVariant['id'] = $secondItemVariantId; + $firstItem = $this->itemPayload('Camiseta', 3, [$foreignVariant]); + $firstItem['id'] = $firstItemId; + + $this->postJson('/api/v1/adminapp/tenant/merchandise', [ + 'items' => [$firstItem], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('items.0.variants.0.id'); + + $this->assertDatabaseMissing('attribute_options', ['value' => 'azul_marino']); + } + + /** @return array{Tenant, Attribute, Attribute} */ + private function configuredTenant(): array + { + $headerLogo = $this->attachment('header.png'); + $footerLogo = $this->attachment('footer.png'); + $tenant = Tenant::query()->create([ + 'codigo' => 'fiesta_futbol_infantil', + 'nombre' => 'Fiesta Fútbol Infantil', + 'dominio' => 'fiesta.test', + 'primary_color' => '#112233', + 'secondary_color' => '#445566', + 'danger_color' => '#cc0000', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#111111', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + 'website_type_code' => 'onticket', + ]); + $colorAttribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'color', + 'nombre' => 'Color', + 'type' => FieldType::Select, + 'is_required' => true, + ]); + $colorAttribute->options()->createMany([ + ['value' => 'Verde', 'label' => 'Verde', 'sort_order' => 1], + ['value' => 'Blanco', 'label' => 'Blanco', 'sort_order' => 2], + ]); + $sizeAttribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'talle', + 'nombre' => 'Talle', + 'type' => FieldType::Select, + 'is_required' => true, + ]); + $sizeAttribute->options()->createMany([ + ['value' => 'S', 'label' => 'S', 'sort_order' => 1], + ['value' => 'M', 'label' => 'M', 'sort_order' => 2], + ['value' => 'L', 'label' => 'L', 'sort_order' => 3], + ['value' => 'XL', 'label' => 'XL', 'sort_order' => 4], + ]); + $menu = Menu::query()->create([ + 'code' => 'adminapp.fiesta-futbol-infantil.merchandising', + 'label' => 'Merchandising', + 'route' => '/admin/merchandising', + ]); + $tenant->menues()->attach($menu->code); + + return [$tenant, $colorAttribute, $sizeAttribute]; + } + + private function attachment(string $filename): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$filename}", + 'filename' => $filename, + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } + + /** + * @param array> $variants + * @return array + */ + private function itemPayload(string $title, int $limit, array $variants): array + { + return [ + 'title' => $title, + 'description' => null, + 'max_units_per_user' => $limit, + 'variants' => $variants, + ]; + } + + /** @return array */ + private function variantPayload( + string $color, + string $size, + int $stock, + float $price, + ): array { + return [ + 'color' => $color, + 'size' => $size, + 'stock' => $stock, + 'price' => $price, + ]; + } + + private function createAdminAppUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } +} diff --git a/tests/Feature/Forms/AdminAppFoodFormControllerTest.php b/tests/Feature/Forms/AdminAppFoodFormControllerTest.php new file mode 100644 index 0000000..f3e9db3 --- /dev/null +++ b/tests/Feature/Forms/AdminAppFoodFormControllerTest.php @@ -0,0 +1,82 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/food') + ->assertUnauthorized(); + } + + public function test_it_returns_event_dates_schedules_and_services_for_the_tenant(): void + { + $tenant = Tenant::query()->create([ + 'codigo' => 'fiesta', + 'nombre' => 'Fiesta', + 'dominio' => 'fiesta.test', + 'website_type_code' => 'onticket', + ]); + $eventDate = $tenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $this->createAttribute($tenant, 'horario', [ + ['value' => 'Cena', 'label' => 'Cena', 'sort_order' => 2], + ['value' => 'Almuerzo', 'label' => 'Almuerzo', 'sort_order' => 1], + ]); + $this->createAttribute($tenant, 'servicio', [ + ['value' => 'Comedor', 'label' => 'Comedor', 'sort_order' => 1], + ]); + + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ])); + + $this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/food') + ->assertOk() + ->assertJsonCount(1, 'data.event_dates') + ->assertJsonPath('data.event_dates.0.date', '2026-10-09') + ->assertJsonPath('data.event_dates.0.validity_time_id', $eventDate->validity_time_id) + ->assertJsonPath('data.event_dates.0.validity_time.type', 'fixed_window') + ->assertJsonPath('data.schedules.0.value', 'Almuerzo') + ->assertJsonPath('data.schedules.1.value', 'Cena') + ->assertJsonPath('data.services.0.value', 'Comedor'); + } + + /** @param array $options */ + private function createAttribute(Tenant $tenant, string $code, array $options): void + { + $attribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'type' => FieldType::Select, + 'is_required' => true, + ]); + $attribute->options()->createMany($options); + } +} diff --git a/tests/Feature/Forms/AdminAppMerchandiseFormControllerTest.php b/tests/Feature/Forms/AdminAppMerchandiseFormControllerTest.php new file mode 100644 index 0000000..cc37123 --- /dev/null +++ b/tests/Feature/Forms/AdminAppMerchandiseFormControllerTest.php @@ -0,0 +1,102 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create([ + 'codigo' => 'onticket', + 'nombre' => 'OnTicket', + ]); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/merchandise') + ->assertUnauthorized(); + } + + public function test_it_returns_color_and_size_options_for_the_authenticated_tenant(): void + { + $tenant = $this->createTenant('fiesta'); + $otherTenant = $this->createTenant('other'); + $this->createAttribute($tenant, 'color', [ + ['value' => 'verde', 'label' => 'Verde', 'sort_order' => 2], + ['value' => 'blanco', 'label' => 'Blanco', 'sort_order' => 1], + ]); + $this->createAttribute($tenant, 'talle', [ + ['value' => 'S', 'label' => 'S', 'sort_order' => 1], + ['value' => 'M', 'label' => 'M', 'sort_order' => 2], + ]); + $this->createAttribute($otherTenant, 'color', [ + ['value' => 'negro', 'label' => 'Negro', 'sort_order' => 1], + ]); + + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ])); + + $this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/merchandise') + ->assertOk() + ->assertJsonCount(2, 'data.colors') + ->assertJsonCount(2, 'data.sizes') + ->assertJsonPath('data.colors.0.value', 'blanco') + ->assertJsonPath('data.colors.0.label', 'Blanco') + ->assertJsonPath('data.colors.1.value', 'verde') + ->assertJsonPath('data.sizes.0.value', 'S') + ->assertJsonPath('data.sizes.1.value', 'M') + ->assertJsonMissing(['value' => 'negro']); + } + + public function test_a_customer_cannot_get_the_merchandise_form(): void + { + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::User->value, + ])); + + $this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/merchandise') + ->assertForbidden(); + } + + private function createTenant(string $code): Tenant + { + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'website_type_code' => 'onticket', + ]); + } + + /** @param array $options */ + private function createAttribute(Tenant $tenant, string $code, array $options): void + { + $attribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'type' => FieldType::Select, + 'is_required' => true, + ]); + $attribute->options()->createMany($options); + } +} diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index ba3bcb5..7fd1162 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -259,7 +259,7 @@ class TelepagosWebhookTest extends TestCase ]); } - public function test_webhook_confirms_a_purchase_when_its_ticket_use_date_has_ended(): void + public function test_webhook_confirms_a_purchase_with_tickets_enabled(): void { $tenant = $this->createTenant('expired-ticket', 'Expired Ticket', 'expired-ticket.com.ar'); $this->configureTelepagosIntegration($tenant); @@ -267,7 +267,6 @@ class TelepagosWebhookTest extends TestCase $variant = $this->createVariantForTenant('expired-ticket', 1, '50.00'); $variant->catalogItem->update([ 'has_tickets' => true, - 'maximum_use_date' => now()->subMinute(), ]); $purchase = $this->createPendingTransferPurchase( $tenant, diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index ab003c7..6cbc361 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -11,7 +11,6 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; -use App\Domains\Event\Models\Event; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Tenant\Models\Tenant; @@ -34,12 +33,10 @@ class StorePurchaseTest extends TestCase public function test_it_creates_an_independent_purchase_snapshot_from_cart(): void { $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); - $event = Event::query()->create([ - 'tenant_code' => $tenant->codigo, - 'name' => 'Sonder Fest', - 'address' => 'Test address', + $tenant->update([ + 'event_title' => 'Sonder Fest', + 'event_location' => 'Test address', ]); - $tenant->update(['active_event_id' => $event->id]); $user = User::factory()->create([ 'email' => 'buyer@example.com', ]); @@ -92,7 +89,6 @@ class StorePurchaseTest extends TestCase $response->assertJsonPath('data.nombre_apellido', null); $response->assertJsonPath('data.email', null); $response->assertJsonPath('data.tenant_codigo', 'sonder'); - $response->assertJsonPath('data.event_id', $event->id); $response->assertJsonPath('data.status', Purchase::STATUS_CREATED); $response->assertJsonPath('data.items_source', 'purchase'); $response->assertJsonCount(1, 'data.items'); @@ -105,7 +101,6 @@ class StorePurchaseTest extends TestCase 'id' => $purchaseId, 'cart_id' => $cartId, 'tenant_codigo' => 'sonder', - 'event_id' => $event->id, 'user_id' => $user->id, 'dni' => null, 'telefono' => null, @@ -190,6 +185,98 @@ class StorePurchaseTest extends TestCase ]); } + public function test_it_enforces_the_user_purchase_limit_and_releases_it_after_cancellation(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 20, '50.00'); + $variant->catalogItem->update(['max_units_per_user' => 3]); + + $firstPurchaseId = $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras/start-checkout', [ + 'direct_item' => [ + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, + 'cantidad' => 2, + ], + ]) + ->assertCreated() + ->json('data.id'); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras/start-checkout', [ + 'direct_item' => [ + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, + 'cantidad' => 2, + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('direct_item.cantidad'); + + $this->actingAs($otherUser, 'sanctum') + ->postJson('/api/tenants/sonder/compras/start-checkout', [ + 'direct_item' => [ + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, + 'cantidad' => 3, + ], + ]) + ->assertCreated(); + + $this->actingAs($user, 'sanctum') + ->postJson("/api/tenants/sonder/compras/{$firstPurchaseId}/cancel") + ->assertOk(); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras/start-checkout', [ + 'direct_item' => [ + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, + 'cantidad' => 3, + ], + ]) + ->assertCreated(); + } + + public function test_the_user_purchase_limit_is_shared_by_all_item_variants(): void + { + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $firstVariant = $this->createVariantForTenant('sonder', 20, '50.00'); + $firstVariant->catalogItem->update(['max_units_per_user' => 3]); + $secondInventory = Inventory::query()->create(['real_stock' => 20]); + $secondVariant = Variant::query()->create([ + 'catalog_item_id' => $firstVariant->catalog_item_id, + 'inventory_id' => $secondInventory->id, + ]); + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $user->id, + 'status' => 'active', + ]); + $cart->addItem($firstVariant->catalog_item_id, $firstVariant->id, 2); + $cart->addItem($secondVariant->catalog_item_id, $secondVariant->id, 2); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras/start-checkout', [ + 'cart_id' => $cart->id, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('cart_id'); + + $this->assertDatabaseCount('compras', 0); + $this->assertDatabaseHas('inventories', [ + 'id' => $firstVariant->inventory_id, + 'reserved_stock' => 2, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $secondInventory->id, + 'reserved_stock' => 2, + ]); + } + public function test_it_restores_the_source_cart_when_checkout_is_cancelled(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); @@ -382,6 +469,32 @@ class StorePurchaseTest extends TestCase ]); } + public function test_it_rejects_a_quantity_update_above_the_user_purchase_limit(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $variant->catalogItem->update(['max_units_per_user' => 3]); + $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); + $itemId = $purchase->items->firstOrFail()->id; + + $this->actingAs($user, 'sanctum') + ->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [ + 'quantity' => 4, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('quantity'); + + $this->assertDatabaseHas('compra_items', [ + 'id' => $itemId, + 'cantidad' => 2, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'reserved_stock' => 2, + ]); + } + public function test_it_reopens_a_pending_purchase_before_editing_items(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); diff --git a/tests/Feature/Sale/AdminAppSaleControllerTest.php b/tests/Feature/Sale/AdminAppSaleControllerTest.php new file mode 100644 index 0000000..bf22af0 --- /dev/null +++ b/tests/Feature/Sale/AdminAppSaleControllerTest.php @@ -0,0 +1,265 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); + } + + public function test_authentication_is_required_to_read_a_sale_detail(): void + { + $this->getJson('/api/v1/adminapp/tenant/sales/1')->assertUnauthorized(); + } + + public function test_an_adminapp_user_can_read_a_sale_detail_from_its_tenant(): void + { + $tenant = $this->createTenant('acme'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'status' => Purchase::STATUS_PAID, + 'total' => '40000.00', + 'nombre_apellido' => 'Ada Lovelace', + ]); + PurchaseItem::query()->create([ + 'compra_id' => $purchase->id, + 'source_catalog_item_id' => 10, + 'nombre' => 'Comida', + 'item_nombre' => 'Comida', + 'variant_attributes' => [ + ['name' => 'Fecha', 'value' => ['2026-10-12']], + ['name' => 'Servicio', 'value' => 'Almuerzo'], + ], + 'cantidad' => 2, + 'precio_unitario' => '10000.00', + 'total' => '20000.00', + ]); + + $this->getJson("/api/v1/adminapp/tenant/sales/{$purchase->id}") + ->assertOk() + ->assertJsonPath('data.id', $purchase->id) + ->assertJsonPath('data.items.0.product', 'Comida') + ->assertJsonPath('data.items.0.event_dates.0', '2026-10-12') + ->assertJsonPath('data.items.0.quantity', 2) + ->assertJsonPath('data.items.0.unit_price', '10000.00') + ->assertJsonPath('data.items.0.total', '20000.00') + ->assertJsonPath('data.total', '40000.00'); + } + + public function test_an_adminapp_user_cannot_read_a_sale_from_another_tenant(): void + { + $tenant = $this->createTenant('acme'); + $otherTenant = $this->createTenant('other'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $foreignPurchase = Purchase::query()->create([ + 'tenant_codigo' => $otherTenant->codigo, + 'status' => Purchase::STATUS_PAID, + 'total' => '10000.00', + ]); + + $this->getJson("/api/v1/adminapp/tenant/sales/{$foreignPurchase->id}") + ->assertNotFound(); + } + + public function test_an_adminapp_user_can_read_all_tickets_from_a_sale_in_its_tenant(): void + { + $tenant = $this->createTenant('acme'); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); + + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'status' => Purchase::STATUS_PAID, + 'total' => '20000.00', + ]); + $firstTicket = Ticket::query()->create([ + 'tenant_code' => $tenant->codigo, + 'ticket' => '11111111-1111-4111-8111-111111111111', + 'name' => 'Abono general', + 'description' => 'Acceso general', + 'source_purchase_id' => $purchase->id, + 'user_id' => $admin->id, + ]); + $usedTicket = Ticket::query()->create([ + 'tenant_code' => $tenant->codigo, + 'ticket' => '22222222-2222-4222-8222-222222222222', + 'name' => 'Abono general', + 'description' => 'Acceso general', + 'source_purchase_id' => $purchase->id, + 'used_at' => now()->subMinute(), + 'user_id' => $admin->id, + ]); + + $this->getJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/tickets") + ->assertOk() + ->assertExactJson([ + 'data' => [ + [ + 'product' => 'Abono general', + 'id' => $firstTicket->id, + 'expires_at' => null, + 'status' => Ticket::STATUS_ACTIVE, + ], + [ + 'product' => 'Abono general', + 'id' => $usedTicket->id, + 'expires_at' => null, + 'status' => Ticket::STATUS_USED, + ], + ], + ]); + } + + public function test_an_adminapp_user_cannot_read_tickets_from_another_tenant_sale(): void + { + $tenant = $this->createTenant('acme'); + $otherTenant = $this->createTenant('other'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $foreignPurchase = Purchase::query()->create([ + 'tenant_codigo' => $otherTenant->codigo, + 'status' => Purchase::STATUS_PAID, + 'total' => '10000.00', + ]); + + $this->getJson("/api/v1/adminapp/tenant/sales/{$foreignPurchase->id}/tickets") + ->assertNotFound(); + } + + public function test_an_adminapp_user_can_confirm_a_pending_sale(): void + { + Event::fake([PurchasePaid::class]); + $tenant = $this->createTenant('acme'); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); + + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'status' => Purchase::STATUS_PENDING_PAYMENT, + 'total' => '10000.00', + ]); + + $this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/confirm") + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_PAID); + + $this->assertSame(Purchase::STATUS_PAID, $purchase->fresh()->status); + $this->assertDatabaseHas('value_changes', [ + 'trackable_id' => $purchase->id, + 'attribute' => 'status', + 'old_value' => Purchase::STATUS_PENDING_PAYMENT, + 'new_value' => Purchase::STATUS_PAID, + 'user_id' => $admin->id, + ]); + Event::assertDispatchedTimes(PurchasePaid::class, 1); + + $this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/confirm") + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_PAID); + Event::assertDispatchedTimes(PurchasePaid::class, 1); + } + + public function test_adminapp_cancellation_does_not_restore_the_source_cart(): void + { + $tenant = $this->createTenant('acme'); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); + + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $admin->id, + 'status' => 'converted', + ]); + $cart->delete(); + $purchase = Purchase::query()->create([ + 'cart_id' => $cart->id, + 'tenant_codigo' => $tenant->codigo, + 'status' => Purchase::STATUS_PENDING_PAYMENT, + 'total' => '10000.00', + ]); + + $this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/cancel") + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_CANCELLED); + + $this->assertSame(Purchase::STATUS_CANCELLED, $purchase->fresh()->status); + $this->assertSoftDeleted('carritos', ['id' => $cart->id]); + $this->assertSame('converted', Cart::withTrashed()->findOrFail($cart->id)->status); + } + + public function test_an_adminapp_user_cannot_change_a_sale_from_another_tenant(): void + { + $tenant = $this->createTenant('acme'); + $otherTenant = $this->createTenant('other'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $foreignPurchase = Purchase::query()->create([ + 'tenant_codigo' => $otherTenant->codigo, + 'status' => Purchase::STATUS_PENDING_PAYMENT, + 'total' => '10000.00', + ]); + + $this->postJson("/api/v1/adminapp/tenant/sales/{$foreignPurchase->id}/confirm") + ->assertNotFound(); + $this->postJson("/api/v1/adminapp/tenant/sales/{$foreignPurchase->id}/cancel") + ->assertNotFound(); + } + + public function test_a_paid_sale_cannot_be_cancelled_from_adminapp(): void + { + $tenant = $this->createTenant('acme'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'status' => Purchase::STATUS_PAID, + 'total' => '10000.00', + ]); + + $this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/cancel") + ->assertUnprocessable() + ->assertJsonValidationErrors('purchase'); + } + + private function createTenant(string $code): Tenant + { + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'website_type_code' => 'onticket', + ]); + } + + private function createAdminAppUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } +} diff --git a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php index edf87ce..0a5ceee 100644 --- a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php +++ b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php @@ -4,8 +4,6 @@ namespace Tests\Feature\Seeders; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; -use App\Domains\Catalog\Enums\CatalogItemType; -use App\Domains\Catalog\Enums\EventProductType; use App\Domains\Catalog\Enums\FeaturedGroupSource; use App\Domains\Catalog\Enums\GroupLayout; use App\Domains\Catalog\Enums\ProductLayout; @@ -13,10 +11,8 @@ use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\FeaturedGroup; -use App\Domains\Catalog\Models\Inventory; -use App\Domains\Event\Models\Event; -use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\TicketGenerationPolicy; use Database\Seeders\AttributeSeeder; use Database\Seeders\FiestaFutbolInfantilProductSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -26,21 +22,10 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase { use RefreshDatabase; - public function test_it_seeds_event_items_for_the_new_catalog(): void + public function test_it_seeds_the_configurable_catalog_with_its_categories(): void { - $headerLogo = Attachment::query()->create([ - 'path' => 'tests/header.png', - 'filename' => 'header.png', - 'type' => AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - $footerLogo = Attachment::query()->create([ - 'path' => 'tests/footer.png', - 'filename' => 'footer.png', - 'type' => AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - + $headerLogo = $this->attachment('header'); + $footerLogo = $this->attachment('footer'); $tenant = Tenant::query()->create([ 'codigo' => 'fiesta_futbol_infantil', 'nombre' => 'Fiesta Fútbol Infantil', @@ -55,127 +40,17 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase 'footer_logo_id' => $footerLogo->id, ]); - $this->seed([ - AttributeSeeder::class, - FiestaFutbolInfantilProductSeeder::class, - ]); - $this->seed([ - AttributeSeeder::class, - FiestaFutbolInfantilProductSeeder::class, - ]); + $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); + $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); - $this->assertFalse(Attribute::query() - ->where('tenant_codigo', $tenant->codigo) - ->exists()); + $this->assertSame('9, 10, 11 y 12 de Octubre 2026', $tenant->fresh()->event_date_text); - $event = Event::query() - ->where('tenant_code', $tenant->codigo) - ->with('dates') - ->sole(); - - $this->assertSame($event->id, $tenant->fresh()->active_event_id); - $this->assertCount(4, $event->dates); - $this->assertSame(1, Event::query()->where('tenant_code', $tenant->codigo)->count()); - $this->assertSame(4, EventDate::query()->where('event_id', $event->id)->count()); - - $generalAdmission = CatalogItem::query() - ->where('tenant_code', $tenant->codigo) - ->where('slug', 'entrada-general') - ->with('variants.eventDate', 'itemAttributes') - ->sole(); - - $this->assertNull($generalAdmission->inventory_id); - $this->assertTrue($generalAdmission->event->is($event)); - $this->assertSame(EventProductType::Entry, $generalAdmission->event_product_type); - $this->assertCount(0, $generalAdmission->itemAttributes); - $this->assertCount(4, $generalAdmission->variants); - $this->assertEqualsCanonicalizing( - ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'], - $generalAdmission->variants - ->map(fn ($variant) => $variant->eventDate->date->format('Y-m-d')) - ->all() + $this->assertSame( + ['color', 'event_date', 'horario', 'servicio', 'talle', 'tipo_alojamiento'], + Attribute::query()->where('tenant_codigo', $tenant->codigo)->orderBy('codigo')->pluck('codigo')->all(), ); $this->assertSame( - [ - ['2026-10-09 00:00:00', '2026-10-09 23:59:59'], - ['2026-10-10 00:00:00', '2026-10-10 23:59:59'], - ['2026-10-11 00:00:00', '2026-10-11 23:59:59'], - ['2026-10-12 00:00:00', '2026-10-12 23:59:59'], - ], - $generalAdmission->variants - ->sortBy(fn ($variant) => $variant->eventDate->date) - ->map(fn ($variant): array => [ - $variant->getMinimumUseDate()->format('Y-m-d H:i:s'), - $variant->getMaximumUseDate()->format('Y-m-d H:i:s'), - ]) - ->values() - ->all() - ); - - $standardItems = CatalogItem::query() - ->where('tenant_code', $tenant->codigo) - ->where('type', CatalogItemType::Standard->value) - ->get(); - - $this->assertCount(7, $standardItems); - foreach ($standardItems as $standardItem) { - $this->assertSame($event->id, $standardItem->event_id); - $this->assertSame( - '2026-10-09 00:00:00', - $standardItem->minimum_use_date->format('Y-m-d H:i:s'), - ); - $this->assertSame( - '2026-10-12 23:59:59', - $standardItem->maximum_use_date->format('Y-m-d H:i:s'), - ); - } - - $allDaysItem = CatalogItem::query() - ->where('tenant_code', $tenant->codigo) - ->where('nombre', 'Entrada General - Todos los días') - ->with('bundleComponents.variant.eventDate') - ->sole(); - - $this->assertSame(CatalogItemType::Bundle, $allDaysItem->type); - $this->assertSame('40000.00', $allDaysItem->precio); - $this->assertNull($allDaysItem->inventory_id); - $this->assertFalse($allDaysItem->has_tickets); - $this->assertSame($event->id, $allDaysItem->event_id); - $this->assertSame(EventProductType::Entry, $allDaysItem->event_product_type); - $this->assertCount(4, $allDaysItem->bundleComponents); - $this->assertEqualsCanonicalizing( - ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'], - $allDaysItem->bundleComponents - ->map(fn ($component) => $component->variant->eventDate->date->format('Y-m-d')) - ->all() - ); - - $foodCombo = CatalogItem::query() - ->where('tenant_code', $tenant->codigo) - ->where('nombre', 'Combo 2 Panchos + 2 Hamburguesas') - ->with('bundleComponents.catalogItem') - ->sole(); - - $this->assertSame(CatalogItemType::Bundle, $foodCombo->type); - $this->assertSame('24000.00', $foodCombo->precio); - $this->assertNull($foodCombo->inventory_id); - $this->assertSame(EventProductType::Product, $foodCombo->event_product_type); - $this->assertSame( - [ - 'hamburguesa-papa-frita' => 2, - 'pancho' => 2, - ], - $foodCombo->bundleComponents - ->mapWithKeys(fn ($component): array => [ - $component->catalogItem->slug => $component->quantity, - ]) - ->sortKeys() - ->all() - ); - $this->assertSame(9, CatalogItem::query()->where('tenant_code', $tenant->codigo)->count()); - $this->assertSame(10, Inventory::query()->count()); - $this->assertSame( - ['Bebidas', 'Comidas', 'Entradas', 'Estacionamiento', 'Gastronomía'], + ['Alojamientos', 'Comidas', 'Entradas', 'Merchandising'], Category::query() ->where('tenant_code', $tenant->codigo) ->orderBy('nombre') @@ -183,53 +58,100 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase ->all(), ); $this->assertSame( - 'Estacionamiento', + ['abono', 'alojamiento', 'camiseta', 'comida'], + CatalogItem::query()->where('tenant_code', $tenant->codigo)->orderBy('slug')->pluck('slug')->all(), + ); + $this->assertTrue( CatalogItem::query() ->where('tenant_code', $tenant->codigo) - ->where('slug', 'estacionamiento-auto') - ->with('category') - ->sole() - ->category - ->nombre, + ->get() + ->every(fn (CatalogItem $item): bool => $item->has_tickets), + ); + $this->assertTrue( + CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->get() + ->every(fn (CatalogItem $item): bool => $item->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit), ); - $featuredGroups = FeaturedGroup::query() + $expectedVariantCounts = [ + 'camiseta' => 12, + 'alojamiento' => 2, + 'comida' => 24, + 'abono' => 1, + ]; + foreach ($expectedVariantCounts as $slug => $count) { + $item = CatalogItem::query()->where('tenant_code', $tenant->codigo)->where('slug', $slug)->sole(); + $this->assertCount($count, $item->variants); + } + + $variantStocks = CatalogItem::query() ->where('tenant_code', $tenant->codigo) - ->with('category.catalogItems') - ->orderBy('group_order') - ->get(); + ->with('variants.inventory') + ->get() + ->flatMap(fn (CatalogItem $item) => $item->variants) + ->map(fn ($variant): int => $variant->inventory->real_stock); + $this->assertGreaterThan($variantStocks->count() / 2, $variantStocks->filter(fn (int $stock): bool => $stock > 5)->count()); + $this->assertNotEmpty($variantStocks->filter(fn (int $stock): bool => $stock === 0)); + $this->assertNotEmpty($variantStocks->filter(fn (int $stock): bool => $stock >= 1 && $stock <= 5)); $this->assertSame( [ - 'Entradas' => ['entrada-general', 'entrada-general-todos-los-dias'], - 'Estacionamiento' => ['estacionamiento-auto', 'estacionamiento-moto'], - 'Comidas' => ['hamburguesa-papa-frita', 'pancho', 'combo-2-panchos-2-hamburguesas'], - 'Bebidas' => ['coca-cola-500ml', 'agua-mineral-1l'], + 'abono' => 'Entradas', + 'alojamiento' => 'Alojamientos', + 'camiseta' => 'Merchandising', + 'comida' => 'Comidas', ], - $featuredGroups - ->mapWithKeys(fn (FeaturedGroup $group): array => [ - $group->group_name => $group->category->catalogItems->pluck('slug')->all(), - ]) - ->all() + CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->with('category') + ->orderBy('slug') + ->get() + ->mapWithKeys(fn (CatalogItem $item): array => [$item->slug => $item->category?->nombre]) + ->all(), ); - $this->assertSame( - [ - ['Entradas', FeaturedGroupSource::Category, 'Entradas', ProductLayout::Row, GroupLayout::SimpleVertical, 0], - ['Estacionamiento', FeaturedGroupSource::Category, 'Estacionamiento', ProductLayout::ColumnWithCart, GroupLayout::Simple, 1], - ['Comidas', FeaturedGroupSource::Category, 'Comidas', ProductLayout::ColumnWithCart, GroupLayout::Simple, 2], - ['Bebidas', FeaturedGroupSource::Category, 'Bebidas', ProductLayout::ColumnWithCart, GroupLayout::Simple, 3], - ], - $featuredGroups - ->map(fn (FeaturedGroup $group): array => [ - $group->group_name, - $group->source_type, - $group->category->nombre, - $group->product_layout, - $group->group_layout, - $group->group_order, - ]) - ->all() + $abono = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'abono') + ->with('itemAttributes.attribute', 'variants.eventDates') + ->sole(); + $dateAttribute = $abono->itemAttributes->firstWhere('attribute.codigo', 'event_date'); + $this->assertTrue($dateAttribute->allow_multi_select); + $this->assertEqualsCanonicalizing( + [4], + $abono->variants->map(fn ($variant): int => $variant->eventDates->count())->all(), ); + + $food = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'comida') + ->with('variants') + ->sole(); + $this->assertSame('4000.00', $food->precio); + $this->assertCount(24, $food->variants->pluck('descripcion')->unique()); + $this->assertEqualsCanonicalizing( + ['4000.00', '8000.00', '10000.00'], + $food->variants->pluck('precio')->unique()->values()->all(), + ); + $this->assertTrue($food->variants->every( + fn ($variant): bool => $variant->descripcion !== null && $variant->precio !== null + )); + + $featuredGroup = FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->sole(); + $this->assertSame(FeaturedGroupSource::All, $featuredGroup->source_type); + $this->assertSame(ProductLayout::Row, $featuredGroup->product_layout); + $this->assertSame(GroupLayout::SimpleVertical, $featuredGroup->group_layout); + $this->assertNull($featuredGroup->category_id); + } + + private function attachment(string $name): Attachment + { + return Attachment::query()->create([ + 'path' => "tests/{$name}.png", + 'filename' => "{$name}.png", + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); } } diff --git a/tests/Feature/Seeders/MenuSeederTest.php b/tests/Feature/Seeders/MenuSeederTest.php index 7c84f7e..fcf11f0 100644 --- a/tests/Feature/Seeders/MenuSeederTest.php +++ b/tests/Feature/Seeders/MenuSeederTest.php @@ -20,10 +20,12 @@ class MenuSeederTest extends TestCase public function test_it_seeds_the_admin_menus(): void { $tenant = $this->createTenant('admin_tenant'); + $fiestaTenant = $this->createTenant('fiesta_futbol_infantil'); $this->seed(MenuSeeder::class); $expectedMenus = [ + 'adminapp.inicio' => ['Inicio', '/admin/inicio'], 'adminapp.catalog' => ['Catálogo', '/admin/catalog'], 'adminapp.categories' => ['Categorías', '/admin/categories'], 'adminapp.combos' => ['Combos', '/admin/combos'], @@ -31,6 +33,12 @@ class MenuSeederTest extends TestCase 'adminapp.staff' => ['Staff', '/admin/staff'], 'adminapp.ventas' => ['Ventas', '/admin/ventas'], ]; + $fiestaCategoryMenus = [ + 'adminapp.fiesta-futbol-infantil.entradas' => ['Entradas', '/admin/entradas'], + 'adminapp.fiesta-futbol-infantil.alojamientos' => ['Alojamientos', '/admin/alojamientos'], + 'adminapp.fiesta-futbol-infantil.merchandising' => ['Merchandising', '/admin/merchandising'], + 'adminapp.fiesta-futbol-infantil.comida' => ['Comida', '/admin/comidas'], + ]; $adminApp = Menu::query() ->with('children') @@ -41,7 +49,7 @@ class MenuSeederTest extends TestCase $this->assertSame(Menu::CONTENT_TYPE_DYNAMIC, $adminApp->content_type); $this->assertSame('/', $adminApp->route); $this->assertSame( - array_keys($expectedMenus), + array_keys([...$expectedMenus, ...$fiestaCategoryMenus]), $adminApp->children->pluck('code')->sort()->values()->all() ); $this->assertTrue( @@ -64,6 +72,27 @@ class MenuSeederTest extends TestCase ); } + foreach ($fiestaCategoryMenus as $code => [$label, $route]) { + $menu = Menu::query()->where('code', $code)->firstOrFail(); + + $this->assertSame($label, $menu->label); + $this->assertSame($route, $menu->route); + $this->assertSame('main.adminapp', $menu->parent_menu_code); + $this->assertFalse($tenant->menues()->where('menues.code', $code)->exists()); + $this->assertTrue($fiestaTenant->menues()->where('menues.code', $code)->exists()); + } + + foreach ([ + 'adminapp.inicio', + 'adminapp.catalog', + 'adminapp.categories', + 'adminapp.combos', + ] as $code) { + $this->assertFalse( + $fiestaTenant->menues()->where('menues.code', $code)->exists() + ); + } + $this->assertFalse( Menu::query()->whereIn('code', [ 'admin.event', diff --git a/tests/Feature/Tenant/BootstrapTenantControllerTest.php b/tests/Feature/Tenant/BootstrapTenantControllerTest.php index ac87b6c..c5e4162 100644 --- a/tests/Feature/Tenant/BootstrapTenantControllerTest.php +++ b/tests/Feature/Tenant/BootstrapTenantControllerTest.php @@ -52,6 +52,9 @@ class BootstrapTenantControllerTest extends TestCase 'footer_bg_color' => '#ffffff', 'header_logo_id' => $headerAttachment->id, 'footer_logo_id' => $footerAttachment->id, + 'event_date_text' => '9, 10, 11 y 12 de Octubre 2026', + 'display_categories' => false, + 'display_seach_bar' => false, ]); $response = $this->getJson('/api/tenants/bootstrap/acme.com'); @@ -64,6 +67,9 @@ class BootstrapTenantControllerTest extends TestCase ->assertJsonPath('data.secondary_color', '#00ff00') ->assertJsonPath('data.danger_color', '#0000ff') ->assertJsonPath('data.success_color', '#00ff00') + ->assertJsonPath('data.event_date_text', '9, 10, 11 y 12 de Octubre 2026') + ->assertJsonPath('data.display_categories', false) + ->assertJsonPath('data.display_seach_bar', false) ->assertJsonPath('data.header_bg_color', '#ffffff')->assertJsonPath('data.footer_bg_color', '#ffffff'); $headerUrl = $response->json('data.header_logo'); @@ -242,10 +248,10 @@ class BootstrapTenantControllerTest extends TestCase ->assertJsonPath('data.menues.0.code', 'help') ->assertJsonPath('data.menues.0.label', 'Ayuda') ->assertJsonPath('data.menues.0.parent_menu_code', null) - ->assertJsonPath('data.menues.0.submenues.0.code', 'help.faq') - ->assertJsonPath('data.menues.0.submenues.0.label', 'Preguntas frecuentes') + ->assertJsonPath('data.menues.0.submenues.0.code', 'help.shipping') + ->assertJsonPath('data.menues.0.submenues.0.label', 'Envíos') ->assertJsonPath('data.menues.0.submenues.0.parent_menu_code', 'help') - ->assertJsonPath('data.menues.0.submenues.1.code', 'help.shipping'); + ->assertJsonPath('data.menues.0.submenues.1.code', 'help.faq'); $assertCleanMenu = function (array $menu) use (&$assertCleanMenu): void { $this->assertArrayNotHasKey('created_at', $menu); @@ -259,10 +265,10 @@ class BootstrapTenantControllerTest extends TestCase }; $menus = $response->json('data.menues'); - $this->assertEquals($staticContent, $menus[0]['submenues'][0]['static_content']); + $this->assertEquals($staticContent, $menus[0]['submenues'][1]['static_content']); $assertCleanMenu($menus[0]); $this->assertArrayNotHasKey('static_content', $menus[0]); - $this->assertArrayNotHasKey('static_content', $menus[0]['submenues'][1]); + $this->assertArrayNotHasKey('static_content', $menus[0]['submenues'][0]); } public function test_it_returns_only_menus_assigned_to_the_user_role(): void @@ -297,6 +303,49 @@ class BootstrapTenantControllerTest extends TestCase ->assertJsonMissing(['code' => 'admin.catalog']); } + public function test_it_orders_parent_menus_and_their_submenus_by_label(): void + { + $tenant = $this->createTenant(); + $userRole = Role::query()->create([ + 'codigo' => RoleCode::User->value, + 'nombre' => 'Usuario', + ]); + + $zeta = Menu::query()->create([ + 'code' => 'zeta', + 'label' => 'Zeta', + 'route' => '/zeta', + ]); + $tree = Menu::query()->create([ + 'code' => 'tree', + 'label' => 'Árbol', + 'route' => '/arbol', + ]); + $fox = Menu::query()->create([ + 'code' => 'tree.fox', + 'label' => 'Zorro', + 'parent_menu_code' => $tree->code, + 'route' => '/arbol/zorro', + ]); + $eagle = Menu::query()->create([ + 'code' => 'tree.eagle', + 'label' => 'Águila', + 'parent_menu_code' => $tree->code, + 'route' => '/arbol/aguila', + ]); + + $menuCodes = [$zeta->code, $tree->code, $fox->code, $eagle->code]; + $tenant->menues()->sync($menuCodes); + $userRole->menus()->sync($menuCodes); + + $this->getJson('/api/tenants/bootstrap/acme.com') + ->assertOk() + ->assertJsonPath('data.menues.0.code', 'tree') + ->assertJsonPath('data.menues.1.code', 'zeta') + ->assertJsonPath('data.menues.0.submenues.0.code', 'tree.eagle') + ->assertJsonPath('data.menues.0.submenues.1.code', 'tree.fox'); + } + public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void { $base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; diff --git a/tests/Feature/Ticket/TicketControllerTest.php b/tests/Feature/Ticket/TicketControllerTest.php index a04a1dd..5653062 100644 --- a/tests/Feature/Ticket/TicketControllerTest.php +++ b/tests/Feature/Ticket/TicketControllerTest.php @@ -6,7 +6,9 @@ use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Models\ValidityTime; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Str; use Tests\TestCase; @@ -21,6 +23,22 @@ class TicketControllerTest extends TestCase $user = User::factory()->create(); $olderTicket = $this->createTicket($tenant, $user, 'Older ticket'); $newerTicket = $this->createTicket($tenant, $user, 'Newer ticket'); + $validityTimes = collect([ + ValidityTime::query()->create([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => now()->subHour(), + 'fixed_expires_at' => now()->addHour(), + ]), + ValidityTime::query()->create([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => now()->addDay(), + 'fixed_expires_at' => now()->addDays(2), + ]), + ]); + $validityTimes->each(function (ValidityTime $validityTime) use ($newerTicket): void { + $group = $newerTicket->validityGroups()->create(); + $group->validityTimes()->attach($validityTime); + }); $this->actingAs($user, 'sanctum') ->getJson("/api/tenants/{$tenant->codigo}/tickets") @@ -31,6 +49,10 @@ class TicketControllerTest extends TestCase ->assertJsonPath('data.0.is_valid', true) ->assertJsonPath('data.0.is_expired', false) ->assertJsonPath('data.0.is_used', false) + ->assertJsonCount(2, 'data.0.validity_times') + ->assertJsonCount(2, 'data.0.validity_groups') + ->assertJsonCount(1, 'data.0.validity_groups.0.validity_times') + ->assertJsonMissingPath('data.0.validity_time') ->assertJsonPath('data.1.id', $olderTicket->id) ->assertJsonMissingPath('meta') ->assertJsonMissingPath('links'); diff --git a/tests/Feature/Ticket/TicketGeneratorServiceTest.php b/tests/Feature/Ticket/TicketGeneratorServiceTest.php index af886ed..f2082c6 100644 --- a/tests/Feature/Ticket/TicketGeneratorServiceTest.php +++ b/tests/Feature/Ticket/TicketGeneratorServiceTest.php @@ -6,12 +6,18 @@ use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Catalog\Enums\CatalogItemType; +use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; +use App\Domains\Event\Models\EventDate; use App\Domains\Notification\Events\TicketsAvailable; use App\Domains\Purchase\Models\Purchase; +use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\TicketGenerationPolicy; +use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Exceptions\TicketGenerationException; +use App\Domains\Ticket\Models\ValidityTime; use App\Domains\Ticket\Services\TicketGeneratorService; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -51,11 +57,7 @@ class TicketGeneratorServiceTest extends TestCase public function test_it_generates_tickets_from_a_standard_catalog_item(): void { - $item = $this->createTicketableItem( - 'single-day', - now()->subHour(), - now()->addDay(), - ); + $item = $this->createTicketableItem('single-day'); $tickets = $this->service->generate($item, $this->user, 2); @@ -68,8 +70,7 @@ class TicketGeneratorServiceTest extends TestCase $this->assertSame($item->descripcion, $ticket->description); $this->assertSame($item->id, $ticket->source_catalog_item_id); $this->assertNull($ticket->source_variant_id); - $this->assertTrue($ticket->starts_at->equalTo($item->minimum_use_date)); - $this->assertTrue($ticket->expires_at->equalTo($item->maximum_use_date)); + $this->assertTrue($ticket->validityGroups->isEmpty()); } } @@ -84,41 +85,56 @@ class TicketGeneratorServiceTest extends TestCase $this->service->generate($item->fresh(), $this->user); } - public function test_it_generates_a_ticket_when_its_maximum_use_date_was_reached(): void + public function test_variant_properties_are_appended_to_the_ticket_name(): void { - $item = $this->createTicketableItem('expired', maximumUseDate: now()); - - $tickets = $this->service->generate($item, $this->user); - - $this->assertCount(1, $tickets); - $this->assertTrue($tickets->first()->expires_at->equalTo($item->maximum_use_date)); - } - - public function test_variant_dates_override_and_inherit_catalog_item_dates(): void - { - $item = $this->createTicketableItem( - 'variant-dates', - now()->subDay(), - now()->addMonth(), - ); - $inventory = Inventory::query()->create(); + $item = $this->createTicketableItem('shirt'); + $color = Attribute::query()->create([ + 'tenant_codigo' => $this->tenant->codigo, + 'codigo' => 'color', + 'nombre' => 'Color', + 'type' => FieldType::Select, + ]); + $color->options()->create([ + 'value' => 'black', + 'label' => 'Negro', + ]); + $size = Attribute::query()->create([ + 'tenant_codigo' => $this->tenant->codigo, + 'codigo' => 'size', + 'nombre' => 'Talle', + 'type' => FieldType::Select, + ]); + $size->options()->create([ + 'value' => 'xl', + 'label' => 'XL', + ]); + $itemColor = $item->itemAttributes()->create([ + 'attribute_id' => $color->id, + 'sort_order' => 1, + ]); + $itemSize = $item->itemAttributes()->create([ + 'attribute_id' => $size->id, + 'sort_order' => 2, + ]); $variant = $item->variants()->create([ - 'inventory_id' => $inventory->id, - 'maximum_use_date' => now()->addWeek(), + 'inventory_id' => Inventory::query()->create()->id, + ]); + $variant->definitions()->createMany([ + ['item_attribute_id' => $itemColor->id, 'value' => 'black'], + ['item_attribute_id' => $itemSize->id, 'value' => 'xl'], ]); $ticket = $this->service - ->generate($item, $this->user, sourceVariantId: $variant->id) - ->firstOrFail(); + ->generate($item, $this->user, 1, $variant->id) + ->sole(); - $this->assertTrue($ticket->starts_at->equalTo($item->minimum_use_date)); - $this->assertTrue($ticket->expires_at->equalTo($variant->maximum_use_date)); + $this->assertSame('Shirt (Negro, XL)', $ticket->name); } public function test_it_generates_tickets_for_every_bundle_component_and_quantity(): void { - $first = $this->createTicketableItem('first', maximumUseDate: now()->addDay()); - $second = $this->createTicketableItem('second', maximumUseDate: now()->addDays(2)); + $first = $this->createTicketableItem('first'); + $second = $this->createTicketableItem('second'); $bundle = $this->createBundle('bundle'); $bundle->bundleComponents()->createMany([ ['component_catalog_item_id' => $first->id, 'quantity' => 2], @@ -134,18 +150,12 @@ class TicketGeneratorServiceTest extends TestCase $this->assertCount(2, $tickets->where('name', $second->nombre)); } - public function test_bundle_component_uses_its_variant_dates(): void + public function test_bundle_component_preserves_its_source_variant(): void { - $component = $this->createTicketableItem( - 'variant-component', - now()->subDay(), - now()->addMonth(), - ); + $component = $this->createTicketableItem('variant-component'); $inventory = Inventory::query()->create(); $variant = $component->variants()->create([ 'inventory_id' => $inventory->id, - 'minimum_use_date' => now()->addDay(), - 'maximum_use_date' => now()->addWeek(), ]); $bundle = $this->createBundle('variant-bundle'); $bundle->bundleComponents()->create([ @@ -160,8 +170,6 @@ class TicketGeneratorServiceTest extends TestCase $this->assertSame($component->id, $ticket->source_catalog_item_id); $this->assertSame($variant->id, $ticket->source_variant_id); - $this->assertTrue($ticket->starts_at->equalTo($variant->minimum_use_date)); - $this->assertTrue($ticket->expires_at->equalTo($variant->maximum_use_date)); } public function test_bundle_generation_is_rolled_back_when_a_component_is_invalid(): void @@ -224,6 +232,208 @@ class TicketGeneratorServiceTest extends TestCase ]); } + public function test_event_date_and_time_window_are_combined_in_the_same_and_group(): void + { + $item = $this->createTicketableItem('scheduled-meal'); + $eventDate = EventDate::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'date' => '2026-08-20', + 'time_start' => '09:00:00', + 'time_end' => '23:59:59', + ]); + $timeWindow = ValidityTime::query()->create([ + 'type' => ValidityTimeType::TimeWindow, + 'start_time' => '22:00:00', + 'end_time' => '02:00:00', + ]); + $schedule = Attribute::query()->create([ + 'tenant_codigo' => $this->tenant->codigo, + 'codigo' => 'schedule', + 'nombre' => 'Schedule', + 'type' => FieldType::Select, + ]); + $schedule->options()->create([ + 'value' => 'night', + 'label' => '22:00 - 02:00', + 'validity_time_id' => $timeWindow->id, + ]); + $itemSchedule = $item->itemAttributes()->create([ + 'attribute_id' => $schedule->id, + ]); + $inventory = Inventory::query()->create(); + $variant = $item->variants()->create([ + 'event_date_id' => $eventDate->id, + 'inventory_id' => $inventory->id, + ]); + $variant->definitions()->create([ + 'item_attribute_id' => $itemSchedule->id, + 'value' => 'night', + ]); + + $tickets = $this->service->generate($item, $this->user, 2, $variant->id); + + $this->assertCount(2, $tickets); + $this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->validityGroups->count() === 1)); + $this->assertTrue($tickets->every( + fn ($ticket): bool => $ticket->validityGroups->sole()->validityTimes + ->pluck('id') + ->sort() + ->values() + ->all() === collect([$eventDate->validity_time_id, $timeWindow->id])->sort()->values()->all() + )); + $this->assertSame( + '2026-08-20 22:00:00', + $tickets->first()->getEffectiveStartsAt()->format('Y-m-d H:i:s'), + ); + $this->assertSame( + '2026-08-20 23:59:59', + $tickets->first()->getEffectiveExpiresAt()->format('Y-m-d H:i:s'), + ); + } + + public function test_a_multi_date_variant_generates_one_ticket_for_each_selected_date(): void + { + $item = $this->createTicketableItem('multi-date-pass'); + $dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'date' => $date, + 'time_start' => '00:00:00', + 'time_end' => '23:59:59', + ])); + $variant = $item->variants()->create([ + 'inventory_id' => Inventory::query()->create()->id, + ]); + $variant->eventDates()->sync($dates->pluck('id')); + + $tickets = $this->service->generate($item, $this->user, 1, $variant->id); + $tickets->each->loadMissing('validityGroups.validityTimes'); + + $this->assertCount(2, $tickets); + $this->assertSame( + $dates->pluck('validity_time_id')->all(), + $tickets->map(fn ($ticket): int => $ticket->validityGroups->sole()->validityTimes->sole()->id)->all(), + ); + $this->assertSame( + ['2026-08-20 00:00:00', '2026-08-21 00:00:00'], + $tickets->map(fn ($ticket): string => $ticket->validityGroups->sole()->validityTimes->sole()->fixed_starts_at->format('Y-m-d H:i:s'))->all(), + ); + $this->assertSame( + ['Multi-date-pass (20/08/2026)', 'Multi-date-pass (21/08/2026)'], + $tickets->pluck('name')->all(), + ); + $this->assertSame([$variant->id], $tickets->pluck('source_variant_id')->unique()->values()->all()); + } + + public function test_one_per_unit_policy_generates_one_ticket_covering_all_selected_dates(): void + { + $item = $this->createTicketableItem('multi-date-pass'); + $item->update([ + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit, + ]); + $dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'date' => $date, + 'time_start' => '00:00:00', + 'time_end' => '23:59:59', + ])); + $variant = $item->variants()->create([ + 'inventory_id' => Inventory::query()->create()->id, + ]); + $variant->eventDates()->sync($dates->pluck('id')); + + $tickets = $this->service->generate($item, $this->user, 2, $variant->id); + $tickets->each->loadMissing('validityGroups.validityTimes'); + + $this->assertCount(2, $tickets); + $this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->validityGroups->count() === 2)); + $this->assertTrue($tickets->every( + fn ($ticket): bool => $ticket->validityGroups->every( + fn ($group): bool => $group->validityTimes->count() === 1 + ) + )); + $this->assertEqualsCanonicalizing( + $dates->pluck('validity_time_id')->all(), + $tickets->flatMap(fn ($ticket) => $ticket->allValidityTimes())->pluck('id')->unique()->all(), + ); + $this->assertTrue($tickets->every( + fn ($ticket): bool => $ticket->name === 'Multi-date-pass (20/08/2026, 21/08/2026)' + )); + $this->assertTrue($tickets->every( + fn ($ticket): bool => $ticket->allValidityTimes() + ->map(fn (ValidityTime $validityTime): array => [ + $validityTime->fixed_starts_at->format('Y-m-d H:i:s'), + $validityTime->fixed_expires_at->format('Y-m-d H:i:s'), + ]) + ->all() === [ + ['2026-08-20 00:00:00', '2026-08-20 23:59:59'], + ['2026-08-21 00:00:00', '2026-08-21 23:59:59'], + ] + )); + } + + public function test_common_schedule_is_anded_into_each_alternative_event_date_group(): void + { + $item = $this->createTicketableItem('multi-date-lunch'); + $item->update(['ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit]); + $dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'date' => $date, + 'time_start' => '09:00:00', + 'time_end' => '18:00:00', + ])); + $lunch = ValidityTime::query()->create([ + 'type' => ValidityTimeType::TimeWindow, + 'start_time' => '12:00:00', + 'end_time' => '15:00:00', + ]); + $schedule = Attribute::query()->create([ + 'tenant_codigo' => $this->tenant->codigo, + 'codigo' => 'schedule', + 'nombre' => 'Schedule', + 'type' => FieldType::Select, + ]); + $schedule->options()->create([ + 'value' => 'lunch', + 'label' => 'Lunch', + 'validity_time_id' => $lunch->id, + ]); + $itemSchedule = $item->itemAttributes()->create(['attribute_id' => $schedule->id]); + $variant = $item->variants()->create([ + 'inventory_id' => Inventory::query()->create()->id, + ]); + $variant->eventDates()->sync($dates->pluck('id')); + $variant->definitions()->create([ + 'item_attribute_id' => $itemSchedule->id, + 'value' => 'lunch', + ]); + + $ticket = $this->service->generate($item, $this->user, 1, $variant->id)->sole(); + $ticket->loadMissing('validityGroups.validityTimes'); + + $this->assertCount(2, $ticket->validityGroups); + $this->assertTrue($ticket->validityGroups->every( + fn ($group): bool => $group->validityTimes->count() === 2 + && $group->validityTimes->contains($lunch) + )); + $this->assertEqualsCanonicalizing( + $dates->pluck('validity_time_id')->all(), + $ticket->validityGroups + ->flatMap->validityTimes + ->reject(fn (ValidityTime $validityTime): bool => $validityTime->is($lunch)) + ->pluck('id') + ->all(), + ); + $this->assertSame('2026-08-20 12:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s')); + $this->assertSame('2026-08-21 15:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s')); + + Carbon::setTestNow('2026-08-20 13:00:00'); + $this->assertTrue($ticket->isValid()); + + Carbon::setTestNow('2026-08-20 16:00:00'); + $this->assertFalse($ticket->isValid()); + $this->assertFalse($ticket->is_expired); + } + public function test_marking_a_purchase_as_paid_ignores_items_without_tickets(): void { Event::fake([TicketsAvailable::class]); @@ -244,9 +454,9 @@ class TicketGeneratorServiceTest extends TestCase ->assertJsonPath('data.has_generated_tickets', false); } - public function test_paid_status_is_confirmed_when_ticket_maximum_use_date_was_reached(): void + public function test_paid_status_is_confirmed_when_ticket_is_generated(): void { - $item = $this->createTicketableItem('expired-paid-ticket', maximumUseDate: now()); + $item = $this->createTicketableItem('paid-ticket'); $purchase = $this->createPurchase($item, 1); $purchase->markAsPaid(); @@ -258,11 +468,8 @@ class TicketGeneratorServiceTest extends TestCase ]); } - private function createTicketableItem( - string $slug, - mixed $minimumUseDate = null, - mixed $maximumUseDate = null, - ): CatalogItem { + private function createTicketableItem(string $slug): CatalogItem + { return CatalogItem::query()->create([ 'tenant_code' => $this->tenant->codigo, 'slug' => $slug, @@ -270,8 +477,6 @@ class TicketGeneratorServiceTest extends TestCase 'descripcion' => "Descripción de {$slug}", 'precio' => 10, 'has_tickets' => true, - 'minimum_use_date' => $minimumUseDate, - 'maximum_use_date' => $maximumUseDate, ]); } diff --git a/tests/Feature/Ticket/TicketValiditySchemaTest.php b/tests/Feature/Ticket/TicketValiditySchemaTest.php new file mode 100644 index 0000000..c327a72 --- /dev/null +++ b/tests/Feature/Ticket/TicketValiditySchemaTest.php @@ -0,0 +1,49 @@ +assertEqualsCanonicalizing([ + 'id', + 'type', + 'start_time', + 'end_time', + 'fixed_starts_at', + 'fixed_expires_at', + 'created_at', + 'updated_at', + ], Schema::getColumnListing('validity_times')); + + $this->assertFalse(Schema::hasColumn('tickets', 'validity_time_id')); + $this->assertFalse(Schema::hasTable('ticket_validity_times')); + $this->assertEqualsCanonicalizing([ + 'id', + 'ticket_id', + ], Schema::getColumnListing('ticket_validity_groups')); + $this->assertEqualsCanonicalizing([ + 'ticket_validity_group_id', + 'validity_time_id', + ], Schema::getColumnListing('ticket_validity_group_times')); + $this->assertFalse(Schema::hasColumn('tickets', 'service_date')); + $this->assertFalse(Schema::hasColumn('tickets', 'starts_at')); + $this->assertFalse(Schema::hasColumn('tickets', 'expires_at')); + + $this->assertTrue(Schema::hasColumn('catalog_items', 'validity_time_id')); + $this->assertFalse(Schema::hasColumn('catalog_items', 'minimum_use_date')); + $this->assertFalse(Schema::hasColumn('catalog_items', 'maximum_use_date')); + $this->assertTrue(Schema::hasColumn('attribute_options', 'validity_time_id')); + $this->assertTrue(Schema::hasColumn('event_dates', 'validity_time_id')); + $this->assertFalse(Schema::hasColumn('variantes', 'minimum_use_date')); + $this->assertFalse(Schema::hasColumn('variantes', 'maximum_use_date')); + + } +} diff --git a/tests/Unit/Catalog/CatalogModelsTest.php b/tests/Unit/Catalog/CatalogModelsTest.php index 95f8cc7..48e5a17 100644 --- a/tests/Unit/Catalog/CatalogModelsTest.php +++ b/tests/Unit/Catalog/CatalogModelsTest.php @@ -4,7 +4,6 @@ namespace Tests\Unit\Catalog; use App\Domains\Attachable\Models\Attachment; use App\Domains\Catalog\Enums\CatalogItemType; -use App\Domains\Catalog\Enums\EventProductType; use App\Domains\Catalog\Enums\FeaturedGroupSource; use App\Domains\Catalog\Enums\GroupLayout; use App\Domains\Catalog\Enums\InventoryPolicy; @@ -21,13 +20,11 @@ use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\VariantDefinition; -use App\Domains\Event\Models\Event; use App\Domains\Event\Models\EventDate; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use Illuminate\Database\Eloquent\Collection as EloquentCollection; -use Illuminate\Support\Carbon; use Tests\TestCase; class CatalogModelsTest extends TestCase @@ -59,6 +56,18 @@ class CatalogModelsTest extends TestCase $this->assertInstanceOf(AttributeOption::class, $attribute->options()->getRelated()); } + public function test_event_date_is_an_attribute_type_with_dynamic_options(): void + { + $attribute = new Attribute; + $attribute->type = FieldType::EventDate->value; + + $this->assertSame(FieldType::EventDate, $attribute->type); + $this->assertTrue($attribute->type->supportsOptions()); + $this->assertTrue($attribute->type->usesDynamicOptions()); + $this->assertContains('event_date', FieldType::values()); + $this->assertInstanceOf(EventDate::class, $attribute->eventDates()->getRelated()); + } + public function test_catalog_item_is_the_catalog_root(): void { $item = new CatalogItem; @@ -66,8 +75,6 @@ class CatalogModelsTest extends TestCase 'category_id' => '10', 'brand_id' => '20', 'inventory_id' => '30', - 'event_id' => '40', - 'event_product_type' => EventProductType::Entry->value, 'type' => CatalogItemType::Standard->value, 'precio' => '12.50', 'inventory_policy' => InventoryPolicy::Tracked->value, @@ -79,14 +86,11 @@ class CatalogModelsTest extends TestCase $this->assertSame(10, $item->category_id); $this->assertSame(20, $item->brand_id); $this->assertSame(30, $item->inventory_id); - $this->assertSame(40, $item->event_id); - $this->assertSame(EventProductType::Entry, $item->event_product_type); $this->assertSame(CatalogItemType::Standard, $item->type); $this->assertSame('12.50', $item->precio); $this->assertSame(InventoryPolicy::Tracked, $item->inventory_policy); $this->assertTrue($item->has_tickets); $this->assertInstanceOf(Tenant::class, $item->tenant()->getRelated()); - $this->assertInstanceOf(Event::class, $item->event()->getRelated()); $this->assertInstanceOf(Category::class, $item->category()->getRelated()); $this->assertInstanceOf(Brand::class, $item->brand()->getRelated()); $this->assertInstanceOf(Inventory::class, $item->inventory()->getRelated()); @@ -160,24 +164,6 @@ class CatalogModelsTest extends TestCase $this->assertSame('catalog_items_attachments', $variant->attachments()->getTable()); } - public function test_variant_use_dates_override_or_inherit_catalog_item_dates(): void - { - $item = new CatalogItem; - $item->minimum_use_date = Carbon::parse('2026-08-01 09:00:00'); - $item->maximum_use_date = Carbon::parse('2026-08-31 18:00:00'); - - $variant = new Variant; - $variant->maximum_use_date = Carbon::parse('2026-08-15 18:00:00'); - $variant->setRelation('catalogItem', $item); - - $this->assertTrue( - $variant->getMinimumUseDate()->equalTo($item->minimum_use_date), - ); - $this->assertTrue( - $variant->getMaximumUseDate()->equalTo($variant->maximum_use_date), - ); - } - public function test_event_date_identifies_a_variant_without_catalog_attributes(): void { $item = new CatalogItem; @@ -189,15 +175,86 @@ class CatalogModelsTest extends TestCase $eventDate->time_end = '18:00:00'; $variant = new Variant; - $variant->minimum_use_date = Carbon::parse('2026-10-09 08:00:00'); - $variant->maximum_use_date = Carbon::parse('2026-10-09 20:00:00'); $variant->setRelation('catalogItem', $item); $variant->setRelation('eventDate', $eventDate); $variant->setRelation('definitions', new EloquentCollection); $this->assertSame('Entrada General', $variant->getName()); - $this->assertSame('2026-10-09 09:00:00', $variant->getMinimumUseDate()->format('Y-m-d H:i:s')); - $this->assertSame('2026-10-09 18:00:00', $variant->getMaximumUseDate()->format('Y-m-d H:i:s')); + $this->assertSame('2026-10-09', $variant->eventDate->date->format('Y-m-d')); + } + + public function test_variant_exposes_selection_values_with_api_labels(): void + { + $attribute = new Attribute; + $attribute->codigo = 'size'; + $attribute->setRelation('options', new EloquentCollection([ + new AttributeOption(['value' => 'M', 'label' => 'Medium']), + ])); + + $itemAttribute = new ItemAttribute; + $itemAttribute->allow_multi_select = false; + $itemAttribute->setRelation('attribute', $attribute); + + $definition = new VariantDefinition(['value' => 'M']); + $definition->item_attribute_id = 1; + $definition->setRelation('itemAttribute', $itemAttribute); + + $eventDate = new EventDate([ + 'date' => '2026-10-09', + 'time_start' => '09:00:00', + 'time_end' => '18:00:00', + ]); + $eventDate->id = 20; + + $variant = new Variant; + $variant->setRelation('definitions', new EloquentCollection([$definition])); + $variant->setRelation('eventDates', new EloquentCollection([$eventDate])); + + $this->assertSame( + ['value' => 'M', 'label' => 'Medium'], + $variant->selectionOptions()->get('size'), + ); + $this->assertSame( + ['value' => '20', 'label' => '09/10/2026'], + $variant->selectionOptions()->get('event_date'), + ); + } + + public function test_variant_orders_selection_options_by_item_order_and_attribute_label(): void + { + $definitions = collect([ + ['id' => 1, 'code' => 'zeta', 'label' => 'Zeta', 'sort_order' => 2], + ['id' => 2, 'code' => 'priority', 'label' => 'Priority', 'sort_order' => 1], + ['id' => 3, 'code' => 'alpha', 'label' => 'Alpha', 'sort_order' => 2], + ])->map(function (array $data): VariantDefinition { + $attribute = new Attribute([ + 'codigo' => $data['code'], + 'nombre' => $data['label'], + ]); + $attribute->setRelation('options', new EloquentCollection); + + $itemAttribute = new ItemAttribute([ + 'sort_order' => $data['sort_order'], + 'allow_multi_select' => false, + ]); + $itemAttribute->id = $data['id']; + $itemAttribute->setRelation('attribute', $attribute); + + $definition = new VariantDefinition(['value' => $data['code']]); + $definition->item_attribute_id = $itemAttribute->id; + $definition->setRelation('itemAttribute', $itemAttribute); + + return $definition; + }); + + $variant = new Variant; + $variant->setRelation('definitions', new EloquentCollection($definitions)); + $variant->setRelation('eventDates', new EloquentCollection); + + $this->assertSame( + ['priority', 'alpha', 'zeta'], + $variant->selectionOptions()->keys()->all(), + ); } public function test_inventory_maps_stock_without_a_polymorphic_owner(): void @@ -226,6 +283,27 @@ class CatalogModelsTest extends TestCase $this->assertTrue($item->isAvailable()); } + public function test_catalog_item_only_exposes_available_tracked_variants(): void + { + $unavailable = (new Variant)->setRelation('inventory', $this->trackedInventory(3, 3)); + $available = (new Variant)->setRelation('inventory', $this->trackedInventory(5, 2)); + $unavailable->id = 10; + $available->id = 20; + $item = new CatalogItem; + $item->inventory_policy = InventoryPolicy::Tracked; + $item->setRelation('variants', new EloquentCollection([$unavailable, $available])); + + $this->assertSame([$available], $item->visibleVariants()->all()); + $this->assertSame( + [$unavailable, $available], + $item->visibleVariants($unavailable->id)->all(), + ); + + $item->inventory_policy = InventoryPolicy::Unlimited; + + $this->assertSame([$unavailable, $available], $item->visibleVariants()->all()); + } + public function test_catalog_item_prioritizes_its_inventory_over_variants(): void { $item = new CatalogItem; diff --git a/tests/Unit/Event/EventDateTextFormatterTest.php b/tests/Unit/Event/EventDateTextFormatterTest.php new file mode 100644 index 0000000..ca86e70 --- /dev/null +++ b/tests/Unit/Event/EventDateTextFormatterTest.php @@ -0,0 +1,38 @@ + $dates */ + #[DataProvider('dateCases')] + public function test_it_formats_event_dates_in_spanish(array $dates, ?string $expected): void + { + $this->assertSame($expected, (new EventDateTextFormatter)->format($dates)); + } + + /** @return array, string|null}> */ + public static function dateCases(): array + { + return [ + 'no dates' => [[], null], + 'one date' => [['2026-10-09'], '9 de Octubre 2026'], + 'same month sorted' => [ + ['2026-10-12', '2026-10-09', '2026-10-11', '2026-10-10'], + '9, 10, 11 y 12 de Octubre 2026', + ], + 'different months' => [ + ['2026-11-01', '2026-10-31'], + '31 de Octubre y 1 de Noviembre 2026', + ], + 'different years' => [ + ['2027-01-01', '2026-12-31'], + '31 de Diciembre 2026 y 1 de Enero 2027', + ], + ]; + } +} diff --git a/tests/Unit/Event/EventModelsTest.php b/tests/Unit/Event/EventModelsTest.php index 070f880..0650596 100644 --- a/tests/Unit/Event/EventModelsTest.php +++ b/tests/Unit/Event/EventModelsTest.php @@ -2,48 +2,40 @@ namespace Tests\Unit\Event; -use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; -use App\Domains\Event\Models\Event; use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Models\ValidityTime; use Tests\TestCase; class EventModelsTest extends TestCase { - public function test_event_maps_its_tenant_dates_and_catalog_items(): void - { - $event = new Event; - - $this->assertFalse($event->usesTimestamps()); - $this->assertInstanceOf(Tenant::class, $event->tenant()->getRelated()); - $this->assertInstanceOf(EventDate::class, $event->dates()->getRelated()); - $this->assertInstanceOf(CatalogItem::class, $event->catalogItems()->getRelated()); - } - public function test_event_date_maps_schedule_and_variants(): void { $eventDate = new EventDate; $eventDate->setRawAttributes([ - 'event_id' => '10', + 'tenant_code' => 'acme', + 'validity_time_id' => '12', 'date' => '2026-10-09', 'time_start' => '09:00:00', 'time_end' => '18:30:00', ]); $this->assertFalse($eventDate->usesTimestamps()); - $this->assertSame(10, $eventDate->event_id); + $this->assertSame('acme', $eventDate->tenant_code); + $this->assertSame(12, $eventDate->validity_time_id); $this->assertSame('2026-10-09 09:00:00', $eventDate->startsAt()->format('Y-m-d H:i:s')); $this->assertSame('2026-10-09 18:30:00', $eventDate->endsAt()->format('Y-m-d H:i:s')); - $this->assertInstanceOf(Event::class, $eventDate->event()->getRelated()); + $this->assertInstanceOf(Tenant::class, $eventDate->tenant()->getRelated()); + $this->assertInstanceOf(ValidityTime::class, $eventDate->validityTime()->getRelated()); + $this->assertInstanceOf(EventDate::class, (new ValidityTime)->eventDate()->getRelated()); $this->assertInstanceOf(Variant::class, $eventDate->variants()->getRelated()); } - public function test_tenant_has_many_events_and_one_active_event(): void + public function test_tenant_has_many_event_dates(): void { $tenant = new Tenant; - $this->assertInstanceOf(Event::class, $tenant->events()->getRelated()); - $this->assertInstanceOf(Event::class, $tenant->activeEvent()->getRelated()); + $this->assertInstanceOf(EventDate::class, $tenant->eventDates()->getRelated()); } } diff --git a/tests/Unit/Sale/SaleDetailResourceTest.php b/tests/Unit/Sale/SaleDetailResourceTest.php new file mode 100644 index 0000000..676bacf --- /dev/null +++ b/tests/Unit/Sale/SaleDetailResourceTest.php @@ -0,0 +1,41 @@ +forceFill([ + 'id' => 15, + 'total' => '30000.00', + ]); + $item = (new PurchaseItem)->forceFill([ + 'id' => 8, + 'item_nombre' => 'Comida', + 'variant_attributes' => [ + ['name' => 'Fecha', 'value' => ['2026-10-12']], + ], + 'cantidad' => 3, + 'precio_unitario' => '10000.00', + 'total' => '30000.00', + ]); + $purchase->setRelation('items', collect([$item])); + + $data = (new SaleDetailResource($purchase))->resolve(Request::create('/')); + + $this->assertSame(15, $data['id']); + $this->assertSame('Comida', $data['items'][0]['product']); + $this->assertSame(['2026-10-12'], $data['items'][0]['event_dates']); + $this->assertSame(3, $data['items'][0]['quantity']); + $this->assertSame('10000.00', $data['items'][0]['unit_price']); + $this->assertSame('30000.00', $data['items'][0]['total']); + $this->assertSame('30000.00', $data['total']); + } +} diff --git a/tests/Unit/Ticket/TicketTest.php b/tests/Unit/Ticket/TicketTest.php index d4a3958..7163ec8 100644 --- a/tests/Unit/Ticket/TicketTest.php +++ b/tests/Unit/Ticket/TicketTest.php @@ -5,9 +5,12 @@ namespace Tests\Unit\Ticket; use App\Domains\Auth\Models\User; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; -use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Models\TicketValidityGroup; +use App\Domains\Ticket\Models\ValidityTime; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Support\Carbon; use Tests\TestCase; @@ -20,14 +23,12 @@ class TicketTest extends TestCase parent::tearDown(); } - public function test_it_maps_its_dates_and_relations(): void + public function test_it_maps_its_fields_and_relations(): void { $ticket = new Ticket; $ticket->setRawAttributes([ 'source_catalog_item_id' => '20', 'source_variant_id' => '30', - 'starts_at' => '2026-07-21 10:00:00', - 'expires_at' => '2026-07-22 10:00:00', 'used_at' => null, 'scanner_user_id' => '15', 'user_id' => '10', @@ -37,8 +38,6 @@ class TicketTest extends TestCase $this->assertFalse($ticket->usesTimestamps()); $this->assertSame(20, $ticket->source_catalog_item_id); $this->assertSame(30, $ticket->source_variant_id); - $this->assertInstanceOf(Carbon::class, $ticket->starts_at); - $this->assertInstanceOf(Carbon::class, $ticket->expires_at); $this->assertNull($ticket->used_at); $this->assertSame(15, $ticket->scanner_user_id); $this->assertSame(10, $ticket->user_id); @@ -47,105 +46,158 @@ class TicketTest extends TestCase $this->assertInstanceOf(User::class, $ticket->scannerUser()->getRelated()); $this->assertInstanceOf(CatalogItem::class, $ticket->sourceCatalogItem()->getRelated()); $this->assertInstanceOf(Variant::class, $ticket->sourceVariant()->getRelated()); + $this->assertInstanceOf(TicketValidityGroup::class, $ticket->validityGroups()->getRelated()); + $this->assertInstanceOf(ValidityTime::class, (new TicketValidityGroup)->validityTimes()->getRelated()); } - public function test_unused_ticket_without_date_restrictions_is_valid(): void + public function test_unused_ticket_without_validity_time_is_valid(): void { - $this->assertTrue((new Ticket)->isValid()); + $ticket = new Ticket; + + $this->assertTrue($ticket->isValid()); + $this->assertSame(Ticket::STATUS_ACTIVE, $ticket->status); } - public function test_ticket_is_invalid_before_its_start_date(): void + public function test_fixed_window_controls_ticket_validity(): void { Carbon::setTestNow('2026-07-21 10:00:00'); - $ticket = new Ticket(['starts_at' => now()->addSecond()]); + $ticket = $this->ticketWithValidityTime(new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => now()->subHour(), + 'fixed_expires_at' => now()->addHour(), + ])); - $this->assertFalse($ticket->isValid()); + $this->assertTrue($ticket->isValid()); + $this->assertFalse($ticket->is_expired); } - public function test_ticket_is_valid_when_its_start_date_is_reached(): void + public function test_time_window_is_resolved_for_current_date(): void { - Carbon::setTestNow('2026-07-21 10:00:00'); - $ticket = new Ticket(['starts_at' => now()]); + Carbon::setTestNow('2026-07-21 12:00:00'); + $ticket = $this->ticketWithValidityGroups([[new ValidityTime([ + 'type' => ValidityTimeType::TimeWindow, + 'start_time' => '10:00:00', + 'end_time' => '14:00:00', + ])]]); + $this->assertSame('2026-07-21 10:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s')); + $this->assertSame('2026-07-21 14:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s')); $this->assertTrue($ticket->isValid()); } - public function test_ticket_is_invalid_when_it_expires(): void + public function test_ticket_is_invalid_when_validity_time_expires(): void { Carbon::setTestNow('2026-07-21 10:00:00'); - $ticket = new Ticket(['expires_at' => now()]); + $ticket = $this->ticketWithValidityTime(new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_expires_at' => now(), + ])); $this->assertFalse($ticket->isValid()); - } - - public function test_used_ticket_is_invalid(): void - { - $ticket = new Ticket(['used_at' => now()->subSecond()]); - - $this->assertFalse($ticket->isValid()); - } - - public function test_it_appends_computed_status_fields(): void - { - Carbon::setTestNow('2026-07-21 10:00:00'); - $ticket = new Ticket([ - 'starts_at' => now()->subHour(), - 'expires_at' => now()->addHour(), - ]); - - $attributes = $ticket->toArray(); - - $this->assertTrue($attributes['is_valid']); - $this->assertFalse($attributes['is_expired']); - $this->assertFalse($attributes['is_used']); - } - - public function test_unused_ticket_is_expired_when_its_expiration_date_is_reached(): void - { - Carbon::setTestNow('2026-07-21 10:00:00'); - $ticket = new Ticket(['expires_at' => now()]); - - $this->assertFalse($ticket->is_valid); $this->assertTrue($ticket->is_expired); - $this->assertFalse($ticket->is_used); + $this->assertSame(Ticket::STATUS_EXPIRED, $ticket->status); } - public function test_used_ticket_is_not_reported_as_expired(): void + public function test_ticket_is_valid_when_any_validity_time_is_active(): void { Carbon::setTestNow('2026-07-21 10:00:00'); - $ticket = new Ticket([ - 'expires_at' => now()->subHour(), - 'used_at' => now()->subDay(), + $ticket = $this->ticketWithValidityGroups([ + [new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => now()->subDays(2), + 'fixed_expires_at' => now()->subDay(), + ])], + [new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => now()->subHour(), + 'fixed_expires_at' => now()->addHour(), + ])], ]); + $this->assertTrue($ticket->isValid()); + $this->assertFalse($ticket->is_expired); + $this->assertSame('2026-07-19 10:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s')); + $this->assertSame('2026-07-21 11:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s')); + } + + public function test_ticket_is_invalid_but_not_expired_between_validity_times(): void + { + Carbon::setTestNow('2026-07-21 10:00:00'); + $ticket = $this->ticketWithValidityGroups([ + [new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => now()->subDays(2), + 'fixed_expires_at' => now()->subDay(), + ])], + [new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => now()->addDay(), + 'fixed_expires_at' => now()->addDays(2), + ])], + ]); + + $this->assertFalse($ticket->isValid()); + $this->assertFalse($ticket->is_expired); + $this->assertSame(Ticket::STATUS_ACTIVE, $ticket->status); + } + + public function test_used_ticket_is_invalid_and_not_reported_as_expired(): void + { + $ticket = $this->ticketWithValidityTime(new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_expires_at' => now()->subHour(), + ])); + $ticket->used_at = now()->subMinute(); + $this->assertFalse($ticket->is_valid); $this->assertFalse($ticket->is_expired); $this->assertTrue($ticket->is_used); + $this->assertSame(Ticket::STATUS_USED, $ticket->status); } - public function test_event_date_has_priority_over_ticket_and_variant_dates(): void + public function test_all_validity_times_in_the_same_group_must_be_active(): void { - Carbon::setTestNow('2026-10-09 19:00:00'); + Carbon::setTestNow('2026-08-20 13:00:00'); + $ticket = $this->ticketWithValidityGroups([[ + new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => '2026-08-20 09:00:00', + 'fixed_expires_at' => '2026-08-20 18:00:00', + ]), + new ValidityTime([ + 'type' => ValidityTimeType::TimeWindow, + 'start_time' => '12:00:00', + 'end_time' => '15:00:00', + ]), + ]]); - $eventDate = new EventDate; - $eventDate->date = '2026-10-09'; - $eventDate->time_start = '09:00:00'; - $eventDate->time_end = '18:00:00'; + $this->assertTrue($ticket->isValid()); + $this->assertSame('2026-08-20 12:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s')); + $this->assertSame('2026-08-20 15:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s')); - $variant = new Variant; - $variant->minimum_use_date = Carbon::parse('2026-10-09 08:00:00'); - $variant->maximum_use_date = Carbon::parse('2026-10-09 22:00:00'); - $variant->setRelation('eventDate', $eventDate); + Carbon::setTestNow('2026-08-20 16:00:00'); - $ticket = new Ticket([ - 'starts_at' => Carbon::parse('2026-10-09 07:00:00'), - 'expires_at' => Carbon::parse('2026-10-10 23:59:59'), - ]); - $ticket->setRelation('sourceVariant', $variant); - - $this->assertSame('2026-10-09 09:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s')); - $this->assertSame('2026-10-09 18:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s')); $this->assertFalse($ticket->isValid()); $this->assertTrue($ticket->is_expired); } + + private function ticketWithValidityTime(ValidityTime $validityTime): Ticket + { + return $this->ticketWithValidityGroups([[$validityTime]]); + } + + /** @param array> $validityGroups */ + private function ticketWithValidityGroups(array $validityGroups): Ticket + { + $ticket = new Ticket; + $groups = collect($validityGroups)->map(function (array $validityTimes): TicketValidityGroup { + $group = new TicketValidityGroup; + $group->setRelation('validityTimes', new EloquentCollection($validityTimes)); + + return $group; + }); + $ticket->setRelation('validityGroups', new EloquentCollection($groups->all())); + + return $ticket; + } } diff --git a/tests/Unit/Ticket/ValidityTimeResourceTest.php b/tests/Unit/Ticket/ValidityTimeResourceTest.php new file mode 100644 index 0000000..34f202a --- /dev/null +++ b/tests/Unit/Ticket/ValidityTimeResourceTest.php @@ -0,0 +1,55 @@ + ValidityTimeType::TimeWindow, + 'start_time' => '11:00:00', + 'end_time' => null, + 'fixed_starts_at' => '2026-08-20 10:00:00', + ]))->resolve(); + + $this->assertSame('time_window', $resource['type']); + $this->assertTrue($resource['is_valid']); + $this->assertSame('11:00:00', $resource['start_time']); + $this->assertArrayNotHasKey('end_time', $resource); + $this->assertArrayNotHasKey('fixed_starts_at', $resource); + $this->assertArrayNotHasKey('fixed_expires_at', $resource); + } + + public function test_fixed_window_only_contains_non_null_datetime_fields(): void + { + Carbon::setTestNow('2026-08-20 11:00:00'); + $resource = ValidityTimeResource::make(new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'start_time' => '11:00:00', + 'fixed_starts_at' => '2026-08-20 10:00:00', + 'fixed_expires_at' => null, + ]))->resolve(); + + $this->assertSame('fixed_window', $resource['type']); + $this->assertTrue($resource['is_valid']); + $this->assertArrayHasKey('fixed_starts_at', $resource); + $this->assertArrayNotHasKey('fixed_expires_at', $resource); + $this->assertArrayNotHasKey('start_time', $resource); + $this->assertArrayNotHasKey('end_time', $resource); + } +} diff --git a/tests/Unit/Ticket/ValidityTimeTest.php b/tests/Unit/Ticket/ValidityTimeTest.php new file mode 100644 index 0000000..f5d2a25 --- /dev/null +++ b/tests/Unit/Ticket/ValidityTimeTest.php @@ -0,0 +1,70 @@ +setRawAttributes([ + 'type' => ValidityTimeType::FixedWindow->value, + 'fixed_starts_at' => '2026-08-20 10:00:00', + 'fixed_expires_at' => '2026-08-21 02:00:00', + ]); + + $this->assertSame(ValidityTimeType::FixedWindow, $validityTime->type); + $this->assertInstanceOf(Carbon::class, $validityTime->fixed_starts_at); + $this->assertInstanceOf(Carbon::class, $validityTime->fixed_expires_at); + $this->assertInstanceOf(CatalogItem::class, $validityTime->catalogItems()->getRelated()); + $this->assertInstanceOf(AttributeOption::class, $validityTime->attributeOptions()->getRelated()); + $this->assertInstanceOf( + TicketValidityGroup::class, + $validityTime->ticketValidityGroups()->getRelated(), + ); + } + + public function test_it_exposes_supported_type_values(): void + { + $this->assertSame([ + 'time_window', + 'fixed_window', + ], ValidityTimeType::values()); + } + + public function test_fixed_window_is_valid_between_its_limits(): void + { + $validityTime = new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => '2026-08-20 10:00:00', + 'fixed_expires_at' => '2026-08-20 12:00:00', + ]); + + $this->assertTrue($validityTime->isValid(at: Carbon::parse('2026-08-20 10:00:00'))); + $this->assertTrue($validityTime->isValid(at: Carbon::parse('2026-08-20 11:00:00'))); + $this->assertFalse($validityTime->isValid(at: Carbon::parse('2026-08-20 12:00:00'))); + } + + public function test_time_window_is_valid_between_its_limits(): void + { + $validityTime = new ValidityTime([ + 'type' => ValidityTimeType::TimeWindow, + 'start_time' => '11:00:00', + 'end_time' => '14:00:00', + ]); + $this->assertTrue($validityTime->isValid( + Carbon::parse('2026-08-20 12:00:00'), + )); + $this->assertFalse($validityTime->isValid( + Carbon::parse('2026-08-20 14:00:00'), + )); + } +}