diff --git a/app/Domains/Catalog/Enums/InventorySubject.php b/app/Domains/Catalog/Enums/InventorySubject.php new file mode 100644 index 0000000..e7d714f --- /dev/null +++ b/app/Domains/Catalog/Enums/InventorySubject.php @@ -0,0 +1,16 @@ + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Domains/Catalog/Models/CatalogItem.php b/app/Domains/Catalog/Models/CatalogItem.php index 5e9616e..32b9daa 100644 --- a/app/Domains/Catalog/Models/CatalogItem.php +++ b/app/Domains/Catalog/Models/CatalogItem.php @@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Models; use App\Domains\Attachable\Models\Attachment; use App\Domains\Catalog\Enums\CatalogItemType; use App\Domains\Catalog\Enums\InventoryPolicy; +use App\Domains\Catalog\Enums\InventorySubject; use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Enums\TicketGenerationPolicy; @@ -30,6 +31,7 @@ use Illuminate\Support\Collection; 'descripcion', 'precio', 'inventory_policy', + 'inventory_subject', 'max_units_per_user', 'has_tickets', 'ticket_generation_policy', @@ -46,6 +48,7 @@ class CatalogItem extends Model protected $attributes = [ 'type' => CatalogItemType::Standard->value, 'inventory_policy' => InventoryPolicy::Tracked->value, + 'inventory_subject' => InventorySubject::Product->value, 'has_tickets' => false, 'ticket_generation_policy' => TicketGenerationPolicy::PerEventDate->value, ]; @@ -59,6 +62,7 @@ class CatalogItem extends Model 'type' => CatalogItemType::class, 'precio' => 'decimal:2', 'inventory_policy' => InventoryPolicy::class, + 'inventory_subject' => InventorySubject::class, 'max_units_per_user' => 'integer', 'has_tickets' => 'boolean', 'ticket_generation_policy' => TicketGenerationPolicy::class, @@ -208,6 +212,11 @@ class CatalogItem extends Model return $this->nombre; } + public function getSelectionLabel(): string + { + return $this->getName(); + } + public function getDescription(): ?string { return $this->descripcion; diff --git a/app/Domains/Catalog/Models/Variant.php b/app/Domains/Catalog/Models/Variant.php index 7f6051f..762c987 100644 --- a/app/Domains/Catalog/Models/Variant.php +++ b/app/Domains/Catalog/Models/Variant.php @@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Lang; +use Illuminate\Support\Str; #[Fillable([ 'catalog_item_id', @@ -113,6 +115,42 @@ class Variant extends Model return $this->catalogItem->nombre; } + public function getSelectionLabel(): string + { + $this->loadMissing([ + 'catalogItem.itemAttributes.attribute.options', + 'definitions.itemAttribute.attribute.options', + 'eventDates', + 'eventDate', + ]); + + $itemAttributes = $this->catalogItem->itemAttributes; + + $label = $this->selectionOptions($itemAttributes) + ->map(function (array $option, string $attributeCode) use ($itemAttributes): ?string { + $itemAttribute = $itemAttributes->first( + fn (ItemAttribute $candidate): bool => $candidate->attribute?->codigo === $attributeCode, + ); + $translationKey = "api.catalog.attribute_labels.{$attributeCode}"; + $attributeName = Lang::has($translationKey) + ? __($translationKey) + : ($itemAttribute?->attribute?->nombre ?? Str::headline($attributeCode)); + $selectedOptions = array_is_list($option) ? $option : [$option]; + $selectedLabels = collect($selectedOptions) + ->pluck('label') + ->filter() + ->implode(', '); + + return $selectedLabels === '' + ? null + : "{$attributeName} {$selectedLabels}"; + }) + ->filter() + ->implode(' · '); + + return $label !== '' ? $label : $this->getName(); + } + /** @return Collection> */ public function selectionValues(): Collection { diff --git a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php index 09c8762..6f07e98 100644 --- a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php +++ b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php @@ -4,6 +4,7 @@ namespace App\Domains\Catalog\Requests; use App\Domains\Catalog\Enums\CatalogItemType; use App\Domains\Catalog\Enums\InventoryPolicy; +use App\Domains\Catalog\Enums\InventorySubject; use App\Domains\Shared\Rules\ImageOrBase64Rule; use App\Domains\Ticket\Enums\TicketGenerationPolicy; use Illuminate\Foundation\Http\FormRequest; @@ -54,6 +55,7 @@ class StoreCatalogItemRequest extends FormRequest 'descripcion' => ['sometimes', 'nullable', 'string'], 'precio' => ['required', 'numeric', 'min:0'], 'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)], + 'inventory_subject' => ['sometimes', Rule::enum(InventorySubject::class)], 'max_units_per_user' => ['sometimes', 'nullable', 'integer', 'min:1'], 'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'], 'ticket_generation_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(TicketGenerationPolicy::class)], diff --git a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php index 6fc11c1..016624f 100644 --- a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php @@ -33,6 +33,7 @@ class CatalogItemDetailResource extends JsonResource 'category' => $this->category?->nombre, 'brand' => $this->brand?->nombre, 'inventory_policy' => $this->inventory_policy?->value, + 'inventory_subject' => $this->inventory_subject->value, 'max_units_per_user' => $this->max_units_per_user, 'has_tickets' => $this->has_tickets, 'ticket_generation_policy' => $this->ticket_generation_policy->value, diff --git a/app/Domains/Catalog/Resources/CatalogItemResource.php b/app/Domains/Catalog/Resources/CatalogItemResource.php index e25495a..d2b0635 100644 --- a/app/Domains/Catalog/Resources/CatalogItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemResource.php @@ -23,6 +23,7 @@ class CatalogItemResource extends JsonResource 'descripcion' => $this->descripcion, 'precio' => $this->precio, 'inventory_policy' => $this->inventory_policy?->value, + 'inventory_subject' => $this->inventory_subject->value, 'max_units_per_user' => $this->max_units_per_user, 'has_tickets' => $this->has_tickets, 'ticket_generation_policy' => $this->ticket_generation_policy->value, diff --git a/app/Domains/Purchase/Services/Checkout/InsufficientStockMessageBuilder.php b/app/Domains/Purchase/Services/Checkout/InsufficientStockMessageBuilder.php new file mode 100644 index 0000000..e3c5ea2 --- /dev/null +++ b/app/Domains/Purchase/Services/Checkout/InsufficientStockMessageBuilder.php @@ -0,0 +1,43 @@ +inventory_subject) { + InventorySubject::Seat => __('api.purchase.stock.seat_unavailable', [ + 'selection' => $selection->getSelectionLabel(), + ]), + InventorySubject::Ticket => __('api.purchase.stock.ticket_unavailable', [ + 'selection' => $selection->getSelectionLabel(), + ]), + InventorySubject::Product => __('api.purchase.stock.product_unavailable', [ + 'selection' => $this->productSelectionLabel($catalogItem, $selection), + 'max' => $availableQuantity, + ]), + }; + } + + private function productSelectionLabel( + CatalogItem $catalogItem, + CatalogItem|Variant $selection, + ): string { + if ($selection instanceof CatalogItem) { + return $selection->getSelectionLabel(); + } + + return __('api.purchase.stock.product_selection', [ + 'product' => $catalogItem->getName(), + 'selection' => $selection->getSelectionLabel(), + ]); + } +} diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index 153812e..152dadf 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -22,6 +22,7 @@ class StartCheckoutService private readonly UserPurchaseLimitService $purchaseLimits, private readonly CatalogSelectionResolver $selections, private readonly PurchaseItemSnapshotFactory $snapshots, + private readonly InsufficientStockMessageBuilder $stockMessages, ) {} /** @param array $purchaseData */ @@ -128,17 +129,25 @@ class StartCheckoutService if ($availableQuantity !== null && $availableQuantity < $line['quantity']) { throw ValidationException::withMessages([ - "{$line['field']}.cantidad" => __('api.purchase.direct_items_max_stock', [ - 'max' => $availableQuantity, - ]), + "{$line['field']}.cantidad" => $this->stockMessages->build( + $line['catalog_item'], + $line['selection'], + $availableQuantity, + ), ]); } try { $this->inventory->reserve($line['selection'], $line['quantity']); } catch (\InvalidArgumentException) { + $availableQuantity = $this->inventory->availableQuantity($line['selection']) ?? 0; + throw ValidationException::withMessages([ - "{$line['field']}.cantidad" => __('api.purchase.insufficient_stock'), + "{$line['field']}.cantidad" => $this->stockMessages->build( + $line['catalog_item'], + $line['selection'], + $availableQuantity, + ), ]); } } diff --git a/database/migrations/2026_08_12_070000_add_inventory_subject_to_catalog_items.php b/database/migrations/2026_08_12_070000_add_inventory_subject_to_catalog_items.php new file mode 100644 index 0000000..39c706a --- /dev/null +++ b/database/migrations/2026_08_12_070000_add_inventory_subject_to_catalog_items.php @@ -0,0 +1,31 @@ +enum('inventory_subject', InventorySubject::values()) + ->default(InventorySubject::Product->value) + ->after('inventory_policy'); + }); + + DB::table('catalog_items') + ->where('tenant_code', 'desfile_pura_tendencia') + ->where('slug', 'entrada') + ->update(['inventory_subject' => InventorySubject::Seat->value]); + } + + public function down(): void + { + Schema::table('catalog_items', function (Blueprint $table): void { + $table->dropColumn('inventory_subject'); + }); + } +}; diff --git a/lang/en/api.php b/lang/en/api.php index c6a4a74..e4ad48d 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -42,7 +42,12 @@ return [ 'cannot_confirm' => 'A cancelled, rejected, or expired purchase cannot be confirmed.', 'inconsistent_reservation' => 'The purchase has an inconsistent stock reservation.', 'paid_cannot_cancel' => 'A paid purchase cannot be cancelled.', - 'direct_items_max_stock' => 'There is not enough stock. Maximum available: :max.', + 'stock' => [ + 'seat_unavailable' => 'Seat :selection is no longer available.', + 'ticket_unavailable' => 'Ticket :selection is no longer available.', + 'product_unavailable' => 'There is not enough stock for :selection. Maximum available: :max.', + 'product_selection' => ':product (:selection)', + ], 'empty_cart' => 'The selected cart does not contain items.', 'catalog_item_missing' => 'One or more catalog items could not be loaded.', 'catalog_item_wrong_tenant' => 'One or more catalog items do not belong to the tenant.', @@ -80,6 +85,13 @@ return [ 'test_sent' => 'Test email sent successfully.', ], 'catalog' => [ + 'attribute_labels' => [ + 'tipo' => 'Type', + 'sector' => 'Sector', + 'fila' => 'Row', + 'asiento' => 'Seat', + 'event_date' => 'Date', + ], 'standard_with_components' => 'A standard item cannot have components.', 'duplicate_component' => 'The component is duplicated.', 'component_wrong_tenant' => 'The item does not belong to the bundle tenant.', diff --git a/lang/es/api.php b/lang/es/api.php index e5c7e76..5727f87 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -42,7 +42,12 @@ return [ 'cannot_confirm' => 'Una compra cancelada, rechazada o vencida no se puede confirmar.', 'inconsistent_reservation' => 'La compra tiene una reserva de stock inconsistente.', 'paid_cannot_cancel' => 'Una compra pagada no se puede cancelar.', - 'direct_items_max_stock' => 'Stock insuficiente. Máximo disponible: :max.', + 'stock' => [ + 'seat_unavailable' => 'El asiento :selection ya no está disponible.', + 'ticket_unavailable' => 'La entrada :selection ya no está disponible.', + 'product_unavailable' => 'No hay stock suficiente de :selection. Máximo disponible: :max.', + 'product_selection' => ':product (:selection)', + ], 'empty_cart' => 'El carrito seleccionado no contiene productos.', 'catalog_item_missing' => 'No se pudieron cargar uno o más productos del catálogo.', 'catalog_item_wrong_tenant' => 'Uno o más productos no pertenecen al tenant.', @@ -80,6 +85,13 @@ return [ 'test_sent' => 'Correo de prueba enviado correctamente.', ], 'catalog' => [ + 'attribute_labels' => [ + 'tipo' => 'Tipo', + 'sector' => 'Sector', + 'fila' => 'Fila', + 'asiento' => 'Asiento', + 'event_date' => 'Fecha', + ], 'standard_with_components' => 'Un ítem standard no puede tener componentes.', 'duplicate_component' => 'El componente está duplicado.', 'component_wrong_tenant' => 'El ítem no pertenece al tenant del bundle.', diff --git a/tests/Unit/Catalog/CatalogModelsTest.php b/tests/Unit/Catalog/CatalogModelsTest.php index cb69cfb..d200347 100644 --- a/tests/Unit/Catalog/CatalogModelsTest.php +++ b/tests/Unit/Catalog/CatalogModelsTest.php @@ -7,6 +7,7 @@ use App\Domains\Catalog\Enums\CatalogItemType; use App\Domains\Catalog\Enums\FeaturedGroupSource; use App\Domains\Catalog\Enums\GroupLayout; use App\Domains\Catalog\Enums\InventoryPolicy; +use App\Domains\Catalog\Enums\InventorySubject; use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\AttributeOption; @@ -89,6 +90,7 @@ class CatalogModelsTest extends TestCase 'type' => CatalogItemType::Standard->value, 'precio' => '12.50', 'inventory_policy' => InventoryPolicy::Tracked->value, + 'inventory_subject' => InventorySubject::Seat->value, 'has_tickets' => 1, ]); @@ -100,6 +102,7 @@ class CatalogModelsTest extends TestCase $this->assertSame(CatalogItemType::Standard, $item->type); $this->assertSame('12.50', $item->precio); $this->assertSame(InventoryPolicy::Tracked, $item->inventory_policy); + $this->assertSame(InventorySubject::Seat, $item->inventory_subject); $this->assertTrue($item->has_tickets); $this->assertInstanceOf(Tenant::class, $item->tenant()->getRelated()); $this->assertInstanceOf(Category::class, $item->category()->getRelated()); diff --git a/tests/Unit/Purchase/InsufficientStockMessageBuilderTest.php b/tests/Unit/Purchase/InsufficientStockMessageBuilderTest.php new file mode 100644 index 0000000..c84016a --- /dev/null +++ b/tests/Unit/Purchase/InsufficientStockMessageBuilderTest.php @@ -0,0 +1,97 @@ +seatSelection(); + $builder = app(InsufficientStockMessageBuilder::class); + + App::setLocale('es'); + $this->assertSame( + 'Sector A · Fila 2 · Asiento 3', + $variant->getSelectionLabel(), + ); + $this->assertSame( + 'El asiento Sector A · Fila 2 · Asiento 3 ya no está disponible.', + $builder->build($catalogItem, $variant, 0), + ); + + App::setLocale('en'); + $this->assertSame( + 'Sector A · Row 2 · Seat 3', + $variant->getSelectionLabel(), + ); + $this->assertSame( + 'Seat Sector A · Row 2 · Seat 3 is no longer available.', + $builder->build($catalogItem, $variant, 0), + ); + } + + /** @return array{CatalogItem, Variant} */ + private function seatSelection(): array + { + $catalogItem = new CatalogItem([ + 'nombre' => 'Entrada', + 'inventory_subject' => InventorySubject::Seat->value, + ]); + $itemAttributes = collect([ + ['id' => 1, 'code' => 'sector', 'name' => 'Sector', 'value' => 'A'], + ['id' => 2, 'code' => 'fila', 'name' => 'Fila', 'value' => '2'], + ['id' => 3, 'code' => 'asiento', 'name' => 'Asiento', 'value' => '3'], + ])->map(function (array $data): ItemAttribute { + $attribute = new Attribute([ + 'codigo' => $data['code'], + 'nombre' => $data['name'], + ]); + $attribute->setRelation('options', new EloquentCollection([ + new AttributeOption([ + 'value' => $data['value'], + 'label' => $data['value'], + ]), + ])); + + $itemAttribute = new ItemAttribute([ + 'sort_order' => $data['id'], + 'allow_multi_select' => false, + ]); + $itemAttribute->id = $data['id']; + $itemAttribute->setRelation('attribute', $attribute); + + return $itemAttribute; + })->values(); + + $catalogItem->setRelation('itemAttributes', new EloquentCollection($itemAttributes)); + + $definitions = $itemAttributes->map(function (ItemAttribute $itemAttribute): VariantDefinition { + $value = $itemAttribute->attribute->options->first()->value; + $definition = new VariantDefinition(['value' => $value]); + $definition->item_attribute_id = $itemAttribute->id; + $definition->setRelation('itemAttribute', $itemAttribute); + + return $definition; + }); + + $variant = new Variant; + $variant->setRelation('catalogItem', $catalogItem); + $variant->setRelation('definitions', new EloquentCollection($definitions)); + $variant->setRelation('eventDates', new EloquentCollection); + $variant->setRelation('eventDate', null); + + return [$catalogItem, $variant]; + } +}